From 4064b308269739d6c35d87d217d2a4b859c072cc Mon Sep 17 00:00:00 2001 From: henrique221 Date: Wed, 9 Sep 2026 17:35:33 -0300 Subject: [PATCH] feat: add pericope AI suggestions and separate heading usage --- .../pericope-ai-suggestions/design.md | 31 + .../0029_add_pericope_ai_suggestions.sql | 26 + src/db/migrations/meta/0029_snapshot.json | 3729 +++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + src/db/schema.ts | 48 +- ...ai-pericope.repository.integration.test.ts | 327 ++ .../ai-suggestions/ai-pericope.repository.ts | 233 + .../ai-pericope.service.test.ts | 277 ++ .../ai-suggestions.internal.route.test.ts | 36 + .../ai-suggestions.internal.route.ts | 5 +- .../ai-suggestions.repository.ts | 3 +- .../ai-suggestions.route.test.ts | 91 + .../ai-suggestions/ai-suggestions.route.ts | 89 + .../ai-suggestions/ai-suggestions.service.ts | 160 +- .../ai-suggestions/ai-suggestions.types.ts | 79 +- src/domains/pericopes/pericopes.service.ts | 4 +- src/domains/pericopes/pericopes.types.ts | 8 + src/lib/queue.ts | 3 + 18 files changed, 5145 insertions(+), 11 deletions(-) create mode 100644 docs/features/pericope-ai-suggestions/design.md create mode 100644 src/db/migrations/0029_add_pericope_ai_suggestions.sql create mode 100644 src/db/migrations/meta/0029_snapshot.json create mode 100644 src/domains/ai-suggestions/ai-pericope.repository.integration.test.ts create mode 100644 src/domains/ai-suggestions/ai-pericope.repository.ts create mode 100644 src/domains/ai-suggestions/ai-pericope.service.test.ts diff --git a/docs/features/pericope-ai-suggestions/design.md b/docs/features/pericope-ai-suggestions/design.md new file mode 100644 index 00000000..00f362fa --- /dev/null +++ b/docs/features/pericope-ai-suggestions/design.md @@ -0,0 +1,31 @@ +# Pericope AI suggestions + +Supports [fluent-web#394](https://github.com/eten-tech-foundation/fluent-web/issues/394). Depends on the `markers.headings` storage contract in [fluent-api#320](https://github.com/eten-tech-foundation/fluent-api/pull/320). + +The editor queues the active and next pericope by their source identifiers. The API resolves each identifier against the project's selected pericope set, source Bible, book, and chapter assignment. It queues individual source verses whose translation is absent or blank and whose suggestion is not already cached. Verse 1 and saved empty rows participate. Nonempty translations remain untouched. + +Groups with a nonempty source title may also queue a heading-only job. A heading is omitted when the first target verse already has authored `markers.headings`, or when a title suggestion is cached. Title jobs use distinct singleton keys including the selected set and exact source range. Existing scripture job keys and payloads remain compatible. + +## Public HTTP contract + +Use the exact `pericopeNumber` returned by the chapter pericopes endpoint. Sets with sections use a compound identity such as `1_4a`; this keeps two sections that reuse the same raw pericope number separate. + +- `POST /ai-suggestions/queue-pericopes`: `{projectUnitId,bibleId,bookCode,chapterNumber,pericopeNumbers:string[]}` → `{queued,thresholdMet}`. Accepts 1–2 unique identifiers, each 1–100 characters without commas. Chapter assignment AI enablement and the existing activation threshold control queuing. Invalid batches queue nothing. Queue submission failures return an error; singleton deduplication is successful submission. +- `GET /ai-suggestions/pericopes`: the same fields in the query; `pericopeNumbers=4a,4b` is a comma-separated string. Returns `{data:[{pericopeNumber,bibleTextId,suggestedText,modelInfo?}]}`. `bibleTextId` identifies the first source-backed verse in the chapter group. Groups without source titles or with authored headings return no title. +- `POST /ai-suggestions/pericopes/usage`: `{projectUnitId,bibleTextId,pericopeNumber,wasUsed}`. A matching persisted suggestion from the current set must exist. Exposure (`false`) and acceptance (`true`) are recorded separately from verse suggestion usage. Once accepted, a delayed exposure cannot change the record back to false. + +All public endpoints reuse authenticated project access and `project:view` checks. Source verse IDs and title text are resolved on the server, never supplied by the browser. + +## Worker HTTP contract + +Existing trigger/context fields remain required. Optional `pericopeNumber` selects a heading-only job, and API-generated jobs also include `pericopeSetId`. Heading context validates the exact server-derived range and current set. The response adds `sectionHeading:{pericopeNumber,pericopeSetId,bibleTextId,sourceTitle}` or `null` when title generation no longer applies. `sourceVerses` is ordered and limited to exact pericope membership, including for sparse ranges. The worker treats `sectionHeading:null` as a successful no-op. + +`POST /ai-suggestions/internal/results` continues accepting `{items:[...]}` for scripture. Heading jobs send `{items:[],heading:{projectUnitId,bibleTextId,pericopeNumber,pericopeSetId,suggestedText,modelInfo?}}`; mixed heading/scripture results are rejected. Heading text uses the same validator as authored headings: trimmed, 1–300 UTF-16 units, no backslashes or line breaks. A title result never writes scripture or markers. Results are cached once, scoped by project unit, first verse, selected set, and pericope identifier. Old-set results are rejected and old-set caches are never served for a new set. + +Migration `0029_add_pericope_ai_suggestions` creates `ai_pericope_suggestions` and `ai_pericope_suggestion_usage`, with cascading references and uniqueness constraints. No existing translation data is rewritten. + +## Validation + +Unit and route tests cover gates, authorization, schema bounds, exact verse jobs, preservation, omitted titles, singleton behavior, submission failures, and heading context/results. The opt-in PostgreSQL suite applies the full migration history and checks real joins, source isolation, persistent cache uniqueness, monotonic usage, authored text preservation, and set changes. + +Run the integration suite only with a disposable PostgreSQL database named `fluent394_api` bound to `127.0.0.1:55494`, supplying its URL in `PERICOPE_TEST_DATABASE_URL`. The suite refuses any other database target. It creates fixture data only inside that disposable database. diff --git a/src/db/migrations/0029_add_pericope_ai_suggestions.sql b/src/db/migrations/0029_add_pericope_ai_suggestions.sql new file mode 100644 index 00000000..d672026b --- /dev/null +++ b/src/db/migrations/0029_add_pericope_ai_suggestions.sql @@ -0,0 +1,26 @@ +CREATE TABLE "ai_pericope_suggestion_usage" ( + "id" serial PRIMARY KEY NOT NULL, + "suggestion_id" integer NOT NULL, + "user_id" integer NOT NULL, + "was_used" boolean DEFAULT false NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "ai_pericope_suggestions" ( + "id" serial PRIMARY KEY NOT NULL, + "project_unit_id" integer NOT NULL, + "bible_text_id" integer NOT NULL, + "pericope_set_id" integer NOT NULL, + "pericope_number" varchar(100) NOT NULL, + "suggested_text" varchar(300) NOT NULL, + "model_info" varchar(100), + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "ai_pericope_suggestion_usage" ADD CONSTRAINT "ai_pericope_suggestion_usage_suggestion_id_ai_pericope_suggestions_id_fk" FOREIGN KEY ("suggestion_id") REFERENCES "public"."ai_pericope_suggestions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ai_pericope_suggestion_usage" ADD CONSTRAINT "ai_pericope_suggestion_usage_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ai_pericope_suggestions" ADD CONSTRAINT "ai_pericope_suggestions_project_unit_id_project_units_id_fk" FOREIGN KEY ("project_unit_id") REFERENCES "public"."project_units"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ai_pericope_suggestions" ADD CONSTRAINT "ai_pericope_suggestions_bible_text_id_bible_texts_id_fk" FOREIGN KEY ("bible_text_id") REFERENCES "public"."bible_texts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ai_pericope_suggestions" ADD CONSTRAINT "ai_pericope_suggestions_pericope_set_id_pericope_sets_id_fk" FOREIGN KEY ("pericope_set_id") REFERENCES "public"."pericope_sets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "uq_ai_pericope_usage_user" ON "ai_pericope_suggestion_usage" USING btree ("suggestion_id","user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "uq_ai_pericope_suggestion" ON "ai_pericope_suggestions" USING btree ("project_unit_id","bible_text_id","pericope_set_id","pericope_number"); \ No newline at end of file diff --git a/src/db/migrations/meta/0029_snapshot.json b/src/db/migrations/meta/0029_snapshot.json new file mode 100644 index 00000000..e5f26b9c --- /dev/null +++ b/src/db/migrations/meta/0029_snapshot.json @@ -0,0 +1,3729 @@ +{ + "id": "cb929a17-214c-4e19-af23-9a7a0b4885d4", + "prevId": "106a00a1-d2a5-47c4-8448-226ebbd805d9", + "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.ai_pericope_suggestion_usage": { + "name": "ai_pericope_suggestion_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "suggestion_id": { + "name": "suggestion_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "was_used": { + "name": "was_used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_ai_pericope_usage_user": { + "name": "uq_ai_pericope_usage_user", + "columns": [ + { + "expression": "suggestion_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_pericope_suggestion_usage_suggestion_id_ai_pericope_suggestions_id_fk": { + "name": "ai_pericope_suggestion_usage_suggestion_id_ai_pericope_suggestions_id_fk", + "tableFrom": "ai_pericope_suggestion_usage", + "tableTo": "ai_pericope_suggestions", + "columnsFrom": ["suggestion_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_pericope_suggestion_usage_user_id_users_id_fk": { + "name": "ai_pericope_suggestion_usage_user_id_users_id_fk", + "tableFrom": "ai_pericope_suggestion_usage", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_pericope_suggestions": { + "name": "ai_pericope_suggestions", + "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_text_id": { + "name": "bible_text_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pericope_set_id": { + "name": "pericope_set_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pericope_number": { + "name": "pericope_number", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "suggested_text": { + "name": "suggested_text", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true + }, + "model_info": { + "name": "model_info", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_ai_pericope_suggestion": { + "name": "uq_ai_pericope_suggestion", + "columns": [ + { + "expression": "project_unit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "bible_text_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pericope_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pericope_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_pericope_suggestions_project_unit_id_project_units_id_fk": { + "name": "ai_pericope_suggestions_project_unit_id_project_units_id_fk", + "tableFrom": "ai_pericope_suggestions", + "tableTo": "project_units", + "columnsFrom": ["project_unit_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_pericope_suggestions_bible_text_id_bible_texts_id_fk": { + "name": "ai_pericope_suggestions_bible_text_id_bible_texts_id_fk", + "tableFrom": "ai_pericope_suggestions", + "tableTo": "bible_texts", + "columnsFrom": ["bible_text_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_pericope_suggestions_pericope_set_id_pericope_sets_id_fk": { + "name": "ai_pericope_suggestions_pericope_set_id_pericope_sets_id_fk", + "tableFrom": "ai_pericope_suggestions", + "tableTo": "pericope_sets", + "columnsFrom": ["pericope_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_suggestion_usage_log": { + "name": "ai_suggestion_usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bible_text_id": { + "name": "bible_text_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "project_unit_id": { + "name": "project_unit_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "was_used": { + "name": "was_used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_ai_usage_user": { + "name": "idx_ai_usage_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ai_usage_project_unit": { + "name": "idx_ai_usage_project_unit", + "columns": [ + { + "expression": "project_unit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_ai_usage_user_text": { + "name": "uq_ai_usage_user_text", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "bible_text_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_unit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_suggestion_usage_log_user_id_users_id_fk": { + "name": "ai_suggestion_usage_log_user_id_users_id_fk", + "tableFrom": "ai_suggestion_usage_log", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_suggestion_usage_log_bible_text_id_bible_texts_id_fk": { + "name": "ai_suggestion_usage_log_bible_text_id_bible_texts_id_fk", + "tableFrom": "ai_suggestion_usage_log", + "tableTo": "bible_texts", + "columnsFrom": ["bible_text_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_suggestion_usage_log_project_unit_id_project_units_id_fk": { + "name": "ai_suggestion_usage_log_project_unit_id_project_units_id_fk", + "tableFrom": "ai_suggestion_usage_log", + "tableTo": "project_units", + "columnsFrom": ["project_unit_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_suggestions": { + "name": "ai_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "bible_text_id": { + "name": "bible_text_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "project_unit_id": { + "name": "project_unit_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "suggested_text": { + "name": "suggested_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_info": { + "name": "model_info", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_ai_suggestions_bible_text": { + "name": "idx_ai_suggestions_bible_text", + "columns": [ + { + "expression": "bible_text_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_ai_suggestions_per_text_unit": { + "name": "uq_ai_suggestions_per_text_unit", + "columns": [ + { + "expression": "bible_text_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_unit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_suggestions_bible_text_id_bible_texts_id_fk": { + "name": "ai_suggestions_bible_text_id_bible_texts_id_fk", + "tableFrom": "ai_suggestions", + "tableTo": "bible_texts", + "columnsFrom": ["bible_text_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_suggestions_project_unit_id_project_units_id_fk": { + "name": "ai_suggestions_project_unit_id_project_units_id_fk", + "tableFrom": "ai_suggestions", + "tableTo": "project_units", + "columnsFrom": ["project_unit_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "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 + }, + "active_org_id": { + "name": "active_org_id", + "type": "integer", + "primaryKey": false, + "notNull": 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" + }, + "auth_session_active_org_id_organizations_id_fk": { + "name": "auth_session_active_org_id_organizations_id_fk", + "tableFrom": "auth_session", + "tableTo": "organizations", + "columnsFrom": ["active_org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "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": { + "idx_bible_books_bible_book": { + "name": "idx_bible_books_bible_book", + "columns": [ + { + "expression": "bible_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": true, + "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 + }, + "provider": { + "name": "provider", + "type": "bible_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'dbl'" + }, + "external_id": { + "name": "external_id", + "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": { + "idx_bibles_provider_external_id": { + "name": "idx_bibles_provider_external_id", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"bibles\".\"external_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": { + "books_code_unique": { + "name": "books_code_unique", + "nullsNotDistinct": false, + "columns": ["code"] + } + }, + "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'" + }, + "has_claim_conflict": { + "name": "has_claim_conflict", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "claim_conflict_user_id": { + "name": "claim_conflict_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_ai_enabled": { + "name": "is_ai_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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" + }, + "chapter_assignments_claim_conflict_user_id_users_id_fk": { + "name": "chapter_assignments_claim_conflict_user_id_users_id_fk", + "tableFrom": "chapter_assignments", + "tableTo": "users", + "columnsFrom": ["claim_conflict_user_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(255)", + "primaryKey": false, + "notNull": true + }, + "lang_name_localized": { + "name": "lang_name_localized", + "type": "varchar(255)", + "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": { + "languages_lang_code_iso_639_3_unique": { + "name": "languages_lang_code_iso_639_3_unique", + "nullsNotDistinct": false, + "columns": ["lang_code_iso_639_3"] + } + }, + "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 + }, + "running_header": { + "name": "running_header", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "book_title": { + "name": "book_title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "toc_long_name": { + "name": "toc_long_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "toc_short_name": { + "name": "toc_short_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "toc_abbreviation": { + "name": "toc_abbreviation", + "type": "varchar", + "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": { + "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_unit_usfm_imports": { + "name": "project_unit_usfm_imports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "project_unit_id": { + "name": "project_unit_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "usfm": { + "name": "usfm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "materialized_at": { + "name": "materialized_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_usfm_import_per_unit_book": { + "name": "uq_usfm_import_per_unit_book", + "columns": [ + { + "expression": "project_unit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_unit_usfm_imports_project_unit_id_project_units_id_fk": { + "name": "project_unit_usfm_imports_project_unit_id_project_units_id_fk", + "tableFrom": "project_unit_usfm_imports", + "tableTo": "project_units", + "columnsFrom": ["project_unit_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "project_unit_usfm_imports_book_id_books_id_fk": { + "name": "project_unit_usfm_imports_book_id_books_id_fk", + "tableFrom": "project_unit_usfm_imports", + "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.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 + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp", + "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": {}, + "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.storage_objects": { + "name": "storage_objects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "bucket": { + "name": "bucket", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_storage_object_bucket_key": { + "name": "uq_storage_object_bucket_key", + "columns": [ + { + "expression": "bucket", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_storage_objects_unreclaimed": { + "name": "idx_storage_objects_unreclaimed", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 + }, + "markers": { + "name": "markers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "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_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "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": { + "uq_user_role_grant": { + "name": "uq_user_role_grant", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "COALESCE(\"org_id\", -1)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "COALESCE(\"project_id\", -1)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_roles_user": { + "name": "idx_user_roles_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_roles_org": { + "name": "idx_user_roles_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_roles_project": { + "name": "idx_user_roles_project", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_org_id_organizations_id_fk": { + "name": "user_roles_org_id_organizations_id_fk", + "tableFrom": "user_roles", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_project_id_projects_id_fk": { + "name": "user_roles_project_id_projects_id_fk", + "tableFrom": "user_roles", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_roles_created_by_users_id_fk": { + "name": "user_roles_created_by_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "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 + }, + "status": { + "name": "status", + "type": "user_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invited'" + }, + "last_active_org_id": { + "name": "last_active_org_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "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_last_active_org_id_organizations_id_fk": { + "name": "users_last_active_org_id_organizations_id_fk", + "tableFrom": "users", + "tableTo": "organizations", + "columnsFrom": ["last_active_org_id"], + "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 + }, + "public.verse_audio_recordings": { + "name": "verse_audio_recordings", + "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_text_id": { + "name": "bible_text_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_object_id": { + "name": "storage_object_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "version_token": { + "name": "version_token", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "conflict_status": { + "name": "conflict_status", + "type": "verse_audio_conflict_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'clean'" + }, + "active_take_id": { + "name": "active_take_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_verse_audio_per_bible_text": { + "name": "uq_verse_audio_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": { + "verse_audio_recordings_project_unit_id_project_units_id_fk": { + "name": "verse_audio_recordings_project_unit_id_project_units_id_fk", + "tableFrom": "verse_audio_recordings", + "tableTo": "project_units", + "columnsFrom": ["project_unit_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "verse_audio_recordings_bible_text_id_bible_texts_id_fk": { + "name": "verse_audio_recordings_bible_text_id_bible_texts_id_fk", + "tableFrom": "verse_audio_recordings", + "tableTo": "bible_texts", + "columnsFrom": ["bible_text_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "verse_audio_recordings_uploaded_by_users_id_fk": { + "name": "verse_audio_recordings_uploaded_by_users_id_fk", + "tableFrom": "verse_audio_recordings", + "tableTo": "users", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "verse_audio_recordings_storage_object_id_storage_objects_id_fk": { + "name": "verse_audio_recordings_storage_object_id_storage_objects_id_fk", + "tableFrom": "verse_audio_recordings", + "tableTo": "storage_objects", + "columnsFrom": ["storage_object_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "verse_audio_recordings_active_take_id_verse_audio_takes_id_fk": { + "name": "verse_audio_recordings_active_take_id_verse_audio_takes_id_fk", + "tableFrom": "verse_audio_recordings", + "tableTo": "verse_audio_takes", + "columnsFrom": ["active_take_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verse_audio_takes": { + "name": "verse_audio_takes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "recording_id": { + "name": "recording_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_object_id": { + "name": "storage_object_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "varchar(64)", + "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()" + } + }, + "indexes": { + "idx_verse_audio_takes_recording": { + "name": "idx_verse_audio_takes_recording", + "columns": [ + { + "expression": "recording_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_verse_audio_take_content_hash": { + "name": "uq_verse_audio_take_content_hash", + "columns": [ + { + "expression": "recording_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "content_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "verse_audio_takes_recording_id_verse_audio_recordings_id_fk": { + "name": "verse_audio_takes_recording_id_verse_audio_recordings_id_fk", + "tableFrom": "verse_audio_takes", + "tableTo": "verse_audio_recordings", + "columnsFrom": ["recording_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "verse_audio_takes_uploaded_by_users_id_fk": { + "name": "verse_audio_takes_uploaded_by_users_id_fk", + "tableFrom": "verse_audio_takes", + "tableTo": "users", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "verse_audio_takes_storage_object_id_storage_objects_id_fk": { + "name": "verse_audio_takes_storage_object_id_storage_objects_id_fk", + "tableFrom": "verse_audio_takes", + "tableTo": "storage_objects", + "columnsFrom": ["storage_object_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.assignment_role": { + "name": "assignment_role", + "schema": "public", + "values": ["drafter", "peer_checker"] + }, + "public.bible_provider": { + "name": "bible_provider", + "schema": "public", + "values": ["dbl"] + }, + "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"] + }, + "public.verse_audio_conflict_status": { + "name": "verse_audio_conflict_status", + "schema": "public", + "values": ["clean", "conflict"] + } + }, + "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 62cd458c..6499cdd9 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -204,6 +204,13 @@ "when": 1788849706258, "tag": "0028_add_usfm_imports", "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1788984553194, + "tag": "0029_add_pericope_ai_suggestions", + "breakpoints": true } ] } diff --git a/src/db/schema.ts b/src/db/schema.ts index 88371cfa..61cbe9ef 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -443,7 +443,7 @@ export const USFM_HEADING_MARKERS = [ * paragraph record can hold: a paragraph entry is a marker plus an offset into the *verse's* * text, and a heading belongs to no verse (fluent-web#397). */ -const verseHeadingSchema = z.object({ +export const verseHeadingSchema = z.object({ marker: z.enum(USFM_HEADING_MARKERS), text: z .string() @@ -959,6 +959,52 @@ export const ai_suggestion_usage_log = pgTable( ] ); +// Suggestions never modify translated_verses or its authored markers. The set +// identity keeps cached titles separate when a project changes pericope sets. +export const ai_pericope_suggestions = pgTable( + 'ai_pericope_suggestions', + { + id: serial('id').primaryKey(), + projectUnitId: integer('project_unit_id') + .notNull() + .references(() => project_units.id, { onDelete: 'cascade' }), + bibleTextId: integer('bible_text_id') + .notNull() + .references(() => bible_texts.id, { onDelete: 'cascade' }), + pericopeSetId: integer('pericope_set_id') + .notNull() + .references(() => pericope_sets.id, { onDelete: 'cascade' }), + pericopeNumber: varchar('pericope_number', { length: 100 }).notNull(), + suggestedText: varchar('suggested_text', { length: 300 }).notNull(), + modelInfo: varchar('model_info', { length: 100 }), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (table) => [ + uniqueIndex('uq_ai_pericope_suggestion').on( + table.projectUnitId, + table.bibleTextId, + table.pericopeSetId, + table.pericopeNumber + ), + ] +); + +export const ai_pericope_suggestion_usage = pgTable( + 'ai_pericope_suggestion_usage', + { + id: serial('id').primaryKey(), + suggestionId: integer('suggestion_id') + .notNull() + .references(() => ai_pericope_suggestions.id, { onDelete: 'cascade' }), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + wasUsed: boolean('was_used').notNull().default(false), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (table) => [uniqueIndex('uq_ai_pericope_usage_user').on(table.suggestionId, table.userId)] +); + const { createInsertSchema, createSelectSchema } = createSchemaFactory({ zodInstance: z, }); diff --git a/src/domains/ai-suggestions/ai-pericope.repository.integration.test.ts b/src/domains/ai-suggestions/ai-pericope.repository.integration.test.ts new file mode 100644 index 00000000..1b004f65 --- /dev/null +++ b/src/domains/ai-suggestions/ai-pericope.repository.integration.test.ts @@ -0,0 +1,327 @@ +import { and, eq } from 'drizzle-orm'; +import { migrate } from 'drizzle-orm/postgres-js/migrator'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import * as schema from '@/db/schema'; + +import { + logPericopeUsage, + resolvePericopes, + savePericopeSuggestion, +} from './ai-pericope.repository'; +import { logAiSuggestionUsage } from './ai-suggestions.repository'; + +// Opt-in only. Never connect to the developer's configured/shared database. +const { connection } = vi.hoisted(() => ({ + connection: { close: undefined as (() => Promise) | undefined }, +})); +vi.mock('@/db', async () => { + const url = process.env.PERICOPE_TEST_DATABASE_URL; + if (!url) return { db: {} }; + const target = new URL(url); + if ( + target.hostname !== '127.0.0.1' || + target.port !== '55494' || + target.pathname !== '/fluent394_api' + ) { + throw new Error( + 'Pericope integration tests require the isolated fluent394_api database on localhost:55494' + ); + } + const { default: postgres } = await import('postgres'); + const { drizzle } = await import('drizzle-orm/postgres-js'); + const client = postgres(url, { max: 2, onnotice: () => {} }); + connection.close = () => client.end(); + return { db: drizzle(client) }; +}); + +describe.skipIf(!process.env.PERICOPE_TEST_DATABASE_URL)( + 'pericope repository with migrated PostgreSQL', + () => { + let projectUnitId: number; + let projectId: number; + let bibleId: number; + let bookId: number; + let pericopeSetId: number; + let otherSetId: number; + let userId: number; + let bibleTextIds: number[]; + const query = () => ({ + projectUnitId, + bibleId, + bookCode: 'GEN', + chapterNumber: 1, + pericopeNumbers: ['4a', '4b'], + }); + const heading = () => ({ + projectUnitId, + bibleTextId: bibleTextIds[0], + pericopeSetId, + pericopeNumber: '4a', + suggestedText: 'The creation', + modelInfo: 'test-model', + }); + + beforeAll(async () => { + await migrate(db, { migrationsFolder: './src/db/migrations' }); + const suffix = Date.now().toString(); + const [org] = await db + .insert(schema.organizations) + .values({ name: `Pericope test ${suffix}` }) + .returning(); + const [language] = await db + .insert(schema.languages) + .values({ langName: `Test ${suffix}` }) + .returning(); + const [user] = await db + .insert(schema.users) + .values({ username: `pericope-${suffix}`, email: `${suffix}@example.test` }) + .returning(); + userId = user.id; + const sets = await db + .insert(schema.pericope_sets) + .values([{ name: `FIA-${suffix}` }, { name: `FCBH-${suffix}` }]) + .returning(); + pericopeSetId = sets[0].id; + otherSetId = sets[1].id; + const [project] = await db + .insert(schema.projects) + .values({ + name: 'Pericope test', + sourceLanguage: language.id, + targetLanguage: language.id, + organization: org.id, + pericopeSetId, + }) + .returning(); + projectId = project.id; + const [unit] = await db.insert(schema.project_units).values({ projectId }).returning(); + projectUnitId = unit.id; + const [bible] = await db + .insert(schema.bibles) + .values({ languageId: language.id, name: `Source ${suffix}`, abbreviation: `s-${suffix}` }) + .returning(); + bibleId = bible.id; + await db + .insert(schema.books) + .values({ code: 'GEN', eng_display_name: 'Genesis' }) + .onConflictDoNothing(); + const [book] = await db.select().from(schema.books).where(eq(schema.books.code, 'GEN')); + bookId = book.id; + await db.insert(schema.project_unit_bible_books).values({ projectUnitId, bibleId, bookId }); + await db + .insert(schema.chapter_assignments) + .values({ projectUnitId, bibleId, bookId, chapterNumber: 1, isAiEnabled: true }); + const texts = await db + .insert(schema.bible_texts) + .values( + [1, 2, 3, 4].map((verseNumber) => ({ + bibleId, + bookId, + chapterNumber: 1, + verseNumber, + text: `Source ${verseNumber}`, + })) + ) + .returning(); + bibleTextIds = texts.map((text) => text.id); + await db.insert(schema.pericope_verses).values( + [pericopeSetId, otherSetId].flatMap((setId) => + [1, 2, 3, 4].map((verseNumber) => ({ + pericopeSetId: setId, + bookId, + chapterNumber: 1, + verseNumber, + section: setId === pericopeSetId ? null : verseNumber < 4 ? 1 : 2, + pericopeNumber: setId === otherSetId || verseNumber < 4 ? '4a' : '4b', + pericopeTitle: setId === pericopeSetId && verseNumber < 4 ? 'Creation' : null, + })) + ) + ); + await db.insert(schema.translated_verses).values([ + { projectUnitId, bibleTextId: bibleTextIds[1], content: '' }, + { + projectUnitId, + bibleTextId: bibleTextIds[2], + content: 'Already translated', + markers: { headings: [{ marker: 's', text: 'An authored heading' }] }, + }, + ]); + await db + .insert(schema.ai_suggestions) + .values({ projectUnitId, bibleTextId: bibleTextIds[3], suggestedText: 'Cached scripture' }); + }); + afterAll(async () => { + await connection.close?.(); + }); + + it('resolves exact source IDs including absent and empty translation rows, and excludes unrelated contexts', async () => { + const result = await resolvePericopes(query()); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.groups[0].verses).toEqual([ + { + bibleTextId: bibleTextIds[0], + verseNumber: 1, + content: null, + hasAuthoredHeading: false, + hasSuggestion: false, + }, + { + bibleTextId: bibleTextIds[1], + verseNumber: 2, + content: '', + hasAuthoredHeading: false, + hasSuggestion: false, + }, + { + bibleTextId: bibleTextIds[2], + verseNumber: 3, + content: 'Already translated', + hasAuthoredHeading: true, + hasSuggestion: false, + }, + ]); + expect(result.data.groups[1].sourceTitle).toBeNull(); + expect(result.data.groups[1].verses[0].hasSuggestion).toBe(true); + for (const overrides of [ + { bibleId: bibleId + 9999 }, + { projectUnitId: projectUnitId + 9999 }, + { bookCode: 'EXO' }, + { chapterNumber: 2 }, + { pericopeNumbers: ['4a', 'missing'] }, + ]) { + expect((await resolvePericopes({ ...query(), ...overrides })).ok).toBe(false); + } + }); + + it('caches titles once, records shown then used without downgrade, and rejects mismatched usage', async () => { + expect((await savePericopeSuggestion(heading())).ok).toBe(true); + await savePericopeSuggestion({ ...heading(), suggestedText: 'Should not replace cache' }); + const usage = { + projectUnitId, + bibleTextId: bibleTextIds[0], + pericopeNumber: '4a', + wasUsed: false, + }; + expect((await logPericopeUsage(userId, usage)).ok).toBe(true); + expect((await logPericopeUsage(userId, { ...usage, wasUsed: true })).ok).toBe(true); + await logPericopeUsage(userId, usage); + const rows = await db + .select() + .from(schema.ai_pericope_suggestions) + .where(eq(schema.ai_pericope_suggestions.projectUnitId, projectUnitId)); + expect(rows).toHaveLength(1); + expect(rows[0].suggestedText).toBe('The creation'); + const exposures = await db + .select() + .from(schema.ai_pericope_suggestion_usage) + .where(eq(schema.ai_pericope_suggestion_usage.suggestionId, rows[0].id)); + expect(exposures).toHaveLength(1); + expect(exposures[0].wasUsed).toBe(true); + expect((await logPericopeUsage(userId, { ...usage, bibleTextId: bibleTextIds[1] })).ok).toBe( + false + ); + expect((await logPericopeUsage(userId, { ...usage, pericopeNumber: '4b' })).ok).toBe(false); + expect( + await db + .select() + .from(schema.ai_suggestion_usage_log) + .where(eq(schema.ai_suggestion_usage_log.projectUnitId, projectUnitId)) + ).toHaveLength(0); + const scripture = await db + .select() + .from(schema.translated_verses) + .where(eq(schema.translated_verses.projectUnitId, projectUnitId)); + expect(scripture.map((row) => row.content)).toEqual(['', 'Already translated']); + expect(scripture[1].markers?.headings?.[0].text).toBe('An authored heading'); + }); + + it('ignores title-less groups and authored headings, rejects mismatched first verse and old-set results', async () => { + expect( + (await savePericopeSuggestion({ ...heading(), bibleTextId: bibleTextIds[1] })).ok + ).toBe(false); + expect( + ( + await savePericopeSuggestion({ + ...heading(), + bibleTextId: bibleTextIds[3], + pericopeNumber: '4b', + }) + ).ok + ).toBe(true); + await db + .delete(schema.ai_pericope_suggestions) + .where(eq(schema.ai_pericope_suggestions.projectUnitId, projectUnitId)); + await db.insert(schema.translated_verses).values({ + projectUnitId, + bibleTextId: bibleTextIds[0], + content: '', + markers: { headings: [{ marker: 's1', text: 'Written while generating' }] }, + }); + expect((await savePericopeSuggestion(heading())).ok).toBe(true); + expect( + await db + .select() + .from(schema.ai_pericope_suggestions) + .where(eq(schema.ai_pericope_suggestions.projectUnitId, projectUnitId)) + ).toHaveLength(0); + await db + .update(schema.translated_verses) + .set({ markers: null }) + .where( + and( + eq(schema.translated_verses.projectUnitId, projectUnitId), + eq(schema.translated_verses.bibleTextId, bibleTextIds[0]) + ) + ); + await savePericopeSuggestion(heading()); + await db + .update(schema.projects) + .set({ pericopeSetId: otherSetId }) + .where(eq(schema.projects.id, projectId)); + expect((await savePericopeSuggestion(heading())).ok).toBe(false); + const current = await resolvePericopes({ ...query(), pericopeNumbers: ['1_4a', '2_4a'] }); + expect(current.ok && current.data.groups[0].suggestion).toBeNull(); + expect( + current.ok && + current.data.groups.map((group) => ({ + number: group.pericopeNumber, + verses: group.verses.map((verse) => verse.verseNumber), + })) + ).toEqual([ + { number: '1_4a', verses: [1, 2, 3] }, + { number: '2_4a', verses: [4] }, + ]); + expect((await resolvePericopes(query())).ok).toBe(false); + expect( + ( + await logPericopeUsage(userId, { + projectUnitId, + bibleTextId: bibleTextIds[0], + pericopeNumber: '4a', + wasUsed: false, + }) + ).ok + ).toBe(false); + }); + + it('also keeps existing verse acceptance after a delayed exposure event', async () => { + await logAiSuggestionUsage(userId, bibleTextIds[3], projectUnitId, false); + await logAiSuggestionUsage(userId, bibleTextIds[3], projectUnitId, true); + await logAiSuggestionUsage(userId, bibleTextIds[3], projectUnitId, false); + const records = await db + .select() + .from(schema.ai_suggestion_usage_log) + .where( + and( + eq(schema.ai_suggestion_usage_log.projectUnitId, projectUnitId), + eq(schema.ai_suggestion_usage_log.userId, userId) + ) + ); + expect(records).toHaveLength(1); + expect(records[0].wasUsed).toBe(true); + }); + } +); diff --git a/src/domains/ai-suggestions/ai-pericope.repository.ts b/src/domains/ai-suggestions/ai-pericope.repository.ts new file mode 100644 index 00000000..ccb344f7 --- /dev/null +++ b/src/domains/ai-suggestions/ai-pericope.repository.ts @@ -0,0 +1,233 @@ +import { and, asc, eq, inArray, sql } from 'drizzle-orm'; + +import type { Result } from '@/lib/types'; + +import { db } from '@/db'; +import { + ai_pericope_suggestion_usage, + ai_pericope_suggestions, + ai_suggestions, + bible_texts, + books, + chapter_assignments, + pericope_verses, + project_unit_bible_books, + project_units, + projects, + translated_verses, +} from '@/db/schema'; +import { getPericopeGroupNumber } from '@/domains/pericopes/pericopes.types'; +import { err, ErrorCode, ok } from '@/lib/types'; + +import type { + PericopeRequest, + PericopeSuggestionItem, + PericopeUsageRequest, +} from './ai-suggestions.types'; + +export interface PericopeVerse { + bibleTextId: number; + verseNumber: number; + content: string | null; + hasAuthoredHeading: boolean; + hasSuggestion: boolean; +} +export interface ResolvedPericope { + pericopeNumber: string; + sourceTitle: string | null; + verses: PericopeVerse[]; + suggestion: typeof ai_pericope_suggestions.$inferSelect | null; +} +export interface PericopeContext { + pericopeSetId: number; + isAiEnabled: boolean; + groups: ResolvedPericope[]; +} + +/** Resolve exact source-backed verses from the project's current set, never a client range. */ +export async function resolvePericopes(params: PericopeRequest): Promise> { + const [context] = await db + .select({ + pericopeSetId: projects.pericopeSetId, + isAiEnabled: chapter_assignments.isAiEnabled, + bookId: books.id, + }) + .from(project_units) + .innerJoin(projects, eq(project_units.projectId, projects.id)) + .innerJoin( + project_unit_bible_books, + eq(project_unit_bible_books.projectUnitId, project_units.id) + ) + .innerJoin(books, eq(project_unit_bible_books.bookId, books.id)) + .innerJoin( + chapter_assignments, + and( + eq(chapter_assignments.projectUnitId, project_units.id), + eq(chapter_assignments.bibleId, project_unit_bible_books.bibleId), + eq(chapter_assignments.bookId, books.id), + eq(chapter_assignments.chapterNumber, params.chapterNumber) + ) + ) + .where( + and( + eq(project_units.id, params.projectUnitId), + eq(project_unit_bible_books.bibleId, params.bibleId), + eq(books.code, params.bookCode.toUpperCase()) + ) + ) + .limit(1); + if (!context?.pericopeSetId) return err(ErrorCode.INVALID_REFERENCE); + + const rows = await db + .select({ + pericopeNumber: pericope_verses.pericopeNumber, + section: pericope_verses.section, + sourceTitle: pericope_verses.pericopeTitle, + bibleTextId: bible_texts.id, + verseNumber: bible_texts.verseNumber, + content: translated_verses.content, + markers: translated_verses.markers, + suggestionId: ai_suggestions.id, + }) + .from(pericope_verses) + .innerJoin( + bible_texts, + and( + eq(bible_texts.bibleId, params.bibleId), + eq(bible_texts.bookId, pericope_verses.bookId), + eq(bible_texts.chapterNumber, pericope_verses.chapterNumber), + eq(bible_texts.verseNumber, pericope_verses.verseNumber) + ) + ) + .leftJoin( + translated_verses, + and( + eq(translated_verses.bibleTextId, bible_texts.id), + eq(translated_verses.projectUnitId, params.projectUnitId) + ) + ) + .leftJoin( + ai_suggestions, + and( + eq(ai_suggestions.bibleTextId, bible_texts.id), + eq(ai_suggestions.projectUnitId, params.projectUnitId) + ) + ) + .where( + and( + eq(pericope_verses.pericopeSetId, context.pericopeSetId), + eq(pericope_verses.bookId, context.bookId), + eq(pericope_verses.chapterNumber, params.chapterNumber) + ) + ) + .orderBy(asc(bible_texts.verseNumber)); + + const groups: ResolvedPericope[] = params.pericopeNumbers.map((pericopeNumber) => { + const verses = rows.filter((row) => getPericopeGroupNumber(row) === pericopeNumber); + return { + pericopeNumber, + sourceTitle: verses.find((row) => row.sourceTitle?.trim())?.sourceTitle?.trim() ?? null, + verses: verses.map((row) => ({ + bibleTextId: row.bibleTextId, + verseNumber: row.verseNumber, + content: row.content, + hasAuthoredHeading: Boolean(row.markers?.headings?.length), + hasSuggestion: row.suggestionId !== null, + })), + suggestion: null, + }; + }); + // Reject the complete batch if any number is outside this chapter/book/set. + if (groups.some((group) => group.verses.length === 0)) return err(ErrorCode.INVALID_REFERENCE); + + const suggestions = await db + .select() + .from(ai_pericope_suggestions) + .where( + and( + eq(ai_pericope_suggestions.projectUnitId, params.projectUnitId), + eq(ai_pericope_suggestions.pericopeSetId, context.pericopeSetId), + inArray( + ai_pericope_suggestions.bibleTextId, + groups.map((group) => group.verses[0].bibleTextId) + ), + inArray(ai_pericope_suggestions.pericopeNumber, params.pericopeNumbers) + ) + ); + for (const group of groups) { + group.suggestion = + suggestions.find( + (suggestion) => + suggestion.pericopeNumber === group.pericopeNumber && + suggestion.bibleTextId === group.verses[0].bibleTextId + ) ?? null; + } + return ok({ pericopeSetId: context.pericopeSetId, isAiEnabled: context.isAiEnabled, groups }); +} + +export async function savePericopeSuggestion(item: PericopeSuggestionItem): Promise> { + const [verse] = await db + .select({ + bibleId: bible_texts.bibleId, + bookCode: books.code, + chapterNumber: bible_texts.chapterNumber, + }) + .from(bible_texts) + .innerJoin(books, eq(books.id, bible_texts.bookId)) + .where(eq(bible_texts.id, item.bibleTextId)) + .limit(1); + if (!verse) return err(ErrorCode.INVALID_REFERENCE); + const resolved = await resolvePericopes({ + ...verse, + projectUnitId: item.projectUnitId, + pericopeNumbers: [item.pericopeNumber], + }); + if (!resolved.ok) return resolved; + const group = resolved.data.groups[0]; + if ( + resolved.data.pericopeSetId !== item.pericopeSetId || + group.verses[0].bibleTextId !== item.bibleTextId + ) { + return err(ErrorCode.INVALID_REFERENCE); + } + // A drafter may have written a heading while the model was generating. + if (!resolved.data.isAiEnabled || !group.sourceTitle || group.verses[0].hasAuthoredHeading) + return ok(undefined); + await db.insert(ai_pericope_suggestions).values(item).onConflictDoNothing(); + return ok(undefined); +} + +export async function logPericopeUsage( + userId: number, + data: PericopeUsageRequest +): Promise> { + const [suggestion] = await db + .select({ id: ai_pericope_suggestions.id }) + .from(ai_pericope_suggestions) + .innerJoin(project_units, eq(project_units.id, ai_pericope_suggestions.projectUnitId)) + .innerJoin( + projects, + and( + eq(projects.id, project_units.projectId), + eq(projects.pericopeSetId, ai_pericope_suggestions.pericopeSetId) + ) + ) + .where( + and( + eq(ai_pericope_suggestions.projectUnitId, data.projectUnitId), + eq(ai_pericope_suggestions.bibleTextId, data.bibleTextId), + eq(ai_pericope_suggestions.pericopeNumber, data.pericopeNumber) + ) + ) + .limit(1); + if (!suggestion) return err(ErrorCode.INVALID_REFERENCE); + await db + .insert(ai_pericope_suggestion_usage) + .values({ suggestionId: suggestion.id, userId, wasUsed: data.wasUsed }) + .onConflictDoUpdate({ + target: [ai_pericope_suggestion_usage.suggestionId, ai_pericope_suggestion_usage.userId], + // A late exposure request must never downgrade a previously accepted title. + set: { wasUsed: sql`${ai_pericope_suggestion_usage.wasUsed} OR EXCLUDED.was_used` }, + }); + return ok(undefined); +} diff --git a/src/domains/ai-suggestions/ai-pericope.service.test.ts b/src/domains/ai-suggestions/ai-pericope.service.test.ts new file mode 100644 index 00000000..c3329a3b --- /dev/null +++ b/src/domains/ai-suggestions/ai-pericope.service.test.ts @@ -0,0 +1,277 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { PericopeContext, PericopeVerse } from './ai-pericope.repository'; + +import { resolvePericopes, savePericopeSuggestion } from './ai-pericope.repository'; +import { + findNextUntranslatedVerses, + getChapterAssignmentAiStatus, + getSuggestionContextData, + hasReachedAiActivationThreshold, +} from './ai-suggestions.repository'; +import { + getPericopeSuggestions, + getSuggestionContext, + queueNextVerses, + queuePericopes, + saveAiSuggestions, +} from './ai-suggestions.service'; + +const { send } = vi.hoisted(() => ({ send: vi.fn().mockResolvedValue('job') })); +vi.mock('@/env', () => ({ default: { AI_ACTIVATION_THRESHOLD_VERSES: 5 } })); +vi.mock('@/lib/logger', () => ({ logger: { error: vi.fn(), debug: vi.fn() } })); +vi.mock('@/lib/queue', () => ({ + getQueue: vi.fn(async () => ({ send })), + QUEUE_NAMES: { AI_SUGGESTIONS: 'ai-suggestions' }, +})); +vi.mock('./ai-suggestions.repository', () => ({ + hasReachedAiActivationThreshold: vi.fn(), + getSuggestionContextData: vi.fn(), + upsertAiSuggestions: vi.fn(), + getChapterAssignmentAiStatus: vi.fn(), + findNextUntranslatedVerses: vi.fn(), +})); +vi.mock('./ai-pericope.repository', () => ({ + resolvePericopes: vi.fn(), + savePericopeSuggestion: vi.fn(), + logPericopeUsage: vi.fn(), +})); + +const request = { + projectUnitId: 1, + bibleId: 2, + bookCode: 'GEN', + chapterNumber: 1, + pericopeNumbers: ['4a', '4b'], +}; +function verse(number: number, extras: Partial = {}): PericopeVerse { + return { + bibleTextId: number + 100, + verseNumber: number, + content: null, + hasAuthoredHeading: false, + hasSuggestion: false, + ...extras, + }; +} +let context: PericopeContext; + +describe('pericope AI suggestions', () => { + beforeEach(() => { + vi.clearAllMocks(); + send.mockResolvedValue('job'); + context = { + pericopeSetId: 5, + isAiEnabled: true, + groups: [ + { + pericopeNumber: '4a', + sourceTitle: 'Creation', + suggestion: null, + verses: [ + verse(1), + verse(2, { content: '' }), + verse(3, { content: 'Translated' }), + verse(4, { hasSuggestion: true }), + verse(5, { content: ' ' }), + ], + }, + { pericopeNumber: '4b', sourceTitle: null, suggestion: null, verses: [verse(7)] }, + ], + }; + vi.mocked(resolvePericopes).mockImplementation(async () => ({ ok: true, data: context })); + vi.mocked(hasReachedAiActivationThreshold).mockResolvedValue(true); + }); + + it('also propagates a failed queue submission from the legacy queue-next endpoint', async () => { + vi.mocked(getChapterAssignmentAiStatus).mockResolvedValue(true); + vi.mocked(findNextUntranslatedVerses).mockResolvedValue([2]); + send.mockRejectedValueOnce(new Error('queue unavailable')); + expect((await queueNextVerses(1, 2, 'GEN', 1, 1)).ok).toBe(false); + }); + + it('queues exact missing/empty verses including verse 1, preserves text/cache, and queues one separate title', async () => { + expect(await queuePericopes(request)).toEqual({ + ok: true, + data: { queued: true, thresholdMet: true }, + }); + expect(resolvePericopes).toHaveBeenCalledWith(request); + const jobs = send.mock.calls.map((call) => call[1]); + expect(jobs.filter((job) => !job.pericopeNumber).map((job) => job.verseStart)).toEqual([ + 1, 2, 5, 7, + ]); + expect(jobs.find((job) => job.pericopeNumber)).toEqual({ + projectUnitId: 1, + bibleId: 2, + bookCode: 'GEN', + chapterNumber: 1, + verseStart: 1, + verseEnd: 5, + pericopeNumber: '4a', + pericopeSetId: 5, + }); + expect(send.mock.calls[0][2].singletonKey).toBe('1:2:GEN:1:1'); + expect(send.mock.calls[3][2].singletonKey).toBe('heading:1:2:GEN:1:1:5:5:4a'); + }); + + it.each([false, true])( + 'does not queue when threshold=%s and the assignment is disabled', + async (thresholdMet) => { + context.isAiEnabled = false; + vi.mocked(hasReachedAiActivationThreshold).mockResolvedValue(thresholdMet); + expect(await queuePericopes(request)).toEqual({ + ok: true, + data: { queued: false, thresholdMet }, + }); + expect(send).not.toHaveBeenCalled(); + } + ); + + it('does not queue before the activation threshold', async () => { + vi.mocked(hasReachedAiActivationThreshold).mockResolvedValue(false); + expect(await queuePericopes(request)).toEqual({ + ok: true, + data: { queued: false, thresholdMet: false }, + }); + expect(send).not.toHaveBeenCalled(); + }); + + it('rejects an invalid batch before queuing any job', async () => { + vi.mocked(resolvePericopes).mockResolvedValue({ + ok: false, + error: { code: 'INVALID_REFERENCE', message: 'Invalid reference' }, + }); + expect((await queuePericopes(request)).ok).toBe(false); + expect(send).not.toHaveBeenCalled(); + }); + + it('preserves authored headings and omits title-less groups', async () => { + context.groups[0].verses[0].hasAuthoredHeading = true; + await queuePericopes(request); + expect(send.mock.calls.every((call) => call[1].pericopeNumber === undefined)).toBe(true); + }); + + it('does not regenerate cached titles or already suggested verses', async () => { + context.groups = [context.groups[0]]; + context.groups[0].verses.forEach((row) => { + row.hasSuggestion = true; + }); + context.groups[0].suggestion = { + id: 1, + projectUnitId: 1, + bibleTextId: 101, + pericopeSetId: 5, + pericopeNumber: '4a', + suggestedText: 'The creation', + modelInfo: null, + createdAt: new Date(), + }; + expect(await queuePericopes(request)).toEqual({ + ok: true, + data: { queued: false, thresholdMet: true }, + }); + expect(send).not.toHaveBeenCalled(); + expect(await getPericopeSuggestions(request)).toEqual({ + ok: true, + data: { + data: [ + { + bibleTextId: 101, + pericopeNumber: '4a', + suggestedText: 'The creation', + modelInfo: null, + }, + ], + }, + }); + context.groups[0].verses[0].hasAuthoredHeading = true; + expect(await getPericopeSuggestions(request)).toEqual({ ok: true, data: { data: [] } }); + }); + + it('propagates queue failures, but accepts singleton deduplication', async () => { + send.mockRejectedValueOnce(new Error('queue unavailable')); + expect((await queuePericopes(request)).ok).toBe(false); + send.mockResolvedValue(null); + expect((await queuePericopes(request)).ok).toBe(true); + }); + + it('derives heading context and removes non-group source verses from a sparse range', async () => { + context.groups[0].verses = [verse(1), verse(3)]; + vi.mocked(getSuggestionContextData).mockResolvedValue({ + ok: true, + data: { + targetLanguageName: 'Hindi', + contextVerses: [], + sourceVerses: [1, 2, 3].map((number) => ({ + id: number + 100, + verse_number: number, + text: 'source', + })), + }, + }); + const result = await getSuggestionContext({ + ...request, + verseStart: 1, + verseEnd: 3, + pericopeNumber: '4a', + pericopeSetId: 5, + }); + expect(result.ok && result.data.sectionHeading).toEqual({ + pericopeNumber: '4a', + pericopeSetId: 5, + bibleTextId: 101, + sourceTitle: 'Creation', + }); + expect(result.ok && result.data.sourceVerses.map((row) => row.id)).toEqual([101, 103]); + }); + + it.each([ + { verseStart: 2, verseEnd: 5, pericopeSetId: 5 }, + { verseStart: 1, verseEnd: 5, pericopeSetId: 6 }, + ])('rejects mismatched ranges and old-set jobs', async (fields) => { + expect((await getSuggestionContext({ ...request, ...fields, pericopeNumber: '4a' })).ok).toBe( + false + ); + expect(getSuggestionContextData).not.toHaveBeenCalled(); + }); + + it('returns an explicit null heading when no source title exists', async () => { + context.groups[0].sourceTitle = null; + vi.mocked(getSuggestionContextData).mockResolvedValue({ + ok: true, + data: { + targetLanguageName: 'Hindi', + contextVerses: [], + sourceVerses: [], + }, + }); + const result = await getSuggestionContext({ + ...request, + verseStart: 1, + verseEnd: 5, + pericopeNumber: '4a', + }); + expect(result.ok && result.data.sectionHeading).toBeNull(); + }); + + it('saves heading results only through the separate repository', async () => { + const heading = { + projectUnitId: 1, + bibleTextId: 101, + pericopeNumber: '4a', + pericopeSetId: 5, + suggestedText: 'Title', + }; + vi.mocked(savePericopeSuggestion).mockResolvedValue({ ok: true, data: undefined }); + expect((await saveAiSuggestions([], heading)).ok).toBe(true); + expect(savePericopeSuggestion).toHaveBeenCalledWith(heading); + expect( + ( + await saveAiSuggestions( + [{ projectUnitId: 1, bibleTextId: 101, suggestedText: 'Verse' }], + heading + ) + ).ok + ).toBe(false); + }); +}); diff --git a/src/domains/ai-suggestions/ai-suggestions.internal.route.test.ts b/src/domains/ai-suggestions/ai-suggestions.internal.route.test.ts index c1fb5a99..959b42cb 100644 --- a/src/domains/ai-suggestions/ai-suggestions.internal.route.test.ts +++ b/src/domains/ai-suggestions/ai-suggestions.internal.route.test.ts @@ -133,6 +133,42 @@ describe('ai-suggestions internal routes', () => { // ─── POST /ai-suggestions/internal/results ──────────────────────────────── describe('pOST /ai-suggestions/internal/results', () => { + const heading = { + projectUnitId: 1, + bibleTextId: 10, + pericopeNumber: '4a', + pericopeSetId: 5, + suggestedText: 'The creation', + }; + it('accepts a heading-only result separately from scripture', async () => { + vi.mocked(aiSuggestionsService.saveAiSuggestions).mockResolvedValue({ + ok: true, + data: undefined, + }); + expect((await postResults({ items: [], heading })).status).toBe(200); + expect(aiSuggestionsService.saveAiSuggestions).toHaveBeenCalledWith([], heading); + }); + it.each([ + '', + ' ', + 'x'.repeat(301), + 'title\\v 1', + 'line\nbreak', + 'line\rbreak', + 'line\u2028break', + 'line\u2029break', + ])('rejects unsafe heading %j', async (suggestedText) => { + expect( + (await postResults({ items: [], heading: { ...heading, suggestedText } })).status + ).toBe(400); + expect(aiSuggestionsService.saveAiSuggestions).not.toHaveBeenCalled(); + }); + it('requires the source set and rejects mixed heading/scripture results', async () => { + expect( + (await postResults({ items: [], heading: { ...heading, pericopeSetId: undefined } })).status + ).toBe(400); + expect((await postResults({ ...VALID_RESULTS_BODY, heading })).status).toBe(400); + }); it('returns 400 on invalid body (items must be array)', async () => { const res = await postResults({ items: 'not-an-array' }); expect(res.status).toBe(400); diff --git a/src/domains/ai-suggestions/ai-suggestions.internal.route.ts b/src/domains/ai-suggestions/ai-suggestions.internal.route.ts index f527781e..d110c521 100644 --- a/src/domains/ai-suggestions/ai-suggestions.internal.route.ts +++ b/src/domains/ai-suggestions/ai-suggestions.internal.route.ts @@ -50,7 +50,10 @@ server.post('/ai-suggestions/internal/results', requireServiceAuth, async (c) => return c.json({ message: 'Validation failed', errors: parsed.error.errors }, 400); } - const result = await aiSuggestionsService.saveAiSuggestions(parsed.data.items); + const result = await aiSuggestionsService.saveAiSuggestions( + parsed.data.items, + parsed.data.heading + ); if (result.ok) { return c.json({ success: true }, 200); diff --git a/src/domains/ai-suggestions/ai-suggestions.repository.ts b/src/domains/ai-suggestions/ai-suggestions.repository.ts index c46995de..455d9fd1 100644 --- a/src/domains/ai-suggestions/ai-suggestions.repository.ts +++ b/src/domains/ai-suggestions/ai-suggestions.repository.ts @@ -143,7 +143,8 @@ export async function logAiSuggestionUsage( ai_suggestion_usage_log.bibleTextId, ai_suggestion_usage_log.projectUnitId, ], - set: { wasUsed }, // Update if the user later accepts it + // Exposure and acceptance requests can arrive out of order. + set: { wasUsed: sql`${ai_suggestion_usage_log.wasUsed} OR EXCLUDED.was_used` }, }); return ok(undefined); diff --git a/src/domains/ai-suggestions/ai-suggestions.route.test.ts b/src/domains/ai-suggestions/ai-suggestions.route.test.ts index 34fed6b0..8c1cff85 100644 --- a/src/domains/ai-suggestions/ai-suggestions.route.test.ts +++ b/src/domains/ai-suggestions/ai-suggestions.route.test.ts @@ -55,6 +55,9 @@ vi.mock('./ai-suggestions.service', () => ({ getAiSuggestions: vi.fn(), queueNextVerses: vi.fn(), trackUsage: vi.fn(), + queuePericopes: vi.fn(), + getPericopeSuggestions: vi.fn(), + trackPericopeUsage: vi.fn(), })); vi.mock('./ai-suggestions.auth.middleware', async (importOriginal) => { @@ -117,6 +120,94 @@ describe('ai-suggestions routes', () => { vi.clearAllMocks(); }); + describe('pericope routes', () => { + const body = { + projectUnitId: 1, + bibleId: 2, + bookCode: 'GEN', + chapterNumber: 1, + pericopeNumbers: ['4a', '4b'], + }; + const requests = () => [ + () => + server.request('/ai-suggestions/queue-pericopes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + () => + server.request( + '/ai-suggestions/pericopes?projectUnitId=1&bibleId=2&bookCode=GEN&chapterNumber=1&pericopeNumbers=4a,4b' + ), + () => + server.request('/ai-suggestions/pericopes/usage', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + projectUnitId: 1, + bibleTextId: 101, + pericopeNumber: '4a', + wasUsed: true, + }), + }), + ]; + it('requires authentication for queue, retrieval and exposure', async () => { + vi.mocked(auth.api.getSession).mockResolvedValue(null); + for (const request of requests()) expect((await request()).status).toBe(401); + }); + it('requires project grants for queue, retrieval and exposure', async () => { + asAuthenticatedUser(false); + for (const request of requests()) expect((await request()).status).toBe(403); + expect(aiSuggestionsService.queuePericopes).not.toHaveBeenCalled(); + expect(aiSuggestionsService.getPericopeSuggestions).not.toHaveBeenCalled(); + expect(aiSuggestionsService.trackPericopeUsage).not.toHaveBeenCalled(); + }); + it('forwards the exact two alphanumeric group identities and separate usage', async () => { + asAuthenticatedUser(); + vi.mocked(aiSuggestionsService.queuePericopes).mockResolvedValue({ + ok: true, + data: { queued: true, thresholdMet: true }, + }); + vi.mocked(aiSuggestionsService.getPericopeSuggestions).mockResolvedValue({ + ok: true, + data: { data: [] }, + }); + vi.mocked(aiSuggestionsService.trackPericopeUsage).mockResolvedValue({ + ok: true, + data: undefined, + }); + for (const request of requests()) expect((await request()).status).toBe(200); + expect(aiSuggestionsService.queuePericopes).toHaveBeenCalledWith(body); + expect(aiSuggestionsService.getPericopeSuggestions).toHaveBeenCalledWith(body); + expect(aiSuggestionsService.trackPericopeUsage).toHaveBeenCalledWith( + expect.objectContaining({ id: 1 }), + { projectUnitId: 1, bibleTextId: 101, pericopeNumber: '4a', wasUsed: true } + ); + }); + it.each( + [[], ['1', '2', '3'], ['1', '1'], ['x'.repeat(101)], ['1,2']].map((pericopeNumbers) => ({ + pericopeNumbers, + })) + )('rejects invalid groups %j', async ({ pericopeNumbers }) => { + asAuthenticatedUser(); + const res = await server.request('/ai-suggestions/queue-pericopes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...body, pericopeNumbers }), + }); + expect(res.status).toBe(400); + expect(aiSuggestionsService.queuePericopes).not.toHaveBeenCalled(); + }); + it('returns queue submission errors to the caller', async () => { + asAuthenticatedUser(); + vi.mocked(aiSuggestionsService.queuePericopes).mockResolvedValue({ + ok: false, + error: { code: 'INTERNAL_ERROR', message: 'Queue unavailable' }, + }); + expect((await requests()[0]()).status).toBe(500); + }); + }); + describe('get /ai-suggestions', () => { it('returns 401 when the caller is not authenticated', async () => { (auth.api.getSession as any).mockResolvedValue(null); diff --git a/src/domains/ai-suggestions/ai-suggestions.route.ts b/src/domains/ai-suggestions/ai-suggestions.route.ts index 0b66dc41..48416dec 100644 --- a/src/domains/ai-suggestions/ai-suggestions.route.ts +++ b/src/domains/ai-suggestions/ai-suggestions.route.ts @@ -14,6 +14,10 @@ import * as aiSuggestionsService from './ai-suggestions.service'; import { aiSuggestionsListResponseSchema, getAiSuggestionsQuerySchema, + pericopeQuerySchema, + pericopeRequestSchema, + pericopeSuggestionsResponseSchema, + pericopeUsageRequestSchema, queueNextVersesRequestSchema, queueNextVersesResponseSchema, trackUsageRequestSchema, @@ -205,3 +209,88 @@ server.openapi(trackUsageRoute, async (c) => { return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); }); + +const pericopeErrors = { + 400: jsonContent(createMessageObjectSchema('Bad Request'), 'Invalid pericope or source context'), + 401: jsonContent(createMessageObjectSchema('Unauthorized'), 'Authentication required'), + 403: jsonContent(createMessageObjectSchema('Forbidden'), 'Permission denied'), + 404: jsonContent(createMessageObjectSchema('Not Found'), 'Project unit not found'), + 500: jsonContent(createMessageObjectSchema('Internal Server Error'), 'Internal server error'), +}; +const pericopePostMiddleware = [ + authenticateUser, + requirePermission(PERMISSIONS.PROJECT_VIEW), + requireProjectUnitAccess((c) => + c.req.raw + .clone() + .json() + .then((body: { projectUnitId?: unknown }) => Number(body.projectUnitId)) + .catch(() => 0) + ), +] as const; + +server.openapi( + createRoute({ + tags: ['AI Suggestions'], + method: 'post', + path: '/ai-suggestions/queue-pericopes', + middleware: [...pericopePostMiddleware], + request: { body: jsonContent(pericopeRequestSchema, 'Active and next pericope numbers') }, + responses: { + 200: jsonContent(queueNextVersesResponseSchema, 'Queue status'), + ...pericopeErrors, + }, + summary: 'Queue missing verse and optional heading suggestions for pericopes', + }), + async (c) => { + const result = await aiSuggestionsService.queuePericopes(c.req.valid('json')); + if (result.ok) return c.json(result.data, 200); + return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); + } +); + +server.openapi( + createRoute({ + tags: ['AI Suggestions'], + method: 'get', + path: '/ai-suggestions/pericopes', + middleware: [ + authenticateUser, + requirePermission(PERMISSIONS.PROJECT_VIEW), + requireProjectUnitAccess((c) => Number(c.req.query('projectUnitId'))), + ] as const, + request: { query: pericopeQuerySchema }, + responses: { + 200: jsonContent(pericopeSuggestionsResponseSchema, 'Separate heading suggestions'), + ...pericopeErrors, + }, + summary: 'Get optional pericope heading suggestions', + }), + async (c) => { + const result = await aiSuggestionsService.getPericopeSuggestions(c.req.valid('query')); + if (result.ok) return c.json(result.data, 200); + return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); + } +); + +server.openapi( + createRoute({ + tags: ['AI Suggestions'], + method: 'post', + path: '/ai-suggestions/pericopes/usage', + middleware: [...pericopePostMiddleware], + request: { body: jsonContent(pericopeUsageRequestSchema, 'Heading exposure or acceptance') }, + responses: { + 200: jsonContent(createMessageObjectSchema('Logged'), 'Logged'), + ...pericopeErrors, + }, + summary: 'Track pericope heading exposure and acceptance separately from verses', + }), + async (c) => { + const user = c.get('user'); + if (!user?.id) return c.json({ message: 'User not found' }, 401); + const result = await aiSuggestionsService.trackPericopeUsage(user, c.req.valid('json')); + if (result.ok) return c.json({ message: 'Logged' }, 200); + return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); + } +); diff --git a/src/domains/ai-suggestions/ai-suggestions.service.ts b/src/domains/ai-suggestions/ai-suggestions.service.ts index 0bcf135f..7a03b1cb 100644 --- a/src/domains/ai-suggestions/ai-suggestions.service.ts +++ b/src/domains/ai-suggestions/ai-suggestions.service.ts @@ -1,3 +1,4 @@ +import type { AiSuggestionTriggerJob } from '@/lib/queue'; import type { Result, User } from '@/lib/types'; import env from '@/env'; @@ -9,12 +10,21 @@ import type { AiSuggestionItem, AiSuggestionsListResponse, GetAiSuggestionsQuery, + PericopeRequest, + PericopeSuggestionItem, + PericopeSuggestionsResponse, + PericopeUsageRequest, QueueNextVersesResponse, SuggestionContextRequest, SuggestionContextResponse, TrackUsageRequest, } from './ai-suggestions.types'; +import { + logPericopeUsage, + resolvePericopes, + savePericopeSuggestion, +} from './ai-pericope.repository'; import { MAX_CONTEXT_VERSES_TOTAL } from './ai-suggestions.constants'; import { checkBibleTextsExist, @@ -86,7 +96,7 @@ export async function queueNextVerses( return ok({ queued: false, thresholdMet: isThresholdMet }); } - await queueNextVersesForAssignment( + const queued = await queueNextVersesForAssignment( projectUnitId, bibleId, bookCode.toUpperCase(), @@ -94,6 +104,7 @@ export async function queueNextVerses( currentVerse, env.AI_DEFAULT_LOOKAHEAD ); + if (!queued.ok) return queued; return ok({ queued: true, thresholdMet: true }); } catch (error) { @@ -215,7 +226,39 @@ export async function getSuggestionContext( ): Promise> { const { projectUnitId, bibleId, bookCode, chapterNumber, verseStart, verseEnd } = params; - return getSuggestionContextData( + let heading: SuggestionContextResponse['sectionHeading']; + let sourceIds: Set | undefined; + if (params.pericopeNumber !== undefined) { + const resolved = await resolvePericopes({ + projectUnitId, + bibleId, + bookCode, + chapterNumber, + pericopeNumbers: [params.pericopeNumber], + }); + if (!resolved.ok) return resolved; + const group = resolved.data.groups[0]; + if ( + (params.pericopeSetId !== undefined && + params.pericopeSetId !== resolved.data.pericopeSetId) || + verseStart !== group.verses[0].verseNumber || + verseEnd !== group.verses[group.verses.length - 1].verseNumber + ) { + return err(ErrorCode.INVALID_REFERENCE); + } + sourceIds = new Set(group.verses.map((verse) => verse.bibleTextId)); + heading = + group.sourceTitle && resolved.data.isAiEnabled && !group.verses[0].hasAuthoredHeading + ? { + pericopeNumber: group.pericopeNumber, + pericopeSetId: resolved.data.pericopeSetId, + bibleTextId: group.verses[0].bibleTextId, + sourceTitle: group.sourceTitle, + } + : null; + } + + const result = await getSuggestionContextData( projectUnitId, bibleId, bookCode, @@ -225,8 +268,119 @@ export async function getSuggestionContext( verseEnd, MAX_CONTEXT_VERSES_TOTAL ); + if (!result.ok) return result; + return ok({ + ...result.data, + ...(params.pericopeNumber !== undefined + ? { + sectionHeading: heading ?? null, + sourceVerses: result.data.sourceVerses.filter((verse) => sourceIds?.has(verse.id)), + } + : {}), + }); } -export async function saveAiSuggestions(items: AiSuggestionItem[]): Promise> { +export async function saveAiSuggestions( + items: AiSuggestionItem[], + heading?: PericopeSuggestionItem +): Promise> { + if (heading) { + if (items.length) return err(ErrorCode.VALIDATION_ERROR); + try { + return await savePericopeSuggestion(heading); + } catch (error) { + logger.error(error); + return err(ErrorCode.INTERNAL_ERROR); + } + } return upsertAiSuggestions(items); } + +export async function queuePericopes( + params: PericopeRequest +): Promise> { + try { + const resolved = await resolvePericopes(params); + if (!resolved.ok) return resolved; + const thresholdMet = await hasReachedAiActivationThreshold( + params.projectUnitId, + env.AI_ACTIVATION_THRESHOLD_VERSES + ); + if (!thresholdMet || !resolved.data.isAiEnabled) return ok({ queued: false, thresholdMet }); + const jobs: AiSuggestionTriggerJob[] = []; + const base = { + projectUnitId: params.projectUnitId, + bibleId: params.bibleId, + bookCode: params.bookCode.toUpperCase(), + chapterNumber: params.chapterNumber, + }; + for (const group of resolved.data.groups) { + for (const verse of group.verses) { + if (!verse.content?.trim() && !verse.hasSuggestion) { + jobs.push({ ...base, verseStart: verse.verseNumber, verseEnd: verse.verseNumber }); + } + } + if (group.sourceTitle && !group.verses[0].hasAuthoredHeading && !group.suggestion) { + jobs.push({ + ...base, + verseStart: group.verses[0].verseNumber, + verseEnd: group.verses[group.verses.length - 1].verseNumber, + pericopeNumber: group.pericopeNumber, + pericopeSetId: resolved.data.pericopeSetId, + }); + } + } + if (jobs.length === 0) return ok({ queued: false, thresholdMet }); + const boss = await getQueue(); + // Keep verse singleton keys identical to queue-next. Titles have their own identity. + for (const job of jobs) { + const verseKey = `${job.projectUnitId}:${job.bibleId}:${job.bookCode}:${job.chapterNumber}:${job.verseStart}`; + await boss.send(QUEUE_NAMES.AI_SUGGESTIONS, job, { + singletonKey: + job.pericopeNumber === undefined + ? verseKey + : `heading:${verseKey}:${job.verseEnd}:${job.pericopeSetId}:${job.pericopeNumber}`, + }); + } + return ok({ queued: true, thresholdMet }); + } catch (error) { + logger.error(error); + return err(ErrorCode.INTERNAL_ERROR); + } +} + +export async function getPericopeSuggestions( + params: PericopeRequest +): Promise> { + try { + const resolved = await resolvePericopes(params); + if (!resolved.ok) return resolved; + const data = resolved.data.groups.flatMap((group) => { + if (!group.sourceTitle || group.verses[0].hasAuthoredHeading || !group.suggestion) return []; + return [ + { + pericopeNumber: group.pericopeNumber, + bibleTextId: group.suggestion.bibleTextId, + suggestedText: group.suggestion.suggestedText, + modelInfo: group.suggestion.modelInfo, + }, + ]; + }); + return ok({ data }); + } catch (error) { + logger.error(error); + return err(ErrorCode.INTERNAL_ERROR); + } +} + +export async function trackPericopeUsage( + user: User, + data: PericopeUsageRequest +): Promise> { + try { + return await logPericopeUsage(user.id, data); + } catch (error) { + logger.error(error); + return err(ErrorCode.INTERNAL_ERROR); + } +} diff --git a/src/domains/ai-suggestions/ai-suggestions.types.ts b/src/domains/ai-suggestions/ai-suggestions.types.ts index c5506158..5116d7ad 100644 --- a/src/domains/ai-suggestions/ai-suggestions.types.ts +++ b/src/domains/ai-suggestions/ai-suggestions.types.ts @@ -1,5 +1,64 @@ import { z } from '@hono/zod-openapi'; +import { verseHeadingSchema } from '@/db/schema'; + +export const pericopeNumberSchema = z + .string() + .trim() + .min(1) + .max(100) + .regex(/^[^,]+$/); +const pericopeNumbersSchema = z + .array(pericopeNumberSchema) + .min(1) + .max(2) + .refine((values) => new Set(values).size === values.length, 'Duplicate pericope numbers'); +export const pericopeRequestSchema = z.object({ + projectUnitId: z.number().int().positive(), + bibleId: z.number().int().positive(), + bookCode: z + .string() + .trim() + .min(3) + .max(4) + .transform((value) => value.toUpperCase()), + chapterNumber: z.number().int().positive(), + pericopeNumbers: pericopeNumbersSchema, +}); +export type PericopeRequest = z.infer; +export const pericopeQuerySchema = pericopeRequestSchema.extend({ + projectUnitId: z.coerce.number().int().positive(), + bibleId: z.coerce.number().int().positive(), + chapterNumber: z.coerce.number().int().positive(), + pericopeNumbers: z + .string() + .max(201) + .transform((value) => value.split(',')) + .pipe(pericopeNumbersSchema), +}); +export const pericopeSuggestionResponseSchema = z.object({ + pericopeNumber: pericopeNumberSchema, + bibleTextId: z.number().int().positive(), + suggestedText: verseHeadingSchema.shape.text, + modelInfo: z.string().max(100).nullable().optional(), +}); +export const pericopeSuggestionsResponseSchema = z.object({ + data: z.array(pericopeSuggestionResponseSchema), +}); +export type PericopeSuggestionsResponse = z.infer; +export const pericopeUsageRequestSchema = z.object({ + projectUnitId: z.number().int().positive(), + bibleTextId: z.number().int().positive(), + pericopeNumber: pericopeNumberSchema, + wasUsed: z.boolean(), +}); +export type PericopeUsageRequest = z.infer; +export const pericopeSuggestionItemSchema = pericopeSuggestionResponseSchema.extend({ + projectUnitId: z.number().int().positive(), + pericopeSetId: z.number().int().positive(), +}); +export type PericopeSuggestionItem = z.infer; + export const getAiSuggestionsQuerySchema = z.object({ projectUnitId: z.coerce.number().int().positive(), bibleTextIds: z @@ -57,6 +116,8 @@ export const suggestionContextRequestSchema = z.object({ chapterNumber: z.number().int().positive(), verseStart: z.number().int().positive(), verseEnd: z.number().int().positive(), + pericopeNumber: pericopeNumberSchema.optional(), + pericopeSetId: z.number().int().positive().optional(), }); export type SuggestionContextRequest = z.infer; @@ -70,9 +131,15 @@ export const aiSuggestionItemSchema = z.object({ export type AiSuggestionItem = z.infer; -export const upsertAiSuggestionsRequestSchema = z.object({ - items: z.array(aiSuggestionItemSchema), -}); +export const upsertAiSuggestionsRequestSchema = z + .object({ + items: z.array(aiSuggestionItemSchema), + heading: pericopeSuggestionItemSchema.optional(), + }) + .refine( + (value) => !value.heading || value.items.length === 0, + 'A heading-only result cannot contain scripture items' + ); export type UpsertAiSuggestionsRequest = z.infer; @@ -92,4 +159,10 @@ export interface SuggestionContextResponse { targetLanguageName: string; contextVerses: ContextVerse[]; sourceVerses: SourceVerse[]; + sectionHeading?: { + pericopeNumber: string; + pericopeSetId: number; + bibleTextId: number; + sourceTitle: string; + } | null; } diff --git a/src/domains/pericopes/pericopes.service.ts b/src/domains/pericopes/pericopes.service.ts index d5853177..7c3e8614 100644 --- a/src/domains/pericopes/pericopes.service.ts +++ b/src/domains/pericopes/pericopes.service.ts @@ -6,6 +6,7 @@ import { err, ErrorCode, ok } from '@/lib/types'; import type { ChapterPericopesResponse, PericopeSet } from './pericopes.types'; import * as repo from './pericopes.repository'; +import { getPericopeGroupNumber } from './pericopes.types'; export async function listPericopeSets(): Promise> { try { @@ -40,8 +41,7 @@ export async function getChapterPericopes( // 5. Group rows by section + pericope_number in application layer if section is present (FCBH) const groupMap = new Map(); for (const row of rows) { - const groupKey = - row.section !== null ? `${row.section}_${row.pericopeNumber}` : row.pericopeNumber; + const groupKey = getPericopeGroupNumber(row); if (!groupMap.has(groupKey)) { groupMap.set(groupKey, { pericopeNumber: groupKey, diff --git a/src/domains/pericopes/pericopes.types.ts b/src/domains/pericopes/pericopes.types.ts index 8191cc85..907b8a1d 100644 --- a/src/domains/pericopes/pericopes.types.ts +++ b/src/domains/pericopes/pericopes.types.ts @@ -34,6 +34,14 @@ export const chapterPericopesResponseSchema = z export type PericopeGroup = z.infer; export type ChapterPericopesResponse = z.infer; +/** Public identity: FCBH numbers are unique within their section, not the book. */ +export function getPericopeGroupNumber(row: { + section: number | null; + pericopeNumber: string; +}): string { + return row.section !== null ? `${row.section}_${row.pericopeNumber}` : row.pericopeNumber; +} + // ─── Route params ───────────────────────────────────────────────────────────── export const chapterPericopesParamSchema = z.object({ diff --git a/src/lib/queue.ts b/src/lib/queue.ts index d5b22c75..8e484a48 100644 --- a/src/lib/queue.ts +++ b/src/lib/queue.ts @@ -33,6 +33,9 @@ export interface AiSuggestionTriggerJob { chapterNumber: number; verseStart: number; verseEnd: number; + /** Presence selects a heading-only job; scripture jobs omit these fields. */ + pericopeNumber?: string; + pericopeSetId?: number; } export async function initializeQueue(): Promise {