feat(structures): add Region/Delegation/Structure schema + seed (ADR-0027 PR 1)
CI / commits (pull_request) Successful in 3m37s
CI / scan (pull_request) Successful in 3m53s
CI / check (pull_request) Failing after 4m0s
CI / a11y (pull_request) Successful in 3m37s
CI / perf (pull_request) Successful in 8m13s

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:
Julien Gautier
2026-05-26 11:12:19 +02:00
parent 670f6303fe
commit 9695c9080b
6 changed files with 515 additions and 9 deletions
+88
View File
@@ -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)