feat(structures): add Region/Delegation/Structure schema + seed (ADR-0027 PR 1)
Schema (Prisma): three new public-schema models per ADR-0027 - Region (INSEE code PK), Delegation (dept code PK + regionCode FK), Structure (portal-internal code PK + kind discriminator + nullable unique finess/siret/codePaie + nullable delegationCode FK). Migration: hand-written, matches the style of init_audit_schema and users_directory. DDL + CHECK constraint on Structure.kind mirroring STRUCTURE_KINDS + indexes + inline seed (Region 75 Nouvelle-Aquitaine, Delegation 33 Gironde, structures 0330800013 + 0330800021 medico-social Bordeaux/Merignac + siege APF national). Seed is the minimum the 19 test-tenant personas reference; ADR-0029's cascade sync supersedes it when it ships. Catalogue (TypeScript): apps/portal-bff/src/structures/structure-kind.ts exports STRUCTURE_KINDS as const + StructureKind type union + isStructureKind type guard. Jest spec covers catalogue content, type guard, narrowing. Drift gate: extended to scan property-literal "kind: 'X'" expressions in files that import from structure-kind.ts. Three-layer defense in depth for Structure.kind = TS type union + Postgres CHECK + CI gate. 22 spec tests passing. No consumer code yet - PrismaScopeResolver that dereferences Structure.code from UserScope.value lives in ADR-0026 PR 2, which depends on this PR landing first. Reviewer heads-up: schema.prisma already has a User model (ADR-0020 user-directory cache); ADR-0026 PR 1 introduces a different User (UUID PK + Person FK). Naming collision flagged for that PR to resolve - not touched here.
This commit is contained in:
@@ -100,6 +100,94 @@ model User {
|
||||
@@index([username])
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Organisational hierarchy (per ADR-0027)
|
||||
// ============================================================
|
||||
//
|
||||
// Region → Delegation → Structure. The portal's scope-axis
|
||||
// dereferences these three layers — the BFF guard
|
||||
// `principalCoversResource` walks etablissement → delegation →
|
||||
// region to decide whether a scope covers a resource (per
|
||||
// ADR-0025).
|
||||
//
|
||||
// Population in v1: a small inline seed in the
|
||||
// `add_org_hierarchy` migration (the codes the test tenant
|
||||
// exercises). The full APF inventory ships with ADR-0029's
|
||||
// cascade sync, which writes additively into these columns —
|
||||
// no schema churn at sync time.
|
||||
|
||||
model Region {
|
||||
// INSEE region code (2 digits — '75' Nouvelle-Aquitaine,
|
||||
// '11' Île-de-France). Externally meaningful and stable
|
||||
// across reorgs; doubles as primary key.
|
||||
code String @id
|
||||
name String
|
||||
|
||||
delegations Delegation[]
|
||||
|
||||
@@map("regions")
|
||||
@@schema("public")
|
||||
}
|
||||
|
||||
model Delegation {
|
||||
// French department code (2-3 chars — '33' Gironde, '2A',
|
||||
// '971'). Same rationale as Region.code.
|
||||
code String @id
|
||||
name String
|
||||
|
||||
regionCode String @map("region_code")
|
||||
region Region @relation(fields: [regionCode], references: [code])
|
||||
|
||||
structures Structure[]
|
||||
|
||||
@@map("delegations")
|
||||
@@schema("public")
|
||||
@@index([regionCode])
|
||||
}
|
||||
|
||||
model Structure {
|
||||
// Portal-internal stable code, externally meaningful. For
|
||||
// medico-social structures: code = FINESS (9 digits). For
|
||||
// non-medico-social: APF-internal slug ('siege',
|
||||
// 'apf-bdx-merignac', 'ea-toulouse', …). Opaque at the type
|
||||
// level; matching is string equality, not parsing.
|
||||
code String @id
|
||||
name String
|
||||
|
||||
// Closed set, drift-gated. Legal values are tracked in
|
||||
// `apps/portal-bff/src/structures/structure-kind.ts` and
|
||||
// mirrored by a Postgres CHECK constraint on this column
|
||||
// (see the migration). `scripts/check-catalogue-drift.mjs`
|
||||
// asserts the TS constant matches the values used in code.
|
||||
kind String
|
||||
|
||||
// FINESS (9 digits). NULL for non-medico-social structures.
|
||||
// Unique when present. Cascade's StructureSourceFiness is
|
||||
// the long-term authoritative carrier; the portal denormalises
|
||||
// it inline here for v1 scope-axis checks. ADR-0029's sync
|
||||
// owns the write path.
|
||||
finess String? @unique
|
||||
|
||||
// SIRET (14 chars: 9 SIREN + 5 NIC). NULL when the structure
|
||||
// is not SIRENE-registered (most antennes, dispositifs).
|
||||
// Unique when present.
|
||||
siret String? @unique
|
||||
|
||||
// Pléiades payroll code (6 chars). NULL in v1 — populated by
|
||||
// ADR-0029's Pléiades sync once it ships.
|
||||
codePaie String? @unique @map("code_paie")
|
||||
|
||||
// Parent delegation. NULL for structures not attached to one
|
||||
// (siège, mouvement national, …).
|
||||
delegationCode String? @map("delegation_code")
|
||||
delegation Delegation? @relation(fields: [delegationCode], references: [code])
|
||||
|
||||
@@map("structures")
|
||||
@@schema("public")
|
||||
@@index([kind])
|
||||
@@index([delegationCode])
|
||||
}
|
||||
|
||||
model AuditEvent {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
Reference in New Issue
Block a user