In-app and email notification system for offers and platform events
The notification system delivers in-app and email alerts to users when offer lifecycle events occur. It consists of four layers:
notifications table with typed events, read/unread tracking, and email delivery statusNotificationBell React component with dropdown, badge count, and mark-as-readAll types are defined in packages/types/src/types/Notification.ts as the NOTIFICATION_TYPE_VALUES array:
| Type | Triggered When |
|---|---|
OFFER_RECEIVED | Admin approves offer → talent notified |
OFFER_ACCEPTED | Talent accepts offer → buyer notified |
OFFER_COUNTERED | Either party counters → other party notified |
OFFER_REJECTED | Offer rejected → relevant party notified |
OFFER_APPROVED | Admin approves offer in bouncer queue |
OFFER_CANCELLED | Buyer cancels offer |
PAYMENT_RECEIVED | Stripe payment succeeds → talent notified |
DELIVERY_READY | Talent submits deliverables → buyer notified |
REVISION_REQUESTED | Buyer requests revision → talent notified |
OFFER_COMPLETED | Buyer releases payment |
DISPUTE_OPENED | Buyer opens dispute → talent + admins notified |
DISPUTE_RESOLVED | Admin resolves dispute → both parties notified |
OFFER_RELEASE_REMINDER | Auto-release reminder at day 7, 14, 25 |
AUTO_RELEASE | Payment auto-released after 30 days |
Offer event (API route)
→ createNotification()
→ notifications table row (read=false, emailSent=false)
↓
Cron: GET /api/notifications/process-emails
→ getUnsent(20)
→ Cognito AdminGetUser → resolve email
→ SendGrid send
→ markEmailSent()
↓
User opens app
→ GET /api/notifications/list
→ NotificationBell renders dropdown
→ POST /api/notifications/mark-read
Schema: packages/db/src/schema/notifications.ts
Key columns: accountId (recipient after agency routing), type (enum), offerId (optional FK), read (boolean), emailSent / emailSentAt (delivery tracking).
Access functions: packages/db/src/access/notifications.ts
| Function | Purpose |
|---|---|
createNotification(accountId, type, title, body, offerId?) | Insert notification row |
listNotifications(accountId, limit?, offset?) | Paginated list, newest first |
getUnreadCount(accountId) | Count where read = false |
markAsRead(notificationId, accountId) | Set read = true (verifies ownership) |
markAllAsRead(accountId) | Bulk mark all as read for account |
getUnsent(limit) | Fetch where emailSent = false |
markEmailSent(notificationId) | Set emailSent = true and emailSentAt = now() |
hasNotification(accountId, type, offerId, bodyContains?) | Dedup check (used by reminder crons) |
getNotificationRecipient(talentAccountId) | Agency routing — returns agent's accountId if talent has an active agent |
All access functions are exported from packages/db/src/index.ts.
When creating a notification destined for talent, always call getNotificationRecipient(talentAccountId) first. It checks the account_agents table — if the talent has an active agent, the notification is routed to the agent's account instead. This ensures agency workflows receive all talent notifications.
All offer API routes that create notifications already follow this pattern. See any of the routes in apps/zooly-app/app/api/offers/ for examples.
Email templates are defined in packages/srv/src/email/offerEmails.ts. The TEMPLATE_MAP provides a per-type function that generates subject and text from the notification data. The sendNotificationEmail(notification, recipientEmail) function constructs both plaintext and HTML emails and sends via SendGrid.
Exported from @zooly/app-srv via packages/srv/src/index.ts.
The cron endpoint resolves recipient email via AWS Cognito:
account.ownerUserId from the account tableAdminGetUserCommand with the ownerUserId as usernameemail attribute from the Cognito userThis avoids storing emails in the account table and uses Cognito as the email source of truth.
GET /api/notifications/process-emails (in apps/zooly-app/app/api/notifications/process-emails/route.ts) processes up to 20 unsent notifications per invocation. Failed sends are logged but do not block other emails.
| Variable | Purpose |
|---|---|
SENDGRID_API_KEY | SendGrid API key |
SENDGRID_FROM_EMAIL | Sender address (default: support@zooly.ai) |
COGNITO_USER_POOL_ID | For email lookup via AdminGetUser |
COGNITO_REGION or AWS_REGION | AWS region for Cognito client |
Location: packages/client/src/components/NotificationBell.tsx
Exported from @zooly/client as NotificationBell.
Props: onNavigate?: (path: string) => void — called when user clicks a notification (path is /offers/{offerId})
Behavior:
POST /api/notifications/mark-readThe NotificationBell component must be mounted into the app header or sidebar by the host application. It is not auto-mounted anywhere.
When building a new feature that needs notifications:
NOTIFICATION_TYPE_VALUES in packages/types/src/types/Notification.tsTEMPLATE_MAP in packages/srv/src/email/offerEmails.tscreateNotification() after the state change succeedsgetNotificationRecipient() first