Systems & engineeringnew

CTNE — notification engine

Trigger-enqueued, dispatcher-delivered email with deduplication and retries.

Last updated 16 Aug 2026

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.

ctne flow
business event (DB write)
  -> SECURITY DEFINER trigger
  -> notifications row (status = pending)
  -> dispatch-notifications edge function (claims -> sending)
  -> mail provider
  -> sent | failed (retryable)

Warning

A notification engine that can fail a business transaction is a liability. CTNE is designed so the worst outcome of an outage is a late email, never a lost shortlist, approval or rejection.

Anatomy of a notification

FieldMeaning
recipient_user_idWho the message is for. Cascades if the account is deleted.
typeEnumerated event type — the template registry key.
channelemail, sms, whatsapp or in_app. Only email has a dispatcher today.
statuspending → sending → sent, or failed and eligible for retry.
payloadJSON context rendered into the template (names, titles, links).
dedupe_keyUnique. The same event can never send twice.
scheduled_atEarliest send time — enables digests and delays.

Event types

The notification_type enum
TypeAudienceFires when
APPLICATION_NEWEmployerA student submits an internal application
APPLICATION_SHORTLISTEDStudentStatus moves to shortlisted
APPLICATION_ACCEPTEDStudentStatus moves to accepted
APPLICATION_REJECTEDStudentStatus moves to rejected
INTERNSHIP_APPROVEDEmployerA submission is published
INTERNSHIP_REJECTEDEmployerA submission is not approved
INTERNSHIP_CHANGES_REQUESTEDEmployerModeration asks for edits
INTERNSHIP_EXPIRINGEmployerA live listing approaches its deadline
EIES_WARNINGEmployerAn internal application went unanswered past its deadline
EIES_PAUSEDEmployerGrace period lapsed — listing left the public surface
EIES_REACTIVATEDEmployerEmployer responded and the listing returned
EIES_ARCHIVEDEmployerA paused listing was archived

Delivery lifecycle

  • Enqueue. A trigger callsenqueue_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 frompending to sending, 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 writes failed, 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:

1. extend the enum
ALTER TYPE public.notification_type ADD VALUE 'INTERVIEW_SCHEDULED';
2. register a template
INTERVIEW_SCHEDULED: {
  subject: (p) => `Interview scheduled — ${p.role} at ${p.company}`,
  html: (p) => renderLayout({ ... }),
},
3. enqueue from a trigger
PERFORM public.enqueue_notification(
  _student_id,
  'INTERVIEW_SCHEDULED',
  jsonb_build_object('role', v_role, 'company', v_company),
  'email',
  now(),
  'interview:' || NEW.id::text
);

Tip

Debugging a missing email? Look at the notifications row first. A row inpending 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.