§ unswayed-backend
Settings, preferences & messaging (Phase 20)
Settings, preferences & messaging (Phase 20 — UN-161, UN-165, UN-160)
Phase 20 is the final phase of the rebuild roadmap. It adds three small, independent extras — a theme flag, a job-match notification cadence, and starred/archived messaging — and like the phases before it, the interesting part is fitting them onto what already exists rather than inventing parallel systems. The decisions are recorded in ADR-0050.
UN-161 — Theme
Every user gets an isDarkTheme boolean, with a role-based default: applicants
default to dark, employers to light. A new src/user-settings/ module exposes
GET/PATCH /api/user/settings/theme (the standard {status, message, data}
envelope, snake_case is_dark_theme, open to both roles).
The subtle part is the migration. The column is NOT NULL, but its default
depends on the user's role, so it can't be a plain database DEFAULT. There was
no precedent in the repo for a role-derived backfill — every prior NOT NULL
column was added with a single constant default. So the migration is hand-written
in three steps: add the column with a temporary DEFAULT true, UPDATE existing
employers to false, then DROP DEFAULT so the value is owned by the registration
code. For new users, UsersService.createApplicant/createEmployer/createSocialUser
each set isDarkTheme explicitly per role.
A knock-on effect worth remembering: adding a required column to User broke
about ten test fixtures and e2e helpers that construct User objects — the
typecheck caught every one. A required schema field is a forcing function: it
makes every construction site declare the value, which is exactly the point.
UN-165 — Notification preferences & the frequency gate
Applicants can choose how often they receive job-match alerts: daily, weekly,
bi-weekly, or monthly. Rather than a new notification_preferences table, this
extends the existing NotificationPreference model (which already gates push
and job-email notifications) with jobMatchFrequency and lastJobMatchSentAt.
GET/PATCH /api/user/settings/notifications reads and upserts it, and the
previously-unused ensureDefault() helper now seeds the weekly default at
registration.
The enforcement detail is where the spec and the codebase disagreed. The ticket
said "when the job-match notification cron fires" — but there is no such cron.
Job-match alerts are a create-time fan-out: when an employer publishes a job, the
JobNotificationDispatcher matches it against saved JobAlerts and enqueues one
notification per matched user. So the frequency gate lives there, not in a
scheduler. After computing the matched user set, the dispatcher batch-loads their
preferences in a single query (no N+1, because the set is already materialized), a
pure isJobMatchDue(frequency, lastSentAt, now) decides whether each user is due
(daily means "last sent more than 24h ago", etc.; an absent preference row defaults
to weekly; a null last-sent is always due), and only the due users are enqueued.
lastJobMatchSentAt is then stamped — only for the users who were actually
notified. The whole thing sits inside the dispatcher's existing best-effort
try/catch, so a preference read or write failure can never break job creation.
UN-160 — Starred messages & archived conversations
The ticket's "conversations" and "messages" are this repo's Chat and Message
(the Phase-9 chat module). Two net-new per-user join tables — StarredMessage
(unique per user+message) and ArchivedConversation (unique per user+chat) — back
the feature, with the endpoints on the /api/v1/* bare-camelCase surface using the
repo's chats noun:
POST/DELETE /api/v1/messages/:messageId/star+GET /api/v1/messages/starredPOST/DELETE /api/v1/chats/:chatId/archive+GET /api/v1/chats/archived
The toggles are idempotent (an upsert on the way in, a deleteMany on the way
out, so a repeated call is harmless), and authorization reuses the chat's
participant check — you can only star a message in, or archive, a chat you're part
of. Everything is strictly per-user: archiving a conversation never affects the
other participant's view.
The one cross-surface coupling is the requirement that archived conversations
disappear from the default conversation list. That default list is the existing
GET /api/chats, so its query gains one condition — archivedBy: { none: { userId } } — and the chat drops out for that user (and reappears the moment they unarchive
it). (A small gotcha: the relation field on Chat is named archivedBy, while
archivedConversations is the field on User — the schema-correct name matters in
the where.)
With this phase, the rebuild roadmap is complete: phases 0 through 20 are all done.