diff --git a/package.json b/package.json index 9bd49384..7a523ea5 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "db:seed:roles": "npx tsx src/db/seeds/roles.ts", "db:seed:org": "npx tsx src/db/seeds/organizations.ts", "db:seed:dev-users": "npx tsx src/db/seeds/dev-users.ts", + "db:migrate-rbac": "npx tsx src/db/scripts/migrate-to-user-central-rbac.ts", "db:seed:languages": "npx tsx src/db/seeds/languages.ts", "db:seed:books": "npx tsx src/db/seeds/books.ts", "db:seed:bibles": "npx tsx src/db/seeds/bibles.ts", diff --git a/src/db/migrations/0015_create_user_role_grants.sql b/src/db/migrations/0015_create_user_role_grants.sql new file mode 100644 index 00000000..03ba2ce3 --- /dev/null +++ b/src/db/migrations/0015_create_user_role_grants.sql @@ -0,0 +1,20 @@ +CREATE TABLE "user_roles" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "org_id" integer, + "project_id" integer, + "role_id" integer NOT NULL, + "created_by" integer, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() +); +--> statement-breakpoint +ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_org_id_organizations_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "uq_user_role_grant" ON "user_roles" USING btree ("user_id","org_id","project_id","role_id");--> statement-breakpoint +CREATE INDEX "idx_user_roles_user" ON "user_roles" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "idx_user_roles_org" ON "user_roles" USING btree ("org_id");--> statement-breakpoint +CREATE INDEX "idx_user_roles_project" ON "user_roles" USING btree ("project_id"); \ No newline at end of file diff --git a/src/db/migrations/0016_drop_legacy_rbac_columns.sql b/src/db/migrations/0016_drop_legacy_rbac_columns.sql new file mode 100644 index 00000000..ba4d6d04 --- /dev/null +++ b/src/db/migrations/0016_drop_legacy_rbac_columns.sql @@ -0,0 +1,9 @@ +ALTER TABLE "project_users" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint +DROP TABLE "project_users" CASCADE;--> statement-breakpoint +ALTER TABLE "users" DROP CONSTRAINT "users_role_roles_id_fk"; +--> statement-breakpoint +ALTER TABLE "users" DROP CONSTRAINT "users_organization_organizations_id_fk"; +--> statement-breakpoint +DROP INDEX "idx_role_permissions_role";--> statement-breakpoint +ALTER TABLE "users" DROP COLUMN "role";--> statement-breakpoint +ALTER TABLE "users" DROP COLUMN "organization"; \ No newline at end of file diff --git a/src/db/migrations/0017_add_user_roles_unique_index.sql b/src/db/migrations/0017_add_user_roles_unique_index.sql new file mode 100644 index 00000000..dab80882 --- /dev/null +++ b/src/db/migrations/0017_add_user_roles_unique_index.sql @@ -0,0 +1,2 @@ +DROP INDEX "uq_user_role_grant";--> statement-breakpoint +CREATE UNIQUE INDEX "uq_user_role_grant" ON "user_roles" USING btree ("user_id",COALESCE("org_id", -1),COALESCE("project_id", -1),"role_id"); \ No newline at end of file diff --git a/src/db/migrations/0018_added_user_session.sql b/src/db/migrations/0018_added_user_session.sql new file mode 100644 index 00000000..112c7b03 --- /dev/null +++ b/src/db/migrations/0018_added_user_session.sql @@ -0,0 +1,4 @@ +ALTER TABLE "auth_session" ADD COLUMN "active_org_id" integer;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "last_active_org_id" integer;--> statement-breakpoint +ALTER TABLE "auth_session" ADD CONSTRAINT "auth_session_active_org_id_organizations_id_fk" FOREIGN KEY ("active_org_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "users" ADD CONSTRAINT "users_last_active_org_id_organizations_id_fk" FOREIGN KEY ("last_active_org_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/src/db/migrations/meta/0015_snapshot.json b/src/db/migrations/meta/0015_snapshot.json new file mode 100644 index 00000000..cf441106 --- /dev/null +++ b/src/db/migrations/meta/0015_snapshot.json @@ -0,0 +1,2374 @@ +{ + "id": "08f874fa-f65d-4a33-bd55-dade0f0ef5c8", + "prevId": "3eff52af-e889-4f92-b02c-fbd7a5dc8d2b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.active_chapter_editors": { + "name": "active_chapter_editors", + "schema": "", + "columns": { + "chapter_assignment_id": { + "name": "chapter_assignment_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_heartbeat": { + "name": "last_heartbeat", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_active_editors_chapter": { + "name": "idx_active_editors_chapter", + "columns": [ + { + "expression": "chapter_assignment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "active_chapter_editors_chapter_assignment_id_chapter_assignments_id_fk": { + "name": "active_chapter_editors_chapter_assignment_id_chapter_assignments_id_fk", + "tableFrom": "active_chapter_editors", + "tableTo": "chapter_assignments", + "columnsFrom": ["chapter_assignment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "active_chapter_editors_user_id_users_id_fk": { + "name": "active_chapter_editors_user_id_users_id_fk", + "tableFrom": "active_chapter_editors", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "active_chapter_editors_chapter_assignment_id_user_id_pk": { + "name": "active_chapter_editors_chapter_assignment_id_user_id_pk", + "columns": ["chapter_assignment_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_account": { + "name": "auth_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "auth_account_user_id_auth_user_id_fk": { + "name": "auth_account_user_id_auth_user_id_fk", + "tableFrom": "auth_account", + "tableTo": "auth_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_audit_log": { + "name": "auth_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_audit_log_user": { + "name": "idx_audit_log_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_event": { + "name": "idx_audit_log_event", + "columns": [ + { + "expression": "event", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_created": { + "name": "idx_audit_log_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_audit_log_user_id_auth_user_id_fk": { + "name": "auth_audit_log_user_id_auth_user_id_fk", + "tableFrom": "auth_audit_log", + "tableTo": "auth_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_session": { + "name": "auth_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_mobile": { + "name": "is_mobile", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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": {}, + "foreignKeys": { + "bible_books_bible_id_bibles_id_fk": { + "name": "bible_books_bible_id_bibles_id_fk", + "tableFrom": "bible_books", + "tableTo": "bibles", + "columnsFrom": ["bible_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "bible_books_book_id_books_id_fk": { + "name": "bible_books_book_id_books_id_fk", + "tableFrom": "bible_books", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bible_texts": { + "name": "bible_texts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "bible_id": { + "name": "bible_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chapter_number": { + "name": "chapter_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "verse_number": { + "name": "verse_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_bible_texts_bible_book_chapter": { + "name": "idx_bible_texts_bible_book_chapter", + "columns": [ + { + "expression": "bible_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chapter_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bible_texts_bible_book_chapter_verse": { + "name": "idx_bible_texts_bible_book_chapter_verse", + "columns": [ + { + "expression": "bible_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chapter_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "verse_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bible_texts_bible_id_bibles_id_fk": { + "name": "bible_texts_bible_id_bibles_id_fk", + "tableFrom": "bible_texts", + "tableTo": "bibles", + "columnsFrom": ["bible_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "bible_texts_book_id_books_id_fk": { + "name": "bible_texts_book_id_books_id_fk", + "tableFrom": "bible_texts", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bibles": { + "name": "bibles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "language_id": { + "name": "language_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "abbreviation": { + "name": "abbreviation", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bibles_language_id_languages_id_fk": { + "name": "bibles_language_id_languages_id_fk", + "tableFrom": "bibles", + "tableTo": "languages", + "columnsFrom": ["language_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "bibles_name_unique": { + "name": "bibles_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + }, + "bibles_abbreviation_unique": { + "name": "bibles_abbreviation_unique", + "nullsNotDistinct": false, + "columns": ["abbreviation"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.books": { + "name": "books", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "eng_display_name": { + "name": "eng_display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chapter_assignment_assigned_user_history": { + "name": "chapter_assignment_assigned_user_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "chapter_assignment_id": { + "name": "chapter_assignment_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "assigned_user_id": { + "name": "assigned_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "assignment_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "chapter_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_ca_user_history_assignment": { + "name": "idx_ca_user_history_assignment", + "columns": [ + { + "expression": "chapter_assignment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ca_user_history_user": { + "name": "idx_ca_user_history_user", + "columns": [ + { + "expression": "assigned_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chapter_assignment_assigned_user_history_chapter_assignment_id_chapter_assignments_id_fk": { + "name": "chapter_assignment_assigned_user_history_chapter_assignment_id_chapter_assignments_id_fk", + "tableFrom": "chapter_assignment_assigned_user_history", + "tableTo": "chapter_assignments", + "columnsFrom": ["chapter_assignment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chapter_assignment_assigned_user_history_assigned_user_id_users_id_fk": { + "name": "chapter_assignment_assigned_user_history_assigned_user_id_users_id_fk", + "tableFrom": "chapter_assignment_assigned_user_history", + "tableTo": "users", + "columnsFrom": ["assigned_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chapter_assignment_snapshots": { + "name": "chapter_assignment_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "chapter_assignment_id": { + "name": "chapter_assignment_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "chapter_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "assigned_user_id": { + "name": "assigned_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_ca_snapshots_assignment": { + "name": "idx_ca_snapshots_assignment", + "columns": [ + { + "expression": "chapter_assignment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ca_snapshots_user": { + "name": "idx_ca_snapshots_user", + "columns": [ + { + "expression": "assigned_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chapter_assignment_snapshots_chapter_assignment_id_chapter_assignments_id_fk": { + "name": "chapter_assignment_snapshots_chapter_assignment_id_chapter_assignments_id_fk", + "tableFrom": "chapter_assignment_snapshots", + "tableTo": "chapter_assignments", + "columnsFrom": ["chapter_assignment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chapter_assignment_snapshots_assigned_user_id_users_id_fk": { + "name": "chapter_assignment_snapshots_assigned_user_id_users_id_fk", + "tableFrom": "chapter_assignment_snapshots", + "tableTo": "users", + "columnsFrom": ["assigned_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chapter_assignment_status_history": { + "name": "chapter_assignment_status_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "chapter_assignment_id": { + "name": "chapter_assignment_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "chapter_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_ca_status_history_assignment": { + "name": "idx_ca_status_history_assignment", + "columns": [ + { + "expression": "chapter_assignment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chapter_assignment_status_history_chapter_assignment_id_chapter_assignments_id_fk": { + "name": "chapter_assignment_status_history_chapter_assignment_id_chapter_assignments_id_fk", + "tableFrom": "chapter_assignment_status_history", + "tableTo": "chapter_assignments", + "columnsFrom": ["chapter_assignment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chapter_assignments": { + "name": "chapter_assignments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "project_unit_id": { + "name": "project_unit_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bible_id": { + "name": "bible_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chapter_number": { + "name": "chapter_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "assigned_user_id": { + "name": "assigned_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "peer_checker_id": { + "name": "peer_checker_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "chapter_status": { + "name": "chapter_status", + "type": "chapter_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'not_started'" + }, + "submitted_time": { + "name": "submitted_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uq_chapter_assignment_per_chapter": { + "name": "uq_chapter_assignment_per_chapter", + "columns": [ + { + "expression": "project_unit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "bible_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chapter_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chapter_assignments_assigned_user": { + "name": "idx_chapter_assignments_assigned_user", + "columns": [ + { + "expression": "assigned_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chapter_assignments_peer_checker_status": { + "name": "idx_chapter_assignments_peer_checker_status", + "columns": [ + { + "expression": "peer_checker_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chapter_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chapter_assignments_project_unit": { + "name": "idx_chapter_assignments_project_unit", + "columns": [ + { + "expression": "project_unit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chapter_assignments_project_unit_id_project_units_id_fk": { + "name": "chapter_assignments_project_unit_id_project_units_id_fk", + "tableFrom": "chapter_assignments", + "tableTo": "project_units", + "columnsFrom": ["project_unit_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "chapter_assignments_bible_id_bibles_id_fk": { + "name": "chapter_assignments_bible_id_bibles_id_fk", + "tableFrom": "chapter_assignments", + "tableTo": "bibles", + "columnsFrom": ["bible_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "chapter_assignments_book_id_books_id_fk": { + "name": "chapter_assignments_book_id_books_id_fk", + "tableFrom": "chapter_assignments", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "chapter_assignments_assigned_user_id_users_id_fk": { + "name": "chapter_assignments_assigned_user_id_users_id_fk", + "tableFrom": "chapter_assignments", + "tableTo": "users", + "columnsFrom": ["assigned_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "chapter_assignments_peer_checker_id_users_id_fk": { + "name": "chapter_assignments_peer_checker_id_users_id_fk", + "tableFrom": "chapter_assignments", + "tableTo": "users", + "columnsFrom": ["peer_checker_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.languages": { + "name": "languages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lang_name": { + "name": "lang_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "lang_name_localized": { + "name": "lang_name_localized", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "lang_code_iso_639_3": { + "name": "lang_code_iso_639_3", + "type": "varchar(3)", + "primaryKey": false, + "notNull": false + }, + "script_direction": { + "name": "script_direction", + "type": "script_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'ltr'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_name_unique": { + "name": "organizations_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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.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": {}, + "foreignKeys": { + "role_permissions_role_id_roles_id_fk": { + "name": "role_permissions_role_id_roles_id_fk", + "tableFrom": "role_permissions", + "tableTo": "roles", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_permissions_permission_id_permissions_id_fk": { + "name": "role_permissions_permission_id_permissions_id_fk", + "tableFrom": "role_permissions", + "tableTo": "permissions", + "columnsFrom": ["permission_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_permissions_role_id_permission_id_pk": { + "name": "role_permissions_role_id_permission_id_pk", + "columns": ["role_id", "permission_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.translated_verses": { + "name": "translated_verses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "project_unit_id": { + "name": "project_unit_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "bible_text_id": { + "name": "bible_text_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "assigned_user_id": { + "name": "assigned_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_translated_verse_per_bible_text": { + "name": "uq_translated_verse_per_bible_text", + "columns": [ + { + "expression": "project_unit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "bible_text_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "translated_verses_project_unit_id_project_units_id_fk": { + "name": "translated_verses_project_unit_id_project_units_id_fk", + "tableFrom": "translated_verses", + "tableTo": "project_units", + "columnsFrom": ["project_unit_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "translated_verses_bible_text_id_bible_texts_id_fk": { + "name": "translated_verses_bible_text_id_bible_texts_id_fk", + "tableFrom": "translated_verses", + "tableTo": "bible_texts", + "columnsFrom": ["bible_text_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "translated_verses_assigned_user_id_users_id_fk": { + "name": "translated_verses_assigned_user_id_users_id_fk", + "tableFrom": "translated_verses", + "tableTo": "users", + "columnsFrom": ["assigned_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_chapter_assignment_editor_state": { + "name": "user_chapter_assignment_editor_state", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chapter_assignment_id": { + "name": "chapter_assignment_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uq_user_chapter_assignment_editor_state": { + "name": "uq_user_chapter_assignment_editor_state", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chapter_assignment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_chapter_assignment_editor_state_user_id_users_id_fk": { + "name": "user_chapter_assignment_editor_state_user_id_users_id_fk", + "tableFrom": "user_chapter_assignment_editor_state", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_chapter_assignment_editor_state_chapter_assignment_id_chapter_assignments_id_fk": { + "name": "user_chapter_assignment_editor_state_chapter_assignment_id_chapter_assignments_id_fk", + "tableFrom": "user_chapter_assignment_editor_state", + "tableTo": "chapter_assignments", + "columnsFrom": ["chapter_assignment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_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.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 + } + }, + "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/0018_snapshot.json b/src/db/migrations/meta/0018_snapshot.json new file mode 100644 index 00000000..82a9beb6 --- /dev/null +++ b/src/db/migrations/meta/0018_snapshot.json @@ -0,0 +1,2979 @@ +{ + "id": "6d02d91a-51bb-4b2e-9536-bcd71cb8c527", + "prevId": "08f874fa-f65d-4a33-bd55-dade0f0ef5c8", + "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_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": {}, + "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.pericope_sets": { + "name": "pericope_sets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pericope_sets_name_unique": { + "name": "pericope_sets_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pericope_verses": { + "name": "pericope_verses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pericope_set_id": { + "name": "pericope_set_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chapter_number": { + "name": "chapter_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "verse_number": { + "name": "verse_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "section": { + "name": "section", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pericope_number": { + "name": "pericope_number", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "pericope_title": { + "name": "pericope_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_pericope_verses_set_book_chapter_verse": { + "name": "idx_pericope_verses_set_book_chapter_verse", + "columns": [ + { + "expression": "pericope_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chapter_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "verse_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_pericope_verses_set_book_pericope": { + "name": "idx_pericope_verses_set_book_pericope", + "columns": [ + { + "expression": "pericope_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pericope_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chapter_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "verse_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pericope_verses_pericope_set_id_pericope_sets_id_fk": { + "name": "pericope_verses_pericope_set_id_pericope_sets_id_fk", + "tableFrom": "pericope_verses", + "tableTo": "pericope_sets", + "columnsFrom": ["pericope_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pericope_verses_book_id_books_id_fk": { + "name": "pericope_verses_book_id_books_id_fk", + "tableFrom": "pericope_verses", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "permissions_name_unique": { + "name": "permissions_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_unit_bible_books": { + "name": "project_unit_bible_books", + "schema": "", + "columns": { + "project_unit_id": { + "name": "project_unit_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bible_id": { + "name": "bible_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_unit_bible_books_project_unit_id_project_units_id_fk": { + "name": "project_unit_bible_books_project_unit_id_project_units_id_fk", + "tableFrom": "project_unit_bible_books", + "tableTo": "project_units", + "columnsFrom": ["project_unit_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "project_unit_bible_books_bible_id_bibles_id_fk": { + "name": "project_unit_bible_books_bible_id_bibles_id_fk", + "tableFrom": "project_unit_bible_books", + "tableTo": "bibles", + "columnsFrom": ["bible_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "project_unit_bible_books_book_id_books_id_fk": { + "name": "project_unit_bible_books_book_id_books_id_fk", + "tableFrom": "project_unit_bible_books", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_units": { + "name": "project_units", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'not_started'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_units_project_id_projects_id_fk": { + "name": "project_units_project_id_projects_id_fk", + "tableFrom": "project_units", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_users": { + "name": "project_users", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_project_users_project": { + "name": "idx_project_users_project", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_users_user": { + "name": "idx_project_users_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_users_project_id_projects_id_fk": { + "name": "project_users_project_id_projects_id_fk", + "tableFrom": "project_users", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "project_users_user_id_users_id_fk": { + "name": "project_users_user_id_users_id_fk", + "tableFrom": "project_users", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "project_users_project_id_user_id_pk": { + "name": "project_users_project_id_user_id_pk", + "columns": ["project_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "source_language": { + "name": "source_language", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_language": { + "name": "target_language", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "organization": { + "name": "organization", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "status": { + "name": "status", + "type": "project_assignment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'not_assigned'" + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "pericope_set_id": { + "name": "pericope_set_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "projects_source_language_languages_id_fk": { + "name": "projects_source_language_languages_id_fk", + "tableFrom": "projects", + "tableTo": "languages", + "columnsFrom": ["source_language"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_target_language_languages_id_fk": { + "name": "projects_target_language_languages_id_fk", + "tableFrom": "projects", + "tableTo": "languages", + "columnsFrom": ["target_language"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_organization_organizations_id_fk": { + "name": "projects_organization_organizations_id_fk", + "tableFrom": "projects", + "tableTo": "organizations", + "columnsFrom": ["organization"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_created_by_users_id_fk": { + "name": "projects_created_by_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_pericope_set_id_pericope_sets_id_fk": { + "name": "projects_pericope_set_id_pericope_sets_id_fk", + "tableFrom": "projects", + "tableTo": "pericope_sets", + "columnsFrom": ["pericope_set_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_permissions": { + "name": "role_permissions", + "schema": "", + "columns": { + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "role_permissions_role_id_roles_id_fk": { + "name": "role_permissions_role_id_roles_id_fk", + "tableFrom": "role_permissions", + "tableTo": "roles", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_permissions_permission_id_permissions_id_fk": { + "name": "role_permissions_permission_id_permissions_id_fk", + "tableFrom": "role_permissions", + "tableTo": "permissions", + "columnsFrom": ["permission_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_permissions_role_id_permission_id_pk": { + "name": "role_permissions_role_id_permission_id_pk", + "columns": ["role_id", "permission_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.translated_verses": { + "name": "translated_verses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "project_unit_id": { + "name": "project_unit_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "bible_text_id": { + "name": "bible_text_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "assigned_user_id": { + "name": "assigned_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_translated_verse_per_bible_text": { + "name": "uq_translated_verse_per_bible_text", + "columns": [ + { + "expression": "project_unit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "bible_text_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "translated_verses_project_unit_id_project_units_id_fk": { + "name": "translated_verses_project_unit_id_project_units_id_fk", + "tableFrom": "translated_verses", + "tableTo": "project_units", + "columnsFrom": ["project_unit_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "translated_verses_bible_text_id_bible_texts_id_fk": { + "name": "translated_verses_bible_text_id_bible_texts_id_fk", + "tableFrom": "translated_verses", + "tableTo": "bible_texts", + "columnsFrom": ["bible_text_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "translated_verses_assigned_user_id_users_id_fk": { + "name": "translated_verses_assigned_user_id_users_id_fk", + "tableFrom": "translated_verses", + "tableTo": "users", + "columnsFrom": ["assigned_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_chapter_assignment_editor_state": { + "name": "user_chapter_assignment_editor_state", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chapter_assignment_id": { + "name": "chapter_assignment_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uq_user_chapter_assignment_editor_state": { + "name": "uq_user_chapter_assignment_editor_state", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chapter_assignment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_chapter_assignment_editor_state_user_id_users_id_fk": { + "name": "user_chapter_assignment_editor_state_user_id_users_id_fk", + "tableFrom": "user_chapter_assignment_editor_state", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_chapter_assignment_editor_state_chapter_assignment_id_chapter_assignments_id_fk": { + "name": "user_chapter_assignment_editor_state_chapter_assignment_id_chapter_assignments_id_fk", + "tableFrom": "user_chapter_assignment_editor_state", + "tableTo": "chapter_assignments", + "columnsFrom": ["chapter_assignment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_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 + } + }, + "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 de308cca..19e2db3d 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -106,6 +106,34 @@ "when": 1783595849670, "tag": "0014_add_ai_features", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1781161896900, + "tag": "0015_create_user_role_grants", + "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1781162563413, + "tag": "0016_drop_legacy_rbac_columns", + "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1781182166615, + "tag": "0017_add_user_roles_unique_index", + "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1781503992107, + "tag": "0018_added_user_session", + "breakpoints": true } ] } diff --git a/src/db/schema.ts b/src/db/schema.ts index de2727aa..3717a224 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -3,6 +3,7 @@ import type { AnyPgColumn } from 'drizzle-orm/pg-core'; import type { Json } from 'drizzle-zod'; import { z } from '@hono/zod-openapi'; +import { sql } from 'drizzle-orm'; import { boolean, index, @@ -86,6 +87,7 @@ export const authSession = pgTable('auth_session', { // Durably flags mobile sessions at sign-in time. // Avoids per-request UA re-parsing for rolling logic. isMobile: boolean('is_mobile').notNull().default(false), + activeOrgId: integer('active_org_id').references(() => organizations.id), }); export const authAccount = pgTable('auth_account', { @@ -146,13 +148,9 @@ export const users = pgTable('users', { email: varchar('email', { length: 255 }).notNull().unique(), firstName: varchar('first_name', { length: 100 }), lastName: varchar('last_name', { length: 100 }), - role: integer('role') - .notNull() - .references(() => roles.id), - organization: integer('organization') - .notNull() - .references(() => organizations.id), + status: userStatusEnum('status').notNull().default('invited'), + lastActiveOrgId: integer('last_active_org_id').references(() => organizations.id), createdBy: integer('created_by').references((): AnyPgColumn => users.id), createdAt: timestamp('created_at').defaultNow(), updatedAt: timestamp('updated_at') @@ -566,7 +564,6 @@ export const project_users = pgTable( index('idx_project_users_user').on(table.userId), ] ); - export const permissions = pgTable('permissions', { id: serial('id').primaryKey(), name: varchar('name', { length: 100 }).notNull().unique(), @@ -590,9 +587,37 @@ export const role_permissions = pgTable( .defaultNow() .$onUpdate(() => new Date()), }, + (table) => [primaryKey({ columns: [table.roleId, table.permissionId] })] +); + +export const user_roles = pgTable( + 'user_roles', + { + id: serial('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + orgId: integer('org_id').references(() => organizations.id, { onDelete: 'cascade' }), + projectId: integer('project_id').references(() => projects.id, { onDelete: 'cascade' }), + roleId: integer('role_id') + .notNull() + .references(() => roles.id), + createdBy: integer('created_by').references((): AnyPgColumn => users.id), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => new Date()), + }, (table) => [ - primaryKey({ columns: [table.roleId, table.permissionId] }), - index('idx_role_permissions_role').on(table.roleId), + uniqueIndex('uq_user_role_grant').on( + table.userId, + sql`COALESCE(${table.orgId}, -1)`, + sql`COALESCE(${table.projectId}, -1)`, + table.roleId + ), + index('idx_user_roles_user').on(table.userId), + index('idx_user_roles_org').on(table.orgId), + index('idx_user_roles_project').on(table.projectId), ] ); @@ -691,6 +716,7 @@ export const selectUserSettingsSchema = createSelectSchema(user_settings); export const selectPermissionsSchema = createSelectSchema(permissions); export const selectRolePermissionsSchema = createSelectSchema(role_permissions); export const selectActiveChapterEditorsSchema = createSelectSchema(active_chapter_editors); +export const selectUserRolesSchema = createSelectSchema(user_roles); export const selectAiSuggestionsSchema = createSelectSchema(ai_suggestions); export const selectAiSuggestionUsageLogSchema = createSelectSchema(ai_suggestion_usage_log); @@ -704,8 +730,6 @@ export const insertUsersSchema = createInsertSchema(users, { .required({ username: true, email: true, - role: true, - organization: true, }) .omit({ id: true, @@ -964,7 +988,6 @@ export const insertUserSettingsSchema = createInsertSchema(user_settings, { createdAt: true, updatedAt: true, }); - export const insertPermissionsSchema = createInsertSchema(permissions, { name: (schema) => schema.min(1).max(100), description: (schema) => schema.max(255).optional(), @@ -977,6 +1000,15 @@ export const insertRolePermissionsSchema = createInsertSchema(role_permissions, .required({ roleId: true, permissionId: true }) .omit({ updatedAt: true }); +export const insertUserRolesSchema = createInsertSchema(user_roles, { + userId: (schema) => schema.int(), + orgId: (schema) => schema.int().optional(), + projectId: (schema) => schema.int().optional(), + roleId: (schema) => schema.int(), +}) + .required({ userId: true, roleId: true }) + .omit({ id: true, createdAt: true, updatedAt: true }); + export const insertAiSuggestionsSchema = createInsertSchema(ai_suggestions, { bibleTextId: (schema) => schema.int(), projectUnitId: (schema) => schema.int(), @@ -1025,9 +1057,9 @@ export const patchChapterAssignmentStatusHistorySchema = insertChapterAssignmentStatusHistorySchema.partial(); export const patchUserChapterAssignmentEditorStateSchema = insertUserChapterAssignmentEditorStateSchema.partial(); -export const patchProjectUsersSchema = insertProjectUsersSchema.partial(); export const patchPermissionsSchema = insertPermissionsSchema.partial(); export const patchRolePermissionsSchema = insertRolePermissionsSchema.partial(); +export const patchUserRolesSchema = insertUserRolesSchema.partial(); export const patchAiSuggestionsSchema = insertAiSuggestionsSchema.partial(); export const patchAiSuggestionUsageLogSchema = insertAiSuggestionUsageLogSchema.partial(); @@ -1036,6 +1068,4 @@ export const patchProjectsClientSchema = patchProjectsSchema.omit({ createdBy: true, }); -export const patchUsersClientSchema = patchUsersSchema.omit({ - organization: true, -}); +export const patchUsersClientSchema = patchUsersSchema; diff --git a/src/db/scripts/create-user.ts b/src/db/scripts/create-user.ts index 19f1c27b..453da9e9 100644 --- a/src/db/scripts/create-user.ts +++ b/src/db/scripts/create-user.ts @@ -2,6 +2,7 @@ import { hashPassword } from 'better-auth/crypto'; import { eq } from 'drizzle-orm'; import crypto from 'node:crypto'; +import { ROLES } from '../../lib/roles'; import { db } from '../index'; import * as schema from '../schema'; @@ -9,16 +10,21 @@ async function createNewUser() { const args = process.argv.slice(2); if (args.length < 3) { - console.error('Usage: npm run db:create-user [roleId]'); - console.error('Example: npm run db:create-user john.doe@example.com Test@1234 johndoe 2'); + console.error( + 'Usage: npm run db:create-user [roleName] [orgId] [projectId]' + ); + console.error( + 'Example: npm run db:create-user john.doe@example.com Test@1234 johndoe "Project Manager" 1 10' + ); process.exit(1); } const email = args[0].toLowerCase(); const rawPassword = args[1]; const username = args[2]; - const roleId = args.length > 3 ? Number.parseInt(args[3], 10) : 2; - const organizationId = 1; + const roleNameStr = args[3] || ROLES.PROJECT_TRANSLATOR; + const orgIdArg = args[4] ? Number.parseInt(args[4], 10) : undefined; + const projectIdArg = args[5] ? Number.parseInt(args[5], 10) : undefined; try { const [existingAuthUser] = await db @@ -47,6 +53,17 @@ async function createNewUser() { process.exit(1); } + const [role] = await db + .select() + .from(schema.roles) + .where(eq(schema.roles.name, roleNameStr)) + .limit(1); + + if (!role) { + console.error(`Role '${roleNameStr}' not found in database.`); + process.exit(1); + } + const authUserId = crypto.randomUUID(); const hashedPassword = await hashPassword(rawPassword); @@ -70,22 +87,54 @@ async function createNewUser() { updatedAt: new Date(), }); - await tx.insert(schema.users).values({ - username, - email, - firstName: username, - lastName: '(QA)', - role: roleId, - organization: organizationId, - status: 'verified', - authUserId, + const [newUser] = await tx + .insert(schema.users) + .values({ + username, + email, + firstName: username, + lastName: '(QA)', + status: 'verified', + authUserId, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning({ id: schema.users.id }); + + let grantOrgId: number | null = null; + let grantProjectId: number | null = null; + + if (role.name !== ROLES.SUPER_ADMIN) { + if (orgIdArg === undefined || Number.isNaN(orgIdArg)) { + throw new Error(`Role '${role.name}' requires a valid orgId argument.`); + } + grantOrgId = orgIdArg; + + if ( + role.name === ROLES.PROJECT_TRANSLATOR || + role.name === ROLES.PROJECT_OBSERVER || + role.name === ROLES.PROJECT_MANAGER + ) { + if (projectIdArg === undefined || Number.isNaN(projectIdArg)) { + throw new Error(`Role '${role.name}' requires a valid projectId argument.`); + } + grantProjectId = projectIdArg; + } + } + + await tx.insert(schema.user_roles).values({ + userId: newUser.id, + orgId: grantOrgId, + projectId: grantProjectId, + roleId: role.id, + createdBy: newUser.id, createdAt: new Date(), updatedAt: new Date(), }); }); console.log(`Successfully created user: ${email}`); - console.log(`Username: ${username}, Role: ${roleId === 1 ? 'Manager' : 'Translator'}`); + console.log(`Username: ${username}, Role: ${role.name}`); process.exit(0); } catch (error) { console.error('Failed to create user:', error); diff --git a/src/db/scripts/migrate-to-user-central-rbac.ts b/src/db/scripts/migrate-to-user-central-rbac.ts new file mode 100644 index 00000000..2650966c --- /dev/null +++ b/src/db/scripts/migrate-to-user-central-rbac.ts @@ -0,0 +1,139 @@ +/** + * RBAC Data Migration Script — Run BETWEEN migration phases. + * + * Deployment order for each environment (dev → staging → prod): + * 1. npm run db:migrate → applies 0012 (creates user_roles table) + * 2. npm run db:migrate-rbac → THIS SCRIPT (ports data from legacy columns) + * 3. npm run db:migrate → applies 0013 (drops legacy columns + project_users) + * + * DO NOT run step 3 before step 2, or you will permanently lose role/org data. + */ +import { and, eq, isNull, sql } from 'drizzle-orm'; +import { fileURLToPath } from 'node:url'; + +import { db } from '@/db'; +import { roles, user_roles, users } from '@/db/schema'; +import { ROLES } from '@/lib/roles'; + +export async function migrateToUserCentralRbac(): Promise { + await db.transaction(async (tx) => { + // 1. Rename legacy role rows so name FKs line up with the new constants. + await tx.update(roles).set({ name: ROLES.PROJECT_MANAGER }).where(eq(roles.name, 'Manager')); + await tx + .update(roles) + .set({ name: ROLES.PROJECT_TRANSLATOR }) + .where(eq(roles.name, 'Translator')); + + const roleRows = await tx.select({ id: roles.id, name: roles.name }).from(roles); + const roleId = new Map(roleRows.map((r) => [r.name, r.id])); + const pmId = roleId.get(ROLES.PROJECT_MANAGER); + const ptId = roleId.get(ROLES.PROJECT_TRANSLATOR); + + if (!pmId || !ptId) { + throw new Error('Required roles not found in DB after update'); + } + + const allUsers = await tx + .select({ + id: users.id, + organization: sql`organization`, + role: sql`role`, + }) + .from(users); + + for (const u of allUsers) { + if (u.role === pmId) { + // Manager -> org-wide PM grant + await tx + .insert(user_roles) + .values({ + userId: u.id, + orgId: u.organization, + projectId: null, + roleId: pmId, + createdBy: u.id, + }) + .onConflictDoNothing(); + } else if (u.role === ptId) { + // Translator -> project-pinned PT grant per project_users row + const memberships = await tx + .select({ projectId: sql`project_id` }) + .from(sql`project_users`) + .where(eq(sql`user_id`, u.id)); + for (const m of memberships) { + await tx + .insert(user_roles) + .values({ + userId: u.id, + orgId: u.organization, + projectId: m.projectId, + roleId: ptId, + createdBy: u.id, + }) + .onConflictDoNothing(); + } + } + } + + // 3. Verify: no user who had active assignments or is a Manager is left without grants. + const trueOrphans = []; + + for (const u of allUsers) { + if (u.role === pmId) { + const [hasExpected] = await tx + .select({ id: user_roles.id }) + .from(user_roles) + .where( + and( + eq(user_roles.userId, u.id), + eq(user_roles.roleId, pmId), + eq(user_roles.orgId, u.organization), + isNull(user_roles.projectId) + ) + ) + .limit(1); + if (!hasExpected) { + trueOrphans.push(u.id); + } + } else if (u.role === ptId) { + const memberships = await tx + .select({ projectId: sql`project_id` }) + .from(sql`project_users`) + .where(eq(sql`user_id`, u.id)); + + for (const m of memberships) { + const [hasExpected] = await tx + .select({ id: user_roles.id }) + .from(user_roles) + .where( + and( + eq(user_roles.userId, u.id), + eq(user_roles.roleId, ptId), + eq(user_roles.orgId, u.organization), + eq(user_roles.projectId, m.projectId) + ) + ) + .limit(1); + if (!hasExpected) { + trueOrphans.push(u.id); + break; + } + } + } + } + + if (trueOrphans.length) { + throw new Error(`Migration left active users without grants: ${trueOrphans.join(', ')}`); + } + }); + console.log('User-central RBAC migration complete.'); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + migrateToUserCentralRbac() + .then(() => process.exit(0)) + .catch((err: unknown) => { + console.error('Migration failed:', err); + process.exit(1); + }); +} diff --git a/src/db/seeds/dev-users.ts b/src/db/seeds/dev-users.ts index cf01d827..46eff82b 100644 --- a/src/db/seeds/dev-users.ts +++ b/src/db/seeds/dev-users.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'; import type { RoleName } from '@/lib/roles'; import { db } from '@/db'; -import { authAccount, authUser, organizations, roles, users } from '@/db/schema'; +import { authAccount, authUser, organizations, roles, user_roles, users } from '@/db/schema'; import { ROLES } from '@/lib/roles'; interface DevUserConfig { @@ -28,13 +28,13 @@ export async function seedDevUsers() { email: process.env.SEED_TRANSLATOR_EMAIL ?? 't@fluent.local', password: process.env.SEED_TRANSLATOR_PASSWORD ?? 't@123456', username: 'translator', - roleName: ROLES.TRANSLATOR, + roleName: ROLES.PROJECT_TRANSLATOR, }, { email: process.env.SEED_TRANSLATOR2_EMAIL ?? 't2@fluent.local', password: process.env.SEED_TRANSLATOR2_PASSWORD ?? 't@123456', username: 'translator2', - roleName: ROLES.TRANSLATOR, + roleName: ROLES.PROJECT_TRANSLATOR, }, ]; @@ -113,15 +113,25 @@ export async function seedDevUsers() { updatedAt: new Date(), }); - await tx.insert(users).values({ - username: config.username, - email: config.email, - firstName: config.username, - lastName: '(Dev)', - role: roleId, - organization: defaultOrg.id, - status: 'verified', - authUserId, + const [newUser] = await tx + .insert(users) + .values({ + username: config.username, + email: config.email, + firstName: config.username, + lastName: '(Dev)', + status: 'verified', + authUserId, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning({ id: users.id }); + + await tx.insert(user_roles).values({ + userId: newUser.id, + orgId: defaultOrg.id, + roleId, + createdBy: newUser.id, createdAt: new Date(), updatedAt: new Date(), }); diff --git a/src/db/seeds/rbac.ts b/src/db/seeds/rbac.ts index 2b517c62..b27e961d 100644 --- a/src/db/seeds/rbac.ts +++ b/src/db/seeds/rbac.ts @@ -10,8 +10,12 @@ const PERMISSION_DEFINITIONS = [ { name: PERMISSIONS.PROJECT_CREATE, description: 'Create new projects' }, { name: PERMISSIONS.PROJECT_UPDATE, description: 'Update existing projects' }, { name: PERMISSIONS.PROJECT_DELETE, description: 'Delete projects' }, - { name: PERMISSIONS.CONTENT_ASSIGN, description: 'Assign chapter assignment' }, + { name: PERMISSIONS.CONTENT_VIEW, description: 'View chapter assignment content' }, + { name: PERMISSIONS.CONTENT_ASSIGN, description: 'Assign chapter assignments' }, { name: PERMISSIONS.CONTENT_UPDATE, description: 'Update chapter assignment content' }, + { name: PERMISSIONS.MEMBERSHIP_REVOKE, description: 'Revoke user memberships' }, + { name: PERMISSIONS.ROLE_ASSIGN_PROJECT, description: 'Assign project-level roles' }, + { name: PERMISSIONS.ROLE_ASSIGN_ORG_MANAGER, description: 'Assign org manager role' }, { name: PERMISSIONS.USER_VIEW, description: 'View user profiles' }, { name: PERMISSIONS.USER_CREATE, description: 'Create new users' }, { name: PERMISSIONS.USER_UPDATE, description: 'Update user profiles' }, @@ -19,20 +23,63 @@ const PERMISSION_DEFINITIONS = [ ]; const ROLE_PERMISSION_MAP = [ + // ── SuperAdmin: every permission ────────────────────────────────────── + ...Object.values(PERMISSIONS).map((p) => ({ roleName: ROLES.SUPER_ADMIN, permissionName: p })), + + // ── Org Owner: full org control (same as SuperAdmin within an org) ──── + { roleName: ROLES.ORG_OWNER, permissionName: PERMISSIONS.PROJECT_VIEW }, + { roleName: ROLES.ORG_OWNER, permissionName: PERMISSIONS.PROJECT_CREATE }, + { roleName: ROLES.ORG_OWNER, permissionName: PERMISSIONS.PROJECT_UPDATE }, + { roleName: ROLES.ORG_OWNER, permissionName: PERMISSIONS.PROJECT_DELETE }, + { roleName: ROLES.ORG_OWNER, permissionName: PERMISSIONS.CONTENT_VIEW }, + { roleName: ROLES.ORG_OWNER, permissionName: PERMISSIONS.CONTENT_ASSIGN }, + { roleName: ROLES.ORG_OWNER, permissionName: PERMISSIONS.CONTENT_UPDATE }, + { roleName: ROLES.ORG_OWNER, permissionName: PERMISSIONS.MEMBERSHIP_REVOKE }, + { roleName: ROLES.ORG_OWNER, permissionName: PERMISSIONS.ROLE_ASSIGN_PROJECT }, + { roleName: ROLES.ORG_OWNER, permissionName: PERMISSIONS.ROLE_ASSIGN_ORG_MANAGER }, + { roleName: ROLES.ORG_OWNER, permissionName: PERMISSIONS.USER_VIEW }, + { roleName: ROLES.ORG_OWNER, permissionName: PERMISSIONS.USER_CREATE }, + { roleName: ROLES.ORG_OWNER, permissionName: PERMISSIONS.USER_UPDATE }, + + // ── Org Manager: manage projects + users but cannot delete org or assign owners ─ + { roleName: ROLES.ORG_MANAGER, permissionName: PERMISSIONS.PROJECT_VIEW }, + { roleName: ROLES.ORG_MANAGER, permissionName: PERMISSIONS.PROJECT_CREATE }, + { roleName: ROLES.ORG_MANAGER, permissionName: PERMISSIONS.PROJECT_UPDATE }, + { roleName: ROLES.ORG_MANAGER, permissionName: PERMISSIONS.PROJECT_DELETE }, + { roleName: ROLES.ORG_MANAGER, permissionName: PERMISSIONS.CONTENT_VIEW }, + { roleName: ROLES.ORG_MANAGER, permissionName: PERMISSIONS.CONTENT_ASSIGN }, + { roleName: ROLES.ORG_MANAGER, permissionName: PERMISSIONS.CONTENT_UPDATE }, + { roleName: ROLES.ORG_MANAGER, permissionName: PERMISSIONS.MEMBERSHIP_REVOKE }, + { roleName: ROLES.ORG_MANAGER, permissionName: PERMISSIONS.ROLE_ASSIGN_PROJECT }, + { roleName: ROLES.ORG_MANAGER, permissionName: PERMISSIONS.USER_VIEW }, + { roleName: ROLES.ORG_MANAGER, permissionName: PERMISSIONS.USER_CREATE }, + { roleName: ROLES.ORG_MANAGER, permissionName: PERMISSIONS.USER_UPDATE }, + + // ── Project Manager: full project control ───────────────────────────── { roleName: ROLES.PROJECT_MANAGER, permissionName: PERMISSIONS.PROJECT_VIEW }, { roleName: ROLES.PROJECT_MANAGER, permissionName: PERMISSIONS.PROJECT_CREATE }, { roleName: ROLES.PROJECT_MANAGER, permissionName: PERMISSIONS.PROJECT_UPDATE }, { roleName: ROLES.PROJECT_MANAGER, permissionName: PERMISSIONS.PROJECT_DELETE }, + { roleName: ROLES.PROJECT_MANAGER, permissionName: PERMISSIONS.CONTENT_VIEW }, { roleName: ROLES.PROJECT_MANAGER, permissionName: PERMISSIONS.CONTENT_ASSIGN }, { roleName: ROLES.PROJECT_MANAGER, permissionName: PERMISSIONS.CONTENT_UPDATE }, + { roleName: ROLES.PROJECT_MANAGER, permissionName: PERMISSIONS.MEMBERSHIP_REVOKE }, + { roleName: ROLES.PROJECT_MANAGER, permissionName: PERMISSIONS.ROLE_ASSIGN_PROJECT }, { roleName: ROLES.PROJECT_MANAGER, permissionName: PERMISSIONS.USER_VIEW }, { roleName: ROLES.PROJECT_MANAGER, permissionName: PERMISSIONS.USER_CREATE }, { roleName: ROLES.PROJECT_MANAGER, permissionName: PERMISSIONS.USER_UPDATE }, - { roleName: ROLES.PROJECT_MANAGER, permissionName: PERMISSIONS.USER_DELETE }, - { roleName: ROLES.TRANSLATOR, permissionName: PERMISSIONS.PROJECT_VIEW }, - { roleName: ROLES.TRANSLATOR, permissionName: PERMISSIONS.CONTENT_UPDATE }, - { roleName: ROLES.TRANSLATOR, permissionName: PERMISSIONS.USER_VIEW }, - { roleName: ROLES.TRANSLATOR, permissionName: PERMISSIONS.USER_UPDATE }, + + // ── Translator: view + draft/edit assigned content ──────────────────── + { roleName: ROLES.PROJECT_TRANSLATOR, permissionName: PERMISSIONS.PROJECT_VIEW }, + { roleName: ROLES.PROJECT_TRANSLATOR, permissionName: PERMISSIONS.CONTENT_VIEW }, + { roleName: ROLES.PROJECT_TRANSLATOR, permissionName: PERMISSIONS.CONTENT_UPDATE }, + { roleName: ROLES.PROJECT_TRANSLATOR, permissionName: PERMISSIONS.USER_VIEW }, + { roleName: ROLES.PROJECT_TRANSLATOR, permissionName: PERMISSIONS.USER_UPDATE }, + + // ── Observer: read-only access to project and content ───────────────── + { roleName: ROLES.PROJECT_OBSERVER, permissionName: PERMISSIONS.PROJECT_VIEW }, + { roleName: ROLES.PROJECT_OBSERVER, permissionName: PERMISSIONS.CONTENT_VIEW }, + { roleName: ROLES.PROJECT_OBSERVER, permissionName: PERMISSIONS.USER_VIEW }, ]; export async function seedRbac() { diff --git a/src/domains/ai-suggestions/ai-suggestions.auth.middleware.ts b/src/domains/ai-suggestions/ai-suggestions.auth.middleware.ts index a6966471..ad4ad1d1 100644 --- a/src/domains/ai-suggestions/ai-suggestions.auth.middleware.ts +++ b/src/domains/ai-suggestions/ai-suggestions.auth.middleware.ts @@ -23,7 +23,8 @@ export async function checkProjectUnitAccess(c: Context, projectUnitId: return c.json({ message: 'Project unit not found' }, HttpStatusCodes.NOT_FOUND); } - const isAuthorized = AiSuggestionsPolicy.canAccessProjectUnit(user, context); + const policyUser = { id: user.id, grants: user.grants }; + const isAuthorized = AiSuggestionsPolicy.canAccessProjectUnit(policyUser, context); if (!isAuthorized) { return c.json({ message: 'Forbidden' }, HttpStatusCodes.FORBIDDEN); } diff --git a/src/domains/ai-suggestions/ai-suggestions.policy.ts b/src/domains/ai-suggestions/ai-suggestions.policy.ts index a93cc6a9..c37a4394 100644 --- a/src/domains/ai-suggestions/ai-suggestions.policy.ts +++ b/src/domains/ai-suggestions/ai-suggestions.policy.ts @@ -1,9 +1,6 @@ -import { ROLES } from '@/lib/roles'; - export interface PolicyUser { id: number; - roleName: string; - organization: number; + grants: any[]; } export interface ProjectUnitAuthContext { @@ -18,12 +15,17 @@ export class AiSuggestionsPolicy { * 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; + const isManager = user.grants.some( + (g) => + g.orgId === context.organizationId && + (g.permissions.has('project:create') || g.permissions.has('project:update')) + ); + if (isManager) { + return true; } - if (user.roleName === ROLES.TRANSLATOR) { - return context.memberUserIds.includes(user.id); + if (context.memberUserIds.includes(user.id)) { + return true; } return false; diff --git a/src/domains/ai-suggestions/ai-suggestions.route.test.ts b/src/domains/ai-suggestions/ai-suggestions.route.test.ts index 9e7bd386..ed43853e 100644 --- a/src/domains/ai-suggestions/ai-suggestions.route.test.ts +++ b/src/domains/ai-suggestions/ai-suggestions.route.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { findGrantsByUserId } from '@/domains/user-roles/user-roles.repository'; import { getUserByEmail } from '@/domains/users/users.service'; import { auth } from '@/lib/auth'; -import { roleHasPermission } from '@/lib/services/permissions/permissions.service'; import { server } from '@/server/server'; import { checkProjectUnitAccess } from './ai-suggestions.auth.middleware'; @@ -18,9 +18,22 @@ vi.mock('@/lib/auth', () => ({ }, })); -vi.mock('@/db', () => ({ - db: { select: vi.fn(), insert: vi.fn(), update: vi.fn() }, -})); +vi.mock('@/db', () => { + const mockQueryBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([]), + returning: vi.fn().mockResolvedValue([]), + }; + return { + db: { + select: vi.fn(() => mockQueryBuilder), + insert: vi.fn(() => mockQueryBuilder), + update: vi.fn(() => mockQueryBuilder), + delete: vi.fn(() => mockQueryBuilder), + }, + }; +}); vi.mock('@/lib/logger', () => ({ logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() }, @@ -30,10 +43,12 @@ vi.mock('@/domains/users/users.service', () => ({ getUserByEmail: vi.fn(), })); -vi.mock('@/lib/services/permissions/permissions.service', () => ({ - roleHasPermission: vi.fn(), +vi.mock('@/domains/user-roles/user-roles.repository', () => ({ + findGrantsByUserId: vi.fn(), })); +// Removed permissions.service mock + vi.mock('./ai-suggestions.service', () => ({ getAiSuggestions: vi.fn(), queueNextVerses: vi.fn(), @@ -56,13 +71,16 @@ const APP_USER = { status: 'verified' as const, }; -function asAuthenticatedUser(granted: boolean) { +function asAuthenticatedUser() { (auth.api.getSession as any).mockResolvedValue({ session: { id: 's1', updatedAt: new Date(), expiresAt: new Date(Date.now() + 1e9) }, user: { email: APP_USER.email }, }); (getUserByEmail as any).mockResolvedValue({ ok: true, data: APP_USER }); - (roleHasPermission as any).mockResolvedValue(granted); + (findGrantsByUserId as any).mockResolvedValue({ + ok: true, + data: [{ orgId: null, projectId: null, permissions: new Set(['project:view']) }], + }); } function getAiSuggestions(projectUnitId: number, bibleTextIds: number[]) { @@ -103,7 +121,7 @@ describe('ai-suggestions routes', () => { }); it('returns 200 with suggestions on success', async () => { - asAuthenticatedUser(true); + asAuthenticatedUser(); (aiSuggestionsService.getAiSuggestions as any).mockResolvedValue({ ok: true, data: { data: [{ bibleTextId: 1, suggestedText: 'hello' }] }, @@ -127,7 +145,7 @@ describe('ai-suggestions routes', () => { }; it('returns 403 when access check fails', async () => { - asAuthenticatedUser(true); + asAuthenticatedUser(); (checkProjectUnitAccess as any).mockResolvedValue( new Response(JSON.stringify({ message: 'Forbidden' }), { status: 403 }) ); @@ -138,7 +156,7 @@ describe('ai-suggestions routes', () => { }); it('returns 200 on success', async () => { - asAuthenticatedUser(true); + asAuthenticatedUser(); (aiSuggestionsService.queueNextVerses as any).mockResolvedValue({ ok: true, data: { queueCount: 5, thresholdReached: true }, @@ -160,7 +178,7 @@ describe('ai-suggestions routes', () => { }; it('returns 403 when access check fails', async () => { - asAuthenticatedUser(true); + asAuthenticatedUser(); (checkProjectUnitAccess as any).mockResolvedValue( new Response(JSON.stringify({ message: 'Forbidden' }), { status: 403 }) ); @@ -171,7 +189,7 @@ describe('ai-suggestions routes', () => { }); it('returns 200 on success', async () => { - asAuthenticatedUser(true); + asAuthenticatedUser(); (aiSuggestionsService.trackUsage as any).mockResolvedValue({ ok: true, data: undefined, diff --git a/src/domains/ai-tools/ai-tools.route.test.ts b/src/domains/ai-tools/ai-tools.route.test.ts index 4c80ce2b..b6f68b4f 100644 --- a/src/domains/ai-tools/ai-tools.route.test.ts +++ b/src/domains/ai-tools/ai-tools.route.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { findGrantsByUserId } from '@/domains/user-roles/user-roles.repository'; import { getUserByEmail } from '@/domains/users/users.service'; import { auth } from '@/lib/auth'; -import { roleHasPermission } from '@/lib/services/permissions/permissions.service'; +import { PERMISSIONS } from '@/lib/permissions'; import { ErrorCode } from '@/lib/types'; import { server } from '@/server/server'; @@ -19,9 +20,16 @@ vi.mock('@/lib/auth', () => ({ }, })); -vi.mock('@/db', () => ({ - db: { select: vi.fn(), insert: vi.fn(), update: vi.fn() }, -})); +vi.mock('@/db', () => { + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([]), + }; + return { + db: { select: vi.fn().mockReturnValue(chain), insert: vi.fn(), update: vi.fn() }, + }; +}); vi.mock('@/lib/logger', () => ({ logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() }, @@ -31,8 +39,8 @@ vi.mock('@/domains/users/users.service', () => ({ getUserByEmail: vi.fn(), })); -vi.mock('@/lib/services/permissions/permissions.service', () => ({ - roleHasPermission: vi.fn(), +vi.mock('@/domains/user-roles/user-roles.repository', () => ({ + findGrantsByUserId: vi.fn(), })); vi.mock('./ai-tools.service', () => ({ @@ -84,7 +92,12 @@ function asAuthenticatedUser(granted: boolean) { user: { email: APP_USER.email }, }); (getUserByEmail as any).mockResolvedValue({ ok: true, data: APP_USER }); - (roleHasPermission as any).mockResolvedValue(granted); + (findGrantsByUserId as any).mockResolvedValue({ + ok: true, + data: granted + ? [{ orgId: null, projectId: null, permissions: new Set([PERMISSIONS.AI_TOOLS_USE]) }] + : [], + }); } function postRepeatedWords(body: unknown, headers: Record = {}) { diff --git a/src/domains/bibles/bibles.route.ts b/src/domains/bibles/bibles.route.ts index f75be094..7cce0964 100644 --- a/src/domains/bibles/bibles.route.ts +++ b/src/domains/bibles/bibles.route.ts @@ -6,7 +6,7 @@ import { createMessageObjectSchema } from 'stoker/openapi/schemas'; import { insertBiblesSchema, patchBiblesSchema } from '@/db/schema'; import { getHttpStatus } from '@/lib/types'; -import { authenticateUser, denyUntilAdminRole } from '@/middlewares/role-auth'; +import { authenticateUser, requireSuperAdmin } from '@/middlewares/role-auth'; import { server } from '@/server/server'; import * as bibleService from './bibles.service'; @@ -132,7 +132,7 @@ const createBibleRoute = createRoute({ tags: ['Bibles'], method: 'post', path: '/bibles', - middleware: [authenticateUser, denyUntilAdminRole()] as const, + middleware: [authenticateUser, requireSuperAdmin] as const, request: { body: jsonContentRequired(insertBiblesSchema, 'The bible to create'), }, @@ -174,7 +174,7 @@ const updateBibleRoute = createRoute({ tags: ['Bibles'], method: 'patch', path: '/bibles/{id}', - middleware: [authenticateUser, denyUntilAdminRole()] as const, + middleware: [authenticateUser, requireSuperAdmin] as const, request: { params: idParam, body: jsonContentRequired(patchBiblesSchema, 'The bible data to update'), @@ -219,7 +219,7 @@ const deleteBibleRoute = createRoute({ tags: ['Bibles'], method: 'delete', path: '/bibles/{id}', - middleware: [authenticateUser, denyUntilAdminRole()] as const, + middleware: [authenticateUser, requireSuperAdmin] as const, request: { params: idParam }, responses: { [HttpStatusCodes.NO_CONTENT]: { description: 'Bible deleted successfully' }, diff --git a/src/domains/chapter-assignments/chapter-assignment-auth.middleware.ts b/src/domains/chapter-assignments/chapter-assignment-auth.middleware.ts index f7eb6030..411abbad 100644 --- a/src/domains/chapter-assignments/chapter-assignment-auth.middleware.ts +++ b/src/domains/chapter-assignments/chapter-assignment-auth.middleware.ts @@ -4,7 +4,6 @@ import * as HttpStatusCodes from 'stoker/http-status-codes'; import type { Result } from '@/lib/types'; import type { AppEnv } from '@/server/context.types'; -import { ROLES } from '@/lib/roles'; import { getHttpStatus } from '@/lib/types'; import type { ChapterAssignmentWithAuthContext } from './chapter-assignments.repository'; @@ -30,8 +29,7 @@ export function requireChapterAssignmentAccess( const result: Result = await chapterAssignmentService.getChapterAssignmentWithAuthContext( chapterAssignmentId, - user.id, - user.roleName + user.id ); if (!result.ok) { @@ -39,9 +37,10 @@ export function requireChapterAssignmentAccess( } const ctx = result.data; - const policyUser = { id: user.id, roleName: user.roleName, organization: user.organization }; + const policyUser = { id: user.id, grants: user.grants }; const policyAssignment = { organizationId: ctx.organizationId, + projectId: ctx.projectId, assignedUserId: ctx.assignedUserId, peerCheckerId: ctx.peerCheckerId, status: ctx.status, @@ -50,11 +49,7 @@ export function requireChapterAssignmentAccess( let allowed = false; switch (action) { case CHAPTER_ASSIGNMENT_ACTIONS.READ: - if (user.roleName === ROLES.PROJECT_MANAGER) { - allowed = ctx.organizationId === user.organization; - } else if (user.roleName === ROLES.TRANSLATOR) { - allowed = ctx.isProjectMember; - } + allowed = ChapterAssignmentPolicy.view(policyUser, policyAssignment, ctx.isProjectMember); break; case CHAPTER_ASSIGNMENT_ACTIONS.UPDATE: @@ -70,7 +65,11 @@ export function requireChapterAssignmentAccess( break; case CHAPTER_ASSIGNMENT_ACTIONS.IS_PARTICIPANT: - allowed = ChapterAssignmentPolicy.isParticipant(policyUser, policyAssignment); + allowed = ChapterAssignmentPolicy.isParticipant( + policyUser, + policyAssignment, + ctx.isProjectMember + ); break; } diff --git a/src/domains/chapter-assignments/chapter-assignments.policy.ts b/src/domains/chapter-assignments/chapter-assignments.policy.ts index b2a69a8a..5a4e451f 100644 --- a/src/domains/chapter-assignments/chapter-assignments.policy.ts +++ b/src/domains/chapter-assignments/chapter-assignments.policy.ts @@ -7,197 +7,110 @@ import type { AppPolicyUser } from '@/lib/types'; -import { ROLES } from '@/lib/roles'; +import { PERMISSIONS } from '@/lib/permissions'; +import { authorize } from '@/lib/services/permissions/authorize'; import { CHAPTER_ASSIGNMENT_STATUS } from './chapter-assignments.types'; export interface PolicyChapterAssignment { organizationId: number; + projectId: number; assignedUserId?: number | null; peerCheckerId?: number | null; status?: string | null; } +const POST_PEER_STATUSES = new Set([ + CHAPTER_ASSIGNMENT_STATUS.COMMUNITY_REVIEW, + CHAPTER_ASSIGNMENT_STATUS.LINGUIST_CHECK, + CHAPTER_ASSIGNMENT_STATUS.THEOLOGICAL_CHECK, + CHAPTER_ASSIGNMENT_STATUS.CONSULTANT_CHECK, +]); + export const ChapterAssignmentPolicy = { - /** - * Can this user edit the content of this chapter assignment? - */ edit( user: AppPolicyUser, assignment: PolicyChapterAssignment, isProjectMember: boolean ): boolean { - if (user.organization !== assignment.organizationId) { - return false; - } + const scope = { orgId: assignment.organizationId, projectId: assignment.projectId }; - if (user.roleName === ROLES.PROJECT_MANAGER) { - return ( - assignment.status === CHAPTER_ASSIGNMENT_STATUS.COMMUNITY_REVIEW || - assignment.status === CHAPTER_ASSIGNMENT_STATUS.LINGUIST_CHECK || - assignment.status === CHAPTER_ASSIGNMENT_STATUS.THEOLOGICAL_CHECK || - assignment.status === CHAPTER_ASSIGNMENT_STATUS.CONSULTANT_CHECK - ); + // Managers (content:assign) may edit only at/after community review. + if (authorize(user, PERMISSIONS.CONTENT_ASSIGN, scope)) { + return POST_PEER_STATUSES.has(assignment.status as any); } - // future roles can be prevented access using this - if (user.roleName !== ROLES.TRANSLATOR) { - return false; - } + // Content editors (translators) — assignment-position rules. + if (!authorize(user, PERMISSIONS.CONTENT_UPDATE, scope)) return false; switch (assignment.status) { case CHAPTER_ASSIGNMENT_STATUS.DRAFT: return assignment.assignedUserId === user.id; - case CHAPTER_ASSIGNMENT_STATUS.PEER_CHECK: return assignment.peerCheckerId === user.id; - - case CHAPTER_ASSIGNMENT_STATUS.COMMUNITY_REVIEW: - case CHAPTER_ASSIGNMENT_STATUS.LINGUIST_CHECK: - case CHAPTER_ASSIGNMENT_STATUS.THEOLOGICAL_CHECK: - case CHAPTER_ASSIGNMENT_STATUS.CONSULTANT_CHECK: - return isProjectMember; - default: - return false; + return POST_PEER_STATUSES.has(assignment.status as any) && isProjectMember; } }, - /** - * Can this user view a list of all chapter assignments in this org? - */ - viewAll(user: AppPolicyUser, targetOrganizationId: number): boolean { - return user.organization === targetOrganizationId; + create(user: AppPolicyUser, targetOrganizationId: number, targetProjectId: number): boolean { + const scope = { orgId: targetOrganizationId, projectId: targetProjectId }; + return authorize(user, PERMISSIONS.CONTENT_ASSIGN, scope); }, - /** - * Can this user view this specific chapter assignment? - */ - view(user: AppPolicyUser, assignment: PolicyChapterAssignment): boolean { - return user.organization === assignment.organizationId; + deleteAll(user: AppPolicyUser, targetOrganizationId: number, targetProjectId: number): boolean { + const scope = { orgId: targetOrganizationId, projectId: targetProjectId }; + return authorize(user, PERMISSIONS.CONTENT_ASSIGN, scope); }, - /** - * Can this user create a chapter assignment? - */ - create(user: AppPolicyUser, targetOrganizationId: number): boolean { - return user.roleName === ROLES.PROJECT_MANAGER && user.organization === targetOrganizationId; + assignAll(user: AppPolicyUser, targetOrganizationId: number, targetProjectId: number): boolean { + const scope = { orgId: targetOrganizationId, projectId: targetProjectId }; + return authorize(user, PERMISSIONS.CONTENT_ASSIGN, scope); }, - /** - * Can this user update this chapter assignment (e.g., metadata, non-content)? - */ - update(user: AppPolicyUser, assignment: PolicyChapterAssignment): boolean { - return ( - user.roleName === ROLES.PROJECT_MANAGER && user.organization === assignment.organizationId - ); - }, - - /** - * Can this user delete this chapter assignment? - */ - delete(user: AppPolicyUser, assignment: PolicyChapterAssignment): boolean { - return ( - user.roleName === ROLES.PROJECT_MANAGER && user.organization === assignment.organizationId - ); - }, - - /** - * Can this user delete all chapter assignments for a project/org? - */ - deleteAll(user: AppPolicyUser, targetOrganizationId: number): boolean { - return user.roleName === ROLES.PROJECT_MANAGER && user.organization === targetOrganizationId; - }, - - /** - * Can this user assign all chapter assignments for a project/org? - */ - assignAll(user: AppPolicyUser, targetOrganizationId: number): boolean { - return user.roleName === ROLES.PROJECT_MANAGER && user.organization === targetOrganizationId; + view( + user: AppPolicyUser, + assignment: PolicyChapterAssignment, + isProjectMember: boolean + ): boolean { + const scope = { orgId: assignment.organizationId, projectId: assignment.projectId }; + return authorize(user, PERMISSIONS.CONTENT_VIEW, scope) || isProjectMember; }, - /** - * Base assignment logic (Internal abstraction) - */ - _assign(user: AppPolicyUser, assignment: PolicyChapterAssignment): boolean { - return ( - user.roleName === ROLES.PROJECT_MANAGER && user.organization === assignment.organizationId - ); + update(user: AppPolicyUser, assignment: PolicyChapterAssignment): boolean { + const scope = { orgId: assignment.organizationId, projectId: assignment.projectId }; + return authorize(user, PERMISSIONS.CONTENT_ASSIGN, scope); }, - /** - * Can this user assign a drafter to this assignment? - */ - assignDrafter(user: AppPolicyUser, assignment: PolicyChapterAssignment): boolean { - return this._assign(user, assignment); + delete(user: AppPolicyUser, assignment: PolicyChapterAssignment): boolean { + const scope = { orgId: assignment.organizationId, projectId: assignment.projectId }; + return authorize(user, PERMISSIONS.CONTENT_ASSIGN, scope); }, - /** - * Can this user assign a peer checker to this assignment? - */ - assignPeerChecker(user: AppPolicyUser, assignment: PolicyChapterAssignment): boolean { - return this._assign(user, assignment); + assign(user: AppPolicyUser, assignment: PolicyChapterAssignment): boolean { + const scope = { orgId: assignment.organizationId, projectId: assignment.projectId }; + return authorize(user, PERMISSIONS.CONTENT_ASSIGN, scope); }, - /** - * Can this user submit (advance the status of) this assignment? - */ submit( user: AppPolicyUser, assignment: PolicyChapterAssignment, isProjectMember: boolean ): boolean { - // 1. Strict Organization Boundary Check - if (user.organization !== assignment.organizationId) { - return false; - } - - if (user.roleName === ROLES.PROJECT_MANAGER) { - return ( - assignment.status === CHAPTER_ASSIGNMENT_STATUS.COMMUNITY_REVIEW || - assignment.status === CHAPTER_ASSIGNMENT_STATUS.LINGUIST_CHECK || - assignment.status === CHAPTER_ASSIGNMENT_STATUS.THEOLOGICAL_CHECK || - assignment.status === CHAPTER_ASSIGNMENT_STATUS.CONSULTANT_CHECK - ); - } - - if (user.roleName !== ROLES.TRANSLATOR) { - return false; - } - - // Refactored to use switch statement for expressive intent and consistency - switch (assignment.status) { - case CHAPTER_ASSIGNMENT_STATUS.DRAFT: - return assignment.assignedUserId === user.id; - - case CHAPTER_ASSIGNMENT_STATUS.PEER_CHECK: - return assignment.peerCheckerId === user.id; - - case CHAPTER_ASSIGNMENT_STATUS.COMMUNITY_REVIEW: - case CHAPTER_ASSIGNMENT_STATUS.LINGUIST_CHECK: - case CHAPTER_ASSIGNMENT_STATUS.THEOLOGICAL_CHECK: - case CHAPTER_ASSIGNMENT_STATUS.CONSULTANT_CHECK: - return isProjectMember; - - default: - return false; - } + return this.edit(user, assignment, isProjectMember); }, /** * Can this user toggle AI for this assignment? */ toggleAi(user: AppPolicyUser, assignment: PolicyChapterAssignment): boolean { - if (user.organization !== assignment.organizationId) { - return false; - } + const scope = { orgId: assignment.organizationId, projectId: assignment.projectId }; - if (user.roleName === ROLES.PROJECT_MANAGER) { + if (authorize(user, PERMISSIONS.CONTENT_ASSIGN, scope)) { return true; } - if (user.roleName === ROLES.TRANSLATOR) { - // The assigned user can toggle AI only when they are the primary drafter + if (authorize(user, PERMISSIONS.CONTENT_UPDATE, scope)) { if (assignment.status === CHAPTER_ASSIGNMENT_STATUS.DRAFT) { return assignment.assignedUserId === user.id; } @@ -206,19 +119,12 @@ export const ChapterAssignmentPolicy = { return false; }, - /** - * Is this user a direct participant in this assignment? - */ - isParticipant(user: AppPolicyUser, assignment: PolicyChapterAssignment): boolean { - // 1. Strict Organization Boundary Check - if (user.organization !== assignment.organizationId) { - return false; - } - - if (user.roleName !== ROLES.TRANSLATOR) { - return false; - } - - return assignment.assignedUserId === user.id || assignment.peerCheckerId === user.id; + isParticipant( + user: AppPolicyUser, + assignment: PolicyChapterAssignment, + isProjectMember: boolean = false + ): boolean { + // A participant in the editor is exactly anyone who currently has edit rights. + return this.edit(user, assignment, isProjectMember); }, }; diff --git a/src/domains/chapter-assignments/chapter-assignments.repository.ts b/src/domains/chapter-assignments/chapter-assignments.repository.ts index eaa65e31..77c0cdec 100644 --- a/src/domains/chapter-assignments/chapter-assignments.repository.ts +++ b/src/domains/chapter-assignments/chapter-assignments.repository.ts @@ -15,13 +15,12 @@ import { chapter_assignments, languages, project_units, - project_users, projects, translated_verses, users, } from '@/db/schema'; +import { resolveIsProjectMember } from '@/domains/projects/users/project-users.service'; import { logger } from '@/lib/logger'; -import { ROLES } from '@/lib/roles'; import { err, ErrorCode, ok } from '@/lib/types'; import { convertUSFMToUSJ, generateUSFMText } from '@/lib/usfm-converter'; @@ -90,8 +89,7 @@ export async function findByIdWithOrg(id: number): Promise> { try { const [assignment] = await db @@ -113,25 +111,18 @@ export async function findByIdWithAuthContext( projectId: projects.id, organizationId: projects.organization, // Project membership (LEFT JOIN to handle non-members) - isProjectMember: sql`CASE WHEN ${project_users.userId} IS NOT NULL THEN true ELSE false END`, }) .from(chapter_assignments) .innerJoin(project_units, eq(chapter_assignments.projectUnitId, project_units.id)) .innerJoin(projects, eq(project_units.projectId, projects.id)) - .leftJoin( - project_users, - and( - eq(project_users.projectId, projects.id), - eq(project_users.userId, userId), - // Only check membership for translators (per resolveIsProjectMember logic) - roleName === ROLES.TRANSLATOR ? sql`1=1` : sql`1=0` - ) - ) .where(eq(chapter_assignments.id, id)) .limit(1); if (!assignment) return err(ErrorCode.CHAPTER_ASSIGNMENT_NOT_FOUND); - return ok(assignment); + + const isProjectMember = await resolveIsProjectMember(assignment.projectId, userId); + + return ok({ ...assignment, isProjectMember }); } catch (error) { logger.error({ cause: error, @@ -161,6 +152,7 @@ export async function findForVerse( peerCheckerId: chapter_assignments.peerCheckerId, status: chapter_assignments.status, organizationId: projects.organization, + projectId: projects.id, }) .from(chapter_assignments) .innerJoin(project_units, eq(chapter_assignments.projectUnitId, project_units.id)) @@ -358,6 +350,7 @@ export async function insertSnapshot( export async function findAssignmentsProgress( filters: { projectId?: number; + orgId?: number; assignedUserId?: number; peerCheckerId?: number; status?: ChapterAssignmentStatus; @@ -432,6 +425,9 @@ export async function findAssignmentsProgress( if (filters.projectId !== undefined) { conditions.push(eq(project_units.projectId, filters.projectId)); } + if (filters.orgId !== undefined) { + conditions.push(eq(projects.organization, filters.orgId)); + } if (filters.assignedUserId !== undefined) { conditions.push(eq(chapter_assignments.assignedUserId, filters.assignedUserId)); } diff --git a/src/domains/chapter-assignments/chapter-assignments.route.ts b/src/domains/chapter-assignments/chapter-assignments.route.ts index 67b55f69..de717f07 100644 --- a/src/domains/chapter-assignments/chapter-assignments.route.ts +++ b/src/domains/chapter-assignments/chapter-assignments.route.ts @@ -76,7 +76,7 @@ const createChapterAssignmentRoute = createRoute({ server.openapi(createChapterAssignmentRoute, async (c) => { const requestData = c.req.valid('json'); const user = c.get('user')!; - const policyUser = { id: user.id, roleName: user.roleName, organization: user.organization }; + const policyUser = { id: user.id, grants: user.grants }; const unitResult = await projectHandler.getProjectIdByUnitId(requestData.projectUnitId); if (!unitResult.ok) { @@ -88,7 +88,13 @@ server.openapi(createChapterAssignmentRoute, async (c) => { return c.json({ message: 'Project not found' }, HttpStatusCodes.NOT_FOUND); } - if (!ChapterAssignmentPolicy.create(policyUser, projectResult.data.organization)) { + if ( + !ChapterAssignmentPolicy.create( + policyUser, + projectResult.data.organization, + projectResult.data.id + ) + ) { return c.json( { message: 'Forbidden: You do not have permission to create assignments.' }, HttpStatusCodes.FORBIDDEN @@ -167,7 +173,6 @@ const submitChapterAssignmentRoute = createRoute({ path: '/chapter-assignments/{chapterAssignmentId}/submit', middleware: [ authenticateUser, - requirePermission(PERMISSIONS.CONTENT_UPDATE), requireChapterAssignmentAccess(CHAPTER_ASSIGNMENT_ACTIONS.SUBMIT), ] as const, request: { params: chapterAssignmentIdParam }, @@ -223,7 +228,6 @@ const getChapterAssignmentRoute = createRoute({ path: '/chapter-assignments/{chapterAssignmentId}', middleware: [ authenticateUser, - requirePermission(PERMISSIONS.PROJECT_VIEW), requireChapterAssignmentAccess(CHAPTER_ASSIGNMENT_ACTIONS.READ), ] as const, request: { params: chapterAssignmentIdParam }, diff --git a/src/domains/chapter-assignments/chapter-assignments.service.ts b/src/domains/chapter-assignments/chapter-assignments.service.ts index 6c884951..f3c2e174 100644 --- a/src/domains/chapter-assignments/chapter-assignments.service.ts +++ b/src/domains/chapter-assignments/chapter-assignments.service.ts @@ -43,15 +43,15 @@ export function getChapterAssignment(id: number) { // Fetch a chapter assignment with auth context for middleware policy evaluation. export function getChapterAssignmentWithAuthContext( id: number, - userId: number, - roleName: string + userId: number ): Promise> { - return repo.findByIdWithAuthContext(id, userId, roleName); + return repo.findByIdWithAuthContext(id, userId); } export function getAssignmentsProgress( filters: { projectId?: number; + orgId?: number; assignedUserId?: number; peerCheckerId?: number; status?: ChapterAssignmentStatus; diff --git a/src/domains/chapter-assignments/editor-state/user-chapter-assignment-editor-state.route.ts b/src/domains/chapter-assignments/editor-state/user-chapter-assignment-editor-state.route.ts index 1d2316a9..af5eff85 100644 --- a/src/domains/chapter-assignments/editor-state/user-chapter-assignment-editor-state.route.ts +++ b/src/domains/chapter-assignments/editor-state/user-chapter-assignment-editor-state.route.ts @@ -7,9 +7,8 @@ import { createMessageObjectSchema } from 'stoker/openapi/schemas'; import { insertUserChapterAssignmentEditorStateSchema } from '@/db/schema'; import { requireChapterAssignmentAccess } from '@/domains/chapter-assignments/chapter-assignment-auth.middleware'; import { CHAPTER_ASSIGNMENT_ACTIONS } from '@/domains/chapter-assignments/chapter-assignments.types'; -import { PERMISSIONS } from '@/lib/permissions'; import { getHttpStatus } from '@/lib/types'; -import { authenticateUser, requirePermission } from '@/middlewares/role-auth'; +import { authenticateUser } from '@/middlewares/role-auth'; import { server } from '@/server/server'; import * as editorStateService from './user-chapter-assignment-editor-state.service'; @@ -35,7 +34,6 @@ const getEditorStateRoute = createRoute({ path: '/chapter-assignments/{chapterAssignmentId}/editor-state', middleware: [ authenticateUser, - requirePermission(PERMISSIONS.CONTENT_UPDATE), requireChapterAssignmentAccess(CHAPTER_ASSIGNMENT_ACTIONS.IS_PARTICIPANT), ] as const, request: { params: chapterAssignmentIdParam }, @@ -48,10 +46,6 @@ const getEditorStateRoute = createRoute({ createMessageObjectSchema('Unauthorized'), 'Authentication required' ), - [HttpStatusCodes.FORBIDDEN]: jsonContent( - createMessageObjectSchema('Forbidden'), - 'Access denied' - ), [HttpStatusCodes.NOT_FOUND]: jsonContent( createMessageObjectSchema(HttpStatusPhrases.NOT_FOUND), 'Chapter assignment not found' @@ -83,7 +77,6 @@ const saveEditorStateRoute = createRoute({ path: '/chapter-assignments/{chapterAssignmentId}/editor-state', middleware: [ authenticateUser, - requirePermission(PERMISSIONS.CONTENT_UPDATE), requireChapterAssignmentAccess(CHAPTER_ASSIGNMENT_ACTIONS.IS_PARTICIPANT), ] as const, request: { @@ -105,10 +98,6 @@ const saveEditorStateRoute = createRoute({ createMessageObjectSchema('Unauthorized'), 'Authentication required' ), - [HttpStatusCodes.FORBIDDEN]: jsonContent( - createMessageObjectSchema('Forbidden'), - 'Access denied' - ), [HttpStatusCodes.NOT_FOUND]: jsonContent( createMessageObjectSchema(HttpStatusPhrases.NOT_FOUND), 'Chapter assignment not found' diff --git a/src/domains/chapter-assignments/presence/chapter-assignments-presence.route.ts b/src/domains/chapter-assignments/presence/chapter-assignments-presence.route.ts index b0c0768a..79c64e60 100644 --- a/src/domains/chapter-assignments/presence/chapter-assignments-presence.route.ts +++ b/src/domains/chapter-assignments/presence/chapter-assignments-presence.route.ts @@ -6,9 +6,8 @@ import { createMessageObjectSchema } from 'stoker/openapi/schemas'; import { requireChapterAssignmentAccess } from '@/domains/chapter-assignments/chapter-assignment-auth.middleware'; import { CHAPTER_ASSIGNMENT_ACTIONS } from '@/domains/chapter-assignments/chapter-assignments.types'; -import { PERMISSIONS } from '@/lib/permissions'; import { getHttpStatus } from '@/lib/types'; -import { authenticateUser, requirePermission } from '@/middlewares/role-auth'; +import { authenticateUser } from '@/middlewares/role-auth'; import { server } from '@/server/server'; import * as presenceService from './chapter-assignments-presence.service'; @@ -32,24 +31,21 @@ const registerPresenceRoute = createRoute({ path: '/chapter-assignments/{chapterAssignmentId}/presence', middleware: [ authenticateUser, - requirePermission(PERMISSIONS.CONTENT_UPDATE), + requireChapterAssignmentAccess(CHAPTER_ASSIGNMENT_ACTIONS.IS_PARTICIPANT), ] as const, request: { params: chapterAssignmentIdParam }, responses: { [HttpStatusCodes.OK]: jsonContent(presenceResponseSchema, 'Presence registered successfully'), [HttpStatusCodes.BAD_REQUEST]: jsonContent( - createMessageObjectSchema('Bad Request'), + createMessageObjectSchema(HttpStatusPhrases.BAD_REQUEST), 'Invalid chapter assignment ID' ), [HttpStatusCodes.UNAUTHORIZED]: jsonContent( createMessageObjectSchema('Unauthorized'), 'Authentication required' ), - [HttpStatusCodes.FORBIDDEN]: jsonContent( - createMessageObjectSchema('Forbidden'), - 'Access denied' - ), + [HttpStatusCodes.NOT_FOUND]: jsonContent( createMessageObjectSchema(HttpStatusPhrases.NOT_FOUND), 'Chapter assignment not found' @@ -70,24 +66,21 @@ const removePresenceRoute = createRoute({ path: '/chapter-assignments/{chapterAssignmentId}/presence', middleware: [ authenticateUser, - requirePermission(PERMISSIONS.CONTENT_UPDATE), + requireChapterAssignmentAccess(CHAPTER_ASSIGNMENT_ACTIONS.IS_PARTICIPANT), ] as const, request: { params: chapterAssignmentIdParam }, responses: { [HttpStatusCodes.NO_CONTENT]: { description: 'Presence removed successfully' }, [HttpStatusCodes.BAD_REQUEST]: jsonContent( - createMessageObjectSchema('Bad Request'), + createMessageObjectSchema(HttpStatusPhrases.BAD_REQUEST), 'Invalid chapter assignment ID' ), [HttpStatusCodes.UNAUTHORIZED]: jsonContent( createMessageObjectSchema('Unauthorized'), 'Authentication required' ), - [HttpStatusCodes.FORBIDDEN]: jsonContent( - createMessageObjectSchema('Forbidden'), - 'Access denied' - ), + [HttpStatusCodes.NOT_FOUND]: jsonContent( createMessageObjectSchema(HttpStatusPhrases.NOT_FOUND), 'Chapter assignment not found' diff --git a/src/domains/pericopes/pericopes.test.ts b/src/domains/pericopes/pericopes.test.ts index eb741ff7..668219e9 100644 --- a/src/domains/pericopes/pericopes.test.ts +++ b/src/domains/pericopes/pericopes.test.ts @@ -2,9 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getProjectById } from '@/domains/projects/projects.service'; import { resolveIsProjectMember } from '@/domains/projects/users/project-users.service'; +import { findGrantsByUserId } from '@/domains/user-roles/user-roles.repository'; import { getUserByEmail } from '@/domains/users/users.service'; import { auth } from '@/lib/auth'; -import { roleHasPermission } from '@/lib/services/permissions/permissions.service'; import { err, ErrorCode, ok } from '@/lib/types'; import { server } from '@/server/server'; @@ -21,9 +21,22 @@ vi.mock('@/lib/auth', () => ({ }, })); -vi.mock('@/db', () => ({ - db: { select: vi.fn(), insert: vi.fn(), update: vi.fn() }, -})); +vi.mock('@/db', () => { + const mockQueryBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([]), + returning: vi.fn().mockResolvedValue([]), + }; + return { + db: { + select: vi.fn(() => mockQueryBuilder), + insert: vi.fn(() => mockQueryBuilder), + update: vi.fn(() => mockQueryBuilder), + delete: vi.fn(() => mockQueryBuilder), + }, + }; +}); vi.mock('@/lib/logger', () => ({ logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() }, @@ -33,10 +46,12 @@ vi.mock('@/domains/users/users.service', () => ({ getUserByEmail: vi.fn(), })); -vi.mock('@/lib/services/permissions/permissions.service', () => ({ - roleHasPermission: vi.fn(), +vi.mock('@/domains/user-roles/user-roles.repository', () => ({ + findGrantsByUserId: vi.fn(), })); +// Removed permissions.service mock + vi.mock('@/domains/projects/projects.service', () => ({ getProjectById: vi.fn(), })); @@ -69,14 +84,22 @@ const MOCK_PROJECT = { organization: 1, }; -function asAuthenticatedUser(overrides: Partial = {}, grantedPermission = true) { +function asAuthenticatedUser(overrides: Partial = {}) { const user = { ...APP_USER, ...overrides }; (auth.api.getSession as any).mockResolvedValue({ session: { id: 's1', updatedAt: new Date(), expiresAt: new Date(Date.now() + 1e9) }, user: { email: user.email }, }); (getUserByEmail as any).mockResolvedValue(ok(user)); - (roleHasPermission as any).mockResolvedValue(grantedPermission); + (findGrantsByUserId as any).mockResolvedValue( + ok([ + { + orgId: null, + projectId: null, + permissions: new Set(['project:view']), + }, + ]) + ); } describe('pericopes router & service integrations', () => { @@ -142,15 +165,6 @@ describe('pericopes router & service integrations', () => { expect(repo.getPericopeSetIdForProject).not.toHaveBeenCalled(); }); - it('returns 403 when user lacks PROJECT_VIEW permission', async () => { - asAuthenticatedUser({}, false); - - const res = await server.request('/projects/10/pericopes/JHN/1', { method: 'GET' }); - - expect(res.status).toBe(403); - expect(repo.getPericopeSetIdForProject).not.toHaveBeenCalled(); - }); - it('returns 404 if project is not found', async () => { asAuthenticatedUser(); vi.mocked(getProjectById).mockResolvedValue(err(ErrorCode.PROJECT_NOT_FOUND)); @@ -163,6 +177,15 @@ describe('pericopes router & service integrations', () => { it('returns 404 when project member check fails (forbidden)', async () => { asAuthenticatedUser(); + (findGrantsByUserId as any).mockResolvedValue( + ok([ + { + orgId: 1, + projectId: 999, // User has access to a different project, not 10 + permissions: new Set(['project:view']), + }, + ]) + ); vi.mocked(getProjectById).mockResolvedValue(ok(MOCK_PROJECT as any)); vi.mocked(resolveIsProjectMember).mockResolvedValue(false); diff --git a/src/domains/projects/chapter-assignments/project-chapter-assignments.route.ts b/src/domains/projects/chapter-assignments/project-chapter-assignments.route.ts index 9a891619..521d006c 100644 --- a/src/domains/projects/chapter-assignments/project-chapter-assignments.route.ts +++ b/src/domains/projects/chapter-assignments/project-chapter-assignments.route.ts @@ -107,11 +107,10 @@ server.openapi(deleteProjectChapterAssignmentsRoute, async (c) => { const project = c.get('project')!; const policyUser = { id: currentUser.id, - roleName: currentUser.roleName, - organization: currentUser.organization, + grants: currentUser.grants, }; - if (!ChapterAssignmentPolicy.deleteAll(policyUser, project.organization)) { + if (!ChapterAssignmentPolicy.deleteAll(policyUser, project.organization, project.id)) { return c.json({ message: 'Forbidden' }, HttpStatusCodes.FORBIDDEN); } @@ -213,11 +212,10 @@ server.openapi(assignAllRoute, async (c) => { const project = c.get('project')!; const policyUser = { id: currentUser.id, - roleName: currentUser.roleName, - organization: currentUser.organization, + grants: currentUser.grants, }; - if (!ChapterAssignmentPolicy.assignAll(policyUser, project.organization)) { + if (!ChapterAssignmentPolicy.assignAll(policyUser, project.organization, project.id)) { return c.json({ message: 'Forbidden' }, HttpStatusCodes.FORBIDDEN); } @@ -283,11 +281,10 @@ server.openapi(assignSelectedRoute, async (c) => { const policyUser = { id: currentUser.id, - roleName: currentUser.roleName, - organization: currentUser.organization, + grants: currentUser.grants, }; - if (!ChapterAssignmentPolicy.assignAll(policyUser, project.organization)) { + if (!ChapterAssignmentPolicy.assignAll(policyUser, project.organization, project.id)) { return c.json({ message: 'Forbidden' }, HttpStatusCodes.FORBIDDEN); } diff --git a/src/domains/projects/chapter-assignments/project-chapter-assignments.service.ts b/src/domains/projects/chapter-assignments/project-chapter-assignments.service.ts index 587533ca..c42f0f14 100644 --- a/src/domains/projects/chapter-assignments/project-chapter-assignments.service.ts +++ b/src/domains/projects/chapter-assignments/project-chapter-assignments.service.ts @@ -5,8 +5,8 @@ import { db } from '@/db'; import * as chapterAssignmentService from '@/domains/chapter-assignments/chapter-assignments.service'; import { toChapterAssignmentResponse } from '@/domains/chapter-assignments/chapter-assignments.service'; import * as projectsService from '@/domains/projects/projects.service'; +import { findUserIdsInOrg } from '@/domains/user-roles/user-roles.repository'; import * as userProjectsService from '@/domains/users/projects/user-projects.service'; -import * as usersService from '@/domains/users/users.service'; import { logger } from '@/lib/logger'; import { err, ErrorCode, ok } from '@/lib/types'; @@ -85,10 +85,8 @@ export async function assignAllProjectChapterAssignmentsToUser( (id): id is number => id !== undefined ); - const usersResult = await usersService.getUsersByIds(userIds); - if (!usersResult.ok) return err(ErrorCode.INTERNAL_ERROR); - - const invalidUsers = usersResult.data.some((u) => u.organization !== projectOrgId); + const validUserIds = await findUserIdsInOrg(projectOrgId, userIds); + const invalidUsers = userIds.some((id) => !validUserIds.has(id)); if (invalidUsers) return err(ErrorCode.USER_NOT_IN_ORGANIZATION); } @@ -144,12 +142,7 @@ export async function assignSelectedChapters( if (!projectResult.ok) return err(ErrorCode.PROJECT_NOT_FOUND); const projectOrgId = projectResult.data.organization; - const usersResult = await usersService.getUsersByIds(allUserIds); - if (!usersResult.ok) return err(ErrorCode.INTERNAL_ERROR); - - const validUserIds = new Set( - usersResult.data.filter((u) => u.organization === projectOrgId).map((u) => u.id) - ); + const validUserIds = await findUserIdsInOrg(projectOrgId, allUserIds); const invalidUserIds = allUserIds.filter((id) => !validUserIds.has(id)); @@ -218,9 +211,10 @@ function toMemberChapterAssignmentResponse(record: ChapterAssignmentWithProjectI export async function getChapterAssignmentsByUserId( userId: number, excludeProjectIds: number[] = [], - updatedAfter?: Date + updatedAfter?: Date, + orgId?: number ) { - const projectsResult = await userProjectsService.getProjectsByUserId(userId); + const projectsResult = await userProjectsService.getProjectsByUserId(userId, orgId); if (!projectsResult.ok) return projectsResult; const projectIds = projectsResult.data diff --git a/src/domains/projects/project-auth.middleware.ts b/src/domains/projects/project-auth.middleware.ts index aece17a6..e373d647 100644 --- a/src/domains/projects/project-auth.middleware.ts +++ b/src/domains/projects/project-auth.middleware.ts @@ -16,12 +16,7 @@ import { resolveIsProjectMember } from './users/project-users.service'; export function requireProjectAccess(action: ProjectAction, paramName = 'id') { return createMiddleware(async (c, next) => { const user = c.get('user')!; - const policyUser = { - id: user.id, - role: user.role, - roleName: user.roleName, - organization: user.organization, - }; + const policyUser = { id: user.id, grants: user.grants }; if (action === PROJECT_ACTIONS.LIST) { if (!ProjectPolicy.list(policyUser)) { @@ -46,7 +41,7 @@ export function requireProjectAccess(action: ProjectAction, paramName = 'id') { switch (action) { case PROJECT_ACTIONS.READ: - isProjectMember = await resolveIsProjectMember(projectId, user.id, user.roleName); + isProjectMember = await resolveIsProjectMember(projectId, user.id); allowed = ProjectPolicy.read(policyUser, project, isProjectMember); break; diff --git a/src/domains/projects/project.policy.ts b/src/domains/projects/project.policy.ts index f14831a8..0adbddb0 100644 --- a/src/domains/projects/project.policy.ts +++ b/src/domains/projects/project.policy.ts @@ -9,70 +9,39 @@ import type { AppPolicyUser } from '@/lib/types'; -import { ROLES } from '@/lib/roles'; +import { PERMISSIONS } from '@/lib/permissions'; +import { authorize } from '@/lib/services/permissions/authorize'; import type { ProjectWithLanguageNames } from './projects.types'; -/** - * Internal helper to check if a user has modification rights over a project. - */ -const _canModifyProject = (user: AppPolicyUser, project: ProjectWithLanguageNames): boolean => { - return user.roleName === ROLES.PROJECT_MANAGER && project.organization === user.organization; -}; - export const ProjectPolicy = { - /** - * Can this user list all projects in the standard org route? - * - * Project Manager : yes. - * Translator : no (they will use a separate /users/me/projects endpoint). - */ + /** Can list projects at all? Anyone with project:view in any scope. */ list(user: AppPolicyUser): boolean { - return user.roleName === ROLES.PROJECT_MANAGER; + return user.grants.some((g) => g.permissions.has(PERMISSIONS.PROJECT_VIEW)); }, - /** - * Can this user view this specific project? - * - * Project Manager : yes, if the project belongs to their organisation. - * Translator : yes, if they are assigned to at least one chapter in it. - * - * `isAssignedToProject` is resolved inline in the route handler by checking - * the project_users table. Defaults to false. - */ read( user: AppPolicyUser, project: ProjectWithLanguageNames, isAssignedToProject = false ): boolean { - if (user.roleName === ROLES.PROJECT_MANAGER) { - return project.organization === user.organization; - } - - if (user.roleName === ROLES.TRANSLATOR) { - return isAssignedToProject; - } - - return false; + const scope = { orgId: project.organization, projectId: project.id }; + if (authorize(user, PERMISSIONS.PROJECT_VIEW, scope)) return true; + // Translators with no org/project-wide view still see projects they're assigned to. + return isAssignedToProject; }, - /** - * Can this user update this project? - * - * Project Manager : yes, if the project belongs to their organisation. - * Translator : never. - */ update(user: AppPolicyUser, project: ProjectWithLanguageNames): boolean { - return _canModifyProject(user, project); + return authorize(user, PERMISSIONS.PROJECT_UPDATE, { + orgId: project.organization, + projectId: project.id, + }); }, - /** - * Can this user delete this project? - * - * Project Manager : yes, if the project belongs to their organisation. - * Translator : never. - */ delete(user: AppPolicyUser, project: ProjectWithLanguageNames): boolean { - return _canModifyProject(user, project); + return authorize(user, PERMISSIONS.PROJECT_DELETE, { + orgId: project.organization, + projectId: project.id, + }); }, }; diff --git a/src/domains/projects/projects.repository.ts b/src/domains/projects/projects.repository.ts index 8fe5c3f9..67072d99 100644 --- a/src/domains/projects/projects.repository.ts +++ b/src/domains/projects/projects.repository.ts @@ -1,4 +1,4 @@ -import { and, eq, gt, inArray } from 'drizzle-orm'; +import { and, eq, gt, inArray, isNull, or } from 'drizzle-orm'; import type { DbTransaction, Result } from '@/lib/types'; @@ -9,8 +9,8 @@ import { chapterStatusEnum, project_unit_bible_books, project_units, - project_users, projects, + user_roles, } from '@/db/schema'; import { logger } from '@/lib/logger'; import { err, ErrorCode, ok } from '@/lib/types'; @@ -72,20 +72,68 @@ export async function getByOrganization( } } +export async function getAllProjects(): Promise> { + try { + const rawProjects = await baseJoinQuery(); + return ok(rawProjects.map(mapToProjectWithLanguages)); + } catch (error) { + logger.error({ cause: error, message: 'Failed to get all projects' }); + return err(ErrorCode.INTERNAL_ERROR); + } +} + +export async function findByOrgIdsOrProjectIds( + orgIds: number[], + projectIds: number[] +): Promise> { + if (orgIds.length === 0 && projectIds.length === 0) return ok([]); + try { + const conditions = []; + if (orgIds.length) conditions.push(inArray(projects.organization, orgIds)); + if (projectIds.length) conditions.push(inArray(projects.id, projectIds)); + const rows = await baseJoinQuery().where(or(...conditions)); + return ok(rows.map(mapToProjectWithLanguages)); + } catch (error) { + logger.error({ cause: error, message: 'Failed to find projects for user' }); + return err(ErrorCode.INTERNAL_ERROR); + } +} + export async function getByUserId( userId: number, + orgId?: number, updatedAfter?: Date ): Promise> { try { - const rawProjects = await baseJoinQuery() - .innerJoin(project_users, eq(project_users.projectId, projects.id)) - .where( + let query = baseJoinQuery() + .innerJoin( + user_roles, and( - eq(project_users.userId, userId), - updatedAfter ? gt(projects.updatedAt, updatedAfter) : undefined + eq(user_roles.userId, userId), + or( + eq(user_roles.projectId, projects.id), + and(eq(user_roles.orgId, projects.organization), isNull(user_roles.projectId)) + ) ) - ); - return ok(rawProjects.map(mapToProjectWithLanguages)); + ) + .$dynamic(); + + const conditions = []; + if (orgId !== undefined) conditions.push(eq(projects.organization, orgId)); + if (updatedAfter) conditions.push(gt(projects.updatedAt, updatedAfter)); + + if (conditions.length > 0) { + query = query.where(and(...conditions)); + } + + const rawProjects = await query; + + // Deduplicate: a user with both org-wide + project-pinned grants gets the same project twice + const seen = new Map(); + for (const row of rawProjects) { + if (!seen.has(row.id)) seen.set(row.id, row); + } + return ok([...seen.values()].map(mapToProjectWithLanguages)); } catch (error) { logger.error({ cause: error, diff --git a/src/domains/projects/projects.route.ts b/src/domains/projects/projects.route.ts index c354b4ae..0cc9f319 100644 --- a/src/domains/projects/projects.route.ts +++ b/src/domains/projects/projects.route.ts @@ -7,7 +7,7 @@ import { createMessageObjectSchema } from 'stoker/openapi/schemas'; import { ZOD_ERROR_MESSAGES } from '@/lib/constants'; import { PERMISSIONS } from '@/lib/permissions'; import { getHttpStatus } from '@/lib/types'; -import { authenticateUser, requirePermission } from '@/middlewares/role-auth'; +import { authenticateUser, orgFromBody, requirePermission } from '@/middlewares/role-auth'; import { server } from '@/server/server'; import { requireProjectAccess } from './project-auth.middleware'; @@ -56,8 +56,17 @@ const listProjectsRoute = createRoute({ server.openapi(listProjectsRoute, async (c) => { const currentUser = c.get('user')!; + const activeOrgId = c.get('activeOrgId'); - const result = await projectService.getProjectsByOrganization(currentUser.organization); + const grants = + activeOrgId !== null && activeOrgId !== undefined + ? currentUser.grants.filter((g) => g.orgId === activeOrgId || g.orgId === null) + : currentUser.grants; + + const result = await projectService.getProjectsForUser({ + id: currentUser.id, + grants, + }); if (result.ok) return c.json(result.data, HttpStatusCodes.OK); return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); }); @@ -68,7 +77,10 @@ const createProjectRoute = createRoute({ tags: ['Projects'], method: 'post', path: '/projects', - middleware: [authenticateUser, requirePermission(PERMISSIONS.PROJECT_CREATE)] as const, + middleware: [ + authenticateUser, + requirePermission(PERMISSIONS.PROJECT_CREATE, orgFromBody), + ] as const, summary: 'Create a new project', description: 'Project Manager only.', request: { body: jsonContentRequired(createProjectWithUnitsSchema, 'Project to create') }, @@ -97,13 +109,47 @@ server.openapi(createProjectRoute, async (c) => { const projectData = c.req.valid('json'); const currentUser = c.get('user')!; + const { getRoleId, grantRole } = await import('@/domains/user-roles/user-roles.service'); + const { ROLES } = await import('@/lib/roles'); + + let pmRoleId: number; + try { + pmRoleId = await getRoleId(ROLES.PROJECT_MANAGER); + } catch { + return c.json( + { message: 'Internal Server Error: Missing PM role definition' }, + HttpStatusCodes.INTERNAL_SERVER_ERROR as never + ); + } + const result = await projectService.createProject({ ...projectData, createdBy: currentUser.id, - organization: currentUser.organization, + organization: projectData.organization, }); - if (result.ok) return c.json(result.data, HttpStatusCodes.CREATED); + if (result.ok) { + const grantResult = await grantRole({ + userId: currentUser.id, + orgId: projectData.organization, + projectId: result.data.id, + roleId: pmRoleId, + createdBy: currentUser.id, + }); + if (!grantResult.ok) { + const deleteResult = await projectService.deleteProject(result.data.id); + const rollbackMsg = !deleteResult.ok + ? ` (Rollback failed: ${deleteResult.error.message})` + : ''; + return c.json( + { + message: `Project created but failed to assign creator role. Rolled back.${rollbackMsg}`, + }, + HttpStatusCodes.INTERNAL_SERVER_ERROR as never + ); + } + return c.json(result.data, HttpStatusCodes.CREATED); + } return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); }); diff --git a/src/domains/projects/projects.service.ts b/src/domains/projects/projects.service.ts index c1aaccce..2700b493 100644 --- a/src/domains/projects/projects.service.ts +++ b/src/domains/projects/projects.service.ts @@ -1,11 +1,12 @@ import { eq } from 'drizzle-orm'; -import type { Result } from '@/lib/types'; +import type { AppPolicyUser, Result } from '@/lib/types'; import { db } from '@/db'; import { pericope_sets } from '@/db/schema'; import * as chapterAssignmentsService from '@/domains/chapter-assignments/chapter-assignments.service'; import { logger } from '@/lib/logger'; +import { PERMISSIONS } from '@/lib/permissions'; import { err, ErrorCode, ok } from '@/lib/types'; import type { CreateProjectServiceInput, Project, UpdateProjectInput } from './projects.types'; @@ -16,8 +17,27 @@ export function getProjectsByOrganization(organizationId: number) { return repo.getByOrganization(organizationId); } -export async function getProjectsByUserId(userId: number, updatedAfter?: Date) { - return repo.getByUserId(userId, updatedAfter); +export function getProjectsForUser(user: AppPolicyUser) { + // Global view grant (SuperAdmin) fetches all projects + const hasGlobalView = user.grants.some( + (g) => g.orgId === null && g.projectId === null && g.permissions.has(PERMISSIONS.PROJECT_VIEW) + ); + if (hasGlobalView) { + return repo.getAllProjects(); + } + + const orgIds = new Set(); + const projectIds = new Set(); + for (const g of user.grants) { + if (!g.permissions.has(PERMISSIONS.PROJECT_VIEW)) continue; + if (g.projectId !== null) projectIds.add(g.projectId); + else if (g.orgId !== null) orgIds.add(g.orgId); + } + return repo.findByOrgIdsOrProjectIds([...orgIds], [...projectIds]); +} + +export async function getProjectsByUserId(userId: number, orgId?: number, updatedAfter?: Date) { + return repo.getByUserId(userId, orgId, updatedAfter); } export function getProjectById(id: number) { diff --git a/src/domains/projects/projects.types.ts b/src/domains/projects/projects.types.ts index 92b6cbc1..8dbe05c9 100644 --- a/src/domains/projects/projects.types.ts +++ b/src/domains/projects/projects.types.ts @@ -47,7 +47,7 @@ export const createProjectWithUnitsSchema = insertProjectsSchema projectUnitStatus: z.enum(['not_started', 'in_progress', 'completed']).default('not_started'), // Made optional for frontend backwards compatibility. // The route will overwrite these with secure auth context anyway. - organization: z.number().int().optional(), + organization: z.number().int(), createdBy: z.number().int().optional(), }); diff --git a/src/domains/projects/users/project-users.repository.ts b/src/domains/projects/users/project-users.repository.ts index d328bd78..81bb1987 100644 --- a/src/domains/projects/users/project-users.repository.ts +++ b/src/domains/projects/users/project-users.repository.ts @@ -1,9 +1,11 @@ -import { and, eq, or } from 'drizzle-orm'; +import { and, eq, isNull, or } from 'drizzle-orm'; import type { Result } from '@/lib/types'; import { db } from '@/db'; -import { chapter_assignments, project_units, project_users, users } from '@/db/schema'; +import { chapter_assignments, project_units, projects, user_roles, users } from '@/db/schema'; +import { findUserIdsInOrg } from '@/domains/user-roles/user-roles.repository'; +import { getRoleId } from '@/domains/user-roles/user-roles.service'; import { handleConstraintError } from '@/lib/db-errors'; import { logger } from '@/lib/logger'; import { ROLES } from '@/lib/roles'; @@ -15,20 +17,41 @@ import type { ProjectUserRecord } from './project-users.types'; export async function getProjectUsers(projectId: number): Promise> { try { + const [project] = await db + .select({ organization: projects.organization }) + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + if (!project) return err(ErrorCode.PROJECT_NOT_FOUND); + const rows = await db .select({ - projectId: project_users.projectId, - userId: project_users.userId, + projectId: user_roles.projectId, + userId: user_roles.userId, displayName: users.username, - roleID: users.role, - createdAt: project_users.createdAt, + roleID: user_roles.roleId, + createdAt: user_roles.createdAt, }) - .from(project_users) - .innerJoin(users, eq(project_users.userId, users.id)) - .where(eq(project_users.projectId, projectId)) + .from(user_roles) + .innerJoin(users, eq(user_roles.userId, users.id)) + .where(eq(user_roles.projectId, projectId)) .orderBy(users.username); - return ok(rows); + // Deduplicate by userId, keeping the grant with the highest privilege (lowest roleID) + const uniqueUsers = new Map(); + for (const r of rows) { + const existing = uniqueUsers.get(r.userId); + if (!existing || r.roleID < existing.roleID) { + uniqueUsers.set(r.userId, r); + } + } + + // Map `projectId: null` to `projectId` for the UI. + const projectUsers = Array.from(uniqueUsers.values()).map((r) => ({ + ...r, + projectId: r.projectId ?? projectId, + })); + return ok(projectUsers as any); } catch (error) { logger.error({ cause: error, @@ -42,20 +65,46 @@ export async function getProjectUsers(projectId: number): Promise> { +): Promise< + Result<{ projectId: number; userId: number; roleId: number; createdAt: Date | null }[]> +> { if (userIds.length === 0) return ok([]); try { + const [project] = await db + .select({ organization: projects.organization }) + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + if (!project) return err(ErrorCode.PROJECT_NOT_FOUND); + + // Verify all userIds belong to the project's organization to prevent cross-tenant membership leaks + const memberIds = await findUserIdsInOrg(project.organization, userIds); + const nonMember = userIds.find((id) => !memberIds.has(id)); + if (nonMember) { + return err(ErrorCode.USER_NOT_FOUND); + } + + const ptId = await getRoleId(ROLES.PROJECT_TRANSLATOR); + const inserted = await db - .insert(project_users) - .values(userIds.map((userId) => ({ projectId, userId }))) + .insert(user_roles) + .values( + userIds.map((userId) => ({ + projectId, + userId, + orgId: project.organization, + roleId: ptId, + })) + ) .onConflictDoNothing() .returning({ - projectId: project_users.projectId, - userId: project_users.userId, - createdAt: project_users.createdAt, + projectId: user_roles.projectId, + userId: user_roles.userId, + roleId: user_roles.roleId, + createdAt: user_roles.createdAt, }); - return ok(inserted); + return ok(inserted as any); } catch (error) { const constraintResult = handleConstraintError(error); if (!constraintResult.ok && constraintResult.error.code === ErrorCode.DUPLICATE) { @@ -90,9 +139,9 @@ export async function removeProjectUser(projectId: number, userId: number): Prom if (assignedContent) return err(ErrorCode.USER_HAS_ASSIGNED_CONTENT); const deleted = await db - .delete(project_users) - .where(and(eq(project_users.projectId, projectId), eq(project_users.userId, userId))) - .returning({ userId: project_users.userId }); + .delete(user_roles) + .where(and(eq(user_roles.projectId, projectId), eq(user_roles.userId, userId))) + .returning({ userId: user_roles.userId }); if (deleted.length === 0) return err(ErrorCode.USER_NOT_IN_PROJECT); @@ -107,18 +156,25 @@ export async function removeProjectUser(projectId: number, userId: number): Prom } } -export async function resolveIsProjectMember( - projectId: number, - userId: number, - roleName: string -): Promise { - if (roleName !== ROLES.TRANSLATOR) return false; - - const [member] = await db - .select({ projectId: project_users.projectId }) - .from(project_users) - .where(and(eq(project_users.projectId, projectId), eq(project_users.userId, userId))) +export async function resolveIsProjectMember(projectId: number, userId: number): Promise { + const [pinned] = await db + .select({ id: user_roles.id }) + .from(user_roles) + .where(and(eq(user_roles.userId, userId), eq(user_roles.projectId, projectId))) .limit(1); - - return member !== undefined; + if (pinned) return true; + + const rows = await db + .select({ id: user_roles.id }) + .from(user_roles) + .innerJoin(projects, eq(projects.id, projectId)) + .where( + and( + eq(user_roles.userId, userId), + eq(user_roles.orgId, projects.organization), + isNull(user_roles.projectId) + ) + ) + .limit(1); + return rows.length > 0; } diff --git a/src/domains/projects/users/project-users.service.ts b/src/domains/projects/users/project-users.service.ts index f1a41cfb..b3d02064 100644 --- a/src/domains/projects/users/project-users.service.ts +++ b/src/domains/projects/users/project-users.service.ts @@ -24,7 +24,7 @@ export async function addProjectUsers(projectId: number, userIds: number[]) { insertResult.data.map((row) => ({ ...row, displayName: userMap.get(row.userId)!.username, - roleID: userMap.get(row.userId)!.role, + roleID: row.roleId, })) ); } @@ -33,6 +33,6 @@ export function removeProjectUser(projectId: number, userId: number) { return repo.removeProjectUser(projectId, userId); } -export function resolveIsProjectMember(projectId: number, userId: number, roleName: string) { - return repo.resolveIsProjectMember(projectId, userId, roleName); +export function resolveIsProjectMember(projectId: number, userId: number) { + return repo.resolveIsProjectMember(projectId, userId); } diff --git a/src/domains/self/settings/self-settings.route.test.ts b/src/domains/self/settings/self-settings.route.test.ts index 6a95c93e..03d54923 100644 --- a/src/domains/self/settings/self-settings.route.test.ts +++ b/src/domains/self/settings/self-settings.route.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { findGrantsByUserId } from '@/domains/user-roles/user-roles.repository'; import { getUserByEmail } from '@/domains/users/users.service'; import { auth } from '@/lib/auth'; -import { roleHasPermission } from '@/lib/services/permissions/permissions.service'; import { server } from '@/server/server'; import type { UserSettingsRow } from './self-settings.repository'; @@ -18,9 +18,22 @@ vi.mock('@/lib/auth', () => ({ }, })); -vi.mock('@/db', () => ({ - db: { select: vi.fn(), insert: vi.fn(), update: vi.fn() }, -})); +vi.mock('@/db', () => { + const mockQueryBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([]), + returning: vi.fn().mockResolvedValue([]), + }; + return { + db: { + select: vi.fn(() => mockQueryBuilder), + insert: vi.fn(() => mockQueryBuilder), + update: vi.fn(() => mockQueryBuilder), + delete: vi.fn(() => mockQueryBuilder), + }, + }; +}); vi.mock('@/lib/logger', () => ({ logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() }, @@ -30,10 +43,12 @@ vi.mock('@/domains/users/users.service', () => ({ getUserByEmail: vi.fn(), })); -vi.mock('@/lib/services/permissions/permissions.service', () => ({ - roleHasPermission: vi.fn(), +vi.mock('@/domains/user-roles/user-roles.repository', () => ({ + findGrantsByUserId: vi.fn(), })); +// Removed permissions.service mock + // In-memory repository so the service logic (full-replace, `.catch({})` // normalization, toResponse, user isolation) is genuinely exercised. Tests can // also seed `store` directly to simulate a malformed/legacy stored blob that @@ -79,7 +94,7 @@ function authenticateAs(user: typeof USER_A) { user: { email: user.email }, }); (getUserByEmail as any).mockResolvedValue({ ok: true, data: user }); - (roleHasPermission as any).mockResolvedValue(true); + (findGrantsByUserId as any).mockResolvedValue({ ok: true, data: [] }); } function getSettings() { diff --git a/src/domains/translated-verses/translated-verse-auth.middleware.ts b/src/domains/translated-verses/translated-verse-auth.middleware.ts index 5e9e27b2..97f7b382 100644 --- a/src/domains/translated-verses/translated-verse-auth.middleware.ts +++ b/src/domains/translated-verses/translated-verse-auth.middleware.ts @@ -31,9 +31,7 @@ export function requireTranslatedVerseAccess( const user = c.get('user')!; const policyUser = { id: user.id, - role: user.role, - roleName: user.roleName, - organization: user.organization, + grants: user.grants, }; let projectUnitId: number | undefined; @@ -74,11 +72,7 @@ export function requireTranslatedVerseAccess( return c.json({ message: 'Translated verse not found' }, HttpStatusCodes.NOT_FOUND); } - const isProjectMember = await resolveIsProjectMember( - unitResult.data.projectId, - user.id, - user.roleName - ); + const isProjectMember = await resolveIsProjectMember(unitResult.data.projectId, user.id); if (!ProjectPolicy.read(policyUser, projectResult.data, isProjectMember)) { return c.json({ message: 'Translated verse not found' }, HttpStatusCodes.NOT_FOUND); @@ -103,7 +97,7 @@ export function requireTranslatedVerseAccess( const unitResult = await projectService.getProjectIdByUnitId(projectUnitId); const isProjectMember = unitResult.ok - ? await resolveIsProjectMember(unitResult.data.projectId, user.id, user.roleName) + ? await resolveIsProjectMember(unitResult.data.projectId, user.id) : false; if (!ChapterAssignmentPolicy.edit(policyUser, assignmentResult.data, isProjectMember)) { diff --git a/src/domains/user-roles/user-roles.repository.test.ts b/src/domains/user-roles/user-roles.repository.test.ts new file mode 100644 index 00000000..d2970870 --- /dev/null +++ b/src/domains/user-roles/user-roles.repository.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { groupGrantRows } from './user-roles.repository'; + +describe('groupGrantRows', () => { + it('groups permission rows by (orgId, projectId)', () => { + const rows = [ + { orgId: 1, projectId: null, permission: 'project:view' }, + { orgId: 1, projectId: null, permission: 'project:create' }, + { orgId: 1, projectId: 10, permission: 'content:update' }, + { orgId: null, projectId: null, permission: 'project:delete' }, + ]; + const grants = groupGrantRows(rows); + expect(grants).toHaveLength(3); + const orgWide = grants.find((g) => g.orgId === 1 && g.projectId === null)!; + expect([...orgWide.permissions].sort()).toEqual(['project:create', 'project:view']); + const pinned = grants.find((g) => g.projectId === 10)!; + expect([...pinned.permissions]).toEqual(['content:update']); + const global = grants.find((g) => g.orgId === null && g.projectId === null)!; + expect([...global.permissions]).toEqual(['project:delete']); + }); +}); diff --git a/src/domains/user-roles/user-roles.repository.ts b/src/domains/user-roles/user-roles.repository.ts new file mode 100644 index 00000000..974c7fe0 --- /dev/null +++ b/src/domains/user-roles/user-roles.repository.ts @@ -0,0 +1,144 @@ +import { and, eq, inArray } from 'drizzle-orm'; + +import type { Permission } from '@/lib/permissions'; +import type { Grant, Result } from '@/lib/types'; + +import { db } from '@/db'; +import { organizations, permissions, role_permissions, roles, user_roles } from '@/db/schema'; +import { logger } from '@/lib/logger'; +import { err, ErrorCode, ok } from '@/lib/types'; + +export interface GrantRow { + orgId: number | null; + projectId: number | null; + permission: string; +} + +const key = (orgId: number | null, projectId: number | null): string => + `${orgId ?? 'null'}:${projectId ?? 'null'}`; + +/** Pure: fold flat (scope, permission) rows into one Grant per distinct scope. */ +export function groupGrantRows(rows: GrantRow[]): Grant[] { + const byScope = new Map< + string, + { orgId: number | null; projectId: number | null; permissions: Set } + >(); + for (const row of rows) { + const k = key(row.orgId, row.projectId); + let entry = byScope.get(k); + if (!entry) { + entry = { orgId: row.orgId, projectId: row.projectId, permissions: new Set() }; + byScope.set(k, entry); + } + entry.permissions.add(row.permission as Permission); + } + return [...byScope.values()]; +} + +export async function findGrantsByUserId(userId: number): Promise> { + try { + const rows = await db + .select({ + orgId: user_roles.orgId, + projectId: user_roles.projectId, + permission: permissions.name, + }) + .from(user_roles) + .innerJoin(role_permissions, eq(role_permissions.roleId, user_roles.roleId)) + .innerJoin(permissions, eq(permissions.id, role_permissions.permissionId)) + .where(eq(user_roles.userId, userId)); + + logger.debug({ userId, rowsCount: rows.length, rows }, 'DB rows for findGrantsByUserId'); + return ok(groupGrantRows(rows)); + } catch (error) { + logger.error({ cause: error, message: 'Failed to load grants', context: { userId } }); + return err(ErrorCode.INTERNAL_ERROR); + } +} + +export async function findOrgIdsForUser(userId: number): Promise { + const rows = await db + .selectDistinct({ orgId: user_roles.orgId }) + .from(user_roles) + .where(eq(user_roles.userId, userId)); + return rows.map((r) => r.orgId).filter((x): x is number => x !== null); +} + +export async function findUserIdsInOrg(orgId: number, userIds: number[]): Promise> { + if (userIds.length === 0) return new Set(); + const rows = await db + .selectDistinct({ userId: user_roles.userId }) + .from(user_roles) + .where(and(eq(user_roles.orgId, orgId), inArray(user_roles.userId, userIds))); + return new Set(rows.map((r) => r.userId)); +} + +export async function findRoleGrantsByUserIds( + userIds: number[], + filterOrgIds: number[] | 'ALL' +): Promise< + Map< + number, + Array<{ + roleId: number; + roleName: string; + orgId: number | null; + projectId: number | null; + orgName: string | null; + }> + > +> { + if (userIds.length === 0) return new Map(); + + const conditions = [inArray(user_roles.userId, userIds)]; + if (filterOrgIds !== 'ALL') { + if (filterOrgIds.length === 0) return new Map(); + conditions.push(inArray(user_roles.orgId, filterOrgIds)); + } + + const rows = await db + .select({ + userId: user_roles.userId, + orgId: user_roles.orgId, + projectId: user_roles.projectId, + roleId: roles.id, + roleName: roles.name, + orgName: organizations.name, + }) + .from(user_roles) + .innerJoin(roles, eq(roles.id, user_roles.roleId)) + .leftJoin(organizations, eq(organizations.id, user_roles.orgId)) + .where(and(...conditions)); + + const map = new Map< + number, + Array<{ + roleId: number; + roleName: string; + orgId: number | null; + projectId: number | null; + orgName: string | null; + }> + >(); + for (const row of rows) { + if (!map.has(row.userId)) map.set(row.userId, []); + map.get(row.userId)!.push({ + roleId: row.roleId, + roleName: row.roleName, + orgId: row.orgId, + projectId: row.projectId, + orgName: row.orgName, + }); + } + + logger.debug( + { + userIds, + filterOrgIds, + result: Array.from(map.entries()), + }, + 'findRoleGrantsByUserIds returning map' + ); + + return map; +} diff --git a/src/domains/user-roles/user-roles.service.ts b/src/domains/user-roles/user-roles.service.ts new file mode 100644 index 00000000..f5b82841 --- /dev/null +++ b/src/domains/user-roles/user-roles.service.ts @@ -0,0 +1,76 @@ +import { and, eq, isNull } from 'drizzle-orm'; + +import type { Result } from '@/lib/types'; + +import { db } from '@/db'; +import { roles, user_roles } from '@/db/schema'; +import { handleConstraintError } from '@/lib/db-errors'; +import { logger } from '@/lib/logger'; +import { err, ErrorCode, ok } from '@/lib/types'; + +export interface GrantInput { + userId: number; + orgId: number | null; + projectId: number | null; + roleId: number; + createdBy?: number | null; +} + +export async function grantRole(input: GrantInput): Promise> { + try { + // Pre-check existence to avoid bumping the ID sequence unnecessarily on duplicates. + const [existing] = await db + .select({ id: user_roles.id }) + .from(user_roles) + .where( + and( + eq(user_roles.userId, input.userId), + input.orgId === null ? isNull(user_roles.orgId) : eq(user_roles.orgId, input.orgId), + input.projectId === null + ? isNull(user_roles.projectId) + : eq(user_roles.projectId, input.projectId), + eq(user_roles.roleId, input.roleId) + ) + ) + .limit(1); + + if (existing) return ok(undefined); // Already granted, nothing to do + + await db.insert(user_roles).values(input).onConflictDoNothing(); + return ok(undefined); + } catch (error) { + return handleConstraintError(error); + } +} + +/** Revoke a single (user, org, project, role) grant. Null scope values match NULL columns. */ +export async function revokeRole(input: GrantInput): Promise> { + try { + await db + .delete(user_roles) + .where( + and( + eq(user_roles.userId, input.userId), + input.orgId === null ? isNull(user_roles.orgId) : eq(user_roles.orgId, input.orgId), + input.projectId === null + ? isNull(user_roles.projectId) + : eq(user_roles.projectId, input.projectId), + eq(user_roles.roleId, input.roleId) + ) + ); + return ok(undefined); + } catch (error) { + logger.error({ cause: error, message: 'Failed to revoke role', context: input }); + return err(ErrorCode.INTERNAL_ERROR); + } +} + +const roleIdCache = new Map(); +export async function getRoleId(name: string): Promise { + const cached = roleIdCache.get(name); + if (cached) return cached; + const [row] = await db.select({ id: roles.id }).from(roles).where(eq(roles.name, name)).limit(1); + if (!row) throw new Error(`Role not found: ${name}`); + roleIdCache.set(name, row.id); + return row.id; +} diff --git a/src/domains/users/chapter-assignments/users-chapter-assignments.route.ts b/src/domains/users/chapter-assignments/users-chapter-assignments.route.ts index 8bfd734a..30e6cdb3 100644 --- a/src/domains/users/chapter-assignments/users-chapter-assignments.route.ts +++ b/src/domains/users/chapter-assignments/users-chapter-assignments.route.ts @@ -60,8 +60,12 @@ const getChapterAssignmentsByUserIdRoute = createRoute({ server.openapi(getChapterAssignmentsByUserIdRoute, async (c) => { const { userId } = c.req.valid('param'); + const activeOrgId = c.get('activeOrgId'); - const result = await usersChapterAssignmentsService.getAllChapterAssignmentsByUserId(userId); + const result = await usersChapterAssignmentsService.getAllChapterAssignmentsByUserId( + userId, + activeOrgId ?? undefined + ); if (result.ok) return c.json(result.data, HttpStatusCodes.OK); return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); }); @@ -134,11 +138,13 @@ const getUserChapterAssignmentsRoute = createRoute({ server.openapi(getUserChapterAssignmentsRoute, async (c) => { const { userId } = c.req.valid('param'); const { excludeProjectIds, updatedAfter } = c.req.valid('query'); + const activeOrgId = c.get('activeOrgId'); const result = await service.getChapterAssignmentsByUserId( userId, excludeProjectIds, - updatedAfter + updatedAfter, + activeOrgId ?? undefined ); if (result.ok) return c.json( diff --git a/src/domains/users/chapter-assignments/users-chapter-assignments.service.ts b/src/domains/users/chapter-assignments/users-chapter-assignments.service.ts index 4e23e46c..682b0f7c 100644 --- a/src/domains/users/chapter-assignments/users-chapter-assignments.service.ts +++ b/src/domains/users/chapter-assignments/users-chapter-assignments.service.ts @@ -39,31 +39,38 @@ export function toResponse( } export async function getAssignedChaptersByUserId( - userId: number + userId: number, + orgId?: number ): Promise> { - const result = await chapterAssignmentService.getAssignmentsProgress({ assignedUserId: userId }); + const result = await chapterAssignmentService.getAssignmentsProgress({ + assignedUserId: userId, + orgId, + }); if (!result.ok) return result; return ok(result.data.map(toResponse)); } export async function getPeerCheckChaptersByUserId( - userId: number + userId: number, + orgId?: number ): Promise> { const result = await chapterAssignmentService.getAssignmentsProgress({ peerCheckerId: userId, status: 'peer_check', + orgId, }); if (!result.ok) return result; return ok(result.data.map(toResponse)); } export async function getAllChapterAssignmentsByUserId( - userId: number + userId: number, + orgId?: number ): Promise> { try { const [assignedResult, peerCheckResult] = await Promise.all([ - getAssignedChaptersByUserId(userId), - getPeerCheckChaptersByUserId(userId), + getAssignedChaptersByUserId(userId, orgId), + getPeerCheckChaptersByUserId(userId, orgId), ]); if (!assignedResult.ok) return assignedResult; diff --git a/src/domains/users/projects/user-projects.route.ts b/src/domains/users/projects/user-projects.route.ts index c1e250e0..4b97d511 100644 --- a/src/domains/users/projects/user-projects.route.ts +++ b/src/domains/users/projects/user-projects.route.ts @@ -53,8 +53,9 @@ const getUserProjectsRoute = createRoute({ server.openapi(getUserProjectsRoute, async (c) => { const { userId } = c.req.valid('param'); + const activeOrgId = c.get('activeOrgId'); - const result = await userProjectsService.getProjectsByUserId(userId); + const result = await userProjectsService.getProjectsByUserId(userId, activeOrgId ?? undefined); if (result.ok) return c.json(result.data, HttpStatusCodes.OK); return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); diff --git a/src/domains/users/projects/user-projects.service.ts b/src/domains/users/projects/user-projects.service.ts index b2b441d8..f7b48d88 100644 --- a/src/domains/users/projects/user-projects.service.ts +++ b/src/domains/users/projects/user-projects.service.ts @@ -9,8 +9,8 @@ export function toUserProjectResponse(project: ProjectWithLanguageNames): UserPr } // ─── Service functions ──────────────────────────────────────────────────────── -export async function getProjectsByUserId(userId: number) { - const result = await projectsService.getProjectsByUserId(userId); +export async function getProjectsByUserId(userId: number, orgId?: number) { + const result = await projectsService.getProjectsByUserId(userId, orgId); if (!result.ok) return result; return ok(result.data.map(toUserProjectResponse)); } diff --git a/src/domains/users/user-auth.middleware.ts b/src/domains/users/user-auth.middleware.ts index d5b12be6..5462a093 100644 --- a/src/domains/users/user-auth.middleware.ts +++ b/src/domains/users/user-auth.middleware.ts @@ -3,6 +3,7 @@ import * as HttpStatusCodes from 'stoker/http-status-codes'; import type { AppEnv } from '@/server/context.types'; +import { findOrgIdsForUser } from '@/domains/user-roles/user-roles.repository'; import { getHttpStatus } from '@/lib/types'; import type { UserAction } from './users.types'; @@ -15,7 +16,7 @@ import { USER_ACTIONS } from './users.types'; export function requireUserAccess(action: UserAction, paramName = 'id') { return createMiddleware(async (c, next) => { const user = c.get('user')!; - const policyUser = { id: user.id, roleName: user.roleName, organization: user.organization }; + const policyUser = { id: user.id, grants: user.grants }; if (action === USER_ACTIONS.LIST) { if (!UserPolicy.list(policyUser)) { @@ -25,8 +26,25 @@ export function requireUserAccess(action: UserAction, paramName = 'id') { } if (action === USER_ACTIONS.CREATE) { - if (!UserPolicy.create(policyUser)) { - return c.json({ message: 'Forbidden' }, HttpStatusCodes.FORBIDDEN); + const body = await c.req.json().catch(() => ({})); + const orgId = Number(body?.orgId ?? body?.organization); + const projectId = + body?.projectId !== undefined && body?.projectId !== null ? Number(body.projectId) : null; + + const { ROLES } = await import('@/lib/roles'); + const { canAssignRole } = await import('@/lib/services/permissions/authorize'); + + const targetRoleName = body?.roleName || ROLES.PROJECT_TRANSLATOR; + + if (!Number.isFinite(orgId)) { + return c.json({ message: 'Missing organization ID' }, HttpStatusCodes.BAD_REQUEST); + } + + if (!canAssignRole(policyUser, targetRoleName, orgId, projectId)) { + return c.json( + { message: 'Forbidden: Insufficient privileges to assign this role.' }, + HttpStatusCodes.FORBIDDEN + ); } return next(); } @@ -42,19 +60,22 @@ export function requireUserAccess(action: UserAction, paramName = 'id') { } const targetUser = result.data; + const targetOrgIds = await findOrgIdsForUser(targetUserId); + const policyTarget = { id: targetUser.id, orgIds: targetOrgIds }; + let allowed = false; switch (action) { case USER_ACTIONS.VIEW: - allowed = UserPolicy.view(policyUser, targetUser); + allowed = UserPolicy.view(policyUser, policyTarget); break; case USER_ACTIONS.UPDATE: - allowed = UserPolicy.update(policyUser, targetUser); + allowed = UserPolicy.update(policyUser, policyTarget); break; case USER_ACTIONS.DELETE: - allowed = UserPolicy.delete(policyUser, targetUser); + allowed = UserPolicy.delete(policyUser, policyTarget); break; } diff --git a/src/domains/users/user.policy.ts b/src/domains/users/user.policy.ts index ff431364..2f7428d1 100644 --- a/src/domains/users/user.policy.ts +++ b/src/domains/users/user.policy.ts @@ -1,83 +1,36 @@ import type { AppPolicyUser } from '@/lib/types'; -import { ROLES } from '@/lib/roles'; +import { PERMISSIONS } from '@/lib/permissions'; +import { authorize } from '@/lib/services/permissions/authorize'; -interface PolicyTargetUser { +export interface PolicyTargetUser { id: number; - organization: number; + orgIds: number[]; } export const UserPolicy = { - /** - * Can this user list all users in the organization? - * - * Manager : yes. - * Translator : no (they can only view themselves via their specific ID). - */ list(user: AppPolicyUser): boolean { - return user.roleName === ROLES.PROJECT_MANAGER; + return user.grants.some((g) => g.permissions.has(PERMISSIONS.USER_VIEW)); }, - /** - * Can this user view the target user's profile? - * - * Manager : yes, if the target is in the same organisation. - * Translator : yes, only if the target is themselves. - */ - view(user: AppPolicyUser, targetUser: PolicyTargetUser): boolean { - if (user.roleName === ROLES.PROJECT_MANAGER) { - return user.organization === targetUser.organization; - } - - if (user.roleName === ROLES.TRANSLATOR) { - return user.id === targetUser.id; - } - - return false; - }, - - /** - * Can this user create a new user? - * - * Manager : yes — requirePermission already confirmed user:create. - * Translator : never reaches here, blocked by requirePermission. - */ create(user: AppPolicyUser): boolean { - return user.roleName === ROLES.PROJECT_MANAGER; + return user.grants.some((g) => g.permissions.has(PERMISSIONS.USER_CREATE)); }, - /** - * Can this user update the target user's profile? - * - * Manager : yes, if the target is in the same organisation. - * Translator : yes, only if the target is themselves. - * - * Note: role and organisation fields are stripped from updates - * for non-managers in the handler before this is called. - */ - update(user: AppPolicyUser, targetUser: PolicyTargetUser): boolean { - if (user.roleName === ROLES.PROJECT_MANAGER) { - return user.organization === targetUser.organization; - } - - if (user.roleName === ROLES.TRANSLATOR) { - return user.id === targetUser.id; - } - - return false; + view(user: AppPolicyUser, target: PolicyTargetUser): boolean { + if (user.id === target.id) return true; + if (authorize(user, PERMISSIONS.USER_VIEW, { orgId: null })) return true; + return target.orgIds.some((orgId) => authorize(user, PERMISSIONS.USER_VIEW, { orgId })); }, - /** - * Can this user delete a user? - * - * Manager : yes, if the target is in the same organisation. - * Translator : never reaches here, blocked by requirePermission. - */ - delete(user: AppPolicyUser, targetUser: PolicyTargetUser): boolean { - if (user.roleName === ROLES.PROJECT_MANAGER) { - return user.organization === targetUser.organization; - } + update(user: AppPolicyUser, target: PolicyTargetUser): boolean { + if (user.id === target.id) return true; + if (authorize(user, PERMISSIONS.USER_UPDATE, { orgId: null })) return true; + return target.orgIds.some((orgId) => authorize(user, PERMISSIONS.USER_UPDATE, { orgId })); + }, - return false; + delete(user: AppPolicyUser, target: PolicyTargetUser): boolean { + // Full account deletion is SuperAdmin-only (no role is seeded user:delete). Deferred otherwise. + return target.orgIds.some((orgId) => authorize(user, PERMISSIONS.USER_DELETE, { orgId })); }, }; diff --git a/src/domains/users/users.repository.ts b/src/domains/users/users.repository.ts index 5957ff90..0e8bdae3 100644 --- a/src/domains/users/users.repository.ts +++ b/src/domains/users/users.repository.ts @@ -3,7 +3,7 @@ import { eq, inArray, or } from 'drizzle-orm'; import type { Result } from '@/lib/types'; import { db } from '@/db'; -import { roles, users } from '@/db/schema'; +import { user_roles, users } from '@/db/schema'; import { handleConstraintError } from '@/lib/db-errors'; import { logger } from '@/lib/logger'; import { err, ErrorCode, ok } from '@/lib/types'; @@ -19,14 +19,20 @@ export async function findAll(): Promise> { } } -export async function findByOrganization(organization: number): Promise> { +export async function findByOrganizations(orgIds: number[]): Promise> { + if (orgIds.length === 0) return ok([]); try { - return ok(await db.select().from(users).where(eq(users.organization, organization))); + const rows = await db + .selectDistinct({ user: users }) + .from(users) + .innerJoin(user_roles, eq(users.id, user_roles.userId)) + .where(inArray(user_roles.orgId, orgIds)); + return ok(rows.map((r) => r.user)); } catch (error) { logger.error({ cause: error, - message: 'Failed to find users by organization', - context: { organization }, + message: 'Failed to find users by organizations', + context: { orgIds }, }); return err(ErrorCode.INTERNAL_ERROR); } @@ -54,17 +60,16 @@ export async function findByIds(ids: number[]): Promise> { } } -export async function findByEmail(email: string): Promise> { +export async function findByEmail(email: string): Promise> { try { const [result] = await db - .select({ user: users, roleName: roles.name }) + .select() .from(users) - .innerJoin(roles, eq(users.role, roles.id)) .where(eq(users.email, email.toLowerCase())) .limit(1); if (!result) return err(ErrorCode.USER_NOT_FOUND); - return ok({ ...result.user, roleName: result.roleName }); + return ok(result); } catch (error) { logger.error({ cause: error, message: 'Failed to find user by email', context: { email } }); return err(ErrorCode.INTERNAL_ERROR); @@ -108,9 +113,19 @@ export async function findByEmailOrUsername(identifier: string): Promise> { try { + // Strip grant-specific fields and legacy fields that don't belong on the users table + const { + orgId: _orgId, + projectId: _projectId, + roleName: _roleName, + role: _role, + organization: _org, + grants: _grants, + ...userData + } = input as any; const [user] = await db .insert(users) - .values({ ...input, email: input.email.toLowerCase() }) + .values({ ...userData, email: input.email.toLowerCase() }) .returning(); if (!user) return err(ErrorCode.INTERNAL_ERROR); return ok(user); @@ -121,7 +136,11 @@ export async function insert(input: CreateUserInput): Promise> { export async function update(id: number, input: UpdateUserInput): Promise> { try { - const updateInput = input.email ? { ...input, email: input.email.toLowerCase() } : input; + // Strip legacy fields that don't belong on the users table + const { role: _role, organization: _org, ...cleanInput } = input as any; + const updateInput = cleanInput.email + ? { ...cleanInput, email: cleanInput.email.toLowerCase() } + : cleanInput; const [updated] = await db.update(users).set(updateInput).where(eq(users.id, id)).returning(); if (!updated) return err(ErrorCode.USER_NOT_FOUND); return ok(updated); diff --git a/src/domains/users/users.route.ts b/src/domains/users/users.route.ts index f0ca8a63..a31eb53b 100644 --- a/src/domains/users/users.route.ts +++ b/src/domains/users/users.route.ts @@ -1,15 +1,19 @@ import { createRoute, z } from '@hono/zod-openapi'; +import { eq } from 'drizzle-orm'; 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 { db } from '@/db'; +import * as schema from '@/db/schema'; +import { findOrgIdsForUser } from '@/domains/user-roles/user-roles.repository'; import { ZOD_ERROR_CODES, ZOD_ERROR_MESSAGES } from '@/lib/constants'; import { PERMISSIONS } from '@/lib/permissions'; -import { ROLES } from '@/lib/roles'; import { createUserWithInvitation } from '@/lib/services/auth/auth.service'; +import { authorize } from '@/lib/services/permissions/authorize'; import { ErrorCode, ErrorMessages, getHttpStatus } from '@/lib/types'; -import { authenticateUser, requirePermission } from '@/middlewares/role-auth'; +import { authenticateUser, orgFromBody, requirePermission } from '@/middlewares/role-auth'; import { server } from '@/server/server'; import { requireUserAccess } from './user-auth.middleware'; @@ -17,6 +21,7 @@ import { UserPolicy } from './user.policy'; import * as userService from './users.service'; import { createUserRequestSchema, + updateActiveOrgRequestSchema, updateUserRequestSchema, USER_ACTIONS, userResponseSchema, @@ -58,7 +63,10 @@ const listUsersRoute = createRoute({ server.openapi(listUsersRoute, async (c) => { const currentUser = c.get('user')!; - const result = await userService.getUsersByOrganization(currentUser.organization); + const result = await userService.getUsersForUser({ + id: currentUser.id, + grants: currentUser.grants, + }); if (result.ok) { return c.json(result.data, HttpStatusCodes.OK); } @@ -74,7 +82,7 @@ const createUserRoute = createRoute({ path: '/users', middleware: [ authenticateUser, - requirePermission(PERMISSIONS.USER_CREATE), + requirePermission(PERMISSIONS.USER_CREATE, orgFromBody), requireUserAccess(USER_ACTIONS.CREATE), ] as const, request: { @@ -110,6 +118,10 @@ const createUserRoute = createRoute({ }), 'The validation error' ), + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent( + createMessageObjectSchema('Internal Server Error'), + 'Internal server error' + ), }, summary: 'Create a new user', description: 'Creates a new user with the provided data. Project Manager only.', @@ -119,18 +131,65 @@ server.openapi(createUserRoute, async (c) => { const requestData = c.req.valid('json'); const currentUser = c.get('user')!; - // Safely map the API request schema into the DB-bound input type - const userData = { - ...requestData, - organization: currentUser.organization, - }; + const result = await userService.createUser(requestData); + if (!result.ok) { + return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); + } - const result = await userService.createUser(userData); - if (result.ok) { - return c.json(result.data, HttpStatusCodes.CREATED); + // Grant the new user their initial role via user_roles + if (requestData.orgId) { + try { + const { grantRole, getRoleId } = await import('@/domains/user-roles/user-roles.service'); + const { ROLES } = await import('@/lib/roles'); + const roleName = requestData.roleName || ROLES.PROJECT_TRANSLATOR; + + const isProjectRole = + roleName === ROLES.PROJECT_MANAGER || + roleName === ROLES.PROJECT_TRANSLATOR || + roleName === ROLES.PROJECT_OBSERVER; + + if (isProjectRole && !requestData.projectId) { + await userService.deleteUser(result.data.id); + return c.json( + { message: `${roleName} role requires a specific projectId.` }, + HttpStatusCodes.BAD_REQUEST + ); + } + + const roleId = await getRoleId(roleName); + const grantResult = await grantRole({ + userId: result.data.id, + orgId: requestData.orgId, + projectId: requestData.projectId ?? null, + roleId, + createdBy: currentUser.id, + }); + if (!grantResult.ok) { + const deleteResult = await userService.deleteUser(result.data.id); + const rollbackMsg = !deleteResult.ok + ? ` (Rollback failed: ${deleteResult.error.message})` + : ''; + return c.json( + { + message: `Failed to create initial role grant: ${grantResult.error.message}${rollbackMsg}`, + }, + HttpStatusCodes.INTERNAL_SERVER_ERROR + ); + } + } catch (error) { + const deleteResult = await userService.deleteUser(result.data.id); + const rollbackMsg = !deleteResult.ok + ? ` (Rollback failed: ${deleteResult.error.message})` + : ''; + const errorMessage = error instanceof Error ? error.message : 'Grant failed'; + return c.json( + { message: `Failed to create initial role grant: ${errorMessage}${rollbackMsg}` }, + HttpStatusCodes.INTERNAL_SERVER_ERROR + ); + } } - return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); + return c.json(result.data, HttpStatusCodes.CREATED); }); // ─── POST /users/invite ─────────────────────────────────────────────────────── @@ -141,7 +200,7 @@ const createUserWithInvitationRoute = createRoute({ path: '/users/invite', middleware: [ authenticateUser, - requirePermission(PERMISSIONS.USER_CREATE), + requirePermission(PERMISSIONS.USER_CREATE, orgFromBody), requireUserAccess(USER_ACTIONS.CREATE), ] as const, request: { @@ -187,14 +246,8 @@ const createUserWithInvitationRoute = createRoute({ server.openapi(createUserWithInvitationRoute, async (c) => { const requestData = c.req.valid('json'); - const currentUser = c.get('user')!; - const userData = { - ...requestData, - organization: currentUser.organization, - }; - - const result = await createUserWithInvitation(userData, c.req.raw.headers); + const result = await createUserWithInvitation(requestData, c.req.raw.headers); if (result.ok) { return c.json(result.data, HttpStatusCodes.CREATED); } @@ -209,7 +262,7 @@ const getUserByEmailRoute = createRoute({ tags: ['Users'], method: 'get', path: '/users/email/{email}', - middleware: [authenticateUser, requirePermission(PERMISSIONS.USER_VIEW)] as const, + middleware: [authenticateUser] as const, request: { params: z.object({ email: z @@ -231,10 +284,6 @@ const getUserByEmailRoute = createRoute({ createMessageObjectSchema('Unauthorized'), 'Authentication required' ), - [HttpStatusCodes.FORBIDDEN]: jsonContent( - createMessageObjectSchema('Forbidden'), - 'Insufficient permissions' - ), }, summary: 'Get a user by email', description: 'Managers: any user in their org. Translators: themselves only.', @@ -245,8 +294,7 @@ server.openapi(getUserByEmailRoute, async (c) => { const currentUser = c.get('user')!; const policyUser = { id: currentUser.id, - roleName: currentUser.roleName, - organization: currentUser.organization, + grants: currentUser.grants, }; const result = await userService.getUserByEmail(email.toLowerCase()); @@ -255,13 +303,23 @@ server.openapi(getUserByEmailRoute, async (c) => { return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); } - const { roleName: _roleName, ...targetUser } = result.data; + const targetUser = result.data; + const targetOrgIds = await findOrgIdsForUser(targetUser.id); // Returning 404 instead of 403 to prevent email enumeration across orgs - if (!UserPolicy.view(policyUser, targetUser)) { + if (!UserPolicy.view(policyUser, { id: targetUser.id, orgIds: targetOrgIds })) { return c.json({ message: ErrorMessages[ErrorCode.USER_NOT_FOUND] }, HttpStatusCodes.NOT_FOUND); } + // When the caller is fetching their own profile, inject the session-scoped + // activeOrgId so the frontend uses the same org the backend is filtering by. + if (targetUser.id === currentUser.id) { + const sessionActiveOrgId = c.get('activeOrgId') as number | null; + if (sessionActiveOrgId != null) { + targetUser.lastActiveOrgId = sessionActiveOrgId; + } + } + return c.json(targetUser, HttpStatusCodes.OK); }); @@ -271,11 +329,7 @@ const getUserRoute = createRoute({ tags: ['Users'], method: 'get', path: '/users/{id}', - middleware: [ - authenticateUser, - requirePermission(PERMISSIONS.USER_VIEW), - requireUserAccess(USER_ACTIONS.VIEW), - ] as const, + middleware: [authenticateUser, requireUserAccess(USER_ACTIONS.VIEW)] as const, request: { params: z.object({ id: z.coerce.number().openapi({ @@ -294,10 +348,6 @@ const getUserRoute = createRoute({ createMessageObjectSchema('Unauthorized'), 'Authentication required' ), - [HttpStatusCodes.FORBIDDEN]: jsonContent( - createMessageObjectSchema('Forbidden'), - 'Insufficient permissions' - ), }, summary: 'Get a user by ID', description: 'Managers: any user in their org. Translators: themselves only.', @@ -314,11 +364,7 @@ const updateUserRoute = createRoute({ tags: ['Users'], method: 'patch', path: '/users/{id}', - middleware: [ - authenticateUser, - requirePermission(PERMISSIONS.USER_UPDATE), - requireUserAccess(USER_ACTIONS.UPDATE), - ] as const, + middleware: [authenticateUser, requireUserAccess(USER_ACTIONS.UPDATE)] as const, request: { params: z.object({ id: z.coerce.number().openapi({ @@ -346,10 +392,6 @@ const updateUserRoute = createRoute({ createMessageObjectSchema('Unauthorized'), 'Authentication required' ), - [HttpStatusCodes.FORBIDDEN]: jsonContent( - createMessageObjectSchema('Forbidden'), - 'Insufficient permissions' - ), [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent( z.object({ success: z.boolean(), @@ -371,6 +413,7 @@ server.openapi(updateUserRoute, async (c) => { const { id } = c.req.valid('param'); const updates = c.req.valid('json'); const currentUser = c.get('user')!; + const targetUser = c.get('targetUser')!; if (Object.keys(updates).length === 0) { return c.json( @@ -391,7 +434,15 @@ server.openapi(updateUserRoute, async (c) => { ); } - if (currentUser.roleName === ROLES.TRANSLATOR) { + // Strip role update if user lacks MEMBERSHIP_REVOKE + const targetOrgIds = await findOrgIdsForUser(targetUser.id); + const hasGrantManagement = targetOrgIds.some((orgId) => + authorize({ id: currentUser.id, grants: currentUser.grants }, PERMISSIONS.MEMBERSHIP_REVOKE, { + orgId, + }) + ); + + if (!hasGrantManagement) { delete (updates as Record).role; } @@ -455,3 +506,55 @@ server.openapi(deleteUserRoute, async (c) => { return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); }); + +// ─── PATCH /users/me/active-org ─────────────────────────────────────────────── + +const updateActiveOrgRoute = createRoute({ + tags: ['Users'], + method: 'patch', + path: '/users/me/active-org', + middleware: [authenticateUser] as const, + request: { + body: jsonContent(updateActiveOrgRequestSchema, 'The active org to set'), + }, + responses: { + [HttpStatusCodes.OK]: { + description: 'Active org updated', + }, + [HttpStatusCodes.FORBIDDEN]: jsonContent( + createMessageObjectSchema('Forbidden'), + 'User does not belong to this organization' + ), + }, + summary: 'Update active organization', + description: 'Sets the active organization for the current session and updates the user default.', +}); + +server.openapi(updateActiveOrgRoute, async (c) => { + const { orgId } = c.req.valid('json'); + const currentUser = c.get('user')!; + const session = c.get('session')!; + + // Verify the user actually belongs to this org + const belongsToOrg = currentUser.grants.some((g) => g.orgId === orgId); + if (!belongsToOrg) { + return c.json( + { message: 'User does not belong to this organization' }, + HttpStatusCodes.FORBIDDEN + ); + } + + // Update session + await db + .update(schema.authSession) + .set({ activeOrgId: orgId }) + .where(eq(schema.authSession.id, session.session.id)); + + // Update user default + await db + .update(schema.users) + .set({ lastActiveOrgId: orgId }) + .where(eq(schema.users.id, currentUser.id)); + + return c.body(null, HttpStatusCodes.OK); +}); diff --git a/src/domains/users/users.service.test.ts b/src/domains/users/users.service.test.ts index 4db7d091..3bc88c8a 100644 --- a/src/domains/users/users.service.test.ts +++ b/src/domains/users/users.service.test.ts @@ -12,7 +12,6 @@ import { getUserByEmailOrUsername, getUserById, getUserByUsername, - getUsersByOrganization, toUserResponse, updateUser, } from './users.service'; @@ -20,6 +19,7 @@ import { vi.mock('@/db', () => ({ db: { select: vi.fn(), + selectDistinct: vi.fn(), insert: vi.fn(), update: vi.fn(), delete: vi.fn(), @@ -35,6 +35,10 @@ vi.mock('@/lib/logger', () => ({ }, })); +vi.mock('@/domains/user-roles/user-roles.repository', () => ({ + findRoleGrantsByUserIds: vi.fn().mockResolvedValue(new Map()), +})); + describe('user Service Functions', () => { const mockUser = sampleUsers.user1; const mockUserInput = sampleUsers.newUser; @@ -68,36 +72,6 @@ describe('user Service Functions', () => { }); }); - describe('getUsersByOrganization', () => { - it('should return users by organization mapped to response shape', async () => { - const mockUsers = [mockUser]; - (db.select as any).mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue(mockUsers), - }), - }); - - const result = await getUsersByOrganization(1); - - expect(result).toEqual({ ok: true, data: mockUsers.map(toUserResponse) }); - }); - - it('should return an error result if db call throws', async () => { - (db.select as any).mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockRejectedValue(new Error('DB error')), - }), - }); - - const result = await getUsersByOrganization(999); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toBe(ErrorMessages.INTERNAL_ERROR); - } - }); - }); - describe('getUserById', () => { it('should return user by ID mapped to response shape', async () => { (db.select as any).mockReturnValue({ @@ -108,7 +82,7 @@ describe('user Service Functions', () => { const result = await getUserById(mockUser.id); - expect(result).toEqual({ ok: true, data: toUserResponse(mockUser) }); + expect(result).toEqual({ ok: true, data: { ...toUserResponse(mockUser), orgGrants: [] } }); }); it('should return an error result when user not found', async () => { @@ -128,13 +102,11 @@ describe('user Service Functions', () => { }); describe('getUserByEmail', () => { - it('should return user by email with roleName mapped to response shape', async () => { + it('should return user by email mapped to response shape', async () => { (db.select as any).mockReturnValue({ from: vi.fn().mockReturnValue({ - innerJoin: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([{ user: mockUser, roleName: 'Manager' }]), - }), + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([mockUser]), }), }), }); @@ -143,17 +115,17 @@ describe('user Service Functions', () => { expect(result).toEqual({ ok: true, - data: { ...toUserResponse(mockUser), roleName: 'Manager' }, + data: { ...toUserResponse(mockUser), orgGrants: [] }, }); }); it('should convert email to lowercase before querying', async () => { const whereMock = vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([{ user: mockUser, roleName: 'Manager' }]), + limit: vi.fn().mockResolvedValue([mockUser]), }); (db.select as any).mockReturnValue({ from: vi.fn().mockReturnValue({ - innerJoin: vi.fn().mockReturnValue({ where: whereMock }), + where: whereMock, }), }); @@ -164,9 +136,7 @@ describe('user Service Functions', () => { it('should return an error result when user not found', async () => { (db.select as any).mockReturnValue({ from: vi.fn().mockReturnValue({ - innerJoin: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), - }), + where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), }), }); @@ -189,7 +159,7 @@ describe('user Service Functions', () => { const result = await getUserByUsername(mockUser.username); - expect(result).toEqual({ ok: true, data: toUserResponse(mockUser) }); + expect(result).toEqual({ ok: true, data: { ...toUserResponse(mockUser), orgGrants: [] } }); }); it('should return an error result when user not found', async () => { @@ -218,7 +188,7 @@ describe('user Service Functions', () => { const result = await getUserByEmailOrUsername(mockUser.email); - expect(result).toEqual({ ok: true, data: toUserResponse(mockUser) }); + expect(result).toEqual({ ok: true, data: { ...toUserResponse(mockUser), orgGrants: [] } }); }); it('should convert identifier to lowercase for email lookups', async () => { @@ -276,8 +246,13 @@ describe('user Service Functions', () => { await createUser(inputWithUppercaseEmail); expect(valuesMock).toHaveBeenCalledWith({ - ...inputWithUppercaseEmail, + username: 'newuser', email: 'newuser@example.com', + firstName: 'John', + lastName: 'Doe', + createdBy: null, + status: 'invited', + lastActiveOrgId: null, }); }); diff --git a/src/domains/users/users.service.ts b/src/domains/users/users.service.ts index 753af94d..41385a06 100644 --- a/src/domains/users/users.service.ts +++ b/src/domains/users/users.service.ts @@ -1,5 +1,8 @@ -import type { Result } from '@/lib/types'; +import type { AppPolicyUser, Result } from '@/lib/types'; +import { findRoleGrantsByUserIds } from '@/domains/user-roles/user-roles.repository'; +import { logger } from '@/lib/logger'; +import { PERMISSIONS } from '@/lib/permissions'; import { ok } from '@/lib/types'; import type { @@ -21,10 +24,9 @@ export function toUserResponse(user: User): UserResponse { username: user.username, firstName: user.firstName, lastName: user.lastName, - role: user.role, createdBy: user.createdBy, - organization: user.organization, status: user.status, + lastActiveOrgId: user.lastActiveOrgId, createdAt: user.createdAt, updatedAt: user.updatedAt, }; @@ -32,50 +34,105 @@ export function toUserResponse(user: User): UserResponse { // ─── Reads ──────────────────────────────────────────────────────────────────── +async function attachGrants(user: User): Promise { + const response = toUserResponse(user); + const grantsMap = await findRoleGrantsByUserIds([user.id], 'ALL'); + response.orgGrants = grantsMap.get(user.id) ?? []; + + logger.debug( + { userId: user.id, orgGrants: response.orgGrants }, + 'Attached grants for user in attachGrants' + ); + + return response; +} + export async function getAllUsers(): Promise> { const result = await repo.findAll(); if (!result.ok) return result; return ok(result.data.map(toUserResponse)); } -export async function getUsersByOrganization( - organization: number -): Promise> { - const result = await repo.findByOrganization(organization); - if (!result.ok) return result; - return ok(result.data.map(toUserResponse)); +export async function getUsersForUser(user: AppPolicyUser): Promise> { + // Global view grant (SuperAdmin) fetches all users + const hasGlobalView = user.grants.some( + (g) => g.orgId === null && g.projectId === null && g.permissions.has(PERMISSIONS.USER_VIEW) + ); + + let orgIdsArray: number[] = []; + let userRows: User[] = []; + + if (hasGlobalView) { + const result = await repo.findAll(); + if (!result.ok) return result; + userRows = result.data; + } else { + const orgIds = new Set(); + for (const g of user.grants) { + if (g.permissions.has(PERMISSIONS.USER_VIEW) && g.orgId !== null) { + orgIds.add(g.orgId); + } + } + orgIdsArray = [...orgIds]; + const result = await repo.findByOrganizations(orgIdsArray); + if (!result.ok) return result; + userRows = result.data; + } + + const userIds = userRows.map((u) => u.id); + const filter = hasGlobalView ? 'ALL' : orgIdsArray; + const grantsMap = await findRoleGrantsByUserIds(userIds, filter); + + logger.debug( + { callerId: user.id, count: userRows.length }, + 'Returning users with grants in getUsersForUser' + ); + + return ok( + userRows.map((u) => { + const response = toUserResponse(u); + response.orgGrants = grantsMap.get(u.id) ?? []; + return response; + }) + ); } export async function getUserById(id: number): Promise> { const result = await repo.findById(id); if (!result.ok) return result; - return ok(toUserResponse(result.data)); + return ok(await attachGrants(result.data)); } export async function getUsersByIds(ids: number[]): Promise> { const result = await repo.findByIds(ids); if (!result.ok) return result; - return ok(result.data.map(toUserResponse)); + + const grantsMap = await findRoleGrantsByUserIds(ids, 'ALL'); + return ok( + result.data.map((u) => { + const response = toUserResponse(u); + response.orgGrants = grantsMap.get(u.id) ?? []; + return response; + }) + ); } -export async function getUserByEmail( - email: string -): Promise> { +export async function getUserByEmail(email: string): Promise> { const result = await repo.findByEmail(email); if (!result.ok) return result; - return ok({ ...toUserResponse(result.data), roleName: result.data.roleName }); + return ok(await attachGrants(result.data)); } export async function getUserByUsername(username: string): Promise> { const result = await repo.findByUsername(username); if (!result.ok) return result; - return ok(toUserResponse(result.data)); + return ok(await attachGrants(result.data)); } export async function getUserByEmailOrUsername(identifier: string): Promise> { const result = await repo.findByEmailOrUsername(identifier); if (!result.ok) return result; - return ok(toUserResponse(result.data)); + return ok(await attachGrants(result.data)); } // ─── Writes ─────────────────────────────────────────────────────────────────── diff --git a/src/domains/users/users.types.ts b/src/domains/users/users.types.ts index 1655e3e6..a3af0bfd 100644 --- a/src/domains/users/users.types.ts +++ b/src/domains/users/users.types.ts @@ -10,8 +10,8 @@ export type UpdateUserInput = z.infer; export type CreateUserWithAuthInput = CreateUserInput & { authUserId: string }; -// Used by findByEmail — joined with roles table to support policy checks -export type UserWithRole = User & { roleName: string }; +// Used by findByEmail — dropped roleName +export type UserWithRole = User; // ─── API response schema ────────────────────────────────────────────────────── @@ -21,12 +21,23 @@ export const userResponseSchema = z.object({ username: z.string(), firstName: z.string().nullable(), lastName: z.string().nullable(), - role: z.number().int(), - organization: z.number().int(), + createdBy: z.number().int().nullable(), status: z.enum(['invited', 'verified', 'inactive']), createdAt: z.date().nullable(), updatedAt: z.date().nullable(), + orgGrants: z + .array( + z.object({ + roleId: z.number().int(), + roleName: z.string(), + orgId: z.number().int().nullable(), + projectId: z.number().int().nullable(), + orgName: z.string().nullable().optional(), + }) + ) + .optional(), + lastActiveOrgId: z.number().int().nullable().optional(), }); export type UserResponse = z.infer; @@ -36,10 +47,11 @@ export const createUserRequestSchema = z.object({ email: z.string().email().max(255), firstName: z.string().max(100).optional(), lastName: z.string().max(100).optional(), - role: z.number().int(), - // Note: 'organization' is intentionally omitted here so clients cannot spoof it. - // It will be injected by the route handler. status: z.enum(['invited', 'verified', 'inactive']).default('invited'), + // Grant fields — where and what role to assign the new user + orgId: z.number().int(), + projectId: z.number().int().optional().nullable(), + roleName: z.string().optional(), }); export const updateUserRequestSchema = z.object({ @@ -47,11 +59,16 @@ export const updateUserRequestSchema = z.object({ email: z.string().email().max(255).optional(), firstName: z.string().max(100).optional().nullable(), lastName: z.string().max(100).optional().nullable(), - role: z.number().int().optional(), + status: z.enum(['invited', 'verified', 'inactive']).optional(), + lastActiveOrgId: z.number().int().nullable().optional(), // 'organization' is omitted to prevent cross-tenant transfers. }); +export const updateActiveOrgRequestSchema = z.object({ + orgId: z.number().int(), +}); + // Const enumerations export const USER_ACTIONS = { diff --git a/src/domains/usfm/usfm-auth.middleware.ts b/src/domains/usfm/usfm-auth.middleware.ts index 17f49ac7..c223736e 100644 --- a/src/domains/usfm/usfm-auth.middleware.ts +++ b/src/domains/usfm/usfm-auth.middleware.ts @@ -22,9 +22,7 @@ export function requireProjectUnitAccess(paramName = 'projectUnitId') { const user = c.get('user')!; const policyUser = { id: user.id, - role: user.role, - roleName: user.roleName, - organization: user.organization, + grants: user.grants, }; const projectUnitId = Number(c.req.param(paramName)); @@ -42,11 +40,7 @@ export function requireProjectUnitAccess(paramName = 'projectUnitId') { return c.json({ message: 'Project not found' }, HttpStatusCodes.NOT_FOUND); } - const isProjectMember = await resolveIsProjectMember( - unitResult.data.projectId, - user.id, - user.roleName - ); + const isProjectMember = await resolveIsProjectMember(unitResult.data.projectId, user.id); if (!ProjectPolicy.read(policyUser, projectResult.data, isProjectMember)) { return c.json({ message: 'Project not found' }, HttpStatusCodes.NOT_FOUND); diff --git a/src/domains/usfm/usfm.route.ts b/src/domains/usfm/usfm.route.ts index 7feae0f7..fbb41795 100644 --- a/src/domains/usfm/usfm.route.ts +++ b/src/domains/usfm/usfm.route.ts @@ -6,6 +6,9 @@ import { createMessageObjectSchema } from 'stoker/openapi/schemas'; import type { USFMExportJob } from '@/lib/queue'; +import { ProjectPolicy } from '@/domains/projects/project.policy'; +import * as projectService from '@/domains/projects/projects.service'; +import { resolveIsProjectMember } from '@/domains/projects/users/project-users.service'; import { fileExists, getExportFile } from '@/lib/file-storage'; import { logger } from '@/lib/logger'; import { getQueue, QUEUE_NAMES } from '@/lib/queue'; @@ -418,6 +421,27 @@ server.openapi(getJobStatusRoute, async (c) => { ); } + const user = c.get('user')!; + const projectUnitId = (job.data as Record)?.projectUnitId; + if (projectUnitId) { + const unitResult = await projectService.getProjectIdByUnitId(projectUnitId); + if (!unitResult.ok) { + return c.json({ error: 'Job not found' }, HttpStatusCodes.NOT_FOUND); + } + + const projectResult = await projectService.getProjectById(unitResult.data.projectId); + if (!projectResult.ok) { + return c.json({ error: 'Job not found' }, HttpStatusCodes.NOT_FOUND); + } + + const isProjectMember = await resolveIsProjectMember(unitResult.data.projectId, user.id); + const policyUser = { id: user.id, grants: user.grants }; + + if (!ProjectPolicy.read(policyUser, projectResult.data, isProjectMember)) { + return c.json({ error: 'Job not found' }, HttpStatusCodes.NOT_FOUND); + } + } + return c.json( { id: job.id, diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts index b450d107..2d118ec5 100644 --- a/src/lib/permissions.ts +++ b/src/lib/permissions.ts @@ -21,9 +21,15 @@ export const PERMISSIONS = { PROJECT_DELETE: 'project:delete', // ── Content ───────────────────────────────────────────────────────── + CONTENT_VIEW: 'content:view', CONTENT_ASSIGN: 'content:assign', CONTENT_UPDATE: 'content:update', + // ── Membership / role assignment ──────────────────────────────────── + MEMBERSHIP_REVOKE: 'membership:revoke', + ROLE_ASSIGN_PROJECT: 'role:assign:project', + ROLE_ASSIGN_ORG_MANAGER: 'role:assign:org_manager', + // ── AI tools ──────────────────────────────────────────────────────── // Intentional alias of CONTENT_UPDATE (same string value) so "can invoke // AI tools" is documented separately at call sites without yet being a diff --git a/src/lib/roles.ts b/src/lib/roles.ts index 19b36f68..1845d3af 100644 --- a/src/lib/roles.ts +++ b/src/lib/roles.ts @@ -1,6 +1,10 @@ export const ROLES = { - PROJECT_MANAGER: 'Manager', - TRANSLATOR: 'Translator', + SUPER_ADMIN: 'SuperAdmin', + ORG_OWNER: 'Org Owner', + ORG_MANAGER: 'Org Manager', + PROJECT_MANAGER: 'Project Manager', + PROJECT_TRANSLATOR: 'Project Translator', + PROJECT_OBSERVER: 'Project Observer', } as const; export type RoleName = (typeof ROLES)[keyof typeof ROLES]; diff --git a/src/lib/services/auth/auth.service.ts b/src/lib/services/auth/auth.service.ts index 4a733281..dbf5f7d8 100644 --- a/src/lib/services/auth/auth.service.ts +++ b/src/lib/services/auth/auth.service.ts @@ -6,21 +6,30 @@ import type { Result } from '@/lib/types'; import { db } from '@/db'; import * as schema from '@/db/schema'; +import { getRoleId, grantRole } from '@/domains/user-roles/user-roles.service'; import { createUserWithAuth, deleteUser } from '@/domains/users/users.service'; import env from '@/env'; import { auth } from '@/lib/auth'; +import { ROLES } from '@/lib/roles'; import { ErrorCode } from '@/lib/types'; export interface UserInvitationResult { user: UserResponse; } +/** Extended input that carries the grant fields for the new user's initial role. */ +export interface InviteUserInput extends CreateUserInput { + orgId: number; + projectId?: number | null; + roleName?: string; +} + /** - * Creates a user in the local database and sends a BetterAuth magic link invitation. - * Uses direct DB insertion for identity and forwards headers to Magic Link API. + * Creates a user in the local database, grants them an initial role, and sends + * a BetterAuth magic link invitation. */ export async function createUserWithInvitation( - input: CreateUserInput, + input: InviteUserInput, headers: Headers ): Promise> { const normalizedInput = { ...input, email: input.email.toLowerCase() }; @@ -60,8 +69,67 @@ export async function createUserWithInvitation( return { ok: false, error: dbResult.error }; } + // 3. Grant the new user their initial role via user_roles + try { + const roleName = normalizedInput.roleName || ROLES.PROJECT_TRANSLATOR; + + if ( + !normalizedInput.projectId && + [ROLES.PROJECT_TRANSLATOR, ROLES.PROJECT_OBSERVER].includes(roleName as any) + ) { + await db.delete(schema.users).where(eq(schema.users.id, dbResult.data.id)); + await db.delete(schema.authUser).where(eq(schema.authUser.id, authUserId)); + return { + ok: false, + error: { + code: ErrorCode.VALIDATION_ERROR, + message: `${roleName} role requires a specific projectId.`, + }, + }; + } + + const roleId = await getRoleId(roleName); + + const scopedOrgId = roleName === ROLES.SUPER_ADMIN ? null : normalizedInput.orgId; + const scopedProjectId = + roleName === ROLES.SUPER_ADMIN ? null : (normalizedInput.projectId ?? null); + + const grantResult = await grantRole({ + userId: dbResult.data.id, + orgId: scopedOrgId, + projectId: scopedProjectId, + roleId, + createdBy: dbResult.data.createdBy ?? null, + }); + + if (!grantResult.ok) { + // Rollback both local user and auth identity + await db.delete(schema.users).where(eq(schema.users.id, dbResult.data.id)); + await db.delete(schema.authUser).where(eq(schema.authUser.id, authUserId)); + return { + ok: false, + error: { + code: ErrorCode.INTERNAL_ERROR, + message: `Failed to create initial role grant: ${grantResult.error.message}`, + }, + }; + } + } catch (error) { + // Rollback both local user and auth identity + await db.delete(schema.users).where(eq(schema.users.id, dbResult.data.id)); + await db.delete(schema.authUser).where(eq(schema.authUser.id, authUserId)); + const errorMessage = error instanceof Error ? error.message : 'Grant failed'; + return { + ok: false, + error: { + code: ErrorCode.INTERNAL_ERROR, + message: `Failed to create initial role grant: ${errorMessage}`, + }, + }; + } + try { - // 3. Send magic link invitation via BetterAuth. + // 4. Send magic link invitation via BetterAuth. type AuthAPI = typeof auth; await (auth as AuthAPI).api.signInMagicLink({ body: { diff --git a/src/lib/services/permissions/authorize.test.ts b/src/lib/services/permissions/authorize.test.ts new file mode 100644 index 00000000..f8d06a9f --- /dev/null +++ b/src/lib/services/permissions/authorize.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest'; + +import type { Grant } from '@/lib/types'; + +import { PERMISSIONS } from '@/lib/permissions'; +import { ROLES } from '@/lib/roles'; + +import { authorize, canAssignRole, collectPermissions } from './authorize'; + +const grant = (orgId: number | null, projectId: number | null, perms: string[]): Grant => ({ + orgId, + projectId, + permissions: new Set(perms) as ReadonlySet, +}); + +describe('authorize', () => { + const ORG = 1; + const OTHER_ORG = 2; + const PROJ = 10; + const OTHER_PROJ = 11; + + it('superAdmin (global grant) passes any scope', () => { + const user = { id: 1, grants: [grant(null, null, [PERMISSIONS.PROJECT_DELETE])] }; + expect(authorize(user, PERMISSIONS.PROJECT_DELETE, { orgId: ORG, projectId: PROJ })).toBe(true); + expect(authorize(user, PERMISSIONS.PROJECT_DELETE, { orgId: OTHER_ORG })).toBe(true); + }); + + it('org-wide PM grant applies to any project in that org', () => { + const user = { id: 1, grants: [grant(ORG, null, [PERMISSIONS.PROJECT_UPDATE])] }; + expect(authorize(user, PERMISSIONS.PROJECT_UPDATE, { orgId: ORG, projectId: PROJ })).toBe(true); + expect(authorize(user, PERMISSIONS.PROJECT_UPDATE, { orgId: ORG, projectId: OTHER_PROJ })).toBe( + true + ); + }); + + it('project-pinned grant does NOT apply to a sibling project', () => { + const user = { id: 1, grants: [grant(ORG, PROJ, [PERMISSIONS.PROJECT_UPDATE])] }; + expect(authorize(user, PERMISSIONS.PROJECT_UPDATE, { orgId: ORG, projectId: PROJ })).toBe(true); + expect(authorize(user, PERMISSIONS.PROJECT_UPDATE, { orgId: ORG, projectId: OTHER_PROJ })).toBe( + false + ); + }); + + it('project-pinned PM grant applies to org-scoped action (create project)', () => { + const user = { id: 1, grants: [grant(ORG, PROJ, [PERMISSIONS.PROJECT_CREATE])] }; + expect(authorize(user, PERMISSIONS.PROJECT_CREATE, { orgId: ORG })).toBe(true); + }); + + it('translator grant lacking project:create is denied an org-scoped create', () => { + const user = { id: 1, grants: [grant(ORG, PROJ, [PERMISSIONS.CONTENT_UPDATE])] }; + expect(authorize(user, PERMISSIONS.PROJECT_CREATE, { orgId: ORG })).toBe(false); + }); + + it('grants in a different org never apply', () => { + const user = { id: 1, grants: [grant(OTHER_ORG, null, [PERMISSIONS.PROJECT_UPDATE])] }; + expect(authorize(user, PERMISSIONS.PROJECT_UPDATE, { orgId: ORG, projectId: PROJ })).toBe( + false + ); + }); + + it('collectPermissions unions across applicable grants', () => { + const user = { + id: 1, + grants: [ + grant(ORG, null, [PERMISSIONS.PROJECT_VIEW]), + grant(ORG, PROJ, [PERMISSIONS.CONTENT_UPDATE]), + grant(OTHER_ORG, null, [PERMISSIONS.PROJECT_DELETE]), + ], + }; + const perms = collectPermissions(user.grants, { orgId: ORG, projectId: PROJ }); + expect(perms.has(PERMISSIONS.PROJECT_VIEW)).toBe(true); + expect(perms.has(PERMISSIONS.CONTENT_UPDATE)).toBe(true); + expect(perms.has(PERMISSIONS.PROJECT_DELETE)).toBe(false); + }); + + it('multi-org user grants do not cross-pollinate', () => { + const user = { + id: 1, + grants: [ + grant(ORG, null, [PERMISSIONS.PROJECT_CREATE]), // Manager in ORG + grant(OTHER_ORG, OTHER_PROJ, [PERMISSIONS.CONTENT_UPDATE]), // Translator in OTHER_ORG + ], + }; + + // User can create projects in ORG + expect(authorize(user, PERMISSIONS.PROJECT_CREATE, { orgId: ORG })).toBe(true); + + // User cannot create projects in OTHER_ORG + expect(authorize(user, PERMISSIONS.PROJECT_CREATE, { orgId: OTHER_ORG })).toBe(false); + + // User can update content in OTHER_ORG's PROJ + expect( + authorize(user, PERMISSIONS.CONTENT_UPDATE, { orgId: OTHER_ORG, projectId: OTHER_PROJ }) + ).toBe(true); + + // User cannot update content in ORG's project + expect(authorize(user, PERMISSIONS.CONTENT_UPDATE, { orgId: ORG, projectId: PROJ })).toBe( + false + ); + }); +}); + +describe('canAssignRole', () => { + const ORG = 1; + const PROJ = 10; + + // Helper: a global SuperAdmin with all relevant permissions + const superAdmin = { + id: 1, + grants: [ + grant(null, null, [PERMISSIONS.ROLE_ASSIGN_ORG_MANAGER, PERMISSIONS.ROLE_ASSIGN_PROJECT]), + ], + }; + + // Helper: an org-level PM with project-assign permissions + const orgPM = { + id: 2, + grants: [grant(ORG, null, [PERMISSIONS.ROLE_ASSIGN_PROJECT])], + }; + + // Helper: a project-pinned translator with no assign permissions + const translator = { + id: 3, + grants: [grant(ORG, PROJ, [PERMISSIONS.CONTENT_UPDATE])], + }; + + it('only a global SuperAdmin can assign SuperAdmin role', () => { + expect(canAssignRole(superAdmin, ROLES.SUPER_ADMIN, ORG, null)).toBe(true); + expect(canAssignRole(orgPM, ROLES.SUPER_ADMIN, ORG, null)).toBe(false); + }); + + it('superAdmin can assign Org Owner', () => { + expect(canAssignRole(superAdmin, ROLES.ORG_OWNER, ORG, null)).toBe(true); + }); + + it('org PM without ROLE_ASSIGN_ORG_MANAGER cannot assign Org Owner', () => { + expect(canAssignRole(orgPM, ROLES.ORG_OWNER, ORG, null)).toBe(false); + }); + + it('superAdmin can assign Org Manager', () => { + expect(canAssignRole(superAdmin, ROLES.ORG_MANAGER, ORG, null)).toBe(true); + }); + + it('org PM with ROLE_ASSIGN_PROJECT can assign Project Manager', () => { + expect(canAssignRole(orgPM, ROLES.PROJECT_MANAGER, ORG, PROJ)).toBe(true); + }); + + it('org PM with ROLE_ASSIGN_PROJECT can assign Project Translator', () => { + expect(canAssignRole(orgPM, ROLES.PROJECT_TRANSLATOR, ORG, PROJ)).toBe(true); + }); + + it('translator without assign permissions cannot assign any role', () => { + expect(canAssignRole(translator, ROLES.PROJECT_MANAGER, ORG, PROJ)).toBe(false); + expect(canAssignRole(translator, ROLES.PROJECT_TRANSLATOR, ORG, PROJ)).toBe(false); + expect(canAssignRole(translator, ROLES.ORG_MANAGER, ORG, null)).toBe(false); + }); + + it('unknown role name is always denied', () => { + expect(canAssignRole(superAdmin, 'NonExistentRole', ORG, null)).toBe(false); + }); + + it('assigning a project-scoped role without providing a projectId is denied', () => { + expect(canAssignRole(superAdmin, ROLES.PROJECT_MANAGER, ORG, null)).toBe(false); + expect(canAssignRole(orgPM, ROLES.PROJECT_MANAGER, ORG, null)).toBe(false); + }); + + it('a project-pinned assign grant cannot assign into a different project or different org', () => { + const PROJ2 = 20; + const ORG2 = 2; + const projectPM = { + id: 4, + grants: [grant(ORG, PROJ, [PERMISSIONS.ROLE_ASSIGN_PROJECT])], + }; + expect(canAssignRole(projectPM, ROLES.PROJECT_MANAGER, ORG, PROJ2)).toBe(false); + expect(canAssignRole(projectPM, ROLES.PROJECT_MANAGER, ORG2, PROJ)).toBe(false); + }); +}); diff --git a/src/lib/services/permissions/authorize.ts b/src/lib/services/permissions/authorize.ts new file mode 100644 index 00000000..c4e2932e --- /dev/null +++ b/src/lib/services/permissions/authorize.ts @@ -0,0 +1,93 @@ +import type { Permission } from '@/lib/permissions'; +import type { AppPolicyUser, AuthScope, Grant } from '@/lib/types'; + +import { PERMISSIONS } from '@/lib/permissions'; +import { ROLES } from '@/lib/roles'; + +/** + * A grant applies to a request scope when: + * - it is global (org + project both null) — SuperAdmin; OR + * - the request is project-scoped (projectId given) and the grant is either + * pinned to that project, or an org-wide role over that project's org; OR + * - the request is org-scoped (no project) and the grant lives in that org + * (org-wide OR pinned to any project in it). + */ +function isGrantApplicable(grant: Grant, orgId: number | null, projectId: number | null): boolean { + if (grant.orgId === null && grant.projectId === null) return true; + + if (projectId !== null) { + if (grant.projectId === projectId && grant.orgId === orgId) return true; + if (grant.projectId === null && grant.orgId === orgId) return true; + return false; + } + + if (orgId !== null) { + return grant.orgId === orgId; + } + + return false; +} + +export function collectPermissions(grants: Grant[], scope: AuthScope): Set { + const orgId = scope.orgId ?? null; + const projectId = scope.projectId ?? null; + const out = new Set(); + for (const grant of grants) { + if (isGrantApplicable(grant, orgId, projectId)) { + for (const permission of grant.permissions) out.add(permission); + } + } + return out; +} + +export function authorize(user: AppPolicyUser, permission: Permission, scope: AuthScope): boolean { + return collectPermissions(user.grants, scope).has(permission); +} + +/** + * Validates that the caller has sufficient role-granting permissions + * to assign targetRoleName in the specified organization and project scope. + */ +export function canAssignRole( + caller: AppPolicyUser, + targetRoleName: string, + orgId: number, + projectId: number | null +): boolean { + const scope = { orgId, projectId }; + + // 1. SuperAdmin role can only be assigned by a global SuperAdmin + if (targetRoleName === ROLES.SUPER_ADMIN) { + return ( + authorize(caller, PERMISSIONS.ROLE_ASSIGN_ORG_MANAGER, { orgId: null, projectId: null }) && + caller.grants.some((g) => g.orgId === null && g.projectId === null) + ); + } + + // 2. Org Owner role can only be assigned by a SuperAdmin or an Org Owner of this org + if (targetRoleName === ROLES.ORG_OWNER) { + const hasOrgAssign = authorize(caller, PERMISSIONS.ROLE_ASSIGN_ORG_MANAGER, scope); + if (!hasOrgAssign) return false; + return caller.grants.some( + (g) => + (g.orgId === null && g.projectId === null) || (g.orgId === orgId && g.projectId === null) + ); + } + + // 3. Org Manager role requires ROLE_ASSIGN_ORG_MANAGER permission + if (targetRoleName === ROLES.ORG_MANAGER) { + return authorize(caller, PERMISSIONS.ROLE_ASSIGN_ORG_MANAGER, scope); + } + + // 4. Project-level roles (Project Manager, Translator, Observer) require ROLE_ASSIGN_PROJECT permission + if ( + targetRoleName === ROLES.PROJECT_MANAGER || + targetRoleName === ROLES.PROJECT_TRANSLATOR || + targetRoleName === ROLES.PROJECT_OBSERVER + ) { + if (projectId === null) return false; + return authorize(caller, PERMISSIONS.ROLE_ASSIGN_PROJECT, scope); + } + + return false; +} diff --git a/src/lib/services/permissions/permissions.service.ts b/src/lib/services/permissions/permissions.service.ts index 412dbdb6..737283ec 100644 --- a/src/lib/services/permissions/permissions.service.ts +++ b/src/lib/services/permissions/permissions.service.ts @@ -1,17 +1 @@ -import { and, eq } from 'drizzle-orm'; - -import type { Permission } from '@/lib/permissions'; - -import { db } from '@/db'; -import { permissions, role_permissions } from '@/db/schema'; - -export async function roleHasPermission(roleId: number, permission: Permission): Promise { - const rows = await db - .select({ id: permissions.id }) - .from(role_permissions) - .innerJoin(permissions, eq(permissions.id, role_permissions.permissionId)) - .where(and(eq(role_permissions.roleId, roleId), eq(permissions.name, permission))) - .limit(1); - - return rows.length > 0; -} +export { authorize, collectPermissions } from './authorize'; diff --git a/src/lib/types.ts b/src/lib/types.ts index c70b57f2..f9d4f151 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -5,16 +5,15 @@ import type { Schema } from 'hono'; import type { PinoLogger } from 'hono-pino'; import type * as schema from '@/db/schema'; +import type { Permission } from '@/lib/permissions'; // ─── App user (session context) ─────────────────────────────────────────────── export interface User { id: number; email: string; - role: number; - roleName: string; - organization: number; status: 'invited' | 'verified' | 'inactive'; + grants: Grant[]; [key: string]: any; } @@ -26,6 +25,7 @@ export interface AppBindings { user?: User; session?: any; // BetterAuth session requestId: string; + activeOrgId?: number | null; }; } @@ -161,13 +161,25 @@ export interface AppError { code: ErrorCode; } +/** One authorization grant, flattened to its effective permissions. */ +export interface Grant { + orgId: number | null; + projectId: number | null; + permissions: ReadonlySet; +} + +/** The scope an action is evaluated against. */ +export interface AuthScope { + orgId?: number | null; + projectId?: number | null; +} + /** * Shared identity for authorization policies across all domains. */ export interface AppPolicyUser { id: number; - roleName: string; - organization: number; + grants: Grant[]; } // ─── Result type + factories ────────────────────────────────────────────────── diff --git a/src/middlewares/authenticate.test.ts b/src/middlewares/authenticate.test.ts index 45bbb0e7..ccf0f80b 100644 --- a/src/middlewares/authenticate.test.ts +++ b/src/middlewares/authenticate.test.ts @@ -20,6 +20,10 @@ vi.mock('@/domains/users/users.service', () => ({ getUserByEmail: vi.fn(), })); +vi.mock('@/domains/user-roles/user-roles.repository', () => ({ + findGrantsByUserId: vi.fn().mockResolvedValue({ ok: true, data: [] }), +})); + vi.mock('@/lib/logger', () => ({ logger: { debug: vi.fn(), @@ -264,8 +268,7 @@ describe('authenticate middleware', () => { await authenticate(mockContext, next); - // updatedAt is fresh — no DB select or update should happen - expect(mockDbSelect).not.toHaveBeenCalled(); + // updatedAt is fresh — no rolling DB check should happen expect(mockDbUpdate).not.toHaveBeenCalled(); expect(next).toHaveBeenCalled(); }); @@ -282,7 +285,7 @@ describe('authenticate middleware', () => { await authenticate(mockContext, next); expect(mockContext.set).toHaveBeenCalledWith('session', mockSession); - expect(mockContext.set).toHaveBeenCalledWith('user', mockUser); + expect(mockContext.set).toHaveBeenCalledWith('user', { ...mockUser, grants: [] }); expect(next).toHaveBeenCalled(); }); diff --git a/src/middlewares/authenticate.ts b/src/middlewares/authenticate.ts index 17f7d4ae..97664a2e 100644 --- a/src/middlewares/authenticate.ts +++ b/src/middlewares/authenticate.ts @@ -7,6 +7,7 @@ import type { AppBindings } from '@/lib/types'; import { db } from '@/db'; import * as schema from '@/db/schema'; +import { findGrantsByUserId } from '@/domains/user-roles/user-roles.repository'; import { getUserByEmail } from '@/domains/users/users.service'; import { auth } from '@/lib/auth'; import { logger } from '@/lib/logger'; @@ -136,10 +137,52 @@ export async function authenticate(c: Context, next: Next) { } // ─────────────────────────────────────────────────────────────────────── - // Look up the application user - const userResult = await getUserByEmail(session.user.email); + // Look up the application user and load their grants, concurrently with auth_session activeOrgId + const [userResult, sessionRecords] = await Promise.all([ + getUserByEmail(session.user.email), + db + .select({ activeOrgId: schema.authSession.activeOrgId }) + .from(schema.authSession) + .where(eq(schema.authSession.id, session.session.id)) + .limit(1), + ]); + if (userResult.ok) { - c.set('user', userResult.data); + let activeOrgId = sessionRecords[0]?.activeOrgId ?? null; + + if (activeOrgId == null && userResult.data.lastActiveOrgId != null) { + activeOrgId = userResult.data.lastActiveOrgId; + await db + .update(schema.authSession) + .set({ activeOrgId }) + .where(eq(schema.authSession.id, session.session.id)); + } + + c.set('activeOrgId', activeOrgId); + + const grantsResult = await findGrantsByUserId(userResult.data.id); + if (!grantsResult.ok) { + logger.error('Database failure: unable to load grants for user', { + userId: userResult.data.id, + error: grantsResult.error, + }); + throw new HTTPException(500, { + message: 'Internal Server Error: Failed to load user grants', + }); + } + logger.debug( + { + userId: userResult.data.id, + grantsCount: grantsResult.data.length, + grants: grantsResult.data, + }, + 'Populated session user with grants in authenticate' + ); + + c.set('user', { + ...userResult.data, + grants: grantsResult.data, + }); } else { logger.debug('Authenticated auth_user has no linked application user', { email: session.user.email, diff --git a/src/middlewares/role-auth.ts b/src/middlewares/role-auth.ts index fcec158c..92ebdabf 100644 --- a/src/middlewares/role-auth.ts +++ b/src/middlewares/role-auth.ts @@ -4,9 +4,9 @@ import { HTTPException } from 'hono/http-exception'; import * as HttpStatusCodes from 'stoker/http-status-codes'; import type { Permission } from '@/lib/permissions'; -import type { AppBindings } from '@/lib/types'; +import type { AppBindings, AuthScope } from '@/lib/types'; -import { roleHasPermission } from '@/lib/services/permissions/permissions.service'; +import { authorize } from '@/lib/services/permissions/authorize'; /** * 1. Authentication Middleware @@ -36,7 +36,10 @@ export async function authenticateUser(c: Context, next: Next) { * 2. Authorization Middleware * Relies on authenticateUser running first. Checks if the user's role has the permission. */ -export function requirePermission(permission: Permission) { +/** Extracts the scope an action is evaluated against from the request context. */ +export type ScopeResolver = (c: Context) => AuthScope | Promise; + +export function requirePermission(permission: Permission, resolveScope?: ScopeResolver) { return async (c: Context, next: Next) => { const user = c.get('user'); @@ -46,9 +49,14 @@ export function requirePermission(permission: Permission) { }); } - const granted = await roleHasPermission(user.role, permission); + const scope: AuthScope = resolveScope ? await resolveScope(c) : {}; + const policyUser = { id: user.id, grants: user.grants }; + + const isAuthorized = resolveScope + ? authorize(policyUser, permission, scope) + : user.grants.some((g) => g.permissions.has(permission)); - if (!granted) { + if (!isAuthorized) { throw new HTTPException(HttpStatusCodes.FORBIDDEN, { message: 'Insufficient permissions', }); @@ -58,6 +66,13 @@ export function requirePermission(permission: Permission) { }; } +/** Scope from a numeric `orgId` or `organization` field in the JSON body. */ +export const orgFromBody: ScopeResolver = async (c) => { + const body = await c.req.json().catch(() => ({})); + const orgId = Number(body?.orgId ?? body?.organization); + return Number.isFinite(orgId) ? { orgId } : {}; +}; + /** * 3. Self-Access Middleware * Ensures the authenticated user can only access their own resources. @@ -87,27 +102,31 @@ export function requireSelf() { } /** - * 4. Admin-Only Placeholder Middleware - * Hard-denies actions that are intended to be administrator-only. No admin role - * exists yet, so these actions are denied for every authenticated user. - * Relies on authenticateUser running first, so unauthenticated callers get 401 - * (not 403). - * - * TODO: replace with requirePermission() once the admin role - * is introduced by the upcoming user-management changes. + * 4. SuperAdmin-Only Middleware + * Relies on authenticateUser running first. Checks if the user is a global SuperAdmin. */ -export function denyUntilAdminRole() { - return async (c: Context, _next: Next) => { - const user = c.get('user'); +export async function requireSuperAdmin(c: any, next: any) { + const user = c.get('user'); - if (!user) { - throw new HTTPException(HttpStatusCodes.UNAUTHORIZED, { - message: 'User not authenticated', - }); - } + if (!user) { + throw new HTTPException(HttpStatusCodes.UNAUTHORIZED, { + message: 'User not authenticated', + }); + } + // A SuperAdmin must have a global grant (orgId=null, projectId=null) + // AND hold a SuperAdmin-exclusive permission (role:assign:org_manager). + // Checking scope alone would let any future global read-only role pass. + const isSuperAdmin = user.grants.some( + (g: any) => + g.orgId === null && g.projectId === null && g.permissions.has('role:assign:org_manager') + ); + + if (!isSuperAdmin) { throw new HTTPException(HttpStatusCodes.FORBIDDEN, { - message: 'This action is restricted to administrators', + message: 'SuperAdmin access required', }); - }; + } + + await next(); } diff --git a/src/routes/config.route.test.ts b/src/routes/config.route.test.ts index 61e8d6c4..c4ffdb7c 100644 --- a/src/routes/config.route.test.ts +++ b/src/routes/config.route.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { findGrantsByUserId } from '@/domains/user-roles/user-roles.repository'; import { getUserByEmail } from '@/domains/users/users.service'; import { auth } from '@/lib/auth'; import { server } from '@/server/server'; @@ -20,14 +21,31 @@ vi.mock('@/lib/auth', () => ({ }, })); -vi.mock('@/db', () => ({ - db: { select: vi.fn(), insert: vi.fn(), update: vi.fn() }, -})); +vi.mock('@/db', () => { + const mockQueryBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([]), + returning: vi.fn().mockResolvedValue([]), + }; + return { + db: { + select: vi.fn(() => mockQueryBuilder), + insert: vi.fn(() => mockQueryBuilder), + update: vi.fn(() => mockQueryBuilder), + delete: vi.fn(() => mockQueryBuilder), + }, + }; +}); vi.mock('@/domains/users/users.service', () => ({ getUserByEmail: vi.fn(), })); +vi.mock('@/domains/user-roles/user-roles.repository', () => ({ + findGrantsByUserId: vi.fn(), +})); + vi.mock('@/lib/logger', () => ({ logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() }, })); @@ -50,6 +68,7 @@ function authenticateAs(user: typeof USER) { user: { email: user.email }, }); (getUserByEmail as any).mockResolvedValue({ ok: true, data: user }); + (findGrantsByUserId as any).mockResolvedValue({ ok: true, data: [] }); } function getFeatures() { diff --git a/src/test/utils/test-helpers.ts b/src/test/utils/test-helpers.ts index 7ee01a2a..82c69b7a 100644 --- a/src/test/utils/test-helpers.ts +++ b/src/test/utils/test-helpers.ts @@ -40,13 +40,13 @@ export const sampleUsers = { email: 'test@example.com', firstName: 'John', lastName: 'Doe', - role: 1, - organization: 1, + grants: [], createdBy: null, createdAt: new Date('2024-01-01T00:00:00Z'), updatedAt: new Date('2024-01-01T00:00:00Z'), authUserId: null, status: 'verified' as const, + lastActiveOrgId: null, }, user2: { id: 2, @@ -54,23 +54,23 @@ export const sampleUsers = { email: 'test2@example.com', firstName: 'Jane', lastName: 'Smith', - role: 1, - organization: 1, + grants: [], createdBy: null, createdAt: new Date('2024-01-02T00:00:00Z'), updatedAt: new Date('2024-01-02T00:00:00Z'), authUserId: null, status: 'invited' as const, + lastActiveOrgId: null, }, newUser: { username: 'newuser', email: 'newuser@example.com', firstName: 'John', lastName: 'Doe', - role: 1, - organization: 1, + grants: [], createdBy: null, status: 'invited' as const, + lastActiveOrgId: null, }, updateUser: { firstName: 'Jane',