diff --git a/.env.example b/.env.example
index a51ddf59..abbcd253 100644
--- a/.env.example
+++ b/.env.example
@@ -36,3 +36,15 @@ FLUENT_AI_KEY=fai_dev_admin
# (e.g. POST /tools/greek-room/repeated-words), so leave this empty. If/when
# fluent-ai adopts versioned routing, set this to /api/v1 — no code change needed.
FLUENT_AI_API_PREFIX=
+
+# Feature flags (EN_FEATURE_*)
+# One flat boolean per optional feature, published (camelCased) by
+# GET /config/features so the front-end can hide AI-dependent UI where the
+# backend isn't wired. Accepted: true/false, 1/0, yes/no, on/off.
+#
+# EN_FEATURE_REPEATED_WORD_CHECK — the Repeated Word Check UI. Leave UNSET to
+# derive the safe default: on only when FLUENT_AI_URL + FLUENT_AI_KEY are both
+# set, off otherwise. Set explicitly to force it either way.
+# (A flag is declared in three synced places: src/env.ts, the FLAGS registry in
+# src/lib/features.ts, and the OpenAPI schema in src/routes/config.route.ts.)
+EN_FEATURE_REPEATED_WORD_CHECK=
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
index fdfb9b51..dbed0292 100644
--- a/docker-entrypoint.sh
+++ b/docker-entrypoint.sh
@@ -31,5 +31,8 @@ npx tsx src/db/seeds/bibles.ts || { echo "ERROR: bibles seed failed"; exit 1; }
echo "Seeding bible texts..."
npx tsx src/db/seeds/bible-texts.ts || { echo "ERROR: bible texts seed failed"; exit 1; }
+echo "Seeding pericope sets..."
+npx tsx src/db/seeds/pericope-sets.ts || { echo "ERROR: pericope sets seed failed"; exit 1; }
+
echo "Starting dev server..."
exec npx tsx watch src/index.ts
diff --git a/docs/proposals/feature-flags/feature-flags-suggestion.md b/docs/proposals/feature-flags/feature-flags-suggestion.md
new file mode 100644
index 00000000..d4c39198
--- /dev/null
+++ b/docs/proposals/feature-flags/feature-flags-suggestion.md
@@ -0,0 +1,316 @@
+# Feature Flags via a Published Config Endpoint — Proposal
+
+**Status:** Reviewed and implemented. Q1–Q4 resolved by kaseywright (2026-07-07);
+see §9. Shipped in fluent-api #213 / fluent-web #337. The one change from the
+original draft: `GET /config/features` is **authenticated** (login-only, no
+role), not unauthenticated — Q1's floated alternative was chosen.
+**Origin:** fluent-ai is not hosted in any deployed environment. Every PR that
+depends on it therefore cannot merge — merging would light up UI (and code
+paths) that call a service that isn't there, which would block promoting the
+branch to production, which in turn blocks unrelated changes queued behind it.
+**Ships as:** a coordinated pair of PRs — fluent-api (the flag source + endpoint)
+and fluent-web (the consumer hook, a gate primitive, and an unlinked diagnostics
+page). A small fluent-platform compose/env note may follow.
+
+---
+
+## 1. Problem
+
+- fluent-ai has no hosted URL in `development`/`staging`/`production`. In those
+ environments `FLUENT_AI_URL` / `FLUENT_AI_KEY` point at nothing usable.
+- The AI-dependent feature that exists today is the **Repeated Word Check**
+ (fluent-web Checks tab/panel → `POST /ai/tools/greek-room/repeated-words`).
+- We need a way to **turn AI-dependent front-end features on and off per
+ environment** so their PRs can merge and ride to production **hidden**, and be
+ switched on later (in the environment where fluent-ai actually runs) with a
+ config change and no redeploy of code.
+
+This is fundamentally a **wiring / deploy concern**, not application data.
+
+---
+
+## 2. Goals & non-goals
+
+**Goals**
+
+- A single source of truth for "which optional features are on," owned by
+ fluent-api and driven by environment configuration.
+- A read-only endpoint fluent-web can query to decide what UI to render.
+- fluent-web hides AI UI by default and reveals it only when the flag says so.
+- An unlinked, login-gated diagnostics page where an operator can see the flag
+ state (and, over time, other technical/health details).
+- An **extensible** shape so the next feature/flag is added without changing the
+ contract or the consumer primitives.
+
+**Non-goals (this proposal)**
+
+- **No server-side enforcement.** This is a _publishing_ mechanism, deliberately
+ decoupled from the request path. Turning a flag off does **not** make
+ `/ai/tools/...` start rejecting requests. If AI is unconfigured the AI call
+ errors out on its own (a separate, pre-existing consequence — see §7). Coupling
+ publish-state to enforcement is explicitly rejected (**D5**).
+- **No database.** Flags are env-derived, not stored/edited at runtime (**D1**).
+- **No runtime toggling UI.** The diagnostics page is read-only (**D8**).
+
+---
+
+## 3. Design overview
+
+```mermaid
+flowchart TD
+ env["env (source of truth)
EN_FEATURE_* vars + AI wiring"]
+
+ subgraph api["fluent-api"]
+ build["buildFeatures()
reads env, derives flag map"]
+ end
+
+ subgraph web["fluent-web"]
+ hook["useFeatureFlags()"]
+ gate["<FeatureGate feature=...>
Checks tab renders only when on;
hidden by default / on error"]
+ diag["/_authenticated/debug
diagnostics page (read-only)"]
+ end
+
+ env --> build
+ build -->|"GET /config/features (authenticated — login-only, no role)"| hook
+ hook --> gate
+ hook --> diag
+```
+
+- fluent-api **owns the truth** (from env) and publishes a **read-only
+ projection** of it. fluent-web never decides policy; it only reflects what the
+ API reports. (This resolves the apparent "env vs API" split: the API is not a
+ second source of truth, it is the _publication channel_ for the env truth.)
+
+---
+
+## 4. fluent-api: the flag source
+
+### 4.1 Env variables — one flat var per flag, `EN_FEATURE_*` prefix — **D1, D2**
+
+Each feature flag is its **own flat boolean env var** under a **dedicated
+`EN_FEATURE_` prefix**. The prefix is hopefully specific enough not to collide with unrelated vars. Each flag
+var is **declared explicitly in the Zod `EnvSchema`** in
+[`src/env.ts`](../../../src/env.ts):
+
+```ts
+const EnvSchema = z.object({
+ // ...existing vars...
+
+ // ── Feature flags (EN_FEATURE_*) ──────────────────────────────────────
+ // One flat boolean per optional feature. Each is declared explicitly (see
+ // D2) so the schema doubles as the authoritative catalog of known flags and
+ // validates values. All EN_FEATURE_* vars are swept into the published map
+ // (§4.3); the key after the prefix is camelCased for the wire.
+ //
+ // Repeated Word Check (the one AI-dependent feature today). Left optional so
+ // its default can be derived from AI wiring (§4.2).
+ //
+ // NB: parse with a custom string→boolean transform (`envBool()`), NOT
+ // z.coerce.boolean(). Coercion follows JS truthiness, so the string "false"
+ // would parse to `true` and silently INVERT the safe default — exactly the
+ // wrong failure mode for a flag whose job is to keep AI UI hidden.
+ //
+ // Zod's built-in `z.stringbool()` would do the job, but it is a **Zod 4**
+ // API; this repo is pinned to Zod 3.x (`@hono/zod-openapi` binds the classic
+ // v3 `z`), so `z.stringbool` does not exist here. Instead we ship a small
+ // `envBool()` helper (in this file) that parses the usual env spellings
+ // ("true"/"false", "1"/"0", "yes"/"no", "on"/"off", case-insensitive),
+ // treats blank/whitespace-only as unset (`undefined`), and rejects anything
+ // else. `.optional()` preserves the unset case so §4.2's derived default
+ // still applies. If/when the repo moves to Zod 4, `envBool()` can be
+ // replaced by `z.stringbool()`.
+ EN_FEATURE_REPEATED_WORD_CHECK: envBool().optional(),
+});
+```
+
+**Why declare each flag in the schema (a deliberate DRY violation).** We could
+sweep `process.env` for the prefix generically, but a fixed `z.object` **strips
+unknown keys** on parse — so an undeclared `EN_FEATURE_*` var would be dropped
+before we could read it. Rather than parse outside the schema, we **declare each
+flag** so: (a) the schema is the single authoritative list of known flags —
+useful documentation in its own right, alongside the existing
+[`.env.example`](../../../.env.example) (which already carries a "# Fluent-AI
+integration" section we extend); (b) values are validated/coerced like every
+other env var; (c) there are no surprises from stray env keys. Adding a flag is:
+one schema line + one `.env.example` line + one map entry (§4.3).
+
+### 4.2 Per-flag default — AI-wiring safety for the repeated-word check — **D2**
+
+The repeated-word-check flag is an explicit switch that **defaults to off when
+AI is not wired**:
+
+- If `EN_FEATURE_REPEATED_WORD_CHECK` is set explicitly → use it.
+- If unset → derive: `true` only when **both** `FLUENT_AI_URL` and
+ `FLUENT_AI_KEY` are present and non-empty; otherwise `false`.
+
+This is belt-and-suspenders: an operator can force it, but forgetting to set it
+in an environment where fluent-ai isn't wired yields the safe answer (off). It
+does **not** enforce anything — it only decides what we _publish_ (**D5**).
+Future non-AI flags need not carry this derivation; they can simply default to
+`false` when unset.
+
+### 4.3 Endpoint — **D3, D4**
+
+Add a **meta route** as a sibling of [`/health`](../../../src/routes/health.route.ts)
+in `src/routes/`:
+
+```http
+GET /config/features (authenticated — login-only session, no role; Q1 resolved)
+
+200 OK
+Content-Type: application/json
+
+{
+ "features": {
+ "repeatedWordCheck": true
+ }
+}
+
+401 Unauthorized (no valid session)
+```
+
+- Placed in `src/routes/config.route.ts`, registered on `server` like
+ `health.route.ts`. `/config` namespaces future non-feature config cleanly.
+- Returns a **named map** (`features: { : boolean }`), never a bare boolean,
+ so new flags are additive (**D6**). The map is assembled from the declared
+ `EN_FEATURE_*` vars: the part after the prefix is **camelCased** for the wire
+ (`EN_FEATURE_REPEATED_WORD_CHECK` → `repeatedWordCheck`). Today the only key is
+ `repeatedWordCheck`, whose value is the effective flag from §4.2.
+- Response schema via `@hono/zod-openapi` so it appears in the OpenAPI doc like
+ every other route.
+
+**Alternative floated (not chosen): fold into `/health`.** "What services are
+enabled" is arguably a health/readiness concern, and `/health` is already
+unauthenticated and env-derived. We could instead extend the health payload with
+a `features` block and skip a new route. Downside: it overloads a liveness probe
+with product-config semantics and couples two audiences (uptime monitors vs. the
+SPA). **Recommendation: dedicated `/config/features`; reviewer may prefer the
+`/health` merge (Q2).**
+
+---
+
+## 5. fluent-web: consuming the flags
+
+### 5.1 A small, extensible primitive — **D6, D7**
+
+Add a feature-flags module (e.g. `src/features/flags/`) with:
+
+- **`useFeatureFlags()`** — a TanStack Query hook that GETs `/config/features`
+ and returns the typed `features` map. Cached like other config; a sensible
+ `staleTime` (flags change only on deploy/config).
+- **`useFeatureFlag(name)`** — thin selector returning a single boolean.
+- **`…`** — renders
+ children only when the named flag is on.
+
+The map shape (`Record`) means adding a feature later is a
+new key + a new gate usage — **no change to the hook or the component** (**D6**).
+
+### 5.2 Fail closed (hidden) — **D7**
+
+While flags are **loading**, or if the endpoint **errors/unreachable**, gated AI
+UI is **treated as off (hidden)**. This is the safe default given the entire
+point is "don't surface features whose backend isn't there." Non-AI/base UI is
+never gated and renders normally regardless.
+
+### 5.3 Wiring the one feature today
+
+The Checks tab/panel in the drafting area (repeated-word check) is wrapped so it
+appears only when `repeatedWordCheck` is on. Relevant surfaces:
+
+- Checks panel — `fluent-web/src/features/checks/components/ChecksPanel.tsx`
+- Drafting page host — `fluent-web/src/features/bible/components/DraftingPage.tsx`
+
+Exact insertion point is an implementation detail for the fluent-web PR; the
+contract here is only "gate the AI UI on `repeatedWordCheck`, default hidden."
+
+---
+
+## 6. fluent-web: the diagnostics page — **D8**
+
+An **unlinked** page under the existing `_authenticated` layout (login required,
+**no** role check), e.g. route `/_authenticated/debug` (final path TBD in the
+web PR). Precedent: the router already has a login gate via `_authenticated` and
+a Manager-only gate on `/users` — we intentionally use only the login gate here.
+
+- **Unlinked by design**: no nav entry. "If you know about it, you know about
+ it." Access is gated by login (the `_authenticated` layout) plus the URL's
+ obscurity; with Q1 resolved to require a session, the underlying
+ `GET /config/features` endpoint is itself login-gated too. Even so, keep only
+ non-sensitive, non-secret technical/health info here — the login gate is a
+ low bar, not a data-protection boundary.
+- **Read-only**: displays the flag map as the API reports it. No toggles.
+- **Framing / future use**: a `chrome://`-style catch-all for technical details
+ — whoever keeps things running peeks here to see what's on/off and, over time,
+ other health/technical info (build/version, `/health` echo, env name, etc.).
+ So it is scoped as "diagnostics page that currently shows feature flags,"
+ not "the feature-flags page."
+
+---
+
+## 7. Interaction with the actual AI call path (out of scope, noted)
+
+Because there is **no enforcement** (**D5**), a client that hits
+`/ai/tools/greek-room/repeated-words` while the flag is off still reaches the
+existing proxy, which will fail on its own if fluent-ai is unreachable (the
+client already maps this to `AI_SERVICE_UNAVAILABLE` → 502; see
+[`fluent-ai.client.ts`](../../../src/lib/services/fluent-ai/fluent-ai.client.ts)).
+That is a **pre-existing, separate consequence** of AI not being wired — this
+proposal neither introduces nor removes it. The flag's job is purely to keep the
+UI (and thus normal users) away from that path in environments where it can't
+work.
+
+---
+
+## 8. Decisions
+
+| ID | Decision | Rationale |
+| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| D1 | Flags are **env-sourced, not stored in the DB**. | This is a deploy/wiring concern. A DB adds a migration + admin surface for something that changes only when infra changes. |
+| D2 | **One flat boolean env var per flag under the `EN_FEATURE_*` prefix**, each **declared explicitly in the Zod schema**. The repeated-word-check flag defaults OFF when `FLUENT_AI_URL`/`FLUENT_AI_KEY` are absent. | Specific prefix avoids collisions; declaring each flag makes the schema the authoritative catalog and survives Zod's unknown-key stripping. Operator can force it; forgetting it where AI isn't wired yields the safe answer. |
+| D3 | Expose via a **dedicated meta route `GET /config/features`** in `src/routes/`, sibling of `/health`. **Login-gated** (`authenticateUser`, no role) per the Q1 resolution — it is a config projection for signed-in SPA users, not a public liveness probe. | Matches the `/health` precedent (env-derived, DB-less) for structure; `/config` leaves room to grow. Auth added per Q1 (kaseywright, 2026-07-07): only logged-in users need it, so requiring a session costs nothing and keeps the surface minimal. |
+| D4 | Response is a **named map** `{ features: { : boolean } }` assembled from the `EN_FEATURE_*` vars (prefix stripped, camelCased). | Additive: new flags need no contract change; env keys never leak verbatim onto the wire. |
+| D5 | **No server-side enforcement.** Publishing is decoupled from the AI request path. | Keeps the flag a pure signal; enforcement/behavior on missing AI is a separate, pre-existing concern. |
+| D6 | fluent-web consumes flags through a **reusable `useFeatureFlags` hook + `FeatureGate` primitive** over a `Record`. | Adding a feature later is a key + a gate usage, nothing more. |
+| D7 | AI/gated UI **fails closed (hidden)** while loading or on endpoint error. | The whole point is to not surface features whose backend isn't there. |
+| D8 | A **login-gated, unlinked, read-only diagnostics page** hosts the flag view (extensible to other technical/health info). | Reuses the existing `_authenticated` gate; "know the URL" access; grows into a `chrome://`-style ops page. |
+
+---
+
+## 9. Open questions for the reviewer — RESOLVED
+
+All four were answered by **kaseywright** on **2026-07-07** (proposal PR #211
+review + follow-up on impl PR #213); the resolutions are folded into the design
+above and shipped in fluent-api #213 / fluent-web #337.
+
+1. **Endpoint auth (Q1) → REQUIRE A SESSION (changed from the draft).** The
+ floated alternative was chosen: `GET /config/features` is now **login-gated**
+ (`authenticateUser`, no role) and returns **401** without a valid session.
+ Rationale: only signed-in SPA users ever need the flag map, so requiring a
+ session costs nothing and keeps the surface minimal. This is the one change
+ from the original draft; §3/§4.3/§6 and D3 above reflect it.
+2. **Route vs. `/health` (Q2) → DEDICATED `GET /config/features`** (the
+ recommendation). Kept product-config semantics off the liveness probe.
+3. **Prefix & map key (Q3) → CONFIRMED.** `EN_FEATURE_*` prefix and
+ `EN_FEATURE_REPEATED_WORD_CHECK` → `repeatedWordCheck`, with each flag
+ declared explicitly in the schema, are accepted as-is.
+4. **Default-derivation (Q4) → KEEP THE AI-WIRING-DERIVED DEFAULT.** The unset
+ default deriving from `FLUENT_AI_URL`/`FLUENT_AI_KEY` presence is retained
+ (an earlier lean toward a plain `false` default was reversed by the reviewer
+ on #213). It is defensive belt-and-suspenders; an explicit
+ `EN_FEATURE_REPEATED_WORD_CHECK=false` is always the operator override.
+
+---
+
+## 10. Rollout
+
+1. **fluent-api PR** — add `EN_FEATURE_REPEATED_WORD_CHECK` to the env schema and
+ to [`.env.example`](../../../.env.example), add the `buildFeatures()` deriver
+ (sweep declared `EN_FEATURE_*` → camelCased map, with the AI-wiring default),
+ the `GET /config/features` route + OpenAPI schema + tests. Merges safely:
+ default is off wherever AI isn't wired.
+2. **fluent-web PR** — add `useFeatureFlags`/`FeatureGate`, gate the Checks UI
+ (default hidden), add the unlinked diagnostics page. Merges safely: with the
+ flag off (prod), the Checks UI simply doesn't render — unblocking the AI PRs.
+3. **Later, per environment** — set `EN_FEATURE_REPEATED_WORD_CHECK=true` (and
+ wire `FLUENT_AI_URL`/`FLUENT_AI_KEY`) wherever fluent-ai is actually hosted.
+ No code redeploy required to flip it.
diff --git a/docs/proposals/feature-flags/feature-flags-summary.md b/docs/proposals/feature-flags/feature-flags-summary.md
new file mode 100644
index 00000000..6bb85913
--- /dev/null
+++ b/docs/proposals/feature-flags/feature-flags-summary.md
@@ -0,0 +1,85 @@
+# Feature Flags — Review Summary
+
+**Status:** Reviewed and implemented. Q1–Q4 resolved by kaseywright (2026-07-07);
+see "Reviewer outcome" below. Shipped in fluent-api #213 / fluent-web #337. One
+change from the draft: `GET /config/features` is **authenticated** (login-only,
+no role), not unauthenticated.
+
+**Purpose:** Reviewer orientation for a lightweight feature-flag mechanism. The
+full design is in the sibling [`feature-flags-suggestion.md`](feature-flags-suggestion.md)
+(problem, design, decisions **D1–D8**, open questions **Q1–Q4**). Ships as a
+coordinated pair of PRs — fluent-api (flag source + endpoint) and fluent-web
+(consumer hook, gate primitive, diagnostics page).
+
+## The problem
+
+fluent-ai is **not hosted** in any deployed environment. Every PR that depends on
+it therefore can't merge — doing so would surface UI whose backend isn't there
+and block promoting to production, which stalls unrelated work queued behind it.
+We need to **turn AI-dependent front-end features on/off per environment** so
+their PRs can merge and ride to production **hidden**, then be switched on later
+(where fluent-ai actually runs) with a config change and no code redeploy. This
+is a **wiring/deploy concern**, not application data.
+
+## What's being proposed
+
+1. **Env-sourced flags, one flat var per flag** under a specific `EN_FEATURE_*`
+ prefix (e.g. `EN_FEATURE_REPEATED_WORD_CHECK`), each **declared explicitly in
+ the Zod env schema** — the schema doubles as the authoritative flag catalog
+ and survives Zod's unknown-key stripping. No database (**D1, D2**).
+2. **A read-only endpoint `GET /config/features`** — a new **login-gated** meta
+ route (sibling of `/health`; `authenticateUser`, no role, 401 without a
+ session) returning a **named map**,
+ `{ features: { repeatedWordCheck: true } }`, assembled from the `EN_FEATURE_*`
+ vars (prefix stripped, camelCased). New flags are purely additive (**D3, D4**).
+3. **fluent-api owns the truth, publishes a projection.** The env is the single
+ source of truth; the endpoint is the publication channel, not a second
+ source. fluent-web never decides policy — it only reflects.
+4. **Safe default.** The repeated-word-check flag defaults **off** unless
+ `FLUENT_AI_URL` + `FLUENT_AI_KEY` are both wired, so forgetting to set it in
+ an AI-less environment yields the safe answer (**D2**).
+5. **fluent-web consumes it** via a small extensible primitive — a
+ `useFeatureFlags()` TanStack Query hook + a `` wrapper over a
+ `Record`. Gated AI UI **fails closed (hidden)** while loading
+ or on endpoint error (**D6, D7**).
+6. **An unlinked, login-gated, read-only diagnostics page** (a `chrome://`-style
+ catch-all for technical/health details, starting with the flag map). Reuses
+ the existing `_authenticated` gate; no role check (**D8**).
+
+## Explicitly out of scope
+
+- **No server-side enforcement.** Turning a flag off does **not** gate the AI
+ endpoints — publishing is deliberately decoupled from the request path. If AI
+ is unconfigured, the AI call fails on its own (a pre-existing, separate
+ consequence). (**D5**)
+- No database, no runtime toggling UI, no per-user/tenant targeting, no
+ percentage rollouts. This is a per-environment on/off wiring switch.
+
+## Areas where input was most valuable (all resolved)
+
+1. **Endpoint auth (Q1).** `GET /config/features` was proposed unauthenticated;
+ the reviewer chose the floated alternative — **require a session**.
+2. **Route vs. `/health` (Q2).** Whether to fold a `features` block into
+ `/health` instead of a dedicated route.
+3. **Prefix & key naming (Q3).** `EN_FEATURE_*` prefix,
+ `EN_FEATURE_REPEATED_WORD_CHECK` → `repeatedWordCheck`, and per-flag schema
+ declaration.
+4. **Default-derivation (Q4).** Whether to keep the AI-wiring-derived unset
+ default or use a plain `false`.
+
+## Reviewer outcome
+
+Resolved by **kaseywright** on **2026-07-07** (proposal PR #211 review +
+follow-up on impl PR #213):
+
+- **Q1 → authenticate.** `GET /config/features` is **login-gated**
+ (`authenticateUser`, no role; 401 without a session) — the one change from the
+ draft. Only signed-in SPA users need the flag map.
+- **Q2 → dedicated `GET /config/features`.** Kept product-config semantics off
+ the liveness probe.
+- **Q3 → confirmed as-is.** `EN_FEATURE_*` prefix + `repeatedWordCheck` key +
+ explicit per-flag schema declaration accepted.
+- **Q4 → keep the AI-wiring-derived default.** Retained as defensive
+ belt-and-suspenders (an earlier lean toward a plain `false` was reversed by
+ the reviewer on #213); explicit `EN_FEATURE_REPEATED_WORD_CHECK=false` is the
+ operator override.
diff --git a/src/app.ts b/src/app.ts
index 53863fd1..147319e4 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -2,6 +2,7 @@ import configureOpenAPI from '@/lib/configure-open-api';
import { server } from '@/server/server';
import '@/routes/index.route';
import '@/routes/health.route';
+import '@/routes/config.route';
import '@/routes/auth-doc.route';
import '@/domains/users/users.route';
import '@/domains/users/chapter-assignments/users-chapter-assignments.route';
@@ -22,6 +23,7 @@ import '@/domains/users/projects/user-projects.route';
import '@/domains/chapter-assignments/presence/chapter-assignments-presence.route';
import '@/domains/ai-tools/ai-tools.route';
import '@/domains/pericopes/pericopes.route';
+import '@/domains/self/settings/self-settings.route';
configureOpenAPI(server);
export default server;
diff --git a/src/db/migrations/0013_add_user_settings.sql b/src/db/migrations/0013_add_user_settings.sql
new file mode 100644
index 00000000..8d856109
--- /dev/null
+++ b/src/db/migrations/0013_add_user_settings.sql
@@ -0,0 +1,8 @@
+CREATE TABLE "user_settings" (
+ "user_id" integer PRIMARY KEY NOT NULL,
+ "settings" jsonb,
+ "created_at" timestamp DEFAULT now(),
+ "updated_at" timestamp DEFAULT now()
+);
+--> statement-breakpoint
+ALTER TABLE "user_settings" ADD CONSTRAINT "user_settings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
\ No newline at end of file
diff --git a/src/db/migrations/meta/0012_snapshot.json b/src/db/migrations/meta/0012_snapshot.json
index 1d506bb8..575f6a13 100644
--- a/src/db/migrations/meta/0012_snapshot.json
+++ b/src/db/migrations/meta/0012_snapshot.json
@@ -1530,7 +1530,7 @@
"nulls": "last"
}
],
- "isUnique": false,
+ "isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
diff --git a/src/db/migrations/meta/0013_snapshot.json b/src/db/migrations/meta/0013_snapshot.json
new file mode 100644
index 00000000..08d18152
--- /dev/null
+++ b/src/db/migrations/meta/0013_snapshot.json
@@ -0,0 +1,2557 @@
+{
+ "id": "20c0729b-b802-485f-a821-a05c1933d52e",
+ "prevId": "cb933e4b-a676-4fc7-9a63-cace0c602cb3",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.active_chapter_editors": {
+ "name": "active_chapter_editors",
+ "schema": "",
+ "columns": {
+ "chapter_assignment_id": {
+ "name": "chapter_assignment_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_heartbeat": {
+ "name": "last_heartbeat",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_active_editors_chapter": {
+ "name": "idx_active_editors_chapter",
+ "columns": [
+ {
+ "expression": "chapter_assignment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "active_chapter_editors_chapter_assignment_id_chapter_assignments_id_fk": {
+ "name": "active_chapter_editors_chapter_assignment_id_chapter_assignments_id_fk",
+ "tableFrom": "active_chapter_editors",
+ "tableTo": "chapter_assignments",
+ "columnsFrom": ["chapter_assignment_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "active_chapter_editors_user_id_users_id_fk": {
+ "name": "active_chapter_editors_user_id_users_id_fk",
+ "tableFrom": "active_chapter_editors",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "active_chapter_editors_chapter_assignment_id_user_id_pk": {
+ "name": "active_chapter_editors_chapter_assignment_id_user_id_pk",
+ "columns": ["chapter_assignment_id", "user_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.auth_account": {
+ "name": "auth_account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(36)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(36)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "auth_account_user_id_auth_user_id_fk": {
+ "name": "auth_account_user_id_auth_user_id_fk",
+ "tableFrom": "auth_account",
+ "tableTo": "auth_user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.auth_audit_log": {
+ "name": "auth_audit_log",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(36)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "event": {
+ "name": "event",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'{}'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_audit_log_user": {
+ "name": "idx_audit_log_user",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_audit_log_event": {
+ "name": "idx_audit_log_event",
+ "columns": [
+ {
+ "expression": "event",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_audit_log_created": {
+ "name": "idx_audit_log_created",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "auth_audit_log_user_id_auth_user_id_fk": {
+ "name": "auth_audit_log_user_id_auth_user_id_fk",
+ "tableFrom": "auth_audit_log",
+ "tableTo": "auth_user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.auth_session": {
+ "name": "auth_session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(36)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(36)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "is_mobile": {
+ "name": "is_mobile",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "auth_session_user_id_auth_user_id_fk": {
+ "name": "auth_session_user_id_auth_user_id_fk",
+ "tableFrom": "auth_session",
+ "tableTo": "auth_user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "auth_session_token_unique": {
+ "name": "auth_session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.auth_user": {
+ "name": "auth_user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(36)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "two_factor_enabled": {
+ "name": "two_factor_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "auth_user_email_unique": {
+ "name": "auth_user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": ["email"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.auth_verification": {
+ "name": "auth_verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(36)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.bible_books": {
+ "name": "bible_books",
+ "schema": "",
+ "columns": {
+ "bible_id": {
+ "name": "bible_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "bible_books_bible_id_bibles_id_fk": {
+ "name": "bible_books_bible_id_bibles_id_fk",
+ "tableFrom": "bible_books",
+ "tableTo": "bibles",
+ "columnsFrom": ["bible_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "bible_books_book_id_books_id_fk": {
+ "name": "bible_books_book_id_books_id_fk",
+ "tableFrom": "bible_books",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.bible_texts": {
+ "name": "bible_texts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "bible_id": {
+ "name": "bible_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "chapter_number": {
+ "name": "chapter_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "verse_number": {
+ "name": "verse_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "text": {
+ "name": "text",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_bible_texts_bible_book_chapter": {
+ "name": "idx_bible_texts_bible_book_chapter",
+ "columns": [
+ {
+ "expression": "bible_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "chapter_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_bible_texts_bible_book_chapter_verse": {
+ "name": "idx_bible_texts_bible_book_chapter_verse",
+ "columns": [
+ {
+ "expression": "bible_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "chapter_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "verse_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "bible_texts_bible_id_bibles_id_fk": {
+ "name": "bible_texts_bible_id_bibles_id_fk",
+ "tableFrom": "bible_texts",
+ "tableTo": "bibles",
+ "columnsFrom": ["bible_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "bible_texts_book_id_books_id_fk": {
+ "name": "bible_texts_book_id_books_id_fk",
+ "tableFrom": "bible_texts",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.bibles": {
+ "name": "bibles",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "language_id": {
+ "name": "language_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "abbreviation": {
+ "name": "abbreviation",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "bibles_language_id_languages_id_fk": {
+ "name": "bibles_language_id_languages_id_fk",
+ "tableFrom": "bibles",
+ "tableTo": "languages",
+ "columnsFrom": ["language_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "bibles_name_unique": {
+ "name": "bibles_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["name"]
+ },
+ "bibles_abbreviation_unique": {
+ "name": "bibles_abbreviation_unique",
+ "nullsNotDistinct": false,
+ "columns": ["abbreviation"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.books": {
+ "name": "books",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "code": {
+ "name": "code",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "eng_display_name": {
+ "name": "eng_display_name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.chapter_assignment_assigned_user_history": {
+ "name": "chapter_assignment_assigned_user_history",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "chapter_assignment_id": {
+ "name": "chapter_assignment_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "assigned_user_id": {
+ "name": "assigned_user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "assignment_role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "chapter_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_ca_user_history_assignment": {
+ "name": "idx_ca_user_history_assignment",
+ "columns": [
+ {
+ "expression": "chapter_assignment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_ca_user_history_user": {
+ "name": "idx_ca_user_history_user",
+ "columns": [
+ {
+ "expression": "assigned_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "chapter_assignment_assigned_user_history_chapter_assignment_id_chapter_assignments_id_fk": {
+ "name": "chapter_assignment_assigned_user_history_chapter_assignment_id_chapter_assignments_id_fk",
+ "tableFrom": "chapter_assignment_assigned_user_history",
+ "tableTo": "chapter_assignments",
+ "columnsFrom": ["chapter_assignment_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "chapter_assignment_assigned_user_history_assigned_user_id_users_id_fk": {
+ "name": "chapter_assignment_assigned_user_history_assigned_user_id_users_id_fk",
+ "tableFrom": "chapter_assignment_assigned_user_history",
+ "tableTo": "users",
+ "columnsFrom": ["assigned_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.chapter_assignment_snapshots": {
+ "name": "chapter_assignment_snapshots",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "chapter_assignment_id": {
+ "name": "chapter_assignment_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "chapter_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "assigned_user_id": {
+ "name": "assigned_user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "content": {
+ "name": "content",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_ca_snapshots_assignment": {
+ "name": "idx_ca_snapshots_assignment",
+ "columns": [
+ {
+ "expression": "chapter_assignment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_ca_snapshots_user": {
+ "name": "idx_ca_snapshots_user",
+ "columns": [
+ {
+ "expression": "assigned_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "chapter_assignment_snapshots_chapter_assignment_id_chapter_assignments_id_fk": {
+ "name": "chapter_assignment_snapshots_chapter_assignment_id_chapter_assignments_id_fk",
+ "tableFrom": "chapter_assignment_snapshots",
+ "tableTo": "chapter_assignments",
+ "columnsFrom": ["chapter_assignment_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "chapter_assignment_snapshots_assigned_user_id_users_id_fk": {
+ "name": "chapter_assignment_snapshots_assigned_user_id_users_id_fk",
+ "tableFrom": "chapter_assignment_snapshots",
+ "tableTo": "users",
+ "columnsFrom": ["assigned_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.chapter_assignment_status_history": {
+ "name": "chapter_assignment_status_history",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "chapter_assignment_id": {
+ "name": "chapter_assignment_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "chapter_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_ca_status_history_assignment": {
+ "name": "idx_ca_status_history_assignment",
+ "columns": [
+ {
+ "expression": "chapter_assignment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "chapter_assignment_status_history_chapter_assignment_id_chapter_assignments_id_fk": {
+ "name": "chapter_assignment_status_history_chapter_assignment_id_chapter_assignments_id_fk",
+ "tableFrom": "chapter_assignment_status_history",
+ "tableTo": "chapter_assignments",
+ "columnsFrom": ["chapter_assignment_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.chapter_assignments": {
+ "name": "chapter_assignments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "project_unit_id": {
+ "name": "project_unit_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "bible_id": {
+ "name": "bible_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "chapter_number": {
+ "name": "chapter_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "assigned_user_id": {
+ "name": "assigned_user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "peer_checker_id": {
+ "name": "peer_checker_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "chapter_status": {
+ "name": "chapter_status",
+ "type": "chapter_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'not_started'"
+ },
+ "submitted_time": {
+ "name": "submitted_time",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "uq_chapter_assignment_per_chapter": {
+ "name": "uq_chapter_assignment_per_chapter",
+ "columns": [
+ {
+ "expression": "project_unit_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "bible_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "chapter_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_chapter_assignments_assigned_user": {
+ "name": "idx_chapter_assignments_assigned_user",
+ "columns": [
+ {
+ "expression": "assigned_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_chapter_assignments_peer_checker_status": {
+ "name": "idx_chapter_assignments_peer_checker_status",
+ "columns": [
+ {
+ "expression": "peer_checker_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "chapter_status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_chapter_assignments_project_unit": {
+ "name": "idx_chapter_assignments_project_unit",
+ "columns": [
+ {
+ "expression": "project_unit_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "chapter_assignments_project_unit_id_project_units_id_fk": {
+ "name": "chapter_assignments_project_unit_id_project_units_id_fk",
+ "tableFrom": "chapter_assignments",
+ "tableTo": "project_units",
+ "columnsFrom": ["project_unit_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "cascade"
+ },
+ "chapter_assignments_bible_id_bibles_id_fk": {
+ "name": "chapter_assignments_bible_id_bibles_id_fk",
+ "tableFrom": "chapter_assignments",
+ "tableTo": "bibles",
+ "columnsFrom": ["bible_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "chapter_assignments_book_id_books_id_fk": {
+ "name": "chapter_assignments_book_id_books_id_fk",
+ "tableFrom": "chapter_assignments",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "chapter_assignments_assigned_user_id_users_id_fk": {
+ "name": "chapter_assignments_assigned_user_id_users_id_fk",
+ "tableFrom": "chapter_assignments",
+ "tableTo": "users",
+ "columnsFrom": ["assigned_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "chapter_assignments_peer_checker_id_users_id_fk": {
+ "name": "chapter_assignments_peer_checker_id_users_id_fk",
+ "tableFrom": "chapter_assignments",
+ "tableTo": "users",
+ "columnsFrom": ["peer_checker_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.languages": {
+ "name": "languages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "lang_name": {
+ "name": "lang_name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "lang_name_localized": {
+ "name": "lang_name_localized",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lang_code_iso_639_3": {
+ "name": "lang_code_iso_639_3",
+ "type": "varchar(3)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "script_direction": {
+ "name": "script_direction",
+ "type": "script_direction",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'ltr'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.organizations": {
+ "name": "organizations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "organizations_name_unique": {
+ "name": "organizations_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["name"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.pericope_sets": {
+ "name": "pericope_sets",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "pericope_sets_name_unique": {
+ "name": "pericope_sets_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["name"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.pericope_verses": {
+ "name": "pericope_verses",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "pericope_set_id": {
+ "name": "pericope_set_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "chapter_number": {
+ "name": "chapter_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "verse_number": {
+ "name": "verse_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "section": {
+ "name": "section",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pericope_number": {
+ "name": "pericope_number",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pericope_title": {
+ "name": "pericope_title",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "idx_pericope_verses_set_book_chapter_verse": {
+ "name": "idx_pericope_verses_set_book_chapter_verse",
+ "columns": [
+ {
+ "expression": "pericope_set_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "chapter_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "verse_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_pericope_verses_set_book_pericope": {
+ "name": "idx_pericope_verses_set_book_pericope",
+ "columns": [
+ {
+ "expression": "pericope_set_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "pericope_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "chapter_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "verse_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "pericope_verses_pericope_set_id_pericope_sets_id_fk": {
+ "name": "pericope_verses_pericope_set_id_pericope_sets_id_fk",
+ "tableFrom": "pericope_verses",
+ "tableTo": "pericope_sets",
+ "columnsFrom": ["pericope_set_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "pericope_verses_book_id_books_id_fk": {
+ "name": "pericope_verses_book_id_books_id_fk",
+ "tableFrom": "pericope_verses",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.permissions": {
+ "name": "permissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "permissions_name_unique": {
+ "name": "permissions_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["name"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.project_unit_bible_books": {
+ "name": "project_unit_bible_books",
+ "schema": "",
+ "columns": {
+ "project_unit_id": {
+ "name": "project_unit_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "bible_id": {
+ "name": "bible_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "project_unit_bible_books_project_unit_id_project_units_id_fk": {
+ "name": "project_unit_bible_books_project_unit_id_project_units_id_fk",
+ "tableFrom": "project_unit_bible_books",
+ "tableTo": "project_units",
+ "columnsFrom": ["project_unit_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "cascade"
+ },
+ "project_unit_bible_books_bible_id_bibles_id_fk": {
+ "name": "project_unit_bible_books_bible_id_bibles_id_fk",
+ "tableFrom": "project_unit_bible_books",
+ "tableTo": "bibles",
+ "columnsFrom": ["bible_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "project_unit_bible_books_book_id_books_id_fk": {
+ "name": "project_unit_bible_books_book_id_books_id_fk",
+ "tableFrom": "project_unit_bible_books",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.project_units": {
+ "name": "project_units",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "project_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'not_started'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "project_units_project_id_projects_id_fk": {
+ "name": "project_units_project_id_projects_id_fk",
+ "tableFrom": "project_units",
+ "tableTo": "projects",
+ "columnsFrom": ["project_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.project_users": {
+ "name": "project_users",
+ "schema": "",
+ "columns": {
+ "project_id": {
+ "name": "project_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_project_users_project": {
+ "name": "idx_project_users_project",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_project_users_user": {
+ "name": "idx_project_users_user",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "project_users_project_id_projects_id_fk": {
+ "name": "project_users_project_id_projects_id_fk",
+ "tableFrom": "project_users",
+ "tableTo": "projects",
+ "columnsFrom": ["project_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "cascade"
+ },
+ "project_users_user_id_users_id_fk": {
+ "name": "project_users_user_id_users_id_fk",
+ "tableFrom": "project_users",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {
+ "project_users_project_id_user_id_pk": {
+ "name": "project_users_project_id_user_id_pk",
+ "columns": ["project_id", "user_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.projects": {
+ "name": "projects",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_language": {
+ "name": "source_language",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_language": {
+ "name": "target_language",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organization": {
+ "name": "organization",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": true
+ },
+ "status": {
+ "name": "status",
+ "type": "project_assignment_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'not_assigned'"
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "pericope_set_id": {
+ "name": "pericope_set_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "projects_source_language_languages_id_fk": {
+ "name": "projects_source_language_languages_id_fk",
+ "tableFrom": "projects",
+ "tableTo": "languages",
+ "columnsFrom": ["source_language"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "projects_target_language_languages_id_fk": {
+ "name": "projects_target_language_languages_id_fk",
+ "tableFrom": "projects",
+ "tableTo": "languages",
+ "columnsFrom": ["target_language"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "projects_organization_organizations_id_fk": {
+ "name": "projects_organization_organizations_id_fk",
+ "tableFrom": "projects",
+ "tableTo": "organizations",
+ "columnsFrom": ["organization"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "projects_created_by_users_id_fk": {
+ "name": "projects_created_by_users_id_fk",
+ "tableFrom": "projects",
+ "tableTo": "users",
+ "columnsFrom": ["created_by"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "projects_pericope_set_id_pericope_sets_id_fk": {
+ "name": "projects_pericope_set_id_pericope_sets_id_fk",
+ "tableFrom": "projects",
+ "tableTo": "pericope_sets",
+ "columnsFrom": ["pericope_set_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.role_permissions": {
+ "name": "role_permissions",
+ "schema": "",
+ "columns": {
+ "role_id": {
+ "name": "role_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission_id": {
+ "name": "permission_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_role_permissions_role": {
+ "name": "idx_role_permissions_role",
+ "columns": [
+ {
+ "expression": "role_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "role_permissions_role_id_roles_id_fk": {
+ "name": "role_permissions_role_id_roles_id_fk",
+ "tableFrom": "role_permissions",
+ "tableTo": "roles",
+ "columnsFrom": ["role_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "role_permissions_permission_id_permissions_id_fk": {
+ "name": "role_permissions_permission_id_permissions_id_fk",
+ "tableFrom": "role_permissions",
+ "tableTo": "permissions",
+ "columnsFrom": ["permission_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "role_permissions_role_id_permission_id_pk": {
+ "name": "role_permissions_role_id_permission_id_pk",
+ "columns": ["role_id", "permission_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.roles": {
+ "name": "roles",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "roles_name_unique": {
+ "name": "roles_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["name"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.translated_verses": {
+ "name": "translated_verses",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "project_unit_id": {
+ "name": "project_unit_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "bible_text_id": {
+ "name": "bible_text_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "assigned_user_id": {
+ "name": "assigned_user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "uq_translated_verse_per_bible_text": {
+ "name": "uq_translated_verse_per_bible_text",
+ "columns": [
+ {
+ "expression": "project_unit_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "bible_text_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "translated_verses_project_unit_id_project_units_id_fk": {
+ "name": "translated_verses_project_unit_id_project_units_id_fk",
+ "tableFrom": "translated_verses",
+ "tableTo": "project_units",
+ "columnsFrom": ["project_unit_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "cascade"
+ },
+ "translated_verses_bible_text_id_bible_texts_id_fk": {
+ "name": "translated_verses_bible_text_id_bible_texts_id_fk",
+ "tableFrom": "translated_verses",
+ "tableTo": "bible_texts",
+ "columnsFrom": ["bible_text_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "translated_verses_assigned_user_id_users_id_fk": {
+ "name": "translated_verses_assigned_user_id_users_id_fk",
+ "tableFrom": "translated_verses",
+ "tableTo": "users",
+ "columnsFrom": ["assigned_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_chapter_assignment_editor_state": {
+ "name": "user_chapter_assignment_editor_state",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "chapter_assignment_id": {
+ "name": "chapter_assignment_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resources": {
+ "name": "resources",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "uq_user_chapter_assignment_editor_state": {
+ "name": "uq_user_chapter_assignment_editor_state",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "chapter_assignment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_chapter_assignment_editor_state_user_id_users_id_fk": {
+ "name": "user_chapter_assignment_editor_state_user_id_users_id_fk",
+ "tableFrom": "user_chapter_assignment_editor_state",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_chapter_assignment_editor_state_chapter_assignment_id_chapter_assignments_id_fk": {
+ "name": "user_chapter_assignment_editor_state_chapter_assignment_id_chapter_assignments_id_fk",
+ "tableFrom": "user_chapter_assignment_editor_state",
+ "tableTo": "chapter_assignments",
+ "columnsFrom": ["chapter_assignment_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_settings": {
+ "name": "user_settings",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "settings": {
+ "name": "settings",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_settings_user_id_users_id_fk": {
+ "name": "user_settings_user_id_users_id_fk",
+ "tableFrom": "user_settings",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "auth_user_id": {
+ "name": "auth_user_id",
+ "type": "varchar(36)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "username": {
+ "name": "username",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "first_name": {
+ "name": "first_name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_name": {
+ "name": "last_name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "role": {
+ "name": "role",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organization": {
+ "name": "organization",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "user_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'invited'"
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "users_auth_user_id_auth_user_id_fk": {
+ "name": "users_auth_user_id_auth_user_id_fk",
+ "tableFrom": "users",
+ "tableTo": "auth_user",
+ "columnsFrom": ["auth_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "users_role_roles_id_fk": {
+ "name": "users_role_roles_id_fk",
+ "tableFrom": "users",
+ "tableTo": "roles",
+ "columnsFrom": ["role"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "users_organization_organizations_id_fk": {
+ "name": "users_organization_organizations_id_fk",
+ "tableFrom": "users",
+ "tableTo": "organizations",
+ "columnsFrom": ["organization"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "users_created_by_users_id_fk": {
+ "name": "users_created_by_users_id_fk",
+ "tableFrom": "users",
+ "tableTo": "users",
+ "columnsFrom": ["created_by"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_username_unique": {
+ "name": "users_username_unique",
+ "nullsNotDistinct": false,
+ "columns": ["username"]
+ },
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": ["email"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.assignment_role": {
+ "name": "assignment_role",
+ "schema": "public",
+ "values": ["drafter", "peer_checker"]
+ },
+ "public.chapter_status": {
+ "name": "chapter_status",
+ "schema": "public",
+ "values": [
+ "not_started",
+ "draft",
+ "peer_check",
+ "community_review",
+ "linguist_check",
+ "theological_check",
+ "consultant_check",
+ "complete"
+ ]
+ },
+ "public.project_assignment_status": {
+ "name": "project_assignment_status",
+ "schema": "public",
+ "values": ["active", "not_assigned"]
+ },
+ "public.project_status": {
+ "name": "project_status",
+ "schema": "public",
+ "values": ["not_started", "in_progress", "completed"]
+ },
+ "public.script_direction": {
+ "name": "script_direction",
+ "schema": "public",
+ "values": ["ltr", "rtl"]
+ },
+ "public.user_status": {
+ "name": "user_status",
+ "schema": "public",
+ "values": ["invited", "verified", "inactive"]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json
index 3532543f..2a7331fb 100644
--- a/src/db/migrations/meta/_journal.json
+++ b/src/db/migrations/meta/_journal.json
@@ -92,6 +92,13 @@
"when": 1782282922268,
"tag": "0012_add_pericope_sets",
"breakpoints": true
+ },
+ {
+ "idx": 13,
+ "version": "7",
+ "when": 1783084537563,
+ "tag": "0013_add_user_settings",
+ "breakpoints": true
}
]
}
diff --git a/src/db/schema.editor-state-extension.test.ts b/src/db/schema.editor-state-extension.test.ts
new file mode 100644
index 00000000..3049c847
--- /dev/null
+++ b/src/db/schema.editor-state-extension.test.ts
@@ -0,0 +1,105 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ editorStateResourcesSchema,
+ userSettingsSchema,
+ userSettingsWriteSchema,
+} from '@/db/schema';
+
+// Phase 1 (Repeated Word Check) extends the existing editor-state JSONB schema
+// with two OPTIONAL keys and adds the user-global settings blob. These tests
+// prove backward compatibility (old rows parse unchanged) and the new shapes.
+
+const LEGACY_EDITOR_STATE = {
+ activeResource: 'aquifer',
+ bookCode: 'JDG',
+ chapterNumber: 4,
+ verseNumber: 3,
+ languageCode: 'eng',
+ tabStatus: true,
+};
+
+describe('editorStateResourcesSchema — Repeated Word Check extension', () => {
+ it('parses a legacy row (no new keys) unchanged — backward compatible', () => {
+ const parsed = editorStateResourcesSchema.parse(LEGACY_EDITOR_STATE);
+ expect(parsed).toEqual(LEGACY_EDITOR_STATE);
+ });
+
+ it('still accepts null (the column is nullable)', () => {
+ expect(editorStateResourcesSchema.parse(null)).toBeNull();
+ });
+
+ it('round-trips activeLeftTab and checkOccurrenceRules', () => {
+ const withChecks = {
+ ...LEGACY_EDITOR_STATE,
+ activeLeftTab: 'checks' as const,
+ checkOccurrenceRules: {
+ 'JDG 4:3|the the|0': 'suppress' as const,
+ 'JDG 4:3|and and|1': 'surface' as const,
+ },
+ };
+ expect(editorStateResourcesSchema.parse(withChecks)).toEqual(withChecks);
+ });
+
+ it('rejects an invalid activeLeftTab value', () => {
+ expect(() =>
+ editorStateResourcesSchema.parse({ ...LEGACY_EDITOR_STATE, activeLeftTab: 'nope' })
+ ).toThrow();
+ });
+
+ it('rejects an invalid occurrence-rule verdict', () => {
+ expect(() =>
+ editorStateResourcesSchema.parse({
+ ...LEGACY_EDITOR_STATE,
+ checkOccurrenceRules: { 'JDG 4:3|the the|0': 'ignore' },
+ })
+ ).toThrow();
+ });
+});
+
+describe('userSettingsSchema — user-global settings blob (W8)', () => {
+ it('parses an empty blob', () => {
+ expect(userSettingsSchema.parse({})).toEqual({});
+ });
+
+ it('round-trips checkIgnoredWordPairs', () => {
+ const blob = { checkIgnoredWordPairs: { 'the the': 'suppress' as const } };
+ expect(userSettingsSchema.parse(blob)).toEqual(blob);
+ });
+
+ it('.catch({}) — an unknown/old top-level shape collapses to {} rather than throwing', () => {
+ expect(userSettingsSchema.parse({ checkIgnoredWordPairs: 'not-a-record' })).toEqual({});
+ expect(userSettingsSchema.parse('totally wrong')).toEqual({});
+ });
+});
+
+describe('userSettingsWriteSchema — strict write path (A4)', () => {
+ // The write schema is deliberately the strict object schema WITHOUT the
+ // `.catch({})` fallback used on the read path. A malformed write must be
+ // surfaced (the route maps this to a 422) rather than silently swallowed
+ // and persisted as an empty blob.
+ it('accepts a well-formed write blob', () => {
+ const blob = { checkIgnoredWordPairs: { 'the the': 'suppress' as const } };
+ expect(userSettingsWriteSchema.parse(blob)).toEqual(blob);
+ });
+
+ it('accepts an empty write blob', () => {
+ expect(userSettingsWriteSchema.parse({})).toEqual({});
+ });
+
+ it('rejects an invalid verdict value rather than collapsing to {}', () => {
+ expect(() =>
+ userSettingsWriteSchema.parse({ checkIgnoredWordPairs: { 'the the': 'ignore' } })
+ ).toThrow();
+ });
+
+ it('rejects a non-record checkIgnoredWordPairs rather than collapsing to {}', () => {
+ expect(() =>
+ userSettingsWriteSchema.parse({ checkIgnoredWordPairs: 'not-a-record' })
+ ).toThrow();
+ });
+
+ it('rejects a non-object top-level shape rather than collapsing to {}', () => {
+ expect(() => userSettingsWriteSchema.parse('totally wrong')).toThrow();
+ });
+});
diff --git a/src/db/schema.ts b/src/db/schema.ts
index 9d019c11..3835585a 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -457,9 +457,63 @@ export const editorStateResourcesSchema = z
verseNumber: z.number(),
languageCode: z.string().min(1),
tabStatus: z.boolean(),
+ // ── Repeated Word Check additions (optional; old rows parse unchanged — no migration) ──
+ // Which left-panel tab is active (Resources vs Checks).
+ activeLeftTab: z.enum(['resources', 'checks']).optional(),
+ // Per-occurrence ignore rules for repeated-word findings, keyed by
+ // "{snt_id}|{repeated_word}|{ordinal}" (see fluent-web useResolvedFindings).
+ checkOccurrenceRules: z.record(z.string(), z.enum(['suppress', 'surface'])).optional(),
})
.nullable();
+// ─── User-global settings (Fluent preference store; W2/W7) ───────────────────
+// One row per user, a single Zod-typed JSONB blob. `.catch({})` so unknown/old
+// shapes parse as empty rather than throwing (W8). Surfaced via GET/PUT /self/settings.
+//
+// ⚠️ IMPLEMENTATION NOTE — full-replace today; ADD MERGE BEFORE A SECOND KEY ⚠️
+// `PUT /self/settings` is a deliberate **full-replace** of the whole blob
+// (last-writer-wins; no PATCH/ETags — §8.1/§8.3). That is safe ONLY while there
+// is exactly ONE key (`checkIgnoredWordPairs`): the client just GETs, edits the
+// one key, and PUTs the whole blob back. There is no server-side merge.
+//
+// Because this is a plain `z.object`, the write schema **strips any unknown
+// key**, so a sibling setting cannot even reach the store today — which means
+// this object is the *only* gate: you literally cannot introduce a second
+// setting without editing THIS schema. So, before adding any second key here,
+// you MUST also make the write path merge instead of replace, or the
+// full-replace PUT will silently drop whichever key the caller didn't send.
+// Recommended at that point (in `self-settings.service.upsertSettings`):
+// 1. GET the existing stored blob for the user.
+// 2. Shallow-merge the incoming keys over it (set provided keys; treat an
+// explicit `null` value as "delete this key"; leave absent keys untouched).
+// 3. Persist the merged blob.
+// Until then, keep the single-key full-replace contract.
+// The bare object shape (no `.catch`). This is also the OpenAPI-facing schema:
+// the `.catch({})` wrapper below produces a `ZodCatch` node that
+// @asteasolutions/zod-to-openapi (under @hono/zod-openapi) cannot render — it
+// throws "Unknown zod object type" while building the spec, which 500s `/doc`.
+// The `.catch` only changes behaviour on INVALID input (see `userSettingsSchema`);
+// it adds/removes no fields, so the documented shape is identical either way.
+// Anything exposed on the OpenAPI surface must therefore embed THIS schema, not
+// the `.catch` variant. (Guarded by src/routes/doc.route.test.ts.)
+export const userSettingsObjectSchema = z.object({
+ // Global "Ignore Everywhere" rules, keyed by the NFC-normalized repeated-word
+ // pair string (e.g. "the the"); applies across all of the user's projects.
+ checkIgnoredWordPairs: z.record(z.string(), z.enum(['suppress', 'surface'])).optional(),
+});
+
+// Read/storage schema: `.catch({})` so unknown/old shapes parse as empty rather
+// than throwing (W8). Use this when reading rows back from the DB. NOTE: this is
+// a `ZodCatch` — keep it OFF the OpenAPI surface (see the note on
+// `userSettingsObjectSchema` above); expose the plain object shape there instead.
+export const userSettingsSchema = userSettingsObjectSchema.catch({});
+
+// Strict write schema (no `.catch`): a malformed *incoming* PUT body is rejected
+// (surfaced as a 422 VALIDATION_ERROR — a well-formed request whose contents fail
+// schema validation; see self-settings.service.upsertSettings) instead of being
+// silently swallowed.
+export const userSettingsWriteSchema = userSettingsObjectSchema;
+
export const user_chapter_assignment_editor_state = pgTable(
'user_chapter_assignment_editor_state',
{
@@ -483,6 +537,17 @@ export const user_chapter_assignment_editor_state = pgTable(
]
);
+export const user_settings = pgTable('user_settings', {
+ userId: integer('user_id')
+ .primaryKey()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ settings: jsonb('settings').$type>(),
+ createdAt: timestamp('created_at').defaultNow(),
+ updatedAt: timestamp('updated_at')
+ .defaultNow()
+ .$onUpdate(() => new Date()),
+});
+
export const project_users = pgTable(
'project_users',
{
@@ -578,6 +643,7 @@ export const selectUserChapterAssignmentEditorStateSchema = createSelectSchema(
user_chapter_assignment_editor_state
);
export const selectProjectUsersSchema = createSelectSchema(project_users);
+export const selectUserSettingsSchema = createSelectSchema(user_settings);
export const selectPermissionsSchema = createSelectSchema(permissions);
export const selectRolePermissionsSchema = createSelectSchema(role_permissions);
export const selectActiveChapterEditorsSchema = createSelectSchema(active_chapter_editors);
@@ -841,6 +907,18 @@ export const insertProjectUsersSchema = createInsertSchema(project_users, {
createdAt: true,
});
+export const insertUserSettingsSchema = createInsertSchema(user_settings, {
+ userId: (schema) => schema.int(),
+ settings: () => userSettingsSchema,
+})
+ .required({
+ userId: true,
+ })
+ .omit({
+ createdAt: true,
+ updatedAt: true,
+ });
+
export const insertPermissionsSchema = createInsertSchema(permissions, {
name: (schema) => schema.min(1).max(100),
description: (schema) => schema.max(255).optional(),
diff --git a/src/db/scripts/bootstrap.ts b/src/db/scripts/bootstrap.ts
index c093e549..5ad74e8a 100644
--- a/src/db/scripts/bootstrap.ts
+++ b/src/db/scripts/bootstrap.ts
@@ -45,9 +45,79 @@ if (runtime.database !== database || migrator.database !== database) {
);
}
+/**
+ * Transient errors seen while Postgres is up-but-not-ready. `pg_isready` (the
+ * compose healthcheck) reports success as soon as the postmaster accepts
+ * connections, but during startup/WAL-recovery the server still rejects real
+ * queries with `57P03` ("the database system is starting up"). On a restart the
+ * API therefore wins the race, fires its first bootstrap query, and dies. These
+ * are retryable — the DB just needs a moment — so we poll until a trivial query
+ * succeeds instead of crashing the container (and taking its dependents down).
+ */
+const TRANSIENT_STARTUP_CODES = new Set([
+ '57P03', // cannot_connect_now — the database system is starting up
+ '57P02', // crash_shutdown_in_progress
+ '57P01', // admin_shutdown / terminating connection
+ '08006', // connection_failure
+ '08001', // sqlclient_unable_to_establish_sqlconnection
+ '08004', // sqlserver_rejected_establishment_of_sqlconnection
+]);
+
+/** Node/socket-level errors that mean "DB not accepting connections yet". */
+const TRANSIENT_SOCKET_CODES = new Set([
+ 'ECONNREFUSED',
+ 'ECONNRESET',
+ 'ENOTFOUND', // DNS for the `db` service name may not resolve at first
+ 'ETIMEDOUT',
+ 'EAI_AGAIN',
+]);
+
+function isTransientDbError(err: unknown): boolean {
+ const code = (err as { code?: string })?.code;
+ return (
+ typeof code === 'string' &&
+ (TRANSIENT_STARTUP_CODES.has(code) || TRANSIENT_SOCKET_CODES.has(code))
+ );
+}
+
+const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms));
+
+/**
+ * Poll until the database actually answers a query (not just accepts a socket),
+ * so bootstrap runs against a server that is past its startup phase. Retries
+ * only transient startup/connection errors; a real error (bad credentials, etc.)
+ * fails fast. Bounded so a genuinely-down DB still surfaces after ~1 minute.
+ */
+async function waitForDatabase(sql: postgres.Sql): Promise {
+ const maxAttempts = 30;
+ const delayMs = 2000; // ~60s total ceiling
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ try {
+ await sql`SELECT 1`;
+ if (attempt > 1) {
+ console.log(`Database ready after ${attempt} attempt(s).`);
+ }
+ return;
+ } catch (err) {
+ if (!isTransientDbError(err) || attempt === maxAttempts) throw err;
+ const code = (err as { code?: string }).code;
+ console.log(
+ `Database not ready yet (attempt ${attempt}/${maxAttempts}, ${code}); ` +
+ `retrying in ${delayMs}ms...`
+ );
+ await sleep(delayMs);
+ }
+ }
+}
+
async function main() {
const sql = postgres(bootstrapUrl!, { max: 1 });
try {
+ // The compose healthcheck (pg_isready) can report "ready" while Postgres is
+ // still starting up and rejecting queries with 57P03; wait for a real query
+ // to succeed before issuing any DDL, so a restart doesn't lose the race.
+ await waitForDatabase(sql);
+
// Ask Postgres to produce safe identifier / literal text so special
// characters in role names, the db name, or passwords cannot break the DDL.
const ident = async (name: string): Promise => {
diff --git a/src/db/seeds/data/languages.json b/src/db/seeds/data/languages.json
index 0245a067..d9647e15 100644
--- a/src/db/seeds/data/languages.json
+++ b/src/db/seeds/data/languages.json
@@ -23,5 +23,11 @@
"lang_name_localized" : "कुक्णा",
"lang_code_iso_639_3" : "kex",
"script_direction" : "ltr"
+ },
+ {
+ "lang_name" : "English",
+ "lang_name_localized" : "English",
+ "lang_code_iso_639_3" : "eng",
+ "script_direction" : "ltr"
}
]}
diff --git a/src/db/seeds/dev-users.ts b/src/db/seeds/dev-users.ts
index 3f39f9b0..6081b424 100644
--- a/src/db/seeds/dev-users.ts
+++ b/src/db/seeds/dev-users.ts
@@ -30,6 +30,12 @@ export async function seedDevUsers() {
username: 'translator',
roleName: ROLES.TRANSLATOR,
},
+ {
+ email: process.env.SEED_TRANSLATOR2_EMAIL ?? 't2@fluent.local',
+ password: process.env.SEED_TRANSLATOR2_PASSWORD ?? 't2@123456',
+ username: 'translator2',
+ roleName: ROLES.TRANSLATOR,
+ },
];
const [defaultOrg] = await db
diff --git a/src/domains/chapter-assignments/chapter-assignments.repository.ts b/src/domains/chapter-assignments/chapter-assignments.repository.ts
index ae4eac47..2baf7f05 100644
--- a/src/domains/chapter-assignments/chapter-assignments.repository.ts
+++ b/src/domains/chapter-assignments/chapter-assignments.repository.ts
@@ -383,6 +383,9 @@ export async function findAssignmentsProgress(
chapterNumber: chapter_assignments.chapterNumber,
status: chapter_assignments.status,
targetLanguage: targetLang.langName,
+ // ISO 639-3 code consumed by the repeated-words check as greek-room's
+ // lang_code (whitelist is keyed on the code). See phase-04 BUG #2.
+ targetLangCode: targetLang.langCodeIso6393,
sourceLangCode: sourceLang.langCodeIso6393,
totalVerses: sql`COUNT(${bible_texts.id})`.mapWith(Number),
completedVerses:
@@ -448,6 +451,7 @@ export async function findAssignmentsProgress(
projects.name,
bibles.name,
targetLang.langName,
+ targetLang.langCodeIso6393,
sourceLang.langCodeIso6393,
books.code,
books.eng_display_name,
diff --git a/src/domains/chapter-assignments/chapter-assignments.types.ts b/src/domains/chapter-assignments/chapter-assignments.types.ts
index 767b7856..784a337c 100644
--- a/src/domains/chapter-assignments/chapter-assignments.types.ts
+++ b/src/domains/chapter-assignments/chapter-assignments.types.ts
@@ -49,7 +49,14 @@ export interface ChapterAssignmentProgressInfo {
bookNameEng: string;
chapterNumber: number;
status: string;
+ /** Human-readable target language display NAME, e.g. "English". */
targetLanguage: string | null;
+ /**
+ * ISO 639-3 target language CODE, e.g. "eng". Consumed by the repeated-words
+ * check as greek-room's `lang_code` (which keys its legitimate-duplicate
+ * whitelist on the ISO code). See phase-04 manual smoke (BUG #2).
+ */
+ targetLangCode: string | null;
sourceLangCode: string | null;
totalVerses: number;
completedVerses: number;
diff --git a/src/domains/projects/chapter-assignments/project-chapter-assignments.service.ts b/src/domains/projects/chapter-assignments/project-chapter-assignments.service.ts
index 129e981d..3b6d450c 100644
--- a/src/domains/projects/chapter-assignments/project-chapter-assignments.service.ts
+++ b/src/domains/projects/chapter-assignments/project-chapter-assignments.service.ts
@@ -1,3 +1,4 @@
+import type { ChapterAssignmentProgressInfo } from '@/domains/chapter-assignments/chapter-assignments.types';
import type { Result } from '@/lib/types';
import { db } from '@/db';
@@ -29,11 +30,14 @@ export function deleteChapterAssignmentsByProject(projectId: number) {
return repo.deleteByProject(projectId);
}
-export async function getChapterAssignmentProgressByProject(projectId: number) {
- const result = await chapterAssignmentService.getAssignmentsProgress({ projectId });
- if (!result.ok) return result;
-
- const mapped = result.data.map((info) => ({
+/**
+ * Map a repository `ChapterAssignmentProgressInfo` row to the wire-shape
+ * `ChapterAssignmentProgress` response. Shared by the project-progress and
+ * assign-selected endpoints so the field mapping (incl. `targetLangCode`) lives
+ * in exactly one place.
+ */
+function toChapterAssignmentProgressResponse(info: ChapterAssignmentProgressInfo) {
+ return {
assignmentId: info.assignmentId,
projectUnitId: info.projectUnitId,
status: info.status,
@@ -43,6 +47,7 @@ export async function getChapterAssignmentProgressByProject(projectId: number) {
bookId: info.bookId,
bookCode: info.bookCode,
sourceLangCode: info.sourceLangCode ?? '',
+ targetLangCode: info.targetLangCode ?? '',
assignedUser: info.assignedUserId
? { id: info.assignedUserId, displayName: info.assignedUserDisplayName ?? '' }
: null,
@@ -54,7 +59,14 @@ export async function getChapterAssignmentProgressByProject(projectId: number) {
createdAt: info.createdAt,
updatedAt: info.updatedAt,
submittedTime: info.submittedTime,
- }));
+ };
+}
+
+export async function getChapterAssignmentProgressByProject(projectId: number) {
+ const result = await chapterAssignmentService.getAssignmentsProgress({ projectId });
+ if (!result.ok) return result;
+
+ const mapped = result.data.map(toChapterAssignmentProgressResponse);
return ok(mapped);
}
@@ -171,28 +183,7 @@ export async function assignSelectedChapters(
const mapped = assignmentsResult.data
.filter((info) => updatedIds.includes(info.assignmentId))
- .map((info) => ({
- assignmentId: info.assignmentId,
- projectUnitId: info.projectUnitId,
- status: info.status,
- bookNameEng: info.bookNameEng,
- chapterNumber: info.chapterNumber,
- bibleId: info.bibleId,
- bookId: info.bookId,
- bookCode: info.bookCode,
- sourceLangCode: info.sourceLangCode ?? '',
- assignedUser: info.assignedUserId
- ? { id: info.assignedUserId, displayName: info.assignedUserDisplayName ?? '' }
- : null,
- peerChecker: info.peerCheckerId
- ? { id: info.peerCheckerId, displayName: info.peerCheckerDisplayName ?? '' }
- : null,
- totalVerses: info.totalVerses,
- completedVerses: info.completedVerses,
- createdAt: info.createdAt,
- updatedAt: info.updatedAt,
- submittedTime: info.submittedTime,
- }));
+ .map(toChapterAssignmentProgressResponse);
return ok(mapped);
});
diff --git a/src/domains/projects/chapter-assignments/project-chapter-assignments.types.ts b/src/domains/projects/chapter-assignments/project-chapter-assignments.types.ts
index 107aaa04..575bf557 100644
--- a/src/domains/projects/chapter-assignments/project-chapter-assignments.types.ts
+++ b/src/domains/projects/chapter-assignments/project-chapter-assignments.types.ts
@@ -20,6 +20,11 @@ export const chapterAssignmentProgressResponseSchema = z.object({
bookId: z.number(),
bookCode: z.string(),
sourceLangCode: z.string(),
+ // ISO 639-3 target language CODE, e.g. "eng". Consumed by the repeated-words
+ // check as greek-room's lang_code (whitelist is keyed on the code). The PM
+ // "open chapter" path builds its ProjectItem from this response, so the code
+ // must be surfaced here too (not only on the user-assignments endpoint).
+ targetLangCode: z.string(),
status: z.string(),
bookNameEng: z.string(),
chapterNumber: z.number(),
diff --git a/src/domains/self/settings/self-settings.repository.ts b/src/domains/self/settings/self-settings.repository.ts
new file mode 100644
index 00000000..6c437176
--- /dev/null
+++ b/src/domains/self/settings/self-settings.repository.ts
@@ -0,0 +1,65 @@
+import { eq, sql } from 'drizzle-orm';
+
+import type { Result } from '@/lib/types';
+
+import { db } from '@/db';
+import { user_settings } from '@/db/schema';
+import { logger } from '@/lib/logger';
+import { err, ErrorCode, ok } from '@/lib/types';
+
+import type { UpsertUserSettingsInput, UserSettings } from './self-settings.types';
+
+export interface UserSettingsRow {
+ settings: UserSettings | null;
+ updatedAt: Date | null;
+}
+
+export async function findByUser(userId: number): Promise> {
+ try {
+ const [result] = await db
+ .select({
+ settings: user_settings.settings,
+ updatedAt: user_settings.updatedAt,
+ })
+ .from(user_settings)
+ .where(eq(user_settings.userId, userId))
+ .limit(1);
+
+ return ok(result ?? null);
+ } catch (error) {
+ logger.error({
+ cause: error,
+ message: 'Failed to fetch user settings',
+ context: { userId },
+ });
+ return err(ErrorCode.INTERNAL_ERROR);
+ }
+}
+
+export async function upsert(input: UpsertUserSettingsInput): Promise> {
+ try {
+ const [result] = await db
+ .insert(user_settings)
+ .values({ userId: input.userId, settings: input.settings })
+ .onConflictDoUpdate({
+ target: user_settings.userId,
+ set: {
+ settings: sql`excluded.settings`,
+ updatedAt: sql`now()`,
+ },
+ })
+ .returning({
+ settings: user_settings.settings,
+ updatedAt: user_settings.updatedAt,
+ });
+
+ return ok(result);
+ } catch (error) {
+ logger.error({
+ cause: error,
+ message: 'Failed to upsert user settings',
+ context: { userId: input.userId },
+ });
+ return err(ErrorCode.INTERNAL_ERROR);
+ }
+}
diff --git a/src/domains/self/settings/self-settings.route.test.ts b/src/domains/self/settings/self-settings.route.test.ts
new file mode 100644
index 00000000..6a95c93e
--- /dev/null
+++ b/src/domains/self/settings/self-settings.route.test.ts
@@ -0,0 +1,248 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { getUserByEmail } from '@/domains/users/users.service';
+import { auth } from '@/lib/auth';
+import { roleHasPermission } from '@/lib/services/permissions/permissions.service';
+import { server } from '@/server/server';
+
+import type { UserSettingsRow } from './self-settings.repository';
+
+import '@/domains/self/settings/self-settings.route';
+
+// ─── Module mocks ─────────────────────────────────────────────────────────────
+
+vi.mock('@/lib/auth', () => ({
+ auth: {
+ api: { getSession: vi.fn() },
+ handler: vi.fn(),
+ },
+}));
+
+vi.mock('@/db', () => ({
+ db: { select: vi.fn(), insert: vi.fn(), update: vi.fn() },
+}));
+
+vi.mock('@/lib/logger', () => ({
+ logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
+}));
+
+vi.mock('@/domains/users/users.service', () => ({
+ getUserByEmail: vi.fn(),
+}));
+
+vi.mock('@/lib/services/permissions/permissions.service', () => ({
+ roleHasPermission: vi.fn(),
+}));
+
+// In-memory repository so the service logic (full-replace, `.catch({})`
+// normalization, toResponse, user isolation) is genuinely exercised. Tests can
+// also seed `store` directly to simulate a malformed/legacy stored blob that
+// never went through the write validator (see the read-path normalization test).
+const store = new Map();
+
+vi.mock('./self-settings.repository', () => ({
+ findByUser: vi.fn(async (userId: number) => ({ ok: true, data: store.get(userId) ?? null })),
+ upsert: vi.fn(async (input: { userId: number; settings: unknown }) => {
+ const row: UserSettingsRow = {
+ settings: input.settings as never,
+ updatedAt: new Date('2026-06-18T00:00:00.000Z'),
+ };
+ store.set(input.userId, row);
+ return { ok: true, data: row };
+ }),
+}));
+
+// ─── Fixtures ─────────────────────────────────────────────────────────────────
+
+const USER_A = {
+ id: 1,
+ email: 'a@example.com',
+ role: 5,
+ roleName: 'translator',
+ organization: 1,
+ status: 'verified' as const,
+};
+
+const USER_B = {
+ id: 2,
+ email: 'b@example.com',
+ role: 5,
+ roleName: 'translator',
+ organization: 1,
+ status: 'verified' as const,
+};
+
+/** Authenticate as the given app user (no permission gate on the self domain). */
+function authenticateAs(user: typeof USER_A) {
+ (auth.api.getSession as any).mockResolvedValue({
+ session: { id: 's1', updatedAt: new Date(), expiresAt: new Date(Date.now() + 1e9) },
+ user: { email: user.email },
+ });
+ (getUserByEmail as any).mockResolvedValue({ ok: true, data: user });
+ (roleHasPermission as any).mockResolvedValue(true);
+}
+
+function getSettings() {
+ return server.request('/self/settings', { method: 'GET' });
+}
+
+function putSettings(body: unknown) {
+ return server.request('/self/settings', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+}
+
+describe('gET /self/settings', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ store.clear();
+ });
+
+ it('returns 401 when the caller is not authenticated', async () => {
+ (auth.api.getSession as any).mockResolvedValue(null);
+
+ const res = await getSettings();
+
+ expect(res.status).toBe(401);
+ });
+
+ it('returns 403 when the user account is inactive', async () => {
+ authenticateAs({ ...USER_A, status: 'inactive' as never });
+
+ const res = await getSettings();
+
+ expect(res.status).toBe(403);
+ });
+
+ it('returns 200 with settings: null for a user with no row yet (not 404)', async () => {
+ authenticateAs(USER_A);
+
+ const res = await getSettings();
+
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json).toEqual({ settings: null, updatedAt: null });
+ });
+
+ it('returns the saved blob after a PUT', async () => {
+ authenticateAs(USER_A);
+ await putSettings({ settings: { checkIgnoredWordPairs: { 'the the': 'suppress' } } });
+
+ const res = await getSettings();
+
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.settings).toEqual({ checkIgnoredWordPairs: { 'the the': 'suppress' } });
+ expect(json.updatedAt).toBe('2026-06-18T00:00:00.000Z');
+ });
+
+ it('scopes reads to the session user — a caller never sees another user row', async () => {
+ authenticateAs(USER_A);
+ await putSettings({ settings: { checkIgnoredWordPairs: { 'the the': 'suppress' } } });
+
+ authenticateAs(USER_B);
+ const res = await getSettings();
+
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.settings).toBeNull();
+ });
+
+ it('normalizes a malformed/legacy stored blob to {} on read (.catch({}) tolerance)', async () => {
+ // Seed the store directly to simulate a row that never went through the write
+ // validator (e.g. written by an older app version or a manual edit). The whole
+ // blob is the wrong shape, so the tolerant read schema must degrade it to `{}`
+ // rather than leaking the unnormalized value through GET.
+ store.set(USER_A.id, {
+ settings: { legacyKey: 'whatever', checkIgnoredWordPairs: 'not-an-object' } as never,
+ updatedAt: new Date('2026-06-18T00:00:00.000Z'),
+ });
+ authenticateAs(USER_A);
+
+ const res = await getSettings();
+
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.settings).toEqual({});
+ });
+
+ it('preserves a genuine null settings row (does not coerce null to {})', async () => {
+ // A nullable column with no settings yet must stay `null` — parsing `null`
+ // through `.catch({})` would yield `{}` and break the "settings: null when no
+ // row" contract, so the read path guards null before parsing.
+ store.set(USER_A.id, {
+ settings: null,
+ updatedAt: new Date('2026-06-18T00:00:00.000Z'),
+ });
+ authenticateAs(USER_A);
+
+ const res = await getSettings();
+
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.settings).toBeNull();
+ });
+});
+
+describe('pUT /self/settings', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ store.clear();
+ });
+
+ it('returns 401 when the caller is not authenticated', async () => {
+ (auth.api.getSession as any).mockResolvedValue(null);
+
+ const res = await putSettings({ settings: {} });
+
+ expect(res.status).toBe(401);
+ });
+
+ it('returns 403 when the user account is inactive', async () => {
+ authenticateAs({ ...USER_A, status: 'inactive' as never });
+
+ const res = await putSettings({ settings: {} });
+
+ expect(res.status).toBe(403);
+ });
+
+ it('creates then full-replaces (keys omitted on the second PUT are gone)', async () => {
+ authenticateAs(USER_A);
+
+ const created = await putSettings({
+ settings: { checkIgnoredWordPairs: { 'the the': 'suppress', 'and and': 'suppress' } },
+ });
+ expect(created.status).toBe(200);
+ expect((await created.json()).settings).toEqual({
+ checkIgnoredWordPairs: { 'the the': 'suppress', 'and and': 'suppress' },
+ });
+
+ const replaced = await putSettings({ settings: {} });
+ expect(replaced.status).toBe(200);
+ expect((await replaced.json()).settings).toEqual({});
+ });
+
+ it('returns 422 on schema violation (wrong rule value type)', async () => {
+ authenticateAs(USER_A);
+
+ const res = await putSettings({
+ settings: { checkIgnoredWordPairs: { 'the the': 'not-a-valid-verdict' } },
+ });
+
+ // A well-formed request whose body fails schema validation is a 422
+ // (VALIDATION_ERROR), not a 400.
+ expect(res.status).toBe(422);
+ });
+
+ it('strips unknown top-level keys on write (persists as {} rather than rejecting)', async () => {
+ authenticateAs(USER_A);
+
+ const res = await putSettings({ settings: { someFutureKey: 123 } });
+
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.settings).toEqual({});
+ });
+});
diff --git a/src/domains/self/settings/self-settings.route.ts b/src/domains/self/settings/self-settings.route.ts
new file mode 100644
index 00000000..2f84a83d
--- /dev/null
+++ b/src/domains/self/settings/self-settings.route.ts
@@ -0,0 +1,107 @@
+import { createRoute } from '@hono/zod-openapi';
+import * as HttpStatusCodes from 'stoker/http-status-codes';
+import * as HttpStatusPhrases from 'stoker/http-status-phrases';
+import { jsonContent } from 'stoker/openapi/helpers';
+import { createMessageObjectSchema } from 'stoker/openapi/schemas';
+
+import { getHttpStatus } from '@/lib/types';
+import { authenticateUser } from '@/middlewares/role-auth';
+import { server } from '@/server/server';
+
+import * as selfSettingsService from './self-settings.service';
+import { saveUserSettingsRequestSchema, userSettingsResponseSchema } from './self-settings.types';
+
+// ─── GET /self/settings ───────────────────────────────────────────────────────
+// User-global preference store for the current session user. No `{userId}` in the
+// path and no resource to scope, so `authenticateUser` alone guards it (W7).
+
+const getSelfSettingsRoute = createRoute({
+ tags: ['Self - Settings'],
+ method: 'get',
+ path: '/self/settings',
+ middleware: [authenticateUser] as const,
+ responses: {
+ [HttpStatusCodes.OK]: jsonContent(
+ userSettingsResponseSchema,
+ 'The settings for the current user (settings is null when no row exists yet)'
+ ),
+ [HttpStatusCodes.UNAUTHORIZED]: jsonContent(
+ createMessageObjectSchema('Unauthorized'),
+ 'Authentication required'
+ ),
+ [HttpStatusCodes.FORBIDDEN]: jsonContent(
+ createMessageObjectSchema(HttpStatusPhrases.FORBIDDEN),
+ 'User account is inactive'
+ ),
+ [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent(
+ createMessageObjectSchema(HttpStatusPhrases.INTERNAL_SERVER_ERROR),
+ 'Internal server error'
+ ),
+ },
+ summary: 'Get settings for current user',
+ description: 'Returns the saved user-global settings blob for the current session user.',
+});
+
+server.openapi(getSelfSettingsRoute, async (c) => {
+ const currentUser = c.get('user')!;
+
+ const result = await selfSettingsService.getSettings(currentUser.id);
+ if (result.ok) return c.json(result.data, HttpStatusCodes.OK);
+ // Derive the status from the error code (matches the PUT handler and the rest
+ // of the codebase) rather than hardcoding 500: getSettings only surfaces repo
+ // errors (→ 500) today, but this stays correct if it gains new failure modes.
+ return c.json({ message: result.error.message }, getHttpStatus(result.error) as never);
+});
+
+// ─── PUT /self/settings ───────────────────────────────────────────────────────
+// Full-replace upsert: the client GETs, merges in memory, and PUTs the whole blob
+// (last-writer-wins; no PATCH / ETags / optimistic concurrency — §8.3).
+
+const saveSelfSettingsRoute = createRoute({
+ tags: ['Self - Settings'],
+ method: 'put',
+ path: '/self/settings',
+ middleware: [authenticateUser] as const,
+ request: {
+ body: jsonContent(saveUserSettingsRequestSchema, 'The settings blob to save (full replace)'),
+ },
+ responses: {
+ [HttpStatusCodes.OK]: jsonContent(userSettingsResponseSchema, 'The saved settings'),
+ [HttpStatusCodes.BAD_REQUEST]: jsonContent(
+ createMessageObjectSchema('Bad Request'),
+ 'Invalid settings payload'
+ ),
+ // A well-formed request whose body fails schema validation is surfaced as
+ // 422 by the service (ErrorCode.VALIDATION_ERROR → getHttpStatus → 422), and
+ // the route test asserts it. Declare it here so the published OpenAPI
+ // contract advertises the real client-error surface (the paired fluent-web
+ // PR generates types/docs from this spec).
+ [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent(
+ createMessageObjectSchema('Unprocessable Entity'),
+ 'Settings payload failed schema validation'
+ ),
+ [HttpStatusCodes.UNAUTHORIZED]: jsonContent(
+ createMessageObjectSchema('Unauthorized'),
+ 'Authentication required'
+ ),
+ [HttpStatusCodes.FORBIDDEN]: jsonContent(
+ createMessageObjectSchema(HttpStatusPhrases.FORBIDDEN),
+ 'User account is inactive'
+ ),
+ [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent(
+ createMessageObjectSchema(HttpStatusPhrases.INTERNAL_SERVER_ERROR),
+ 'Internal server error'
+ ),
+ },
+ summary: 'Save settings for current user',
+ description: 'Replaces the user-global settings blob for the current session user.',
+});
+
+server.openapi(saveSelfSettingsRoute, async (c) => {
+ const { settings } = c.req.valid('json');
+ const currentUser = c.get('user')!;
+
+ const result = await selfSettingsService.upsertSettings(currentUser.id, settings);
+ if (result.ok) return c.json(result.data, HttpStatusCodes.OK);
+ return c.json({ message: result.error.message }, getHttpStatus(result.error) as never);
+});
diff --git a/src/domains/self/settings/self-settings.service.ts b/src/domains/self/settings/self-settings.service.ts
new file mode 100644
index 00000000..a1ce1794
--- /dev/null
+++ b/src/domains/self/settings/self-settings.service.ts
@@ -0,0 +1,56 @@
+import { userSettingsSchema, userSettingsWriteSchema } from '@/db/schema';
+import { err, ErrorCode, ok } from '@/lib/types';
+
+import type { UserSettings, UserSettingsResponse } from './self-settings.types';
+
+import * as repo from './self-settings.repository';
+
+function toResponse(row: repo.UserSettingsRow | null): UserSettingsResponse {
+ if (!row) return { settings: null, updatedAt: null };
+ return {
+ // Normalize the stored JSONB blob through the tolerant read schema
+ // (`userSettingsSchema = userSettingsObjectSchema.catch({})`). The `jsonb`
+ // column accepts any JSON and Drizzle's `.$type<>()` is compile-time only, so
+ // a malformed/legacy stored blob must be re-validated on read — `.catch({})`
+ // degrades an unparseable shape to `{}` instead of leaking it (W8). A genuine
+ // `null` (no settings yet) is preserved: parsing `null` would yield `{}`,
+ // which would break the "settings: null when no row" contract.
+ settings: row.settings == null ? null : userSettingsSchema.parse(row.settings),
+ updatedAt: row.updatedAt ? row.updatedAt.toISOString() : null,
+ };
+}
+
+export async function getSettings(userId: number) {
+ const result = await repo.findByUser(userId);
+ if (!result.ok) return result;
+ return ok(toResponse(result.data));
+}
+
+export async function upsertSettings(userId: number, rawSettings: unknown) {
+ // Validate the incoming write with the STRICT schema (no `.catch`), so a
+ // malformed body is rejected (as a 422 — see below) rather than silently
+ // swallowed. Unknown top-level keys are stripped; bad shapes for known keys
+ // fail parsing. (The `.catch({})` W8 tolerance applies only when reading
+ // stored rows back.)
+ const parsed = userSettingsWriteSchema.safeParse(rawSettings);
+ // A malformed body is a request-shape (validation) failure, not a reference
+ // error — VALIDATION_ERROR maps to 422, the correct status for a well-formed
+ // request whose contents fail schema validation.
+ if (!parsed.success) return err(ErrorCode.VALIDATION_ERROR);
+
+ const settings: UserSettings = parsed.data;
+
+ // ⚠️ FULL-REPLACE — intentional WHILE there is a single settings key ⚠️
+ // `repo.upsert` overwrites the whole `settings` JSONB blob (no server-side
+ // merge). The client GETs, edits its one key, and PUTs the whole blob back
+ // (last-writer-wins; §8.1/§8.3). This is safe ONLY because `userSettings*`
+ // currently has exactly one key (`checkIgnoredWordPairs`) — see the gate note
+ // on `userSettingsObjectSchema` in `db/schema.ts`. BEFORE a second key is
+ // added there, this function MUST read-merge-write instead: load the existing
+ // blob, shallow-merge the incoming keys over it (an explicit `null` value
+ // deletes that key; absent keys stay untouched), then persist — otherwise this
+ // full-replace silently drops whatever key the caller didn't send.
+ const result = await repo.upsert({ userId, settings });
+ if (!result.ok) return result;
+ return ok(toResponse(result.data));
+}
diff --git a/src/domains/self/settings/self-settings.types.ts b/src/domains/self/settings/self-settings.types.ts
new file mode 100644
index 00000000..8f594c59
--- /dev/null
+++ b/src/domains/self/settings/self-settings.types.ts
@@ -0,0 +1,53 @@
+import { z } from '@hono/zod-openapi';
+
+import type { userSettingsSchema } from '@/db/schema';
+
+import { userSettingsObjectSchema } from '@/db/schema';
+
+// ─── DB-derived types ─────────────────────────────────────────────────────────
+
+export type UserSettings = z.infer;
+
+export interface UpsertUserSettingsInput {
+ userId: number;
+ settings: UserSettings;
+}
+
+// ─── API request schema ───────────────────────────────────────────────────────
+
+// The blob is validated against `userSettingsSchema` in the service layer (so a
+// schema violation yields a 400). At the route boundary we accept any object so
+// the OpenAPI surface documents `{ settings: {...} }` without duplicating the
+// per-key rules.
+export const saveUserSettingsRequestSchema = z
+ .object({
+ settings: z.record(z.string(), z.unknown()),
+ })
+ .openapi('UserSettingsInput');
+
+export type SaveUserSettingsRequest = z.infer;
+
+// ─── API response schema ──────────────────────────────────────────────────────
+
+// `settings` advertises the real settings shape (the allowed keys and their enum
+// values) rather than a generic object: the service's `toResponse` already
+// normalizes every stored blob through `userSettingsSchema` on read, so the
+// response body provably conforms to it — the spec should say so. Nullable for a
+// user with no row yet (mirrors editor-state — not a 404). (We intentionally do
+// NOT tighten the *request* schema; see the boundary-vs-service note above.)
+//
+// We embed the PLAIN `userSettingsObjectSchema`, NOT the `.catch({})` read schema
+// (`userSettingsSchema`): the `.catch` wrapper is a `ZodCatch` that
+// zod-to-openapi can't render (it 500s `/doc` — see the note in db/schema.ts and
+// the guard in src/routes/doc.route.test.ts). The two schemas describe the same
+// fields, so the documented shape is unchanged; the fail-soft `.catch` tolerance
+// lives entirely in the service read path (`toResponse`), which is where it
+// belongs — it was never meant to be part of the published contract.
+export const userSettingsResponseSchema = z
+ .object({
+ settings: userSettingsObjectSchema.nullable(),
+ updatedAt: z.string().nullable(),
+ })
+ .openapi('UserSettingsResponse');
+
+export type UserSettingsResponse = z.infer;
diff --git a/src/domains/users/chapter-assignments/users-chapter-assignments.service.test.ts b/src/domains/users/chapter-assignments/users-chapter-assignments.service.test.ts
new file mode 100644
index 00000000..29f445cb
--- /dev/null
+++ b/src/domains/users/chapter-assignments/users-chapter-assignments.service.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest';
+
+import type { ChapterAssignmentProgressInfo } from '@/domains/chapter-assignments/chapter-assignments.types';
+
+import { toResponse } from './users-chapter-assignments.service';
+
+/**
+ * A complete ChapterAssignmentProgressInfo with sensible defaults; override via
+ * the partial argument. Mirrors the shape produced by the repository query.
+ */
+const makeProgressInfo = (
+ over: Partial = {}
+): ChapterAssignmentProgressInfo => ({
+ assignmentId: 1,
+ projectId: 1,
+ projectName: 'Test project',
+ projectUnitId: 1,
+ bibleId: 1,
+ bibleName: 'Test Bible',
+ bookId: 3,
+ bookCode: 'LEV',
+ bookNameEng: 'Leviticus',
+ chapterNumber: 2,
+ status: 'draft',
+ // `targetLanguage` is the human display NAME; `targetLangCode` is the ISO code.
+ targetLanguage: 'English',
+ targetLangCode: 'eng',
+ sourceLangCode: 'eng',
+ totalVerses: 4,
+ completedVerses: 0,
+ assignedUserId: 2,
+ assignedUserDisplayName: 'translator',
+ peerCheckerId: null,
+ peerCheckerDisplayName: null,
+ submittedTime: null,
+ createdAt: null,
+ updatedAt: null,
+ ...over,
+});
+
+describe('toResponse', () => {
+ it('exposes the target ISO 639-3 code as targetLangCode (BUG #2 regression)', () => {
+ // The repeated-words check sends targetLangCode as greek-room's lang_code;
+ // greek-room keys its legitimate-duplicate whitelist on the ISO code, so the
+ // API must surface the code (not just the display name). See phase-04 manual
+ // smoke (BUG #2, 2026-06-23).
+ const response = toResponse(
+ makeProgressInfo({ targetLanguage: 'English', targetLangCode: 'eng' })
+ );
+ expect(response.targetLangCode).toBe('eng');
+ // The display name still flows through its own field.
+ expect(response.targetLanguage).toBe('English');
+ });
+
+ it('falls back to "" when the target ISO code is null', () => {
+ const response = toResponse(makeProgressInfo({ targetLangCode: null }));
+ expect(response.targetLangCode).toBe('');
+ });
+});
diff --git a/src/domains/users/chapter-assignments/users-chapter-assignments.service.ts b/src/domains/users/chapter-assignments/users-chapter-assignments.service.ts
index ffbc3549..5580949b 100644
--- a/src/domains/users/chapter-assignments/users-chapter-assignments.service.ts
+++ b/src/domains/users/chapter-assignments/users-chapter-assignments.service.ts
@@ -22,6 +22,7 @@ export function toResponse(
bibleName: assignment.bibleName ?? '',
chapterStatus: assignment.status,
targetLanguage: assignment.targetLanguage ?? '',
+ targetLangCode: assignment.targetLangCode ?? '',
sourceLangCode: assignment.sourceLangCode ?? '',
bookCode: assignment.bookCode,
bookId: assignment.bookId,
diff --git a/src/domains/users/chapter-assignments/users-chapter-assignments.types.ts b/src/domains/users/chapter-assignments/users-chapter-assignments.types.ts
index 6fb50897..a7c7111d 100644
--- a/src/domains/users/chapter-assignments/users-chapter-assignments.types.ts
+++ b/src/domains/users/chapter-assignments/users-chapter-assignments.types.ts
@@ -9,7 +9,10 @@ export interface UserChapterAssignment {
bibleId: number;
bibleName: string;
chapterStatus: string;
+ /** Human-readable target language display NAME, e.g. "English". */
targetLanguage: string;
+ /** ISO 639-3 target language CODE, e.g. "eng" (the check's lang_code). */
+ targetLangCode: string;
sourceLangCode: string;
bookCode: string;
bookId: number;
@@ -33,6 +36,7 @@ export const userChapterAssignmentResponseSchema = z.object({
bibleName: z.string(),
chapterStatus: z.string(),
targetLanguage: z.string(),
+ targetLangCode: z.string(),
sourceLangCode: z.string(),
bookCode: z.string(),
bookId: z.number().int(),
diff --git a/src/env.ts b/src/env.ts
index ca05521c..ac55da0c 100644
--- a/src/env.ts
+++ b/src/env.ts
@@ -9,6 +9,40 @@ expand(
})
);
+// ── Env boolean parser ────────────────────────────────────────────────────────
+// A string→boolean parser for env vars, behaviourally equivalent to Zod v4's
+// z.stringbool(). We do NOT use z.coerce.boolean(): coercion follows JS
+// truthiness, so the string "false" would parse to `true` and silently INVERT a
+// safe-off default — exactly the wrong failure mode for a feature flag whose job
+// is to keep AI UI hidden. We also can't reach z.stringbool() here: it lives in
+// the Zod v4 API (the `zod/v4` subpath in zod 3.25.x), whereas @hono/zod-openapi
+// binds the classic (v3-style) `z` used throughout this schema. So we implement
+// the same contract explicitly on the classic `z`: accept the usual env
+// spellings (true/false, 1/0, yes/no, on/off — case-insensitive), reject
+// anything else, and preserve the unset case via .optional() so a derived
+// default can still apply.
+const TRUTHY = new Set(['true', '1', 'yes', 'on']);
+const FALSY = new Set(['false', '0', 'no', 'off']);
+const envBool = () =>
+ z.string().transform((v, ctx): boolean | undefined => {
+ const normalized = v.trim().toLowerCase();
+ // Treat a blank / whitespace-only value as UNSET (undefined) rather than a
+ // parse error: dotenv loads a bare `EN_FEATURE_REPEATED_WORD_CHECK=` line
+ // (exactly how .env.example documents it) as the empty string "", and
+ // .optional() alone does NOT catch that — the transform still runs on "".
+ // Without this, copying .env.example verbatim would fail EnvSchema.safeParse
+ // at boot. Returning undefined lets the derived default (AI-wiring) apply,
+ // which is the intended "flag not explicitly set" behaviour.
+ if (normalized === '') return undefined;
+ if (TRUTHY.has(normalized)) return true;
+ if (FALSY.has(normalized)) return false;
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Expected a boolean-ish string (true/false, 1/0, yes/no, on/off), received "${v}"`,
+ });
+ return z.NEVER;
+ });
+
const EnvSchema = z.object({
NODE_ENV: z.string().default('development'),
PORT: z.coerce.number().default(9999),
@@ -41,10 +75,43 @@ const EnvSchema = z.object({
// '/api/v1' via env with no code change. Leading slash optional; trailing
// slashes are trimmed when the request URL is assembled.
FLUENT_AI_API_PREFIX: z.string().default(''),
+
+ // ── Feature flags (EN_FEATURE_*) ──────────────────────────────────────
+ // One flat boolean env var per optional feature, under a dedicated
+ // EN_FEATURE_ prefix. Each flag is declared explicitly here (proposal D2) so
+ // this schema stays the OPERATOR's catalog of known flags and validates their
+ // values — a fixed z.object strips unknown keys, so an undeclared EN_FEATURE_*
+ // var would be dropped before it could be read.
+ //
+ // ⚠️ A flag lives in THREE places that must stay in sync — adding/removing one
+ // here means updating the other two (a test in
+ // src/lib/features.test.ts fails on drift, in both directions):
+ // 1. this env-schema line (operator's catalog + validation)
+ // 2. the FLAGS registry (src/lib/features.ts — env↔wire mapping + default)
+ // 3. the OpenAPI response schema (src/routes/config.route.ts — programmer's catalog)
+ // …plus a line in .env.example.
+ //
+ // Repeated Word Check — the one AI-dependent feature today. Left optional so
+ // its unset default can be derived from AI wiring by buildFeatures(): true
+ // only when FLUENT_AI_URL + FLUENT_AI_KEY are both wired, otherwise false —
+ // so forgetting to set it in an AI-less environment yields the safe answer
+ // (off). Parsed via envBool() (NOT z.coerce.boolean(), which would turn the
+ // string "false" into true and invert the safe default).
+ EN_FEATURE_REPEATED_WORD_CHECK: envBool().optional(),
});
export type env = z.infer;
+/**
+ * The `EN_FEATURE_*` keys DECLARED in the schema (regardless of whether they are
+ * set in the current environment — Zod strips unset optionals from the parsed
+ * object, so this reads the schema shape, not `process.env`). This is the
+ * env-side half of the feature-flag drift check in src/lib/features.test.ts.
+ */
+export const declaredFeatureEnvKeys: string[] = Object.keys(EnvSchema.shape).filter((k) =>
+ k.startsWith('EN_FEATURE_')
+);
+
// eslint-disable-next-line ts/no-redeclare
const { data: env, error } = EnvSchema.safeParse(process.env);
diff --git a/src/lib/features.test.ts b/src/lib/features.test.ts
new file mode 100644
index 00000000..cd40488f
--- /dev/null
+++ b/src/lib/features.test.ts
@@ -0,0 +1,106 @@
+import { describe, expect, it } from 'vitest';
+
+import type env from '@/env';
+
+import { declaredFeatureEnvKeys } from '@/env';
+
+import { buildFeatures, FEATURE_PREFIX, FLAGS, wireFeatureKeys } from './features';
+
+// A minimal env stand-in for buildFeatures(): only the fields the resolvers read
+// matter. Cast through unknown so tests don't have to fabricate the whole env.
+function makeEnv(overrides: Partial>): typeof env {
+ return overrides as unknown as typeof env;
+}
+
+const AI_WIRED = { FLUENT_AI_URL: 'http://ai:8200', FLUENT_AI_KEY: 'k' };
+const AI_UNWIRED = { FLUENT_AI_URL: '', FLUENT_AI_KEY: '' };
+
+describe('buildFeatures', () => {
+ it('honors an explicitly-set flag (true) regardless of AI wiring', () => {
+ const features = buildFeatures(
+ makeEnv({ ...AI_UNWIRED, EN_FEATURE_REPEATED_WORD_CHECK: true })
+ );
+ expect(features.repeatedWordCheck).toBe(true);
+ });
+
+ it('honors an explicitly-set flag (false) even when AI is wired', () => {
+ const features = buildFeatures(makeEnv({ ...AI_WIRED, EN_FEATURE_REPEATED_WORD_CHECK: false }));
+ expect(features.repeatedWordCheck).toBe(false);
+ });
+
+ it('derives repeatedWordCheck = true when unset and AI is wired', () => {
+ const features = buildFeatures(makeEnv({ ...AI_WIRED }));
+ expect(features.repeatedWordCheck).toBe(true);
+ });
+
+ it('derives repeatedWordCheck = false (safe default) when unset and AI is not wired', () => {
+ const features = buildFeatures(makeEnv({ ...AI_UNWIRED }));
+ expect(features.repeatedWordCheck).toBe(false);
+ });
+
+ it('treats a missing (undefined) AI url/key as not wired → safe-off default', () => {
+ const features = buildFeatures(makeEnv({}));
+ expect(features.repeatedWordCheck).toBe(false);
+ });
+
+ it('returns exactly the known flag keys — no extras, none missing', () => {
+ const features = buildFeatures(makeEnv({ ...AI_WIRED }));
+ expect(Object.keys(features).sort()).toEqual([...wireFeatureKeys].sort());
+ });
+});
+
+/**
+ * Keep-in-sync drift guard. A feature flag is declared in three places that use
+ * two vocabularies:
+ * 1. env schema — `EN_FEATURE_*` keys (operator's catalog)
+ * 2. FLAGS registry — camelCase wire keys (env↔wire mapping)
+ * 3. OpenAPI schema — camelCase wire keys (programmer's catalog)
+ * They are the same set under the deterministic prefix-strip + camelCase rule.
+ * This test asserts all three project to the SAME set, so adding a flag in only
+ * one place fails here (in every direction) — the belt to the compiler's
+ * suspenders (the `satisfies` in features.ts already hard-guards FLAGS↔wire).
+ */
+describe('feature flag declarations stay in sync (drift guard)', () => {
+ // The prefix-strip + camelCase rule, inlined because only this test needs it.
+ const toWireKey = (envKey: string): string =>
+ envKey
+ .slice(FEATURE_PREFIX.length)
+ .toLowerCase()
+ .replace(/_([a-z0-9])/g, (_, ch: string) => ch.toUpperCase());
+
+ const envSideWireKeys = [...declaredFeatureEnvKeys].map(toWireKey).sort();
+ const registryKeys = Object.keys(FLAGS).sort();
+ const wireKeys = [...wireFeatureKeys].sort();
+
+ const explain = [
+ 'Feature-flag declarations are out of sync. Every flag must appear in all THREE:',
+ ` • env schema (EN_FEATURE_*) → wire keys: [${envSideWireKeys.join(', ')}]`,
+ ` • FLAGS registry keys: [${registryKeys.join(', ')}]`,
+ ` • OpenAPI featuresSchema keys: [${wireKeys.join(', ')}]`,
+ 'To add a flag: add EN_FEATURE_ in src/env.ts (+ .env.example), a FLAGS',
+ 'entry in src/lib/features.ts, and a property in featuresSchema. To remove one,',
+ 'delete it from all three.',
+ ].join('\n');
+
+ it('env schema flags (camelCased) match the FLAGS registry keys', () => {
+ expect(registryKeys, explain).toEqual(envSideWireKeys);
+ });
+
+ it('registry keys (FLAGS) match the OpenAPI featuresSchema keys', () => {
+ expect(wireKeys, explain).toEqual(registryKeys);
+ });
+
+ it('env schema flags (camelCased) match the OpenAPI featuresSchema keys', () => {
+ expect(wireKeys, explain).toEqual(envSideWireKeys);
+ });
+
+ it('every FLAGS entry points at a declared EN_FEATURE_* env var', () => {
+ const declared = new Set(declaredFeatureEnvKeys);
+ for (const wireKey of Object.keys(FLAGS)) {
+ const envKey = FLAGS[wireKey as keyof typeof FLAGS].env;
+ expect(declared.has(envKey), `${envKey} (backing ${wireKey}) is not declared in env.ts`).toBe(
+ true
+ );
+ }
+ });
+});
diff --git a/src/lib/features.ts b/src/lib/features.ts
new file mode 100644
index 00000000..dd736469
--- /dev/null
+++ b/src/lib/features.ts
@@ -0,0 +1,129 @@
+import { z } from '@hono/zod-openapi';
+
+import type env from '@/env';
+
+/**
+ * Feature flags — the env-sourced projection published by `GET /config/features`.
+ *
+ * The env is the single source of truth (see the `EN_FEATURE_*` block in
+ * {@link file://../env.ts}); this module derives the read-only map that
+ * fluent-web consumes to decide which optional UI to render. There is NO
+ * server-side enforcement here (proposal D5) — this only *publishes* which
+ * optional features are on; it does not gate the AI request path.
+ *
+ * ── Two audiences, one fact, kept in sync ──────────────────────────────────
+ * A feature flag is written in two vocabularies:
+ * • the OPERATOR reads the `EN_FEATURE_*` env var (declared literally in the
+ * Zod schema in env.ts, so the schema stays their catalog);
+ * • the API/UI PROGRAMMER reads the camelCase wire key in the OpenAPI doc for
+ * `GET /config/features`.
+ * The two are the same fact under a deterministic naming rule. Rather than make
+ * either side infer the other, both are declared, and drift between them is
+ * caught (see features.test.ts, which asserts the env-side and wire-side flag
+ * sets are equal in BOTH directions). This `FLAGS` registry is the single place
+ * that ties an env var to its wire key and its unset-default; `buildFeatures()`
+ * iterates it (no string-prefix sweep, no casts) and the wire type is derived
+ * from it, so the OpenAPI schema and the registry cannot drift (a compile-time
+ * `satisfies` in config.route.ts enforces that half).
+ *
+ * Adding a feature = declare `EN_FEATURE_` in env.ts + a line in
+ * `.env.example` + one entry here + one property in the OpenAPI schema. The
+ * drift test fails if any of those fall out of step.
+ */
+
+/** The `EN_FEATURE_` prefix that marks a schema key as a feature flag. */
+export const FEATURE_PREFIX = 'EN_FEATURE_';
+
+type Env = typeof env;
+
+/** Resolves an unset (optional) flag to its safe default from the full env. */
+type DefaultResolver = (e: Env) => boolean;
+
+interface FlagDefinition {
+ /** The env var backing this flag. Constrained to env keys that are BOTH under
+ * the `EN_FEATURE_` prefix AND parse to `boolean | undefined`, so a typo, an
+ * unbacked flag, OR a flag pointing at a non-boolean env key is a compile
+ * error (registry → env schema link), and `buildFeatures` needs no cast. */
+ readonly env: {
+ [K in keyof Env]: Env[K] extends boolean | undefined
+ ? K extends `${typeof FEATURE_PREFIX}${string}`
+ ? K
+ : never
+ : never;
+ }[keyof Env];
+ /** The value published when the env var is unset (undefined). */
+ readonly default: DefaultResolver;
+}
+
+/**
+ * True when fluent-ai is actually wired (both URL and key present, non-empty).
+ * Used to derive the safe-off default of AI-dependent flags left unset — so
+ * forgetting the flag in an AI-less environment yields the safe answer (off).
+ */
+const aiIsWired: DefaultResolver = (e) => Boolean(e.FLUENT_AI_URL && e.FLUENT_AI_KEY);
+
+/**
+ * The flag registry. Keys are the camelCase WIRE keys (what the API publishes
+ * and fluent-web reads); each value ties that wire key to its backing env var
+ * and its unset-default.
+ *
+ * ⚠️ A flag lives in THREE places that must stay in sync — adding/removing an
+ * entry here means updating the other two (the drift test below fails, in
+ * both directions, if they diverge):
+ * 1. the env-schema line (src/env.ts — operator's catalog + validation)
+ * 2. this FLAGS registry (env↔wire mapping + default)
+ * 3. the OpenAPI response schema (src/routes/config.route.ts — programmer's catalog)
+ * …plus a line in .env.example.
+ */
+export const FLAGS = {
+ // Repeated Word Check — the one AI-dependent feature today. Defaults to
+ // whether AI is wired when EN_FEATURE_REPEATED_WORD_CHECK is unset (D2/§4.2).
+ repeatedWordCheck: { env: 'EN_FEATURE_REPEATED_WORD_CHECK', default: aiIsWired },
+} as const satisfies Record;
+
+/** The set of known wire keys, e.g. `'repeatedWordCheck'`. */
+export type FeatureName = keyof typeof FLAGS;
+
+/** The published feature map: every known flag, always present. */
+export type Features = Record;
+
+/**
+ * Build the published feature map from the parsed, validated env.
+ *
+ * For each registry entry: an explicitly set env var uses its parsed boolean
+ * value; an unset (optional → `undefined`) var falls back to the entry's
+ * `default` resolver. No prefix sweep and no cast — the registry keys ARE the
+ * wire keys, so the returned object is exactly `Features`.
+ */
+export function buildFeatures(e: Env): Features {
+ const entries = (Object.keys(FLAGS) as FeatureName[]).map((wireKey) => {
+ const def = FLAGS[wireKey];
+ // No cast needed: FlagDefinition.env is constrained to env keys whose parsed
+ // type is `boolean | undefined`, so the compiler already knows `raw` is that.
+ const raw = e[def.env];
+ const value = typeof raw === 'boolean' ? raw : def.default(e);
+ return [wireKey, value] as const;
+ });
+
+ return Object.fromEntries(entries) as Features;
+}
+
+// ── The published wire schema (programmer's catalog) ────────────────────────
+// Named, boolean properties so the OpenAPI doc for GET /config/features lists
+// each flag explicitly (a programmer reading the docs never has to infer the
+// key from the env var). Consumed by src/routes/config.route.ts.
+//
+// The `satisfies z.ZodType` below is the compile-time half of the
+// keep-in-sync guarantee: it forces this schema's inferred type to be assignable
+// to Features (= the FLAGS keys), so a property named here that isn't a known
+// flag, or a wrong value type, is a `tsc` error. The reverse direction (a FLAGS
+// key with no property here) and the env↔wire directions are covered by the
+// drift test in features.test.ts.
+export const featuresSchema = z
+ .object({
+ repeatedWordCheck: z.boolean().openapi({ example: false }),
+ })
+ .openapi('Features') satisfies z.ZodType;
+
+/** The wire-side flag keys — the wire half of the drift check in features.test.ts. */
+export const wireFeatureKeys: string[] = Object.keys(featuresSchema.shape);
diff --git a/src/routes/config.route.test.ts b/src/routes/config.route.test.ts
new file mode 100644
index 00000000..ffd2fcc4
--- /dev/null
+++ b/src/routes/config.route.test.ts
@@ -0,0 +1,104 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { getUserByEmail } from '@/domains/users/users.service';
+import { auth } from '@/lib/auth';
+import { server } from '@/server/server';
+import '@/routes/config.route';
+
+// ─── Module mocks ─────────────────────────────────────────────────────────────
+// The route is login-gated (#211 Q1): the global passive `authenticate`
+// middleware resolves the session → app user via BetterAuth + getUserByEmail and
+// puts it on the context; `authenticateUser` on the route then 401s when there is
+// no user. Mock those two boundaries so we can drive the authenticated /
+// unauthenticated cases without a real DB or auth server (mirrors
+// self-settings.route.test.ts).
+
+vi.mock('@/lib/auth', () => ({
+ auth: {
+ api: { getSession: vi.fn() },
+ handler: vi.fn(),
+ },
+}));
+
+vi.mock('@/db', () => ({
+ db: { select: vi.fn(), insert: vi.fn(), update: vi.fn() },
+}));
+
+vi.mock('@/domains/users/users.service', () => ({
+ getUserByEmail: vi.fn(),
+}));
+
+vi.mock('@/lib/logger', () => ({
+ logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
+}));
+
+// ─── Fixtures ─────────────────────────────────────────────────────────────────
+
+const USER = {
+ id: 1,
+ email: 'a@example.com',
+ role: 5,
+ roleName: 'translator',
+ organization: 1,
+ status: 'verified' as const,
+};
+
+/** Drive the passive auth middleware to a valid session → linked app user. */
+function authenticateAs(user: typeof USER) {
+ (auth.api.getSession as any).mockResolvedValue({
+ session: { id: 's1', updatedAt: new Date(), expiresAt: new Date(Date.now() + 1e9) },
+ user: { email: user.email },
+ });
+ (getUserByEmail as any).mockResolvedValue({ ok: true, data: user });
+}
+
+function getFeatures() {
+ return server.request('/config/features', { method: 'GET' });
+}
+
+describe('gET /config/features', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('returns 401 when the caller is not authenticated (no session)', async () => {
+ (auth.api.getSession as any).mockResolvedValue(null);
+
+ const res = await getFeatures();
+
+ expect(res.status).toBe(401);
+ });
+
+ it('returns 200 for an authenticated user', async () => {
+ authenticateAs(USER);
+
+ const res = await getFeatures();
+
+ expect(res.status).toBe(200);
+ });
+
+ it('returns a named features map of booleans (authenticated)', async () => {
+ authenticateAs(USER);
+
+ const res = await getFeatures();
+ const json = await res.json();
+
+ expect(json).toHaveProperty('features');
+ expect(typeof json.features).toBe('object');
+ for (const value of Object.values(json.features)) {
+ expect(typeof value).toBe('boolean');
+ }
+ });
+
+ it('publishes the repeatedWordCheck flag (authenticated)', async () => {
+ authenticateAs(USER);
+
+ const res = await getFeatures();
+ const json = await res.json();
+
+ // .env.test wires FLUENT_AI_URL + FLUENT_AI_KEY and leaves the flag unset,
+ // so the derived (safe) default resolves to true in the test env.
+ expect(json.features).toHaveProperty('repeatedWordCheck');
+ expect(json.features.repeatedWordCheck).toBe(true);
+ });
+});
diff --git a/src/routes/config.route.ts b/src/routes/config.route.ts
new file mode 100644
index 00000000..927efb30
--- /dev/null
+++ b/src/routes/config.route.ts
@@ -0,0 +1,52 @@
+import { createRoute } from '@hono/zod-openapi';
+import * as HttpStatusCodes from 'stoker/http-status-codes';
+import { jsonContent } from 'stoker/openapi/helpers';
+import { createMessageObjectSchema } from 'stoker/openapi/schemas';
+import { z } from 'zod';
+
+import env from '@/env';
+import { buildFeatures, featuresSchema } from '@/lib/features';
+import { authenticateUser } from '@/middlewares/role-auth';
+import { server } from '@/server/server';
+
+// The published feature map. `featuresSchema` (the programmer's catalog of named
+// flags) lives with the FLAGS registry in src/lib/features.ts so the two stay
+// tied by a compile-time `satisfies`; a flag is declared in THREE synced places
+// — env.ts, the FLAGS registry, and featuresSchema — guarded by the drift test
+// in src/lib/features.test.ts.
+const featuresResponseSchema = z.object({
+ features: featuresSchema,
+});
+
+const configFeaturesRoute = createRoute({
+ tags: ['Config'],
+ method: 'get',
+ path: '/config/features',
+ // Login-gated (no role) — matches the /self/settings pattern: authenticateUser
+ // turns a missing session into a 401 (the global passive `authenticate` in
+ // server.ts populates c.get('user') first). Reviewer-confirmed for #211 Q1
+ // (kaseywright, 2026-07-07): keep the read behind auth, reversible later.
+ middleware: [authenticateUser] as const,
+ responses: {
+ [HttpStatusCodes.OK]: jsonContent(
+ featuresResponseSchema,
+ 'The set of optional features that are enabled in this environment'
+ ),
+ [HttpStatusCodes.UNAUTHORIZED]: jsonContent(
+ createMessageObjectSchema('Unauthorized'),
+ 'Authentication required'
+ ),
+ },
+ summary: 'Published feature flags',
+ description:
+ 'Returns the env-derived map of which optional features are enabled in this ' +
+ 'environment. Requires an authenticated session (login-gated, no role) and is ' +
+ 'read-only: it publishes state, it does not enforce it — the publish-vs-enforce ' +
+ 'decoupling (D5) is unchanged; only the read is now login-gated (#211 Q1).',
+});
+
+server.openapi(configFeaturesRoute, (c) => {
+ return c.json({ features: buildFeatures(env) }, HttpStatusCodes.OK);
+});
+
+export default server;
diff --git a/src/routes/doc.route.test.ts b/src/routes/doc.route.test.ts
new file mode 100644
index 00000000..cb2c90a4
--- /dev/null
+++ b/src/routes/doc.route.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it, vi } from 'vitest';
+
+// ─── Module mocks ─────────────────────────────────────────────────────────────
+// Importing `@/app` wires up every route module + `configureOpenAPI(server)`,
+// which registers the `/doc` endpoint. We only need the OpenAPI *document* to
+// render, so stub out the side-effecting deps (DB, auth, logger) the same way
+// the domain route tests do — none of them are touched by spec generation.
+
+// Some modules build Drizzle queries at import time (e.g.
+// projects.query-builder.ts calls `db.select(...).from(...)` at module scope).
+// A plain `vi.fn()` returns `undefined`, so `.from` would throw during import
+// and mask the actual thing under test. A self-returning chainable proxy lets
+// those builders construct without touching a real DB; no query is executed by
+// this test (it only renders the OpenAPI document).
+const chainable: any = new Proxy(vi.fn(), {
+ get: () => () => chainable,
+ apply: () => chainable,
+});
+
+vi.mock('@/db', () => ({
+ db: chainable,
+}));
+
+vi.mock('@/lib/auth', () => ({
+ auth: {
+ api: { getSession: vi.fn() },
+ handler: vi.fn(),
+ },
+}));
+
+vi.mock('@/lib/logger', () => ({
+ logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
+}));
+
+// ─── Regression: the OpenAPI spec must build ────────────────────────────────────
+// `GET /doc` walks EVERY schema registered on the app to emit the OpenAPI JSON.
+// If any registered schema is a node the generator can't render (e.g. a
+// `.catch(...)`-wrapped schema, which zod-to-openapi has no handler for), the
+// whole endpoint 500s — with nothing at the type/unit level to catch it, so it
+// only surfaces at runtime (as it did: `/doc` returned 500 "Unknown zod object
+// type"). This test renders the real, fully-assembled document and asserts it
+// succeeds, so that class of break fails CI instead of production.
+
+describe('gET /doc (OpenAPI document)', () => {
+ it('renders the full OpenAPI spec without throwing (200, valid JSON)', async () => {
+ const { default: app } = await import('@/app');
+
+ const res = await app.request('/doc', { method: 'GET' });
+
+ expect(res.status).toBe(200);
+
+ const doc = await res.json();
+ expect(doc.openapi).toBe('3.0.0');
+ // A representative registered path proves the walk actually completed rather
+ // than short-circuiting on an empty document.
+ expect(doc.paths['/self/settings']).toBeDefined();
+ });
+});