Skip to main content

Event System

The backend uses @nestjs/event-emitter (built on eventemitter2) for decoupled domain events. Events drive cross-cutting concerns -- notifications, activity logging, credit allocation -- without coupling domain services to each other.

Core Principles

  • Fire-and-forget: Listeners wrap all logic in try-catch and never rethrow. A failed notification must never break a business operation.
  • After-write emission: Events are emitted from the service layer, after the primary database write succeeds.
  • Typed payloads: Event payloads are defined as TypeScript interfaces in *.events.ts files with constant names exported as as const objects.
  • Multiple listeners per event: A single event (e.g., subscription.created) can be handled by multiple independent listeners (notification + credit allocation).

Event Naming Convention

Pattern: {domain}.{entity}.{action} or {domain}.{action}, all lowercase and dot-separated.

talent.created                     # domain.action
talent.cv.uploaded # domain.entity.action
candidate.application.received # domain.entity.action
interview.slot.offer.created # nested entity
user.password.reset.requested # deep nesting for specificity

Event Categories

The platform emits 80 events across six categories, handled by specialized listeners.

CategoryCountKey Events
User & Company19user.created, user.email.verified, user.role.assigned, company.created, company.team.member.added
Billing & Credits23subscription.created (also allocates credits), payment.succeeded, payment.failed, invoice.paid, enterprise.invoice.generated, enterprise.access.blocked
Collaboration9talent.shared, invitation.received, connection.request.received, permission.changed
Talent Management13cv.uploaded, cv.parsed, talent.profile.created, talent.pool.member.added
Recruitment9job.created, job.status.changed, job.archived (SUP-00083 — emitted by removeJob soft-archive; reuses notifyCandidatesOfJobClosure to email invited candidates), candidate.application.received, candidate.status.changed, candidate.withdrawn
Meetings & Interviews23interview.scheduled, interview.reminder, interview.slot.offer.created, meeting.created, meeting.reminder

Emission and Listener Patterns

Events are emitted from the service layer after the database write succeeds, wrapped in try-catch so failures never break the caller:

// Emission (service layer)
const job = await this.prisma.job.create({ ... });
try {
this.eventEmitter.emit('job.created', {
jobId: job.id, companyId: user.selectedCompanyId, createdById: user.id,
});
} catch (error) {
this.logger.error('Failed to emit job.created event', error);
}

// Listener (fire-and-forget, never rethrow)
@OnEvent('subscription.created')
async handleSubscriptionCreated(payload: SubscriptionPayload) {
try {
await this.notificationService.createFromTemplate(
payload.userId, NotificationType.SUBSCRIPTION_CREATED,
{ planName: payload.planName },
);
} catch (error) {
this.logger.error('Failed to handle subscription.created', error);
}
}

Notification Listeners

Six dedicated listeners in src/notification-system/listeners/ handle event-to-notification routing:

ListenerEventsDomain
UserCompanyNotificationListener19User accounts, profiles, roles, companies
BillingSystemNotificationListener23Subscriptions, payments, invoices, matching, system, enterprise billing
CollaborationNotificationListener9Sharing, invitations, connections, permissions
TalentManagementNotificationListener13CVs, talent profiles, pools, collections
RecruitmentNotificationListener8Jobs, candidates, pipelines
MeetingNotificationListener7Meeting scheduling, reminders, completion

Other Listeners

ListenerEventsPurpose
CreditSubscriptionListener2Credit allocation on subscription.created (idempotent) + initial credits on user.created (all users)
TalentActivityListener22Activity audit logging for all talent events
InterviewNotificationListener9Interview-specific notification delivery
SlotOfferNotificationListener3Slot offer email delivery
CandidateWithdrawalListener1Cascades candidate.withdrawn into the interview-scheduling domain — cancels every open InterviewBooking / Meeting for the withdrawn candidate so they lose Jitsi access. See Candidate Management → Withdrawal Lifecycle.
AuthListeners2Password reset and new password flows

Multi-Listener Events

Some events are consumed by multiple independent listeners:

EventListeners
subscription.createdNotification delivery + credit allocation (idempotent — skips if already allocated for this subscription)
user.createdInitial credit grant for all users (emitted by Prisma $use middleware via setImmediate, idempotent)
talent.sharedCollaboration notification + activity logging
meeting.createdCalendar invitation emails + in-app notifications
candidate.withdrawnRecruiter notifications (RecruitmentNotificationListener) + interview/meeting cancellation cascade (CandidateWithdrawalListener)

Scheduled Jobs

Nine cron jobs handle time-based operations:

ScheduleJobPurpose
Daily 2:00 AMCredit replenishmentReplenish subscription credits (30-day cycle)
Daily 9:00 AMSubscription expiry warningsNotify at 7, 3, and 1 days before expiration
Daily 9:00 AMDaily digestAggregated daily activity summaries via email
Monday 9:00 AMWeekly digestWeekly activity summaries via email
Every hourInterview remindersReminders at 24h, 6h, and 1h before interviews
Every hourMeeting remindersReminders at 24h, 6h, and 1h before meetings
Every hourSlot offer expirationExpire unclaimed PENDING slot offers past deadline
1st of month 3:00 AMEnterprise invoice generationGenerate usage-based invoices for POSTPAID companies
Daily 10:00 AMEnterprise overdue checkBlock access for companies with past-due enterprise invoices