CODEX // oaoisme wiki

§ unswayed-backend

Settings, preferences & messaging (Phase 20)

updated 2026-06-26

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/starred
  • POST/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.