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(); + }); +});