refactor(users): rename Prisma User model to UserDirectoryEntry (#230)
CI / check (push) Failing after 3m36s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m46s
CI / a11y (push) Successful in 1m44s
CI / perf (push) Successful in 4m41s

## 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 <julien.gautier@apf.asso.fr>
Reviewed-on: #230
This commit was merged in pull request #230.
This commit is contained in:
2026-05-26 12:42:23 +02:00
parent ff1713eb6d
commit b84b58068a
6 changed files with 66 additions and 36 deletions
@@ -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";
+8 -2
View File
@@ -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])
@@ -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<Record<string, string>>;
};
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({});
});
});
@@ -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 };
}
@@ -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<typeof makeLogger>;
} {
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<string, unknown>;
update: Record<string, unknown>;
@@ -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<string, unknown>;
};
expect(args.update['displayName']).toBe('Jane Updated');
@@ -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<void> {
async recordSignIn(entry: RecordSignInInput): Promise<void> {
try {
await this.prisma.user.upsert({
await this.prisma.userDirectoryEntry.upsert({
where: { oid: entry.oid },
create: {
oid: entry.oid,