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.tsfiles with constant names exported asas constobjects. - 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.
| Category | Count | Key Events |
|---|---|---|
| User & Company | 19 | user.created, user.email.verified, user.role.assigned, company.created, company.team.member.added |
| Billing & Credits | 23 | subscription.created (also allocates credits), payment.succeeded, payment.failed, invoice.paid, enterprise.invoice.generated, enterprise.access.blocked |
| Collaboration | 9 | talent.shared, invitation.received, connection.request.received, permission.changed |
| Talent Management | 13 | cv.uploaded, cv.parsed, talent.profile.created, talent.pool.member.added |
| Recruitment | 9 | job.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 & Interviews | 23 | interview.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:
| Listener | Events | Domain |
|---|---|---|
UserCompanyNotificationListener | 19 | User accounts, profiles, roles, companies |
BillingSystemNotificationListener | 23 | Subscriptions, payments, invoices, matching, system, enterprise billing |
CollaborationNotificationListener | 9 | Sharing, invitations, connections, permissions |
TalentManagementNotificationListener | 13 | CVs, talent profiles, pools, collections |
RecruitmentNotificationListener | 8 | Jobs, candidates, pipelines |
MeetingNotificationListener | 7 | Meeting scheduling, reminders, completion |
Other Listeners
| Listener | Events | Purpose |
|---|---|---|
CreditSubscriptionListener | 2 | Credit allocation on subscription.created (idempotent) + initial credits on user.created (all users) |
TalentActivityListener | 22 | Activity audit logging for all talent events |
InterviewNotificationListener | 9 | Interview-specific notification delivery |
SlotOfferNotificationListener | 3 | Slot offer email delivery |
CandidateWithdrawalListener | 1 | Cascades 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. |
AuthListeners | 2 | Password reset and new password flows |
Multi-Listener Events
Some events are consumed by multiple independent listeners:
| Event | Listeners |
|---|---|
subscription.created | Notification delivery + credit allocation (idempotent — skips if already allocated for this subscription) |
user.created | Initial credit grant for all users (emitted by Prisma $use middleware via setImmediate, idempotent) |
talent.shared | Collaboration notification + activity logging |
meeting.created | Calendar invitation emails + in-app notifications |
candidate.withdrawn | Recruiter notifications (RecruitmentNotificationListener) + interview/meeting cancellation cascade (CandidateWithdrawalListener) |
Scheduled Jobs
Nine cron jobs handle time-based operations:
| Schedule | Job | Purpose |
|---|---|---|
| Daily 2:00 AM | Credit replenishment | Replenish subscription credits (30-day cycle) |
| Daily 9:00 AM | Subscription expiry warnings | Notify at 7, 3, and 1 days before expiration |
| Daily 9:00 AM | Daily digest | Aggregated daily activity summaries via email |
| Monday 9:00 AM | Weekly digest | Weekly activity summaries via email |
| Every hour | Interview reminders | Reminders at 24h, 6h, and 1h before interviews |
| Every hour | Meeting reminders | Reminders at 24h, 6h, and 1h before meetings |
| Every hour | Slot offer expiration | Expire unclaimed PENDING slot offers past deadline |
| 1st of month 3:00 AM | Enterprise invoice generation | Generate usage-based invoices for POSTPAID companies |
| Daily 10:00 AM | Enterprise overdue check | Block access for companies with past-due enterprise invoices |