Skip to content

feat: notification system with unread indicators, browser/desktop#27

Merged
BuckyMcYolo merged 5 commits intomainfrom
dev
Mar 31, 2026
Merged

feat: notification system with unread indicators, browser/desktop#27
BuckyMcYolo merged 5 commits intomainfrom
dev

Conversation

@BuckyMcYolo
Copy link
Copy Markdown
Owner

@BuckyMcYolo BuckyMcYolo commented Mar 24, 2026

notifications, and Tauri app setup

Notification System with Unread Indicators and Desktop/Browser Support

This PR implements a full-stack notification system with unread/mention indicators, browser notifications, and Tauri desktop notification support. It spans DB schema, API endpoints, realtime bootstrapping, frontend state and UI, Tauri integration, and email delivery integration.

Key Features Added

  • Database
    • New user_notification_settings table with enums (desktopNotification, dmNotification), Drizzle/Zod schemas, select/insert/update schemas, and re-exported from packages/db.
  • API
    • GET and PATCH /v1/notification-settings routes and handlers (session-protected) for reading/updating user notification preferences.
  • Realtime
    • New NotificationBootstrap payload and server event "notification:bootstrap".
    • New getUnreadStatesForUser(userId) to compute per-channel unread and mention counts and lastReadMessageId.
    • Initialize connection now fetches unread-state bootstrap and emits notification:bootstrap before joining presence room.
    • channel:mark-read now emits updated channel:read-state to peers and the requesting socket.
  • Frontend unread state & UX
    • UnreadProvider context and useUnread() hook with optimistic mark-as-read and rollback on failure.
    • Channel and DM lists show unread/mention badges and a left unread marker; counts suppressed for active channel.
    • useAutoMarkRead(channelId) hook: debounced (1s) automatic mark-as-read, subscribes to message:created, respects document.visibilityState.
  • Notifications (Browser + Tauri)
    • notification-dispatcher abstracts runtime differences (get/request permission, showNotification). Tauri runtime dynamically imports @tauri-apps/plugin-notification and uses sendNotification; browser uses Notification API with icon and click handling.
    • useBrowserNotifications() subscribes to realtime notification:mention and notification:unread, respects NotificationSettings, suppresses when document is visible, and deduplicates via tags.
    • Realtime unread payloads enriched with authorName, contentPreview (100-char fallback or attachment summary), and channelName.
  • Tauri/Desktop integration
    • Added tauri-plugin-notification dependency and plugin registration in Rust builder.
    • Tauri config identifier updated and frontendDist path adjusted; capability default.json updated to include notification:default.
    • apps/desktop package scripts added (dev/build) and top-level package.json script desktop.
    • CORS/default origins extended to include tauri://localhost.
    • Android icon/color assets added for desktop packaging.
  • Settings UI and wiring
    • NotificationSettings React component: shows permission prompt, selects for Desktop & DM notification levels, integrates with API via React Query and requests permissions via notification-dispatcher.
    • SettingsDialog now renders NotificationSettings when selected.
    • BrowserNotifications side-effect component wired into authenticated layout under UnreadProvider.
  • Email / Auth
    • Resend integrated for sending verification and password-reset emails; auth config updated to send using Resend callbacks.
    • env schema adds RESEND_API_KEY (required) and EMAIL_FROM (default); packages/auth now depends on resend.
  • Misc / Developer experience
    • .gitignore updated to ignore Tauri generated schemas.
    • ROADMAP.md updated to mark desktop/notification deliverables complete.
    • Turbo UI mode changed (tui → stream-with-experimental-timestamps).
    • New frontend components/utilities (AuthLayout, PasswordInput) and several auth-related routes/pages added/updated (check-email, forgot-password, reset-password, signup/login UX updates).
    • packages/realtime-types updated with new types and event signatures.

Notable Code/Behavior Changes

  • Exports/types: new exported functions/types across packages (DB schemas, API route types, realtime types, frontend hooks/components).
  • Real-time behavior changed: bootstrap emitted before join; mark-read emits both to peers and requester.
  • Mark read computation: markChannelRead now sets lastReadAt to new Date() when inferring from latest message.
  • Frontend: optimistic read removal with server ack/rollback; auto-mark-read tied to visibility and message events.

Deployment / Operational Notes

  • DB migration required: new schema (user_notification_settings) added—run repository's Drizzle/DB push/migrations (e.g., pnpm db:push for @repo/db) before deploying.
  • Env changes: RESEND_API_KEY must be provided; EMAIL_FROM default added.
  • Ensure desktop build picks up web dist at updated frontendDist path (../../../apps/web/dist) for Tauri packaging.
  • No automated tests for notification flows, settings API, or unread-state behavior were added.

Potential Issues / Recommendations

  • Add and run DB migration (or confirm migration artifacts) before deployment to avoid runtime failures.
  • Add tests for notification-settings endpoints, unread-state bootstrap, optimistic mark-read rollback, and browser/desktop notification gating.
  • Verify Resend email templates, delivery behavior, and production credentials.
  • Review error handling around dynamic Tauri imports and notification plugin failures (current implementation swallows some errors).
  • Consider documenting UX differences/limitations for Tauri notifications (click behavior, permissions) for maintainers.

Confidence Score: 3/5

The PR is well-scoped and integrates end-to-end (DB, API, realtime, frontend, desktop, and email). Primary concerns before merging are operational: ensure DB migration is applied, provide RESEND_API_KEY, and add test coverage for critical notification flows. Minor error-handling and runtime edge cases (dynamic Tauri import failures, permission edge cases) remain to be hardened.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 24, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 32a704cb-b0a7-417d-99ec-76946b2e63ea

📥 Commits

Reviewing files that changed from the base of the PR and between 57ae720 and 6706d09.

📒 Files selected for processing (4)
  • apps/web/src/components/onboarding/onboarding-dialog.tsx
  • apps/web/src/hooks/use-browser-notifications.ts
  • apps/web/src/routes/login.tsx
  • packages/auth/src/lib/auth.ts

📝 Walkthrough

Walkthrough

Adds a cross-platform notification system: DB schema and API for user notification settings, realtime unread-state bootstrap/events, Tauri desktop notification integration, browser/Tauri notification dispatch, auto mark-as-read behavior, and UI wiring for settings and sidebar unread/mention indicators.

Changes

Cohort / File(s) Summary
Ignore Rules
\.gitignore
Ignore Tauri-generated schema outputs at **/src-tauri/gen/schemas.
Roadmap
ROADMAP.md
Marks notification/read-state deliverables complete and updates roadmap checklist.
API mount
apps/api/src/app.ts
Mounts notificationSettingsRouter under /v1.
Notification Settings API
apps/api/src/routes/v1/notification-settings/...
apps/api/src/routes/v1/notification-settings/handlers.ts, index.ts, routes.ts, schema.ts
Adds GET/PATCH endpoints, OpenAPI route defs/types, request/response schemas, and handlers (upsert behavior).
DB: notification settings
packages/db/src/schemas/user-notification-settings.ts, packages/db/src/schemas/index.ts
Adds user_notification_settings table, enums, Drizzle/Zod schemas, relations, and re-exports.
Realtime: unread bootstrap & read-state
apps/realtime/src/index.ts, apps/realtime/src/services/read-states.ts, apps/realtime/src/services/channel-access.ts, apps/realtime/src/services/notifications.ts
Bootstraps per-user unread state on connect, adds getUnreadStatesForUser, adjusts mark-read timestamps, augments unread payloads with author/content/channel, and includes channel name in accessible-channel payloads.
Realtime types
packages/realtime-types/src/events.ts
Adds NotificationBootstrap type, extends UnreadNotification with author/content/channel fields, and adds "notification:bootstrap" server-to-client event.
Web: unread context & hooks
apps/web/src/context/unread-context.tsx, apps/web/src/hooks/use-auto-mark-read.ts, apps/web/src/hooks/use-browser-notifications.ts
Adds UnreadProvider/useUnread, debounced auto-mark-read hook, and browser notification hook honoring user settings and visibility.
Web: notification dispatcher
apps/web/src/lib/notification-dispatcher.ts
Abstracts permission queries/requests and showing notifications for Tauri and browser runtimes.
Web: UI wiring
apps/web/src/routes/_authenticated.tsx, apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx, apps/web/src/routes/_authenticated/dms/$dmId.tsx
Wraps app with UnreadProvider, mounts BrowserNotifications side-effect, and invokes useAutoMarkRead in channel and DM views.
Web: settings UI
apps/web/src/components/settings/notification-settings.tsx, apps/web/src/components/settings/settings-dialog.tsx
Adds NotificationSettings component, permission prompt, API integration, and integrates it into SettingsDialog.
Web: sidebar / DM list
apps/web/src/components/sidebar/channel-panel/channel-list.tsx, apps/web/src/components/sidebar/dm-panel/dm-list.tsx
Consumes useUnread to show left unread indicator, mention badges, typography emphasis for unread, and passes channelId to DM items.
Web: auth & UX
multiple apps/web/src/routes/*, apps/web/src/components/auth/*
Adds AuthLayout, PasswordInput, new auth pages (check-email, forgot-password, reset-password), and revises login/signup flows.
Web: other libs & wiring
apps/web/src/lib/notification-dispatcher.ts, apps/web/src/lib/notification-dispatcher.ts
Cross-runtime notification helper and settings UI integration.
Tauri desktop changes
apps/desktop/package.json, apps/desktop/src-tauri/Cargo.toml, apps/desktop/src-tauri/capabilities/default.json, apps/desktop/src-tauri/src/lib.rs, apps/desktop/src-tauri/tauri.conf.json, icon files
Adds tauri-plugin-notification, grants notification:default capability, registers plugin, updates scripts/config/identifier/frontendDist, and adds Android icon resources.
Packages & scripts
apps/web/package.json, package.json, packages/auth/package.json
Adds @tauri-apps/plugin-notification dependency, top-level desktop script, and resend dependency for auth email sending.
Auth / Email (Resend) & env
packages/auth/src/lib/auth.ts, packages/env/src/server.ts
Integrates Resend for verification/reset emails, adds RESEND_API_KEY and EMAIL_FROM env validation/default, and includes tauri://localhost in trusted origins/CORS.
Misc config
turbo.json
Changes ui field to stream-with-experimental-timestamps.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client (Web/Tauri)
    participant Realtime as Realtime Server
    participant API as API Server
    participant DB as Database

    Client->>Realtime: socket connect (auth)
    Realtime->>API: request unread bootstrap for user
    API->>DB: query memberships, read-states, counts
    DB-->>API: NotificationBootstrap { readStates }
    API-->>Realtime: NotificationBootstrap
    Realtime-->>Client: notification:bootstrap
    Client->>Client: UnreadProvider replaces stateMap -> UI updates
Loading
sequenceDiagram
    participant User as User
    participant Client as Client (Web)
    participant Realtime as Realtime Server
    participant DB as Database

    User->>Client: view / receive message in channel
    Client->>Client: useAutoMarkRead debounced trigger
    Client->>Realtime: channel:mark-read
    Realtime->>DB: upsert channel_read_state
    DB-->>Realtime: updated read state
    Realtime-->>Client: channel:read-state (user room & requester)
    Client->>Client: UnreadProvider clears/updates entry -> UI updates
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇 I hopped from socket to socket with glee,

Bootstraps, badges, and pings now follow me,
Desktop chimes and browser bells in tune,
Settings saved beneath the glowing moon,
I nibbled unread crumbs — hooray, more tea! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.82% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly summarizes the main change: introducing a notification system with unread indicators and desktop/browser support.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • 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: 10

Caution

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

⚠️ Outside diff range comments (2)
apps/web/src/components/sidebar/channel-panel/channel-list.tsx (1)

599-611: ⚠️ Potential issue | 🟡 Minor

Avoid two competing ml-auto margins between trailing controls.

The mention badge (line 600) and DropdownMenuTrigger (line 609) both use ml-auto within the same flex row container. When both are rendered (mentionCount > 0 and canManage both true), they create competing margin claims—the badge gets pushed left and the menu button pushed right, leaving a gap. Keep ml-auto on only one element or group both trailing controls in a wrapper element with appropriate spacing.

🤖 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
599 - 611, The trailing controls (the mention badge rendered when mentionCount >
0 and the DropdownMenuTrigger used when canManage is true) both use ml-auto
causing competing margins; remove ml-auto from one of them or wrap both (the
span showing {mentionCount} and the DropdownMenuTrigger) in a single container
(e.g., a flex row right-aligned) so only that wrapper gets ml-auto, and adjust
spacing between the badge and menu button via gap or margin; update JSX around
mentionCount, DropdownMenuTrigger, menuOpen and the surrounding container to
reflect the single right-aligned wrapper.
apps/realtime/src/services/read-states.ts (1)

52-68: ⚠️ Potential issue | 🟠 Major

Don't advance lastReadAt past the message snapshot.

Replacing latestMessage.createdAt with new Date() widens the read window beyond the row you just selected. Any message inserted after the latestMessage query but before this write can now fall behind lastReadAt and get marked read even though the client never saw it.

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

In `@apps/realtime/src/services/read-states.ts` around lines 52 - 68, The code
advances lastReadAt to new Date(), which can move the read cursor past the
message snapshot; instead assign the timestamp from the selected row so the read
pointer does not exceed the snapshot: set lastReadAt = latestMessage.createdAt
(ensure any necessary type conversion preserves the exact timestamp returned by
the DB) when latestMessage exists, keeping the existing assignment to
lastReadMessageId; update this in the block that references latestMessage and
lastReadAt in read-states.ts.
🤖 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/realtime/src/index.ts`:
- Around line 282-293: The bootstrap of unread state using
getUnreadStatesForUser() is sent fire-and-forget after the socket joins
userRoom(), which allows in-flight notification:unread/notification:mention
messages to arrive and then be wiped out when
socket.emit("notification:bootstrap", ...) replaces the unread map; fix by
serializing bootstrap with live delivery: either send the bootstrap snapshot
before joining the userRoom() in initializeConnection so live fanout cannot race
it, or add a monotonic bootstrap cursor/version field to the bootstrap payload
and include that same cursor/version on all live unread events
(notification:unread/notification:mention) so the client can ignore any
bootstrap older than the latest cursor; update code paths around
getUnreadStatesForUser, socket.emit("notification:bootstrap"), and the live
fanout that emits notification:unread/notification:mention to carry/compare the
cursor/version.

In `@apps/web/src/context/unread-context.tsx`:
- Around line 134-149: The markChannelRead callback currently deletes the
channel entry from stateMap optimistically and emits "channel:mark-read" without
handling the server ack; change markChannelRead to snapshot the previous entry
from stateMap (or the whole map) before deleting, pass an acknowledgement
callback to socket.emit("channel:mark-read", ..., ack) and if the ack returns {
ok: false } restore the saved snapshot via setStateMap so the local unread state
is rolled back; keep the optimistic delete for fast UX but ensure rollback on
failure using the saved snapshot in the same markChannelRead function.

In `@apps/web/src/hooks/use-auto-mark-read.ts`:
- Around line 25-32: The mount effect currently calls debouncedMarkRead() even
when the tab is hidden; change the effect in use-auto-mark-read.ts so it only
triggers debouncedMarkRead() when document.visibilityState === "visible" (or
when document.hidden is false) and channelId is set, leaving the existing
cleanup of timerRef intact; this ensures the existing visibilitychange effect
still handles the first visible read while preventing off-screen mounts from
marking the channel read.

In `@apps/web/src/hooks/use-browser-notifications.ts`:
- Around line 32-45: onMention currently treats settings === undefined as
enabled and also ignores DM mute preferences; update the handler (onMention
inside useEffect) to first bail if settings is not yet loaded, then honor both
settings.desktopNotifications === "nothing" and—when payload.guildId ===
null—settings.dmNotifications === "nothing" before calling showNotification.
Reference the onMention handler, the MentionNotification payload
(payload.guildId and payload.messageId), settings.desktopNotifications and
settings.dmNotifications, and showNotification so the code returns early when
settings are undefined or when the appropriate notification preference is
"nothing".

In `@apps/web/src/lib/notification-dispatcher.ts`:
- Around line 61-70: The Tauri branch in notification-dispatcher ignores the
notification "tag" and the "onClick" callback, so update the isTauri() handling
to preserve feature parity: when importing sendNotification, pass through the
tag option (e.g., include tag in the sendNotification call) and wire up click
handling by registering a Tauri event or fallback that invokes the provided
onClick callback (or document/emit a warning if Tauri cannot support click
callbacks). Locate the isTauri() conditional and the sendNotification usage and
ensure both tag and onClick are forwarded or gracefully handled.
- Around line 26-33: Update getNotificationPermissionSync to avoid returning the
misleading "default" for Tauri by introducing a distinct sentinel and updating
the return type: instead of unconditionally returning "default" when isTauri()
is true, return a clearly named sentinel (e.g., "tauri-unknown" or null) and add
that sentinel to the function's union return type; adjust callers to handle the
new sentinel (or document the limitation in the function JSDoc) so UI components
can render a Tauri-specific state rather than treating it as the browser
"default". Ensure references to getNotificationPermissionSync and isTauri are
updated accordingly.

In `@packages/db/src/schemas/user-notification-settings.ts`:
- Around line 36-59: The current schema exports
(selectUserNotificationSettingsSchema, insertUserNotificationSettingsSchema,
notificationSettingsResponseSchema, updateNotificationSettingsSchema) are
generated via drizzle-zod which requires Zod v3 and conflicts with Zod v4;
replace the drizzle-zod usage with Drizzle ORM's built-in Zod generators by
importing createSelectSchema and createInsertSchema from "drizzle-orm/zod" (or
regenerate these schemas with that API) so they are compatible with Zod v4, and
if you cannot migrate immediately apply the drizzle-zod zod/v4 namespace
workaround or install with --legacy-peer-deps as a temporary measure.

In `@packages/realtime-types/src/events.ts`:
- Around line 223-230: The NotificationBootstrap type duplicates the shape of
ChannelReadState; replace the inline readStates item with a derived type to
avoid drift by making readStates: Array<ChannelReadState> (or
ChannelReadState[]) and import or reference the existing ChannelReadState type
where NotificationBootstrap is defined so both contracts stay in sync; update
any usages/imports that expect the old inline shape if necessary.

---

Outside diff comments:
In `@apps/realtime/src/services/read-states.ts`:
- Around line 52-68: The code advances lastReadAt to new Date(), which can move
the read cursor past the message snapshot; instead assign the timestamp from the
selected row so the read pointer does not exceed the snapshot: set lastReadAt =
latestMessage.createdAt (ensure any necessary type conversion preserves the
exact timestamp returned by the DB) when latestMessage exists, keeping the
existing assignment to lastReadMessageId; update this in the block that
references latestMessage and lastReadAt in read-states.ts.

In `@apps/web/src/components/sidebar/channel-panel/channel-list.tsx`:
- Around line 599-611: The trailing controls (the mention badge rendered when
mentionCount > 0 and the DropdownMenuTrigger used when canManage is true) both
use ml-auto causing competing margins; remove ml-auto from one of them or wrap
both (the span showing {mentionCount} and the DropdownMenuTrigger) in a single
container (e.g., a flex row right-aligned) so only that wrapper gets ml-auto,
and adjust spacing between the badge and menu button via gap or margin; update
JSX around mentionCount, DropdownMenuTrigger, menuOpen and the surrounding
container to reflect the single right-aligned wrapper.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0557efad-a91a-4290-90ef-d36415d6cb72

📥 Commits

Reviewing files that changed from the base of the PR and between e61ef3f and 1090102.

⛔ Files ignored due to path filters (2)
  • apps/desktop/src-tauri/Cargo.lock is excluded by !**/*.lock
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (28)
  • .gitignore
  • ROADMAP.md
  • apps/api/src/app.ts
  • apps/api/src/routes/v1/notification-settings/handlers.ts
  • apps/api/src/routes/v1/notification-settings/index.ts
  • apps/api/src/routes/v1/notification-settings/routes.ts
  • apps/api/src/routes/v1/notification-settings/schema.ts
  • apps/desktop/package.json
  • apps/desktop/src-tauri/Cargo.toml
  • apps/desktop/src-tauri/capabilities/default.json
  • apps/desktop/src-tauri/src/lib.rs
  • apps/realtime/src/index.ts
  • apps/realtime/src/services/read-states.ts
  • apps/web/package.json
  • apps/web/src/components/settings/notification-settings.tsx
  • apps/web/src/components/settings/settings-dialog.tsx
  • apps/web/src/components/sidebar/channel-panel/channel-list.tsx
  • apps/web/src/components/sidebar/dm-panel/dm-list.tsx
  • apps/web/src/context/unread-context.tsx
  • apps/web/src/hooks/use-auto-mark-read.ts
  • apps/web/src/hooks/use-browser-notifications.ts
  • apps/web/src/lib/notification-dispatcher.ts
  • apps/web/src/routes/_authenticated.tsx
  • apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx
  • apps/web/src/routes/_authenticated/dms/$dmId.tsx
  • packages/db/src/schemas/index.ts
  • packages/db/src/schemas/user-notification-settings.ts
  • packages/realtime-types/src/events.ts

Comment thread apps/realtime/src/index.ts Outdated
Comment on lines +206 to +250
// For each channel, compute unread and mention counts
const results = await Promise.all(
channelIds.map(async (channelId) => {
const readState = readStateMap.get(channelId)
const lastReadAt = readState?.lastReadAt ?? new Date(0)

const [unreadRow, mentionRow] = await Promise.all([
db
.select({ count: count() })
.from(schema.message)
.where(
and(
eq(schema.message.channelId, channelId),
gt(schema.message.createdAt, lastReadAt),
ne(schema.message.authorId, userId)
)
)
.then((rows) => rows[0]),
db
.select({ count: count() })
.from(schema.messageMention)
.where(
and(
eq(schema.messageMention.channelId, channelId),
eq(schema.messageMention.mentionedUserId, userId),
gt(schema.messageMention.createdAt, lastReadAt)
)
)
.then((rows) => rows[0]),
])

const unreadCount = Number(unreadRow?.count ?? 0)
const mentionCount = Number(mentionRow?.count ?? 0)

// Only include channels with unread activity
if (unreadCount === 0 && mentionCount === 0) return null

return {
channelId,
unreadCount,
mentionCount,
lastReadMessageId: readState?.lastReadMessageId ?? null,
}
})
)
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

Avoid 2N aggregate queries during unread bootstrap.

This runs two count queries per channel on every socket connect/reconnect. A user in 100 channels turns one bootstrap into 200 aggregate selects before the client is ready. Aggregate unread and mention counts across all channelIds with GROUP BY channelId and merge the results in memory instead of querying per channel.

Comment on lines +72 to +74
const handleChange = (key: keyof NotificationSettings, value: string) => {
updateSettings({ [key]: value })
}
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:

cat -n apps/web/src/components/settings/notification-settings.tsx | head -80

Repository: BuckyMcYolo/townhall

Length of output: 3250


🏁 Script executed:

rg "type NotificationSettings|interface NotificationSettings" -A 5

Repository: BuckyMcYolo/townhall

Length of output: 1047


🏁 Script executed:

rg "mutationFn|updateSettings" apps/web/src/components/settings/notification-settings.tsx -B 2 -A 2

Repository: BuckyMcYolo/townhall

Length of output: 403


🏁 Script executed:

rg "handleChange" apps/web/src/components/settings/notification-settings.tsx -B 2 -A 2

Repository: BuckyMcYolo/townhall

Length of output: 670


🏁 Script executed:

grep -n "onValueChange\|onChange" apps/web/src/components/settings/notification-settings.tsx

Repository: BuckyMcYolo/townhall

Length of output: 216


🏁 Script executed:

cat -n apps/web/src/components/settings/notification-settings.tsx | sed -n '130,160p'

Repository: BuckyMcYolo/townhall

Length of output: 1495


🏁 Script executed:

fd "tsconfig.json|biome.json|biome.jsonc" -t f

Repository: BuckyMcYolo/townhall

Length of output: 405


🏁 Script executed:

cat biome.json

Repository: BuckyMcYolo/townhall

Length of output: 4193


🏁 Script executed:

cat apps/web/tsconfig.json

Repository: BuckyMcYolo/townhall

Length of output: 275


🏁 Script executed:

cat apps/web/src/components/settings/notification-settings.tsx | sed -n '20,75p'

Repository: BuckyMcYolo/townhall

Length of output: 1834


Use a generic type-safe handler to preserve per-setting union types.

{ [key]: value } with value: string is typed as Record<keyof NotificationSettings, string>, which is incompatible with Partial<NotificationSettings>. The value: string parameter accepts any string instead of restricting to valid enum values per key.

Suggested fix
-  const handleChange = (key: keyof NotificationSettings, value: string) => {
-    updateSettings({ [key]: value })
+  const handleChange = <K extends keyof NotificationSettings>(
+    key: K,
+    value: NotificationSettings[K]
+  ) => {
+    updateSettings({ [key]: value } as Pick<NotificationSettings, K>)
   }

Update the Select call sites to pass narrowed values:

onValueChange={(v) =>
  handleChange(
    "desktopNotifications",
    v as NotificationSettings["desktopNotifications"]
  )
}

Comment thread apps/web/src/context/unread-context.tsx
Comment thread apps/web/src/hooks/use-auto-mark-read.ts
Comment thread apps/web/src/hooks/use-browser-notifications.ts
Comment on lines +26 to +33
export function getNotificationPermissionSync():
| "granted"
| "denied"
| "default" {
if (isTauri()) return "default"
if (!("Notification" in window)) return "denied"
return Notification.permission
}
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

getNotificationPermissionSync() returns misleading value for Tauri.

Returning "default" unconditionally for Tauri doesn't reflect the actual permission state. If UI components rely on this for rendering permission prompts or status indicators, they may display incorrect information.

Consider documenting this limitation clearly or returning a distinct sentinel value that callers can handle appropriately.

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

In `@apps/web/src/lib/notification-dispatcher.ts` around lines 26 - 33, Update
getNotificationPermissionSync to avoid returning the misleading "default" for
Tauri by introducing a distinct sentinel and updating the return type: instead
of unconditionally returning "default" when isTauri() is true, return a clearly
named sentinel (e.g., "tauri-unknown" or null) and add that sentinel to the
function's union return type; adjust callers to handle the new sentinel (or
document the limitation in the function JSDoc) so UI components can render a
Tauri-specific state rather than treating it as the browser "default". Ensure
references to getNotificationPermissionSync and isTauri are updated accordingly.

Comment on lines +61 to +70
if (isTauri()) {
try {
const { sendNotification } = await import(
"@tauri-apps/plugin-notification"
)
sendNotification({ title, body })
} catch {
// Tauri notification plugin not available
}
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 | 🟡 Minor

Tauri notifications lack feature parity with browser.

The Tauri path ignores the tag option (useful for notification grouping/replacement) and the onClick callback. If users expect click-to-navigate behavior in the desktop app, this will silently fail.

📝 Suggested improvement to document the limitation
   if (isTauri()) {
     try {
       const { sendNotification } = await import(
         "@tauri-apps/plugin-notification"
       )
+      // Note: Tauri notifications don't support tag grouping or click handlers
       sendNotification({ title, body })
     } catch {
       // Tauri notification plugin not available
     }
     return
   }
📝 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
if (isTauri()) {
try {
const { sendNotification } = await import(
"@tauri-apps/plugin-notification"
)
sendNotification({ title, body })
} catch {
// Tauri notification plugin not available
}
return
if (isTauri()) {
try {
const { sendNotification } = await import(
"@tauri-apps/plugin-notification"
)
// Note: Tauri notifications don't support tag grouping or click handlers
sendNotification({ title, body })
} catch {
// Tauri notification plugin not available
}
return
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/lib/notification-dispatcher.ts` around lines 61 - 70, The Tauri
branch in notification-dispatcher ignores the notification "tag" and the
"onClick" callback, so update the isTauri() handling to preserve feature parity:
when importing sendNotification, pass through the tag option (e.g., include tag
in the sendNotification call) and wire up click handling by registering a Tauri
event or fallback that invokes the provided onClick callback (or document/emit a
warning if Tauri cannot support click callbacks). Locate the isTauri()
conditional and the sendNotification usage and ensure both tag and onClick are
forwarded or gracefully handled.

Comment on lines +36 to +59
export const selectUserNotificationSettingsSchema = createSelectSchema(
userNotificationSettings
)
export const insertUserNotificationSettingsSchema = createInsertSchema(
userNotificationSettings
).omit({
id: true,
createdAt: true,
updatedAt: true,
})

export const notificationSettingsResponseSchema =
selectUserNotificationSettingsSchema.pick({
desktopNotifications: true,
dmNotifications: true,
})

export const updateNotificationSettingsSchema =
insertUserNotificationSettingsSchema
.pick({
desktopNotifications: true,
dmNotifications: true,
})
.partial()
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

🌐 Web query:

drizzle-zod Zod 4 compatibility

💡 Result:

drizzle-zod has partial compatibility with Zod v4 features via Zod v3's /v4 namespace (import { z } from 'zod/v4'), supported since drizzle-zod@0.8.1 (May 2025). However, it is not fully compatible with the standalone Zod v4 package (^4.0.0) due to peerDependency on Zod ^3.25.1, causing installation conflicts (ERESOLVE). Workarounds include using --legacy-peer-deps, pnpm overrides, or pinning to drizzle-zod@^0.6.0. Full native Zod v4 support (import { z } from 'zod') remains unresolved (open issue #4746 as of late 2025). Drizzle ORM itself now provides first-class Zod schema generation (deprecated drizzle-zod), explicitly using zod/v4 in docs (e.g., import { z } from 'zod/v4';, createSelectSchema from 'drizzle-orm/zod'). This is the recommended approach for new projects, with built-in support for Zod v4 features.

Citations:


🏁 Script executed:

# Find the package.json and check drizzle-zod and zod versions
fd -n "package\.json" packages/db | head -5 | xargs -I {} cat {}

Repository: BuckyMcYolo/townhall

Length of output: 294


🏁 Script executed:

# Read the full user-notification-settings.ts file to check imports and usage
cat -n packages/db/src/schemas/user-notification-settings.ts

Repository: BuckyMcYolo/townhall

Length of output: 2440


🏁 Script executed:

# Find package.json and check versions
find . -name "package.json" -path "*/packages/*" | head -1 | xargs cat | grep -A 5 '"dependencies"'

Repository: BuckyMcYolo/townhall

Length of output: 210


🏁 Script executed:

# Also check root package.json
cat package.json | grep -E '"drizzle-zod"|"zod"' | head -10

Repository: BuckyMcYolo/townhall

Length of output: 46


🏁 Script executed:

# Find all package.json files and search for drizzle-zod version
find . -name "package.json" -type f | xargs grep -l "drizzle-zod" 2>/dev/null

Repository: BuckyMcYolo/townhall

Length of output: 90


🏁 Script executed:

# Get the full root package.json to see all dependencies
cat package.json

Repository: BuckyMcYolo/townhall

Length of output: 811


🏁 Script executed:

# Read packages/db/package.json to see actual versions
cat packages/db/package.json

Repository: BuckyMcYolo/townhall

Length of output: 714


Resolve drizzle-zod and Zod v4 incompatibility.

The project uses drizzle-zod@0.8.3 which declares a peerDependency on zod@^3.25.1, not v4. Using zod@^4.3.6 will cause ERESOLVE dependency conflicts during installation. The recommended fix is to migrate to Drizzle ORM's built-in Zod schema generation (createSelectSchema and createInsertSchema from drizzle-orm/zod), which has first-class support for Zod v4. Alternatively, if staying with drizzle-zod, implement the zod/v4 namespace workaround (supported since drizzle-zod@0.8.1) or use --legacy-peer-deps flag, though the built-in Drizzle ORM approach is preferred for new projects.

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

In `@packages/db/src/schemas/user-notification-settings.ts` around lines 36 - 59,
The current schema exports (selectUserNotificationSettingsSchema,
insertUserNotificationSettingsSchema, notificationSettingsResponseSchema,
updateNotificationSettingsSchema) are generated via drizzle-zod which requires
Zod v3 and conflicts with Zod v4; replace the drizzle-zod usage with Drizzle
ORM's built-in Zod generators by importing createSelectSchema and
createInsertSchema from "drizzle-orm/zod" (or regenerate these schemas with that
API) so they are compatible with Zod v4, and if you cannot migrate immediately
apply the drizzle-zod zod/v4 namespace workaround or install with
--legacy-peer-deps as a temporary measure.

Comment on lines +223 to +230
export type NotificationBootstrap = {
readStates: Array<{
channelId: string
unreadCount: number
mentionCount: number
lastReadMessageId: string | null
}>
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Avoid type drift by deriving bootstrap item shape from ChannelReadState.

The readStates item duplicates fields already defined in ChannelReadState. Deriving it keeps both event contracts in sync.

♻️ Suggested refactor
+type NotificationBootstrapReadState = Pick<
+  ChannelReadState,
+  "channelId" | "unreadCount" | "mentionCount" | "lastReadMessageId"
+>
+
 export type NotificationBootstrap = {
-  readStates: Array<{
-    channelId: string
-    unreadCount: number
-    mentionCount: number
-    lastReadMessageId: string | null
-  }>
+  readStates: NotificationBootstrapReadState[]
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/realtime-types/src/events.ts` around lines 223 - 230, The
NotificationBootstrap type duplicates the shape of ChannelReadState; replace the
inline readStates item with a derived type to avoid drift by making readStates:
Array<ChannelReadState> (or ChannelReadState[]) and import or reference the
existing ChannelReadState type where NotificationBootstrap is defined so both
contracts stay in sync; update any usages/imports that expect the old inline
shape if necessary.

- Add Resend email integration to auth package with new env vars
- Enrich unread notifications with author name, content preview,
  and channel name for better browser/Tauri notifications
- Fix notification bootstrap race by awaiting unread state before
  joining user presence room
- Add optimistic rollback for failed mark-read acknowledgements
- Skip auto-mark-read when tab is not visible
- Allow tauri://localhost in CORS origins for desktop app
- Fix Tauri config identifier and frontendDist path
- Fix channel list layout so badge and menu don't overlap
- Improve login/signup pages with PasswordInput, loading, and errors
- Add `pnpm desktop` script and switch Turbo UI to stream mode
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: 13

Caution

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

⚠️ Outside diff range comments (1)
apps/realtime/src/index.ts (1)

92-100: ⚠️ Potential issue | 🟠 Major

Keep tauri://localhost when REALTIME_CORS_ORIGIN is configured.

The current fallback only includes the Tauri origin when the env var is empty. Any environment that already sets REALTIME_CORS_ORIGIN will still reject the desktop client.

🐛 Proposed fix
 const defaultOrigins = [
   "http://localhost:3000",
   "http://localhost:3001",
-  "tauri://localhost",
 ]
-const corsOrigins = (env.REALTIME_CORS_ORIGIN || defaultOrigins.join(","))
-  .split(",")
-  .map((origin) => origin.trim())
-  .filter(Boolean)
+const corsOrigins = [
+  ...new Set([
+    ...(env.REALTIME_CORS_ORIGIN || defaultOrigins.join(","))
+      .split(",")
+      .map((origin) => origin.trim())
+      .filter(Boolean),
+    "tauri://localhost",
+  ]),
+]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/realtime/src/index.ts` around lines 92 - 100, The CORS setup uses
defaultOrigins only when env.REALTIME_CORS_ORIGIN is empty, which drops
"tauri://localhost" when REALTIME_CORS_ORIGIN is set; change the logic in the
corsOrigins calculation to always include "tauri://localhost" (from
defaultOrigins) by parsing env.REALTIME_CORS_ORIGIN into an array,
trimming/filtering, then ensuring "tauri://localhost" is present (append if
missing) before assigning corsOrigins; adjust references in this block around
defaultOrigins, corsOrigins, and env.REALTIME_CORS_ORIGIN.
♻️ Duplicate comments (4)
apps/realtime/src/index.ts (1)

285-298: ⚠️ Potential issue | 🔴 Critical

The unread bootstrap can still race live fanout.

socket.join(userPresenceRoom) still happens before the snapshot emit, so notification:unread / notification:mention can arrive first and then be overwritten by notification:bootstrap.

🐛 Proposed fix
-    await socket.join(userPresenceRoom)
-
     // Bootstrap unread state before joining userRoom so live notifications
     // arriving between join and bootstrap don't get wiped
     try {
       const bootstrap = await getUnreadStatesForUser(socket.data.user.id)
       socket.emit("notification:bootstrap", bootstrap)
     } catch (err) {
       console.error("Failed to bootstrap unread states:", {
         socketId: socket.id,
         userId: socket.data.user.id,
         error: err,
       })
     }
+
+    await socket.join(userPresenceRoom)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/realtime/src/index.ts` around lines 285 - 298, The bootstrap emit races
with live notifications because socket.join(userPresenceRoom) occurs before
fetching and emitting the snapshot; move the unread bootstrap before joining the
presence room: call getUnreadStatesForUser(socket.data.user.id) and
socket.emit("notification:bootstrap", ...) (keeping the existing try/catch and
error logging) prior to calling socket.join(userPresenceRoom) so that subsequent
fanout into userPresenceRoom cannot overwrite the bootstrapped state.
packages/realtime-types/src/events.ts (1)

226-233: 🧹 Nitpick | 🔵 Trivial

Consider deriving from ChannelReadState to avoid type drift.

The readStates item shape duplicates fields already in ChannelReadState. This was flagged in a previous review.

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

In `@packages/realtime-types/src/events.ts` around lines 226 - 233, The
NotificationBootstrap.readStates duplicates ChannelReadState; replace the inline
object type in NotificationBootstrap with Array<ChannelReadState> (importing
ChannelReadState if not already) so the shape is derived from the canonical
type, and make sure the lastReadMessageId and numeric fields’
nullability/optional status match ChannelReadState exactly to avoid type drift.
apps/web/src/hooks/use-browser-notifications.ts (1)

35-39: ⚠️ Potential issue | 🟠 Major

Honor DM mute preference for mention notifications.

For guildId === null, mentions currently bypass dmNotifications and still notify.

Proposed fix
     const onMention = (payload: MentionNotification) => {
       if (document.visibilityState === "visible") return
       if (!settings) return
       if (settings.desktopNotifications === "nothing") return
+      if (payload.guildId === null && settings.dmNotifications === "nothing") {
+        return
+      }

       const mentionType =
         payload.type === "everyone_mention" ? "@everyone" : "a mention"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/hooks/use-browser-notifications.ts` around lines 35 - 39, The
onMention handler currently skips honoring DM mute settings; update the
early-return logic inside onMention(MentionNotification) to check
payload.guildId and the user's DM preference: if payload.guildId === null and
settings?.dmNotifications === "none" (or whatever the "mute" value is in your
settings) then return early to suppress the notification; keep the existing
checks for document.visibilityState and desktopNotifications and perform this
DM-pref check before creating/sending the notification.
apps/web/src/lib/notification-dispatcher.ts (1)

26-31: ⚠️ Potential issue | 🟡 Minor

getNotificationPermissionSync() still returns an ambiguous Tauri value.

Returning "default" for all Tauri cases conflates unknown state with browser-default permission and can mislead UI state handling.

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

In `@apps/web/src/lib/notification-dispatcher.ts` around lines 26 - 31,
getNotificationPermissionSync currently returns "default" for Tauri via the
isTauri() branch, which conflates Tauri-specific unknown state with the browser
"default" permission; change the function signature to include a distinct Tauri
value (e.g., add "tauri" or "unknown-tauri" to the union) and return that value
from the isTauri() branch instead of "default"; update any callers of
getNotificationPermissionSync (UI state logic, notification-dispatcher
consumers) to handle the new Tauri-specific value appropriately so UI can
distinguish browser-default from Tauri unknown state; reference
getNotificationPermissionSync and isTauri when locating the change.
🤖 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/realtime/src/services/notifications.ts`:
- Around line 125-131: Normalize mention tokens in message content before
computing contentPreview: convert patterns like `<@uuid>` and `[@... id="uuid"]`
to a human-readable form (e.g., `@username` or `@user`) or strip them to `@user`
placeholders. Implement or call a helper (e.g., normalizeMentions(content:
string): string) and use it when reading input.message.content in the
contentPreview expression so the preview displays normalized text instead of raw
mention tokens; keep the attachments fallback unchanged.

In `@apps/web/src/context/unread-context.tsx`:
- Line 149: The ack callback currently types the parameter as (res: { ok:
boolean }) => ...; change that to use the full MarkChannelReadAck union type
(the exported type used for the mark-read ack) so you can access success data or
error details in the handler; update the handler in unread-context (the arrow
callback where res is typed) to narrow on res.ok and use res.state on success
and log or handle res.error on failure.

In `@apps/web/src/hooks/use-auto-mark-read.ts`:
- Around line 18-21: The debounce callback scheduled by debouncedMarkRead
currently calls markChannelRead(channelIdRef.current) without re-checking tab
visibility; update the timeout handler inside useAutoMarkRead (the function
using timerRef.current and channelIdRef.current) to first verify the page is
still visible (e.g., check document.visibilityState === 'visible' or an existing
isVisibleRef) and only then call markChannelRead, otherwise skip/cancel marking
to avoid clearing unread while the tab is hidden.

In `@apps/web/src/lib/notification-dispatcher.ts`:
- Around line 18-19: Guard accesses to the browser globals by checking for their
existence before using them: replace direct uses like if (!("Notification" in
window)) return false and if (Notification.permission === "granted") return true
with checks that first ensure a browser environment (e.g. if (typeof window ===
"undefined" || typeof Notification === "undefined") return false) and then
safely read Notification.permission (e.g. const perm = typeof Notification !==
"undefined" ? Notification.permission : "default";). Apply the same pattern for
every occurrence that touches window or Notification (the instances currently
checking "Notification" in window, Notification.permission, and any other direct
window/Notification access at the other noted locations).

In `@apps/web/src/routes/check-email.tsx`:
- Around line 14-19: The validateSearch currently uses an unsafe assertion
(search.email as string); replace it with a Zod schema to validate and coerce
the search params: import Zod (z), define a schema like z.object({ email:
z.string().email().optional().default("") }), and use that schema inside the
Route's validateSearch (or pass it directly if createFileRoute/TanStack Router
supports a schema) to parse/validate the incoming search and return the typed {
email } for CheckEmailPage instead of the manual cast.
- Around line 24-38: The mutation currently destructures mutate: resendEmail,
isPending and isSuccess but omits the error state, so failures from
authClient.sendVerificationEmail aren't surfaced to users; update the
useMutation call to also destructure the error (or isError) from useMutation and
propagate the thrown error so the UI can render feedback, then update the resend
button/UI (which uses resendEmail, isPending, isSuccess) to show an error
message or error state when error/isError is set; reference mutate: resendEmail,
isPending, isSuccess, useMutation and authClient.sendVerificationEmail when
making the changes.

In `@apps/web/src/routes/forgot-password.tsx`:
- Around line 24-39: The mutation currently reads the live `email` state inside
`mutationFn`, so if the input changes while the request is pending the UI may
report a different address; update `sendReset`/`useMutation` to capture the
submitted value instead — either change `mutationFn` to accept an `email`
argument and call `sendReset(submittedEmail)`, or inside the form submit handler
copy `const submittedEmail = email` and pass that into `sendReset` (or set it on
a closure) so `authClient.requestPasswordReset({ email: submittedEmail,
redirectTo: "/reset-password" })` uses the frozen value; apply the same pattern
for the other mutation usages referenced (lines 54-56, 86-89).

In `@apps/web/src/routes/reset-password.tsx`:
- Around line 38-47: The password mismatch check currently inside mutationFn
(checking password vs confirmPassword) should be moved out of mutationFn and
performed before calling the mutation so it becomes a client-side validation
rather than a mutation error; ensure you set a local error state (e.g.,
localError) when password !== confirmPassword and only call
authClient.resetPassword({ newPassword: password, token }) from mutationFn when
validation passes, then display errors using localError || error?.message so
client validation shows before any mutation error.

In `@apps/web/src/routes/signup.tsx`:
- Around line 172-180: The Terms/Privacy text is rendered as non-focusable
<span> elements, causing accessibility/UX issues; replace those <span
className="underline underline-offset-4 hover:text-primary">Terms of
Service</span> and Privacy Policy spans in the signup component's JSX with real
interactive links (use your router's <Link> or a semantic <a>), provide
appropriate hrefs (to the Terms and Privacy routes or external URLs), keep the
existing classes for styling, and ensure the links are keyboard-focusable and
include target/rel if opening externally.

In `@packages/auth/src/lib/auth.ts`:
- Around line 168-169: Replace the two informational console.error calls in
auth.ts with an info-level logger: locate the console.error call that logs
"[TOWNHALL EMAIL] Sending verification email to ${user.email}..." and the later
console.error that logs the success message, and change them to use an
info/info-level logging method (for example console.info or your existing
logger.info) so non-error informational messages aren't written to error logs;
keep the message text unchanged and only update the logging level.
- Around line 141-163: The sendResetPassword function currently uses a
fire-and-forget .then() on resend.emails.send which silences failures; change
sendResetPassword to await the promise returned by resend.emails.send (like
sendVerificationEmail does), catch and rethrow or return the error so callers
can handle it, and replace the .then() logging with proper try/catch around
resend.emails.send to log and propagate failures from sendResetPassword.

In `@packages/env/src/server.ts`:
- Around line 45-46: The server env schema currently requires RESEND_API_KEY
which causes serverSchema.parse(process.env) to fail on services that don't use
email; update the schema so RESEND_API_KEY is not mandatory (e.g., change
RESEND_API_KEY: z.string().min(1) to RESEND_API_KEY:
z.string().min(1).optional()) or refactor by extracting email fields
(RESEND_API_KEY and EMAIL_FROM) into a separate emailSchema and only
parse/validate that schema in modules that actually send email (where
RESEND_API_KEY is needed) while keeping serverSchema.parse(process.env)
non-blocking for non-email apps.

In `@turbo.json`:
- Line 3: The Turborepo configuration uses an undocumented experimental UI value
("stream-with-experimental-timestamps") for the "ui" key; update that "ui" value
to a documented, stable option such as "stream" (non-interactive) or "tui"
(interactive) to avoid breaking changes—locate the "ui" entry in the turbo.json
(the "ui" key and its current value) and replace the experimental string with
either "stream" or "tui" depending on desired behavior.

---

Outside diff comments:
In `@apps/realtime/src/index.ts`:
- Around line 92-100: The CORS setup uses defaultOrigins only when
env.REALTIME_CORS_ORIGIN is empty, which drops "tauri://localhost" when
REALTIME_CORS_ORIGIN is set; change the logic in the corsOrigins calculation to
always include "tauri://localhost" (from defaultOrigins) by parsing
env.REALTIME_CORS_ORIGIN into an array, trimming/filtering, then ensuring
"tauri://localhost" is present (append if missing) before assigning corsOrigins;
adjust references in this block around defaultOrigins, corsOrigins, and
env.REALTIME_CORS_ORIGIN.

---

Duplicate comments:
In `@apps/realtime/src/index.ts`:
- Around line 285-298: The bootstrap emit races with live notifications because
socket.join(userPresenceRoom) occurs before fetching and emitting the snapshot;
move the unread bootstrap before joining the presence room: call
getUnreadStatesForUser(socket.data.user.id) and
socket.emit("notification:bootstrap", ...) (keeping the existing try/catch and
error logging) prior to calling socket.join(userPresenceRoom) so that subsequent
fanout into userPresenceRoom cannot overwrite the bootstrapped state.

In `@apps/web/src/hooks/use-browser-notifications.ts`:
- Around line 35-39: The onMention handler currently skips honoring DM mute
settings; update the early-return logic inside onMention(MentionNotification) to
check payload.guildId and the user's DM preference: if payload.guildId === null
and settings?.dmNotifications === "none" (or whatever the "mute" value is in
your settings) then return early to suppress the notification; keep the existing
checks for document.visibilityState and desktopNotifications and perform this
DM-pref check before creating/sending the notification.

In `@apps/web/src/lib/notification-dispatcher.ts`:
- Around line 26-31: getNotificationPermissionSync currently returns "default"
for Tauri via the isTauri() branch, which conflates Tauri-specific unknown state
with the browser "default" permission; change the function signature to include
a distinct Tauri value (e.g., add "tauri" or "unknown-tauri" to the union) and
return that value from the isTauri() branch instead of "default"; update any
callers of getNotificationPermissionSync (UI state logic,
notification-dispatcher consumers) to handle the new Tauri-specific value
appropriately so UI can distinguish browser-default from Tauri unknown state;
reference getNotificationPermissionSync and isTauri when locating the change.

In `@packages/realtime-types/src/events.ts`:
- Around line 226-233: The NotificationBootstrap.readStates duplicates
ChannelReadState; replace the inline object type in NotificationBootstrap with
Array<ChannelReadState> (importing ChannelReadState if not already) so the shape
is derived from the canonical type, and make sure the lastReadMessageId and
numeric fields’ nullability/optional status match ChannelReadState exactly to
avoid type drift.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4e324f10-26aa-47ee-8c67-eddcf59a97d8

📥 Commits

Reviewing files that changed from the base of the PR and between 1090102 and d143917.

⛔ Files ignored due to path filters (52)
  • apps/desktop/src-tauri/icons/128x128.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/128x128@2x.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/32x32.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/64x64.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/Square107x107Logo.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/Square142x142Logo.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/Square150x150Logo.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/Square284x284Logo.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/Square30x30Logo.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/Square310x310Logo.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/Square44x44Logo.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/Square71x71Logo.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/Square89x89Logo.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/StoreLogo.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/icon.ico is excluded by !**/*.ico
  • apps/desktop/src-tauri/icons/icon.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png is excluded by !**/*.png
  • apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png is excluded by !**/*.png
  • apps/desktop/src-tauri/src/icon.png is excluded by !**/*.png
  • apps/web/public/townhallicon.png is excluded by !**/*.png
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (25)
  • apps/desktop/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml
  • apps/desktop/src-tauri/icons/android/values/ic_launcher_background.xml
  • apps/desktop/src-tauri/icons/icon.icns
  • apps/desktop/src-tauri/tauri.conf.json
  • apps/realtime/src/index.ts
  • apps/realtime/src/services/channel-access.ts
  • apps/realtime/src/services/notifications.ts
  • apps/web/src/components/auth/auth-layout.tsx
  • apps/web/src/components/auth/password-input.tsx
  • apps/web/src/components/sidebar/channel-panel/channel-list.tsx
  • apps/web/src/context/unread-context.tsx
  • apps/web/src/hooks/use-auto-mark-read.ts
  • apps/web/src/hooks/use-browser-notifications.ts
  • apps/web/src/lib/notification-dispatcher.ts
  • apps/web/src/routes/check-email.tsx
  • apps/web/src/routes/forgot-password.tsx
  • apps/web/src/routes/login.tsx
  • apps/web/src/routes/reset-password.tsx
  • apps/web/src/routes/signup.tsx
  • package.json
  • packages/auth/package.json
  • packages/auth/src/lib/auth.ts
  • packages/env/src/server.ts
  • packages/realtime-types/src/events.ts
  • turbo.json

Comment on lines +125 to +131
const contentPreview = input.message.content
? input.message.content.length > 100
? `${input.message.content.slice(0, 100)}…`
: input.message.content
: input.message.attachments.length > 0
? `sent ${input.message.attachments.length} attachment${input.message.attachments.length > 1 ? "s" : ""}`
: null
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

Normalize mention syntax before filling contentPreview.

This preview uses raw message.content, but the same file accepts <@uuid> and [@... id="uuid"] mention formats. Browser/Tauri notifications will surface those tokens verbatim instead of readable text.

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

In `@apps/realtime/src/services/notifications.ts` around lines 125 - 131,
Normalize mention tokens in message content before computing contentPreview:
convert patterns like `<@uuid>` and `[@... id="uuid"]` to a human-readable form
(e.g., `@username` or `@user`) or strip them to `@user` placeholders. Implement
or call a helper (e.g., normalizeMentions(content: string): string) and use it
when reading input.message.content in the contentPreview expression so the
preview displays normalized text instead of raw mention tokens; keep the
attachments fallback unchanged.

socket.emit(
"channel:mark-read",
{ channelId, lastReadMessageId },
(res: { ok: boolean }) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Minor: Ack response type is simplified.

The callback types res as { ok: boolean } but MarkChannelReadAck provides { ok: true; state: ChannelReadState } | { ok: false; error: string }. This works but you could use the full type to access state on success or log error on failure.

♻️ Use full ack type
+import type { ChannelReadState } from "@repo/realtime-types"
+
+type MarkReadAckResult = { ok: true; state: ChannelReadState } | { ok: false; error: string }
+
       socket.emit(
         "channel:mark-read",
         { channelId, lastReadMessageId },
-        (res: { ok: boolean }) => {
-          if (!res.ok && snapshot) {
+        (res: MarkReadAckResult) => {
+          if (!res.ok) {
+            console.warn("mark-read failed:", res.error)
+            if (snapshot) {
               const restore = snapshot
               setStateMap((prev) => {
                 const next = new Map(prev)
                 next.set(channelId, restore)
                 return next
               })
+            }
           }
         }
       )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/context/unread-context.tsx` at line 149, The ack callback
currently types the parameter as (res: { ok: boolean }) => ...; change that to
use the full MarkChannelReadAck union type (the exported type used for the
mark-read ack) so you can access success data or error details in the handler;
update the handler in unread-context (the arrow callback where res is typed) to
narrow on res.ok and use res.state on success and log or handle res.error on
failure.

Comment on lines +18 to +21
timerRef.current = setTimeout(() => {
if (channelIdRef.current) {
markChannelRead(channelIdRef.current)
}
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

Re-check visibility inside the debounce callback before marking read.

debouncedMarkRead() schedules while visible, but the tab can become hidden before the timer fires. In that case, unread can be cleared off-screen.

Proposed fix
     timerRef.current = setTimeout(() => {
-      if (channelIdRef.current) {
+      if (
+        channelIdRef.current &&
+        document.visibilityState === "visible"
+      ) {
         markChannelRead(channelIdRef.current)
       }
     }, DEBOUNCE_MS)
📝 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
timerRef.current = setTimeout(() => {
if (channelIdRef.current) {
markChannelRead(channelIdRef.current)
}
timerRef.current = setTimeout(() => {
if (
channelIdRef.current &&
document.visibilityState === "visible"
) {
markChannelRead(channelIdRef.current)
}
}, DEBOUNCE_MS)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/hooks/use-auto-mark-read.ts` around lines 18 - 21, The debounce
callback scheduled by debouncedMarkRead currently calls
markChannelRead(channelIdRef.current) without re-checking tab visibility; update
the timeout handler inside useAutoMarkRead (the function using timerRef.current
and channelIdRef.current) to first verify the page is still visible (e.g., check
document.visibilityState === 'visible' or an existing isVisibleRef) and only
then call markChannelRead, otherwise skip/cancel marking to avoid clearing
unread while the tab is hidden.

Comment on lines +18 to +19
if (!("Notification" in window)) return false
if (Notification.permission === "granted") return true
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

Guard window access for non-browser execution paths.

These functions assume window exists and can throw when invoked outside the browser runtime.

Proposed fix
 const isTauri = () =>
   typeof window !== "undefined" && "__TAURI_INTERNALS__" in window
+const isBrowser = () => typeof window !== "undefined"

 export async function requestNotificationPermission(): Promise<boolean> {
+  if (!isBrowser()) return false
   if (isTauri()) {
@@
 export function getNotificationPermissionSync():
   | "granted"
   | "denied"
   | "default" {
+  if (!isBrowser()) return "denied"
   if (isTauri()) return "default"
@@
 export async function getNotificationPermission(): Promise<
   "granted" | "denied" | "default"
 > {
+  if (!isBrowser()) return "denied"
   if (isTauri()) {
@@
 export async function showNotification(
@@
 ) {
+  if (!isBrowser()) return
   if (isTauri()) {

Also applies to: 31-32, 49-50, 78-79

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

In `@apps/web/src/lib/notification-dispatcher.ts` around lines 18 - 19, Guard
accesses to the browser globals by checking for their existence before using
them: replace direct uses like if (!("Notification" in window)) return false and
if (Notification.permission === "granted") return true with checks that first
ensure a browser environment (e.g. if (typeof window === "undefined" || typeof
Notification === "undefined") return false) and then safely read
Notification.permission (e.g. const perm = typeof Notification !== "undefined" ?
Notification.permission : "default";). Apply the same pattern for every
occurrence that touches window or Notification (the instances currently checking
"Notification" in window, Notification.permission, and any other direct
window/Notification access at the other noted locations).

Comment on lines +14 to +19
export const Route = createFileRoute("/check-email")({
component: CheckEmailPage,
validateSearch: (search: Record<string, unknown>) => ({
email: (search.email as string) ?? "",
}),
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider using Zod for search param validation.

The manual type assertion (search.email as string) bypasses type safety. TanStack Router supports Zod schemas for validateSearch.

♻️ Suggested refactor using Zod
+import { z } from "zod"
+
+const searchSchema = z.object({
+  email: z.string().default(""),
+})
+
 export const Route = createFileRoute("/check-email")({
   component: CheckEmailPage,
-  validateSearch: (search: Record<string, unknown>) => ({
-    email: (search.email as string) ?? "",
-  }),
+  validateSearch: searchSchema,
 })
📝 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
export const Route = createFileRoute("/check-email")({
component: CheckEmailPage,
validateSearch: (search: Record<string, unknown>) => ({
email: (search.email as string) ?? "",
}),
})
import { z } from "zod"
import { createFileRoute } from "@tanstack/react-router"
import { CheckEmailPage } from "../components/check-email"
const searchSchema = z.object({
email: z.string().default(""),
})
export const Route = createFileRoute("/check-email")({
component: CheckEmailPage,
validateSearch: searchSchema,
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/routes/check-email.tsx` around lines 14 - 19, The validateSearch
currently uses an unsafe assertion (search.email as string); replace it with a
Zod schema to validate and coerce the search params: import Zod (z), define a
schema like z.object({ email: z.string().email().optional().default("") }), and
use that schema inside the Route's validateSearch (or pass it directly if
createFileRoute/TanStack Router supports a schema) to parse/validate the
incoming search and return the typed { email } for CheckEmailPage instead of the
manual cast.

Comment on lines +38 to +47
mutationFn: async () => {
if (password !== confirmPassword) {
throw new Error("Passwords do not match")
}
const { error } = await authClient.resetPassword({
newPassword: password,
token,
})
if (error) throw new Error(error.message ?? "Failed to reset password")
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider validating password match before mutation.

The password mismatch check inside mutationFn creates a mutation error state for form validation. This works but could confuse error tracking/monitoring.

♻️ Move validation before mutation call
   const {
     mutate: resetPassword,
     isPending,
     isSuccess,
     error,
   } = useMutation({
     mutationFn: async () => {
-      if (password !== confirmPassword) {
-        throw new Error("Passwords do not match")
-      }
       const { error } = await authClient.resetPassword({
         newPassword: password,
         token,
       })
       if (error) throw new Error(error.message ?? "Failed to reset password")
     },
   })

+  const [localError, setLocalError] = useState<string | null>(null)
+
+  const handleSubmit = (e: FormEvent) => {
+    e.preventDefault()
+    setLocalError(null)
+    if (password !== confirmPassword) {
+      setLocalError("Passwords do not match")
+      return
+    }
+    resetPassword()
+  }

Then use localError || error?.message for display.

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

In `@apps/web/src/routes/reset-password.tsx` around lines 38 - 47, The password
mismatch check currently inside mutationFn (checking password vs
confirmPassword) should be moved out of mutationFn and performed before calling
the mutation so it becomes a client-side validation rather than a mutation
error; ensure you set a local error state (e.g., localError) when password !==
confirmPassword and only call authClient.resetPassword({ newPassword: password,
token }) from mutationFn when validation passes, then display errors using
localError || error?.message so client validation shows before any mutation
error.

Comment on lines +172 to +180
<p className="px-6 text-center text-xs text-muted-foreground">
By clicking continue, you agree to our{" "}
<span className="underline underline-offset-4 hover:text-primary">
Terms of Service
</span>{" "}
and{" "}
<span className="underline underline-offset-4 hover:text-primary">
Privacy Policy
</span>
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

Use real links for Terms/Privacy instead of styled spans.

These elements look clickable but are not focusable or navigable, which is an accessibility/UX issue. Replace them with <Link>/<a> targets.

🤖 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 172 - 180, The Terms/Privacy
text is rendered as non-focusable <span> elements, causing accessibility/UX
issues; replace those <span className="underline underline-offset-4
hover:text-primary">Terms of Service</span> and Privacy Policy spans in the
signup component's JSX with real interactive links (use your router's <Link> or
a semantic <a>), provide appropriate hrefs (to the Terms and Privacy routes or
external URLs), keep the existing classes for styling, and ensure the links are
keyboard-focusable and include target/rel if opening externally.

Comment on lines +141 to +163
async sendResetPassword({ user, url }) {
resend.emails
.send({
from: env.EMAIL_FROM,
to: user.email,
subject: "Reset your Townhall password",
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 32px 0;">
<h2 style="margin: 0 0 8px; font-size: 24px; color: #1a1a1a;">Reset Your Password</h2>
<p style="margin: 0 0 24px; color: #555; font-size: 16px; line-height: 1.5;">Click the button below to reset your password.</p>
<a href="${url}" style="display: inline-block; background: #8B6914; color: #fff; padding: 12px 28px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 16px;">Reset Password</a>
<p style="color: #999; font-size: 13px; margin-top: 24px; line-height: 1.4;">If you didn't request a password reset, you can safely ignore this email.</p>
</div>
`,
})
.then(({ data, error }) => {
if (error) {
console.error("Failed to send reset password email:", error)
} else {
console.log("Reset password email sent:", data?.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 | 🟠 Major

sendResetPassword silently fails on email errors.

Unlike sendVerificationEmail which awaits and handles errors, this function uses fire-and-forget (.then()). If the email fails, the user sees success but never receives the reset link.

Consider awaiting the send operation and propagating errors, or at minimum ensuring the user experience handles this edge case.

🛠️ Suggested fix to await and propagate errors
-    async sendResetPassword({ user, url }) {
-      resend.emails
-        .send({
+    async sendResetPassword({ user, url }) {
+      try {
+        const { error } = await resend.emails.send({
           from: env.EMAIL_FROM,
           to: user.email,
           subject: "Reset your Townhall password",
           html: `...`,
         })
-        .then(({ data, error }) => {
-          if (error) {
-            console.error("Failed to send reset password email:", error)
-          } else {
-            console.log("Reset password email sent:", data?.id)
-          }
-        })
+        if (error) {
+          console.error("Failed to send reset password email:", error)
+          throw new Error("Failed to send reset password email")
+        }
+      } catch (err) {
+        console.error("Exception sending reset password email:", err)
+        throw err
+      }
     },
📝 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
async sendResetPassword({ user, url }) {
resend.emails
.send({
from: env.EMAIL_FROM,
to: user.email,
subject: "Reset your Townhall password",
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 32px 0;">
<h2 style="margin: 0 0 8px; font-size: 24px; color: #1a1a1a;">Reset Your Password</h2>
<p style="margin: 0 0 24px; color: #555; font-size: 16px; line-height: 1.5;">Click the button below to reset your password.</p>
<a href="${url}" style="display: inline-block; background: #8B6914; color: #fff; padding: 12px 28px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 16px;">Reset Password</a>
<p style="color: #999; font-size: 13px; margin-top: 24px; line-height: 1.4;">If you didn't request a password reset, you can safely ignore this email.</p>
</div>
`,
})
.then(({ data, error }) => {
if (error) {
console.error("Failed to send reset password email:", error)
} else {
console.log("Reset password email sent:", data?.id)
}
})
},
async sendResetPassword({ user, url }) {
try {
const { error } = await resend.emails.send({
from: env.EMAIL_FROM,
to: user.email,
subject: "Reset your Townhall password",
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 32px 0;">
<h2 style="margin: 0 0 8px; font-size: 24px; color: `#1a1a1a`;">Reset Your Password</h2>
<p style="margin: 0 0 24px; color: `#555`; font-size: 16px; line-height: 1.5;">Click the button below to reset your password.</p>
<a href="${url}" style="display: inline-block; background: `#8B6914`; color: `#fff`; padding: 12px 28px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 16px;">Reset Password</a>
<p style="color: `#999`; font-size: 13px; margin-top: 24px; line-height: 1.4;">If you didn't request a password reset, you can safely ignore this email.</p>
</div>
`,
})
if (error) {
console.error("Failed to send reset password email:", error)
throw new Error("Failed to send reset password email")
}
} catch (err) {
console.error("Exception sending reset password email:", err)
throw err
}
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/auth/src/lib/auth.ts` around lines 141 - 163, The sendResetPassword
function currently uses a fire-and-forget .then() on resend.emails.send which
silences failures; change sendResetPassword to await the promise returned by
resend.emails.send (like sendVerificationEmail does), catch and rethrow or
return the error so callers can handle it, and replace the .then() logging with
proper try/catch around resend.emails.send to log and propagate failures from
sendResetPassword.

Comment thread packages/env/src/server.ts Outdated
Comment on lines +45 to +46
RESEND_API_KEY: z.string().min(1),
EMAIL_FROM: z.string().default("Townhall <team@townhall.chat>"),
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:

#!/bin/bash
# Find all apps that import `@repo/env/server`
rg -l '@repo/env/server' --type ts -g '!*.d.ts' apps/

Repository: BuckyMcYolo/townhall

Length of output: 229


🏁 Script executed:

# Check if RESEND_API_KEY is referenced in any of the importing apps
rg 'RESEND_API_KEY|EMAIL_FROM' apps/worker apps/realtime apps/api --type ts -g '!*.d.ts'

Repository: BuckyMcYolo/townhall

Length of output: 46


🏁 Script executed:

# Find where RESEND_API_KEY and EMAIL_FROM are used in the codebase
rg 'RESEND_API_KEY|EMAIL_FROM' --type ts -g '!*.d.ts' -A 2

Repository: BuckyMcYolo/townhall

Length of output: 1059


🏁 Script executed:

# Check the packages/env/src/server.ts file to understand the validation schema
cat -n packages/env/src/server.ts | head -100

Repository: BuckyMcYolo/townhall

Length of output: 2432


🏁 Script executed:

# Check packages/auth to see if it uses these new fields
rg 'RESEND_API_KEY|EMAIL_FROM|Resend' packages/auth --type ts -g '!*.d.ts' -B 2 -A 2

Repository: BuckyMcYolo/townhall

Length of output: 1919


🏁 Script executed:

# Check if packages/auth is imported by the apps
rg 'packages/auth|@repo/auth' apps/worker apps/realtime apps/api --type ts -g '!*.d.ts' | head -20

Repository: BuckyMcYolo/townhall

Length of output: 842


🏁 Script executed:

# Check how packages/auth is exported and if it's forced to load
cat -n packages/auth/src/index.ts | head -50

Repository: BuckyMcYolo/townhall

Length of output: 122


🏁 Script executed:

# Check what happens at the top level of these apps - do they import auth?
head -30 apps/worker/src/index.ts
head -30 apps/realtime/src/index.ts  
head -30 apps/api/src/index.ts

Repository: BuckyMcYolo/townhall

Length of output: 2427


🏁 Script executed:

# Find where `@repo/auth` package is configured
find packages -name "package.json" -type f -exec grep -l '"name".*auth' {} \;

Repository: BuckyMcYolo/townhall

Length of output: 90


🏁 Script executed:

# Find the actual auth entry point
find packages -path "*/auth/*" -name "index.ts" -o -path "*/auth/*" -name "index.js"

Repository: BuckyMcYolo/townhall

Length of output: 46


🏁 Script executed:

# Check the monorepo structure to understand how packages are resolved
cat -n pnpm-workspace.yaml 2>/dev/null || cat -n tsconfig.json | head -50

Repository: BuckyMcYolo/townhall

Length of output: 124


🏁 Script executed:

# Check the auth package.json entry point
cat packages/auth/package.json

Repository: BuckyMcYolo/townhall

Length of output: 673


🏁 Script executed:

# Look for where auth is actually exported
rg 'export.*auth|export.*from' packages/auth --type ts -g '!*.d.ts' | head -20

Repository: BuckyMcYolo/townhall

Length of output: 580


🏁 Script executed:

# Verify the exact startup behavior - when does env validation happen?
rg 'serverSchema.parse|export const env' packages/env --type ts -g '!*.d.ts'

Repository: BuckyMcYolo/townhall

Length of output: 208


Making RESEND_API_KEY required breaks deployment for apps that don't use email.

The env schema validates at import time (export const env = serverSchema.parse(process.env)). Making RESEND_API_KEY a required field will cause apps/worker to fail at startup, even though it doesn't use email functionality and never imports @repo/auth. The apps/realtime and apps/api servers also import the env but may not always require email configuration depending on deployment setup.

Consider making RESEND_API_KEY optional with .optional(), or split email-related fields into a separate schema that's only validated where needed.

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

In `@packages/env/src/server.ts` around lines 45 - 46, The server env schema
currently requires RESEND_API_KEY which causes serverSchema.parse(process.env)
to fail on services that don't use email; update the schema so RESEND_API_KEY is
not mandatory (e.g., change RESEND_API_KEY: z.string().min(1) to RESEND_API_KEY:
z.string().min(1).optional()) or refactor by extracting email fields
(RESEND_API_KEY and EMAIL_FROM) into a separate emailSchema and only
parse/validate that schema in modules that actually send email (where
RESEND_API_KEY is needed) while keeping serverSchema.parse(process.env)
non-blocking for non-email apps.

Comment thread turbo.json
{
"$schema": "https://turborepo.dev/schema.json",
"ui": "tui",
"ui": "stream-with-experimental-timestamps",
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check Turborepo version and verify UI configuration options

# Check package.json files for turborepo version
echo "=== Turborepo version in package.json files ==="
fd -t f '^package\.json$' --exec cat {} \; | jq -r 'select(.devDependencies.turbo or .dependencies.turbo) | {turbo: (.devDependencies.turbo // .dependencies.turbo)}'

# Search for any documentation or comments about this UI option
echo -e "\n=== References to UI configuration in codebase ==="
rg -n "stream-with-experimental-timestamps" -C 2

Repository: BuckyMcYolo/townhall

Length of output: 379


🌐 Web query:

What are the valid UI configuration options for Turborepo, and is "stream-with-experimental-timestamps" a stable feature?

💡 Result:

The valid UI configuration options for Turborepo are "stream" (default, non-interactive streamed logs) and "tui" (interactive terminal UI for viewing and interacting with task logs). These can be set in turbo.json under the "ui" key, via the TURBO_UI environment variable, or the --ui CLI flag. "stream-with-experimental-timestamps" is not a valid or documented stable UI option in official Turborepo documentation. It appears in the Turborepo source code (turborepo-types/src/lib.rs) as an enum variant (StreamWithTimestamps), suggesting it may be an internal or experimental feature added in a canary release around late 2024/early 2025 (e.g., v2.7.6-canary.2 changelog mentions "feat: Add stream-with-experimental-timestamps UI mode"). However, it is not listed in current official docs or stable releases as of 2026-03-29, so it is not stable.

Citations:


Use a documented, stable UI mode instead of the experimental feature.

stream-with-experimental-timestamps is not an officially documented or stable Turborepo UI configuration option. While it appears to exist in canary releases, it is not listed in official documentation and remains experimental. Turborepo v2.8.3 (currently in use) supports the stable options "stream" (default, non-interactive) and "tui" (interactive terminal UI). Switch to one of these documented options to avoid potential breaking changes or unexpected behavior in future Turborepo updates.

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

In `@turbo.json` at line 3, The Turborepo configuration uses an undocumented
experimental UI value ("stream-with-experimental-timestamps") for the "ui" key;
update that "ui" value to a documented, stable option such as "stream"
(non-interactive) or "tui" (interactive) to avoid breaking changes—locate the
"ui" entry in the turbo.json (the "ui" key and its current value) and replace
the experimental string with either "stream" or "tui" depending on desired
behavior.

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: 6

♻️ Duplicate comments (2)
apps/realtime/src/index.ts (1)

285-299: ⚠️ Potential issue | 🟠 Major

Unread bootstrap still has a race window that can drop updates.

Between Line 288 and Line 299, live unread events that occur after the DB snapshot but before socket.join(userPresenceRoom) are lost for this socket. This can leave unread counts stale until another event arrives. Please add a cursor/version protocol (bootstrap + live unread/mention events) so stale bootstraps are ignored while still avoiding missed events.

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

In `@apps/realtime/src/index.ts` around lines 285 - 299, The bootstrap snapshot
from getUnreadStatesForUser and the subsequent
socket.emit("notification:bootstrap", ...) can race with live unread/mention
events sent after the DB read but before socket.join(userPresenceRoom); fix by
adding a monotonic cursor/version: have getUnreadStatesForUser return a snapshot
plus a cursor/version token, include that cursor in the bootstrap payload
emitted to the client, store it on the socket (e.g., socket.data.unreadCursor),
and ensure all live events emitted to the room (notification:unread,
notification:mention, etc.) also carry their cursor/version so the client or
server-side delivery logic can ignore any event with cursor <= bootstrap cursor;
update producers of live unread events to attach the same cursor/version
semantics and compare against socket.data.unreadCursor when deciding to deliver
to this socket.
packages/env/src/server.ts (1)

45-46: ⚠️ Potential issue | 🟠 Major

Don't make the shared env parse depend on Resend.

Line 49 parses serverSchema eagerly. Requiring RESEND_API_KEY here forces every process that imports @repo/env/server to define a mail secret, even if that service never sends email. Keep it optional in this shared schema and validate it next to new Resend(...) instead.

🛠️ Suggested change
-  RESEND_API_KEY: z.string().min(1),
+  RESEND_API_KEY: z.string().min(1).optional(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/env/src/server.ts` around lines 45 - 46, The shared serverSchema
currently requires RESEND_API_KEY and is parsed eagerly, forcing every importer
of `@repo/env/server` to provide that secret; change serverSchema to make
RESEND_API_KEY optional (or remove it from the required fields) and stop
depending on its presence during the shared parse; then perform a dedicated
validation (e.g., assert or zod parse) for RESEND_API_KEY right where you
construct the Resend client (the new Resend(...) call) so only services that
instantiate Resend must supply the key.
🤖 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/web/src/components/onboarding/onboarding-dialog.tsx`:
- Around line 51-56: The initial step state uses hasUsername computed from
authClient.useSession() at mount, but never updates when session hydrates; add
an effect that watches session (from authClient.useSession()) and recomputes
hasUsername (using MIN_USERNAME_LENGTH) and calls setStep("welcome") when a
valid username exists (and optionally setStep("username") if it becomes invalid)
to keep step in sync with session hydration; reference the existing symbols
session, hasUsername, step, setStep, MIN_USERNAME_LENGTH and implement the logic
inside a useEffect that depends on session.
- Around line 67-109: The username availability flow can be overwritten by stale
network responses; update handleUsernameChange to use a monotonic request ID
(e.g., a useRef like usernameCheckRequestId) that you increment each time you
schedule/check availability, capture the current ID in the async timeout
callback before calling authClient.isUsernameAvailable, and only update
usernameAvailability when the captured ID still matches the ref (ignore response
if it doesn't). Also ensure the request ID ref is reset/managed on clearTimeout
and component unmount so in-flight or old timers cannot flip state incorrectly.

In `@apps/web/src/hooks/use-browser-notifications.ts`:
- Around line 53-61: In onUnread, settings is already guarded by the earlier if
(!settings) return, so remove the unnecessary optional chaining on
settings?.dmNotifications and use settings.dmNotifications for consistency with
onMention; update the conditional that checks payload.guildId === null and
settings?.dmNotifications === "nothing" to reference settings.dmNotifications
instead.

In `@apps/web/src/routes/_authenticated/`$guildSlug/$channelId.tsx:
- Line 19: A pending timeout in useAutoMarkRead can call markChannelRead for the
wrong channel after navigation because channelIdRef is read inside the delayed
callback; fix by capturing the current channelId in the closure when scheduling
the timeout (e.g., const id = channelId) and ensure any existing timer is
cleared on channel change/unmount (clearTimeout(timerRef.current)) before
setting a new one so the delayed callback always uses the captured id and cannot
mark a different channel; update functions/refs in useAutoMarkRead
(channelIdRef, timerRef, markChannelRead) accordingly.

In `@apps/web/src/routes/login.tsx`:
- Around line 39-55: The mutation currently treats the 403 path as success
because mutationFn returns normally; update mutationFn (the async function
passed to mutationFn that calls authClient.signIn.email) to return a
discriminator object like { needsVerification: true } when error.status === 403
instead of just navigating and returning, and then update onSuccess to accept
the mutation result and only call navigate({ to: "/" }) when result is not {
needsVerification: true } (otherwise navigate to "/check-email" there or do
nothing); alternatively you can throw a specific error from mutationFn for the
403 case so onSuccess won't run—reference mutationFn, authClient.signIn.email,
and onSuccess/navigate when making the change.

In `@packages/auth/src/lib/auth.ts`:
- Around line 170-171: The log in auth.ts that calls console.error(`[TOWNHALL
EMAIL] Sending verification email to ${user.email} from ${env.EMAIL_FROM}`)
leaks PII; change it to avoid printing user.email — either log a non-PII
identifier (e.g., user.id or a generated messageId) or redact the address (e.g.,
use user.email.replace(/(.+)@(.+)/,'***@***')). Update the console.error call to
reference the chosen non-PII symbol and keep env.EMAIL_FROM if needed, ensuring
the log message still conveys context without exposing the full email.

---

Duplicate comments:
In `@apps/realtime/src/index.ts`:
- Around line 285-299: The bootstrap snapshot from getUnreadStatesForUser and
the subsequent socket.emit("notification:bootstrap", ...) can race with live
unread/mention events sent after the DB read but before
socket.join(userPresenceRoom); fix by adding a monotonic cursor/version: have
getUnreadStatesForUser return a snapshot plus a cursor/version token, include
that cursor in the bootstrap payload emitted to the client, store it on the
socket (e.g., socket.data.unreadCursor), and ensure all live events emitted to
the room (notification:unread, notification:mention, etc.) also carry their
cursor/version so the client or server-side delivery logic can ignore any event
with cursor <= bootstrap cursor; update producers of live unread events to
attach the same cursor/version semantics and compare against
socket.data.unreadCursor when deciding to deliver to this socket.

In `@packages/env/src/server.ts`:
- Around line 45-46: The shared serverSchema currently requires RESEND_API_KEY
and is parsed eagerly, forcing every importer of `@repo/env/server` to provide
that secret; change serverSchema to make RESEND_API_KEY optional (or remove it
from the required fields) and stop depending on its presence during the shared
parse; then perform a dedicated validation (e.g., assert or zod parse) for
RESEND_API_KEY right where you construct the Resend client (the new Resend(...)
call) so only services that instantiate Resend must supply the key.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3dded95b-27e6-49e5-9eee-ed70542c21d1

📥 Commits

Reviewing files that changed from the base of the PR and between d143917 and 57ae720.

📒 Files selected for processing (9)
  • apps/realtime/src/index.ts
  • apps/web/src/components/onboarding/onboarding-dialog.tsx
  • apps/web/src/hooks/use-browser-notifications.ts
  • apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx
  • apps/web/src/routes/check-email.tsx
  • apps/web/src/routes/login.tsx
  • apps/web/src/routes/signup.tsx
  • packages/auth/src/lib/auth.ts
  • packages/env/src/server.ts

Comment on lines +51 to +56
const { data: session } = authClient.useSession()
const hasUsername = !!(
session?.user?.username &&
session.user.username.length >= MIN_USERNAME_LENGTH
)
const [step, setStep] = useState<Step>(hasUsername ? "welcome" : "username")
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:

cat -n apps/web/src/components/onboarding/onboarding-dialog.tsx | head -130

Repository: BuckyMcYolo/townhall

Length of output: 4917


🏁 Script executed:

# Search for authClient.useSession usage pattern in the codebase
rg "useSession" --type ts --type tsx -B 2 -A 2 | head -50

Repository: BuckyMcYolo/townhall

Length of output: 91


🏁 Script executed:

# Find the authClient implementation to understand useSession behavior
fd -t f -name "*auth*" | head -20

Repository: BuckyMcYolo/townhall

Length of output: 294


🏁 Script executed:

# Find authClient definition
rg "export.*useSession|function useSession" --type ts --type tsx

Repository: BuckyMcYolo/townhall

Length of output: 91


🏁 Script executed:

# Read more of the component to see all useEffect hooks
tail -n +73 apps/web/src/components/onboarding/onboarding-dialog.tsx | head -200

Repository: BuckyMcYolo/townhall

Length of output: 6010


🏁 Script executed:

# Search for authClient package location
fd "auth" | grep -E "(client|index)" | head -20

Repository: BuckyMcYolo/townhall

Length of output: 100


🏁 Script executed:

cat packages/auth/src/lib/auth-client.ts

Repository: BuckyMcYolo/townhall

Length of output: 874


🏁 Script executed:

# Search for any useEffect that might sync step with session or hasUsername
grep -n "useEffect\|step\|session" apps/web/src/components/onboarding/onboarding-dialog.tsx | head -40

Repository: BuckyMcYolo/townhall

Length of output: 597


🏁 Script executed:

# Check if there's any other handling of session changes for step
rg "setStep" apps/web/src/components/onboarding/onboarding-dialog.tsx

Repository: BuckyMcYolo/townhall

Length of output: 351


🏁 Script executed:

# Check the full component for any conditional step logic after mount
sed -n '50,150p' apps/web/src/components/onboarding/onboarding-dialog.tsx | cat -n

Repository: BuckyMcYolo/townhall

Length of output: 3850


Sync step after session hydration.

When authClient.useSession() hydrates after mount, hasUsername recalculates on re-render but step remains at its initialized value. Users with existing usernames stay on the "username" step instead of advancing to "welcome", causing unwanted rename prompts.

Suggested fix
 const [step, setStep] = useState<Step>(hasUsername ? "welcome" : "username")
+
+ useEffect(() => {
+   if (hasUsername) {
+     setStep((current) => (current === "username" ? "welcome" : current))
+   }
+ }, [hasUsername])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/onboarding/onboarding-dialog.tsx` around lines 51 -
56, The initial step state uses hasUsername computed from
authClient.useSession() at mount, but never updates when session hydrates; add
an effect that watches session (from authClient.useSession()) and recomputes
hasUsername (using MIN_USERNAME_LENGTH) and calls setStep("welcome") when a
valid username exists (and optionally setStep("username") if it becomes invalid)
to keep step in sync with session hydration; reference the existing symbols
session, hasUsername, step, setStep, MIN_USERNAME_LENGTH and implement the logic
inside a useEffect that depends on session.

Comment on lines +67 to +109
const [username, setUsername] = useState("")
const [usernameAvailability, setUsernameAvailability] = useState<
"idle" | "checking" | "available" | "taken" | "invalid"
>("idle")
const usernameCheckTimer = useRef<ReturnType<typeof setTimeout> | null>(null)

useEffect(() => {
return () => {
if (usernameCheckTimer.current) clearTimeout(usernameCheckTimer.current)
}
}, [])

const handleUsernameChange = useCallback((value: string) => {
setUsername(value)
if (usernameCheckTimer.current) clearTimeout(usernameCheckTimer.current)

const trimmed = value.trim()
if (!trimmed) {
setUsernameAvailability("idle")
return
}
if (
trimmed.length < MIN_USERNAME_LENGTH ||
trimmed.length > MAX_USERNAME_LENGTH ||
!USERNAME_REGEX.test(trimmed)
) {
setUsernameAvailability("invalid")
return
}

setUsernameAvailability("checking")
usernameCheckTimer.current = setTimeout(async () => {
try {
const { data } = await authClient.isUsernameAvailable({
username: trimmed,
})
setUsernameAvailability((prev) =>
prev === "checking" ? (data?.available ? "available" : "taken") : prev
)
} catch {
setUsernameAvailability((prev) => (prev === "checking" ? "idle" : prev))
}
}, 500)
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:

fd -t f "onboarding-dialog.tsx" --full-path

Repository: BuckyMcYolo/townhall

Length of output: 120


🏁 Script executed:

wc -l apps/web/src/components/onboarding/onboarding-dialog.tsx

Repository: BuckyMcYolo/townhall

Length of output: 124


🏁 Script executed:

sed -n '60,120p' apps/web/src/components/onboarding/onboarding-dialog.tsx | cat -n

Repository: BuckyMcYolo/townhall

Length of output: 2417


🏁 Script executed:

sed -n '67,109p' apps/web/src/components/onboarding/onboarding-dialog.tsx | cat -n

Repository: BuckyMcYolo/townhall

Length of output: 1711


🏁 Script executed:

head -20 apps/web/src/components/onboarding/onboarding-dialog.tsx

Repository: BuckyMcYolo/townhall

Length of output: 775


🏁 Script executed:

sed -n '67,109p' apps/web/src/components/onboarding/onboarding-dialog.tsx

Repository: BuckyMcYolo/townhall

Length of output: 1410


🏁 Script executed:

cat -n apps/web/src/components/onboarding/onboarding-dialog.tsx | sed -n '100,110p'

Repository: BuckyMcYolo/townhall

Length of output: 503


🏁 Script executed:

sed -n '98,110p' apps/web/src/components/onboarding/onboarding-dialog.tsx | cat -A

Repository: BuckyMcYolo/townhall

Length of output: 509


Ignore stale availability responses.

clearTimeout only cancels checks not yet started. After the network request at line 100 is in flight, an older response can still resolve while a newer username is also "checking", because lines 103-107 guard off the status rather than the specific request. This can display the wrong availability state and briefly enable submit for an unchecked username.

The suggested fix using a request ID correctly prevents stale responses from overwriting newer ones.

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

In `@apps/web/src/components/onboarding/onboarding-dialog.tsx` around lines 67 -
109, The username availability flow can be overwritten by stale network
responses; update handleUsernameChange to use a monotonic request ID (e.g., a
useRef like usernameCheckRequestId) that you increment each time you
schedule/check availability, capture the current ID in the async timeout
callback before calling authClient.isUsernameAvailable, and only update
usernameAvailability when the captured ID still matches the ref (ignore response
if it doesn't). Also ensure the request ID ref is reset/managed on clearTimeout
and component unmount so in-flight or old timers cannot flip state incorrectly.

Comment thread apps/web/src/hooks/use-browser-notifications.ts
import { TypingIndicator } from "@/components/chat/typing-indicator"
import { useRightSidebar } from "@/components/sidebar/right-panel/right-sidebar-context"
import { useSocket } from "@/context/socket-context"
import { useAutoMarkRead } from "@/hooks/use-auto-mark-read"
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

Pending debounce from previous channel can mark the next channel as read

Line 57 wires useAutoMarkRead(channelId), but apps/web/src/hooks/use-auto-mark-read.ts currently uses a mutable channelIdRef inside the timeout callback. A timer scheduled in channel A can fire after navigation and call markChannelRead for channel B, causing incorrect unread state.

Proposed fix (in apps/web/src/hooks/use-auto-mark-read.ts)
- const channelIdRef = useRef(channelId)
- channelIdRef.current = channelId
-
- const debouncedMarkRead = () => {
-   if (!channelIdRef.current) return
+ const debouncedMarkRead = (targetChannelId: string) => {
    if (timerRef.current) clearTimeout(timerRef.current)
    timerRef.current = setTimeout(() => {
-     if (channelIdRef.current) {
-       markChannelRead(channelIdRef.current)
-     }
+     markChannelRead(targetChannelId)
    }, DEBOUNCE_MS)
  }

  useEffect(() => {
    if (!channelId) return
    if (document.visibilityState !== "visible") return
-   debouncedMarkRead()
+   debouncedMarkRead(channelId)
    return () => {
      if (timerRef.current) clearTimeout(timerRef.current)
    }
  }, [channelId])

  useEffect(() => {
    if (!channelId) return
    const onVisibilityChange = () => {
      if (document.visibilityState === "visible") {
-       debouncedMarkRead()
+       debouncedMarkRead(channelId)
      }
    }
    document.addEventListener("visibilitychange", onVisibilityChange)
    return () => document.removeEventListener("visibilitychange", onVisibilityChange)
  }, [channelId])

  useEffect(() => {
    if (!socket || !channelId) return
    const onMessageCreated = (message: RealtimeMessage) => {
-     if (message.channelId === channelIdRef.current && document.visibilityState === "visible") {
-       debouncedMarkRead()
+     if (message.channelId === channelId && document.visibilityState === "visible") {
+       debouncedMarkRead(channelId)
      }
    }
    socket.on("message:created", onMessageCreated)
    return () => socket.off("message:created", onMessageCreated)
  }, [socket, channelId])

Also applies to: 57-57

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

In `@apps/web/src/routes/_authenticated/`$guildSlug/$channelId.tsx at line 19, A
pending timeout in useAutoMarkRead can call markChannelRead for the wrong
channel after navigation because channelIdRef is read inside the delayed
callback; fix by capturing the current channelId in the closure when scheduling
the timeout (e.g., const id = channelId) and ensure any existing timer is
cleared on channel change/unmount (clearTimeout(timerRef.current)) before
setting a new one so the delayed callback always uses the captured id and cannot
mark a different channel; update functions/refs in useAutoMarkRead
(channelIdRef, timerRef, markChannelRead) accordingly.

Comment thread apps/web/src/routes/login.tsx
Comment thread packages/auth/src/lib/auth.ts Outdated
@BuckyMcYolo BuckyMcYolo merged commit e92c1f2 into main Mar 31, 2026
@coderabbitai coderabbitai Bot mentioned this pull request Apr 9, 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