Skip to content

Dev#6

Merged
BuckyMcYolo merged 6 commits intomainfrom
dev
Feb 18, 2026
Merged

Dev#6
BuckyMcYolo merged 6 commits intomainfrom
dev

Conversation

@BuckyMcYolo
Copy link
Copy Markdown
Owner

@BuckyMcYolo BuckyMcYolo commented Feb 18, 2026

Summary by CodeRabbit

  • New Features

    • Direct Messages panel with paginated conversation list and DM routes
    • Drag-and-drop channel reordering with persistent updates
    • User profile menu with theme selector (light/dark/system)
  • UI/UX Improvements

    • Resizable sidebar panels and improved navigation between channels/DMs
    • Updated color palette and visual refinements
    • Loading states and skeletons for smoother feedback

channels and API for repositioning the channels
- add clickable DM list with active state and /dms/$dmId route stub
- add clickable channel list with active state and
  /$guildSlug/$channelId route stub
- render stacked avatar cluster for group DMs using AvatarGroup
- seed script: generate 4 group DM conversations with members and
  messages
- remove grip handle icons; make full channel/category row draggable
- reduce channel panel left padding (px-3 → px-2)
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 18, 2026

📝 Walkthrough

Walkthrough

Adds a paginated DM listing API and seed script, session-only auth middleware, guild-scoped channel reorder API, frontend DM panel and DnD channel reordering UI, resizable sidebar layout, new UI primitives, and related config/dependency updates.

Changes

Cohort / File(s) Summary
DM backend & seed
apps/api/scripts/seed-dms.ts, apps/api/src/routes/v1/dms/handlers.ts, apps/api/src/routes/v1/dms/index.ts, apps/api/src/routes/v1/dms/routes.ts, apps/api/src/routes/v1/dms/schema.ts
Adds a seed script for fake DMs/group DMs and implements GET /dms with pagination: queries channels, latest messages, and members; returns paginated DM channel payloads with schemas.
Guild-scoped channels API
apps/api/src/routes/v1/channels/routes.ts, apps/api/src/routes/v1/channels/handlers.ts, apps/api/src/routes/v1/channels/schema.ts, apps/api/src/routes/v1/channels/index.ts
Moves channel routes under /guilds/{guildSlug}/channels, adds PATCH /reorder to update channel positions/parentId inside a DB transaction, and adds validation schemas for reorder payload and guildSlug param.
Auth middleware
apps/api/src/middleware/guild-auth.ts, apps/api/src/middleware/session-auth.ts
Replaces active-guild middleware with guildSlug-based guildAuthMiddleware (404/403 semantics) and adds sessionAuthMiddleware for session-only endpoints (used by /dms).
OpenAPI pagination helpers
apps/api/src/lib/helpers/openapi/schemas.ts
Introduces paginationQuerySchema and a generic paginatedResponseSchema for consistent pagination request/response shapes.
API router registration
apps/api/src/app.ts
Mounts the new dmsRouter under the existing /v1 route chain.
Frontend DM UI
apps/web/src/components/sidebar/dm-panel/dm-list.tsx, apps/web/src/components/sidebar/dm-panel/dm-panel.tsx
Adds DMPanel and DMList components: fetch DMs, render avatars (single/group), last-message previews, and navigate to DM routes.
Channel DnD UI
apps/web/src/components/sidebar/channel-panel/channel-list.tsx
Adds DnDKit-based drag-and-drop for channels/categories, optimistic updates, and a reorder mutation to call the server reorder API.
Sidebar & layout changes
apps/web/src/components/sidebar/index.tsx, apps/web/src/components/sidebar/channel-panel/channel-panel.tsx, apps/web/src/components/sidebar/channel-panel/guild-header.tsx, apps/web/src/components/sidebar/guild-bar/guild-bar.tsx, apps/web/src/components/sidebar/channel-panel/user-bar.tsx
Introduces resizable left panel switching between guild ChannelPanel and DMPanel by guildSlug, updates guild header queries, converts user bar to dropdown with theme toggle and logout, and minor layout tweaks.
Routing & navigation
apps/web/src/routes/__root.tsx, apps/web/src/routes/_authenticated.tsx, apps/web/src/routes/_authenticated/index.tsx, apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx, apps/web/src/routes/_authenticated/dms.tsx, apps/web/src/routes/_authenticated/dms/$dmId.tsx, apps/web/src/routes/_authenticated/dms/index.tsx, apps/web/src/routes/login.tsx, apps/web/src/routes/signup.tsx
Adds DM route hierarchy and DM conversation routes, redirects authenticated entry to /dms, persists last path to localStorage, wraps Outlet with Sidebar, and adds session-based redirect logic on auth pages.
UI primitives & styles
packages/ui/src/components/dropdown-menu.tsx, packages/ui/src/components/resizable.tsx, packages/ui/src/components/toggle.tsx, packages/ui/src/components/toggle-group.tsx, packages/ui/src/components/skeleton.tsx, packages/ui/src/styles/globals.css
Adds Radix-based dropdown, resizable panels wrapper, toggle and toggle-group components, skeleton component, and updates light/dark theme tokens in global CSS.
Dependencies & config
apps/web/package.json, packages/ui/package.json, packages/auth/src/lib/auth.ts
Adds @dnd-kit packages and motion to web app, react-resizable-panels to UI package, and enables session cookie caching (5m maxAge) in auth config.

Sequence Diagram(s)

sequenceDiagram
    actor User as User
    participant Web as Client (Web)
    participant API as API Server
    participant DB as Database

    User->>Web: Open DMs view
    Web->>API: GET /v1/dms?page=1&perPage=20
    API->>DB: Query DM channels (filter by member) + count
    DB-->>API: channels list + total
    API->>DB: Query latest message per channel (by channelIds)
    DB-->>API: messages + authors
    API->>DB: Query channel members (exclude current user)
    DB-->>API: members per channel
    API->>Web: 200 OK { paginated channels with members + lastMessage }
    Web->>User: Render DM list (avatars, previews)
Loading
sequenceDiagram
    actor User as User
    participant Web as Client (Web)
    participant API as API Server
    participant DB as Database

    User->>Web: Drag channel to new position
    Web->>Web: Apply optimistic reorder locally
    Web->>API: PATCH /v1/guilds/{guildSlug}/channels/reorder {channels: [...]}
    API->>DB: BEGIN TRANSACTION
    loop each channel
      API->>DB: UPDATE channels SET position,parentId WHERE id=...
    end
    DB-->>API: COMMIT
    API->>Web: 200 OK { success: true }
    Web->>User: Keep reordered view (confirm)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • feat: added 'v1' prefix to add external facing routes #1: Also modifies /v1 router composition and route mounting; likely related to integrating new routers like dmsRouter.
  • Dev #5: Touches channel listing and reordering across API and web UI; strongly related to the reorder implementation and DnD client changes.
  • Dev #2: Edits apps/api/src/app.ts similarly (route mounting, middleware/CORS); overlaps with router registration concerns in this PR.

Poem

🐰
Hopping through threads where messages bloom bright,
I nudge channels and DMs into new order by night.
Panels stretch, avatars gather, and previews softly hum —
A bunny-built sidebar where conversations come. 🎉

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Dev' is vague and generic, failing to convey any meaningful information about the substantial changes in this pull request. Replace with a descriptive title summarizing the main change, such as 'Add DM list view, channel reordering, and UI improvements' or focus on the primary feature.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch dev

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/api/src/routes/v1/channels/routes.ts (1)

19-38: ⚠️ Potential issue | 🟡 Minor

Missing 404 response in OpenAPI definitions.

The guildAuthMiddleware can return a 404 Not Found when the guild slug doesn't match any guild, but none of the route definitions (listChannels, createChannel, reorderChannels) document a 404 response. This means the generated OpenAPI spec won't reflect this possible response, which could confuse API consumers.

Consider adding a [HttpStatusCodes.NOT_FOUND]: notFoundSchema entry to each route's responses.

Also applies to: 40-63, 65-89

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/routes/v1/channels/routes.ts` around lines 19 - 38, The route
OpenAPI responses are missing a 404 for cases where guildAuthMiddleware returns
Not Found; update the responses object for listChannels (and likewise for
createChannel and reorderChannels) to include [HttpStatusCodes.NOT_FOUND]:
notFoundSchema so the generated spec documents the middleware’s possible 404
outcome; locate the responses block in each createRoute call (symbols:
listChannels, createChannel, reorderChannels) and add that entry alongside the
existing OK/UNAUTHORIZED/FORBIDDEN/INTERNAL_SERVER_ERROR entries.
🧹 Nitpick comments (16)
apps/api/src/middleware/session-auth.ts (1)

16-32: Wrap auth.api.getSession() in a try/catch.

If the underlying auth call throws (e.g. database unavailable, cookie parsing error), the exception propagates uncaught and Hono's generic 500 handler fires. A structured catch gives you a consistent error response and a log trace.

🛡️ Proposed fix
 export const sessionAuthMiddleware = async (
   c: Context<AppBindings>,
   next: Next
 ) => {
-  const session = await auth.api.getSession({ headers: c.req.raw.headers })
+  let session: Awaited<ReturnType<typeof auth.api.getSession>>
+  try {
+    session = await auth.api.getSession({ headers: c.req.raw.headers })
+  } catch {
+    return c.json(
+      { success: false, message: "Unauthorized" },
+      HttpStatusCodes.UNAUTHORIZED
+    )
+  }

   if (!session) {
     return c.json(
       { success: false, message: "Unauthorized" },
       HttpStatusCodes.UNAUTHORIZED
     )
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/middleware/session-auth.ts` around lines 16 - 32, Wrap the call
to auth.api.getSession inside sessionAuthMiddleware in a try/catch so any thrown
errors are handled; specifically, call auth.api.getSession({ headers:
c.req.raw.headers }) inside a try block, and in the catch log the error (using
your app logger or console.error) and return a consistent c.json response with
HttpStatusCodes.INTERNAL_SERVER_ERROR and a short message (e.g. "Authentication
error"); preserve the existing behavior for when session is null (return
UNAUTHORIZED) and continue to set c.set("user", session.user) and
c.set("session", session.session) when successful.
apps/web/src/routes/signup.tsx (1)

23-33: Consider extracting the session-redirect guard into a shared hook.

The session-check useEffect pattern — authClient.useSession() → redirect to / when non-pending and truthy — is identical across signup.tsx and login.tsx. A small custom hook like useRedirectIfAuthenticated() would eliminate the duplication.

♻️ Proposed extraction
// e.g., apps/web/src/hooks/use-redirect-if-authenticated.ts
+import { authClient } from "@repo/auth/client"
+import { useNavigate } from "@tanstack/react-router"
+import { useEffect } from "react"
+
+export function useRedirectIfAuthenticated(to = "/") {
+  const navigate = useNavigate()
+  const { data: session, isPending: sessionPending } = authClient.useSession()
+  useEffect(() => {
+    if (!sessionPending && session) {
+      navigate({ to })
+    }
+  }, [sessionPending, session, navigate, to])
+}

Then in both LoginPage and SignUpPage:

-  const navigate = useNavigate()
-  const { data: session, isPending: sessionPending } = authClient.useSession()
-  useEffect(() => {
-    if (!sessionPending && session) {
-      navigate({ to: "/" })
-    }
-  }, [sessionPending, session, navigate])
+  useRedirectIfAuthenticated()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/routes/signup.tsx` around lines 23 - 33, Extract the duplicate
session-redirect logic into a new hook (e.g., useRedirectIfAuthenticated) that
calls authClient.useSession(), checks sessionPending and session, and calls
navigate({ to: "/" }) when session is non-pending and truthy; then replace the
useEffect block in SignupPage (signup.tsx) and LoginPage (login.tsx) with a call
to useRedirectIfAuthenticated so both files use the shared hook. Ensure the hook
exports a default or named function and imports navigate from the same router
used in the existing files so behavior remains identical.
apps/api/src/lib/helpers/openapi/schemas.ts (1)

2-2: ZodType is valid in Zod 4 but may cause module-instance drift with @hono/zod-openapi.

In Zod 4, all schemas extend the z.ZodType base class, which in turn extends z.$ZodType from zod/v4/core, so the import is correct. However, z is sourced from @hono/zod-openapi while ZodType is pulled directly from "zod". If the two packages resolve to separate zod instances (possible in certain monorepo hoisting scenarios), TypeScript may report the constraint as unsatisfied. For robustness, derive ZodType from the same z export used everywhere in the file:

♻️ Proposed fix
 import { z } from "@hono/zod-openapi"
-import type { ZodType } from "zod"
 import jsonContent from "./json-content"

-export const paginatedResponseSchema = <T extends ZodType>(itemSchema: T) =>
+export const paginatedResponseSchema = <T extends z.ZodType>(itemSchema: T) =>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/lib/helpers/openapi/schemas.ts` at line 2, The file imports
ZodType directly from "zod", which can cause module-instance drift versus the
`z` export used from `@hono/zod-openapi`; change the code to derive ZodType from
the same `z` export used elsewhere in the file (e.g. import the type from the
same module that provides `z` such as `@hono/zod-openapi`, or reference it as
`z.ZodType`) so that all schema types share the same Zod instance; update the
import line and any type references to use that unified `ZodType` symbol (e.g.,
replace `import type { ZodType } from "zod"` with an import from the module that
exports `z` or use `z.ZodType`).
apps/web/src/components/sidebar/dm-panel/dm-list.tsx (2)

65-74: getInitials duplicates logic from UserAvatar.

The initials computation here (split → first char → join → slice(0,2) → uppercase) is identical to the one in apps/web/src/components/ui/user-avatar.tsx. Consider extracting it into a shared utility to keep things DRY.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/sidebar/dm-panel/dm-list.tsx` around lines 65 - 74,
The initials logic in getInitials duplicates the same computation used by
UserAvatar; extract that logic into a shared utility function (e.g., export
function computeInitials(name: string | null): string) and replace the local
getInitials in dm-list.tsx and the inline logic in
apps/web/src/components/ui/user-avatar.tsx to import and call computeInitials
instead, keeping its behavior (split, take first char of each word, join,
slice(0,2), uppercase, default "?") and update imports/exports accordingly.

28-34: Loading state is indistinguishable from empty state.

When useQuery is still fetching, data is undefined, so this renders "No conversations yet." Consider checking isLoading from useQuery to show a skeleton or spinner during the initial fetch.

Proposed approach
- const { data } = useQuery({
+ const { data, isLoading } = useQuery({
    queryKey: ["dms"],
    queryFn: async () => {
      const res = await apiClient.v1.dms.$get({ query: {} })
      if (!res.ok) throw new Error("Failed to fetch DMs")
      return res.json()
    },
  })

+ if (isLoading) {
+   return <div className="px-2 py-4 text-center text-sm text-muted-foreground">Loading…</div>
+ }
+
  if (!data || data.data.length === 0) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/sidebar/dm-panel/dm-list.tsx` around lines 28 - 34,
The current early return treats undefined `data` (while useQuery is loading) the
same as an empty result; update the rendering logic in the DM list component
that calls useQuery so it reads the hook's isLoading (and/or isFetching) flag
and, when true, renders a loading UI (spinner or skeleton) instead of the "No
conversations yet." message, and only show the empty-state message when
isLoading is false and data?.data?.length === 0; reference the useQuery call and
its isLoading return value to implement this conditional.
apps/api/src/routes/v1/dms/schema.ts (1)

5-26: lastMessageAuthorSchema and dmMemberSchema have identical shapes.

Both schemas define { id, name, username, displayUsername, image }. If they're expected to stay in sync, consider extracting a shared base schema to reduce duplication. If they're intentionally separate to allow independent evolution, this is fine as-is.

Example shared schema approach
+const dmUserSchema = z.object({
+  id: z.string().uuid(),
+  name: z.string(),
+  username: z.string().nullable(),
+  displayUsername: z.string().nullable(),
+  image: z.string().nullable(),
+})
+
-export const lastMessageAuthorSchema = z.object({
-  id: z.string().uuid(),
-  name: z.string(),
-  username: z.string().nullable(),
-  displayUsername: z.string().nullable(),
-  image: z.string().nullable(),
-})
+export const lastMessageAuthorSchema = dmUserSchema
 
-export const dmMemberSchema = z.object({
-  id: z.string().uuid(),
-  name: z.string(),
-  username: z.string().nullable(),
-  displayUsername: z.string().nullable(),
-  image: z.string().nullable(),
-})
+export const dmMemberSchema = dmUserSchema
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/routes/v1/dms/schema.ts` around lines 5 - 26,
lastMessageAuthorSchema and dmMemberSchema duplicate the same shape; extract a
shared base schema (e.g., userBaseSchema or memberBaseSchema) that defines id,
name, username, displayUsername, image and then reuse it to build
lastMessageAuthorSchema and dmMemberSchema via zod's .extend() or by assigning
them to the shared schema to remove duplication; alternatively, if you intend
them to diverge later, add a brief comment above both schemas explaining the
intentional duplication so future maintainers know it's deliberate.
apps/api/scripts/seed-dms.ts (2)

84-91: Consider validating that the CLI argument is a valid UUID.

If a non-UUID string is passed, the DB query will either error or return no rows. A quick UUID format check would give a friendlier error message.

Example
 const userId = process.argv[2]
 if (!userId) {
   console.error(
     "Usage: pnpm --filter `@repo/api` exec tsx scripts/seed-dms.ts <user-id>"
   )
   process.exit(1)
 }
+
+const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+if (!uuidRegex.test(userId)) {
+  console.error(`Invalid UUID: ${userId}`)
+  process.exit(1)
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/scripts/seed-dms.ts` around lines 84 - 91, The seed function
currently queries the DB with userId without validating it; add a UUID format
check for the CLI argument (userId) before calling db.select() in seed() so
invalid strings produce a friendly error instead of a DB failure. Implement the
check right before the existing select (or at the start of seed()), using a UUID
validation helper (e.g., a regex or a library function) to verify userId, and if
invalid call console.error with a clear message and process.exit(1); keep the
rest of the logic (the db.select().from(user).where(eq(user.id, userId)) and the
existing targetUser handling) unchanged.

112-120: N+1 query pattern for orphan detection.

Each DM channel is individually queried for members. For a seed script this is acceptable, but it could be a single query with a LEFT JOIN / subquery to find channels with zero members.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/scripts/seed-dms.ts` around lines 112 - 120, The current loop over
orphaned channels issues an N+1 query by calling db.select per channel; replace
this with a single query that deletes all channels with zero members in one
operation: use the channel and channelMember symbols and build a single DELETE
... WHERE channel.id IN (SELECT channel.id FROM channel LEFT JOIN channelMember
ON channel.id = channelMember.channelId WHERE channel.id IN (orphaned ids) GROUP
BY channel.id HAVING COUNT(channelMember.id) = 0) or an equivalent NOT EXISTS
subquery; update the code that currently iterates `for (const ch of orphaned)`
to call this single delete via the db instance to remove all empty channels at
once.
apps/api/src/app.ts (1)

39-53: Remove stale commented-out route-mounting code.

The block at Lines 39–53 is a leftover from a previous implementation and is now completely superseded by the chained .route() approach above it. It also references only waitlistRouter and channelsRouter—leaving out dmsRouter—so re-enabling it as-is would silently drop the DMs API surface.

✂️ Proposed cleanup
-// // Internal routes (not versioned)
-// const internalRoutes = [waitlistRouter] as const
-// for (const route of internalRoutes) {
-//   app.route("/", route)
-// }
-//
-// // Versioned public API routes
-// const v1Routes = [channelsRouter] as const
-// for (const route of v1Routes) {
-//   app.route("/v1", route)
-// }
-//
-// const allRoutes = [...internalRoutes, ...v1Routes] as const
-//
-// export type AppType = (typeof allRoutes)[number]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/app.ts` around lines 39 - 53, Remove the stale commented-out
route-mounting block that defines internalRoutes, v1Routes, allRoutes, and the
AppType alias (the block that references waitlistRouter and channelsRouter)
since the code now uses the chained .route() approach and the commented code
would omit dmsRouter; delete this entire commented section to avoid confusion
and duplication, ensuring routing is only configured via the existing chained
app.route(...) calls and no dead/commented route declarations remain.
apps/web/src/components/sidebar/channel-panel/channel-panel.tsx (1)

12-12: Nit: px-3 pl-1 is misleading — prefer explicit pr-3 pl-1.

px-3 sets both sides to 0.75rem, then pl-1 immediately overrides the left to 0.25rem. The net result is correct (pr-3 pl-1), but the intent is clearer without the conflicting shorthand.

✨ Proposed cleanup
-      <ScrollArea className="flex-1 px-3 pl-1 pt-3">
+      <ScrollArea className="flex-1 pr-3 pl-1 pt-3">
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/sidebar/channel-panel/channel-panel.tsx` at line 12,
The ScrollArea component's className uses the conflicting shorthand "px-3 pl-1"
which sets both padding sides then immediately overrides the left; update the
className on the ScrollArea in channel-panel.tsx to use explicit "pr-3 pl-1"
(keep pl-1) so intent is clear and no shorthand is overridden, e.g., change the
className value on the ScrollArea element to replace "px-3" with "pr-3".
apps/api/src/routes/v1/channels/handlers.ts (1)

101-108: Consider parallelizing updates inside the transaction or using a single batch statement.

The current for...of + await pattern issues N sequential round-trips to the database. For typical guild channel counts this is fine, but a single Promise.all over the update promises (or a bulk CASE WHEN update) would be more efficient.

♻️ Proposed refactor (parallel updates)
  await db.transaction(async (tx) => {
-   for (const update of updates) {
-     await tx
-       .update(channel)
-       .set({ position: update.position, parentId: update.parentId })
-       .where(and(eq(channel.id, update.id), eq(channel.guildId, guild.id)))
-   }
+   await Promise.all(
+     updates.map((update) =>
+       tx
+         .update(channel)
+         .set({ position: update.position, parentId: update.parentId })
+         .where(and(eq(channel.id, update.id), eq(channel.guildId, guild.id)))
+     )
+   )
  })

Note: if the ORM serializes statements within a single connection anyway, the gain may be negligible, but the intent is clearer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/routes/v1/channels/handlers.ts` around lines 101 - 108, The
transaction loop issues updates sequentially; inside the db.transaction
callback, collect each tx.update(channel).set(...).where(...) call into an array
of promises and run them in parallel with Promise.all (i.e., map over updates to
create promises and await Promise.all(promises)) or replace with a single bulk
update (CASE WHEN) if supported; target the db.transaction closure, the
tx.update(channel).set(...) calls, the updates array and the guild.id/channel.id
predicates when implementing the change.
apps/api/src/routes/v1/dms/handlers.ts (2)

109-144: Consider filtering out the current user at the DB level.

The members query (line 121) fetches all members for each DM channel, then the loop on line 134 skips the current user in JS. Adding ne(channelMember.userId, currentUser.id) to the WHERE clause would reduce rows transferred from the database.

♻️ Proposed change
     .from(channelMember)
     .innerJoin(user, eq(channelMember.userId, user.id))
-    .where(inArray(channelMember.channelId, dmChannelIds))
+    .where(
+      and(
+        inArray(channelMember.channelId, dmChannelIds),
+        ne(channelMember.userId, currentUser.id)
+      )
+    )

This would also let you drop the if (m.id === currentUser.id) continue guard on line 134. You'd need to import ne from drizzle-orm.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/routes/v1/dms/handlers.ts` around lines 109 - 144, The members
query currently fetches all channel members then filters out currentUser in JS;
change the DB query in the members selection (the query building around members,
channelMember, and user) to add ne(channelMember.userId, currentUser.id) to the
.where(...) (alongside the existing inArray(channelMember.channelId,
dmChannelIds)), import ne from drizzle-orm, and then remove the runtime guard if
(m.id === currentUser.id) in the loop that builds membersByChannel to avoid
transferring and processing the current user's row.

61-89: Fetching all messages for dedup is a scalability concern.

This query retrieves every message across all page channels, then deduplicates client-side to keep only the latest per channel. For active DM channels with thousands of messages, this will transfer a large number of rows from the database only to discard all but one per channel.

The comment on line 62 says "Uses DISTINCT ON" but the actual query does not — it performs client-side filtering via a Set.

Consider pushing the dedup to the database. For example, a subquery or Drizzle's sql helper with a window function / DISTINCT ON (if using PostgreSQL):

♻️ Sketch: DB-side latest-message-per-channel
SELECT DISTINCT ON (channel_id)
  channel_id, id, content, created_at, author_id
FROM message
WHERE channel_id = ANY($1)
ORDER BY channel_id, created_at DESC

In Drizzle you can use db.execute(sql\...`)or build a subquery. Alternatively, add.limit(dmChannelIds.length)as a rough cap won't work here, but a raw SQLDISTINCT ON` or a lateral join will.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/routes/v1/dms/handlers.ts` around lines 61 - 89, The current
implementation builds dmChannelIds from dmChannels then queries message joined
to user returning all rows and uses a client-side Set in the latestMessages
post-processing to keep only the most recent message per channel; replace this
client-side dedup with a DB-side "latest-per-channel" query (use PostgreSQL
DISTINCT ON or a window function) so the database returns one row per channel
directly (e.g. use db.execute(sql`...`) or a Drizzle subquery/lateral join) and
remove the rows.filter(...) dedup; update usages of dmChannelIds,
latestMessages, message and user selections accordingly to still include author
fields but ensure the ORDER BY/DISTINCT ON or ROW_NUMBER() OVER (PARTITION BY
message.channelId ORDER BY message.createdAt DESC) is applied in the SQL so only
the newest message per channel is retrieved.
apps/web/src/components/sidebar/channel-panel/channel-list.tsx (3)

183-195: Variable activeItem shadows the component-level state.

The local const activeItem on line 189 shadows the activeItem state declared on line 147. While functionally harmless (the callback uses the freshly-found value), this can confuse readers and is flagged by many linters.

Consider renaming to something like foundActive or draggedItem.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/sidebar/channel-panel/channel-list.tsx` around lines
183 - 195, The local const activeItem in the handleDragOver callback shadows the
component-level activeItem state; rename the local variable returned by
findChannel(active.id as string) (and similarly overItem if desired) to
something like draggedItem (and targetItem) and update all references inside
handleDragOver to use those new names so linters and readers won't confuse the
local lookup with the component state; locate this in the handleDragOver
function where findChannel is called and replace references to
activeItem/overItem inside that callback.

128-145: No onSuccess handler — optimistic state may drift from server.

The reorderMutation only invalidates on error. If the server silently rejects or adjusts positions (e.g., due to a concurrent edit), the client's optimistic state will diverge. Consider invalidating on onSettled to always resync after the mutation completes, or at minimum verifying the server response on success.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/sidebar/channel-panel/channel-list.tsx` around lines
128 - 145, The mutation reorderMutation currently only invalidates via onError
which can leave client state out-of-sync; add an onSettled (or onSuccess)
handler on the useMutation config to always call queryClient.invalidateQueries({
queryKey: ["channels", guildSlug] }) after the request completes (or
additionally verify and reconcile the returned payload on success), so that the
channels list in the cache is refreshed regardless of silent server-side
adjustments; locate the useMutation call for reorderMutation and update its
config to include onSettled (or onSuccess) that invalidates the ["channels",
guildSlug] query key.

434-511: Category collapse toggle also triggers drag — potential UX conflict.

The category header <button> has both an onClick handler (toggle collapse) and {...listeners} (drag listeners) on the same element. A click intended to collapse/expand may inadvertently start a drag, or vice versa. The PointerSensor activation constraint of distance: 5 (line 154) provides a small buffer, but quick clicks on touch devices or shaky hands could still trigger unintended drags.

Consider separating the drag handle from the collapse toggle (e.g., a dedicated grip icon for dragging) if this becomes a UX problem.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/sidebar/channel-panel/channel-list.tsx` around lines
434 - 511, The category header button in SortableCategorySection currently mixes
collapse toggle (onClick={() => setCollapsed(!collapsed)}) with drag listeners
({...attributes} {...listeners}), causing clicks to sometimes start a drag;
separate concerns by moving {...attributes} and {...listeners} off the main
collapse toggle and onto a dedicated drag-handle element (e.g., a small
grip/icon next to the Chevron or replace Chevron with two elements: one for
collapse with onClick only, and one for dragging with the listeners), retain the
PointerSensor activation constraint but ensure the collapse button has only the
toggle behavior and proper aria-labels for accessibility; update
SortableCategorySection to reference the new drag-handle element for the
sortable set-up.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/api/scripts/seed-dms.ts`:
- Around line 108-121: The cleanup only deletes channels with channel.type ===
"dm", leaving seeded group DMs behind; update the orphan query to also select
group_dm channels that belong to the previous seed by adding an OR condition:
include channels where channel.type === "group_dm" AND (channel.name matches the
seed names list OR channel.ownerId equals the seed user id), then run the same
member-count check (using channelMember where eq(channelMember.channelId,
ch.id)) and delete if members.length === 0 (or delete directly for known
seed-owned group_dm). Refer to the channel, channel.type, channel.name,
channel.ownerId, channel.id, and channelMember/channelMember.channelId symbols
when implementing the additional filter.

In `@apps/api/src/routes/v1/channels/handlers.ts`:
- Around line 86-99: The rejection happens because channelIds is built from
updates.map(u => u.id) and can contain duplicates while the inArray SQL check
(inArray(channel.id, channelIds)) deduplicates, so existing.length can be
smaller than channelIds.length causing a false 403; fix by deduplicating the IDs
before comparing (e.g., compute a set/uniqueIds from channelIds and compare
existing.length to uniqueIds.size) or enforce uniqueness earlier by adding a Zod
refinement to reorderChannelsRequestSchema to reject duplicate ids; update
references to channelIds, existing and the inArray check accordingly.

In `@apps/web/src/components/sidebar/channel-panel/channel-list.tsx`:
- Around line 275-324: The setQueryData updater currently performs a side-effect
by calling reorderMutation.mutate(payload) inside the callback; move the API
call out of the pure updater: compute newData inside the
queryClient.setQueryData callback (as currently done), return newData, then
immediately after the setQueryData call build the payload with
buildReorderPayload(newData) and call reorderMutation.mutate(payload). Ensure
you reference the same variables (newData, payload) and functions
(queryClient.setQueryData, buildReorderPayload, reorderMutation.mutate) so the
mutation is invoked exactly once outside the updater.

In `@apps/web/src/components/sidebar/channel-panel/guild-header.tsx`:
- Around line 10-11: Destructure isPending (or isLoading) from the useQuery call
that fetches guilds and stop using guildName ?? "Loading..."; instead, when
rendering the guild title (where guildName and guildSlug are used) show
"Loading…" only while isPending is true, and after data resolves if guildName is
undefined render a clear fallback like "Guild not found" (apply the same change
for the other occurrences around the guild header/title rendering and the
secondary usage). Ensure you reference the useQuery call that returns { data:
guilds } and the variable guildName (derived from guilds.find by guildSlug) when
implementing the conditional.
- Around line 12-14: The queryFn in guild-header.tsx incorrectly treats the
authClient.organization.list() response as success even when res.error is set;
update queryFn to check the returned object from authClient.organization.list(),
and if res.error exists throw that error (or throw new Error(res.error.message
|| String(res.error))) so React Query receives a rejected promise and moves to
the error state; keep returning res.data when there is no error.

In `@apps/web/src/components/sidebar/channel-panel/user-bar.tsx`:
- Around line 30-32: The handleLogout function currently calls
authClient.signOut() without handling rejections; update handleLogout to catch
errors from authClient.signOut(), log the error (use existing logger or
console.error) and surface user feedback (e.g., show a toast/error banner) so
the user knows sign-out failed; also consider setting a local loading state
(e.g., isSigningOut) to disable UI while the call is in-flight and only close
the dropdown or update session state on success. Ensure you modify the
handleLogout function to await authClient.signOut() inside a try/catch and
handle both success and failure paths.

In `@apps/web/src/components/sidebar/dm-panel/dm-list.tsx`:
- Line 143: The close icon (<X />) in dm-list.tsx is rendered with no click
handler, so add an interactive handler: wire an onClick on the <X /> element to
call the DM removal function (e.g., handleRemoveDM or removeConversation)
passing the conversation id, or call a provided prop like
onRemoveDM(conversationId); ensure the handler stops event propagation and
triggers the conversation deletion/UI update (or, if removal logic isn't
available yet, remove the <X /> from the JSX to avoid a non-functional control).

In `@apps/web/src/components/sidebar/dm-panel/dm-panel.tsx`:
- Around line 14-19: The Plus button in dm-panel.tsx is missing accessibility
and interaction: add an aria-label (e.g., aria-label="Create direct message" or
similar) to the button element and wire a click handler (either a real handler
or a temporary no-op like onClick={() => {}}) so it is keyboard/assistive-tech
accessible and not inert; update the button JSX where the <Plus .../> icon is
rendered to include these props and, if applicable, call or forward to an
existing handler function (e.g., openNewDm, handleCreateDm) in the component.

In `@packages/auth/src/lib/auth.ts`:
- Around line 22-27: The cookieCache configuration under session currently sets
maxAge to 5*60 which allows revoked sessions to remain valid for up to 5
minutes; change this by either lowering session.cookieCache.maxAge to a safer
value (e.g., 60) or set session.cookieCache.disableCookieCache: true for
sensitive admin routes; alternatively, keep a short global maxAge and explicitly
set disableCookieCache: true in request options for functions handling bans/role
revocations so session revocation is effective immediately (refer to the
session, cookieCache, maxAge and disableCookieCache symbols).

In `@packages/ui/src/components/skeleton.tsx`:
- Around line 1-3: The type reference React.ComponentProps<"div"> in the
Skeleton component causes a TS error because React is not imported; either add
an import for React (e.g., import React from "react" or import type React from
"react") so React is in scope for type references, or replace the type with a
React-free alternative such as JSX.IntrinsicElements["div"] or
React.ComponentPropsWithoutRef<"div"> with an explicit import of just the type
if needed; update the function signature for Skeleton accordingly (reference
symbols: Skeleton, React.ComponentProps<"div">).

In `@packages/ui/src/components/toggle-group.tsx`:
- Around line 36-40: The Tailwind arbitrary value for gap is invalid: in the
ToggleGroup component, update the className string where it currently contains
"gap-[--spacing(var(--gap))]" to use the correct custom property syntax
"gap-(--gap)" so Tailwind outputs gap: var(--gap); (alternatively compute the
resolved pixel value in the style prop before passing into the inline style).
Ensure you change the className token in the toggle-group component so it
matches the CSS custom property used in the style prop (the spacing variable set
via style={{ "--gap": spacing } as React.CSSProperties}).

---

Outside diff comments:
In `@apps/api/src/routes/v1/channels/routes.ts`:
- Around line 19-38: The route OpenAPI responses are missing a 404 for cases
where guildAuthMiddleware returns Not Found; update the responses object for
listChannels (and likewise for createChannel and reorderChannels) to include
[HttpStatusCodes.NOT_FOUND]: notFoundSchema so the generated spec documents the
middleware’s possible 404 outcome; locate the responses block in each
createRoute call (symbols: listChannels, createChannel, reorderChannels) and add
that entry alongside the existing
OK/UNAUTHORIZED/FORBIDDEN/INTERNAL_SERVER_ERROR entries.

---

Duplicate comments:
In `@apps/web/src/routes/login.tsx`:
- Around line 23-31: The session-redirect useEffect in login.tsx duplicates
logic from signup.tsx; extract that logic into a reusable hook (e.g.,
useRedirectIfAuthenticated) that internally calls authClient.useSession() and
perform navigate({ to: "/" }) when session exists and sessionPending is false,
then replace the local useEffect and direct useSession usage in login.tsx with a
call to useRedirectIfAuthenticated(); ensure the hook exports a clear name and
is imported/used in both login.tsx and signup.tsx so the duplicated useEffect is
removed.

---

Nitpick comments:
In `@apps/api/scripts/seed-dms.ts`:
- Around line 84-91: The seed function currently queries the DB with userId
without validating it; add a UUID format check for the CLI argument (userId)
before calling db.select() in seed() so invalid strings produce a friendly error
instead of a DB failure. Implement the check right before the existing select
(or at the start of seed()), using a UUID validation helper (e.g., a regex or a
library function) to verify userId, and if invalid call console.error with a
clear message and process.exit(1); keep the rest of the logic (the
db.select().from(user).where(eq(user.id, userId)) and the existing targetUser
handling) unchanged.
- Around line 112-120: The current loop over orphaned channels issues an N+1
query by calling db.select per channel; replace this with a single query that
deletes all channels with zero members in one operation: use the channel and
channelMember symbols and build a single DELETE ... WHERE channel.id IN (SELECT
channel.id FROM channel LEFT JOIN channelMember ON channel.id =
channelMember.channelId WHERE channel.id IN (orphaned ids) GROUP BY channel.id
HAVING COUNT(channelMember.id) = 0) or an equivalent NOT EXISTS subquery; update
the code that currently iterates `for (const ch of orphaned)` to call this
single delete via the db instance to remove all empty channels at once.

In `@apps/api/src/app.ts`:
- Around line 39-53: Remove the stale commented-out route-mounting block that
defines internalRoutes, v1Routes, allRoutes, and the AppType alias (the block
that references waitlistRouter and channelsRouter) since the code now uses the
chained .route() approach and the commented code would omit dmsRouter; delete
this entire commented section to avoid confusion and duplication, ensuring
routing is only configured via the existing chained app.route(...) calls and no
dead/commented route declarations remain.

In `@apps/api/src/lib/helpers/openapi/schemas.ts`:
- Line 2: The file imports ZodType directly from "zod", which can cause
module-instance drift versus the `z` export used from `@hono/zod-openapi`;
change the code to derive ZodType from the same `z` export used elsewhere in the
file (e.g. import the type from the same module that provides `z` such as
`@hono/zod-openapi`, or reference it as `z.ZodType`) so that all schema types
share the same Zod instance; update the import line and any type references to
use that unified `ZodType` symbol (e.g., replace `import type { ZodType } from
"zod"` with an import from the module that exports `z` or use `z.ZodType`).

In `@apps/api/src/middleware/session-auth.ts`:
- Around line 16-32: Wrap the call to auth.api.getSession inside
sessionAuthMiddleware in a try/catch so any thrown errors are handled;
specifically, call auth.api.getSession({ headers: c.req.raw.headers }) inside a
try block, and in the catch log the error (using your app logger or
console.error) and return a consistent c.json response with
HttpStatusCodes.INTERNAL_SERVER_ERROR and a short message (e.g. "Authentication
error"); preserve the existing behavior for when session is null (return
UNAUTHORIZED) and continue to set c.set("user", session.user) and
c.set("session", session.session) when successful.

In `@apps/api/src/routes/v1/channels/handlers.ts`:
- Around line 101-108: The transaction loop issues updates sequentially; inside
the db.transaction callback, collect each tx.update(channel).set(...).where(...)
call into an array of promises and run them in parallel with Promise.all (i.e.,
map over updates to create promises and await Promise.all(promises)) or replace
with a single bulk update (CASE WHEN) if supported; target the db.transaction
closure, the tx.update(channel).set(...) calls, the updates array and the
guild.id/channel.id predicates when implementing the change.

In `@apps/api/src/routes/v1/dms/handlers.ts`:
- Around line 109-144: The members query currently fetches all channel members
then filters out currentUser in JS; change the DB query in the members selection
(the query building around members, channelMember, and user) to add
ne(channelMember.userId, currentUser.id) to the .where(...) (alongside the
existing inArray(channelMember.channelId, dmChannelIds)), import ne from
drizzle-orm, and then remove the runtime guard if (m.id === currentUser.id) in
the loop that builds membersByChannel to avoid transferring and processing the
current user's row.
- Around line 61-89: The current implementation builds dmChannelIds from
dmChannels then queries message joined to user returning all rows and uses a
client-side Set in the latestMessages post-processing to keep only the most
recent message per channel; replace this client-side dedup with a DB-side
"latest-per-channel" query (use PostgreSQL DISTINCT ON or a window function) so
the database returns one row per channel directly (e.g. use db.execute(sql`...`)
or a Drizzle subquery/lateral join) and remove the rows.filter(...) dedup;
update usages of dmChannelIds, latestMessages, message and user selections
accordingly to still include author fields but ensure the ORDER BY/DISTINCT ON
or ROW_NUMBER() OVER (PARTITION BY message.channelId ORDER BY message.createdAt
DESC) is applied in the SQL so only the newest message per channel is retrieved.

In `@apps/api/src/routes/v1/dms/schema.ts`:
- Around line 5-26: lastMessageAuthorSchema and dmMemberSchema duplicate the
same shape; extract a shared base schema (e.g., userBaseSchema or
memberBaseSchema) that defines id, name, username, displayUsername, image and
then reuse it to build lastMessageAuthorSchema and dmMemberSchema via zod's
.extend() or by assigning them to the shared schema to remove duplication;
alternatively, if you intend them to diverge later, add a brief comment above
both schemas explaining the intentional duplication so future maintainers know
it's deliberate.

In `@apps/web/src/components/sidebar/channel-panel/channel-list.tsx`:
- Around line 183-195: The local const activeItem in the handleDragOver callback
shadows the component-level activeItem state; rename the local variable returned
by findChannel(active.id as string) (and similarly overItem if desired) to
something like draggedItem (and targetItem) and update all references inside
handleDragOver to use those new names so linters and readers won't confuse the
local lookup with the component state; locate this in the handleDragOver
function where findChannel is called and replace references to
activeItem/overItem inside that callback.
- Around line 128-145: The mutation reorderMutation currently only invalidates
via onError which can leave client state out-of-sync; add an onSettled (or
onSuccess) handler on the useMutation config to always call
queryClient.invalidateQueries({ queryKey: ["channels", guildSlug] }) after the
request completes (or additionally verify and reconcile the returned payload on
success), so that the channels list in the cache is refreshed regardless of
silent server-side adjustments; locate the useMutation call for reorderMutation
and update its config to include onSettled (or onSuccess) that invalidates the
["channels", guildSlug] query key.
- Around line 434-511: The category header button in SortableCategorySection
currently mixes collapse toggle (onClick={() => setCollapsed(!collapsed)}) with
drag listeners ({...attributes} {...listeners}), causing clicks to sometimes
start a drag; separate concerns by moving {...attributes} and {...listeners} off
the main collapse toggle and onto a dedicated drag-handle element (e.g., a small
grip/icon next to the Chevron or replace Chevron with two elements: one for
collapse with onClick only, and one for dragging with the listeners), retain the
PointerSensor activation constraint but ensure the collapse button has only the
toggle behavior and proper aria-labels for accessibility; update
SortableCategorySection to reference the new drag-handle element for the
sortable set-up.

In `@apps/web/src/components/sidebar/channel-panel/channel-panel.tsx`:
- Line 12: The ScrollArea component's className uses the conflicting shorthand
"px-3 pl-1" which sets both padding sides then immediately overrides the left;
update the className on the ScrollArea in channel-panel.tsx to use explicit
"pr-3 pl-1" (keep pl-1) so intent is clear and no shorthand is overridden, e.g.,
change the className value on the ScrollArea element to replace "px-3" with
"pr-3".

In `@apps/web/src/components/sidebar/dm-panel/dm-list.tsx`:
- Around line 65-74: The initials logic in getInitials duplicates the same
computation used by UserAvatar; extract that logic into a shared utility
function (e.g., export function computeInitials(name: string | null): string)
and replace the local getInitials in dm-list.tsx and the inline logic in
apps/web/src/components/ui/user-avatar.tsx to import and call computeInitials
instead, keeping its behavior (split, take first char of each word, join,
slice(0,2), uppercase, default "?") and update imports/exports accordingly.
- Around line 28-34: The current early return treats undefined `data` (while
useQuery is loading) the same as an empty result; update the rendering logic in
the DM list component that calls useQuery so it reads the hook's isLoading
(and/or isFetching) flag and, when true, renders a loading UI (spinner or
skeleton) instead of the "No conversations yet." message, and only show the
empty-state message when isLoading is false and data?.data?.length === 0;
reference the useQuery call and its isLoading return value to implement this
conditional.

In `@apps/web/src/routes/signup.tsx`:
- Around line 23-33: Extract the duplicate session-redirect logic into a new
hook (e.g., useRedirectIfAuthenticated) that calls authClient.useSession(),
checks sessionPending and session, and calls navigate({ to: "/" }) when session
is non-pending and truthy; then replace the useEffect block in SignupPage
(signup.tsx) and LoginPage (login.tsx) with a call to useRedirectIfAuthenticated
so both files use the shared hook. Ensure the hook exports a default or named
function and imports navigate from the same router used in the existing files so
behavior remains identical.

Comment thread apps/api/scripts/seed-dms.ts
Comment thread apps/api/src/routes/v1/channels/handlers.ts
Comment thread apps/web/src/components/sidebar/channel-panel/channel-list.tsx
Comment thread apps/web/src/components/sidebar/channel-panel/guild-header.tsx Outdated
Comment thread apps/web/src/components/sidebar/channel-panel/guild-header.tsx
Comment thread apps/web/src/components/sidebar/dm-panel/dm-list.tsx Outdated
Comment thread apps/web/src/components/sidebar/dm-panel/dm-panel.tsx
Comment thread packages/auth/src/lib/auth.ts
Comment thread packages/ui/src/components/skeleton.tsx
Comment thread packages/ui/src/components/toggle-group.tsx
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (4)
apps/web/src/components/sidebar/dm-panel/dm-list.tsx (2)

64-73: getInitials duplicates the initials logic already inside UserAvatar

apps/web/src/components/ui/user-avatar.tsx computes initials with identical split / map / join / slice / toUpperCase logic. Since GroupDMAvatars uses raw <Avatar> elements instead of <UserAvatar>, the logic is duplicated. Consider exporting a shared getInitials utility from user-avatar.tsx (or a shared utils file) and importing it here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/sidebar/dm-panel/dm-list.tsx` around lines 64 - 73,
The initials logic in getInitials is duplicated from UserAvatar; to fix,
move/export the initials helper from the UserAvatar implementation (e.g., export
function getInitials from user-avatar.tsx or a small shared utils module) and
replace the local getInitials in dm-list.tsx by importing that exported
getInitials; update GroupDMAvatars (where raw <Avatar> elements are used) to
call the shared getInitials, remove the local duplicate, and ensure the exported
function signature accepts name: string | null and returns the same two-letter
uppercase fallback "?" behavior.

12-12: Use the @/ path alias for the UserAvatar import

The rest of the file uses @/ for local app imports (e.g., @/lib/api-client on line 11). The relative ../../ui/user-avatar is inconsistent and fragile under any future directory restructuring.

♻️ Proposed fix
-import { UserAvatar } from "../../ui/user-avatar"
+import { UserAvatar } from "@/components/ui/user-avatar"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/sidebar/dm-panel/dm-list.tsx` at line 12, Replace the
relative import of UserAvatar with the project alias; in dm-list.tsx change the
import "import { UserAvatar } from \"../../ui/user-avatar\"" to use the "@/..."
alias (e.g., import { UserAvatar } from "@/components/ui/user-avatar") so it
matches other imports like "@/lib/api-client" and is resilient to future
refactors.
apps/api/src/routes/v1/channels/handlers.ts (1)

104-110: Deduplication is not applied to the actual update loop — duplicates still cause redundant writes.

uniqueChannelIds is used for the guild-membership check but the transaction still iterates over the original updates array. A payload with duplicate IDs (e.g., the same channel appearing twice with different positions) passes verification and then executes multiple UPDATE statements for the same row. The last write silently wins, which is non-deterministic from the caller's perspective and wastes DB round trips.

The cleanest fix is to enforce uniqueness at the schema level so duplicates are rejected before reaching this handler:

🐛 Option A — Zod refinement on the schema (preferred)

Add a refinement to reorderChannelsRequestSchema in schema.ts:

  export const reorderChannelsRequestSchema = z.object({
    channels: z.array(
      z.object({
        id: z.string().uuid(),
        position: z.number().int().nonnegative(),
        parentId: z.string().uuid().nullable().optional(),
      })
-   ),
+   ).refine(
+     (items) => new Set(items.map((i) => i.id)).size === items.length,
+     { message: "Duplicate channel IDs are not allowed" }
+   ),
  })
🐛 Option B — Deduplicate inside the handler
  const channelIds = updates.map((u) => u.id)
  const uniqueChannelIds = [...new Set(channelIds)]

+ // Deduplicate updates so each channel is written at most once
+ const uniqueUpdates = [
+   ...new Map(updates.map((u) => [u.id, u])).values(),
+ ]
+
  // Verify all channels belong to this guild
  const existing = await db
    .select({ id: channel.id })
    .from(channel)
    .where(
      and(eq(channel.guildId, guild.id), inArray(channel.id, uniqueChannelIds))
    )

  if (existing.length !== uniqueChannelIds.length) { … }

  await db.transaction(async (tx) => {
-   for (const update of updates) {
+   for (const update of uniqueUpdates) {
      await tx
        .update(channel)
        .set({ position: update.position, parentId: update.parentId })
        .where(and(eq(channel.id, update.id), eq(channel.guildId, guild.id)))
    }
  })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/routes/v1/channels/handlers.ts` around lines 104 - 110, The
handler currently validates membership using uniqueChannelIds but still iterates
over the original updates array, causing duplicate UPDATEs; fix by enforcing
uniqueness at parse time (preferred) or deduplicating before the transaction:
add a Zod refinement to reorderChannelsRequestSchema (in schema.ts) that checks
all channel ids are unique and returns a validation error if duplicates exist,
or alternatively in the handler create a dedupedUpdates array/Map keyed by
update.id (and a deduped uniqueChannelIds set) and use that deduped collection
in the db.transaction loop where tx.update(channel).set(...).where(...) is
called so each channel id is updated exactly once.
apps/web/src/components/sidebar/channel-panel/channel-list.tsx (1)

113-145: guildSlug as string silently passes undefined to the API when the param is absent.

useParams({ strict: false }) returns guildSlug as string | undefined. Casting with as string (lines 117, 135) bypasses TypeScript's protection — if guildSlug is undefined, the API call targets the literal string "undefined". For useQuery the enabled: !!guildSlug guard is in place, but reorderMutation.mutationFn has no equivalent guard; it relies on handleDragEnd's !data early return being a reliable proxy.

A more explicit alternative:

♻️ Proposed refactor
  queryFn: async () => {
+   if (!guildSlug) throw new Error("guildSlug is required")
    const res = await apiClient.v1.guilds[":guildSlug"].channels.$get({
-     param: { guildSlug: guildSlug as string },
+     param: { guildSlug },
    })
  mutationFn: async (channels: ...) => {
+   if (!guildSlug) throw new Error("guildSlug is required")
    const res = await apiClient.v1.guilds[":guildSlug"].channels.reorder.$patch({
-     param: { guildSlug: guildSlug as string },
+     param: { guildSlug },
      json: { channels },
    })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/sidebar/channel-panel/channel-list.tsx` around lines
113 - 145, The mutation and query currently cast guildSlug with "as string"
which can silently pass undefined to the API; update both the useQuery queryFn
and reorderMutation.mutationFn to explicitly check for guildSlug before calling
apiClient (e.g. if (!guildSlug) throw or return early), remove the unsafe "as
string" casts, and ensure reorderMutation rejects/throws when guildSlug is
missing so the API never receives the literal "undefined" (refer to useQuery
queryFn, reorderMutation.mutationFn, guildSlug, and
apiClient.v1.guilds[":guildSlug"].channels.reorder).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/api/src/routes/v1/channels/handlers.ts`:
- Around line 104-111: The global error handler in
apps/api/src/middleware/on-error.ts currently returns { message, stack? } which
violates the route schema expected by reorderChannels; update the handler to
always include success: false in the JSON response (e.g., return { success:
false, message: err.message, stack?: err.stack }), preserving the optional stack
field and existing status code logic so any DB errors thrown in reorderChannels
still produce a response that matches the declared INTERNAL_SERVER_ERROR schema.

In `@apps/web/src/components/sidebar/channel-panel/channel-list.tsx`:
- Around line 265-269: Add an onDragCancel handler and guard the !over path so
optimistic updates from handleDragOver get reverted: implement onDragCancel on
the DndContext to call the same revert logic you use after successful reorder
(use queryClient.invalidateQueries or reset the cached data set by
queryClient.setQueryData) and clear activeItem, and update handleDragEnd to,
when !over, invalidate/reset the query before returning (so
reorderMutation.mutate is not skipped leaving stale optimistic state); refer to
handleDragEnd, handleDragOver, reorderMutation.mutate, queryClient.setQueryData
and ensure the cancel handler mirrors the cleanup performed after successful
mutations.

In `@apps/web/src/components/sidebar/dm-panel/dm-list.tsx`:
- Around line 18-33: The empty-state "No conversations yet." shows during
initial fetch because the component only checks !data; update the useQuery call
in dm-list.tsx to destructure the loading flag (isLoading or isFetching)
alongside data from useQuery, and change the rendering logic so that while
isLoading is true you render a spinner/skeleton placeholder, and only render the
"No conversations yet." message when isLoading is false and data?.data.length
=== 0; reference the existing useQuery invocation and the data variable to
locate where to add the isLoading/isFetching check and replace the early-return
empty-state.

---

Duplicate comments:
In `@apps/web/src/components/sidebar/channel-panel/channel-list.tsx`:
- Around line 275-326: The updater passed to queryClient.setQueryData is impure
because it assigns to the outer newData variable; make it pure by removing the
newData assignment inside the setQueryData callback and instead call
queryClient.getQueryData(["channels", guildSlug]) immediately after setQueryData
to read the updated ChannelData, then call
reorderMutation.mutate(buildReorderPayload(updatedData)). Remove the newData
variable entirely and ensure all mutation logic uses the value returned by
getQueryData so setQueryData's updater has no side effects.

---

Nitpick comments:
In `@apps/api/src/routes/v1/channels/handlers.ts`:
- Around line 104-110: The handler currently validates membership using
uniqueChannelIds but still iterates over the original updates array, causing
duplicate UPDATEs; fix by enforcing uniqueness at parse time (preferred) or
deduplicating before the transaction: add a Zod refinement to
reorderChannelsRequestSchema (in schema.ts) that checks all channel ids are
unique and returns a validation error if duplicates exist, or alternatively in
the handler create a dedupedUpdates array/Map keyed by update.id (and a deduped
uniqueChannelIds set) and use that deduped collection in the db.transaction loop
where tx.update(channel).set(...).where(...) is called so each channel id is
updated exactly once.

In `@apps/web/src/components/sidebar/channel-panel/channel-list.tsx`:
- Around line 113-145: The mutation and query currently cast guildSlug with "as
string" which can silently pass undefined to the API; update both the useQuery
queryFn and reorderMutation.mutationFn to explicitly check for guildSlug before
calling apiClient (e.g. if (!guildSlug) throw or return early), remove the
unsafe "as string" casts, and ensure reorderMutation rejects/throws when
guildSlug is missing so the API never receives the literal "undefined" (refer to
useQuery queryFn, reorderMutation.mutationFn, guildSlug, and
apiClient.v1.guilds[":guildSlug"].channels.reorder).

In `@apps/web/src/components/sidebar/dm-panel/dm-list.tsx`:
- Around line 64-73: The initials logic in getInitials is duplicated from
UserAvatar; to fix, move/export the initials helper from the UserAvatar
implementation (e.g., export function getInitials from user-avatar.tsx or a
small shared utils module) and replace the local getInitials in dm-list.tsx by
importing that exported getInitials; update GroupDMAvatars (where raw <Avatar>
elements are used) to call the shared getInitials, remove the local duplicate,
and ensure the exported function signature accepts name: string | null and
returns the same two-letter uppercase fallback "?" behavior.
- Line 12: Replace the relative import of UserAvatar with the project alias; in
dm-list.tsx change the import "import { UserAvatar } from
\"../../ui/user-avatar\"" to use the "@/..." alias (e.g., import { UserAvatar }
from "@/components/ui/user-avatar") so it matches other imports like
"@/lib/api-client" and is resilient to future refactors.

Comment on lines +104 to +111
await db.transaction(async (tx) => {
for (const update of updates) {
await tx
.update(channel)
.set({ position: update.position, parentId: update.parentId })
.where(and(eq(channel.id, update.id), eq(channel.guildId, guild.id)))
}
})
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for global error handler in the API
find apps/api -name "*.ts" -o -name "*.tsx" | head -20

Repository: BuckyMcYolo/townhall

Length of output: 836


🏁 Script executed:

#!/bin/bash
# Look for error handler patterns - onError, middleware, etc.
rg -n "onError|app\.use.*error|errorHandler|catch.*Error" apps/api/src --type ts -B 2 -A 5 | head -100

Repository: BuckyMcYolo/townhall

Length of output: 2110


🏁 Script executed:

#!/bin/bash
# Check the main app setup/entry point
find apps/api/src -name "index.ts" -o -name "main.ts" -o -name "app.ts" -o -name "server.ts"

Repository: BuckyMcYolo/townhall

Length of output: 220


🏁 Script executed:

#!/bin/bash
# Look at the route file to see the schema definition
cat -n apps/api/src/routes/v1/channels/handlers.ts | head -150

Repository: BuckyMcYolo/townhall

Length of output: 3793


🏁 Script executed:

cat -n apps/api/src/middleware/on-error.ts

Repository: BuckyMcYolo/townhall

Length of output: 757


🏁 Script executed:

find apps/api/src/routes/v1/channels -name "*.ts" | xargs cat -n

Repository: BuckyMcYolo/townhall

Length of output: 9578


🏁 Script executed:

cat -n apps/api/src/lib/helpers/openapi/schemas.ts

Repository: BuckyMcYolo/townhall

Length of output: 1990


Global error handler violates the declared schema response contract.

The reorderChannels transaction (lines 104–111) propagates unhandled errors to the global Hono error handler at apps/api/src/middleware/on-error.ts. However, this handler returns { message, stack? } while the route's INTERNAL_SERVER_ERROR response schema requires { success: boolean, message: string }. Any DB error thrown during the transaction will produce a malformed response that fails OpenAPI schema validation. The error handler must be updated to include the success field in its response body to match the declared schema.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/routes/v1/channels/handlers.ts` around lines 104 - 111, The
global error handler in apps/api/src/middleware/on-error.ts currently returns {
message, stack? } which violates the route schema expected by reorderChannels;
update the handler to always include success: false in the JSON response (e.g.,
return { success: false, message: err.message, stack?: err.stack }), preserving
the optional stack field and existing status code logic so any DB errors thrown
in reorderChannels still produce a response that matches the declared
INTERNAL_SERVER_ERROR schema.

Comment on lines +265 to +269
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveItem(null)
const { active, over } = event
if (!over || !data || active.id === over.id) return
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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n apps/web/src/components/sidebar/channel-panel/channel-list.tsx | head -100

Repository: BuckyMcYolo/townhall

Length of output: 3473


🏁 Script executed:

cat -n apps/web/src/components/sidebar/channel-panel/channel-list.tsx | sed -n '260,280p'

Repository: BuckyMcYolo/townhall

Length of output: 856


🏁 Script executed:

cat -n apps/web/src/components/sidebar/channel-panel/channel-list.tsx | sed -n '340,370p'

Repository: BuckyMcYolo/townhall

Length of output: 1180


🏁 Script executed:

cat -n apps/web/src/components/sidebar/channel-panel/channel-list.tsx | sed -n '200,260p'

Repository: BuckyMcYolo/townhall

Length of output: 2510


🏁 Script executed:

cat -n apps/web/src/components/sidebar/channel-panel/channel-list.tsx | sed -n '265-330p'

Repository: BuckyMcYolo/townhall

Length of output: 115


🌐 Web query:

DnD Kit onDragCancel event handler documentation

💡 Result:

In dnd-kit (React) legacy @dnd-kit/core, onDragCancel is documented as a prop on <DndContext>:

  • Signature (legacy docs): onDragCancel?(): void (docs.dndkit.com)
  • When it fires: when the current drag operation is canceled (example given: user presses Escape while dragging). (docs.dndkit.com)

If you’re looking at the new “next” dnd-kit React API, note that onDragCancel is effectively replaced by a canceled boolean on dragEnd (so you handle cancellation inside onDragEnd). (next.dndkit.com)

Citations:


🏁 Script executed:

sed -n '265,330p' apps/web/src/components/sidebar/channel-panel/channel-list.tsx

Repository: BuckyMcYolo/townhall

Length of output: 2533


🏁 Script executed:

sed -n '350,360p' apps/web/src/components/sidebar/channel-panel/channel-list.tsx

Repository: BuckyMcYolo/townhall

Length of output: 337


Add onDragCancel handler to prevent persistent cache corruption when drag operations are cancelled or dropped outside valid targets.

When dragging channels between containers, handleDragOver applies optimistic cache updates via queryClient.setQueryData(). However, two scenarios leave the cache in an inconsistent state:

  1. Drag cancelled: DndContext fires onDragCancel separately from onDragEnd, but no handler exists to revert the optimistic update.
  2. Dropped outside target (!over): handleDragEnd returns early at line 269, skipping reorderMutation.mutate(), but the optimistic cache mutation from handleDragOver persists indefinitely.

Add onDragCancel handler and guard the !over case to invalidate the query:

Proposed fix
+ const handleDragCancel = useCallback(() => {
+   setActiveItem(null)
+   queryClient.invalidateQueries({ queryKey: ["channels", guildSlug] })
+ }, [guildSlug, queryClient])

  const handleDragEnd = useCallback(
    (event: DragEndEvent) => {
      setActiveItem(null)
      const { active, over } = event
-     if (!over || !data || active.id === over.id) return
+     if (!data) return
+     if (!over || active.id === over.id) {
+       queryClient.invalidateQueries({ queryKey: ["channels", guildSlug] })
+       return
+     }
  <DndContext
    sensors={sensors}
    collisionDetection={closestCenter}
    onDragStart={handleDragStart}
    onDragOver={handleDragOver}
    onDragEnd={handleDragEnd}
+   onDragCancel={handleDragCancel}
  >

Also applies to: 352–358

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/sidebar/channel-panel/channel-list.tsx` around lines
265 - 269, Add an onDragCancel handler and guard the !over path so optimistic
updates from handleDragOver get reverted: implement onDragCancel on the
DndContext to call the same revert logic you use after successful reorder (use
queryClient.invalidateQueries or reset the cached data set by
queryClient.setQueryData) and clear activeItem, and update handleDragEnd to,
when !over, invalidate/reset the query before returning (so
reorderMutation.mutate is not skipped leaving stale optimistic state); refer to
handleDragEnd, handleDragOver, reorderMutation.mutate, queryClient.setQueryData
and ensure the cancel handler mirrors the cleanup performed after successful
mutations.

Comment on lines +18 to +33
const { data } = useQuery({
queryKey: ["dms"],
queryFn: async () => {
const res = await apiClient.v1.dms.$get({ query: {} })
if (!res.ok) throw new Error("Failed to fetch DMs")
return res.json()
},
})

if (!data || data.data.length === 0) {
return (
<div className="px-2 py-4 text-center text-sm text-muted-foreground">
<p>No conversations yet.</p>
</div>
)
}
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

"No conversations yet." flashes during initial load

!data is true while the query is still fetching, so the empty-state message is shown briefly on every mount before the DMs arrive. Destructure isPending (or isLoading) and render a skeleton/spinner instead.

💡 Proposed fix
-  const { data } = useQuery({
+  const { data, isPending } = useQuery({
     queryKey: ["dms"],
     queryFn: async () => {
       const res = await apiClient.v1.dms.$get({ query: {} })
       if (!res.ok) throw new Error("Failed to fetch DMs")
       return res.json()
     },
   })

+  if (isPending) {
+    return (
+      <div className="space-y-0.5 px-2 py-1">
+        {Array.from({ length: 3 }).map((_, i) => (
+          <div key={i} className="h-10 animate-pulse rounded-lg bg-foreground/[0.06]" />
+        ))}
+      </div>
+    )
+  }
+
   if (!data || data.data.length === 0) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { data } = useQuery({
queryKey: ["dms"],
queryFn: async () => {
const res = await apiClient.v1.dms.$get({ query: {} })
if (!res.ok) throw new Error("Failed to fetch DMs")
return res.json()
},
})
if (!data || data.data.length === 0) {
return (
<div className="px-2 py-4 text-center text-sm text-muted-foreground">
<p>No conversations yet.</p>
</div>
)
}
const { data, isPending } = useQuery({
queryKey: ["dms"],
queryFn: async () => {
const res = await apiClient.v1.dms.$get({ query: {} })
if (!res.ok) throw new Error("Failed to fetch DMs")
return res.json()
},
})
if (isPending) {
return (
<div className="space-y-0.5 px-2 py-1">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-10 animate-pulse rounded-lg bg-foreground/[0.06]" />
))}
</div>
)
}
if (!data || data.data.length === 0) {
return (
<div className="px-2 py-4 text-center text-sm text-muted-foreground">
<p>No conversations yet.</p>
</div>
)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/sidebar/dm-panel/dm-list.tsx` around lines 18 - 33,
The empty-state "No conversations yet." shows during initial fetch because the
component only checks !data; update the useQuery call in dm-list.tsx to
destructure the loading flag (isLoading or isFetching) alongside data from
useQuery, and change the rendering logic so that while isLoading is true you
render a spinner/skeleton placeholder, and only render the "No conversations
yet." message when isLoading is false and data?.data.length === 0; reference the
existing useQuery invocation and the data variable to locate where to add the
isLoading/isFetching check and replace the early-return empty-state.

@BuckyMcYolo BuckyMcYolo merged commit 81c9b93 into main Feb 18, 2026
1 check passed
@coderabbitai coderabbitai Bot mentioned this pull request Mar 22, 2026
Merged
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant