diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index fdfb9b5..460a859 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 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 7f5ef2c..2b85f90 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 workflow..." + exec_api npm run db:seed:dev-workflow 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 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 9bd4938..74efe0d 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-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 1555f9d..0f7a4ce 100644 --- a/src/db/scripts/setup.ts +++ b/src/db/scripts/setup.ts @@ -4,6 +4,7 @@ import { seedBibleTexts } from '@/db/seeds/bible-texts'; import { seedBibles } from '@/db/seeds/bibles'; import { seedBooks } from '@/db/seeds/books'; 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'; @@ -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 workflow...'); + await seedDevWorkflow(); + 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/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-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); + }); +}