The Partners Portal has one in-app notification system — the bell in the top bar and the /notifications page. It is portal-owned (Neon Postgres), with no Salesforce or email coupling; transactional email is a separate path (see Opportunities → Email notifications).
Backed by real code:
notifications in apps/partners/lib/db/schema.tsapps/partners/lib/notifications.ts (sendNotification, notifyOrgMembers)apps/partners/lib/actions/notifications.tsapps/partners/components/shell/NotificationBell.tsx, apps/partners/app/(portal)/notifications/apps/partners/lib/actions/broadcast.ts, apps/partners/app/(admin)/admin/broadcast/notifications table#export const notifications = pgTable("notifications", {
id: uuid("id")
.primaryKey()
.default(sql`gen_random_uuid()`),
clerkOrgId: text("clerk_org_id").notNull(),
clerkUserId: text("clerk_user_id"), // null = org-wide notification
type: text("type").notNull(), // deal_status | mdf_status | team_member | agreement | broadcast
title: text("title").notNull(),
body: text("body").notNull(),
href: text("href"),
readAt: timestamp("read_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
Every row is scoped to an org (clerkOrgId). The clerkUserId column decides who within that org sees it:
clerkUserId = null → org-wide. Every member of the org sees it. This is the default and what most events use.clerkUserId = <id> → addressed to one member. Only that user sees it.The read side enforces this. getNotifications, getUnreadCount, and both mark-read actions filter:
and(
eq(notifications.clerkOrgId, session.clerkOrgId),
or(isNull(notifications.clerkUserId), eq(notifications.clerkUserId, session.clerkUserId))
);
so a user sees org-wide rows plus rows addressed to them — never another member's targeted notification. (Without the clerkUserId clause a targeted notification would leak to the whole org.)
sendNotification(input)#Inserts one row. Pass clerkUserId to target a single member, or omit it for an org-wide notification.
notifyOrgMembers({ clerkOrgId, excludeClerkUserId, ... })#Fans a notification out to every member of the org except one — the actor who triggered the event. It lists the org's Clerk memberships and inserts one user-targeted row per other member. Best-effort: a Clerk/DB hiccup never throws back to the caller. Used for self-service actions where the actor already knows what they did but teammates should be told — e.g. a partner updating their own deal stage should not ping their own bell. Relies on the read-side clerkUserId filter above so each row reaches only its addressee.
| Type | Fired by | Scope | When |
|---|---|---|---|
deal_status | updateDealStage (lib/actions/update-opportunity.ts) | per-member, excludes actor | A partner changes a deal's sales stage |
deal_status | grantExtension (lib/actions/admin.ts) | org-wide | An admin grants a 30-day extension |
mdf_status | reviewMdfRequest / approveMdfRequest / rejectMdfRequest | org-wide | An MDF request moves to under-review / approved / rejected |
team_member | inviteMember / removeMember (lib/actions/team.ts) | org-wide | A teammate is invited to or removed from the org |
agreement | uploadPartnerAgreement (lib/actions/agreements.ts) | org-wide | An agreement is added for the org |
broadcast | sendBroadcast (lib/actions/broadcast.ts) | org-wide, per targeted org | An internal admin sends an announcement |
Every notification write is fire-and-forget (.catch(() => undefined)) so it can never fail the primary action.
lib/actions/notifications.ts:
getNotifications(limit) — the visible list, newest first (scoped as above).getUnreadCount() — unread badge count (readAt IS NULL, same scope, capped at 99).markNotificationRead({ id }) — marks one row read, re-asserting the org + visibility scope so a user can't mark a row they can't see.markAllRead() — marks every currently-visible unread row read.Internal staff with admin write clearance can send an announcement to partner organisations from /admin/broadcast.
Action — sendBroadcast (lib/actions/broadcast.ts), gated by requireAdminWriteSession():
title (3–120), body (3–2000), optional href, and an audience tier. The href must be an in-app path starting with / — an absolute/external URL is rejected to avoid an open-redirect through the notification click-through.clerkOrgId from partner_profiles.clerkOrgId from partner_sessions where partnerTier matches (tiers are portal-owned; see MDF → Quarterly limits).broadcast notification per targeted org (clerkUserId null → every member sees it).broadcast.sent with clerkOrgId = null (the "system/global event" sentinel), recording the title, tier, and org count.UI — /admin/broadcast (requireInternalReadSession to view; the compose form renders only for admin-write). Audience dropdown (All / per tier), title, message, optional in-app link. On success it reports how many orgs received it. The nav item lives under the Admin section (internal-only).