/** * Test-tenant seed per ADR-0026 PR 2. * * Run via: * pnpm exec prisma db seed * * Idempotent: re-running this script preserves existing rows. The * checks for "row already there" run against the unique constraints: * - User.entraOid (skipping the cold path of * PersonAndUserProvisioner if the row exists), * - UserScope.(userId, kind, value). * * The seed creates Person + User + UserScope rows for the 19 personas * provisioned in the `apfrd.onmicrosoft.com` test tenant. The * persona matrix below is transcribed from * `notes/test-tenant-role-assignments.md` (gitignored) — that file * remains the human-readable reference; this constant is the source * of truth for the seed. * * **Test-tenant Entra `oid`s** are read from a JSON file pointed at * by `TEST_TENANT_PERSONAS_PATH` (or `infra/test-tenant.personas.json` * by default). The file is gitignored — copy * `infra/test-tenant.personas.example.json` and fill in real values * from the Entra admin centre. Missing file → seed skips the * test-tenant section cleanly (no error, just a log line). * * **`Person.source = 'seed'`** for every row created here, per the * PERSON_SOURCES catalogue. Differentiates these rows from * 'self-signin' (lazy-created at first sign-in) and 'admin-ui' (future * scope-seeding admin screen, ADR-0026 PR 2b). * * **Privileges + functional roles are NOT seeded** — those come from * Entra at sign-in (Entra `roles` claim + `groups` claim resolved by * `EntraGroupToRoleResolver`). Only scopes are portal-side data. */ import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { PrismaClient } from '@prisma/client'; interface PersonaSeed { readonly slug: string; readonly email: string; readonly displayName: string; /** * Scope tuples per `notes/test-tenant-role-assignments.md`. The * `value` is omitted for valueless kinds (self / siege / * unrestricted) — the seed writes an empty string to the DB * `value` column, matching the column's default. */ readonly scopes: ReadonlyArray<{ readonly kind: string; readonly value?: string }>; } const TENANT_DOMAIN = 'apfrd.onmicrosoft.com'; const PERSONAS: ReadonlyArray = [ { slug: 'admin', email: `admin@${TENANT_DOMAIN}`, displayName: 'Admin User', scopes: [{ kind: 'unrestricted' }], }, { slug: 'directeur-bordeaux', email: `directeur-bordeaux@${TENANT_DOMAIN}`, displayName: 'Directeur Bordeaux', scopes: [{ kind: 'etablissement', value: '0330800013' }], }, { slug: 'directeur-complexe', email: `directeur-complexe@${TENANT_DOMAIN}`, displayName: 'Directeur Complexe', scopes: [ { kind: 'etablissement', value: '0330800013' }, { kind: 'etablissement', value: '0330800021' }, ], }, { slug: 'rh-aquitaine', email: `rh-aquitaine@${TENANT_DOMAIN}`, displayName: 'RH Aquitaine', scopes: [{ kind: 'delegation', value: '33' }], }, { slug: 'rh-siege', email: `rh-siege@${TENANT_DOMAIN}`, displayName: 'RH Siège', scopes: [{ kind: 'unrestricted' }], }, { slug: 'collaborateur-simple', email: `collaborateur-simple@${TENANT_DOMAIN}`, displayName: 'Collaborateur Simple', scopes: [{ kind: 'self' }], }, { slug: 'tresorier-bordeaux', email: `tresorier-bordeaux@${TENANT_DOMAIN}`, displayName: 'Trésorier Bordeaux', scopes: [{ kind: 'delegation', value: '33' }], }, { slug: 'dpo', email: `dpo@${TENANT_DOMAIN}`, displayName: 'DPO', scopes: [{ kind: 'unrestricted' }], }, { slug: 'it', email: `it@${TENANT_DOMAIN}`, displayName: 'IT', scopes: [{ kind: 'unrestricted' }], }, { slug: 'benevole-aquitaine', email: `benevole-aquitaine@${TENANT_DOMAIN}`, displayName: 'Bénévole Aquitaine', scopes: [{ kind: 'delegation', value: '33' }], }, { slug: 'chef-equipe-bordeaux', email: `chef-equipe-bordeaux@${TENANT_DOMAIN}`, displayName: 'Chef Equipe Bordeaux', scopes: [{ kind: 'etablissement', value: '0330800013' }], }, { slug: 'chef-service-bordeaux', email: `chef-service-bordeaux@${TENANT_DOMAIN}`, displayName: 'Chef Service Bordeaux', scopes: [{ kind: 'etablissement', value: '0330800013' }], }, { slug: 'directeur-territorial-aquitaine', email: `directeur-territorial-aquitaine@${TENANT_DOMAIN}`, displayName: 'Directeur Territorial Aquitaine', scopes: [{ kind: 'delegation', value: '33' }], }, { slug: 'juriste-siege', email: `juriste-siege@${TENANT_DOMAIN}`, displayName: 'Juriste Siège', scopes: [{ kind: 'unrestricted' }], }, { slug: 'rssi', email: `rssi@${TENANT_DOMAIN}`, displayName: 'RSSI', scopes: [{ kind: 'unrestricted' }], }, { slug: 'communication-siege', email: `communication-siege@${TENANT_DOMAIN}`, displayName: 'Communication Siège', scopes: [{ kind: 'unrestricted' }], }, { slug: 'elu-ca-national', email: `elu-ca-national@${TENANT_DOMAIN}`, displayName: 'Elu CA National', scopes: [{ kind: 'siege' }], }, { slug: 'president-cd-aquitaine', email: `president-cd-aquitaine@${TENANT_DOMAIN}`, displayName: 'Président CD Aquitaine', scopes: [{ kind: 'delegation', value: '33' }], }, { slug: 'secretaire-cd-aquitaine', email: `secretaire-cd-aquitaine@${TENANT_DOMAIN}`, displayName: 'Secrétaire CD Aquitaine', scopes: [{ kind: 'delegation', value: '33' }], }, ]; async function main(): Promise { const prisma = new PrismaClient(); try { await seedTestTenant(prisma); } finally { await prisma.$disconnect(); } } async function seedTestTenant(prisma: PrismaClient): Promise { const personasPath = resolve( process.env['TEST_TENANT_PERSONAS_PATH'] ?? 'infra/test-tenant.personas.json', ); if (!existsSync(personasPath)) { console.log( `[seed] TEST_TENANT_PERSONAS_PATH (${personasPath}) not found — skipping test-tenant seed.`, ); return; } let entraOidBySlug: Record; try { const raw = readFileSync(personasPath, 'utf8'); entraOidBySlug = JSON.parse(raw) as Record; } catch (err) { console.error( `[seed] could not parse ${personasPath}: ${err instanceof Error ? err.message : String(err)}`, ); process.exit(1); } const tenantId = process.env['ENTRA_TENANT_ID'] ?? TENANT_DOMAIN; const counts = { personsCreated: 0, usersCreated: 0, scopesCreated: 0, skipped: 0 }; for (const persona of PERSONAS) { const entraOid = entraOidBySlug[persona.slug]; if (entraOid === undefined || typeof entraOid !== 'string' || entraOid === '') { console.warn(`[seed] persona "${persona.slug}" missing oid in ${personasPath} — skipping.`); counts.skipped += 1; continue; } const { userId, created } = await ensurePersonAndUser(prisma, persona, entraOid, tenantId); if (created) { counts.personsCreated += 1; counts.usersCreated += 1; } for (const scope of persona.scopes) { const seededNew = await ensureUserScope(prisma, userId, scope.kind, scope.value ?? ''); if (seededNew) counts.scopesCreated += 1; } } console.log( `[seed] test-tenant complete — created ${counts.personsCreated} Person + ${counts.usersCreated} User + ${counts.scopesCreated} UserScope rows; skipped ${counts.skipped} personas (missing oid).`, ); } async function ensurePersonAndUser( prisma: PrismaClient, persona: PersonaSeed, entraOid: string, tenantId: string, ): Promise<{ userId: string; personId: string; created: boolean }> { const existing = await prisma.user.findUnique({ where: { entraOid }, select: { id: true, personId: true }, }); if (existing) { return { userId: existing.id, personId: existing.personId, created: false }; } const [firstName, lastName] = splitDisplayName(persona.displayName); const created = await prisma.user.create({ data: { entraOid, tenantId, person: { create: { firstName, lastName, email: persona.email, source: 'seed', }, }, }, select: { id: true, personId: true }, }); return { userId: created.id, personId: created.personId, created: true }; } async function ensureUserScope( prisma: PrismaClient, userId: string, kind: string, value: string, ): Promise { const existing = await prisma.userScope.findUnique({ where: { userId_kind_value: { userId, kind, value } }, }); if (existing) return false; await prisma.userScope.create({ data: { userId, kind, value, source: 'seed' }, }); return true; } function splitDisplayName(displayName: string): [string, string] { const trimmed = displayName.trim(); const space = trimmed.indexOf(' '); if (space < 0) return [trimmed, '']; return [trimmed.slice(0, space), trimmed.slice(space + 1).trim()]; } main().catch((err: unknown) => { console.error('[seed] failed:', err); process.exit(1); });