Skip to content
Merged

Dev #10

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ NEXT_PUBLIC_REALTIME_URL=http://localhost:8000
NODE_ENV=development
PORT=8080
REALTIME_PORT=8000
REDIS_URL=redis://127.0.0.1:6379
SELF_HOSTED=true
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@
"@repo/auth": "workspace:*",
"@repo/db": "workspace:*",
"@repo/env": "workspace:*",
"@repo/realtime-types": "workspace:*",
"dotenv": "^17.2.4",
"drizzle-orm": "^0.45.1",
"hono": "^4.11.9",
"hono-pino": "^0.8.0",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
"redis": "^4.7.0",
"zod": "^4.3.6"
},
"devDependencies": {
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import configureOpenAPI from "@/lib/helpers/openapi/configure-openapi"
import index from "@/routes/index.route"
import channelsRouter from "@/routes/v1/channels/index"
import dmsRouter from "@/routes/v1/dms/index"
import guildsRouter from "@/routes/v1/guilds/index"
import waitlistRouter from "@/routes/waitlist/index"

const app = createApp()
Expand All @@ -28,6 +29,7 @@ app.route("/", index)
const routes = app
.route("/", waitlistRouter)
.route("/v1", channelsRouter)
.route("/v1", guildsRouter)
.route("/v1", dmsRouter)

export type AppType = typeof routes
Expand Down
25 changes: 25 additions & 0 deletions apps/api/src/lib/redis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { env } from "@repo/env/server"
import { createClient, type RedisClientType } from "redis"

const redisClient: RedisClientType = createClient({ url: env.REDIS_URL })

let connectPromise: Promise<RedisClientType> | null = null

redisClient.on("error", (error) => {
console.error("[api] redis error:", error)
})

export async function getRedisClient() {
if (redisClient.isOpen) {
return redisClient
}

if (!connectPromise) {
connectPromise = redisClient.connect().finally(() => {
connectPromise = null
})
}

await connectPromise
return redisClient
}
77 changes: 77 additions & 0 deletions apps/api/src/routes/v1/guilds/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { db, eq, schema } from "@repo/db"
import { PRESENCE_ONLINE_USERS_SET_KEY } from "@repo/realtime-types"
import { asc } from "drizzle-orm"
import * as HttpStatusCodes from "@/lib/helpers/http/status-codes"
import { getRedisClient } from "@/lib/redis"
import type { AppRouteHandler } from "@/lib/types/app-types"
import type { ListGuildMembersRoute } from "@/routes/v1/guilds/routes"

const PRESENCE_MEMBERSHIP_CHUNK_SIZE = 250

async function listOnlineUserIds(userIds: string[]) {
if (userIds.length === 0) return new Set<string>()

try {
const redis = await getRedisClient()
const membership: boolean[] = []

for (
let index = 0;
index < userIds.length;
index += PRESENCE_MEMBERSHIP_CHUNK_SIZE
) {
const chunk = userIds.slice(index, index + PRESENCE_MEMBERSHIP_CHUNK_SIZE)
const chunkMembership = await redis.smIsMember(
PRESENCE_ONLINE_USERS_SET_KEY,
chunk
)
membership.push(...chunkMembership)
}

const onlineIds = userIds.filter((_, index) => membership[index] === true)

return new Set(onlineIds)
} catch (error) {
console.error("[api] failed to read presence from redis:", error)
return new Set<string>()
}
}

export const listGuildMembers: AppRouteHandler<ListGuildMembersRoute> = async (
c
) => {
const guild = c.var.guild

const memberRows = await db
.select({
userId: schema.guildMember.userId,
role: schema.guildMember.role,
name: schema.user.name,
image: schema.user.image,
})
.from(schema.guildMember)
.innerJoin(schema.user, eq(schema.guildMember.userId, schema.user.id))
.where(eq(schema.guildMember.guildId, guild.id))
.orderBy(asc(schema.user.name))

const userIds = memberRows.map((row) => row.userId)
const onlineUserIds = await listOnlineUserIds(userIds)

return c.json(
{
guildId: guild.id,
guildSlug: guild.slug,
guildName: guild.name,
members: memberRows.map((member) => ({
userId: member.userId,
name: member.name,
image: member.image,
role: member.role,
status: onlineUserIds.has(member.userId)
? ("online" as const)
: ("offline" as const),
})),
},
HttpStatusCodes.OK
)
}
10 changes: 10 additions & 0 deletions apps/api/src/routes/v1/guilds/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createRouter } from "@/lib/helpers/app/create-app"
import * as handlers from "@/routes/v1/guilds/handlers"
import * as routes from "@/routes/v1/guilds/routes"

const guildsRouter = createRouter().openapi(
routes.listGuildMembers,
handlers.listGuildMembers
)

export default guildsRouter
36 changes: 36 additions & 0 deletions apps/api/src/routes/v1/guilds/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { createRoute } from "@hono/zod-openapi"
import * as HttpStatusCodes from "@/lib/helpers/http/status-codes"
import jsonContent from "@/lib/helpers/openapi/json-content"
import {
forbiddenSchema,
internalServerErrorSchema,
notFoundSchema,
unauthorizedSchema,
} from "@/lib/helpers/openapi/schemas"
import { guildAuthMiddleware } from "@/middleware/guild-auth"
import { guildSlugParamsSchema, listGuildMembersResponseSchema } from "./schema"
Comment thread
BuckyMcYolo marked this conversation as resolved.

export const listGuildMembers = createRoute({
path: "/guilds/{guildSlug}/members",
method: "get",
summary: "List guild members with presence",
description:
"Returns all guild members and their current online/offline status.",
tags: ["Guilds"],
middleware: [guildAuthMiddleware] as const,
request: {
params: guildSlugParamsSchema,
},
responses: {
[HttpStatusCodes.OK]: jsonContent({
schema: listGuildMembersResponseSchema,
description: "Guild members with presence status",
}),
[HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema,
[HttpStatusCodes.FORBIDDEN]: forbiddenSchema,
[HttpStatusCodes.NOT_FOUND]: notFoundSchema,
[HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema,
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})

export type ListGuildMembersRoute = typeof listGuildMembers
19 changes: 19 additions & 0 deletions apps/api/src/routes/v1/guilds/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { z } from "@hono/zod-openapi"
import { guildSlugParamsSchema } from "@/routes/v1/channels/schema"

export { guildSlugParamsSchema }

export const guildMemberPresenceSchema = z.object({
userId: z.string().uuid(),
name: z.string(),
image: z.string().nullable(),
role: z.string(),
status: z.enum(["online", "offline"]),
})

export const listGuildMembersResponseSchema = z.object({
guildId: z.string().uuid(),
guildSlug: z.string(),
guildName: z.string(),
members: z.array(guildMemberPresenceSchema),
})
2 changes: 2 additions & 0 deletions apps/realtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@
"check-types": "tsc --noEmit"
},
"dependencies": {
"@socket.io/redis-adapter": "^8.3.0",
"@repo/auth": "workspace:*",
"@repo/db": "workspace:*",
"@repo/env": "workspace:*",
"@repo/realtime-types": "workspace:*",
"redis": "^4.7.0",
"socket.io": "^4.8.1",
"zod": "^4.3.6"
},
Expand Down
Loading