feat(portal-bff): admin user-directory read endpoint
CI / scan (pull_request) Successful in 2m29s
CI / commits (pull_request) Successful in 2m45s
CI / check (pull_request) Successful in 2m59s
CI / a11y (pull_request) Successful in 2m27s
CI / perf (pull_request) Successful in 7m2s

Second PR of the portal-admin User-list chantier per ADR-0020 §"v1
scope — User list (read-only)". Ships the read side: paginated,
filterable HTTP endpoint that queries the `public.users` directory
populated at sign-in by PR #140. The SPA viewer screen lands in the
final PR of the chantier.

What lands

- AdminUsersQueryDto (admin/users-query.dto.ts): mirrors
  AdminAuditQueryDto's posture — filters all optional, every
  unknown key rejected by `forbidNonWhitelisted`, limit capped at
  MAX_LIMIT (200) / default 50. Filters: username (exact prefix),
  displayName (case-insensitive contains), audience (workforce |
  customer enum), lastSeenAtFrom/To (ISO-8601).

- AdminUsersReader (admin/admin-users-reader.service.ts): Prisma
  typed client against `public.users` — no `SET LOCAL ROLE` dance
  because `public.users` has no role-based privilege gate; the
  trust boundary is the controller's @RequireAdmin guard. Order:
  `last_seen_at DESC, oid ASC` (the second clause is a deterministic
  tie-breaker for pagination during sign-in bursts that share a
  timestamp). COUNT + SELECT run in a single Prisma transaction so
  the `total` reported to the SPA matches what's on the page even
  under a concurrent sign-in.

- AdminUsersController (admin/admin-users.controller.ts):
  GET /api/admin/users, @RequireAdmin at the class level, forwards
  the validated DTO to AdminUsersReader, then emits
  admin.users.query with { filters, resultCount } as the
  fishing-expedition deterrent (mirror of admin.audit.query from
  PR #132).

- AuditWriter.adminUsersQuery() typed method + AdminUsersQueryInput
  type. Same outcome=success / payload shape as adminAuditQuery —
  two distinct event types so a reviewer can pivot directly on
  eventType without parsing payload.

Tests: +18 specs (DTO validation 9, reader 9 covering COUNT+SELECT
ordering, filter forwarding, default/cap on limit, range filter
composition; controller 6; audit typed method 2). All 391 BFF
specs pass.

Out of scope (next PR of the chantier):

- portal-admin /users screen — the SPA viewer with filter form +
  table + pagination, mirroring the /audit page that the audit
  viewer PR shipped.
- Sign-in counts joined from `audit.events` on `actor_id_hash`
  (computed via HashUserIdService on the fly). Deferred until the
  v1 list ships and the admin demand makes itself known.
This commit is contained in:
Julien Gautier
2026-05-14 19:56:11 +02:00
parent aca9e8d155
commit 0817520e49
10 changed files with 607 additions and 2 deletions
@@ -0,0 +1,147 @@
import { Test } from '@nestjs/testing';
import { PrismaService } from 'nestjs-prisma';
import { AdminUsersReader } from './admin-users-reader.service';
interface MockPrisma {
user: {
count: jest.Mock;
findMany: jest.Mock;
};
$transaction: jest.Mock;
}
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 },
// 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
// them together so Prisma's tuple-return contract is honoured.
$transaction: jest
.fn()
.mockImplementation((promises: ReadonlyArray<Promise<unknown>>) => Promise.all(promises)),
};
}
async function createSubject(prisma: MockPrisma): Promise<AdminUsersReader> {
const moduleRef = await Test.createTestingModule({
providers: [AdminUsersReader, { provide: PrismaService, useValue: prisma }],
}).compile();
return moduleRef.get(AdminUsersReader);
}
const ROW = {
oid: 'user-oid',
tid: 'tenant-1',
audience: 'workforce',
username: 'jane.doe@apf.example',
displayName: 'Jane Doe',
firstSeenAt: new Date('2026-05-10T10:00:00.000Z'),
lastSeenAt: new Date('2026-05-14T08:30:00.000Z'),
};
describe('AdminUsersReader.findUsers', () => {
it('issues a COUNT + SELECT in a single Prisma transaction', async () => {
const prisma = buildPrisma({ count: 5, items: [ROW] });
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(page.total).toBe(5);
expect(page.items).toHaveLength(1);
});
it('projects rows into the SPA-facing shape (ISO timestamps)', async () => {
const prisma = buildPrisma({ items: [ROW] });
const reader = await createSubject(prisma);
const page = await reader.findUsers({});
expect(page.items[0]).toEqual({
oid: 'user-oid',
tid: 'tenant-1',
audience: 'workforce',
username: 'jane.doe@apf.example',
displayName: 'Jane Doe',
firstSeenAt: '2026-05-10T10:00:00.000Z',
lastSeenAt: '2026-05-14T08:30:00.000Z',
});
});
it('orders by last_seen_at DESC, oid ASC for deterministic pagination', async () => {
const prisma = buildPrisma();
const reader = await createSubject(prisma);
await reader.findUsers({});
const findManyArgs = prisma.user.findMany.mock.calls[0]?.[0] as {
orderBy: ReadonlyArray<Record<string, string>>;
};
expect(findManyArgs.orderBy).toEqual([{ lastSeenAt: 'desc' }, { oid: 'asc' }]);
});
it('passes startsWith filter on username through to Prisma', async () => {
const prisma = buildPrisma();
const reader = await createSubject(prisma);
await reader.findUsers({ username: 'jane' });
const args = prisma.user.findMany.mock.calls[0]?.[0] as {
where: { username?: { startsWith?: string } };
};
expect(args.where.username).toEqual({ startsWith: 'jane' });
});
it('passes case-insensitive contains filter on displayName through to Prisma', async () => {
const prisma = buildPrisma();
const reader = await createSubject(prisma);
await reader.findUsers({ displayName: 'doe' });
const args = prisma.user.findMany.mock.calls[0]?.[0] as {
where: { displayName?: { contains?: string; mode?: string } };
};
expect(args.where.displayName).toEqual({ contains: 'doe', mode: 'insensitive' });
});
it('combines lastSeenAtFrom and lastSeenAtTo into a single gte/lt range filter', async () => {
const prisma = buildPrisma();
const reader = await createSubject(prisma);
await reader.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 {
where: { lastSeenAt?: { gte?: Date; lt?: Date } };
};
expect(args.where.lastSeenAt?.gte).toBeInstanceOf(Date);
expect(args.where.lastSeenAt?.lt).toBeInstanceOf(Date);
expect(args.where.lastSeenAt?.gte?.toISOString()).toBe('2026-05-01T00:00:00.000Z');
expect(args.where.lastSeenAt?.lt?.toISOString()).toBe('2026-05-14T23:59:59.999Z');
});
it('applies default limit (50) and offset (0) when not provided', async () => {
const prisma = buildPrisma();
const reader = await createSubject(prisma);
const page = await reader.findUsers({});
const args = prisma.user.findMany.mock.calls[0]?.[0] as {
take: number;
skip: number;
};
expect(args.take).toBe(50);
expect(args.skip).toBe(0);
expect(page.limit).toBe(50);
expect(page.offset).toBe(0);
});
it('caps limit at MAX_LIMIT (200) even when a caller bypasses the DTO', async () => {
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 };
expect(args.take).toBe(200);
});
it('emits no WHERE clause when no filter is provided', async () => {
const prisma = buildPrisma();
const reader = await createSubject(prisma);
await reader.findUsers({});
const args = prisma.user.findMany.mock.calls[0]?.[0] as { where: object };
expect(args.where).toEqual({});
});
});