## Summary
Aerie's Rhodes-style site events (dri.assigned, milestone.completed, note.mention) were log-only: they wrote audit rows into notificationDispatchLog and never reached a user. This PR adds an Aerie-native notification system — an in-app inbox surfaced from a bell in the global shell, per-user/per-channel preferences, site subscriptions, and a Google Chat delivery channel built on the existing Rhodes Chat app credentials.
The design follows an outbox event → fanout → delivery split. Producer mutations enqueue a small notificationEvents row and return; an async processor resolves and de-dupes recipients (direct recipients plus active site subscribers), applies preferences, writes notifications inbox rows, updates bounded per-user inbox state, and enqueues notificationDeliveries for enabled external channels. Delivery dispatch uses an indexed queue state (ready / leased / terminal) with a 5-minute cron backstop so retries do not scan terminal rows. Missing or unverified Chat setup is recorded as non-retryable blocked_config rather than looping forever. Actor self-notifications are intentionally allowed and de-duped like any other recipient.
### Testing
Currently, in the [Rhodes GChat Config](https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat?project=locationos-487316&supportedpurview=project) the
HTTP endpoint URL points to https://quiet-shrimp-950.convex.site/sync/google-chat/events, which is my Aerie Convex Dev environment. It will need to be switched with https://oceanic-pika-463.convex.site/sync/google-chat/events after prod is deployed.
GOOGLE_CHAT_PROJECT_NUMBER and GOOGLE_CHAT_SERVICE_ACCOUNT_KEY need to be in your Convex environment variables, accessible here: [Convex Dev](https://dashboard.convex.dev/t/ai-builder-team/bran-chat/quiet-shrimp-950/settings/environment-variables)
First-time Google Chat setup: in Google Chat, start a chat with the "Rhodes" app and send Link. This creates the verified Chat identity/DM-space connection needed for delivery.
<img width="296" height="98" alt="Screenshot 2026-06-16 at 6 57 55 PM" src="https://github.com/user-attachments/assets/831e0733-e0a2-4787-8107-c2f5974fbe59" />
1. @mention yourself in a note on Alpha School Test Site 59. (e.g. : Add a note to Alpha School Test Site 60 that mentions: \@Yibin Long HVAC Broken)
2. Assign yourself p1Dri/p2Dri
3. Complete a milestone for a site in which you're the p1Dri
All 3 should result in notifications on both GChat and in-app notifications
### Screenshots
<img width="1233" height="541" alt="Screenshot 2026-06-16 at 6 52 41 PM" src="https://github.com/user-attachments/assets/7f574a0e-c287-4ca0-88c6-3a9ff45f3484" />
<img width="430" height="528" alt="Screenshot 2026-06-16 at 6 52 52 PM" src="https://github.com/user-attachments/assets/2013c2e7-5fbb-4a91-b4f5-17f2fa2649e7" />
<img width="717" height="483" alt="Screenshot 2026-06-16 at 6 58 36 PM" src="https://github.com/user-attachments/assets/43275236-ee37-4422-aca0-b9b571c7287f" />
<img width="1004" height="275" alt="Screenshot 2026-06-16 at 7 06 00 PM" src="https://github.com/user-attachments/assets/c03e6015-ef9c-4227-8f49-705ed0cb40da" />
### Changes
Schema
- chat/convex/notifications/schema.ts *(new)* — notifications (inbox), notificationInboxState (bounded unread count/read watermark), notificationEvents (transactional outbox), notificationDeliveries (per-channel attempts + indexed queue state), notificationPreferences, siteNotificationSubscriptions, and verified notificationChannelConnections.
- chat/convex/schema.ts — Registers notificationsSchema in the root schema.
Backend — inbox, preferences, subscriptions
- chat/convex/notifications/inbox.ts *(new)* — Current-user queries/mutations: listMine (All/Unread/Archived), unreadCount, markRead, markAllRead, archive, unarchive. All scoped to the caller.
- chat/convex/notifications/preferences.ts *(new)* — listMine / setPreference, with in-app and Google Chat defaulting to enabled when no row exists.
- chat/convex/notifications/siteSubscriptions.ts *(new)* — Subscribe/unsubscribe/list for the current user's site subscriptions.
- chat/convex/notifications/channelConnections.ts *(new)* — Current-user Google Chat connection status plus internal verified binding from signed Google Chat interactions.
Backend — events & delivery
- chat/convex/notifications/events.ts *(new)* — enqueueNotificationEvent outbox + processor: recipient resolution/de-dupe, preference gating, inbox state updates, inbox row creation, delivery row creation, and dispatch scheduling.
- chat/convex/notifications/dispatch.ts *(new)* — processPending dispatcher: bounded batch over due deliveries, Google Chat adapter (service-account auth, verified DM-space usage/discovery/caching, message send), bounded retry with backoff, and blocked_config/retryable: false for user-actionable config failures.
- chat/convex/notifications/dispatchState.ts *(new)* — Indexed delivery queue helpers (ready / leased / terminal), leasing, context reads that require verified Chat connections, and result recording.
- chat/convex/notifications/googleChatInteractions.ts *(new)* — Signed Google Chat/Workspace add-on interaction endpoint. Users connect delivery by messaging Rhodes Link; the handler verifies Google, extracts chat.user, and stores the verified Chat user/DM-space connection.
- chat/convex/crons.ts — Adds 5-minute fallback drains for notification event processing and external delivery dispatch.
- chat/convex/_generated/api.d.ts — Regenerated to register the new notifications/* modules.
Producers
- chat/convex/rhodes/runtime/writes/siteWrites.ts — Enqueues dri.assigned on DRI assignment and milestone.completed on milestone completion.
- chat/convex/rhodes/runtime/writes/noteWrites.ts — Enqueues note.mention for mentioned users.
- chat/convex/rhodes/dashboard.ts — Enqueues dri.assigned / milestone.completed on the dashboard field-edit paths that mirror the runtime writes.
UI
- chat/components/notifications/notification-bell.tsx *(new)* — Bell with unread badge and a popover (All/Unread/Archived tabs, mark-all-read, per-row read/navigate/archive, inline preferences). Queries are skipped until Convex auth is ready.
- chat/components/notifications/site-subscription-button.tsx *(new)* — Subscribe/unsubscribe control for a site.
- chat/components/shell/top-bar.tsx / mobile-top-bar.tsx — Mount the bell before the theme picker in both shells.
- chat/components/dashboards/portfolio/site-detail-page.tsx — Adds the subscription button to the site header.
Tests
- chat/convex/notifications/notifications.test.ts *(new)* — Event outbox/fanout, preference gating, subscriber resolution/de-dupe, actor self-notifications, read/archive scoping, inbox-state mark-all-read, verified Google Chat binding, and dispatcher success/failure/blocked/retry behavior.
- chat/components/notifications/__tests__/* *(new)* — Bell rendering/tabs/badge/auth-skip and subscription-button behavior.
- chat/components/shell/__tests__/top-bar.test.tsx / mobile-top-bar.test.tsx — Assert the bell renders before the picker in both shells.
### Design Decisions
- New inbox tables, not notificationDispatchLog — The legacy log is audit-shaped (no createdAt/readAt/archivedAt, no unread indexes), so it stays for audit and the new notifications table becomes the inbox source of truth. No historical rows are migrated.
- Enqueue alongside, not replacing, the audit log — Producers keep calling logNotificationDispatch and additionally enqueue notification events, so audit behavior is unchanged and the new path is additive.
- Google Chat identity is verified, not client-supplied — Browser code cannot bind arbitrary Chat IDs. A user must message Rhodes Link; the signed Google Chat interaction creates a verified connection row. GChat delivery requires that one-time setup.
- Actor self-notifications are allowed — If the actor is also the direct recipient or an active site subscriber, they remain in the recipient set. This supports self-mentions and lets users verify actions they triggered.
- blocked_config is non-retryable — A user who has not completed the one-time Rhodes Link setup has no verified DM space; that's a user-actionable config gap, recorded once rather than retried into an infinite failure loop.
- Outbox + cron backstops — Product writes enqueue notification events rather than doing full fanout inline. Event processing and delivery dispatch are both kicked quickly and backed by 5-minute crons. A failed external delivery never rolls back the product mutation or the in-app row.
- Defaults without preference rows — In-app and Google Chat default to ON, so no backfill of preference rows is required for existing users.
## Test Plan
- [x] pnpm biome check clean on changed files (lefthook pre-commit, every commit)
- [x] typecheck-chat passes (lefthook ran tsc -p chat/tsconfig.json on every commit)
- [x] check-convex-paths passes
- [ ] pnpm --filter @bran/chat test — notifications, top-bar, and notification component suites
- [ ] Manual: assign a DRI / complete a milestone / mention a user, confirm the recipient gets an in-app notification and the badge increments
- [ ] Manual: subscribe to a site as a non-DRI and confirm relevant site notifications arrive
- [ ] Manual: after messaging Rhodes Link, confirm a Google Chat DM is delivered; without the one-time Link setup, confirm the delivery is blocked_config and not retried
- [ ] Manual: verify the bell popover (All/Unread/Archived, mark-all-read, archive, row navigation) and preference toggles in desktop and mobile viewports