Notification System
Event-driven, multi-channel notification platform with 84 event handlers across 7 specialized listeners.
Architecture
Event emission never blocks business logic. All listeners wrap their logic in try-catch and never rethrow.
Event Categories
| Listener | Events | Domains |
|---|---|---|
UserCompanyNotificationListener | 19 | User account, profile, roles, onboarding, company lifecycle, team management |
BillingSystemNotificationListener | 23 | Subscriptions, payments, invoices, matching, system updates, security, enterprise billing events |
TalentManagementNotificationListener | 13 | CV lifecycle, talent profiles, pools, CV collections, profile views |
CollaborationNotificationListener | 12 | Talent sharing, invitations, connection requests, job seeker invitations, permissions |
RecruitmentNotificationListener | 8 | Jobs, candidates, applications, pipeline status changes |
MeetingNotificationListener | 7 | Meeting creation, rescheduling, cancellation, reminders |
SupportNotificationListener | 3 | Support ticket created, support agent reply, ticket tester assigned |
Delivery Channels
Channels are selected by notification priority:
| Priority | In-App | Push | SMS | |
|---|---|---|---|---|
| LOW | Yes | -- | -- | -- |
| NORMAL | Yes | Yes | -- | -- |
| HIGH | Yes | Yes | Yes | -- |
| URGENT | Yes | Yes | Yes | Yes |
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.
| Type | Metadata | Action |
|---|---|---|
INVITATION_RECEIVED | metadata.token | Accept/decline collaboration invitation |
CONNECTION_REQUEST_RECEIVED | metadata.requestId | Accept/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:
- Company-specific preferences (highest priority) -- overrides for a specific company context
- User default preferences (fallback) -- global user settings
Key preference fields:
| Category | Fields |
|---|---|
| Channels | enableEmailNotifications, enableInAppNotifications, enablePushNotifications, enableSmsNotifications |
| Categories | notifyOnJobApplications, notifyOnInterviews, notifyOnInvitations, notifyOnBillingEvents, notifyOnMatchingJobs |
| Digest | enableDailyDigest, enableWeeklyDigest, digestTime |
| Email Frequency | recruitmentEmailFrequency, collaborationEmailFrequency, billingEmailFrequency, matchingEmailFrequency, messagingEmailFrequency, systemEmailFrequency |
| DND | doNotDisturbEnabled, doNotDisturbStart, doNotDisturbEnd |
Per-Category Email Frequency
Each notification category has an independent email frequency setting:
| Frequency | Behavior |
|---|---|
IMMEDIATE | Emails sent as soon as the notification is created (default) |
DAILY | Batched into the daily digest email (9AM) |
WEEKLY | Batched into the weekly digest email (Monday 9AM) |
MONTHLY | Batched 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:
- SSE primary —
EventSourceconnected to/notifications/streamfor real-time push (typically <500ms from publish to toast). - Polling fallback — Apollo
useQueryonunreadNotificationCountcontinues to run at 60s while SSE is connected and tightens to 30s on SSE error. De-duplication viatoastedIdsRefmakes 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.
Internal team notifications (to job owners, recruiters, hiring company contacts) are always sent regardless of candidate source.
Scheduled Jobs
| Schedule | Purpose |
|---|---|
| Daily 9AM | Subscription expiry warnings (7/3/1 days), daily digest emails |
| Monday 9AM | Weekly digest emails |
| Hourly | Interview and meeting reminders (24h/6h/1h, INVITED candidates only) |