Notification System Overview

In-app and email notification system for offers and platform events

What is the Notification System?

The notification system delivers in-app and email alerts to users when offer lifecycle events occur. It consists of four layers:

  1. Database layer — a notifications table with typed events, read/unread tracking, and email delivery status
  2. Access functions — CRUD operations with built-in agency routing
  3. Email delivery — a cron endpoint that sends emails via SendGrid, resolving recipient addresses from Cognito
  4. UI component — a NotificationBell React component with dropdown, badge count, and mark-as-read

Notification Types

All types are defined in packages/types/src/types/Notification.ts as the NOTIFICATION_TYPE_VALUES array:

TypeTriggered When
OFFER_RECEIVEDAdmin approves offer → talent notified
OFFER_ACCEPTEDTalent accepts offer → buyer notified
OFFER_COUNTEREDEither party counters → other party notified
OFFER_REJECTEDOffer rejected → relevant party notified
OFFER_APPROVEDAdmin approves offer in bouncer queue
OFFER_CANCELLEDBuyer cancels offer
PAYMENT_RECEIVEDStripe payment succeeds → talent notified
DELIVERY_READYTalent submits deliverables → buyer notified
REVISION_REQUESTEDBuyer requests revision → talent notified
OFFER_COMPLETEDBuyer releases payment
DISPUTE_OPENEDBuyer opens dispute → talent + admins notified
DISPUTE_RESOLVEDAdmin resolves dispute → both parties notified
OFFER_RELEASE_REMINDERAuto-release reminder at day 7, 14, 25
AUTO_RELEASEPayment auto-released after 30 days

Architecture

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

Database Layer

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

FunctionPurpose
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.

Agency Routing

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 Delivery

Templates

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.

Email Address Resolution

The cron endpoint resolves recipient email via AWS Cognito:

  1. Look up account.ownerUserId from the account table
  2. Call Cognito AdminGetUserCommand with the ownerUserId as username
  3. Extract the email attribute from the Cognito user

This avoids storing emails in the account table and uses Cognito as the email source of truth.

Cron Endpoint

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.

Environment Variables

VariablePurpose
SENDGRID_API_KEYSendGrid API key
SENDGRID_FROM_EMAILSender address (default: support@zooly.ai)
COGNITO_USER_POOL_IDFor email lookup via AdminGetUser
COGNITO_REGION or AWS_REGIONAWS region for Cognito client

NotificationBell Component

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:

  • Bell icon with red badge showing unread count (capped at "99+")
  • Click toggles a dropdown with the most recent notifications
  • Each notification shows an emoji type icon, title, relative time, and an unread indicator dot
  • "Mark all as read" link in footer calls POST /api/notifications/mark-read
  • Auto-refreshes every 30 seconds
  • Click-outside closes dropdown

Adding Notifications for New Features

When building a new feature that needs notifications:

  1. Add the new type to NOTIFICATION_TYPE_VALUES in packages/types/src/types/Notification.ts
  2. Add a template entry to TEMPLATE_MAP in packages/srv/src/email/offerEmails.ts
  3. In your API route, call createNotification() after the state change succeeds
  4. For talent-targeted notifications, resolve the recipient with getNotificationRecipient() first
  5. No additional wiring needed — the cron endpoint will pick up and email the notification automatically