refactor(users): rename Prisma User model to UserDirectoryEntry #230

Merged
julien merged 1 commits from fix/rename-user-to-user-directory-entry into main 2026-05-26 12:42:24 +02:00
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 // (combined with the salted hash) — never the BFF's primary actor
// identifier elsewhere. // 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 // **No PII redaction on read.** Per ADR-0013 the audit module
// hashes the actor id to defend against an audit-log dump leaking // hashes the actor id to defend against an audit-log dump leaking
// who-did-what. This table is the *deliberate* PII storage: an // 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 // usernames. The trust boundary is the admin role gate
// (ADR-0020 §"Auth — `admin` role claim"). // (ADR-0020 §"Auth — `admin` role claim").
model User { model UserDirectoryEntry {
// Entra `oid` — stable per-user identifier inside the tenant. // Entra `oid` — stable per-user identifier inside the tenant.
// Used as the natural primary key. Per-tenant uniqueness is // Used as the natural primary key. Per-tenant uniqueness is
// sufficient: the dual-audience design (ADR-0008) currently // sufficient: the dual-audience design (ADR-0008) currently
@@ -94,7 +100,7 @@ model User {
// without scanning audit.events. // without scanning audit.events.
lastSeenAt DateTime @default(now()) @map("last_seen_at") @db.Timestamptz(6) lastSeenAt DateTime @default(now()) @map("last_seen_at") @db.Timestamptz(6)
@@map("users") @@map("user_directory_entries")
@@schema("public") @@schema("public")
@@index([lastSeenAt(sort: Desc)]) @@index([lastSeenAt(sort: Desc)])
@@index([username]) @@index([username])
@@ -3,7 +3,7 @@ import { PrismaService } from 'nestjs-prisma';
import { AdminUsersReader } from './admin-users-reader.service'; import { AdminUsersReader } from './admin-users-reader.service';
interface MockPrisma { interface MockPrisma {
user: { userDirectoryEntry: {
count: jest.Mock; count: jest.Mock;
findMany: 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 count = jest.fn().mockResolvedValue(opts?.count ?? 0);
const findMany = jest.fn().mockResolvedValue(opts?.items ?? []); const findMany = jest.fn().mockResolvedValue(opts?.items ?? []);
return { return {
user: { count, findMany }, userDirectoryEntry: { count, findMany },
// The reader calls $transaction with the promises returned by // The reader calls $transaction with the promises returned by
// `count()` + `findMany()` — the operations have already fired // `count()` + `findMany()` — the operations have already fired
// by the time $transaction sees them. The mock just resolves // by the time $transaction sees them. The mock just resolves
@@ -48,8 +48,8 @@ describe('AdminUsersReader.findUsers', () => {
const reader = await createSubject(prisma); const reader = await createSubject(prisma);
const page = await reader.findUsers({}); const page = await reader.findUsers({});
expect(prisma.$transaction).toHaveBeenCalledTimes(1); expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(prisma.user.count).toHaveBeenCalledTimes(1); expect(prisma.userDirectoryEntry.count).toHaveBeenCalledTimes(1);
expect(prisma.user.findMany).toHaveBeenCalledTimes(1); expect(prisma.userDirectoryEntry.findMany).toHaveBeenCalledTimes(1);
expect(page.total).toBe(5); expect(page.total).toBe(5);
expect(page.items).toHaveLength(1); expect(page.items).toHaveLength(1);
}); });
@@ -73,7 +73,7 @@ describe('AdminUsersReader.findUsers', () => {
const prisma = buildPrisma(); const prisma = buildPrisma();
const reader = await createSubject(prisma); const reader = await createSubject(prisma);
await reader.findUsers({}); 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>>; orderBy: ReadonlyArray<Record<string, string>>;
}; };
expect(findManyArgs.orderBy).toEqual([{ lastSeenAt: 'desc' }, { oid: 'asc' }]); expect(findManyArgs.orderBy).toEqual([{ lastSeenAt: 'desc' }, { oid: 'asc' }]);
@@ -83,7 +83,7 @@ describe('AdminUsersReader.findUsers', () => {
const prisma = buildPrisma(); const prisma = buildPrisma();
const reader = await createSubject(prisma); const reader = await createSubject(prisma);
await reader.findUsers({ username: 'jane' }); 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 } }; where: { username?: { startsWith?: string } };
}; };
expect(args.where.username).toEqual({ startsWith: 'jane' }); expect(args.where.username).toEqual({ startsWith: 'jane' });
@@ -93,7 +93,7 @@ describe('AdminUsersReader.findUsers', () => {
const prisma = buildPrisma(); const prisma = buildPrisma();
const reader = await createSubject(prisma); const reader = await createSubject(prisma);
await reader.findUsers({ displayName: 'doe' }); 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 } }; where: { displayName?: { contains?: string; mode?: string } };
}; };
expect(args.where.displayName).toEqual({ contains: 'doe', mode: 'insensitive' }); expect(args.where.displayName).toEqual({ contains: 'doe', mode: 'insensitive' });
@@ -106,7 +106,7 @@ describe('AdminUsersReader.findUsers', () => {
lastSeenAtFrom: '2026-05-01T00:00:00.000Z', lastSeenAtFrom: '2026-05-01T00:00:00.000Z',
lastSeenAtTo: '2026-05-14T23:59:59.999Z', 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 } }; where: { lastSeenAt?: { gte?: Date; lt?: Date } };
}; };
expect(args.where.lastSeenAt?.gte).toBeInstanceOf(Date); expect(args.where.lastSeenAt?.gte).toBeInstanceOf(Date);
@@ -119,7 +119,7 @@ describe('AdminUsersReader.findUsers', () => {
const prisma = buildPrisma(); const prisma = buildPrisma();
const reader = await createSubject(prisma); const reader = await createSubject(prisma);
const page = await reader.findUsers({}); 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; take: number;
skip: number; skip: number;
}; };
@@ -133,7 +133,7 @@ describe('AdminUsersReader.findUsers', () => {
const prisma = buildPrisma(); const prisma = buildPrisma();
const reader = await createSubject(prisma); const reader = await createSubject(prisma);
await reader.findUsers({ limit: 1000 }); 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); expect(args.take).toBe(200);
}); });
@@ -141,7 +141,7 @@ describe('AdminUsersReader.findUsers', () => {
const prisma = buildPrisma(); const prisma = buildPrisma();
const reader = await createSubject(prisma); const reader = await createSubject(prisma);
await reader.findUsers({}); 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({}); expect(args.where).toEqual({});
}); });
}); });
@@ -4,9 +4,9 @@ import { PrismaService } from 'nestjs-prisma';
import { DEFAULT_LIMIT, MAX_LIMIT, type AdminUsersQueryDto } from './users-query.dto'; import { DEFAULT_LIMIT, MAX_LIMIT, type AdminUsersQueryDto } from './users-query.dto';
/** /**
* SPA-facing projection of a row from `public.users`. Mirrors the * SPA-facing projection of a row from `public.user_directory_entries`.
* Prisma model but exposes ISO-string timestamps so the SPA never * Mirrors the Prisma model but exposes ISO-string timestamps so the
* has to know about `Date` serialisation conventions. * SPA never has to know about `Date` serialisation conventions.
*/ */
export interface AdminUserDto { export interface AdminUserDto {
readonly oid: string; readonly oid: string;
@@ -26,14 +26,15 @@ export interface AdminUsersPage {
} }
/** /**
* `AdminUsersReader` — query side of the `public.users` directory * `AdminUsersReader` — query side of the
* per ADR-0020 §"v1 scope — User list (read-only)". Drives the * `public.user_directory_entries` directory per ADR-0020 §"v1 scope
* `GET /api/admin/users` admin endpoint. * — User list (read-only)". Drives the `GET /api/admin/users` admin
* endpoint.
* *
* Uses Prisma's typed client directly — unlike `AuditReader`, no * Uses Prisma's typed client directly — unlike `AuditReader`, no
* `SET LOCAL ROLE` is needed because `public.users` has no * `SET LOCAL ROLE` is needed because `public.user_directory_entries`
* role-based privilege gate (it's a regular business table; the * has no role-based privilege gate (it's a regular business table;
* trust boundary is the `@RequireAdmin` guard on the controller). * the trust boundary is the `@RequireAdmin` guard on the controller).
* *
* Default order: `last_seen_at DESC` — the "most recently active" * Default order: `last_seen_at DESC` — the "most recently active"
* sort the admin UI most often wants. Falls back to `oid ASC` as * 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 // otherwise produce an off-by-one between the count and the
// items. // items.
const [total, items] = await this.prisma.$transaction([ const [total, items] = await this.prisma.$transaction([
this.prisma.user.count({ where }), this.prisma.userDirectoryEntry.count({ where }),
this.prisma.user.findMany({ this.prisma.userDirectoryEntry.findMany({
where, where,
orderBy: [{ lastSeenAt: 'desc' }, { oid: 'asc' }], orderBy: [{ lastSeenAt: 'desc' }, { oid: 'asc' }],
take: limit, take: limit,
@@ -73,8 +74,8 @@ export class AdminUsersReader {
} }
} }
function buildWhere(filters: AdminUsersQueryDto): Prisma.UserWhereInput { function buildWhere(filters: AdminUsersQueryDto): Prisma.UserDirectoryEntryWhereInput {
const where: Prisma.UserWhereInput = {}; const where: Prisma.UserDirectoryEntryWhereInput = {};
if (filters.username !== undefined) { if (filters.username !== undefined) {
where.username = { startsWith: filters.username }; where.username = { startsWith: filters.username };
} }
@@ -1,9 +1,9 @@
import type { Logger } from 'nestjs-pino'; import type { Logger } from 'nestjs-pino';
import type { PrismaService } from 'nestjs-prisma'; import type { PrismaService } from 'nestjs-prisma';
import { UserDirectoryService, type UserDirectoryEntry } from './user-directory.service'; import { UserDirectoryService, type RecordSignInInput } from './user-directory.service';
interface PrismaStub { interface PrismaStub {
user: { userDirectoryEntry: {
upsert: jest.Mock; upsert: jest.Mock;
}; };
} }
@@ -22,7 +22,7 @@ function makeSubject(opts?: { upsert?: jest.Mock }): {
logger: ReturnType<typeof makeLogger>; logger: ReturnType<typeof makeLogger>;
} { } {
const prisma: PrismaStub = { const prisma: PrismaStub = {
user: { userDirectoryEntry: {
upsert: opts?.upsert ?? jest.fn().mockResolvedValue(undefined), upsert: opts?.upsert ?? jest.fn().mockResolvedValue(undefined),
}, },
}; };
@@ -31,7 +31,7 @@ function makeSubject(opts?: { upsert?: jest.Mock }): {
return { service, prisma, logger }; return { service, prisma, logger };
} }
const ENTRY: UserDirectoryEntry = { const ENTRY: RecordSignInInput = {
oid: 'user-oid', oid: 'user-oid',
tid: 'tenant-1', tid: 'tenant-1',
username: 'jane.doe@apf.example', 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 () => { it('issues an upsert keyed by oid with the right create / update payloads', async () => {
const { service, prisma } = makeSubject(); const { service, prisma } = makeSubject();
await service.recordSignIn(ENTRY); await service.recordSignIn(ENTRY);
expect(prisma.user.upsert).toHaveBeenCalledTimes(1); expect(prisma.userDirectoryEntry.upsert).toHaveBeenCalledTimes(1);
const args = prisma.user.upsert.mock.calls[0]?.[0] as { const args = prisma.userDirectoryEntry.upsert.mock.calls[0]?.[0] as {
where: { oid: string }; where: { oid: string };
create: Record<string, unknown>; create: Record<string, unknown>;
update: Record<string, unknown>; update: Record<string, unknown>;
@@ -79,7 +79,7 @@ describe('UserDirectoryService.recordSignIn', () => {
displayName: 'Jane Updated', displayName: 'Jane Updated',
username: 'jane2@apf.example', 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>; update: Record<string, unknown>;
}; };
expect(args.update['displayName']).toBe('Jane Updated'); 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 * about. Defined here (rather than importing the full session-side
* type) so the directory service stays decoupled from the auth * type) so the directory service stays decoupled from the auth
* module's internal shape and the spec can hand-roll the input. * 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 oid: string;
readonly tid: string; readonly tid: string;
readonly username: string; readonly username: string;
@@ -57,9 +61,9 @@ export class UserDirectoryService {
* don't apply; the field is set once via the DEFAULT clause on * don't apply; the field is set once via the DEFAULT clause on
* INSERT and the UPDATE branch leaves it alone. * INSERT and the UPDATE branch leaves it alone.
*/ */
async recordSignIn(entry: UserDirectoryEntry): Promise<void> { async recordSignIn(entry: RecordSignInInput): Promise<void> {
try { try {
await this.prisma.user.upsert({ await this.prisma.userDirectoryEntry.upsert({
where: { oid: entry.oid }, where: { oid: entry.oid },
create: { create: {
oid: entry.oid, oid: entry.oid,