Every notification in the Partners Portal goes through one dispatcher. It resolves a per-event routing config, fans out to in-app, email, and Slack, and records each attempt so a failure is visible rather than silent.
Backed by real code:
apps/partners/lib/notifications/events.tsapps/partners/lib/notifications/resolve.tsapps/partners/lib/notifications/dispatch.tsapps/partners/lib/notifications/channels/{in-app,email,slack}.tsapps/partners/lib/notifications/deliveries.tsnotifications, notification_routes, notification_deliveries in apps/partners/lib/db/schema.tsapps/partners/lib/actions/notifications.tsapps/partners/app/(admin)/admin/notifications/apps/partners/app/api/cron/notification-retry/route.tsBefore the engine, the portal had two disconnected delivery paths hand-wired at every call site: the in-app notifications table and eight Resend senders. Three consequences:
clerkOrgId-scoped to a partner org. Deal registration emailed only session.email — the partner who had just submitted the deal. This left the WEB-1 requirement "notify the internal channel/sales team of new registrations" unmet from 2026-06-01..catch(() => undefined), so a Resend outage was completely silent.NOTIFICATION_EVENTS in lib/notifications/events.ts is the single source. Three consumers read it — the routing table, the admin matrix, and the dispatcher — so adding an event is one entry rather than four edits.
Each entry declares a dotted key, a label, an audience, its defaultChannels, and an inAppType.
| Key | Audience | Default channels |
|---|---|---|
deal.registered | both | in_app, email, slack |
deal.updated | partner | in_app |
deal.status_changed | both | in_app, email |
deal.extension_requested | internal | in_app, email |
deal.extension_granted | partner | in_app, email |
mdf.submitted | internal | in_app, email |
mdf.status_changed | partner | in_app, email |
agreement.uploaded | partner | in_app |
team.member_invited | partner | in_app |
team.member_removed | partner | in_app |
access_request.submitted | internal | in_app, email |
access_request.decided | partner | |
sync.retry_failed | internal | in_app |
broadcast.sent | partner | in_app |
deal.registered is the only event defaulting to all three channels — it is the one the requirement names.
content.published is deliberately absent. Content documents are global, not org-scoped, so a partner in-app row could only be addressed to the uploader's org — an internal admin's — which is both the wrong audience and misleading. Announcing new content stays with Broadcast.
Two new notifications.type values. The column was documented as five values; access_request and sync were added for events the original five cannot describe. The column is free text, so no migration was needed — but every NotificationType must have an icon in NotificationBell, which a registry test asserts.
notification_routes, one row per event:
| Column | Notes |
|---|---|
eventKey | PK, matches a registry key |
enabledChannels | Subset of in_app / email / slack |
internalEmails | CRO, VP Sales, … — empty for partner-only events |
slackWebhookUrl | Per-event override |
updatedByClerkUserId, updatedAt | Audit trail |
Seeding is lazy, not a migration. resolveRoute(key, stored, envWebhook) returns the stored row if present, otherwise the registry default. A missing row therefore can never mean "notification silently disabled" — the worst case is that defaults apply. Rows are written on first admin save.
At least one channel is mandatory. A Zod refinement in RouteConfigSchema rejects an empty enabledChannels, enforced in the server action. The UI additionally disables the last remaining checkbox, but the server is the boundary.
A stored channel that is not a real channel is filtered, not trusted — a retired channel name left in a row cannot crash the dispatcher.
Slack uses an Incoming Webhook, not a bot token: no OAuth flow, no scopes, no token refresh, no channel picker — a single URL posting to one channel, which is the entire requirement. The webhook host is pinned to hooks.slack.com by schema refinement, so a mistyped or hostile URL cannot receive internal deal data. SLACK_WEBHOOK_URL is an optional env fallback used when a route has no per-event override.
notify({
event: "deal.registered",
partnerOrgId, // omit for internal-only events
partnerEmail, // partner contact for the email channel
excludeClerkUserId, // excluded from the partner in-app fan-out
title,
body,
href,
});
Resolves the route, derives targets, fans out, records each attempt.
Promise.allSettled, not all — one dead channel must not suppress the others.notify() never throws. The whole body is wrapped; a notification failure must not roll back the business action that triggered it. Every gap-event call site uses void notify(...) for the same reason — a Slack outage cannot fail a deal registration.deriveTargets sends to a partner inbox only for partner/both events and to internal recipients only for internal/both. An address that is both the partner contact and an internal recipient is deduplicated.Route resolution, recipient derivation, and retry policy are pure functions in resolve.ts and deliveries.ts — no DB, no network. Only three thin adapters touch I/O. This keeps the interesting decisions testable under the suite's environment: "node": the repo has no @vitejs/plugin-react and is missing @testing-library/dom, so anything requiring React to exercise would mean adding test infrastructure to cover logic that does not need it.
sendNotification and notifyOrgMembers in lib/notifications.ts are now thin wrappers over notify(), with unchanged signatures, so the ~15 pre-existing call sites kept working without a flag day. New code should call notify() with a registry event key.
"use server"constraint.dispatch.ts,lib/notifications.ts, andlib/actions/notification-routes.tscarry the directive, where every runtime export must be an async function. This is whyLEGACY_TYPE_EVENTlives inevents.tsrather than next to the shims that use it: a constant exported from a"use server"module type-checks and runs in dev, then failsnext build.
notifications.audience is "partner" | "internal", defaulting to "partner" so every pre-existing row kept its behaviour with no backfill.
Why a column rather than org scoping. requirePartnerSession() returns clerkOrgId: orgId ?? "" for internal staff — the currently active Clerk org, not MATTERS_CLERK_ORG_ID. Internal users switch orgs, so an internal user sitting in a partner's context would have read that partner's inbox.
getNotifications, getUnreadCount, markNotificationRead, and markAllRead all share one visibilityPredicate():
audience = 'internal' rows, regardless of active org.audience = 'partner' rows in their own org only.Both keep the clerkUserId clause (clerkUserId IS NULL OR clerkUserId = me). That clause exists because a user-targeted notification would otherwise leak to every member of an org; widening it to make internal delivery work would have re-opened exactly that bug, which is why the audience column exists instead.
Internal status is resolved from accountType via isInternalTier, not from session.partnerType. The latter is Salesforce-sourced metadata on a partner row and is not a trustworthy authorization signal.
app/(admin)/layout.tsx previously rendered <Navbar notifications={[]} unreadNotifications={0} /> — both hardcoded — while the portal layout genuinely fetched. Writing internal rows would have changed nothing visible, so the layout now fetches like the portal does.
notification_deliveries holds one row per attempt: eventKey, channel, target, audience, ok, error (truncated to 500), durationMs, attempt, nextRetryAt, and the payload for replay.
Retry is deliberately asymmetric:
computeNextRetryAt returns null for them, so they never carry a nextRetryAt at all./api/cron/notification-retry runs every 15 minutes, authorised by the same constant-time CRON_SECRET bearer comparison the two existing crons use. It:
ok = false AND nextRetryAt <= now(),nextRetryAt before replaying, so a throwing send cannot compound on the next tick,attempt + 1 rather than mutating the original, preserving the failure history,jsonb payload rather than trusting its shape.A dedicated route rather than piggybacking: lead-lifecycle-sweep runs daily at 02:00 — far too slow for a deal alert — and revalidate-partner-status has an unrelated concern and its own time budget.
/admin/notifications, gated by requireSuperAdminSession(). Super-admin only: requireAdminSession (read tier) and requireAdminWriteSession are both too permissive for deciding who receives internal deal alerts.
· default.not retried (partner) from exhausted (internal).notification_route.updated to activity_log with a { field: [before, after] } diff over only the fields that actually changed, matching the opportunity.admin_field_override shape.The admin announcement channel: /admin/broadcast composes a message to all partner orgs or one tier, and emits broadcast.sent per org. The tier audience now resolves from partner_profiles.partner_tier (the org-level source of truth) — it previously filtered per-member partner_sessions rows, so an org whose member rows disagreed had its audience silently split. See Admin Navigation & Org Hub. It remains the right tool for anything global — including new content — because it targets every partner org explicitly rather than inferring one from a call site.
One schema step, recorded in apps/partners/PENDING_TASKS.md §12:
pnpm --filter @matters/partners db:push
db:push needs a TTY. All three changes are additive. Until it runs, notify() fails against the missing tables — and because it swallows its own errors, the visible symptom is simply that no notification arrives while the business action still succeeds.
SLACK_WEBHOOK_URL is optional; a per-event webhook set from /admin/notifications takes precedence.
The dispatcher's fan-out has no integration test. Its pure parts — resolveRoute, deriveTargets, computeNextRetryAt, slackPayload, audienceFilter — are covered, but the orchestration itself is verified by manual check, because the repo has no DB or Clerk test harness.