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
Showing only changes of commit 8aec83f82e - Show all commits
@@ -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,