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