Skip to main content

Notification System

Event-driven, multi-channel notification platform with 84 event handlers across 7 specialized listeners.

Architecture

info

Event emission never blocks business logic. All listeners wrap their logic in try-catch and never rethrow.

Event Categories

ListenerEventsDomains
UserCompanyNotificationListener19User account, profile, roles, onboarding, company lifecycle, team management
BillingSystemNotificationListener23Subscriptions, payments, invoices, matching, system updates, security, enterprise billing events
TalentManagementNotificationListener13CV lifecycle, talent profiles, pools, CV collections, profile views
CollaborationNotificationListener12Talent sharing, invitations, connection requests, job seeker invitations, permissions
RecruitmentNotificationListener8Jobs, candidates, applications, pipeline status changes
MeetingNotificationListener7Meeting creation, rescheduling, cancellation, reminders
SupportNotificationListener3Support ticket created, support agent reply, ticket tester assigned

Delivery Channels

Channels are selected by notification priority:

PriorityIn-AppEmailPushSMS
LOWYes------
NORMALYesYes----
HIGHYesYesYes--
URGENTYesYesYesYes

Push and SMS are infrastructure-ready but not yet integrated with external providers.

Actionable Notifications

Some notification types carry metadata that enables inline Accept/Decline actions directly in the frontend UI. See Actionable Notifications for the full specification.

TypeMetadataAction
INVITATION_RECEIVEDmetadata.tokenAccept/decline collaboration invitation
CONNECTION_REQUEST_RECEIVEDmetadata.requestIdAccept/decline company connection request

Template System

Pre-configured templates using {{variableName}} substitution across 6 categories: Recruitment (8), Collaboration (11), Billing (8), Matching (3), System (4), and Support (2).

// Template definition
{
name: 'job_application_received',
titleTemplate: 'New Application for {{jobTitle}}',
messageTemplate: '{{candidateName}} has applied for {{jobTitle}}.',
variables: ['jobTitle', 'candidateName', 'candidateId', 'jobId'],
defaultPriority: NORMAL,
defaultChannels: [IN_APP, EMAIL]
}

// Creating a notification from a template
await notificationService.createFromTemplate(
userId,
NotificationType.JOB_APPLICATION_RECEIVED,
{ jobTitle: 'Senior Developer', candidateName: 'John Doe' }
);

User Preferences

Preferences follow a two-level hierarchy:

  1. Company-specific preferences (highest priority) -- overrides for a specific company context
  2. User default preferences (fallback) -- global user settings

Key preference fields:

CategoryFields
ChannelsenableEmailNotifications, enableInAppNotifications, enablePushNotifications, enableSmsNotifications
CategoriesnotifyOnJobApplications, notifyOnInterviews, notifyOnInvitations, notifyOnBillingEvents, notifyOnMatchingJobs
DigestenableDailyDigest, enableWeeklyDigest, digestTime
Email FrequencyrecruitmentEmailFrequency, collaborationEmailFrequency, billingEmailFrequency, matchingEmailFrequency, messagingEmailFrequency, systemEmailFrequency
DNDdoNotDisturbEnabled, doNotDisturbStart, doNotDisturbEnd

Per-Category Email Frequency

Each notification category has an independent email frequency setting:

FrequencyBehavior
IMMEDIATEEmails sent as soon as the notification is created (default)
DAILYBatched into the daily digest email (9AM)
WEEKLYBatched into the weekly digest email (Monday 9AM)
MONTHLYBatched into a monthly digest

All categories default to IMMEDIATE. The frequency controls email delivery only — in-app notifications are always delivered immediately regardless of this setting.

GraphQL API

# Paginated notifications with filters
query GetNotifications($input: GetNotificationsInput) {
myNotifications(input: $input) {
notifications { id type category priority title message status readAt actionUrl }
total
hasMore
}
}

# Unread count (polled every 30s by frontend)
query { unreadNotificationCount(companyId: "...") { count } }

# User default preferences (takes no parameters)
query { myNotificationPreferences {
enableEmailNotifications enableInAppNotifications doNotDisturbEnabled
} }

# Company-specific preferences (separate query)
query { companyNotificationPreferences(companyId: "...") {
enableEmailNotifications enableInAppNotifications doNotDisturbEnabled
} }

# Mutations
mutation { markNotificationAsRead(notificationId: "...") { success } }
mutation { markAllNotificationsAsRead(companyId: "...") { success } }
mutation { archiveNotification(notificationId: "...") { success } }
mutation { updateNotificationPreferences(input: $input) { success } }

Frontend Integration

The frontend layers two delivery channels for resilience:

  1. SSE primaryEventSource connected to /notifications/stream for real-time push (typically <500ms from publish to toast).
  2. Polling fallback — Apollo useQuery on unreadNotificationCount continues to run at 60s while SSE is connected and tightens to 30s on SSE error. De-duplication via toastedIdsRef makes double-delivery harmless.
// Real-time toast on every new notification
const es = new EventSource(`${API_URL}/notifications/stream?token=${token}`);
es.addEventListener("notification", (evt) => {
const n = JSON.parse(evt.data) as Notification;
if (toastedIdsRef.current.has(n.id)) return; // dedupe
toastedIdsRef.current.add(n.id);
setLastNotification(n); // NotificationToastHandler picks this up and fires addTWToast
refetchUnreadCount();
});

// Polling stays in place as a fallback; cadence varies on SSE state
useQuery(GET_UNREAD_COUNT, { pollInterval: sseConnected ? 60000 : 30000 });

See Real-time delivery for the SSE contract, reconnect semantics, and heartbeat behaviour, and Observability for the superadmin debug page.

Emitting Events

Services emit events after the primary database write. Always wrap in try-catch and include all required template variables:

try {
this.eventEmitter.emit('candidate.application.received', {
candidateId: candidate.id,
jobId: job.id,
userId: user.id,
candidateName: `${user.firstName} ${user.lastName}`,
jobTitle: job.title,
companyName: company.name,
});
} catch (error) {
this.logger.error('Failed to emit event', error);
}

Candidate Notification Rules

Automatic notifications to candidates are gated by the candidate's source field. Only candidates with source: INVITED receive automatic emails and in-app notifications for:

  • Pipeline status changes (status changed, hired, rejected)
  • Interview events (scheduled, rescheduled, cancelled, no-show)
  • Interview reminders (24h, 6h, 1h before)
  • Job closure notifications

Non-invited candidates receive no automatic notifications. The pipeline owner can manually notify any candidate using the notifyCandidate mutation.

tip

Internal team notifications (to job owners, recruiters, hiring company contacts) are always sent regardless of candidate source.

Scheduled Jobs

SchedulePurpose
Daily 9AMSubscription expiry warnings (7/3/1 days), daily digest emails
Monday 9AMWeekly digest emails
HourlyInterview and meeting reminders (24h/6h/1h, INVITED candidates only)