Overview
CTNE — the CareerTruss Notification Engine — is the wiring that carries signal out of the product. Business logic writes a row. CTNE decides how, when and whether that row becomes an email.
Status: Live Phase 1 — email channel
The core principle
No part of the application is allowed to call a mail vendor directly. Shortlisting a candidate is a database write; sending the email about it is a separate, retryable job. If the mail provider is unreachable, the shortlist still succeeds and the notification simply waits.
business event (DB write)
-> SECURITY DEFINER trigger
-> notifications row (status = pending)
-> dispatch-notifications edge function (claims -> sending)
-> mail provider
-> sent | failed (retryable)Warning
Anatomy of a notification
| Field | Meaning |
|---|---|
| recipient_user_id | Who the message is for. Cascades if the account is deleted. |
| type | Enumerated event type — the template registry key. |
| channel | email, sms, whatsapp or in_app. Only email has a dispatcher today. |
| status | pending → sending → sent, or failed and eligible for retry. |
| payload | JSON context rendered into the template (names, titles, links). |
| dedupe_key | Unique. The same event can never send twice. |
| scheduled_at | Earliest send time — enables digests and delays. |
Event types
| Type | Audience | Fires when |
|---|---|---|
| APPLICATION_NEW | Employer | A student submits an internal application |
| APPLICATION_SHORTLISTED | Student | Status moves to shortlisted |
| APPLICATION_ACCEPTED | Student | Status moves to accepted |
| APPLICATION_REJECTED | Student | Status moves to rejected |
| INTERNSHIP_APPROVED | Employer | A submission is published |
| INTERNSHIP_REJECTED | Employer | A submission is not approved |
| INTERNSHIP_CHANGES_REQUESTED | Employer | Moderation asks for edits |
| INTERNSHIP_EXPIRING | Employer | A live listing approaches its deadline |
| EIES_WARNING | Employer | An internal application went unanswered past its deadline |
| EIES_PAUSED | Employer | Grace period lapsed — listing left the public surface |
| EIES_REACTIVATED | Employer | Employer responded and the listing returned |
| EIES_ARCHIVED | Employer | A paused listing was archived |
Delivery lifecycle
- Enqueue. A trigger calls
enqueue_notification(user_id, type, payload, channel, scheduled_at, dedupe_key)inside the same transaction as the business change. - Claim. The dispatcher atomically moves a batch from
pendingtosending, so two concurrent runs can never send the same row. - Render. The type resolves to a template in the shared registry; the payload supplies the variables.
- Settle. Success writes
sent; a provider error writesfailed, which a later retry pass can pick up.
When the dispatcher runs
Two triggers, on purpose. The app kicks the dispatcher opportunistically after status changes and applications, so mail typically leaves within seconds. A scheduled sweep runs behind it as a safety net, retrying failures and catching anything the opportunistic kick missed.
Deduplication is structural
dedupe_key carries a unique constraint. Double-clicking "Shortlist", a retried trigger and a replayed sweep all collide on the same key and only one row survives. Idempotency is enforced by the schema, not by careful coding.Extending CTNE
Adding an event type is a three-line change:
ALTER TYPE public.notification_type ADD VALUE 'INTERVIEW_SCHEDULED';INTERVIEW_SCHEDULED: {
subject: (p) => `Interview scheduled — ${p.role} at ${p.company}`,
html: (p) => renderLayout({ ... }),
},PERFORM public.enqueue_notification(
_student_id,
'INTERVIEW_SCHEDULED',
jsonb_build_object('role', v_role, 'company', v_company),
'email',
now(),
'interview:' || NEW.id::text
);Tip
pending means the dispatcher has not run; a row infailed means it ran and the provider refused; no row at all means the trigger never fired.