-- Phase 5C §4 — durable notification lifecycle. Every "we should email this student" moment -- (activation, enrollment confirmation, password reset, password-changed) becomes a row here -- FIRST, committed in the same transaction/request as the business action, and a separate -- worker (notification-worker.js) is what actually talks to the email provider — the HTTP -- request that triggers a notification never waits on outbound email network calls. -- -- recipient_email is a SNAPSHOT taken at enqueue time, not a live join to students.email — -- documented privacy note: this duplicates PII already stored on `students`/in an enrollment's -- metadata (the minor/no-email contact address, see 020_enrollments.sql's `metadata` column, -- Phase 5C's `contact_email` convention) purely so the worker and any later delivery audit -- don't need to reconstruct "what address would this have gone to" after the fact — same -- sensitivity class as data already in this database, not a new exposure. Never rendered -- unmasked through admin APIs (see notifications.js's maskEmail). -- -- metadata MAY transiently hold plaintext activation/reset token material between enqueue and -- the worker's terminal (sent/failed) update — see notifications.js's header comment for the -- exact reasoning. It is scrubbed the moment a row reaches a terminal state. NEVER a raw error -- stack trace, NEVER a Google credential, NEVER a full provider payload. CREATE TABLE IF NOT EXISTS notification_events ( notification_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), student_id TEXT NOT NULL REFERENCES students(student_id), enrollment_id UUID REFERENCES enrollments(enrollment_id), notification_type TEXT NOT NULL CHECK (notification_type IN ('activation', 'enrollment_confirmation', 'password_reset', 'password_changed')), channel TEXT NOT NULL DEFAULT 'email' CHECK (channel IN ('email')), recipient_email TEXT, provider TEXT, provider_message_id TEXT, status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'sending', 'sent', 'failed', 'cancelled')), attempt_count INTEGER NOT NULL DEFAULT 0, next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_error_code TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), queued_at TIMESTAMPTZ NOT NULL DEFAULT now(), sent_at TIMESTAMPTZ, failed_at TIMESTAMPTZ, updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), metadata JSONB ); CREATE INDEX IF NOT EXISTS idx_notification_events_student ON notification_events(student_id); CREATE INDEX IF NOT EXISTS idx_notification_events_enrollment ON notification_events(enrollment_id); CREATE INDEX IF NOT EXISTS idx_notification_events_status ON notification_events(status); -- The worker's claim query — partial index scoped to exactly the rows it ever selects from. CREATE INDEX IF NOT EXISTS idx_notification_events_claimable ON notification_events(next_attempt_at) WHERE status = 'queued';