From b84b58068abd1afc0c539cf5eba77b8f39d2f480 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Tue, 26 May 2026 12:42:23 +0200 Subject: [PATCH] refactor(users): rename Prisma User model to UserDirectoryEntry (#230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary **Mechanical refactor only**, prerequisite to ADR-0026 PR 1. Renames the existing Prisma `User` model (the ADR-0020 user-directory cache, `oid` PK, written by `UserDirectoryService.recordSignIn`) to `UserDirectoryEntry`. Frees the `User` name for the upcoming ADR-0026 model (UUID PK, FK to `Person`, `lastSignInAt` — different semantics). Zero behavioural change. Same columns, same indexes, same constraints, same call sites — just a different identifier on the model and the table. ## What lands | File | Change | | --- | --- | | `apps/portal-bff/prisma/schema.prisma` | `model User` → `model UserDirectoryEntry`. `@@map("users")` → `@@map("user_directory_entries")`. Comment block extended to flag the upcoming distinction from ADR-0026's new `User`. | | `apps/portal-bff/prisma/migrations/20260526200000_rename_users_to_user_directory_entries/migration.sql` | **New**. `ALTER TABLE "users" RENAME TO "user_directory_entries"` + `ALTER INDEX` renames for the PK constraint and the two named indexes (Postgres doesn't auto-rename these on table rename). | | `apps/portal-bff/src/users/user-directory.service.ts` | Two renames: the **TS input interface** `UserDirectoryEntry` → `RecordSignInInput` (the existing name was a misnomer — it's the input to `recordSignIn`, not the entry itself, and would collide with the Prisma-generated `UserDirectoryEntry` type after the model rename). The **Prisma client ref** `this.prisma.user.upsert` → `this.prisma.userDirectoryEntry.upsert`. | | `apps/portal-bff/src/users/user-directory.service.spec.ts` | Imports / mock setup / fixture type updated to track the two renames. | | `apps/portal-bff/src/admin/admin-users-reader.service.ts` | `this.prisma.user.{count,findMany}` → `this.prisma.userDirectoryEntry.{count,findMany}`. `Prisma.UserWhereInput` → `Prisma.UserDirectoryEntryWhereInput`. Doc comments mentioning `public.users` updated to `public.user_directory_entries`. The class name `AdminUsersReader`, the endpoint URL `/api/admin/users`, the DTO `AdminUserDto`, and the local `interface UserRow` are unchanged — these are SPA/HTTP-facing identifiers, where the URL semantics ("admin user list") still hold regardless of the backing table name. | | `apps/portal-bff/src/admin/admin-users-reader.service.spec.ts` | Mock setup updated to track the Prisma client field rename. | ## Why two renames in one file `apps/portal-bff/src/users/user-directory.service.ts` had a TypeScript `interface UserDirectoryEntry` carrying the **input shape** of `recordSignIn(entry: UserDirectoryEntry)`. After the Prisma model rename to `UserDirectoryEntry`, that name would clash with the Prisma-generated row type. The fix is to rename the TS interface to its proper role — `RecordSignInInput` — at the same time. Net effect: clearer naming on both sides (the call-input name now describes the call, the persisted-row type name now describes the row). ## Recovery procedure (for anyone with the old migration applied locally) The new migration `20260526200000_rename_users_to_user_directory_entries` is a pure `ALTER TABLE ... RENAME` + index renames — Prisma's `migrate dev` runs it forward without prompting: ```bash cd apps/portal-bff && pnpm exec prisma migrate dev # Should report: "Applied migration `20260526200000_rename_users_to_user_directory_entries`" ``` No `down -v` needed — existing data carries over. ## Test plan - [x] `node scripts/check-catalogue-drift.mjs` — clean (4 / 24 / 7). - [x] `node --test scripts/check-catalogue-drift.spec.mjs` — 22 tests passing. - [x] `pnpm exec prettier --check` clean on the touched files. - [x] Sweep grep — zero leftover `model User`, `prisma.user.`, `Prisma.UserWhereInput`, `interface UserDirectoryEntry`, or `public.users` references anywhere under `apps/portal-bff/src/` or `apps/portal-bff/prisma/`. - [ ] **Locally**: `pnpm exec prisma migrate dev` applies the rename migration cleanly; `pnpm exec nx test portal-bff` runs the updated specs green. - [ ] **Review focus** — the two-rename rationale in `user-directory.service.ts`, the migration's `ALTER INDEX` clauses (don't forget those — `ALTER TABLE ... RENAME` does NOT cascade to index names in Postgres), the unchanged class/URL/DTO/local-interface identifiers in `admin-users-reader.service.ts`. ## Why ship as a separate PR ADR-0026 PR 1's nominal scope is `Person` + `User` + `UserScope` + provisioner + drift gate + PrincipalBuilder — already ~15+ files touched. Folding the rename into that PR would mix mechanical refactor with new design. Splitting keeps both PRs reviewable for what they actually do. ## What's next (post-merge) **ADR-0026 PR 1** — `Person` + new `User` + `UserScope` schema + `PersonAndUserProvisioner` wired into `SessionEstablisher` + `Person.source` catalogue + drift-gate extension + `PrincipalBuilder` populating `Principal.user.{id, personId}` from real rows. Now unblocked. --------- Co-authored-by: Julien Gautier Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/230 --- .../migration.sql | 19 +++++++++++++ apps/portal-bff/prisma/schema.prisma | 10 +++++-- .../admin/admin-users-reader.service.spec.ts | 22 +++++++-------- .../src/admin/admin-users-reader.service.ts | 27 ++++++++++--------- .../src/users/user-directory.service.spec.ts | 14 +++++----- .../src/users/user-directory.service.ts | 10 ++++--- 6 files changed, 66 insertions(+), 36 deletions(-) create mode 100644 apps/portal-bff/prisma/migrations/20260526200000_rename_users_to_user_directory_entries/migration.sql diff --git a/apps/portal-bff/prisma/migrations/20260526200000_rename_users_to_user_directory_entries/migration.sql b/apps/portal-bff/prisma/migrations/20260526200000_rename_users_to_user_directory_entries/migration.sql new file mode 100644 index 0000000..fa7e3bd --- /dev/null +++ b/apps/portal-bff/prisma/migrations/20260526200000_rename_users_to_user_directory_entries/migration.sql @@ -0,0 +1,19 @@ +-- Rename `users` -> `user_directory_entries`. +-- +-- Prepares for ADR-0026 PR 1 which introduces a NEW `User` model +-- (UUID PK + FK to `Person` + `lastSignInAt`) with materially +-- different semantics. The existing model (ADR-0020 sign-in cache, +-- Entra `oid` as PK) keeps its role but gets a more precise name +-- that won't collide. +-- +-- Mechanical refactor only — same columns, same indexes, same +-- constraints. ADR-0026 PR 1 lands the new `users` table with +-- the new semantics. + +ALTER TABLE "users" RENAME TO "user_directory_entries"; + +-- Rename the PK constraint + indexes so they track the new table name. +-- Postgres doesn't auto-rename these on ALTER TABLE RENAME. +ALTER INDEX "users_pkey" RENAME TO "user_directory_entries_pkey"; +ALTER INDEX "users_last_seen_at_idx" RENAME TO "user_directory_entries_last_seen_at_idx"; +ALTER INDEX "users_username_idx" RENAME TO "user_directory_entries_username_idx"; diff --git a/apps/portal-bff/prisma/schema.prisma b/apps/portal-bff/prisma/schema.prisma index 4fce951..24b3279 100644 --- a/apps/portal-bff/prisma/schema.prisma +++ b/apps/portal-bff/prisma/schema.prisma @@ -67,6 +67,12 @@ enum AuditOutcome { // (combined with the salted hash) — never the BFF's primary actor // identifier elsewhere. // +// **Distinct from the upcoming ADR-0026 `User` model.** That one +// (UUID PK + FK to `Person` + lazy-created at OIDC callback) is the +// portal-account overlay on a `Person` golden record. This +// `UserDirectoryEntry` is the ADR-0020 sign-in cache — different +// semantics, kept apart so neither concept overloads the other. +// // **No PII redaction on read.** Per ADR-0013 the audit module // hashes the actor id to defend against an audit-log dump leaking // who-did-what. This table is the *deliberate* PII storage: an @@ -74,7 +80,7 @@ enum AuditOutcome { // usernames. The trust boundary is the admin role gate // (ADR-0020 §"Auth — `admin` role claim"). -model User { +model UserDirectoryEntry { // Entra `oid` — stable per-user identifier inside the tenant. // Used as the natural primary key. Per-tenant uniqueness is // sufficient: the dual-audience design (ADR-0008) currently @@ -94,7 +100,7 @@ model User { // without scanning audit.events. lastSeenAt DateTime @default(now()) @map("last_seen_at") @db.Timestamptz(6) - @@map("users") + @@map("user_directory_entries") @@schema("public") @@index([lastSeenAt(sort: Desc)]) @@index([username]) diff --git a/apps/portal-bff/src/admin/admin-users-reader.service.spec.ts b/apps/portal-bff/src/admin/admin-users-reader.service.spec.ts index 401e396..741ab3d 100644 --- a/apps/portal-bff/src/admin/admin-users-reader.service.spec.ts +++ b/apps/portal-bff/src/admin/admin-users-reader.service.spec.ts @@ -3,7 +3,7 @@ import { PrismaService } from 'nestjs-prisma'; import { AdminUsersReader } from './admin-users-reader.service'; interface MockPrisma { - user: { + userDirectoryEntry: { count: jest.Mock; findMany: jest.Mock; }; @@ -14,7 +14,7 @@ function buildPrisma(opts?: { count?: number; items?: unknown[] }): MockPrisma { const count = jest.fn().mockResolvedValue(opts?.count ?? 0); const findMany = jest.fn().mockResolvedValue(opts?.items ?? []); return { - user: { count, findMany }, + userDirectoryEntry: { count, findMany }, // The reader calls $transaction with the promises returned by // `count()` + `findMany()` — the operations have already fired // by the time $transaction sees them. The mock just resolves @@ -48,8 +48,8 @@ describe('AdminUsersReader.findUsers', () => { const reader = await createSubject(prisma); const page = await reader.findUsers({}); expect(prisma.$transaction).toHaveBeenCalledTimes(1); - expect(prisma.user.count).toHaveBeenCalledTimes(1); - expect(prisma.user.findMany).toHaveBeenCalledTimes(1); + expect(prisma.userDirectoryEntry.count).toHaveBeenCalledTimes(1); + expect(prisma.userDirectoryEntry.findMany).toHaveBeenCalledTimes(1); expect(page.total).toBe(5); expect(page.items).toHaveLength(1); }); @@ -73,7 +73,7 @@ describe('AdminUsersReader.findUsers', () => { const prisma = buildPrisma(); const reader = await createSubject(prisma); await reader.findUsers({}); - const findManyArgs = prisma.user.findMany.mock.calls[0]?.[0] as { + const findManyArgs = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as { orderBy: ReadonlyArray>; }; expect(findManyArgs.orderBy).toEqual([{ lastSeenAt: 'desc' }, { oid: 'asc' }]); @@ -83,7 +83,7 @@ describe('AdminUsersReader.findUsers', () => { const prisma = buildPrisma(); const reader = await createSubject(prisma); await reader.findUsers({ username: 'jane' }); - const args = prisma.user.findMany.mock.calls[0]?.[0] as { + const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as { where: { username?: { startsWith?: string } }; }; expect(args.where.username).toEqual({ startsWith: 'jane' }); @@ -93,7 +93,7 @@ describe('AdminUsersReader.findUsers', () => { const prisma = buildPrisma(); const reader = await createSubject(prisma); await reader.findUsers({ displayName: 'doe' }); - const args = prisma.user.findMany.mock.calls[0]?.[0] as { + const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as { where: { displayName?: { contains?: string; mode?: string } }; }; expect(args.where.displayName).toEqual({ contains: 'doe', mode: 'insensitive' }); @@ -106,7 +106,7 @@ describe('AdminUsersReader.findUsers', () => { lastSeenAtFrom: '2026-05-01T00:00:00.000Z', lastSeenAtTo: '2026-05-14T23:59:59.999Z', }); - const args = prisma.user.findMany.mock.calls[0]?.[0] as { + const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as { where: { lastSeenAt?: { gte?: Date; lt?: Date } }; }; expect(args.where.lastSeenAt?.gte).toBeInstanceOf(Date); @@ -119,7 +119,7 @@ describe('AdminUsersReader.findUsers', () => { const prisma = buildPrisma(); const reader = await createSubject(prisma); const page = await reader.findUsers({}); - const args = prisma.user.findMany.mock.calls[0]?.[0] as { + const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as { take: number; skip: number; }; @@ -133,7 +133,7 @@ describe('AdminUsersReader.findUsers', () => { const prisma = buildPrisma(); const reader = await createSubject(prisma); await reader.findUsers({ limit: 1000 }); - const args = prisma.user.findMany.mock.calls[0]?.[0] as { take: number }; + const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as { take: number }; expect(args.take).toBe(200); }); @@ -141,7 +141,7 @@ describe('AdminUsersReader.findUsers', () => { const prisma = buildPrisma(); const reader = await createSubject(prisma); await reader.findUsers({}); - const args = prisma.user.findMany.mock.calls[0]?.[0] as { where: object }; + const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as { where: object }; expect(args.where).toEqual({}); }); }); diff --git a/apps/portal-bff/src/admin/admin-users-reader.service.ts b/apps/portal-bff/src/admin/admin-users-reader.service.ts index 62283b5..f5a50b0 100644 --- a/apps/portal-bff/src/admin/admin-users-reader.service.ts +++ b/apps/portal-bff/src/admin/admin-users-reader.service.ts @@ -4,9 +4,9 @@ import { PrismaService } from 'nestjs-prisma'; import { DEFAULT_LIMIT, MAX_LIMIT, type AdminUsersQueryDto } from './users-query.dto'; /** - * SPA-facing projection of a row from `public.users`. Mirrors the - * Prisma model but exposes ISO-string timestamps so the SPA never - * has to know about `Date` serialisation conventions. + * SPA-facing projection of a row from `public.user_directory_entries`. + * Mirrors the Prisma model but exposes ISO-string timestamps so the + * SPA never has to know about `Date` serialisation conventions. */ export interface AdminUserDto { readonly oid: string; @@ -26,14 +26,15 @@ export interface AdminUsersPage { } /** - * `AdminUsersReader` — query side of the `public.users` directory - * per ADR-0020 §"v1 scope — User list (read-only)". Drives the - * `GET /api/admin/users` admin endpoint. + * `AdminUsersReader` — query side of the + * `public.user_directory_entries` directory per ADR-0020 §"v1 scope + * — User list (read-only)". Drives the `GET /api/admin/users` admin + * endpoint. * * Uses Prisma's typed client directly — unlike `AuditReader`, no - * `SET LOCAL ROLE` is needed because `public.users` has no - * role-based privilege gate (it's a regular business table; the - * trust boundary is the `@RequireAdmin` guard on the controller). + * `SET LOCAL ROLE` is needed because `public.user_directory_entries` + * has no role-based privilege gate (it's a regular business table; + * the trust boundary is the `@RequireAdmin` guard on the controller). * * Default order: `last_seen_at DESC` — the "most recently active" * sort the admin UI most often wants. Falls back to `oid ASC` as @@ -55,8 +56,8 @@ export class AdminUsersReader { // otherwise produce an off-by-one between the count and the // items. const [total, items] = await this.prisma.$transaction([ - this.prisma.user.count({ where }), - this.prisma.user.findMany({ + this.prisma.userDirectoryEntry.count({ where }), + this.prisma.userDirectoryEntry.findMany({ where, orderBy: [{ lastSeenAt: 'desc' }, { oid: 'asc' }], take: limit, @@ -73,8 +74,8 @@ export class AdminUsersReader { } } -function buildWhere(filters: AdminUsersQueryDto): Prisma.UserWhereInput { - const where: Prisma.UserWhereInput = {}; +function buildWhere(filters: AdminUsersQueryDto): Prisma.UserDirectoryEntryWhereInput { + const where: Prisma.UserDirectoryEntryWhereInput = {}; if (filters.username !== undefined) { where.username = { startsWith: filters.username }; } diff --git a/apps/portal-bff/src/users/user-directory.service.spec.ts b/apps/portal-bff/src/users/user-directory.service.spec.ts index dc758f3..65c11a1 100644 --- a/apps/portal-bff/src/users/user-directory.service.spec.ts +++ b/apps/portal-bff/src/users/user-directory.service.spec.ts @@ -1,9 +1,9 @@ import type { Logger } from 'nestjs-pino'; import type { PrismaService } from 'nestjs-prisma'; -import { UserDirectoryService, type UserDirectoryEntry } from './user-directory.service'; +import { UserDirectoryService, type RecordSignInInput } from './user-directory.service'; interface PrismaStub { - user: { + userDirectoryEntry: { upsert: jest.Mock; }; } @@ -22,7 +22,7 @@ function makeSubject(opts?: { upsert?: jest.Mock }): { logger: ReturnType; } { const prisma: PrismaStub = { - user: { + userDirectoryEntry: { upsert: opts?.upsert ?? jest.fn().mockResolvedValue(undefined), }, }; @@ -31,7 +31,7 @@ function makeSubject(opts?: { upsert?: jest.Mock }): { return { service, prisma, logger }; } -const ENTRY: UserDirectoryEntry = { +const ENTRY: RecordSignInInput = { oid: 'user-oid', tid: 'tenant-1', username: 'jane.doe@apf.example', @@ -43,8 +43,8 @@ describe('UserDirectoryService.recordSignIn', () => { it('issues an upsert keyed by oid with the right create / update payloads', async () => { const { service, prisma } = makeSubject(); await service.recordSignIn(ENTRY); - expect(prisma.user.upsert).toHaveBeenCalledTimes(1); - const args = prisma.user.upsert.mock.calls[0]?.[0] as { + expect(prisma.userDirectoryEntry.upsert).toHaveBeenCalledTimes(1); + const args = prisma.userDirectoryEntry.upsert.mock.calls[0]?.[0] as { where: { oid: string }; create: Record; update: Record; @@ -79,7 +79,7 @@ describe('UserDirectoryService.recordSignIn', () => { displayName: 'Jane Updated', username: 'jane2@apf.example', }); - const args = prisma.user.upsert.mock.calls[0]?.[0] as { + const args = prisma.userDirectoryEntry.upsert.mock.calls[0]?.[0] as { update: Record; }; expect(args.update['displayName']).toBe('Jane Updated'); diff --git a/apps/portal-bff/src/users/user-directory.service.ts b/apps/portal-bff/src/users/user-directory.service.ts index cd417e4..a31e814 100644 --- a/apps/portal-bff/src/users/user-directory.service.ts +++ b/apps/portal-bff/src/users/user-directory.service.ts @@ -7,8 +7,12 @@ import { PrismaService } from 'nestjs-prisma'; * about. Defined here (rather than importing the full session-side * type) so the directory service stays decoupled from the auth * module's internal shape and the spec can hand-roll the input. + * + * Distinct from the Prisma-generated `UserDirectoryEntry` type + * (the full DB row shape) — this is the input to `recordSignIn`, + * not the persisted entry itself. */ -export interface UserDirectoryEntry { +export interface RecordSignInInput { readonly oid: string; readonly tid: string; readonly username: string; @@ -57,9 +61,9 @@ export class UserDirectoryService { * don't apply; the field is set once via the DEFAULT clause on * INSERT and the UPDATE branch leaves it alone. */ - async recordSignIn(entry: UserDirectoryEntry): Promise { + async recordSignIn(entry: RecordSignInInput): Promise { try { - await this.prisma.user.upsert({ + await this.prisma.userDirectoryEntry.upsert({ where: { oid: entry.oid }, create: { oid: entry.oid,