refactor(users): rename Prisma User model to UserDirectoryEntry
Mechanical refactor, prerequisite to ADR-0026 PR 1. Frees the `User` name for the upcoming ADR-0026 model (UUID PK + FK to Person + lastSignInAt), which has materially different semantics from the ADR-0020 user-directory cache. Zero behavioural change. Same columns, same indexes, same constraints, same call sites - just a different identifier on the model, the table, and the corresponding Prisma client field. Two renames in user-directory.service.ts: the Prisma model rename collided with an existing TS interface also called UserDirectoryEntry (which was actually the input shape of recordSignIn). The interface gets renamed to RecordSignInInput at the same time, which is its correct semantic name. Net effect: clearer on both sides. Postgres ALTER TABLE RENAME does NOT cascade to PK constraint / index names; the migration explicitly ALTER INDEXes the three named indexes (pkey + last_seen_at + username). 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 "admin user list" URL semantics hold regardless of the backing table name.
This commit is contained in:
+19
@@ -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";
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user