Skip to content
Merged
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
5 changes: 3 additions & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,17 @@
},
"dependencies": {
"@hono/node-server": "^1.19.9",
"@hono/zod-openapi": "^0.19.2",
"@hono/zod-openapi": "^1.2.2",
"@repo/auth": "workspace:*",
"@repo/db": "workspace:*",
"@repo/env": "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",
"zod": "^3.24.2"
"zod": "^4.3.6"
Comment thread
BuckyMcYolo marked this conversation as resolved.
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { cors } from "hono/cors"
import createApp from "@/lib/helpers/app/create-app"
import configureOpenAPI from "@/lib/helpers/openapi/configure-openapi"
import index from "@/routes/index.route"
import channelsRouter from "@/routes/v1/channels/index"
import waitlistRouter from "@/routes/waitlist/index"

const app = createApp()
Expand Down Expand Up @@ -33,7 +34,7 @@ for (const route of internalRoutes) {
}

// Versioned public API routes
const v1Routes = [] as const
const v1Routes = [channelsRouter] as const
for (const route of v1Routes) {
app.route("/v1", route)
}
Expand Down
356 changes: 356 additions & 0 deletions apps/api/src/lib/helpers/http/status-codes.ts

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions apps/api/src/lib/helpers/openapi/json-content.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ZodSchema } from "zod"
import type { ZodType } from "zod"

const jsonContent = <T extends ZodSchema>({
const jsonContent = <T extends ZodType>({
schema,
description,
}: {
Expand Down
26 changes: 26 additions & 0 deletions apps/api/src/lib/helpers/openapi/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,32 @@
import { z } from "@hono/zod-openapi"
import jsonContent from "./json-content"

const errorSchema = z.object({
success: z.literal(false),
message: z.string(),
})

export const unauthorizedSchema = jsonContent({
schema: errorSchema.openapi({
example: { success: false, message: "Unauthorized" },
}),
description: "Unauthorized",
})

export const forbiddenSchema = jsonContent({
schema: errorSchema.openapi({
example: { success: false, message: "Forbidden" },
}),
description: "Forbidden",
})

export const notFoundSchema = jsonContent({
schema: errorSchema.openapi({
example: { success: false, message: "Not found" },
}),
description: "Not found",
})

export const internalServerErrorSchema = jsonContent({
schema: z
.object({
Expand Down
9 changes: 9 additions & 0 deletions apps/api/src/lib/types/app-types.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import type { OpenAPIHono, RouteConfig, RouteHandler } from "@hono/zod-openapi"
import type { Session } from "@repo/auth"
import type { guild, guildMember } from "@repo/db/schema"
import type { Schema } from "hono"
import type { PinoLogger } from "hono-pino"

export type Guild = typeof guild.$inferSelect
export type GuildMember = typeof guildMember.$inferSelect

export interface AppBindings {
Variables: {
logger: PinoLogger
user: Session["user"]
session: Session["session"]
guild: Guild
member: GuildMember
}
}

Expand Down
77 changes: 77 additions & 0 deletions apps/api/src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { auth } from "@repo/auth"
import { db } from "@repo/db"
import { guild, guildMember } from "@repo/db/schema"
import { and, eq } from "drizzle-orm"
import type { Context, Next } from "hono"
import * as HttpStatusCodes from "@/lib/helpers/http/status-codes"
import type { AppBindings } from "@/lib/types/app-types"

/**
* Authenticates the request via better-auth session and resolves the
* user's active guild + membership.
*
* Sets in context:
* - user: The authenticated user
* - session: The session object
* - guild: The user's active guild
* - member: The user's membership in the active guild
*/
export const authMiddleware = async (c: Context<AppBindings>, next: Next) => {
const session = await auth.api.getSession({ headers: c.req.raw.headers })

if (!session) {
return c.json(
{ success: false, message: "Unauthorized" },
HttpStatusCodes.UNAUTHORIZED
)
}

const activeGuildId = session.session.activeOrganizationId

if (!activeGuildId) {
return c.json(
{ success: false, message: "No active guild selected" },
HttpStatusCodes.BAD_REQUEST
)
}

const memberRecord = await db
.select()
.from(guildMember)
.where(
and(
eq(guildMember.userId, session.user.id),
eq(guildMember.guildId, activeGuildId)
)
)
.limit(1)
.then((rows) => rows[0])

if (!memberRecord) {
return c.json(
{ success: false, message: "You are not a member of this guild" },
HttpStatusCodes.FORBIDDEN
)
}

const guildRecord = await db
.select()
.from(guild)
.where(eq(guild.id, activeGuildId))
.limit(1)
.then((rows) => rows[0])

if (!guildRecord) {
return c.json(
{ success: false, message: "Guild not found" },
HttpStatusCodes.NOT_FOUND
)
}

c.set("user", session.user)
c.set("session", session.session)
c.set("guild", guildRecord)
c.set("member", memberRecord)

await next()
}
40 changes: 40 additions & 0 deletions apps/api/src/routes/v1/channels/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { db } from "@repo/db"
import { channel } from "@repo/db/schema"
import { eq } from "drizzle-orm"
import * as HttpStatusCodes from "@/lib/helpers/http/status-codes"
import type { AppRouteHandler } from "@/lib/types/app-types"
import type { CreateChannelRoute, ListChannelsRoute } from "./routes"

export const listChannels: AppRouteHandler<ListChannelsRoute> = async (c) => {
const guild = c.var.guild

const channels = await db
.select()
.from(channel)
.where(eq(channel.guildId, guild.id))

return c.json({ success: true, data: channels }, HttpStatusCodes.OK)
}

export const createChannel: AppRouteHandler<CreateChannelRoute> = async (c) => {
const guild = c.var.guild
const body = c.req.valid("json")

const newChannel = await db
.insert(channel)
.values({
...body,
guildId: guild.id,
})
.returning()
.then((rows) => rows[0])

if (!newChannel) {
return c.json(
{ success: false, message: "Internal server error" },
HttpStatusCodes.INTERNAL_SERVER_ERROR
)
}

return c.json({ success: true, data: newChannel }, HttpStatusCodes.CREATED)
}
9 changes: 9 additions & 0 deletions apps/api/src/routes/v1/channels/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createRouter } from "@/lib/helpers/app/create-app"
import * as handlers from "./handlers"
import * as routes from "./routes"

const channelsRouter = createRouter()
.openapi(routes.listChannels, handlers.listChannels)
.openapi(routes.createChannel, handlers.createChannel)

export default channelsRouter
60 changes: 60 additions & 0 deletions apps/api/src/routes/v1/channels/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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,
unauthorizedSchema,
} from "@/lib/helpers/openapi/schemas"
import { authMiddleware } from "@/middleware/auth"
import {
createChannelRequestSchema,
createChannelResponseSchema,
listChannelsResponseSchema,
} from "./schema"

export const listChannels = createRoute({
path: "/channels",
method: "get",
summary: "List channels",
description: "Lists all channels in the user's active guild.",
tags: ["Channels"],
middleware: [authMiddleware] as const,
responses: {
[HttpStatusCodes.OK]: jsonContent({
schema: listChannelsResponseSchema,
description: "List of channels",
}),
[HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema,
[HttpStatusCodes.FORBIDDEN]: forbiddenSchema,
[HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema,
},
})

export const createChannel = createRoute({
path: "/channels",
method: "post",
summary: "Create a channel",
description:
"Creates a new channel in the user's active guild. Guild ID is derived from the session.",
tags: ["Channels"],
middleware: [authMiddleware] as const,
request: {
body: jsonContent({
schema: createChannelRequestSchema,
description: "Channel details",
}),
},
responses: {
[HttpStatusCodes.CREATED]: jsonContent({
schema: createChannelResponseSchema,
description: "Channel created",
}),
[HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema,
[HttpStatusCodes.FORBIDDEN]: forbiddenSchema,
[HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema,
},
})
Comment on lines +16 to +57
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

OpenAPI responses are missing 400 Bad Request and 404 Not Found.

Both routes can return 400 (middleware: "No active guild selected"; POST also from zod validation failures) and 404 (middleware: "Guild not found"). Documenting these in the OpenAPI spec ensures accurate client codegen and API docs.

🤖 Prompt for AI Agents
In `@apps/api/src/routes/v1/channels/routes.ts` around lines 16 - 57, Add 400 and
404 responses to the OpenAPI response maps for both listChannels and
createChannel: include HttpStatusCodes.BAD_REQUEST with a jsonContent/schema
(e.g., badRequestSchema) describing "No active guild selected" and for POST also
"validation errors" from the createChannelRequestSchema, and include
HttpStatusCodes.NOT_FOUND with a jsonContent/schema (e.g., notFoundSchema)
describing "Guild not found"; update the responses object in the createRoute
calls for listChannels and createChannel (referencing listChannels,
createChannel, authMiddleware, and createChannelRequestSchema) so client
generation and docs include these cases.


export type ListChannelsRoute = typeof listChannels
export type CreateChannelRoute = typeof createChannel
16 changes: 16 additions & 0 deletions apps/api/src/routes/v1/channels/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { z } from "@hono/zod-openapi"
import { insertChannelSchema, selectChannelSchema } from "@repo/db/schema"

export const channelResponseSchema = selectChannelSchema

export const listChannelsResponseSchema = z.object({
success: z.literal(true),
data: z.array(selectChannelSchema),
})

export const createChannelRequestSchema = insertChannelSchema

export const createChannelResponseSchema = z.object({
success: z.literal(true),
data: selectChannelSchema,
})
88 changes: 88 additions & 0 deletions apps/web/src/components/sidebar/channel-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { cn } from "@repo/ui/lib/utils"
import { Hash, Volume2 } from "lucide-react"
import { UserAvatar } from "../user-avatar"

// Hardcoded mock data — will be replaced with real data
const channels = [
{ name: "general", active: true, unread: false },
{ name: "introductions", active: false, unread: true },
{ name: "development", active: false, unread: false },
{ name: "design", active: false, unread: false },
{ name: "off-topic", active: false, unread: true },
]

const voiceChannels = [
{ name: "Lounge", usersIn: ["Sam Chen", "Jordan Blake"] },
{ name: "Dev Session", usersIn: [] as string[] },
]

export function ChannelList() {
return (
<nav>
{/* Text Channels */}
<span className="mb-1 block px-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Channels
</span>
{channels.map((ch) => (
<div
key={ch.name}
className={cn(
"relative flex items-center gap-2 rounded-lg px-2 py-[6px] text-[14px]",
ch.active
? "bg-foreground/[0.06] font-medium text-foreground"
: ch.unread
? "font-medium text-foreground"
: "text-muted-foreground"
)}
>
{ch.active && (
<div className="absolute left-0 top-1/2 h-4 w-[3px] -translate-y-1/2 rounded-r-full bg-primary" />
)}
<Hash className="size-[16px] shrink-0 opacity-50" />
<span className="truncate">{ch.name}</span>
{ch.unread && !ch.active && (
<div className="ml-auto size-1.5 shrink-0 rounded-full bg-primary" />
)}
</div>
))}

{/* Voice Channels */}
<span className="mb-1 mt-5 block px-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Voice
</span>
{voiceChannels.map((ch) => (
<div key={ch.name}>
<div className="flex items-center gap-2 rounded-lg px-2 py-[6px] text-[14px] text-muted-foreground">
<Volume2 className="size-[16px] shrink-0 opacity-50" />
<span className="truncate">{ch.name}</span>
{ch.usersIn.length > 0 && (
<div className="ml-auto flex items-center gap-1">
<div className="flex gap-[3px]">
<div className="h-3 w-[2px] animate-pulse rounded-full bg-emerald-500" />
<div className="h-2 w-[2px] animate-pulse rounded-full bg-emerald-500 [animation-delay:150ms]" />
<div className="h-3.5 w-[2px] animate-pulse rounded-full bg-emerald-500 [animation-delay:300ms]" />
</div>
<span className="text-[11px] text-emerald-600">
{ch.usersIn.length}
</span>
</div>
)}
</div>
{ch.usersIn.length > 0 && (
<div className="mb-1 ml-2 space-y-px">
{ch.usersIn.map((name) => (
<div
key={name}
className="flex items-center gap-2 rounded-md px-2 py-1 text-[13px] text-muted-foreground"
>
<UserAvatar name={name} size="sm" />
<span className="truncate">{name}</span>
</div>
))}
</div>
)}
</div>
))}
</nav>
)
}
Loading