From 79a3f03264408adeef6b7bd1e5b7f6398f4f0cd9 Mon Sep 17 00:00:00 2001 From: mattrace-gloo Date: Tue, 30 Jun 2026 11:25:26 -0700 Subject: [PATCH 1/2] [#207]: Seed local dev project and chapter assignment for mobile My Work. Adds an idempotent dev-project seeder wired into db:setup, fapi.sh db:seed, and the Docker entrypoint so t@fluent.local gets a draft GEN 1 assignment after local Docker init. Refs eten-tech-foundation/fluent-mobile#119. --- docker-entrypoint.sh | 3 + fapi.sh | 4 +- package.json | 1 + src/db/scripts/setup.ts | 23 ++-- src/db/seeds/dev-project.ts | 203 ++++++++++++++++++++++++++++++++++++ 5 files changed, 224 insertions(+), 10 deletions(-) create mode 100644 src/db/seeds/dev-project.ts diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index fdfb9b5..9aa5268 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -31,5 +31,8 @@ npx tsx src/db/seeds/bibles.ts || { echo "ERROR: bibles seed failed"; exit 1; } echo "Seeding bible texts..." npx tsx src/db/seeds/bible-texts.ts || { echo "ERROR: bible texts seed failed"; exit 1; } +echo "Seeding local dev project (mobile My Work)..." +npx tsx src/db/seeds/dev-project.ts || { echo "ERROR: dev project seed failed"; exit 1; } + echo "Starting dev server..." exec npx tsx watch src/index.ts diff --git a/fapi.sh b/fapi.sh index 7f5ef2c..17d0bef 100755 --- a/fapi.sh +++ b/fapi.sh @@ -572,6 +572,8 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then exec_api npm run db:seed:bibles echo_running "Seeding bible texts..." exec_api npm run db:seed:bible-texts + echo_running "Seeding local dev project (mobile My Work)..." + exec_api npm run db:seed:dev-project echo_success "All seeds complete." ;; @@ -691,7 +693,7 @@ Development (runs in API container): Database: db:init Run migrations + all seeds (interactive confirmation) db:migrate Run Drizzle migrations - db:seed Seed all data (org, roles, RBAC, dev users, languages, books, bibles, bible texts) + db:seed Seed all data (org, roles, RBAC, dev users, languages, books, bibles, bible texts, dev project) db:generate Generate a new Drizzle migration db:studio Launch Drizzle Studio on the host db:psql Open psql session diff --git a/package.json b/package.json index 9bd4938..0439696 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "db:seed:books": "npx tsx src/db/seeds/books.ts", "db:seed:bibles": "npx tsx src/db/seeds/bibles.ts", "db:seed:bible-texts": "npx tsx src/db/seeds/bible-texts.ts", + "db:seed:dev-project": "npx tsx src/db/seeds/dev-project.ts", "db:migrate-users": "npx tsx src/db/scripts/migrate-users-to-betterauth.ts", "db:set-password": "npx tsx src/db/scripts/set-password.ts", "db:create-user": "npx tsx src/db/scripts/create-user.ts", diff --git a/src/db/scripts/setup.ts b/src/db/scripts/setup.ts index 1555f9d..70fce67 100644 --- a/src/db/scripts/setup.ts +++ b/src/db/scripts/setup.ts @@ -3,6 +3,7 @@ import { execSync } from 'node:child_process'; import { seedBibleTexts } from '@/db/seeds/bible-texts'; import { seedBibles } from '@/db/seeds/bibles'; import { seedBooks } from '@/db/seeds/books'; +import { seedDevProject } from '@/db/seeds/dev-project'; import { seedDevUsers } from '@/db/seeds/dev-users'; import { seedLanguages } from '@/db/seeds/languages'; import { seedOrganizations } from '@/db/seeds/organizations'; @@ -12,42 +13,46 @@ import { seedRoles } from '@/db/seeds/roles'; async function setup() { console.log('=== Fluent DB Setup ===\n'); - console.log('[1/9] Running migrations...'); + console.log('[1/10] Running migrations...'); execSync('npx drizzle-kit migrate', { stdio: 'inherit' }); console.log('Migrations complete.\n'); - console.log('[2/9] Seeding organizations...'); + console.log('[2/10] Seeding organizations...'); await seedOrganizations(); console.log(''); - console.log('[3/9] Seeding roles...'); + console.log('[3/10] Seeding roles...'); await seedRoles(); console.log(''); - console.log('[4/9] Seeding RBAC...'); + console.log('[4/10] Seeding RBAC...'); await seedRbac(); console.log(''); - console.log('[5/9] Seeding dev users...'); + console.log('[5/10] Seeding dev users...'); await seedDevUsers(); console.log(''); - console.log('[6/9] Seeding languages...'); + console.log('[6/10] Seeding languages...'); await seedLanguages(); console.log(''); - console.log('[7/9] Seeding books...'); + console.log('[7/10] Seeding books...'); await seedBooks(); console.log(''); - console.log('[8/9] Seeding bibles...'); + console.log('[8/10] Seeding bibles...'); await seedBibles(); console.log(''); - console.log('[9/9] Seeding bible texts...'); + console.log('[9/10] Seeding bible texts...'); await seedBibleTexts(); console.log(''); + console.log('[10/10] Seeding local dev project (mobile My Work)...'); + await seedDevProject(); + console.log(''); + console.log('=== Setup complete ==='); const managerEmail = process.env.SEED_MANAGER_EMAIL ?? 'pm@fluent.local'; const managerPassword = process.env.SEED_MANAGER_PASSWORD ?? 'pm@123456'; diff --git a/src/db/seeds/dev-project.ts b/src/db/seeds/dev-project.ts new file mode 100644 index 0000000..81b06a1 --- /dev/null +++ b/src/db/seeds/dev-project.ts @@ -0,0 +1,203 @@ +import { and, eq, inArray } from 'drizzle-orm'; +import { fileURLToPath } from 'node:url'; + +import { db } from '@/db'; +import { + bibles, + books, + chapter_assignments, + languages, + organizations, + project_units, + projects, + users, +} from '@/db/schema'; +import * as chapterAssignmentService from '@/domains/chapter-assignments/chapter-assignments.service'; +import { createProject } from '@/domains/projects/projects.service'; +import * as projectUsersRepo from '@/domains/projects/users/project-users.repository'; + +/** Idempotency marker — local Docker seeds only; never run against shared dev Postgres. */ +const DEV_PROJECT_SEED_KEY = 'local-mobile-my-work'; +const DEV_PROJECT_NAME = 'Local Dev — Mobile My Work'; +const IRV_ABBREVIATION = 'IRV'; +const IRV_BOOK_CODES = ['GEN', 'EXO'] as const; +const LANGUAGE_CODE = 'guj'; +const FIRST_ASSIGNED_CHAPTER = { bookCode: 'GEN' as const, chapterNumber: 1 }; + +export async function seedDevProject() { + const [org] = await db + .select({ id: organizations.id }) + .from(organizations) + .where(eq(organizations.name, 'Fluent Dev')) + .limit(1); + + if (!org) { + throw new Error('Default organization "Fluent Dev" not found. Run seedOrganizations first.'); + } + + const managerEmail = process.env.SEED_MANAGER_EMAIL ?? 'pm@fluent.local'; + const translatorEmail = process.env.SEED_TRANSLATOR_EMAIL ?? 't@fluent.local'; + + const [manager] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.email, managerEmail)) + .limit(1); + const [translator] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.email, translatorEmail)) + .limit(1); + + if (!manager) { + throw new Error(`Manager user "${managerEmail}" not found. Run seedDevUsers first.`); + } + if (!translator) { + throw new Error(`Translator user "${translatorEmail}" not found. Run seedDevUsers first.`); + } + + const [language] = await db + .select({ id: languages.id }) + .from(languages) + .where(eq(languages.langCodeIso6393, LANGUAGE_CODE)) + .limit(1); + + if (!language) { + throw new Error(`Language "${LANGUAGE_CODE}" not found. Run seedLanguages first.`); + } + + const [bible] = await db + .select({ id: bibles.id }) + .from(bibles) + .where(eq(bibles.abbreviation, IRV_ABBREVIATION)) + .limit(1); + + if (!bible) { + throw new Error(`Bible "${IRV_ABBREVIATION}" not found. Run seedBibles first.`); + } + + const bookRows = await db + .select({ id: books.id, code: books.code }) + .from(books) + .where(inArray(books.code, [...IRV_BOOK_CODES])); + + const bookIdByCode = new Map(); + for (const row of bookRows) { + if (!bookIdByCode.has(row.code)) { + bookIdByCode.set(row.code, row.id); + } + } + + const missingBooks = IRV_BOOK_CODES.filter((code) => !bookIdByCode.has(code)); + if (missingBooks.length > 0) { + throw new Error(`Book(s) not found: ${missingBooks.join(', ')}. Run seedBooks first.`); + } + + const bookIds = IRV_BOOK_CODES.map((code) => bookIdByCode.get(code) as number); + + const [existingProject] = await db + .select({ id: projects.id }) + .from(projects) + .where(and(eq(projects.organization, org.id), eq(projects.name, DEV_PROJECT_NAME))) + .limit(1); + + let projectId: number; + + if (existingProject) { + projectId = existingProject.id; + console.log( + `Dev project "${DEV_PROJECT_NAME}" already exists (id=${projectId}) — ensuring membership and assignments.` + ); + } else { + const createResult = await createProject({ + name: DEV_PROJECT_NAME, + sourceLanguage: language.id, + targetLanguage: language.id, + organization: org.id, + createdBy: manager.id, + bibleId: bible.id, + bookId: bookIds, + projectUnitStatus: 'not_started', + metadata: { devSeedKey: DEV_PROJECT_SEED_KEY }, + isActive: true, + }); + + if (!createResult.ok) { + throw new Error( + createResult.error.message ?? 'Failed to create local dev project for mobile My Work.' + ); + } + + projectId = createResult.data.id; + console.log(`Created dev project "${DEV_PROJECT_NAME}" (id=${projectId}).`); + } + + const membershipResult = await projectUsersRepo.addProjectUsers(projectId, [ + manager.id, + translator.id, + ]); + if (!membershipResult.ok) { + throw new Error( + membershipResult.error.message ?? 'Failed to add dev users to local dev project.' + ); + } + + const genBookId = bookIdByCode.get(FIRST_ASSIGNED_CHAPTER.bookCode); + if (!genBookId) { + throw new Error(`Book "${FIRST_ASSIGNED_CHAPTER.bookCode}" not found for assignment.`); + } + + const [assignment] = await db + .select({ + id: chapter_assignments.id, + assignedUserId: chapter_assignments.assignedUserId, + status: chapter_assignments.status, + }) + .from(chapter_assignments) + .innerJoin(project_units, eq(chapter_assignments.projectUnitId, project_units.id)) + .where( + and( + eq(project_units.projectId, projectId), + eq(chapter_assignments.bookId, genBookId), + eq(chapter_assignments.chapterNumber, FIRST_ASSIGNED_CHAPTER.chapterNumber) + ) + ) + .limit(1); + + if (!assignment) { + throw new Error( + `Chapter assignment for ${FIRST_ASSIGNED_CHAPTER.bookCode} ${FIRST_ASSIGNED_CHAPTER.chapterNumber} not found on dev project.` + ); + } + + if (assignment.assignedUserId === translator.id && assignment.status === 'draft') { + console.log( + `Translator already assigned to ${FIRST_ASSIGNED_CHAPTER.bookCode} ${FIRST_ASSIGNED_CHAPTER.chapterNumber} (draft) — dev project seed complete.` + ); + return; + } + + const assignResult = await chapterAssignmentService.updateChapterAssignment(assignment.id, { + assignedUserId: translator.id, + }); + + if (!assignResult.ok) { + throw new Error( + assignResult.error.message ?? + `Failed to assign ${FIRST_ASSIGNED_CHAPTER.bookCode} ${FIRST_ASSIGNED_CHAPTER.chapterNumber} to translator.` + ); + } + + console.log( + `Assigned ${FIRST_ASSIGNED_CHAPTER.bookCode} ${FIRST_ASSIGNED_CHAPTER.chapterNumber} to ${translatorEmail} (draft). Dev project seed complete.` + ); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + seedDevProject() + .then(() => process.exit(0)) + .catch((err: unknown) => { + console.error('Seed failed:', err); + process.exit(1); + }); +} From fc03688c5b31e22237531c4d96225830f2c25c4d Mon Sep 17 00:00:00 2001 From: mattrace-gloo Date: Tue, 30 Jun 2026 11:32:05 -0700 Subject: [PATCH 2/2] [#207]: Generalize dev-workflow seed and ensure both dev logins always work. Replace mobile-specific dev-project with data-driven dev-workflow.json. Re-running seedDevUsers now syncs pm@ and t@ credentials to env defaults; workflow assigns GEN 1 to translator and EXO 1 to PM. --- docker-entrypoint.sh | 4 +- fapi.sh | 6 +- package.json | 2 +- src/db/scripts/setup.ts | 6 +- src/db/seeds/data/dev-workflow.json | 27 +++ src/db/seeds/dev-project.ts | 203 ------------------ src/db/seeds/dev-users.ts | 75 ++++--- src/db/seeds/dev-workflow.ts | 305 ++++++++++++++++++++++++++++ 8 files changed, 393 insertions(+), 235 deletions(-) create mode 100644 src/db/seeds/data/dev-workflow.json delete mode 100644 src/db/seeds/dev-project.ts create mode 100644 src/db/seeds/dev-workflow.ts diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 9aa5268..460a859 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -31,8 +31,8 @@ npx tsx src/db/seeds/bibles.ts || { echo "ERROR: bibles seed failed"; exit 1; } echo "Seeding bible texts..." npx tsx src/db/seeds/bible-texts.ts || { echo "ERROR: bible texts seed failed"; exit 1; } -echo "Seeding local dev project (mobile My Work)..." -npx tsx src/db/seeds/dev-project.ts || { echo "ERROR: dev project seed failed"; exit 1; } +echo "Seeding local dev workflow..." +npx tsx src/db/seeds/dev-workflow.ts || { echo "ERROR: dev workflow seed failed"; exit 1; } echo "Starting dev server..." exec npx tsx watch src/index.ts diff --git a/fapi.sh b/fapi.sh index 17d0bef..2b85f90 100755 --- a/fapi.sh +++ b/fapi.sh @@ -572,8 +572,8 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then exec_api npm run db:seed:bibles echo_running "Seeding bible texts..." exec_api npm run db:seed:bible-texts - echo_running "Seeding local dev project (mobile My Work)..." - exec_api npm run db:seed:dev-project + echo_running "Seeding local dev workflow..." + exec_api npm run db:seed:dev-workflow echo_success "All seeds complete." ;; @@ -693,7 +693,7 @@ Development (runs in API container): Database: db:init Run migrations + all seeds (interactive confirmation) db:migrate Run Drizzle migrations - db:seed Seed all data (org, roles, RBAC, dev users, languages, books, bibles, bible texts, dev project) + db:seed Seed all data (org, roles, RBAC, dev users, languages, books, bibles, bible texts, dev workflow) db:generate Generate a new Drizzle migration db:studio Launch Drizzle Studio on the host db:psql Open psql session diff --git a/package.json b/package.json index 0439696..74efe0d 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "db:seed:books": "npx tsx src/db/seeds/books.ts", "db:seed:bibles": "npx tsx src/db/seeds/bibles.ts", "db:seed:bible-texts": "npx tsx src/db/seeds/bible-texts.ts", - "db:seed:dev-project": "npx tsx src/db/seeds/dev-project.ts", + "db:seed:dev-workflow": "npx tsx src/db/seeds/dev-workflow.ts", "db:migrate-users": "npx tsx src/db/scripts/migrate-users-to-betterauth.ts", "db:set-password": "npx tsx src/db/scripts/set-password.ts", "db:create-user": "npx tsx src/db/scripts/create-user.ts", diff --git a/src/db/scripts/setup.ts b/src/db/scripts/setup.ts index 70fce67..0f7a4ce 100644 --- a/src/db/scripts/setup.ts +++ b/src/db/scripts/setup.ts @@ -3,8 +3,8 @@ import { execSync } from 'node:child_process'; import { seedBibleTexts } from '@/db/seeds/bible-texts'; import { seedBibles } from '@/db/seeds/bibles'; import { seedBooks } from '@/db/seeds/books'; -import { seedDevProject } from '@/db/seeds/dev-project'; import { seedDevUsers } from '@/db/seeds/dev-users'; +import { seedDevWorkflow } from '@/db/seeds/dev-workflow'; import { seedLanguages } from '@/db/seeds/languages'; import { seedOrganizations } from '@/db/seeds/organizations'; import { seedRbac } from '@/db/seeds/rbac'; @@ -49,8 +49,8 @@ async function setup() { await seedBibleTexts(); console.log(''); - console.log('[10/10] Seeding local dev project (mobile My Work)...'); - await seedDevProject(); + console.log('[10/10] Seeding local dev workflow...'); + await seedDevWorkflow(); console.log(''); console.log('=== Setup complete ==='); diff --git a/src/db/seeds/data/dev-workflow.json b/src/db/seeds/data/dev-workflow.json new file mode 100644 index 0000000..dc012b6 --- /dev/null +++ b/src/db/seeds/data/dev-workflow.json @@ -0,0 +1,27 @@ +{ + "projects": [ + { + "seedKey": "local-dev-sample-project", + "name": "Local Dev Sample Project", + "organizationName": "Fluent Dev", + "bibleAbbreviation": "IRV", + "bookCodes": ["GEN", "EXO"], + "sourceLanguageCode": "guj", + "targetLanguageCode": "guj", + "createdByEmail": "$SEED_MANAGER", + "memberEmails": ["$SEED_MANAGER", "$SEED_TRANSLATOR"], + "chapterAssignments": [ + { + "bookCode": "GEN", + "chapterNumber": 1, + "assignedUserEmail": "$SEED_TRANSLATOR" + }, + { + "bookCode": "EXO", + "chapterNumber": 1, + "assignedUserEmail": "$SEED_MANAGER" + } + ] + } + ] +} diff --git a/src/db/seeds/dev-project.ts b/src/db/seeds/dev-project.ts deleted file mode 100644 index 81b06a1..0000000 --- a/src/db/seeds/dev-project.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { and, eq, inArray } from 'drizzle-orm'; -import { fileURLToPath } from 'node:url'; - -import { db } from '@/db'; -import { - bibles, - books, - chapter_assignments, - languages, - organizations, - project_units, - projects, - users, -} from '@/db/schema'; -import * as chapterAssignmentService from '@/domains/chapter-assignments/chapter-assignments.service'; -import { createProject } from '@/domains/projects/projects.service'; -import * as projectUsersRepo from '@/domains/projects/users/project-users.repository'; - -/** Idempotency marker — local Docker seeds only; never run against shared dev Postgres. */ -const DEV_PROJECT_SEED_KEY = 'local-mobile-my-work'; -const DEV_PROJECT_NAME = 'Local Dev — Mobile My Work'; -const IRV_ABBREVIATION = 'IRV'; -const IRV_BOOK_CODES = ['GEN', 'EXO'] as const; -const LANGUAGE_CODE = 'guj'; -const FIRST_ASSIGNED_CHAPTER = { bookCode: 'GEN' as const, chapterNumber: 1 }; - -export async function seedDevProject() { - const [org] = await db - .select({ id: organizations.id }) - .from(organizations) - .where(eq(organizations.name, 'Fluent Dev')) - .limit(1); - - if (!org) { - throw new Error('Default organization "Fluent Dev" not found. Run seedOrganizations first.'); - } - - const managerEmail = process.env.SEED_MANAGER_EMAIL ?? 'pm@fluent.local'; - const translatorEmail = process.env.SEED_TRANSLATOR_EMAIL ?? 't@fluent.local'; - - const [manager] = await db - .select({ id: users.id }) - .from(users) - .where(eq(users.email, managerEmail)) - .limit(1); - const [translator] = await db - .select({ id: users.id }) - .from(users) - .where(eq(users.email, translatorEmail)) - .limit(1); - - if (!manager) { - throw new Error(`Manager user "${managerEmail}" not found. Run seedDevUsers first.`); - } - if (!translator) { - throw new Error(`Translator user "${translatorEmail}" not found. Run seedDevUsers first.`); - } - - const [language] = await db - .select({ id: languages.id }) - .from(languages) - .where(eq(languages.langCodeIso6393, LANGUAGE_CODE)) - .limit(1); - - if (!language) { - throw new Error(`Language "${LANGUAGE_CODE}" not found. Run seedLanguages first.`); - } - - const [bible] = await db - .select({ id: bibles.id }) - .from(bibles) - .where(eq(bibles.abbreviation, IRV_ABBREVIATION)) - .limit(1); - - if (!bible) { - throw new Error(`Bible "${IRV_ABBREVIATION}" not found. Run seedBibles first.`); - } - - const bookRows = await db - .select({ id: books.id, code: books.code }) - .from(books) - .where(inArray(books.code, [...IRV_BOOK_CODES])); - - const bookIdByCode = new Map(); - for (const row of bookRows) { - if (!bookIdByCode.has(row.code)) { - bookIdByCode.set(row.code, row.id); - } - } - - const missingBooks = IRV_BOOK_CODES.filter((code) => !bookIdByCode.has(code)); - if (missingBooks.length > 0) { - throw new Error(`Book(s) not found: ${missingBooks.join(', ')}. Run seedBooks first.`); - } - - const bookIds = IRV_BOOK_CODES.map((code) => bookIdByCode.get(code) as number); - - const [existingProject] = await db - .select({ id: projects.id }) - .from(projects) - .where(and(eq(projects.organization, org.id), eq(projects.name, DEV_PROJECT_NAME))) - .limit(1); - - let projectId: number; - - if (existingProject) { - projectId = existingProject.id; - console.log( - `Dev project "${DEV_PROJECT_NAME}" already exists (id=${projectId}) — ensuring membership and assignments.` - ); - } else { - const createResult = await createProject({ - name: DEV_PROJECT_NAME, - sourceLanguage: language.id, - targetLanguage: language.id, - organization: org.id, - createdBy: manager.id, - bibleId: bible.id, - bookId: bookIds, - projectUnitStatus: 'not_started', - metadata: { devSeedKey: DEV_PROJECT_SEED_KEY }, - isActive: true, - }); - - if (!createResult.ok) { - throw new Error( - createResult.error.message ?? 'Failed to create local dev project for mobile My Work.' - ); - } - - projectId = createResult.data.id; - console.log(`Created dev project "${DEV_PROJECT_NAME}" (id=${projectId}).`); - } - - const membershipResult = await projectUsersRepo.addProjectUsers(projectId, [ - manager.id, - translator.id, - ]); - if (!membershipResult.ok) { - throw new Error( - membershipResult.error.message ?? 'Failed to add dev users to local dev project.' - ); - } - - const genBookId = bookIdByCode.get(FIRST_ASSIGNED_CHAPTER.bookCode); - if (!genBookId) { - throw new Error(`Book "${FIRST_ASSIGNED_CHAPTER.bookCode}" not found for assignment.`); - } - - const [assignment] = await db - .select({ - id: chapter_assignments.id, - assignedUserId: chapter_assignments.assignedUserId, - status: chapter_assignments.status, - }) - .from(chapter_assignments) - .innerJoin(project_units, eq(chapter_assignments.projectUnitId, project_units.id)) - .where( - and( - eq(project_units.projectId, projectId), - eq(chapter_assignments.bookId, genBookId), - eq(chapter_assignments.chapterNumber, FIRST_ASSIGNED_CHAPTER.chapterNumber) - ) - ) - .limit(1); - - if (!assignment) { - throw new Error( - `Chapter assignment for ${FIRST_ASSIGNED_CHAPTER.bookCode} ${FIRST_ASSIGNED_CHAPTER.chapterNumber} not found on dev project.` - ); - } - - if (assignment.assignedUserId === translator.id && assignment.status === 'draft') { - console.log( - `Translator already assigned to ${FIRST_ASSIGNED_CHAPTER.bookCode} ${FIRST_ASSIGNED_CHAPTER.chapterNumber} (draft) — dev project seed complete.` - ); - return; - } - - const assignResult = await chapterAssignmentService.updateChapterAssignment(assignment.id, { - assignedUserId: translator.id, - }); - - if (!assignResult.ok) { - throw new Error( - assignResult.error.message ?? - `Failed to assign ${FIRST_ASSIGNED_CHAPTER.bookCode} ${FIRST_ASSIGNED_CHAPTER.chapterNumber} to translator.` - ); - } - - console.log( - `Assigned ${FIRST_ASSIGNED_CHAPTER.bookCode} ${FIRST_ASSIGNED_CHAPTER.chapterNumber} to ${translatorEmail} (draft). Dev project seed complete.` - ); -} - -if (process.argv[1] === fileURLToPath(import.meta.url)) { - seedDevProject() - .then(() => process.exit(0)) - .catch((err: unknown) => { - console.error('Seed failed:', err); - process.exit(1); - }); -} diff --git a/src/db/seeds/dev-users.ts b/src/db/seeds/dev-users.ts index 3f39f9b..09f592c 100644 --- a/src/db/seeds/dev-users.ts +++ b/src/db/seeds/dev-users.ts @@ -1,9 +1,10 @@ import { hashPassword } from 'better-auth/crypto'; -import { eq } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; import type { RoleName } from '@/lib/roles'; +import type { DbTransaction } from '@/lib/types'; import { db } from '@/db'; import { authAccount, authUser, organizations, roles, users } from '@/db/schema'; @@ -16,6 +17,39 @@ interface DevUserConfig { roleName: RoleName; } +async function ensureCredentialPassword( + tx: DbTransaction, + authUserId: string, + email: string, + password: string +): Promise { + const hashedPassword = await hashPassword(password); + + const [credentialAccount] = await tx + .select({ id: authAccount.id }) + .from(authAccount) + .where(and(eq(authAccount.userId, authUserId), eq(authAccount.providerId, 'credential'))) + .limit(1); + + if (credentialAccount) { + await tx + .update(authAccount) + .set({ password: hashedPassword, updatedAt: new Date() }) + .where(eq(authAccount.id, credentialAccount.id)); + return; + } + + await tx.insert(authAccount).values({ + id: crypto.randomUUID(), + userId: authUserId, + accountId: email, + providerId: 'credential', + password: hashedPassword, + createdAt: new Date(), + updatedAt: new Date(), + }); +} + export async function seedDevUsers() { const DEV_USERS: DevUserConfig[] = [ { @@ -51,9 +85,6 @@ export async function seedDevUsers() { throw new Error(`Role "${config.roleName}" not found. Run seedRoles first.`); } - const authUserId = crypto.randomUUID(); - const hashedPassword = await hashPassword(config.password); - await db.transaction(async (tx) => { const [existingAuthUser] = await tx .select({ id: authUser.id }) @@ -61,22 +92,25 @@ export async function seedDevUsers() { .where(eq(authUser.email, config.email)) .limit(1); - if (existingAuthUser) { - console.log(`Skipping ${config.email} — already exists in auth_user.`); - return; - } - - const [existingUserByEmail] = await tx + const [existingAppUser] = await tx .select({ id: users.id }) .from(users) .where(eq(users.email, config.email)) .limit(1); - if (existingUserByEmail) { - console.log(`Skipping ${config.email} — already exists in users.`); + if (existingAuthUser && existingAppUser) { + await ensureCredentialPassword(tx, existingAuthUser.id, config.email, config.password); + console.log(`Ensured dev user credentials: ${config.email} (${config.roleName})`); return; } + if (existingAuthUser || existingAppUser) { + throw new Error( + `Dev user "${config.email}" is partially seeded (auth=${Boolean(existingAuthUser)}, users=${Boolean(existingAppUser)}). ` + + 'Run on a fresh database or repair manually.' + ); + } + const [existingUserByUsername] = await tx .select({ id: users.id }) .from(users) @@ -84,10 +118,13 @@ export async function seedDevUsers() { .limit(1); if (existingUserByUsername) { - console.log(`Skipping ${config.username} — username already exists in users.`); - return; + throw new Error( + `Username "${config.username}" already exists for a different dev user. Resolve manually before re-seeding.` + ); } + const authUserId = crypto.randomUUID(); + await tx.insert(authUser).values({ id: authUserId, email: config.email, @@ -97,15 +134,7 @@ export async function seedDevUsers() { updatedAt: new Date(), }); - await tx.insert(authAccount).values({ - id: crypto.randomUUID(), - userId: authUserId, - accountId: config.email, - providerId: 'credential', - password: hashedPassword, - createdAt: new Date(), - updatedAt: new Date(), - }); + await ensureCredentialPassword(tx, authUserId, config.email, config.password); await tx.insert(users).values({ username: config.username, diff --git a/src/db/seeds/dev-workflow.ts b/src/db/seeds/dev-workflow.ts new file mode 100644 index 0000000..ad7a0f1 --- /dev/null +++ b/src/db/seeds/dev-workflow.ts @@ -0,0 +1,305 @@ +import { and, eq, inArray, sql } from 'drizzle-orm'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { db } from '@/db'; +import { + bibles, + books, + chapter_assignments, + languages, + organizations, + project_units, + projects, + users, +} from '@/db/schema'; +import * as chapterAssignmentService from '@/domains/chapter-assignments/chapter-assignments.service'; +import { createProject } from '@/domains/projects/projects.service'; +import * as projectUsersRepo from '@/domains/projects/users/project-users.repository'; + +interface ChapterAssignmentSpec { + bookCode: string; + chapterNumber: number; + assignedUserEmail?: string; + peerCheckerEmail?: string; +} + +interface DevWorkflowProjectSpec { + seedKey: string; + name: string; + organizationName: string; + bibleAbbreviation: string; + bookCodes: string[]; + sourceLanguageCode: string; + targetLanguageCode: string; + createdByEmail: string; + memberEmails: string[]; + chapterAssignments?: ChapterAssignmentSpec[]; +} + +interface DevWorkflowFile { + projects: DevWorkflowProjectSpec[]; +} + +function loadDevWorkflow(): DevWorkflowFile { + const raw = readFileSync(new URL('./data/dev-workflow.json', import.meta.url), 'utf-8'); + return JSON.parse(raw) as DevWorkflowFile; +} + +function resolveDevUserEmail(email: string): string { + if (email === '$SEED_MANAGER') { + return process.env.SEED_MANAGER_EMAIL ?? 'pm@fluent.local'; + } + if (email === '$SEED_TRANSLATOR') { + return process.env.SEED_TRANSLATOR_EMAIL ?? 't@fluent.local'; + } + return email; +} + +async function resolveUserId(email: string): Promise { + const resolvedEmail = resolveDevUserEmail(email); + const [user] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.email, resolvedEmail)) + .limit(1); + + if (!user) { + throw new Error(`User "${resolvedEmail}" not found. Run seedDevUsers first.`); + } + + return user.id; +} + +async function resolveLanguageId(code: string): Promise { + const [language] = await db + .select({ id: languages.id }) + .from(languages) + .where(eq(languages.langCodeIso6393, code)) + .limit(1); + + if (!language) { + throw new Error(`Language "${code}" not found. Run seedLanguages first.`); + } + + return language.id; +} + +async function resolveBibleId(abbreviation: string): Promise { + const [bible] = await db + .select({ id: bibles.id }) + .from(bibles) + .where(eq(bibles.abbreviation, abbreviation)) + .limit(1); + + if (!bible) { + throw new Error(`Bible "${abbreviation}" not found. Run seedBibles first.`); + } + + return bible.id; +} + +async function resolveBookIds(codes: string[]): Promise> { + const bookRows = await db + .select({ id: books.id, code: books.code }) + .from(books) + .where(inArray(books.code, codes)); + + const bookIdByCode = new Map(); + for (const row of bookRows) { + if (!bookIdByCode.has(row.code)) { + bookIdByCode.set(row.code, row.id); + } + } + + const missing = codes.filter((code) => !bookIdByCode.has(code)); + if (missing.length > 0) { + throw new Error(`Book(s) not found: ${missing.join(', ')}. Run seedBooks first.`); + } + + return bookIdByCode; +} + +async function ensureProject( + spec: DevWorkflowProjectSpec, + organizationId: number, + bibleId: number, + bookIdByCode: Map, + createdById: number +): Promise { + const [existingProject] = await db + .select({ id: projects.id }) + .from(projects) + .where(sql`${projects.metadata}->>'devSeedKey' = ${spec.seedKey}`) + .limit(1); + + if (existingProject) { + console.log( + `Dev workflow project "${spec.name}" already exists (id=${existingProject.id}, seedKey=${spec.seedKey}).` + ); + return existingProject.id; + } + + const bookIds = spec.bookCodes.map((code) => bookIdByCode.get(code) as number); + const sourceLanguageId = await resolveLanguageId(spec.sourceLanguageCode); + const targetLanguageId = await resolveLanguageId(spec.targetLanguageCode); + + const createResult = await createProject({ + name: spec.name, + sourceLanguage: sourceLanguageId, + targetLanguage: targetLanguageId, + organization: organizationId, + createdBy: createdById, + bibleId, + bookId: bookIds, + projectUnitStatus: 'not_started', + metadata: { devSeedKey: spec.seedKey }, + isActive: true, + }); + + if (!createResult.ok) { + throw new Error( + createResult.error.message ?? `Failed to create dev workflow project "${spec.name}".` + ); + } + + console.log(`Created dev workflow project "${spec.name}" (id=${createResult.data.id}).`); + return createResult.data.id; +} + +async function ensureChapterAssignments( + projectId: number, + bookIdByCode: Map, + assignments: ChapterAssignmentSpec[] +): Promise { + for (const assignmentSpec of assignments) { + const bookId = bookIdByCode.get(assignmentSpec.bookCode); + if (!bookId) { + throw new Error(`Book "${assignmentSpec.bookCode}" not found for chapter assignment.`); + } + + const [assignment] = await db + .select({ + id: chapter_assignments.id, + assignedUserId: chapter_assignments.assignedUserId, + peerCheckerId: chapter_assignments.peerCheckerId, + status: chapter_assignments.status, + }) + .from(chapter_assignments) + .innerJoin(project_units, eq(chapter_assignments.projectUnitId, project_units.id)) + .where( + and( + eq(project_units.projectId, projectId), + eq(chapter_assignments.bookId, bookId), + eq(chapter_assignments.chapterNumber, assignmentSpec.chapterNumber) + ) + ) + .limit(1); + + if (!assignment) { + throw new Error( + `Chapter assignment for ${assignmentSpec.bookCode} ${assignmentSpec.chapterNumber} not found on project ${projectId}.` + ); + } + + const updateData: { + assignedUserId?: number; + peerCheckerId?: number; + } = {}; + + if (assignmentSpec.assignedUserEmail) { + const assignedUserId = await resolveUserId(assignmentSpec.assignedUserEmail); + if (assignment.assignedUserId !== assignedUserId || assignment.status === 'not_started') { + updateData.assignedUserId = assignedUserId; + } + } + + if (assignmentSpec.peerCheckerEmail) { + const peerCheckerId = await resolveUserId(assignmentSpec.peerCheckerEmail); + if (assignment.peerCheckerId !== peerCheckerId) { + updateData.peerCheckerId = peerCheckerId; + } + } + + if (Object.keys(updateData).length === 0) { + console.log( + `Chapter ${assignmentSpec.bookCode} ${assignmentSpec.chapterNumber} already matches dev-workflow spec — skipping.` + ); + continue; + } + + const assignResult = await chapterAssignmentService.updateChapterAssignment( + assignment.id, + updateData + ); + + if (!assignResult.ok) { + throw new Error( + assignResult.error.message ?? + `Failed to apply dev-workflow assignment for ${assignmentSpec.bookCode} ${assignmentSpec.chapterNumber}.` + ); + } + + console.log( + `Applied dev-workflow assignment for ${assignmentSpec.bookCode} ${assignmentSpec.chapterNumber}.` + ); + } +} + +async function seedDevWorkflowProject(spec: DevWorkflowProjectSpec): Promise { + const [org] = await db + .select({ id: organizations.id }) + .from(organizations) + .where(eq(organizations.name, spec.organizationName)) + .limit(1); + + if (!org) { + throw new Error( + `Organization "${spec.organizationName}" not found. Run seedOrganizations first.` + ); + } + + const bibleId = await resolveBibleId(spec.bibleAbbreviation); + const bookIdByCode = await resolveBookIds(spec.bookCodes); + const createdById = await resolveUserId(spec.createdByEmail); + const memberIds = await Promise.all(spec.memberEmails.map((email) => resolveUserId(email))); + + const projectId = await ensureProject(spec, org.id, bibleId, bookIdByCode, createdById); + + const membershipResult = await projectUsersRepo.addProjectUsers(projectId, memberIds); + if (!membershipResult.ok) { + throw new Error( + membershipResult.error.message ?? + `Failed to add members to dev workflow project "${spec.name}".` + ); + } + + if (spec.chapterAssignments?.length) { + await ensureChapterAssignments(projectId, bookIdByCode, spec.chapterAssignments); + } +} + +/** Idempotent local-dev workflow seed — driven by `data/dev-workflow.json`. Local Docker only. */ +export async function seedDevWorkflow() { + const { projects: projectSpecs } = loadDevWorkflow(); + + if (projectSpecs.length === 0) { + console.log('Dev workflow seed: no projects declared — skipping.'); + return; + } + + for (const spec of projectSpecs) { + await seedDevWorkflowProject(spec); + } + + console.log(`Dev workflow seeded (${projectSpecs.length} project(s)).`); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + seedDevWorkflow() + .then(() => process.exit(0)) + .catch((err: unknown) => { + console.error('Seed failed:', err); + process.exit(1); + }); +}