refactor(users): rename Prisma User model to UserDirectoryEntry #230
+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