diff --git a/docs/cross-schema-types.md b/docs/cross-schema-types.md new file mode 100644 index 0000000..739c424 --- /dev/null +++ b/docs/cross-schema-types.md @@ -0,0 +1,56 @@ +# Cross-Schema Type Safety + +## Problem + +`fluent-api` (TypeScript / Drizzle) and `fluent-ai` (Python / SQLAlchemy + Alembic) share a single PostgreSQL database. Each service owns a distinct schema: + +- `fluent-api` owns `public`, `pgboss`, `drizzle` +- `fluent-ai` owns `ai` + +When `fluent-api` needs to read or write tables in the `ai` schema (e.g. `ai_suggestion_jobs`, `ai_suggestions`), Drizzle's query builder requires table definitions in the codebase. Previously these definitions were inlined in `src/db/schema.ts`, which created three problems: + +1. **Schema ownership was unclear** — AI tables sat alongside public tables in the same file. +2. **Drizzle Kit generated migrations for AI tables** — even though they are managed by Alembic in `fluent-ai`. +3. **Schema drift risk** — changes in `fluent-ai` could silently diverge from the Drizzle stubs in `fluent-api`. + +## Decision + +Extract externally-owned schema stubs into a dedicated `src/db/external/` directory. + +### What changed + +- `src/db/external/ai-schema.ts` — read-only Drizzle stubs for the `ai` schema. +- `src/db/schema.ts` — no longer contains AI table definitions or the unused `aiSuggestionJobStatusEnum`. +- Consumers (`ai-suggestions.repository.ts`, `clean-ai-jobs.ts`) import AI tables from `@/db/external/ai-schema`. + +### Why this approach + +We evaluated several options: + +| Approach | Verdict | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| **HTTP API boundary** | Overkill for same-DB data. Adds network hops, serialization, auth, and latency to solve a type-definition problem. | +| **Raw SQL + Zod** | Loses Drizzle joins and relational inference. More boilerplate, not less. | +| **Shared npm package** | Impossible — `fluent-ai` is Python, not Node.js. | +| **Views in `public`** | Breaks inserts/updates on `ai` tables that `fluent-api` legitimately writes to. | +| **Co-located stubs (`src/db/external/`)** | Keeps Drizzle type safety with minimal complexity. Makes ownership explicit. No new infrastructure. | + +### Trade-offs + +- **Staleness risk**: If `fluent-ai` changes a column, `fluent-api`'s stub becomes stale. Mitigation: regenerate from the database when schema changes occur. +- **Regeneration is manual today**: We do not yet automate introspection in CI. This is acceptable because the `ai` schema changes infrequently. + +## Regenerating stubs + +When `fluent-ai` migrates the `ai` schema, update the stubs: + +```bash +npx drizzle-kit introspect --tables='ai.*' --out=./src/db/external/ai-schema.ts +``` + +Review the diff, commit, and run `npm run typecheck` before merging. + +## Future considerations + +- If the ecosystem grows and services split onto separate databases, the natural next step is an HTTP API boundary (or async events via PgBoss) rather than cross-database queries. +- If schema drift becomes painful, automate introspection in CI: run it after `fluent-ai` migrations, diff against the checked-in file, and fail the build on unexpected changes. diff --git a/src/app.ts b/src/app.ts index aecadd8..a4e5599 100644 --- a/src/app.ts +++ b/src/app.ts @@ -20,6 +20,7 @@ import '@/domains/chapter-assignments/editor-state/user-chapter-assignment-edito import '@/domains/projects/users/project-users.route'; import '@/domains/users/projects/user-projects.route'; import '@/domains/chapter-assignments/presence/chapter-assignments-presence.route'; +import '@/domains/ai-suggestions/ai-suggestions.route'; configureOpenAPI(server); export default server; diff --git a/src/db/external/ai-schema.ts b/src/db/external/ai-schema.ts new file mode 100644 index 0000000..9a4bff1 --- /dev/null +++ b/src/db/external/ai-schema.ts @@ -0,0 +1,164 @@ +import { z } from '@hono/zod-openapi'; +/** + * EXTERNALLY OWNED — `ai` schema stubs for Drizzle type safety. + * + * These tables live in the `ai` schema and are owned by `fluent-ai` + * (Alembic-managed). `fluent-api` needs read/write access to them via + * `role_web_data` / `role_pgboss_user` grants, so we maintain lightweight + * Drizzle stubs here. + * + * DO NOT hand-edit column definitions. When `fluent-ai` changes the + * `ai` schema, regenerate this file from the database: + * + * npx drizzle-kit introspect --tables='ai.*' --out=./src/db/external/ai-schema.ts + * + * Then review the diff and commit. + */ +import { + boolean, + index, + integer, + pgSchema, + serial, + text, + timestamp, + uniqueIndex, + varchar, +} from 'drizzle-orm/pg-core'; +import { createSchemaFactory } from 'drizzle-zod'; + +import { bible_texts, bibles, project_units, users } from '@/db/schema'; + +export const aiSchema = pgSchema('ai'); + +export const ai_suggestion_jobs = aiSchema.table( + 'ai_suggestion_jobs', + { + id: serial('id').primaryKey(), + projectUnitId: integer('project_unit_id') + .notNull() + .references(() => project_units.id, { onDelete: 'cascade' }), + bibleId: integer('bible_id') + .notNull() + .references(() => bibles.id), + bookCode: varchar('book_code', { length: 50 }).notNull(), + chapterNumber: integer('chapter_number').notNull(), + verseStart: integer('verse_start').notNull(), + verseEnd: integer('verse_end').notNull(), + status: varchar('status', { length: 20 }).notNull().default('queued'), + retryCount: integer('retry_count').notNull().default(0), + errorMessage: text('error_message'), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => [ + index('idx_ai_jobs_project_unit').on(table.projectUnitId), + index('idx_ai_jobs_status').on(table.status), + uniqueIndex('uq_ai_jobs_range').on( + table.projectUnitId, + table.bibleId, + table.bookCode, + table.chapterNumber, + table.verseStart, + table.verseEnd + ), + ] +); + +export const ai_suggestions = aiSchema.table( + 'ai_suggestions', + { + id: serial('id').primaryKey(), + bibleTextId: integer('bible_text_id') + .notNull() + .references(() => bible_texts.id, { onDelete: 'cascade' }), + projectUnitId: integer('project_unit_id') + .notNull() + .references(() => project_units.id, { onDelete: 'cascade' }), + suggestedText: text('suggested_text').notNull(), + modelInfo: varchar('model_info', { length: 100 }), + createdAt: timestamp('created_at').defaultNow(), + }, + (table) => [ + index('idx_ai_suggestions_bible_text').on(table.bibleTextId), + uniqueIndex('uq_ai_suggestions_per_text_unit').on(table.bibleTextId, table.projectUnitId), + ] +); + +export const ai_suggestion_usage_log = aiSchema.table( + 'ai_suggestion_usage_log', + { + id: serial('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + bibleTextId: integer('bible_text_id') + .notNull() + .references(() => bible_texts.id, { onDelete: 'cascade' }), + projectUnitId: integer('project_unit_id') + .notNull() + .references(() => project_units.id, { onDelete: 'cascade' }), + wasUsed: boolean('was_used').notNull().default(false), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (table) => [ + index('idx_ai_usage_user').on(table.userId), + index('idx_ai_usage_project_unit').on(table.projectUnitId), + uniqueIndex('uq_ai_usage_user_text').on(table.userId, table.bibleTextId), + ] +); + +const { createInsertSchema, createSelectSchema } = createSchemaFactory({ + zodInstance: z, +}); + +export const selectAiSuggestionJobsSchema = createSelectSchema(ai_suggestion_jobs); +export const selectAiSuggestionsSchema = createSelectSchema(ai_suggestions); +export const selectAiSuggestionUsageLogSchema = createSelectSchema(ai_suggestion_usage_log); + +export const insertAiSuggestionJobsSchema = createInsertSchema(ai_suggestion_jobs, { + projectUnitId: (schema) => schema.int(), + bibleId: (schema) => schema.int(), + bookCode: (schema) => schema.min(1), + chapterNumber: (schema) => schema.int().min(1), + verseStart: (schema) => schema.int().min(1), + verseEnd: (schema) => schema.int().min(1), +}) + .required({ + projectUnitId: true, + bibleId: true, + bookCode: true, + chapterNumber: true, + verseStart: true, + verseEnd: true, + }) + .omit({ + id: true, + status: true, + retryCount: true, + errorMessage: true, + createdAt: true, + updatedAt: true, + }); + +export const insertAiSuggestionsSchema = createInsertSchema(ai_suggestions, { + bibleTextId: (schema) => schema.int(), + projectUnitId: (schema) => schema.int(), + suggestedText: (schema) => schema.min(1), +}) + .required({ bibleTextId: true, projectUnitId: true, suggestedText: true }) + .omit({ id: true, createdAt: true }); + +export const insertAiSuggestionUsageLogSchema = createInsertSchema(ai_suggestion_usage_log, { + userId: (schema) => schema.int(), + bibleTextId: (schema) => schema.int(), + projectUnitId: (schema) => schema.int(), +}) + .required({ userId: true, bibleTextId: true, projectUnitId: true, wasUsed: true }) + .omit({ id: true, createdAt: true }); + +export const patchAiSuggestionJobsSchema = insertAiSuggestionJobsSchema.partial(); +export const patchAiSuggestionsSchema = insertAiSuggestionsSchema.partial(); +export const patchAiSuggestionUsageLogSchema = insertAiSuggestionUsageLogSchema.partial(); diff --git a/src/db/migrations/0011_add_is_ai_enabled_to_chapter_assignments.sql b/src/db/migrations/0011_add_is_ai_enabled_to_chapter_assignments.sql new file mode 100644 index 0000000..5c8e332 --- /dev/null +++ b/src/db/migrations/0011_add_is_ai_enabled_to_chapter_assignments.sql @@ -0,0 +1 @@ +ALTER TABLE "chapter_assignments" ADD COLUMN "is_ai_enabled" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/src/db/migrations/meta/0011_snapshot.json b/src/db/migrations/meta/0011_snapshot.json new file mode 100644 index 0000000..3d5c7a8 --- /dev/null +++ b/src/db/migrations/meta/0011_snapshot.json @@ -0,0 +1,2289 @@ +{ + "id": "d0d20803-a8ad-41e1-98d8-eed5fcf618a5", + "prevId": "215ac9fb-e170-4975-9f11-77b471673625", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.active_chapter_editors": { + "name": "active_chapter_editors", + "schema": "", + "columns": { + "chapter_assignment_id": { + "name": "chapter_assignment_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_heartbeat": { + "name": "last_heartbeat", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_active_editors_chapter": { + "name": "idx_active_editors_chapter", + "columns": [ + { + "expression": "chapter_assignment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "active_chapter_editors_chapter_assignment_id_chapter_assignments_id_fk": { + "name": "active_chapter_editors_chapter_assignment_id_chapter_assignments_id_fk", + "tableFrom": "active_chapter_editors", + "tableTo": "chapter_assignments", + "columnsFrom": ["chapter_assignment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "active_chapter_editors_user_id_users_id_fk": { + "name": "active_chapter_editors_user_id_users_id_fk", + "tableFrom": "active_chapter_editors", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "active_chapter_editors_chapter_assignment_id_user_id_pk": { + "name": "active_chapter_editors_chapter_assignment_id_user_id_pk", + "columns": ["chapter_assignment_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_account": { + "name": "auth_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "auth_account_user_id_auth_user_id_fk": { + "name": "auth_account_user_id_auth_user_id_fk", + "tableFrom": "auth_account", + "tableTo": "auth_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_audit_log": { + "name": "auth_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_audit_log_user": { + "name": "idx_audit_log_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_event": { + "name": "idx_audit_log_event", + "columns": [ + { + "expression": "event", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_created": { + "name": "idx_audit_log_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_audit_log_user_id_auth_user_id_fk": { + "name": "auth_audit_log_user_id_auth_user_id_fk", + "tableFrom": "auth_audit_log", + "tableTo": "auth_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_session": { + "name": "auth_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "auth_session_user_id_auth_user_id_fk": { + "name": "auth_session_user_id_auth_user_id_fk", + "tableFrom": "auth_session", + "tableTo": "auth_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_session_token_unique": { + "name": "auth_session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_user": { + "name": "auth_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_user_email_unique": { + "name": "auth_user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_verification": { + "name": "auth_verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bible_books": { + "name": "bible_books", + "schema": "", + "columns": { + "bible_id": { + "name": "bible_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bible_books_bible_id_bibles_id_fk": { + "name": "bible_books_bible_id_bibles_id_fk", + "tableFrom": "bible_books", + "tableTo": "bibles", + "columnsFrom": ["bible_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "bible_books_book_id_books_id_fk": { + "name": "bible_books_book_id_books_id_fk", + "tableFrom": "bible_books", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bible_texts": { + "name": "bible_texts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "bible_id": { + "name": "bible_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chapter_number": { + "name": "chapter_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "verse_number": { + "name": "verse_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_bible_texts_bible_book_chapter": { + "name": "idx_bible_texts_bible_book_chapter", + "columns": [ + { + "expression": "bible_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chapter_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bible_texts_bible_book_chapter_verse": { + "name": "idx_bible_texts_bible_book_chapter_verse", + "columns": [ + { + "expression": "bible_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chapter_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "verse_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bible_texts_bible_id_bibles_id_fk": { + "name": "bible_texts_bible_id_bibles_id_fk", + "tableFrom": "bible_texts", + "tableTo": "bibles", + "columnsFrom": ["bible_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "bible_texts_book_id_books_id_fk": { + "name": "bible_texts_book_id_books_id_fk", + "tableFrom": "bible_texts", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bibles": { + "name": "bibles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "language_id": { + "name": "language_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "abbreviation": { + "name": "abbreviation", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bibles_language_id_languages_id_fk": { + "name": "bibles_language_id_languages_id_fk", + "tableFrom": "bibles", + "tableTo": "languages", + "columnsFrom": ["language_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "bibles_name_unique": { + "name": "bibles_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + }, + "bibles_abbreviation_unique": { + "name": "bibles_abbreviation_unique", + "nullsNotDistinct": false, + "columns": ["abbreviation"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.books": { + "name": "books", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "eng_display_name": { + "name": "eng_display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chapter_assignment_assigned_user_history": { + "name": "chapter_assignment_assigned_user_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "chapter_assignment_id": { + "name": "chapter_assignment_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "assigned_user_id": { + "name": "assigned_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "assignment_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "chapter_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_ca_user_history_assignment": { + "name": "idx_ca_user_history_assignment", + "columns": [ + { + "expression": "chapter_assignment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ca_user_history_user": { + "name": "idx_ca_user_history_user", + "columns": [ + { + "expression": "assigned_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chapter_assignment_assigned_user_history_chapter_assignment_id_chapter_assignments_id_fk": { + "name": "chapter_assignment_assigned_user_history_chapter_assignment_id_chapter_assignments_id_fk", + "tableFrom": "chapter_assignment_assigned_user_history", + "tableTo": "chapter_assignments", + "columnsFrom": ["chapter_assignment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chapter_assignment_assigned_user_history_assigned_user_id_users_id_fk": { + "name": "chapter_assignment_assigned_user_history_assigned_user_id_users_id_fk", + "tableFrom": "chapter_assignment_assigned_user_history", + "tableTo": "users", + "columnsFrom": ["assigned_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chapter_assignment_snapshots": { + "name": "chapter_assignment_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "chapter_assignment_id": { + "name": "chapter_assignment_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "chapter_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "assigned_user_id": { + "name": "assigned_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_ca_snapshots_assignment": { + "name": "idx_ca_snapshots_assignment", + "columns": [ + { + "expression": "chapter_assignment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ca_snapshots_user": { + "name": "idx_ca_snapshots_user", + "columns": [ + { + "expression": "assigned_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chapter_assignment_snapshots_chapter_assignment_id_chapter_assignments_id_fk": { + "name": "chapter_assignment_snapshots_chapter_assignment_id_chapter_assignments_id_fk", + "tableFrom": "chapter_assignment_snapshots", + "tableTo": "chapter_assignments", + "columnsFrom": ["chapter_assignment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chapter_assignment_snapshots_assigned_user_id_users_id_fk": { + "name": "chapter_assignment_snapshots_assigned_user_id_users_id_fk", + "tableFrom": "chapter_assignment_snapshots", + "tableTo": "users", + "columnsFrom": ["assigned_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chapter_assignment_status_history": { + "name": "chapter_assignment_status_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "chapter_assignment_id": { + "name": "chapter_assignment_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "chapter_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_ca_status_history_assignment": { + "name": "idx_ca_status_history_assignment", + "columns": [ + { + "expression": "chapter_assignment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chapter_assignment_status_history_chapter_assignment_id_chapter_assignments_id_fk": { + "name": "chapter_assignment_status_history_chapter_assignment_id_chapter_assignments_id_fk", + "tableFrom": "chapter_assignment_status_history", + "tableTo": "chapter_assignments", + "columnsFrom": ["chapter_assignment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chapter_assignments": { + "name": "chapter_assignments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "project_unit_id": { + "name": "project_unit_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bible_id": { + "name": "bible_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chapter_number": { + "name": "chapter_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "assigned_user_id": { + "name": "assigned_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "peer_checker_id": { + "name": "peer_checker_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "chapter_status": { + "name": "chapter_status", + "type": "chapter_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'not_started'" + }, + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.languages": { + "name": "languages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lang_name": { + "name": "lang_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "lang_name_localized": { + "name": "lang_name_localized", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "lang_code_iso_639_3": { + "name": "lang_code_iso_639_3", + "type": "varchar(3)", + "primaryKey": false, + "notNull": false + }, + "script_direction": { + "name": "script_direction", + "type": "script_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'ltr'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_name_unique": { + "name": "organizations_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "permissions_name_unique": { + "name": "permissions_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_unit_bible_books": { + "name": "project_unit_bible_books", + "schema": "", + "columns": { + "project_unit_id": { + "name": "project_unit_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bible_id": { + "name": "bible_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_unit_bible_books_project_unit_id_project_units_id_fk": { + "name": "project_unit_bible_books_project_unit_id_project_units_id_fk", + "tableFrom": "project_unit_bible_books", + "tableTo": "project_units", + "columnsFrom": ["project_unit_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "project_unit_bible_books_bible_id_bibles_id_fk": { + "name": "project_unit_bible_books_bible_id_bibles_id_fk", + "tableFrom": "project_unit_bible_books", + "tableTo": "bibles", + "columnsFrom": ["bible_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "project_unit_bible_books_book_id_books_id_fk": { + "name": "project_unit_bible_books_book_id_books_id_fk", + "tableFrom": "project_unit_bible_books", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_units": { + "name": "project_units", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'not_started'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_units_project_id_projects_id_fk": { + "name": "project_units_project_id_projects_id_fk", + "tableFrom": "project_units", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_users": { + "name": "project_users", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_project_users_project": { + "name": "idx_project_users_project", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_users_user": { + "name": "idx_project_users_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_users_project_id_projects_id_fk": { + "name": "project_users_project_id_projects_id_fk", + "tableFrom": "project_users", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "project_users_user_id_users_id_fk": { + "name": "project_users_user_id_users_id_fk", + "tableFrom": "project_users", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "project_users_project_id_user_id_pk": { + "name": "project_users_project_id_user_id_pk", + "columns": ["project_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "source_language": { + "name": "source_language", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_language": { + "name": "target_language", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "organization": { + "name": "organization", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "status": { + "name": "status", + "type": "project_assignment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'not_assigned'" + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_permissions": { + "name": "role_permissions", + "schema": "", + "columns": { + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_role_permissions_role": { + "name": "idx_role_permissions_role", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "role_permissions_role_id_roles_id_fk": { + "name": "role_permissions_role_id_roles_id_fk", + "tableFrom": "role_permissions", + "tableTo": "roles", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_permissions_permission_id_permissions_id_fk": { + "name": "role_permissions_permission_id_permissions_id_fk", + "tableFrom": "role_permissions", + "tableTo": "permissions", + "columnsFrom": ["permission_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_permissions_role_id_permission_id_pk": { + "name": "role_permissions_role_id_permission_id_pk", + "columns": ["role_id", "permission_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.translated_verses": { + "name": "translated_verses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "project_unit_id": { + "name": "project_unit_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "bible_text_id": { + "name": "bible_text_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "assigned_user_id": { + "name": "assigned_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_translated_verse_per_bible_text": { + "name": "uq_translated_verse_per_bible_text", + "columns": [ + { + "expression": "project_unit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "bible_text_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "translated_verses_project_unit_id_project_units_id_fk": { + "name": "translated_verses_project_unit_id_project_units_id_fk", + "tableFrom": "translated_verses", + "tableTo": "project_units", + "columnsFrom": ["project_unit_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "translated_verses_bible_text_id_bible_texts_id_fk": { + "name": "translated_verses_bible_text_id_bible_texts_id_fk", + "tableFrom": "translated_verses", + "tableTo": "bible_texts", + "columnsFrom": ["bible_text_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "translated_verses_assigned_user_id_users_id_fk": { + "name": "translated_verses_assigned_user_id_users_id_fk", + "tableFrom": "translated_verses", + "tableTo": "users", + "columnsFrom": ["assigned_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_chapter_assignment_editor_state": { + "name": "user_chapter_assignment_editor_state", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chapter_assignment_id": { + "name": "chapter_assignment_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uq_user_chapter_assignment_editor_state": { + "name": "uq_user_chapter_assignment_editor_state", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chapter_assignment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_chapter_assignment_editor_state_user_id_users_id_fk": { + "name": "user_chapter_assignment_editor_state_user_id_users_id_fk", + "tableFrom": "user_chapter_assignment_editor_state", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_chapter_assignment_editor_state_chapter_assignment_id_chapter_assignments_id_fk": { + "name": "user_chapter_assignment_editor_state_chapter_assignment_id_chapter_assignments_id_fk", + "tableFrom": "user_chapter_assignment_editor_state", + "tableTo": "chapter_assignments", + "columnsFrom": ["chapter_assignment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "auth_user_id": { + "name": "auth_user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "organization": { + "name": "organization", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "user_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invited'" + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "users_auth_user_id_auth_user_id_fk": { + "name": "users_auth_user_id_auth_user_id_fk", + "tableFrom": "users", + "tableTo": "auth_user", + "columnsFrom": ["auth_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "users_role_roles_id_fk": { + "name": "users_role_roles_id_fk", + "tableFrom": "users", + "tableTo": "roles", + "columnsFrom": ["role"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "users_organization_organizations_id_fk": { + "name": "users_organization_organizations_id_fk", + "tableFrom": "users", + "tableTo": "organizations", + "columnsFrom": ["organization"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "users_created_by_users_id_fk": { + "name": "users_created_by_users_id_fk", + "tableFrom": "users", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.assignment_role": { + "name": "assignment_role", + "schema": "public", + "values": ["drafter", "peer_checker"] + }, + "public.chapter_status": { + "name": "chapter_status", + "schema": "public", + "values": [ + "not_started", + "draft", + "peer_check", + "community_review", + "linguist_check", + "theological_check", + "consultant_check", + "complete" + ] + }, + "public.project_assignment_status": { + "name": "project_assignment_status", + "schema": "public", + "values": ["active", "not_assigned"] + }, + "public.project_status": { + "name": "project_status", + "schema": "public", + "values": ["not_started", "in_progress", "completed"] + }, + "public.script_direction": { + "name": "script_direction", + "schema": "public", + "values": ["ltr", "rtl"] + }, + "public.user_status": { + "name": "user_status", + "schema": "public", + "values": ["invited", "verified", "inactive"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 853784a..a2a2a42 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1778225352600, "tag": "0010_add_better_auth", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1779911602173, + "tag": "0011_add_is_ai_enabled_to_chapter_assignments", + "breakpoints": true } ] } diff --git a/src/db/schema.ts b/src/db/schema.ts index 12a525e..19a5fd0 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -40,7 +40,6 @@ export const chapterStatusEnum = pgEnum('chapter_status', [ 'complete', ]); export const assignmentRoleEnum = pgEnum('assignment_role', ['drafter', 'peer_checker']); - export const roles = pgTable('roles', { id: serial('id').primaryKey(), name: varchar('name', { length: 255 }).notNull().unique(), @@ -324,6 +323,7 @@ export const chapter_assignments = pgTable( assignedUserId: integer('assigned_user_id').references(() => users.id), peerCheckerId: integer('peer_checker_id').references(() => users.id), status: chapterStatusEnum('chapter_status').notNull().default('not_started'), + isAiEnabled: boolean('is_ai_enabled').default(false).notNull(), submittedTime: timestamp('submitted_time'), createdAt: timestamp('created_at').defaultNow(), updatedAt: timestamp('updated_at') diff --git a/src/domains/ai-suggestions/ai-suggestions.auth.middleware.ts b/src/domains/ai-suggestions/ai-suggestions.auth.middleware.ts new file mode 100644 index 0000000..6c3e0f1 --- /dev/null +++ b/src/domains/ai-suggestions/ai-suggestions.auth.middleware.ts @@ -0,0 +1,42 @@ +import { createMiddleware } from 'hono/factory'; +import * as HttpStatusCodes from 'stoker/http-status-codes'; + +import type { AppEnv } from '@/server/context.types'; + +import { AiSuggestionsPolicy } from './ai-suggestions.policy'; +import { findProjectUnitAuthContext } from './ai-suggestions.repository'; + +/** + * Middleware that verifies the authenticated user has access to a specific project unit + * according to the AI Suggestions authorization policy. + * + * @param getProjectUnitId A function to extract the projectUnitId from the request context + */ +export const requireProjectUnitAccess = ( + getProjectUnitId: ( + c: Parameters>[0]>[0] + ) => number | Promise +) => + createMiddleware(async (c, next) => { + const user = c.get('user'); + if (!user) { + return c.json({ message: 'Unauthorized' }, HttpStatusCodes.UNAUTHORIZED); + } + + const projectUnitId = await getProjectUnitId(c); + if (!projectUnitId || Number.isNaN(projectUnitId)) { + return c.json({ message: 'Invalid project unit ID' }, HttpStatusCodes.BAD_REQUEST); + } + + const context = await findProjectUnitAuthContext(projectUnitId); + if (!context) { + return c.json({ message: 'Project unit not found' }, HttpStatusCodes.NOT_FOUND); + } + + const isAuthorized = AiSuggestionsPolicy.canAccessProjectUnit(user, context); + if (!isAuthorized) { + return c.json({ message: 'Forbidden' }, HttpStatusCodes.FORBIDDEN); + } + + await next(); + }); diff --git a/src/domains/ai-suggestions/ai-suggestions.policy.ts b/src/domains/ai-suggestions/ai-suggestions.policy.ts new file mode 100644 index 0000000..a93cc6a --- /dev/null +++ b/src/domains/ai-suggestions/ai-suggestions.policy.ts @@ -0,0 +1,31 @@ +import { ROLES } from '@/lib/roles'; + +export interface PolicyUser { + id: number; + roleName: string; + organization: number; +} + +export interface ProjectUnitAuthContext { + organizationId: number; + memberUserIds: number[]; +} + +export class AiSuggestionsPolicy { + /** + * Determines if a user can access AI suggestions for a specific project unit. + * Project Managers can access if they belong to the same organization. + * Translators can access if they are assigned as a member of the project. + */ + static canAccessProjectUnit(user: PolicyUser, context: ProjectUnitAuthContext): boolean { + if (user.roleName === ROLES.PROJECT_MANAGER) { + return context.organizationId === user.organization; + } + + if (user.roleName === ROLES.TRANSLATOR) { + return context.memberUserIds.includes(user.id); + } + + return false; + } +} diff --git a/src/domains/ai-suggestions/ai-suggestions.repository.ts b/src/domains/ai-suggestions/ai-suggestions.repository.ts new file mode 100644 index 0000000..6f6cbf4 --- /dev/null +++ b/src/domains/ai-suggestions/ai-suggestions.repository.ts @@ -0,0 +1,266 @@ +import { and, asc, eq, gt, inArray, isNull, sql } from 'drizzle-orm'; + +import type { DbTransaction, Result } from '@/lib/types'; + +import { db } from '@/db'; +import { + ai_suggestion_jobs, + ai_suggestion_usage_log, + ai_suggestions, +} from '@/db/external/ai-schema'; +import { + bible_texts, + books, + chapter_assignments, + project_units, + project_users, + projects, + translated_verses, +} from '@/db/schema'; +import { logger } from '@/lib/logger'; +import { err, ErrorCode, ok } from '@/lib/types'; + +import type { ProjectUnitAuthContext } from './ai-suggestions.policy'; + +export async function findProjectUnitAuthContext( + projectUnitId: number +): Promise { + const records = await db + .select({ + organizationId: projects.organization, + memberUserId: project_users.userId, + }) + .from(project_units) + .innerJoin(projects, eq(project_units.projectId, projects.id)) + .leftJoin(project_users, eq(project_users.projectId, projects.id)) + .where(eq(project_units.id, projectUnitId)); + + if (records.length === 0) return null; + + return { + organizationId: records[0].organizationId, + memberUserIds: records.map((r) => r.memberUserId).filter((id): id is number => id !== null), + }; +} + +export async function checkBibleTextsExist(ids: number[]): Promise { + if (ids.length === 0) return true; + + const existingIds = await db + .select({ id: bible_texts.id }) + .from(bible_texts) + .where(inArray(bible_texts.id, ids)); + + return existingIds.length === ids.length; +} + +export async function getChapterAssignmentAiStatus( + projectUnitId: number, + bibleId: number, + bookCode: string, + chapterNumber: number +): Promise { + const normalizedBookCode = bookCode.toUpperCase(); + + const assignment = await db + .select({ isAiEnabled: chapter_assignments.isAiEnabled }) + .from(chapter_assignments) + .innerJoin(books, eq(chapter_assignments.bookId, books.id)) + .where( + and( + eq(chapter_assignments.projectUnitId, projectUnitId), + eq(chapter_assignments.bibleId, bibleId), + eq(books.code, normalizedBookCode), + eq(chapter_assignments.chapterNumber, chapterNumber) + ) + ) + .limit(1); + + if (!assignment[0]) return null; + return assignment[0].isAiEnabled; +} + +export async function getBookCodeById(bookId: number): Promise { + const book = await db + .select({ code: books.code }) + .from(books) + .where(eq(books.id, bookId)) + .limit(1); + + return book[0]?.code ?? null; +} + +export async function queueAiSuggestionJobs( + jobs: { + projectUnitId: number; + bibleId: number; + bookCode: string; + chapterNumber: number; + verseStart: number; + verseEnd: number; + }[], + tx?: DbTransaction +): Promise> { + const database = tx || db; + try { + if (jobs.length === 0) return ok(undefined); + + await database + .insert(ai_suggestion_jobs) + .values(jobs) + .onConflictDoNothing({ + target: [ + ai_suggestion_jobs.projectUnitId, + ai_suggestion_jobs.bibleId, + ai_suggestion_jobs.bookCode, + ai_suggestion_jobs.chapterNumber, + ai_suggestion_jobs.verseStart, + ai_suggestion_jobs.verseEnd, + ], + }); + + return ok(undefined); + } catch (error) { + logger.error({ + cause: error, + message: 'Failed to queue AI suggestion jobs', + context: { jobCount: jobs.length }, + }); + return err(ErrorCode.INTERNAL_ERROR); + } +} + +export async function getAiSuggestions( + projectUnitId: number, + bibleTextIds: number[], + tx?: DbTransaction +) { + const database = tx || db; + try { + if (bibleTextIds.length === 0) return ok([]); + + const results = await database + .select() + .from(ai_suggestions) + .where( + and( + eq(ai_suggestions.projectUnitId, projectUnitId), + inArray(ai_suggestions.bibleTextId, bibleTextIds) + ) + ); + + return ok(results); + } catch (error) { + logger.error({ + cause: error, + message: 'Failed to fetch AI suggestions', + context: { projectUnitId, textIdsCount: bibleTextIds.length }, + }); + return err(ErrorCode.INTERNAL_ERROR); + } +} + +export async function logAiSuggestionUsage( + userId: number, + bibleTextId: number, + projectUnitId: number, + wasUsed: boolean, + tx?: DbTransaction +): Promise> { + const database = tx || db; + try { + await database + .insert(ai_suggestion_usage_log) + .values({ + userId, + bibleTextId, + projectUnitId, + wasUsed, + }) + .onConflictDoUpdate({ + target: [ai_suggestion_usage_log.userId, ai_suggestion_usage_log.bibleTextId], + set: { wasUsed }, // Update if the user later accepts it + }); + + return ok(undefined); + } catch (error) { + logger.error({ + cause: error, + message: 'Failed to log AI suggestion usage', + context: { userId, bibleTextId }, + }); + return err(ErrorCode.INTERNAL_ERROR); + } +} + +export async function findNextUntranslatedVerses( + projectUnitId: number, + bibleId: number, + bookCode: string, + chapterNumber: number, + currentVerse: number, + lookahead: number +): Promise { + const nextVerses = await db + .select({ verseNumber: bible_texts.verseNumber }) + .from(bible_texts) + .innerJoin(books, eq(bible_texts.bookId, books.id)) + .leftJoin( + translated_verses, + and( + eq(translated_verses.bibleTextId, bible_texts.id), + eq(translated_verses.projectUnitId, projectUnitId) + ) + ) + .where( + and( + eq(bible_texts.bibleId, bibleId), + eq(books.code, bookCode), + eq(bible_texts.chapterNumber, chapterNumber), + gt(bible_texts.verseNumber, currentVerse), + isNull(translated_verses.projectUnitId) + ) + ) + .orderBy(asc(bible_texts.verseNumber)) + .limit(lookahead); + + return nextVerses.map((v) => v.verseNumber); +} + +export async function hasReachedAiActivationThreshold( + projectUnitId: number, + threshold: number +): Promise { + const projectInfo = await db + .select({ + sourceLanguage: projects.sourceLanguage, + targetLanguage: projects.targetLanguage, + organization: projects.organization, + }) + .from(project_units) + .innerJoin(projects, eq(project_units.projectId, projects.id)) + .where(eq(project_units.id, projectUnitId)) + .limit(1); + + if (!projectInfo[0]) return false; + + const { sourceLanguage, targetLanguage, organization } = projectInfo[0]; + + const result = await db + .select({ id: translated_verses.id }) + .from(translated_verses) + .innerJoin(project_units, eq(translated_verses.projectUnitId, project_units.id)) + .innerJoin(projects, eq(project_units.projectId, projects.id)) + .where( + and( + eq(projects.sourceLanguage, sourceLanguage), + eq(projects.targetLanguage, targetLanguage), + eq(projects.organization, organization), + sql`length(trim(${translated_verses.content})) > 0` + ) + ) + .limit(1) + .offset(threshold - 1); + + return result.length > 0; +} diff --git a/src/domains/ai-suggestions/ai-suggestions.route.ts b/src/domains/ai-suggestions/ai-suggestions.route.ts new file mode 100644 index 0000000..b5cd6b3 --- /dev/null +++ b/src/domains/ai-suggestions/ai-suggestions.route.ts @@ -0,0 +1,185 @@ +import { createRoute } from '@hono/zod-openapi'; +import * as HttpStatusCodes from 'stoker/http-status-codes'; +import * as HttpStatusPhrases from 'stoker/http-status-phrases'; +import { jsonContent } from 'stoker/openapi/helpers'; +import { createMessageObjectSchema } from 'stoker/openapi/schemas'; + +import { PERMISSIONS } from '@/lib/permissions'; +import { getHttpStatus } from '@/lib/types'; +import { authenticateUser, requirePermission } from '@/middlewares/role-auth'; +import { server } from '@/server/server'; + +import { requireProjectUnitAccess } from './ai-suggestions.auth.middleware'; +import * as aiSuggestionsService from './ai-suggestions.service'; +import { + aiSuggestionsListResponseSchema, + getAiSuggestionsQuerySchema, + queueNextVersesRequestSchema, + queueNextVersesResponseSchema, + trackUsageRequestSchema, +} from './ai-suggestions.types'; + +// ─── GET /ai-suggestions ────────────────────────────────────────────── + +const getAiSuggestionsRoute = createRoute({ + tags: ['AI Suggestions'], + method: 'get', + path: '/ai-suggestions', + middleware: [ + authenticateUser, + requirePermission(PERMISSIONS.PROJECT_VIEW), + requireProjectUnitAccess((c) => Number(c.req.query('projectUnitId'))), + ] as const, + request: { + query: getAiSuggestionsQuerySchema, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent(aiSuggestionsListResponseSchema, 'List of AI suggestions'), + [HttpStatusCodes.BAD_REQUEST]: jsonContent( + createMessageObjectSchema('Bad Request'), + 'Validation error' + ), + [HttpStatusCodes.UNAUTHORIZED]: jsonContent( + createMessageObjectSchema('Unauthorized'), + 'Authentication required' + ), + [HttpStatusCodes.FORBIDDEN]: jsonContent( + createMessageObjectSchema('Forbidden'), + 'Permission denied' + ), + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent( + createMessageObjectSchema(HttpStatusPhrases.INTERNAL_SERVER_ERROR), + 'Internal server error' + ), + }, + summary: 'Get pre-generated AI suggestions', + description: 'Retrieves AI suggestions for the specified bible text IDs.', +}); + +server.openapi(getAiSuggestionsRoute, async (c) => { + const query = c.req.valid('query'); + + const result = await aiSuggestionsService.getAiSuggestions(query); + if (result.ok) { + return c.json(result.data, HttpStatusCodes.OK); + } + + return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); +}); + +// ─── POST /ai-suggestions/queue-next ────────────────────────────────────────── + +const queueNextVersesRoute = createRoute({ + tags: ['AI Suggestions'], + method: 'post', + path: '/ai-suggestions/queue-next', + middleware: [ + authenticateUser, + requirePermission(PERMISSIONS.PROJECT_VIEW), + requireProjectUnitAccess(async (c) => { + const body = await c.req.json(); + return body.projectUnitId; + }), + ] as const, + request: { + body: jsonContent(queueNextVersesRequestSchema, 'Verses context'), + }, + responses: { + [HttpStatusCodes.OK]: jsonContent( + queueNextVersesResponseSchema, + 'Queueing status and threshold state' + ), + [HttpStatusCodes.BAD_REQUEST]: jsonContent( + createMessageObjectSchema('Bad Request'), + 'Validation error' + ), + [HttpStatusCodes.UNAUTHORIZED]: jsonContent( + createMessageObjectSchema('Unauthorized'), + 'Authentication required' + ), + [HttpStatusCodes.FORBIDDEN]: jsonContent( + createMessageObjectSchema('Forbidden'), + 'Permission denied' + ), + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent( + createMessageObjectSchema(HttpStatusPhrases.INTERNAL_SERVER_ERROR), + 'Internal server error' + ), + }, + summary: 'Queue pre-generation of AI suggestions', + description: + 'Triggers the queue to generate suggestions for the next few verses ahead of the drafter.', +}); + +server.openapi(queueNextVersesRoute, async (c) => { + const body = c.req.valid('json'); + + const result = await aiSuggestionsService.queueNextVerses( + body.projectUnitId, + body.bibleId, + body.bookCode, + body.chapterNumber, + body.currentVerse + ); + if (result.ok) { + return c.json(result.data, HttpStatusCodes.OK); + } + + return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); +}); + +// ─── POST /ai-suggestions/usage ────────────────────────────────────────────── + +const trackUsageRoute = createRoute({ + tags: ['AI Suggestions'], + method: 'post', + path: '/ai-suggestions/usage', + middleware: [ + authenticateUser, + requirePermission(PERMISSIONS.PROJECT_VIEW), + requireProjectUnitAccess(async (c) => { + const body = await c.req.json(); + return body.projectUnitId; + }), + ] as const, + request: { + body: jsonContent(trackUsageRequestSchema, 'Usage data'), + }, + responses: { + [HttpStatusCodes.OK]: jsonContent(createMessageObjectSchema('Successfully logged'), 'Logged'), + [HttpStatusCodes.BAD_REQUEST]: jsonContent( + createMessageObjectSchema('Bad Request'), + 'Validation error' + ), + [HttpStatusCodes.UNAUTHORIZED]: jsonContent( + createMessageObjectSchema('Unauthorized'), + 'Authentication required' + ), + [HttpStatusCodes.FORBIDDEN]: jsonContent( + createMessageObjectSchema('Forbidden'), + 'Permission denied' + ), + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent( + createMessageObjectSchema(HttpStatusPhrases.INTERNAL_SERVER_ERROR), + 'Internal server error' + ), + }, + summary: 'Track AI suggestion usage', + description: 'Logs whether an AI suggestion was viewed or used by the user.', +}); + +server.openapi(trackUsageRoute, async (c) => { + const body = c.req.valid('json'); + const user = c.get('user'); + + if (!user?.id) { + return c.json({ message: 'User not found' }, HttpStatusCodes.UNAUTHORIZED); + } + + const result = await aiSuggestionsService.trackUsage(user, body); + if (result.ok) { + return c.json({ message: 'Logged' }, HttpStatusCodes.OK); + } + + 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 new file mode 100644 index 0000000..40fb278 --- /dev/null +++ b/src/domains/ai-suggestions/ai-suggestions.service.ts @@ -0,0 +1,168 @@ +import type { Result, User } from '@/lib/types'; + +import env from '@/env'; +import { logger } from '@/lib/logger'; +import { err, ErrorCode, ok } from '@/lib/types'; + +import type { + AiSuggestionsListResponse, + GetAiSuggestionsQuery, + QueueNextVersesResponse, + TrackUsageRequest, +} from './ai-suggestions.types'; + +import { + checkBibleTextsExist, + findNextUntranslatedVerses, + getAiSuggestions as getAiSuggestionsRepo, + getBookCodeById, + getChapterAssignmentAiStatus, + hasReachedAiActivationThreshold, + logAiSuggestionUsage, + queueAiSuggestionJobs, +} from './ai-suggestions.repository'; + +export async function trackUsage(user: User, data: TrackUsageRequest): Promise> { + return logAiSuggestionUsage(user.id, data.bibleTextId, data.projectUnitId, data.wasUsed); +} + +export async function getAiSuggestions( + query: GetAiSuggestionsQuery +): Promise> { + const ids = query.bibleTextIds; + + if ( + ids.length === 0 || + ids.length > env.AI_MAX_REQUESTED_BIBLE_TEXT_IDS || + ids.length !== new Set(ids).size + ) { + return err(ErrorCode.VALIDATION_ERROR); + } + + const allExist = await checkBibleTextsExist(ids); + if (!allExist) { + return err(ErrorCode.VALIDATION_ERROR); + } + + const suggestionsResult = await getAiSuggestionsRepo(query.projectUnitId, ids); + + if (!suggestionsResult.ok) { + return suggestionsResult; + } + + const data = suggestionsResult.data.map((suggestion) => ({ + bibleTextId: suggestion.bibleTextId, + suggestedText: suggestion.suggestedText, + modelInfo: suggestion.modelInfo, + })); + + return ok({ data }); +} + +export async function queueNextVerses( + projectUnitId: number, + bibleId: number, + bookCode: string, + chapterNumber: number, + currentVerse: number +): Promise> { + try { + const [isThresholdMet, isAiEnabled] = await Promise.all([ + hasReachedAiActivationThreshold(projectUnitId, env.AI_ACTIVATION_THRESHOLD_VERSES), + getChapterAssignmentAiStatus(projectUnitId, bibleId, bookCode, chapterNumber), + ]); + + if (isAiEnabled === null) { + return err(ErrorCode.INVALID_REFERENCE); + } + + if (!isThresholdMet || !isAiEnabled) { + return ok({ queued: false, thresholdMet: isThresholdMet }); + } + + await queueNextVersesForAssignment( + projectUnitId, + bibleId, + bookCode.toUpperCase(), + chapterNumber, + currentVerse, + env.AI_DEFAULT_LOOKAHEAD + ); + + return ok({ queued: true, thresholdMet: true }); + } catch (error) { + logger.error(error); + return err(ErrorCode.INTERNAL_ERROR); + } +} + +async function queueNextVersesForAssignment( + projectUnitId: number, + bibleId: number, + bookCode: string, + chapterNumber: number, + currentVerse: number, + lookahead: number +): Promise> { + const nextVerses = await findNextUntranslatedVerses( + projectUnitId, + bibleId, + bookCode, + chapterNumber, + currentVerse, + lookahead + ); + + if (nextVerses.length === 0) return ok(undefined); + + const jobs = nextVerses.map((verseNumber) => ({ + projectUnitId, + bibleId, + bookCode, + chapterNumber, + verseStart: verseNumber, + verseEnd: verseNumber, + })); + + return queueAiSuggestionJobs(jobs); +} + +export async function handleChapterAssigned( + projectUnitId: number, + bibleId: number, + bookId: number, + chapterNumber: number +): Promise> { + try { + const bookCode = await getBookCodeById(bookId); + + if (!bookCode) { + return ok(undefined); + } + + const isThresholdMet = await hasReachedAiActivationThreshold( + projectUnitId, + env.AI_ACTIVATION_THRESHOLD_VERSES + ); + + if (isThresholdMet) { + await queueNextVersesForAssignment( + projectUnitId, + bibleId, + bookCode.toUpperCase(), + chapterNumber, + 0, + env.AI_INITIAL_QUEUE_COUNT + ); + } + + return ok(undefined); + } catch (error) { + logger.error({ + cause: error, + message: 'Failed to trigger initial AI queue on chapter assignment', + context: { projectUnitId, chapterNumber }, + }); + 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 new file mode 100644 index 0000000..4da7baa --- /dev/null +++ b/src/domains/ai-suggestions/ai-suggestions.types.ts @@ -0,0 +1,49 @@ +import { z } from '@hono/zod-openapi'; + +export const getAiSuggestionsQuerySchema = z.object({ + projectUnitId: z.coerce.number().int().positive(), + bibleTextIds: z + .string() + .regex(/^\d+(,\d+)*$/, 'Expected comma-separated numeric bible text IDs') + .describe('Comma-separated list of bible text IDs') + .transform((val) => val.split(',').map((id) => Number.parseInt(id.trim(), 10))), +}); + +export type GetAiSuggestionsQuery = z.infer; + +export const aiSuggestionResponseSchema = z.object({ + bibleTextId: z.number().int(), + suggestedText: z.string(), + modelInfo: z.string().nullable().optional(), +}); + +export const aiSuggestionsListResponseSchema = z.object({ + data: z.array(aiSuggestionResponseSchema), +}); + +export type AiSuggestionsListResponse = z.infer; + +export const queueNextVersesRequestSchema = z.object({ + projectUnitId: z.number().int().positive(), + bibleId: z.number().int().positive(), + bookCode: z.string(), + chapterNumber: z.number().int().positive(), + currentVerse: z.number().int().positive(), +}); + +export type QueueNextVersesRequest = z.infer; + +export const queueNextVersesResponseSchema = z.object({ + queued: z.boolean(), + thresholdMet: z.boolean(), +}); + +export type QueueNextVersesResponse = z.infer; + +export const trackUsageRequestSchema = z.object({ + bibleTextId: z.number().int().positive(), + projectUnitId: z.number().int().positive(), + wasUsed: z.boolean(), +}); + +export type TrackUsageRequest = z.infer; diff --git a/src/domains/chapter-assignments/chapter-assignments.policy.ts b/src/domains/chapter-assignments/chapter-assignments.policy.ts index 9ce015e..b2a69a8 100644 --- a/src/domains/chapter-assignments/chapter-assignments.policy.ts +++ b/src/domains/chapter-assignments/chapter-assignments.policy.ts @@ -184,6 +184,28 @@ export const ChapterAssignmentPolicy = { } }, + /** + * Can this user toggle AI for this assignment? + */ + toggleAi(user: AppPolicyUser, assignment: PolicyChapterAssignment): boolean { + if (user.organization !== assignment.organizationId) { + return false; + } + + if (user.roleName === ROLES.PROJECT_MANAGER) { + return true; + } + + if (user.roleName === ROLES.TRANSLATOR) { + // The assigned user can toggle AI only when they are the primary drafter + if (assignment.status === CHAPTER_ASSIGNMENT_STATUS.DRAFT) { + return assignment.assignedUserId === user.id; + } + } + + return false; + }, + /** * Is this user a direct participant in this assignment? */ diff --git a/src/domains/chapter-assignments/chapter-assignments.repository.ts b/src/domains/chapter-assignments/chapter-assignments.repository.ts index ae4eac4..002fd8c 100644 --- a/src/domains/chapter-assignments/chapter-assignments.repository.ts +++ b/src/domains/chapter-assignments/chapter-assignments.repository.ts @@ -69,6 +69,7 @@ export async function findByIdWithOrg(id: number): Promise { if (result.ok) return c.body(null, HttpStatusCodes.NO_CONTENT); return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); }); + +// ─── PATCH /chapter-assignments/:chapterAssignmentId/ai-status ──────────────── +const updateAiStatusRoute = createRoute({ + tags: ['Chapter Assignments'], + method: 'patch', + path: '/chapter-assignments/{chapterAssignmentId}/ai-status', + middleware: [ + authenticateUser, + requirePermission(PERMISSIONS.CONTENT_UPDATE), + requireChapterAssignmentAccess(CHAPTER_ASSIGNMENT_ACTIONS.TOGGLE_AI), + ] as const, + summary: 'Toggle AI Suggestion status for Chapter Assignment', + description: + 'Project Manager only. Enables or disables AI translation suggestions for a specific chapter assignment.', + request: { + params: chapterAssignmentIdParam, + body: jsonContentRequired(updateChapterAssignmentAiStatusSchema, 'AI Status update'), + }, + responses: { + [HttpStatusCodes.OK]: jsonContent(createMessageObjectSchema('Updated successfully'), 'Success'), + [HttpStatusCodes.UNAUTHORIZED]: jsonContent( + createMessageObjectSchema('Unauthorized'), + 'Authentication required' + ), + [HttpStatusCodes.FORBIDDEN]: jsonContent( + createMessageObjectSchema('Forbidden'), + 'Insufficient permissions' + ), + [HttpStatusCodes.NOT_FOUND]: jsonContent( + createMessageObjectSchema('Not Found'), + 'Chapter assignment not found' + ), + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent( + createMessageObjectSchema(HttpStatusPhrases.INTERNAL_SERVER_ERROR), + 'Internal server error' + ), + }, +}); + +server.openapi(updateAiStatusRoute, async (c) => { + const { chapterAssignmentId } = c.req.valid('param'); + const { isAiEnabled } = c.req.valid('json'); + + const result = await chapterAssignmentService.toggleChapterAssignmentAiStatus( + chapterAssignmentId, + isAiEnabled + ); + if (result.ok) return c.json({ message: 'Updated successfully' }, HttpStatusCodes.OK); + return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); +}); diff --git a/src/domains/chapter-assignments/chapter-assignments.service.ts b/src/domains/chapter-assignments/chapter-assignments.service.ts index 2d3e9af..410976a 100644 --- a/src/domains/chapter-assignments/chapter-assignments.service.ts +++ b/src/domains/chapter-assignments/chapter-assignments.service.ts @@ -1,6 +1,7 @@ import type { DbTransaction, Result } from '@/lib/types'; import { db } from '@/db'; +import * as aiSuggestionsService from '@/domains/ai-suggestions/ai-suggestions.service'; import { logger } from '@/lib/logger'; import { err, ErrorCode, ok } from '@/lib/types'; @@ -79,6 +80,13 @@ export async function createChapterAssignment(data: CreateChapterAssignmentReque 'drafter', CHAPTER_ASSIGNMENT_STATUS.NOT_STARTED ); + // Fire and forget auto-queueing for drafting assignment + aiSuggestionsService.handleChapterAssigned( + assignment.projectUnitId, + assignment.bibleId, + assignment.bookId, + assignment.chapterNumber + ); } if (assignment.peerCheckerId) { await repo.insertUserAssignmentHistory( @@ -279,6 +287,13 @@ async function recordUserAssignmentChanges( 'drafter', updated.status as ChapterAssignmentStatus ); + // Fire and forget auto-queueing for drafting assignment + aiSuggestionsService.handleChapterAssigned( + updated.projectUnitId, + updated.bibleId, + updated.bookId, + updated.chapterNumber + ); } if ( @@ -295,3 +310,43 @@ async function recordUserAssignmentChanges( ); } } + +export async function toggleChapterAssignmentAiStatus( + assignmentId: number, + isAiEnabled: boolean +): Promise> { + try { + const assignment = await repo.findById(assignmentId); + + if (!assignment) { + return err(ErrorCode.CHAPTER_ASSIGNMENT_NOT_FOUND); + } + + if (assignment.isAiEnabled === isAiEnabled) { + return ok(undefined); + } + + await db.transaction(async (tx) => { + await repo.update(assignmentId, { isAiEnabled }, tx); + }); + + if (isAiEnabled) { + // Fire and forget initial queue + aiSuggestionsService.handleChapterAssigned( + assignment.projectUnitId, + assignment.bibleId, + assignment.bookId, + assignment.chapterNumber + ); + } + + return ok(undefined); + } catch (error) { + logger.error({ + cause: error, + message: 'Failed to toggle AI status for chapter assignment', + context: { assignmentId, isAiEnabled }, + }); + return err(ErrorCode.INTERNAL_ERROR); + } +} diff --git a/src/domains/chapter-assignments/chapter-assignments.types.ts b/src/domains/chapter-assignments/chapter-assignments.types.ts index 767b785..da2d7ae 100644 --- a/src/domains/chapter-assignments/chapter-assignments.types.ts +++ b/src/domains/chapter-assignments/chapter-assignments.types.ts @@ -10,6 +10,7 @@ export const CHAPTER_ASSIGNMENT_ACTIONS = { SUBMIT: 'submit', DELETE: 'delete', IS_PARTICIPANT: 'isParticipant', + TOGGLE_AI: 'toggleAi', } as const; export type ChapterAssignmentAction = @@ -60,6 +61,7 @@ export interface ChapterAssignmentProgressInfo { submittedTime: Date | null; createdAt: Date | null; updatedAt: Date | null; + isAiEnabled: boolean; } // ─── Service input types ────────────────────────────────────────────────────── @@ -78,8 +80,13 @@ export interface UpdateChapterAssignmentRequestData { peerCheckerId?: number | null; status?: ChapterAssignmentStatus; submittedTime?: Date; + isAiEnabled?: boolean; } +export const updateChapterAssignmentAiStatusSchema = z.object({ + isAiEnabled: z.boolean(), +}); + // ─── API response schema ────────────────────────────────────────────────────── export const chapterAssignmentResponseSchema = z.object({ diff --git a/src/domains/projects/chapter-assignments/project-chapter-assignments.repository.ts b/src/domains/projects/chapter-assignments/project-chapter-assignments.repository.ts index 56e9ef5..ae27a65 100644 --- a/src/domains/projects/chapter-assignments/project-chapter-assignments.repository.ts +++ b/src/domains/projects/chapter-assignments/project-chapter-assignments.repository.ts @@ -21,6 +21,7 @@ export async function getByProject(projectId: number): Promise; diff --git a/src/scripts/clean-ai-jobs.ts b/src/scripts/clean-ai-jobs.ts new file mode 100644 index 0000000..a05a49b --- /dev/null +++ b/src/scripts/clean-ai-jobs.ts @@ -0,0 +1,35 @@ +import { and, inArray, lt } from 'drizzle-orm'; +import process from 'node:process'; + +import { db } from '@/db'; +import { ai_suggestion_jobs } from '@/db/external/ai-schema'; +import { logger } from '@/lib/logger'; + +async function main() { + logger.info('Starting AI Queue Cleanup Job...'); + + try { + // Delete jobs that are 'completed' or 'failed' AND were created more than 7 days ago + const sevenDaysAgo = new Date(); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + + const deleted = await db + .delete(ai_suggestion_jobs) + .where( + and( + inArray(ai_suggestion_jobs.status, ['completed', 'failed']), + lt(ai_suggestion_jobs.createdAt, sevenDaysAgo) + ) + ) + .returning({ id: ai_suggestion_jobs.id }); + + logger.info(`Successfully cleaned up ${deleted.length} old AI jobs.`); + } catch (error) { + logger.error({ cause: error, message: 'Failed to run AI Queue Cleanup' }); + process.exit(1); + } + + process.exit(0); +} + +void main();