Admin UI Phase 1 Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship an internal-staff-only admin console: Vite+React+Mantine SPA backed by a new NestJS BFF (services/admin-api), authenticated via Google OIDC SSO, surfacing a read-only dashboard (operational health, volume, AI throughput, human-review queue, per-org drill-down).
Architecture: SPA in apps/admin-ui/ → same-origin /api/* calls → services/admin-api/ BFF (holds platform JWT server-side, Redis-backed sessions, admin audit trail) → fan-out to existing services via three new /v1/admin/stats endpoints (clinical-api, ai-review, human-review). Auth flow: Google OIDC → admin-api callback → server-to-server call to services/auth (new POST /v1/oauth/google/callback) → platform JWT with admin:read admin:cross-tenant scopes.
Tech Stack: NestJS 10.4 (matches existing services), Prisma 7 driver-adapter, ioredis, @sa-platform/auth-client for JWT verification + scope guards, Vite 5, React 18, Mantine 7, TanStack Query 5, React Router 6, Vitest, Playwright.
Spec: docs/superpowers/specs/2026-04-27-admin-ui-phase1-design.md
File Structure¶
New service: services/admin-api/¶
services/admin-api/
├── package.json # @sa-platform/admin-api
├── tsconfig.json
├── jest.config.js # Unit tests
├── jest-integration.config.js # Integration tests (real Redis)
├── prisma/
│ └── schema.prisma # AdminUser + AdminAuditLog
├── src/
│ ├── main.ts # Bootstrap + Swagger
│ ├── app.module.ts
│ ├── openapi-extract.ts # For docs/audiences/integrators/openapi/
│ ├── config/
│ │ ├── app-config.ts # Env validation (dev-default guard)
│ │ ├── app-config.spec.ts
│ │ └── config.module.ts # useFactory pattern
│ ├── prisma/
│ │ ├── prisma.module.ts
│ │ └── prisma.service.ts
│ ├── health/
│ │ ├── health.controller.ts # /health, /health/ready
│ │ └── health.controller.spec.ts
│ ├── session/
│ │ ├── session.module.ts
│ │ ├── session.service.ts # Redis CRUD (admin-session: prefix)
│ │ ├── session.service.spec.ts
│ │ ├── session.guard.ts # Reads cookie, attaches session to req
│ │ └── session.types.ts
│ ├── auth/
│ │ ├── auth.module.ts
│ │ ├── auth.controller.ts # /api/auth/google/start | /callback | /logout
│ │ ├── auth.controller.spec.ts
│ │ ├── google-oauth.client.ts # State+nonce, code→token exchange
│ │ ├── google-oauth.client.spec.ts
│ │ ├── auth-service.client.ts # Calls services/auth POST /v1/oauth/google/callback
│ │ └── auth-service.client.spec.ts
│ ├── admin-users/
│ │ ├── admin-users.module.ts
│ │ ├── admin-users.controller.ts # POST /internal/admin-users/resolve
│ │ ├── admin-users.controller.spec.ts
│ │ ├── admin-users.service.ts
│ │ └── admin-users.service.spec.ts
│ ├── audit/
│ │ ├── audit.module.ts
│ │ ├── audit.service.ts
│ │ └── audit.service.spec.ts
│ ├── clients/
│ │ ├── clinical-api.client.ts
│ │ ├── clinical-api.client.spec.ts
│ │ ├── ai-review.client.ts
│ │ ├── ai-review.client.spec.ts
│ │ ├── human-review.client.ts
│ │ └── human-review.client.spec.ts
│ ├── dashboard/
│ │ ├── dashboard.module.ts
│ │ ├── dashboard.controller.ts # /api/dashboard/{health,volume,ai-review,human-review,orgs/:orgId}
│ │ ├── dashboard.controller.spec.ts
│ │ ├── dashboard.service.ts # Fan-out + cache (30s TTL)
│ │ ├── dashboard.service.spec.ts
│ │ └── dashboard.types.ts
│ └── me/
│ ├── me.controller.ts # GET /api/me
│ └── me.controller.spec.ts
└── test/
└── integration/
├── auth.e2e.spec.ts
├── dashboard.e2e.spec.ts
└── setup.ts
New SPA: apps/admin-ui/¶
apps/admin-ui/
├── package.json # @sa-platform/admin-ui
├── tsconfig.json
├── vite.config.ts
├── vitest.config.ts
├── playwright.config.ts
├── index.html
├── src/
│ ├── main.tsx # App entry, Mantine provider, QueryClient
│ ├── app.tsx # Router
│ ├── theme.ts # Mantine theme tokens
│ ├── api/
│ │ ├── client.ts # fetch wrapper
│ │ ├── session.ts # /api/me, /api/auth/logout queries
│ │ └── dashboard.ts # /api/dashboard/* hooks
│ ├── components/
│ │ ├── app-shell.tsx
│ │ ├── require-session.tsx
│ │ ├── metric-card.tsx
│ │ ├── degraded-card.tsx
│ │ └── stub-page.tsx
│ ├── pages/
│ │ ├── login.tsx
│ │ ├── dashboard.tsx
│ │ └── org-detail.tsx
│ └── routes.tsx
├── test/
│ ├── components/
│ │ ├── app-shell.test.tsx
│ │ ├── require-session.test.tsx
│ │ └── metric-card.test.tsx
│ ├── pages/
│ │ ├── login.test.tsx
│ │ └── dashboard.test.tsx
│ └── e2e/
│ └── login-to-dashboard.spec.ts
Extensions to existing services¶
packages/auth-client/src/auth.types.ts # +admin:read, +admin:write scopes
services/auth/src/oauth/
├── oauth.module.ts # New
├── google-oauth.controller.ts # POST /v1/oauth/google/callback
├── google-oauth.controller.spec.ts
├── google-oauth.service.ts # id_token verify, JWT issue
├── google-oauth.service.spec.ts
└── admin-api.client.ts # Calls admin-api /internal/admin-users/resolve
services/clinical-api/src/admin/
├── admin.module.ts
├── admin.controller.ts # GET /v1/admin/stats
├── admin.controller.spec.ts
├── admin.service.ts
└── admin.service.spec.ts
services/ai-review/src/admin/ # Same structure as clinical-api/admin/
services/human-review/src/admin/ # Same structure
Documentation¶
docs/audiences/tech/services/admin-api.md # NEW
docs/audiences/tech/services/admin-ui.md # NEW
docs/audiences/tech/README.md # Update catalogue
docs/audiences/smt/capability-map.md # Add admin rows
docs/audiences/smt/architecture-glance.md # Update inline mermaid
docs/diagrams/architecture-glance.mmd # Update .mmd source
docs/audiences/smt/roadmap.md # Phase 1 ships
docs/audiences/compliance/security-model.md # Add OIDC SSO section
docs/audiences/compliance/audit-trail.md # Add AdminAuditLog
mkdocs.yml # Nav update
scripts/docs/soup-classification.yaml # +Mantine, +TanStack Query, etc.
Conventions used throughout¶
- Branch: Implementation runs on
feat/admin-ui-phase1(create at the start of Task 0). The plan and spec already live ondocs/admin-ui-phase1-specand merge separately. - Commit style: Each task ends with a commit. Messages follow
<scope>: <imperative>(e.g.feat(admin-api): add session service). - TDD ordering: Failing test → run to verify failure → minimal implementation → run to verify pass → commit.
- Existing patterns to mirror: Read
services/human-review/for the closest reference. It's the most recently shipped service and uses every convention this plan inherits (Prisma 7 driver-adapter, AppConfigService withuseFactory, ioredis withOnApplicationShutdown, integration tests withforceExit: true).
Task 0: Branch + scope expansion in @sa-platform/auth-client¶
Files:
- Modify:
packages/auth-client/src/auth.types.ts -
Test:
packages/auth-client/src/auth.types.spec.ts(existing — extend if necessary, but the export is a const so verifying via TypeScript is sufficient) -
[ ] Step 1: Create the implementation branch
git checkout main
git pull --ff-only origin main
git checkout -b feat/admin-ui-phase1
Expected: git branch --show-current returns feat/admin-ui-phase1.
- [ ] Step 2: Read the existing scope set
Run: cat packages/auth-client/src/auth.types.ts
Expected: a file exporting a Scope union and a KNOWN_SCOPES array. Note the exact structure — append to it, do NOT rewrite.
- [ ] Step 3: Add the new scopes
Edit packages/auth-client/src/auth.types.ts. Add these three string literals to the Scope union (and the matching entries to KNOWN_SCOPES):
| 'admin:read'
| 'admin:write'
| 'admin:cross-tenant'
(admin:cross-tenant already exists in spirit elsewhere; verify whether it is already declared in this file before adding it. If yes, add only admin:read and admin:write.)
Add to KNOWN_SCOPES:
'admin:read',
'admin:write',
(Plus 'admin:cross-tenant' only if it was not already present.)
- [ ] Step 4: Run the package's tests
Run: pnpm --filter @sa-platform/auth-client run test
Expected: PASS. The change is purely additive; existing tests continue to pass.
- [ ] Step 5: Run typecheck across the workspace to confirm nothing else broke
Run: pnpm turbo run typecheck
Expected: PASS for all packages.
- [ ] Step 6: Commit
git add packages/auth-client/src/auth.types.ts
git commit -m "feat(auth-client): add admin:read, admin:write scopes"
Task 1: services/clinical-api — /v1/admin/stats endpoint¶
Files:
- Create:
services/clinical-api/src/admin/admin.module.ts - Create:
services/clinical-api/src/admin/admin.controller.ts - Create:
services/clinical-api/src/admin/admin.controller.spec.ts - Create:
services/clinical-api/src/admin/admin.service.ts - Create:
services/clinical-api/src/admin/admin.service.spec.ts - Modify:
services/clinical-api/src/app.module.ts(register the new module)
Endpoint contract¶
GET /v1/admin/stats?range=7d&org=<orgId> (range: today | 7d | 30d; default 7d. org is optional; only honoured if the JWT carries admin:cross-tenant. Without admin:cross-tenant, results are filtered to the JWT's org_id claim.)
Response shape:
{
range: 'today' | '7d' | '30d';
scope: 'platform' | 'org'; // 'platform' if cross-tenant + no org filter
orgId: string | null;
generatedAt: string; // ISO timestamp
cases: {
today: number;
thisWeek: number;
thisMonth: number;
}
perOrg: Array<{ orgId: string; name: string; count: number }>;
perProduct: Array<{ productCode: string; count: number }>;
}
@RequireScopes('admin:read') on the controller method.
- [ ] Step 1: Write the failing controller test
Create services/clinical-api/src/admin/admin.controller.spec.ts:
import { Test } from '@nestjs/testing';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
describe('AdminController', () => {
let controller: AdminController;
const service = {
getStats: jest.fn(),
} as unknown as jest.Mocked<AdminService>;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [AdminController],
providers: [{ provide: AdminService, useValue: service }],
}).compile();
controller = moduleRef.get(AdminController);
jest.clearAllMocks();
});
it('passes range, org, and JWT context through to the service', async () => {
(service.getStats as jest.Mock).mockResolvedValue({
range: '7d',
scope: 'platform',
orgId: null,
generatedAt: '2026-04-27T00:00:00.000Z',
cases: { today: 1, thisWeek: 7, thisMonth: 30 },
perOrg: [],
perProduct: [],
});
const req = {
auth: { orgId: 'org-1', scopes: ['admin:read', 'admin:cross-tenant'] },
};
const result = await controller.getStats(req as never, '7d', 'org-7');
expect(service.getStats).toHaveBeenCalledWith({
range: '7d',
orgFilter: 'org-7',
callerOrgId: 'org-1',
crossTenant: true,
});
expect(result.scope).toBe('platform');
});
it('forces orgFilter to caller orgId when admin:cross-tenant is absent', async () => {
(service.getStats as jest.Mock).mockResolvedValue({
range: '7d',
scope: 'org',
orgId: 'org-1',
generatedAt: '2026-04-27T00:00:00.000Z',
cases: { today: 0, thisWeek: 0, thisMonth: 0 },
perOrg: [],
perProduct: [],
});
const req = { auth: { orgId: 'org-1', scopes: ['admin:read'] } };
await controller.getStats(req as never, '7d', 'org-7');
expect(service.getStats).toHaveBeenCalledWith({
range: '7d',
orgFilter: 'org-1',
callerOrgId: 'org-1',
crossTenant: false,
});
});
});
- [ ] Step 2: Verify it fails
Run: pnpm --filter @clinical-data-model/clinical-api test -- admin.controller
Expected: FAIL — AdminController and AdminService not found.
- [ ] Step 3: Implement the service interface (no logic yet)
Create services/clinical-api/src/admin/admin.service.ts:
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
export type StatsRange = 'today' | '7d' | '30d';
export interface GetStatsParams {
range: StatsRange;
orgFilter: string | null; // null = platform-wide (only when crossTenant is true)
callerOrgId: string;
crossTenant: boolean;
}
export interface AdminStats {
range: StatsRange;
scope: 'platform' | 'org';
orgId: string | null;
generatedAt: string;
cases: { today: number; thisWeek: number; thisMonth: number };
perOrg: Array<{ orgId: string; name: string; count: number }>;
perProduct: Array<{ productCode: string; count: number }>;
}
@Injectable()
export class AdminService {
constructor(private readonly prisma: PrismaService) {}
async getStats(params: GetStatsParams): Promise<AdminStats> {
const scope: 'platform' | 'org' = params.orgFilter ? 'org' : 'platform';
const generatedAt = new Date().toISOString();
// Time bounds
const now = new Date();
const startOfDay = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
);
const startOfWeek = new Date(startOfDay.getTime() - 7 * 24 * 60 * 60 * 1000);
const startOfMonth = new Date(startOfDay.getTime() - 30 * 24 * 60 * 60 * 1000);
const orgWhere = params.orgFilter ? { organisationId: params.orgFilter } : {};
const [today, thisWeek, thisMonth, perOrgRaw, perProductRaw] = await Promise.all([
this.prisma.case.count({ where: { ...orgWhere, createdAt: { gte: startOfDay } } }),
this.prisma.case.count({ where: { ...orgWhere, createdAt: { gte: startOfWeek } } }),
this.prisma.case.count({ where: { ...orgWhere, createdAt: { gte: startOfMonth } } }),
// Per-org breakdown only meaningful when platform-wide
params.orgFilter
? Promise.resolve([])
: this.prisma.case.groupBy({
by: ['organisationId'],
where: { createdAt: { gte: startOfWeek } },
_count: true,
}),
this.prisma.case.groupBy({
by: ['productCode'],
where: { ...orgWhere, createdAt: { gte: startOfWeek } },
_count: true,
}),
]);
// Resolve org names
const perOrg = await Promise.all(
(perOrgRaw as Array<{ organisationId: string; _count: number }>).map(async (row) => {
const org = await this.prisma.organisation.findUnique({
where: { id: row.organisationId },
});
return { orgId: row.organisationId, name: org?.name ?? 'unknown', count: row._count };
}),
);
const perProduct = (perProductRaw as Array<{ productCode: string; _count: number }>).map(
(row) => ({
productCode: row.productCode,
count: row._count,
}),
);
return {
range: params.range,
scope,
orgId: params.orgFilter,
generatedAt,
cases: { today, thisWeek, thisMonth },
perOrg,
perProduct,
};
}
}
(If clinical-api's Prisma model names differ from case / organisation / productCode, adapt to the actual names — read services/clinical-api/prisma/schema.prisma first.)
- [ ] Step 4: Implement the controller
Create services/clinical-api/src/admin/admin.controller.ts:
import { Controller, Get, Query, Req } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { RequireScopes } from '@sa-platform/auth-client';
import { AdminService, AdminStats, StatsRange } from './admin.service';
interface AuthedRequest {
auth: { orgId: string; scopes: string[] };
}
@ApiTags('admin')
@Controller('v1/admin')
export class AdminController {
constructor(private readonly adminService: AdminService) {}
@Get('stats')
@RequireScopes('admin:read')
async getStats(
@Req() req: AuthedRequest,
@Query('range') rangeParam?: string,
@Query('org') orgParam?: string,
): Promise<AdminStats> {
const range: StatsRange = rangeParam === 'today' || rangeParam === '30d' ? rangeParam : '7d';
const crossTenant = req.auth.scopes.includes('admin:cross-tenant');
const orgFilter = crossTenant ? (orgParam ?? null) : req.auth.orgId;
return this.adminService.getStats({
range,
orgFilter,
callerOrgId: req.auth.orgId,
crossTenant,
});
}
}
- [ ] Step 5: Wire the module
Create services/clinical-api/src/admin/admin.module.ts:
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [AdminController],
providers: [AdminService],
})
export class AdminModule {}
Add AdminModule to services/clinical-api/src/app.module.ts imports.
- [ ] Step 6: Run controller tests
Run: pnpm --filter @clinical-data-model/clinical-api test -- admin.controller
Expected: PASS — both test cases.
- [ ] Step 7: Add a service-level test that pins the time bounds
Create services/clinical-api/src/admin/admin.service.spec.ts:
import { AdminService } from './admin.service';
describe('AdminService.getStats', () => {
const mockPrisma = {
case: {
count: jest.fn(),
groupBy: jest.fn(),
},
organisation: { findUnique: jest.fn() },
};
const service = new AdminService(mockPrisma as never);
beforeEach(() => jest.clearAllMocks());
it('returns scope=platform when orgFilter is null', async () => {
mockPrisma.case.count.mockResolvedValue(0);
mockPrisma.case.groupBy.mockResolvedValue([]);
const result = await service.getStats({
range: '7d',
orgFilter: null,
callerOrgId: 'org-1',
crossTenant: true,
});
expect(result.scope).toBe('platform');
expect(result.orgId).toBeNull();
});
it('returns scope=org when orgFilter is set and resolves org names', async () => {
mockPrisma.case.count.mockResolvedValue(5);
mockPrisma.case.groupBy.mockResolvedValue([]);
const result = await service.getStats({
range: '7d',
orgFilter: 'org-7',
callerOrgId: 'org-7',
crossTenant: false,
});
expect(result.scope).toBe('org');
expect(result.orgId).toBe('org-7');
expect(result.cases.thisWeek).toBe(5);
});
});
- [ ] Step 8: Run service tests + typecheck
Run: pnpm --filter @clinical-data-model/clinical-api test -- admin.service
Expected: PASS.
Run: pnpm --filter @clinical-data-model/clinical-api run typecheck
Expected: PASS.
- [ ] Step 9: Commit
git add services/clinical-api/src/admin/ services/clinical-api/src/app.module.ts
git commit -m "feat(clinical-api): add /v1/admin/stats endpoint"
Task 2: services/ai-review — /v1/admin/stats endpoint¶
Files:
- Create:
services/ai-review/src/admin/admin.module.ts - Create:
services/ai-review/src/admin/admin.controller.ts - Create:
services/ai-review/src/admin/admin.controller.spec.ts - Create:
services/ai-review/src/admin/admin.service.ts - Create:
services/ai-review/src/admin/admin.service.spec.ts - Modify:
services/ai-review/src/app.module.ts
Endpoint contract¶
GET /v1/admin/stats?range=7d&org=<orgId> — same query params as Task 1.
Response shape:
{
range: 'today' | '7d' | '30d';
scope: 'platform' | 'org';
orgId: string | null;
generatedAt: string;
inferences: {
today: number;
success24h: number;
failure24h: number;
successRate24h: number; // 0..1
avgLatencyMs24h: number | null;
}
queueDepth: number; // BullMQ waiting count
recentFailures: Array<{ at: string; reason: string }>; // latest 10
}
- [ ] Step 1: Write the failing controller test
Create services/ai-review/src/admin/admin.controller.spec.ts:
import { Test } from '@nestjs/testing';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
describe('AdminController', () => {
let controller: AdminController;
const service = { getStats: jest.fn() } as unknown as jest.Mocked<AdminService>;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [AdminController],
providers: [{ provide: AdminService, useValue: service }],
}).compile();
controller = moduleRef.get(AdminController);
jest.clearAllMocks();
});
it('forwards range, scope context, and org filter', async () => {
(service.getStats as jest.Mock).mockResolvedValue({
range: '7d',
scope: 'platform',
orgId: null,
generatedAt: '2026-04-27T00:00:00.000Z',
inferences: {
today: 0,
success24h: 0,
failure24h: 0,
successRate24h: 0,
avgLatencyMs24h: null,
},
queueDepth: 0,
recentFailures: [],
});
const req = { auth: { orgId: 'org-1', scopes: ['admin:read', 'admin:cross-tenant'] } };
await controller.getStats(req as never, '7d', undefined);
expect(service.getStats).toHaveBeenCalledWith({
range: '7d',
orgFilter: null,
callerOrgId: 'org-1',
crossTenant: true,
});
});
});
- [ ] Step 2: Verify failure
Run: pnpm --filter @sa-platform/ai-review test -- admin.controller
Expected: FAIL.
- [ ] Step 3: Implement service
Create services/ai-review/src/admin/admin.service.ts:
import { Inject, Injectable } from '@nestjs/common';
import { Queue } from 'bullmq';
import { PrismaService } from '../prisma/prisma.service';
import { AI_REVIEW_QUEUE } from '../queue/queue.tokens';
export type StatsRange = 'today' | '7d' | '30d';
export interface GetStatsParams {
range: StatsRange;
orgFilter: string | null;
callerOrgId: string;
crossTenant: boolean;
}
export interface AiReviewAdminStats {
range: StatsRange;
scope: 'platform' | 'org';
orgId: string | null;
generatedAt: string;
inferences: {
today: number;
success24h: number;
failure24h: number;
successRate24h: number;
avgLatencyMs24h: number | null;
};
queueDepth: number;
recentFailures: Array<{ at: string; reason: string }>;
}
@Injectable()
export class AdminService {
constructor(
private readonly prisma: PrismaService,
@Inject(AI_REVIEW_QUEUE) private readonly queue: Queue,
) {}
async getStats(params: GetStatsParams): Promise<AiReviewAdminStats> {
const scope: 'platform' | 'org' = params.orgFilter ? 'org' : 'platform';
const generatedAt = new Date().toISOString();
const now = Date.now();
const startOfDay = new Date(
Date.UTC(
new Date(now).getUTCFullYear(),
new Date(now).getUTCMonth(),
new Date(now).getUTCDate(),
),
);
const last24h = new Date(now - 24 * 60 * 60 * 1000);
const orgWhere = params.orgFilter ? { orgId: params.orgFilter } : {};
const [today, success24h, failure24h, latencyRows, queueDepth, recentFailureRows] =
await Promise.all([
this.prisma.aiReview.count({ where: { ...orgWhere, createdAt: { gte: startOfDay } } }),
this.prisma.aiReview.count({
where: { ...orgWhere, status: 'completed', completedAt: { gte: last24h } },
}),
this.prisma.aiReview.count({
where: { ...orgWhere, status: 'failed', failedAt: { gte: last24h } },
}),
this.prisma.aiReview.findMany({
where: { ...orgWhere, status: 'completed', completedAt: { gte: last24h } },
select: { startedAt: true, completedAt: true },
take: 500,
}),
this.queue.getWaitingCount(),
this.prisma.aiReview.findMany({
where: { ...orgWhere, status: 'failed', failedAt: { gte: last24h } },
orderBy: { failedAt: 'desc' },
take: 10,
select: { failedAt: true, failureReason: true },
}),
]);
const successRate24h =
success24h + failure24h === 0 ? 0 : success24h / (success24h + failure24h);
const latencies: number[] = (
latencyRows as Array<{ startedAt: Date | null; completedAt: Date | null }>
).flatMap((r) =>
r.startedAt && r.completedAt ? [r.completedAt.getTime() - r.startedAt.getTime()] : [],
);
const avgLatencyMs24h = latencies.length
? Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length)
: null;
return {
range: params.range,
scope,
orgId: params.orgFilter,
generatedAt,
inferences: { today, success24h, failure24h, successRate24h, avgLatencyMs24h },
queueDepth,
recentFailures: (
recentFailureRows as Array<{ failedAt: Date; failureReason: string | null }>
).map((r) => ({
at: r.failedAt.toISOString(),
reason: r.failureReason ?? 'unknown',
})),
};
}
}
(If ai-review's Prisma fields differ — failedAt, failureReason may be different — adapt by reading services/ai-review/prisma/schema.prisma.)
- [ ] Step 4: Implement controller
Create services/ai-review/src/admin/admin.controller.ts:
import { Controller, Get, Query, Req } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { RequireScopes } from '@sa-platform/auth-client';
import { AdminService, AiReviewAdminStats, StatsRange } from './admin.service';
interface AuthedRequest {
auth: { orgId: string; scopes: string[] };
}
@ApiTags('admin')
@Controller('v1/admin')
export class AdminController {
constructor(private readonly adminService: AdminService) {}
@Get('stats')
@RequireScopes('admin:read')
async getStats(
@Req() req: AuthedRequest,
@Query('range') rangeParam?: string,
@Query('org') orgParam?: string,
): Promise<AiReviewAdminStats> {
const range: StatsRange = rangeParam === 'today' || rangeParam === '30d' ? rangeParam : '7d';
const crossTenant = req.auth.scopes.includes('admin:cross-tenant');
const orgFilter = crossTenant ? (orgParam ?? null) : req.auth.orgId;
return this.adminService.getStats({
range,
orgFilter,
callerOrgId: req.auth.orgId,
crossTenant,
});
}
}
- [ ] Step 5: Wire the module
Create services/ai-review/src/admin/admin.module.ts:
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { PrismaModule } from '../prisma/prisma.module';
import { QueueModule } from '../queue/queue.module';
@Module({
imports: [PrismaModule, QueueModule],
controllers: [AdminController],
providers: [AdminService],
})
export class AdminModule {}
(QueueModule is the existing module that provides AI_REVIEW_QUEUE. If it's named differently in the actual codebase, adapt — verify by reading services/ai-review/src/app.module.ts.)
Add AdminModule to services/ai-review/src/app.module.ts imports.
- [ ] Step 6: Add a service spec covering rate calculation
Create services/ai-review/src/admin/admin.service.spec.ts:
import { AdminService } from './admin.service';
describe('AdminService.getStats', () => {
const fakeQueue = { getWaitingCount: jest.fn().mockResolvedValue(3) };
const mockPrisma = {
aiReview: {
count: jest.fn(),
findMany: jest.fn(),
},
};
const service = new AdminService(mockPrisma as never, fakeQueue as never);
beforeEach(() => jest.clearAllMocks());
it('reports successRate=0 when there are zero completions and zero failures', async () => {
mockPrisma.aiReview.count
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0);
mockPrisma.aiReview.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
const r = await service.getStats({
range: '7d',
orgFilter: null,
callerOrgId: 'o-1',
crossTenant: true,
});
expect(r.inferences.successRate24h).toBe(0);
expect(r.inferences.avgLatencyMs24h).toBeNull();
expect(r.queueDepth).toBe(3);
});
it('computes avg latency from startedAt/completedAt', async () => {
mockPrisma.aiReview.count
.mockResolvedValueOnce(2)
.mockResolvedValueOnce(2)
.mockResolvedValueOnce(0);
mockPrisma.aiReview.findMany
.mockResolvedValueOnce([
{ startedAt: new Date(0), completedAt: new Date(1000) },
{ startedAt: new Date(0), completedAt: new Date(3000) },
])
.mockResolvedValueOnce([]);
const r = await service.getStats({
range: '7d',
orgFilter: null,
callerOrgId: 'o-1',
crossTenant: true,
});
expect(r.inferences.avgLatencyMs24h).toBe(2000);
expect(r.inferences.successRate24h).toBe(1);
});
});
- [ ] Step 7: Run tests + typecheck
Run: pnpm --filter @sa-platform/ai-review test -- admin
Expected: PASS — both controller and service specs.
Run: pnpm --filter @sa-platform/ai-review run typecheck
Expected: PASS.
- [ ] Step 8: Commit
git add services/ai-review/src/admin/ services/ai-review/src/app.module.ts
git commit -m "feat(ai-review): add /v1/admin/stats endpoint"
Task 3: services/human-review — /v1/admin/stats endpoint¶
Files:
- Create:
services/human-review/src/admin/admin.module.ts - Create:
services/human-review/src/admin/admin.controller.ts - Create:
services/human-review/src/admin/admin.controller.spec.ts - Create:
services/human-review/src/admin/admin.service.ts - Create:
services/human-review/src/admin/admin.service.spec.ts - Modify:
services/human-review/src/app.module.ts
Endpoint contract¶
GET /v1/admin/stats?range=7d&org=<orgId> — same query semantics as Tasks 1 + 2.
Response shape:
{
range: 'today' | '7d' | '30d';
scope: 'platform' | 'org';
orgId: string | null;
generatedAt: string;
queue: {
open: number; // status='open'
claimed: number; // status='claimed'
submitted24h: number;
}
avgTimeToDecisionMs24h: number | null;
declineCount24h: number;
}
- [ ] Step 1: Write the failing controller test
Create services/human-review/src/admin/admin.controller.spec.ts:
import { Test } from '@nestjs/testing';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
describe('AdminController', () => {
let controller: AdminController;
const service = { getStats: jest.fn() } as unknown as jest.Mocked<AdminService>;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [AdminController],
providers: [{ provide: AdminService, useValue: service }],
}).compile();
controller = moduleRef.get(AdminController);
jest.clearAllMocks();
});
it('honours admin:cross-tenant when forwarding org filter', async () => {
(service.getStats as jest.Mock).mockResolvedValue({
range: '7d',
scope: 'platform',
orgId: null,
generatedAt: '2026-04-27T00:00:00.000Z',
queue: { open: 0, claimed: 0, submitted24h: 0 },
avgTimeToDecisionMs24h: null,
declineCount24h: 0,
});
const req = { auth: { orgId: 'org-1', scopes: ['admin:read', 'admin:cross-tenant'] } };
await controller.getStats(req as never, '7d', 'org-7');
expect(service.getStats).toHaveBeenCalledWith({
range: '7d',
orgFilter: 'org-7',
callerOrgId: 'org-1',
crossTenant: true,
});
});
});
- [ ] Step 2: Verify failure
Run: pnpm --filter @sa-platform/human-review test -- admin.controller
Expected: FAIL.
- [ ] Step 3: Implement service
Create services/human-review/src/admin/admin.service.ts:
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
export type StatsRange = 'today' | '7d' | '30d';
export interface GetStatsParams {
range: StatsRange;
orgFilter: string | null;
callerOrgId: string;
crossTenant: boolean;
}
export interface HumanReviewAdminStats {
range: StatsRange;
scope: 'platform' | 'org';
orgId: string | null;
generatedAt: string;
queue: {
open: number;
claimed: number;
submitted24h: number;
};
avgTimeToDecisionMs24h: number | null;
declineCount24h: number;
}
@Injectable()
export class AdminService {
constructor(private readonly prisma: PrismaService) {}
async getStats(params: GetStatsParams): Promise<HumanReviewAdminStats> {
const scope: 'platform' | 'org' = params.orgFilter ? 'org' : 'platform';
const generatedAt = new Date().toISOString();
const last24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
const orgWhere = params.orgFilter ? { orgId: params.orgFilter } : {};
const [open, claimed, submitted24h, declineCount24h, decisionRows] = await Promise.all([
this.prisma.review.count({ where: { ...orgWhere, status: 'open' } }),
this.prisma.review.count({ where: { ...orgWhere, status: 'claimed' } }),
this.prisma.review.count({
where: { ...orgWhere, status: 'submitted', submittedAt: { gte: last24h } },
}),
this.prisma.review.count({
where: { ...orgWhere, status: 'declined', declinedAt: { gte: last24h } },
}),
this.prisma.review.findMany({
where: { ...orgWhere, status: 'submitted', submittedAt: { gte: last24h } },
select: { claimedAt: true, submittedAt: true },
take: 500,
}),
]);
const decisionTimes: number[] = (
decisionRows as Array<{ claimedAt: Date | null; submittedAt: Date | null }>
).flatMap((r) =>
r.claimedAt && r.submittedAt ? [r.submittedAt.getTime() - r.claimedAt.getTime()] : [],
);
const avgTimeToDecisionMs24h = decisionTimes.length
? Math.round(decisionTimes.reduce((a, b) => a + b, 0) / decisionTimes.length)
: null;
return {
range: params.range,
scope,
orgId: params.orgFilter,
generatedAt,
queue: { open, claimed, submitted24h },
avgTimeToDecisionMs24h,
declineCount24h,
};
}
}
(Field names like declinedAt, submittedAt, claimedAt, status enum literals — verify against services/human-review/prisma/schema.prisma.)
- [ ] Step 4: Implement controller
Create services/human-review/src/admin/admin.controller.ts:
import { Controller, Get, Query, Req } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { RequireScopes } from '@sa-platform/auth-client';
import { AdminService, HumanReviewAdminStats, StatsRange } from './admin.service';
interface AuthedRequest {
auth: { orgId: string; scopes: string[] };
}
@ApiTags('admin')
@Controller('v1/admin')
export class AdminController {
constructor(private readonly adminService: AdminService) {}
@Get('stats')
@RequireScopes('admin:read')
async getStats(
@Req() req: AuthedRequest,
@Query('range') rangeParam?: string,
@Query('org') orgParam?: string,
): Promise<HumanReviewAdminStats> {
const range: StatsRange = rangeParam === 'today' || rangeParam === '30d' ? rangeParam : '7d';
const crossTenant = req.auth.scopes.includes('admin:cross-tenant');
const orgFilter = crossTenant ? (orgParam ?? null) : req.auth.orgId;
return this.adminService.getStats({
range,
orgFilter,
callerOrgId: req.auth.orgId,
crossTenant,
});
}
}
- [ ] Step 5: Wire the module
Create services/human-review/src/admin/admin.module.ts:
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [AdminController],
providers: [AdminService],
})
export class AdminModule {}
Add to services/human-review/src/app.module.ts imports.
- [ ] Step 6: Add a service spec
Create services/human-review/src/admin/admin.service.spec.ts:
import { AdminService } from './admin.service';
describe('AdminService.getStats', () => {
const mockPrisma = {
review: { count: jest.fn(), findMany: jest.fn() },
};
const service = new AdminService(mockPrisma as never);
beforeEach(() => jest.clearAllMocks());
it('returns null avg decision time when there are no submissions', async () => {
mockPrisma.review.count
.mockResolvedValueOnce(5) // open
.mockResolvedValueOnce(2) // claimed
.mockResolvedValueOnce(0) // submitted24h
.mockResolvedValueOnce(1); // declineCount24h
mockPrisma.review.findMany.mockResolvedValue([]);
const r = await service.getStats({
range: '7d',
orgFilter: null,
callerOrgId: 'o-1',
crossTenant: true,
});
expect(r.queue).toEqual({ open: 5, claimed: 2, submitted24h: 0 });
expect(r.declineCount24h).toBe(1);
expect(r.avgTimeToDecisionMs24h).toBeNull();
});
it('computes avg decision time from claimedAt → submittedAt', async () => {
mockPrisma.review.count
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(2)
.mockResolvedValueOnce(0);
mockPrisma.review.findMany.mockResolvedValueOnce([
{ claimedAt: new Date(0), submittedAt: new Date(60_000) },
{ claimedAt: new Date(0), submittedAt: new Date(120_000) },
]);
const r = await service.getStats({
range: '7d',
orgFilter: null,
callerOrgId: 'o-1',
crossTenant: true,
});
expect(r.avgTimeToDecisionMs24h).toBe(90_000);
});
});
- [ ] Step 7: Run tests + typecheck
Run: pnpm --filter @sa-platform/human-review test -- admin
Expected: PASS — both specs.
Run: pnpm --filter @sa-platform/human-review run typecheck
Expected: PASS.
- [ ] Step 8: Commit
git add services/human-review/src/admin/ services/human-review/src/app.module.ts
git commit -m "feat(human-review): add /v1/admin/stats endpoint"
Task 4: services/admin-api scaffold + AppConfigService + health¶
Files:
- Create:
services/admin-api/package.json - Create:
services/admin-api/tsconfig.json - Create:
services/admin-api/jest.config.js - Create:
services/admin-api/src/main.ts - Create:
services/admin-api/src/app.module.ts - Create:
services/admin-api/src/openapi-extract.ts - Create:
services/admin-api/src/config/app-config.ts - Create:
services/admin-api/src/config/app-config.spec.ts - Create:
services/admin-api/src/config/config.module.ts - Create:
services/admin-api/src/health/health.controller.ts - Create:
services/admin-api/src/health/health.controller.spec.ts - Modify:
pnpm-workspace.yaml(addservices/*if not already covered) - Modify:
turbo.json(no change expected — verify pipeline picks up the new package)
Patterns to mirror¶
Read services/human-review/ for: package.json scripts, jest.config.js, main.ts (validation pipe + Swagger), config/ (env validation with useFactory), openapi-extract.ts.
- [ ] Step 1: Create
services/admin-api/package.json
{
"name": "@sa-platform/admin-api",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node dist/main.js",
"start:dev": "ts-node --transpile-only src/main.ts",
"test": "jest",
"test:integration": "jest --config jest-integration.config.js --forceExit",
"typecheck": "tsc --noEmit",
"lint": "eslint src --ext .ts"
},
"dependencies": {
"@nestjs/common": "^10.4.0",
"@nestjs/core": "^10.4.0",
"@nestjs/platform-express": "^10.4.0",
"@nestjs/swagger": "^7.4.0",
"@prisma/client": "^7.0.0",
"@sa-platform/auth-client": "workspace:^",
"@sa-platform/common": "workspace:^",
"@sa-platform/events": "workspace:^",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cookie-parser": "^1.4.6",
"ioredis": "^5.4.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/testing": "^10.4.0",
"@sa-platform/eslint-config": "workspace:^",
"@sa-platform/tsconfig": "workspace:^",
"@types/cookie-parser": "^1.4.7",
"@types/jest": "^29.5.12",
"@types/node": "^20.12.0",
"@types/supertest": "^6.0.2",
"jest": "^29.7.0",
"nock": "^13.5.4",
"prisma": "^7.0.0",
"supertest": "^7.0.0",
"ts-jest": "^29.1.4",
"ts-node": "^10.9.2",
"typescript": "^5.5.0"
}
}
- [ ] Step 2: Create
services/admin-api/tsconfig.json
{
"extends": "@sa-platform/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*"]
}
- [ ] Step 3: Create
services/admin-api/jest.config.js
/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
rootDir: 'src',
moduleFileExtensions: ['ts', 'js'],
testRegex: '\\.spec\\.ts$',
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
transform: { '^.+\\.ts$': ['ts-jest', { isolatedModules: true }] },
};
- [ ] Step 4: Write the failing AppConfigService test
Create services/admin-api/src/config/app-config.spec.ts:
import { AppConfigService } from './app-config';
describe('AppConfigService', () => {
const baseEnv = {
NODE_ENV: 'development',
PORT: '3008',
DATABASE_URL: 'mysql://test:test@localhost:3306/admin',
REDIS_URL: 'redis://localhost:6379',
JWKS_URL: 'http://localhost:3000/.well-known/jwks.json',
SERVICE_AUTH_TOKEN: 'service-dev-token',
AUTH_API_BASE_URL: 'http://localhost:3000',
CLINICAL_API_BASE_URL: 'http://localhost:3001',
AI_REVIEW_API_BASE_URL: 'http://localhost:3005',
HUMAN_REVIEW_API_BASE_URL: 'http://localhost:3007',
GOOGLE_CLIENT_ID: 'goog-id',
GOOGLE_CLIENT_SECRET: 'goog-secret',
GOOGLE_REDIRECT_URI: 'http://localhost:3008/api/auth/google/callback',
ADMIN_DOMAIN_ALLOWLIST: 'skinanalytics.co.uk',
SESSION_COOKIE_DOMAIN: 'localhost',
SESSION_TTL_HOURS: '8',
DASHBOARD_CACHE_TTL_SECONDS: '30',
};
it('parses every required field', () => {
const svc = new AppConfigService(baseEnv);
expect(svc.port).toBe(3008);
expect(svc.googleClientId).toBe('goog-id');
expect(svc.adminDomainAllowlist).toEqual(['skinanalytics.co.uk']);
expect(svc.sessionTtlHours).toBe(8);
expect(svc.dashboardCacheTtlSeconds).toBe(30);
});
it('throws if any required env var is missing', () => {
const env = { ...baseEnv } as Record<string, string>;
delete env.GOOGLE_CLIENT_ID;
expect(() => new AppConfigService(env)).toThrow(/GOOGLE_CLIENT_ID is required/);
});
it('refuses dev default secrets in production', () => {
expect(() => new AppConfigService({ ...baseEnv, NODE_ENV: 'production' })).toThrow(
/Refusing to boot with dev default secret in production/,
);
});
it('parses comma-separated allowlist', () => {
const svc = new AppConfigService({
...baseEnv,
ADMIN_DOMAIN_ALLOWLIST: 'a.com, b.co.uk ,c.io',
});
expect(svc.adminDomainAllowlist).toEqual(['a.com', 'b.co.uk', 'c.io']);
});
});
- [ ] Step 5: Verify the test fails
Run: pnpm --filter @sa-platform/admin-api test -- app-config
Expected: FAIL — AppConfigService not defined.
- [ ] Step 6: Implement
AppConfigService
Create services/admin-api/src/config/app-config.ts:
import { Injectable } from '@nestjs/common';
const DEV_DEFAULT_SECRETS = new Set(['admin-dev-secret', 'service-dev-token']);
function required(env: NodeJS.ProcessEnv, key: string): string {
const v = env[key];
if (v === undefined || v === '') throw new Error(`${key} is required`);
return v;
}
function intFromEnv(env: NodeJS.ProcessEnv, key: string, fallback?: number): number {
const raw = env[key];
if (raw === undefined || raw === '') {
if (fallback !== undefined) return fallback;
throw new Error(`${key} is required`);
}
const n = Number(raw);
if (!Number.isFinite(n)) throw new Error(`${key} must be a number`);
return n;
}
function listFromEnv(env: NodeJS.ProcessEnv, key: string): string[] {
const raw = required(env, key);
return raw
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
@Injectable()
export class AppConfigService {
readonly nodeEnv: string;
readonly port: number;
readonly databaseUrl: string;
readonly redisUrl: string;
readonly jwksUrl: string;
readonly serviceAuthToken: string;
readonly authApiBaseUrl: string;
readonly clinicalApiBaseUrl: string;
readonly aiReviewApiBaseUrl: string;
readonly humanReviewApiBaseUrl: string;
readonly googleClientId: string;
readonly googleClientSecret: string;
readonly googleRedirectUri: string;
readonly adminDomainAllowlist: string[];
readonly sessionCookieDomain: string;
readonly sessionTtlHours: number;
readonly dashboardCacheTtlSeconds: number;
constructor(env: NodeJS.ProcessEnv = process.env) {
this.nodeEnv = env.NODE_ENV ?? 'development';
this.port = intFromEnv(env, 'PORT', 3008);
if (this.port <= 0 || this.port > 65535) throw new Error('PORT must be between 1 and 65535');
this.databaseUrl = required(env, 'DATABASE_URL');
this.redisUrl = required(env, 'REDIS_URL');
this.jwksUrl = required(env, 'JWKS_URL');
this.serviceAuthToken = required(env, 'SERVICE_AUTH_TOKEN');
this.authApiBaseUrl = required(env, 'AUTH_API_BASE_URL');
this.clinicalApiBaseUrl = required(env, 'CLINICAL_API_BASE_URL');
this.aiReviewApiBaseUrl = required(env, 'AI_REVIEW_API_BASE_URL');
this.humanReviewApiBaseUrl = required(env, 'HUMAN_REVIEW_API_BASE_URL');
this.googleClientId = required(env, 'GOOGLE_CLIENT_ID');
this.googleClientSecret = required(env, 'GOOGLE_CLIENT_SECRET');
this.googleRedirectUri = required(env, 'GOOGLE_REDIRECT_URI');
this.adminDomainAllowlist = listFromEnv(env, 'ADMIN_DOMAIN_ALLOWLIST');
this.sessionCookieDomain = required(env, 'SESSION_COOKIE_DOMAIN');
this.sessionTtlHours = intFromEnv(env, 'SESSION_TTL_HOURS', 8);
this.dashboardCacheTtlSeconds = intFromEnv(env, 'DASHBOARD_CACHE_TTL_SECONDS', 30);
if (this.nodeEnv === 'production') {
if (DEV_DEFAULT_SECRETS.has(this.serviceAuthToken)) {
throw new Error('Refusing to boot with dev default secret in production');
}
}
}
}
- [ ] Step 7: Implement
ConfigModulewith theuseFactorypattern
Create services/admin-api/src/config/config.module.ts:
import { Global, Module } from '@nestjs/common';
import { AppConfigService } from './app-config';
@Global()
@Module({
providers: [
{
// useFactory (not bare class) is required because AppConfigService's
// constructor takes a NodeJS.ProcessEnv parameter. NestJS DI cannot
// resolve that type as a token and would fail at boot. The factory
// constructs the service explicitly so DI doesn't try to inject the
// process.env parameter.
provide: AppConfigService,
useFactory: () => new AppConfigService(process.env),
},
],
exports: [AppConfigService],
})
export class ConfigModule {}
- [ ] Step 8: Run the AppConfigService test
Run: pnpm --filter @sa-platform/admin-api test -- app-config
Expected: PASS — all four cases.
- [ ] Step 9: Write the failing health controller test
Create services/admin-api/src/health/health.controller.spec.ts:
import { Test } from '@nestjs/testing';
import { HealthController } from './health.controller';
describe('HealthController', () => {
it('returns ok from /health', async () => {
const moduleRef = await Test.createTestingModule({ controllers: [HealthController] }).compile();
const controller = moduleRef.get(HealthController);
expect(controller.live()).toEqual({ status: 'ok' });
});
});
Run: pnpm --filter @sa-platform/admin-api test -- health
Expected: FAIL.
- [ ] Step 10: Implement the health controller
Create services/admin-api/src/health/health.controller.ts:
import { Controller, Get } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
@ApiTags('health')
@Controller('health')
export class HealthController {
@Get()
live(): { status: 'ok' } {
return { status: 'ok' };
}
}
- [ ] Step 11: Bootstrap (
main.ts) andAppModule
Create services/admin-api/src/app.module.ts:
import { Module } from '@nestjs/common';
import { ConfigModule } from './config/config.module';
import { HealthController } from './health/health.controller';
@Module({
imports: [ConfigModule],
controllers: [HealthController],
})
export class AppModule {}
Create services/admin-api/src/main.ts:
import { ValidationPipe, VersioningType } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import * as cookieParser from 'cookie-parser';
import { AppModule } from './app.module';
import { AppConfigService } from './config/app-config';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.use(cookieParser());
app.enableVersioning({ type: VersioningType.URI, prefix: 'v' });
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
// NOTE: Keep this OpenAPI config in sync with src/openapi-extract.ts
// (which generates the static spec for /docs/audiences/integrators/).
const openApiConfig = new DocumentBuilder()
.setTitle('admin-api')
.setDescription('Admin BFF: Google OIDC sessions, dashboard aggregation, audit log')
.setVersion('1.0.0')
.build();
const openApiDocument = SwaggerModule.createDocument(app, openApiConfig);
SwaggerModule.setup('docs', app, openApiDocument, {
jsonDocumentUrl: 'v1/openapi.json',
});
const config = app.get(AppConfigService);
await app.listen(config.port);
console.log(`admin-api listening on :${config.port}`);
}
void bootstrap();
- [ ] Step 12: Implement
openapi-extract.ts
Create services/admin-api/src/openapi-extract.ts:
import 'reflect-metadata';
const DEFAULTS: Record<string, string> = {
NODE_ENV: 'development',
PORT: '3008',
DATABASE_URL: 'mysql://test:test@localhost:3306/admin',
REDIS_URL: 'redis://localhost:6379',
JWKS_URL: 'http://localhost:3000/.well-known/jwks.json',
SERVICE_AUTH_TOKEN: 'service-dev-token',
AUTH_API_BASE_URL: 'http://localhost:3000',
CLINICAL_API_BASE_URL: 'http://localhost:3001',
AI_REVIEW_API_BASE_URL: 'http://localhost:3005',
HUMAN_REVIEW_API_BASE_URL: 'http://localhost:3007',
GOOGLE_CLIENT_ID: 'goog-id',
GOOGLE_CLIENT_SECRET: 'goog-secret',
GOOGLE_REDIRECT_URI: 'http://localhost:3008/api/auth/google/callback',
ADMIN_DOMAIN_ALLOWLIST: 'skinanalytics.co.uk',
SESSION_COOKIE_DOMAIN: 'localhost',
SESSION_TTL_HOURS: '8',
DASHBOARD_CACHE_TTL_SECONDS: '30',
};
for (const [k, v] of Object.entries(DEFAULTS)) {
process.env[k] = process.env[k] ?? v;
}
import { writeFileSync } from 'node:fs';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
async function main(): Promise<void> {
const outPath = process.argv[2];
if (!outPath) {
console.error('usage: openapi-extract.ts <output-path>');
process.exit(1);
}
const app = await NestFactory.create(AppModule, { logger: false, bufferLogs: true });
// NOTE: Keep this OpenAPI config in sync with src/main.ts (the live serving
// path). Both must stay aligned so the static spec matches what the running
// service exposes.
const config = new DocumentBuilder()
.setTitle('admin-api')
.setDescription('Admin BFF: Google OIDC sessions, dashboard aggregation, audit log')
.setVersion('1.0.0')
.build();
const document = SwaggerModule.createDocument(app, config);
writeFileSync(outPath, JSON.stringify(document, null, 2));
console.log(` -> ${outPath}`);
await app.close();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
- [ ] Step 13: Add the service to the OpenAPI extractor's SERVICES list
Modify scripts/docs/openapi.sh — find the SERVICES=(...) line and add admin-api:
SERVICES=(admin-api ai-review auth clinical-api consent human-review notifications orchestrator user-management)
- [ ] Step 14: Run health controller tests + service typecheck
Run: pnpm --filter @sa-platform/admin-api test
Expected: PASS — health and app-config specs.
Run: pnpm --filter @sa-platform/admin-api run typecheck
Expected: PASS.
- [ ] Step 15: Commit
git add services/admin-api/ scripts/docs/openapi.sh
git commit -m "feat(admin-api): scaffold NestJS service with health endpoint and AppConfigService"
Task 5: services/admin-api Prisma — AdminUser + AdminAuditLog¶
Files:
- Create:
services/admin-api/prisma/schema.prisma - Create:
services/admin-api/src/prisma/prisma.module.ts - Create:
services/admin-api/src/prisma/prisma.service.ts - Create:
services/admin-api/src/prisma/prisma.service.spec.ts - Modify:
services/admin-api/src/app.module.ts(importPrismaModule)
Schema¶
AdminUser: source-of-truth row for who can log in. AdminAuditLog: per-action audit row.
- [ ] Step 1: Create the Prisma schema
Create services/admin-api/prisma/schema.prisma:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
enum AdminRole {
admin
support
}
enum AdminStatus {
active
pending
disabled
}
model AdminUser {
id String @id @default(cuid())
email String @unique
displayName String
role AdminRole @default(support)
status AdminStatus @default(pending)
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
auditLogs AdminAuditLog[]
@@index([status])
}
model AdminAuditLog {
id String @id @default(cuid())
actorId String
actor AdminUser @relation(fields: [actorId], references: [id])
action String // e.g. "dashboard.volume.read", "auth.login", "auth.logout"
target String? // e.g. "ALL" or an orgId
metadata Json?
at DateTime @default(now())
@@index([actorId, at])
@@index([action, at])
}
- [ ] Step 2: Generate the Prisma client
cd services/admin-api && pnpm exec prisma generate && cd ../..
Expected: generated client in node_modules/.pnpm/.../@prisma/client.
- [ ] Step 3: Write the failing PrismaService test
Create services/admin-api/src/prisma/prisma.service.spec.ts:
import { PrismaService } from './prisma.service';
describe('PrismaService', () => {
it('exposes the AdminUser and AdminAuditLog model accessors', () => {
const svc = new PrismaService({
databaseUrl: 'mysql://test:test@localhost:3306/admin',
} as never);
expect(svc.adminUser).toBeDefined();
expect(svc.adminAuditLog).toBeDefined();
});
});
Run: pnpm --filter @sa-platform/admin-api test -- prisma.service
Expected: FAIL — PrismaService not defined.
- [ ] Step 4: Implement
PrismaService
Create services/admin-api/src/prisma/prisma.service.ts:
import { Injectable, Logger, OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { AppConfigService } from '../config/app-config';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnApplicationShutdown {
private readonly logger = new Logger(PrismaService.name);
constructor(config: AppConfigService) {
super({ datasources: { db: { url: config.databaseUrl } } });
}
async onModuleInit(): Promise<void> {
await this.$connect();
this.logger.log('PrismaService connected');
}
async onApplicationShutdown(): Promise<void> {
await this.$disconnect();
}
}
- [ ] Step 5: Implement
PrismaModule
Create services/admin-api/src/prisma/prisma.module.ts:
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
- [ ] Step 6: Wire
PrismaModuleintoAppModule
Edit services/admin-api/src/app.module.ts:
import { Module } from '@nestjs/common';
import { ConfigModule } from './config/config.module';
import { PrismaModule } from './prisma/prisma.module';
import { HealthController } from './health/health.controller';
@Module({
imports: [ConfigModule, PrismaModule],
controllers: [HealthController],
})
export class AppModule {}
- [ ] Step 7: Run the PrismaService test + typecheck
Run: pnpm --filter @sa-platform/admin-api test
Expected: PASS.
Run: pnpm --filter @sa-platform/admin-api run typecheck
Expected: PASS.
- [ ] Step 8: Create the initial migration
cd services/admin-api
DATABASE_URL="mysql://test:test@localhost:3306/admin" pnpm exec prisma migrate dev --name init --create-only
cd ../..
(--create-only writes the SQL without applying — we don't apply locally; CI/deploy applies migrations.)
Expected: a new file under services/admin-api/prisma/migrations/ containing CREATE TABLE AdminUser and CREATE TABLE AdminAuditLog.
- [ ] Step 9: Commit
git add services/admin-api/prisma/ services/admin-api/src/prisma/ services/admin-api/src/app.module.ts
git commit -m "feat(admin-api): add Prisma schema with AdminUser and AdminAuditLog"
Task 6: Session module (Redis-backed) + cookie guard¶
Files:
- Create:
services/admin-api/src/session/session.types.ts - Create:
services/admin-api/src/session/session.service.ts - Create:
services/admin-api/src/session/session.service.spec.ts - Create:
services/admin-api/src/session/session.guard.ts - Create:
services/admin-api/src/session/session.guard.spec.ts - Create:
services/admin-api/src/session/session.module.ts - Modify:
services/admin-api/src/app.module.ts(importSessionModule)
Contract¶
Sessions live in Redis under key admin-session:<sessionId>. Each row holds the user's identity + the platform JWT issued by services/auth. Cookies named admin_session carry the session ID (httpOnly, secure when nodeEnv !== 'development', SameSite=Lax). A SessionGuard reads the cookie, looks up the session, attaches { session, user } to req.session. Throws 401 if absent or expired.
- [ ] Step 1: Define types
Create services/admin-api/src/session/session.types.ts:
export interface SessionRecord {
sessionId: string;
userId: string;
email: string;
displayName: string;
role: 'admin' | 'support';
jwt: string; // platform JWT issued by services/auth
jwtExp: number; // unix seconds
createdAt: number;
expiresAt: number;
}
export interface CreateSessionInput {
userId: string;
email: string;
displayName: string;
role: 'admin' | 'support';
jwt: string;
jwtExp: number;
}
export const SESSION_COOKIE_NAME = 'admin_session';
export const SESSION_KEY_PREFIX = 'admin-session:';
- [ ] Step 2: Write failing service tests
Create services/admin-api/src/session/session.service.spec.ts:
import Redis from 'ioredis-mock';
import { SessionService } from './session.service';
describe('SessionService', () => {
let svc: SessionService;
let redis: Redis;
const config = {
redisUrl: 'redis://mock',
sessionTtlHours: 8,
} as never;
beforeEach(() => {
redis = new Redis();
svc = new SessionService(config, redis as never);
});
afterEach(() => redis.disconnect());
it('creates a session and reads it back by id', async () => {
const session = await svc.create({
userId: 'u-1',
email: 'a@b.com',
displayName: 'A B',
role: 'admin',
jwt: 'jwt',
jwtExp: Math.floor(Date.now() / 1000) + 3600,
});
const found = await svc.findById(session.sessionId);
expect(found?.userId).toBe('u-1');
expect(found?.role).toBe('admin');
});
it('returns null for a missing session', async () => {
expect(await svc.findById('does-not-exist')).toBeNull();
});
it('expires sessions according to ttl', async () => {
const session = await svc.create({
userId: 'u-1',
email: 'a@b.com',
displayName: 'A B',
role: 'support',
jwt: 'jwt',
jwtExp: Math.floor(Date.now() / 1000) + 3600,
});
const ttl = await redis.ttl(`admin-session:${session.sessionId}`);
expect(ttl).toBeGreaterThan(8 * 60 * 60 - 60);
expect(ttl).toBeLessThanOrEqual(8 * 60 * 60);
});
it('destroys a session', async () => {
const session = await svc.create({
userId: 'u-1',
email: 'a@b.com',
displayName: 'A B',
role: 'admin',
jwt: 'jwt',
jwtExp: Math.floor(Date.now() / 1000) + 3600,
});
await svc.destroy(session.sessionId);
expect(await svc.findById(session.sessionId)).toBeNull();
});
});
(ioredis-mock should be added to services/admin-api/package.json devDependencies — pnpm --filter @sa-platform/admin-api add -D ioredis-mock.)
Run: pnpm --filter @sa-platform/admin-api test -- session.service
Expected: FAIL — SessionService not defined.
- [ ] Step 3: Implement
SessionService
Create services/admin-api/src/session/session.service.ts:
import { randomBytes } from 'node:crypto';
import { Inject, Injectable, Logger } from '@nestjs/common';
import Redis from 'ioredis';
import { AppConfigService } from '../config/app-config';
import { CreateSessionInput, SessionRecord, SESSION_KEY_PREFIX } from './session.types';
export const REDIS_SESSION_CLIENT = Symbol('REDIS_SESSION_CLIENT');
@Injectable()
export class SessionService {
private readonly logger = new Logger(SessionService.name);
constructor(
private readonly config: AppConfigService,
@Inject(REDIS_SESSION_CLIENT) private readonly redis: Redis,
) {}
async create(input: CreateSessionInput): Promise<SessionRecord> {
const sessionId = randomBytes(32).toString('hex');
const now = Math.floor(Date.now() / 1000);
const ttlSeconds = this.config.sessionTtlHours * 60 * 60;
const record: SessionRecord = {
sessionId,
userId: input.userId,
email: input.email,
displayName: input.displayName,
role: input.role,
jwt: input.jwt,
jwtExp: input.jwtExp,
createdAt: now,
expiresAt: now + ttlSeconds,
};
await this.redis.set(
`${SESSION_KEY_PREFIX}${sessionId}`,
JSON.stringify(record),
'EX',
ttlSeconds,
);
return record;
}
async findById(sessionId: string): Promise<SessionRecord | null> {
const raw = await this.redis.get(`${SESSION_KEY_PREFIX}${sessionId}`);
return raw ? (JSON.parse(raw) as SessionRecord) : null;
}
async destroy(sessionId: string): Promise<void> {
await this.redis.del(`${SESSION_KEY_PREFIX}${sessionId}`);
}
}
- [ ] Step 4: Implement
SessionGuard
Create services/admin-api/src/session/session.guard.ts:
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Request } from 'express';
import { SessionService } from './session.service';
import { SessionRecord, SESSION_COOKIE_NAME } from './session.types';
declare module 'express' {
interface Request {
session?: SessionRecord;
}
}
@Injectable()
export class SessionGuard implements CanActivate {
constructor(private readonly sessions: SessionService) {}
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const req = ctx.switchToHttp().getRequest<Request>();
const sessionId = req.cookies?.[SESSION_COOKIE_NAME];
if (!sessionId || typeof sessionId !== 'string') {
throw new UnauthorizedException('No session cookie');
}
const record = await this.sessions.findById(sessionId);
if (!record) {
throw new UnauthorizedException('Session not found or expired');
}
req.session = record;
return true;
}
}
- [ ] Step 5: Write a failing guard test
Create services/admin-api/src/session/session.guard.spec.ts:
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { SessionGuard } from './session.guard';
function makeCtx(cookies: Record<string, string>): ExecutionContext {
const req: any = { cookies };
return {
switchToHttp: () => ({ getRequest: () => req }),
} as ExecutionContext;
}
describe('SessionGuard', () => {
const sessions = { findById: jest.fn() };
const guard = new SessionGuard(sessions as never);
beforeEach(() => jest.clearAllMocks());
it('throws when the cookie is absent', async () => {
await expect(guard.canActivate(makeCtx({}))).rejects.toBeInstanceOf(UnauthorizedException);
});
it('throws when the session is not found', async () => {
sessions.findById.mockResolvedValue(null);
await expect(guard.canActivate(makeCtx({ admin_session: 'nope' }))).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
it('attaches the session to the request when valid', async () => {
const record = { sessionId: 'abc', userId: 'u-1', role: 'admin' };
sessions.findById.mockResolvedValue(record);
const ctx = makeCtx({ admin_session: 'abc' });
const ok = await guard.canActivate(ctx);
expect(ok).toBe(true);
expect((ctx.switchToHttp().getRequest() as any).session).toEqual(record);
});
});
- [ ] Step 6: Implement
SessionModule
Create services/admin-api/src/session/session.module.ts:
import { Global, Module, OnApplicationShutdown } from '@nestjs/common';
import Redis from 'ioredis';
import { AppConfigService } from '../config/app-config';
import { SessionService, REDIS_SESSION_CLIENT } from './session.service';
import { SessionGuard } from './session.guard';
@Global()
@Module({
providers: [
{
provide: REDIS_SESSION_CLIENT,
useFactory: (config: AppConfigService): Redis => new Redis(config.redisUrl),
inject: [AppConfigService],
},
SessionService,
SessionGuard,
],
exports: [SessionService, SessionGuard, REDIS_SESSION_CLIENT],
})
export class SessionModule implements OnApplicationShutdown {
constructor(@Inject(REDIS_SESSION_CLIENT) private readonly redis: Redis) {}
async onApplicationShutdown(): Promise<void> {
await this.redis.quit();
}
}
(NOTE: the @Inject import — add it to the imports at the top: import { Global, Inject, Module, OnApplicationShutdown } from '@nestjs/common';. The OnApplicationShutdown lifecycle hook closes Redis cleanly to keep CI tests from hanging — same pattern used by every other service.)
- [ ] Step 7: Wire
SessionModuleintoAppModule
Edit services/admin-api/src/app.module.ts:
import { Module } from '@nestjs/common';
import { ConfigModule } from './config/config.module';
import { PrismaModule } from './prisma/prisma.module';
import { SessionModule } from './session/session.module';
import { HealthController } from './health/health.controller';
@Module({
imports: [ConfigModule, PrismaModule, SessionModule],
controllers: [HealthController],
})
export class AppModule {}
- [ ] Step 8: Run all admin-api tests + typecheck
Run: pnpm --filter @sa-platform/admin-api test
Expected: PASS — health, app-config, prisma, session.service, session.guard.
Run: pnpm --filter @sa-platform/admin-api run typecheck
Expected: PASS.
- [ ] Step 9: Commit
git add services/admin-api/src/session/ services/admin-api/src/app.module.ts services/admin-api/package.json
git commit -m "feat(admin-api): add Redis-backed session module + cookie guard"
Task 7: admin-users module — internal /internal/admin-users/resolve endpoint¶
Files:
- Create:
services/admin-api/src/admin-users/admin-users.module.ts - Create:
services/admin-api/src/admin-users/admin-users.service.ts - Create:
services/admin-api/src/admin-users/admin-users.service.spec.ts - Create:
services/admin-api/src/admin-users/admin-users.controller.ts - Create:
services/admin-api/src/admin-users/admin-users.controller.spec.ts - Create:
services/admin-api/src/admin-users/internal-token.guard.ts - Modify:
services/admin-api/src/app.module.ts
Contract¶
POST /internal/admin-users/resolve (service-to-service only — protected by InternalTokenGuard checking Authorization: Bearer <SERVICE_AUTH_TOKEN>):
Request: { email: string; displayName: string }
Response: { id: string; email: string; displayName: string; role: 'admin'|'support'; status: 'active'|'pending'|'disabled' }
Behaviour: looks up by email. If not found, creates a row with status=pending. If found, updates displayName from Google + lastLoginAt. Phase 1: only status === 'active' is allowed to log in (the auth service rejects pending/disabled responses; this endpoint just returns the truth).
- [ ] Step 1: Implement
InternalTokenGuard
Create services/admin-api/src/admin-users/internal-token.guard.ts:
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Request } from 'express';
import { AppConfigService } from '../config/app-config';
@Injectable()
export class InternalTokenGuard implements CanActivate {
constructor(private readonly config: AppConfigService) {}
canActivate(ctx: ExecutionContext): boolean {
const req = ctx.switchToHttp().getRequest<Request>();
const header = req.headers.authorization ?? '';
const expected = `Bearer ${this.config.serviceAuthToken}`;
if (header !== expected) {
throw new UnauthorizedException('Invalid internal service token');
}
return true;
}
}
- [ ] Step 2: Write the failing service test
Create services/admin-api/src/admin-users/admin-users.service.spec.ts:
import { AdminUsersService } from './admin-users.service';
describe('AdminUsersService.resolve', () => {
const prisma = {
adminUser: {
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
};
const service = new AdminUsersService(prisma as never);
beforeEach(() => jest.clearAllMocks());
it('creates a pending row when the email is unknown', async () => {
prisma.adminUser.findUnique.mockResolvedValue(null);
prisma.adminUser.create.mockResolvedValue({
id: 'u-1',
email: 'a@b.com',
displayName: 'A B',
role: 'support',
status: 'pending',
});
const r = await service.resolve({ email: 'a@b.com', displayName: 'A B' });
expect(prisma.adminUser.create).toHaveBeenCalledWith({
data: { email: 'a@b.com', displayName: 'A B', role: 'support', status: 'pending' },
});
expect(r.status).toBe('pending');
});
it('updates displayName + lastLoginAt for an existing row', async () => {
const existing = {
id: 'u-1',
email: 'a@b.com',
displayName: 'Old Name',
role: 'admin',
status: 'active',
};
prisma.adminUser.findUnique.mockResolvedValue(existing);
prisma.adminUser.update.mockResolvedValue({ ...existing, displayName: 'New Name' });
const r = await service.resolve({ email: 'a@b.com', displayName: 'New Name' });
expect(prisma.adminUser.update).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'u-1' } }),
);
expect(r.displayName).toBe('New Name');
});
});
Run: pnpm --filter @sa-platform/admin-api test -- admin-users.service
Expected: FAIL — AdminUsersService not defined.
- [ ] Step 3: Implement
AdminUsersService
Create services/admin-api/src/admin-users/admin-users.service.ts:
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
export interface AdminUserDto {
id: string;
email: string;
displayName: string;
role: 'admin' | 'support';
status: 'active' | 'pending' | 'disabled';
}
@Injectable()
export class AdminUsersService {
constructor(private readonly prisma: PrismaService) {}
async resolve(input: { email: string; displayName: string }): Promise<AdminUserDto> {
const existing = await this.prisma.adminUser.findUnique({ where: { email: input.email } });
if (!existing) {
const created = await this.prisma.adminUser.create({
data: {
email: input.email,
displayName: input.displayName,
role: 'support',
status: 'pending',
},
});
return this.toDto(created);
}
const updated = await this.prisma.adminUser.update({
where: { id: existing.id },
data: { displayName: input.displayName, lastLoginAt: new Date() },
});
return this.toDto(updated);
}
private toDto(row: {
id: string;
email: string;
displayName: string;
role: string;
status: string;
}): AdminUserDto {
return {
id: row.id,
email: row.email,
displayName: row.displayName,
role: row.role as 'admin' | 'support',
status: row.status as 'active' | 'pending' | 'disabled',
};
}
}
- [ ] Step 4: Implement controller + DTO
Create services/admin-api/src/admin-users/admin-users.controller.ts:
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { IsEmail, IsString } from 'class-validator';
import { AdminUsersService, AdminUserDto } from './admin-users.service';
import { InternalTokenGuard } from './internal-token.guard';
class ResolveDto {
@IsEmail()
email!: string;
@IsString()
displayName!: string;
}
@ApiTags('admin-users-internal')
@Controller('internal/admin-users')
@UseGuards(InternalTokenGuard)
export class AdminUsersController {
constructor(private readonly adminUsers: AdminUsersService) {}
@Post('resolve')
async resolve(@Body() body: ResolveDto): Promise<AdminUserDto> {
return this.adminUsers.resolve({ email: body.email, displayName: body.displayName });
}
}
- [ ] Step 5: Implement controller test
Create services/admin-api/src/admin-users/admin-users.controller.spec.ts:
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { AppConfigService } from '../config/app-config';
import { AdminUsersController } from './admin-users.controller';
import { AdminUsersService } from './admin-users.service';
import { InternalTokenGuard } from './internal-token.guard';
describe('AdminUsersController', () => {
let app: INestApplication;
const service = {
resolve: jest.fn().mockResolvedValue({
id: 'u-1',
email: 'a@b.com',
displayName: 'A B',
role: 'admin',
status: 'active',
}),
};
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [AdminUsersController],
providers: [
{ provide: AdminUsersService, useValue: service },
{ provide: AppConfigService, useValue: { serviceAuthToken: 'service-dev-token' } },
InternalTokenGuard,
],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
});
afterAll(async () => app.close());
it('rejects without bearer', async () => {
const res = await request(app.getHttpServer())
.post('/internal/admin-users/resolve')
.send({ email: 'a@b.com', displayName: 'A B' });
expect(res.status).toBe(401);
});
it('returns the user when bearer matches', async () => {
const res = await request(app.getHttpServer())
.post('/internal/admin-users/resolve')
.set('Authorization', 'Bearer service-dev-token')
.send({ email: 'a@b.com', displayName: 'A B' });
expect(res.status).toBe(201);
expect(res.body.id).toBe('u-1');
});
});
- [ ] Step 6: Wire the module
Create services/admin-api/src/admin-users/admin-users.module.ts:
import { Module } from '@nestjs/common';
import { AdminUsersController } from './admin-users.controller';
import { AdminUsersService } from './admin-users.service';
import { InternalTokenGuard } from './internal-token.guard';
@Module({
controllers: [AdminUsersController],
providers: [AdminUsersService, InternalTokenGuard],
exports: [AdminUsersService],
})
export class AdminUsersModule {}
Add AdminUsersModule to services/admin-api/src/app.module.ts imports.
- [ ] Step 7: Run tests + typecheck
Run: pnpm --filter @sa-platform/admin-api test
Expected: PASS — all admin-users specs.
Run: pnpm --filter @sa-platform/admin-api run typecheck
Expected: PASS.
- [ ] Step 8: Commit
git add services/admin-api/src/admin-users/ services/admin-api/src/app.module.ts
git commit -m "feat(admin-api): add /internal/admin-users/resolve endpoint"
Task 8: services/auth — Google OIDC callback endpoint¶
Files:
- Create:
services/auth/src/oauth/oauth.module.ts - Create:
services/auth/src/oauth/google-oauth.service.ts - Create:
services/auth/src/oauth/google-oauth.service.spec.ts - Create:
services/auth/src/oauth/google-oauth.controller.ts - Create:
services/auth/src/oauth/google-oauth.controller.spec.ts - Create:
services/auth/src/oauth/admin-api.client.ts - Create:
services/auth/src/oauth/admin-api.client.spec.ts - Modify:
services/auth/src/app.module.ts - Modify:
services/auth/src/config/app-config.ts(addADMIN_API_BASE_URL,GOOGLE_CLIENT_ID,ADMIN_DOMAIN_ALLOWLIST) - Modify:
services/auth/src/config/app-config.spec.ts(cover the new fields)
Contract¶
POST /v1/oauth/google/callback (protected by service-to-service auth):
Request: { id_token: string } (the id_token already received by admin-api from Google)
Response: { access_token: string; expires_in: number; token_type: 'Bearer'; scope: string; user: { id, email, displayName, role } }
Behaviour:
- Verify id_token signature against Google's JWKS (
https://www.googleapis.com/oauth2/v3/certs) - Verify
iss in ['https://accounts.google.com', 'accounts.google.com'],aud === GOOGLE_CLIENT_ID,email_verified === true,expnot past - Check email domain ∈
ADMIN_DOMAIN_ALLOWLIST - Call admin-api
POST /internal/admin-users/resolve { email, displayName }(usingSERVICE_AUTH_TOKEN) - Reject if response status ≠
'active' - Issue platform JWT: scope=
'admin:read admin:cross-tenant', sub=user.id, aud='admin-api', exp=now+8h, plusactor_context: { email, displayName, role } -
Return token + user
-
[ ] Step 1: Extend
services/authAppConfigService
Edit services/auth/src/config/app-config.ts. Add fields (mirroring style of existing entries):
readonly googleClientId: string;
readonly adminDomainAllowlist: string[];
readonly adminApiBaseUrl: string;
Inside the constructor, add:
this.googleClientId = required(env, 'GOOGLE_CLIENT_ID');
this.adminApiBaseUrl = required(env, 'ADMIN_API_BASE_URL');
const allowlistRaw = required(env, 'ADMIN_DOMAIN_ALLOWLIST');
this.adminDomainAllowlist = allowlistRaw
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0);
(If services/auth doesn't already have a required / listFromEnv helper, copy the helpers from admin-api Task 4 into the auth file. Read the existing services/auth/src/config/app-config.ts first to match its style.)
- [ ] Step 2: Update
services/authconfig spec
Edit services/auth/src/config/app-config.spec.ts. In whatever fixture defines the baseEnv (or equivalent), add:
GOOGLE_CLIENT_ID: 'goog-id',
ADMIN_API_BASE_URL: 'http://localhost:3008',
ADMIN_DOMAIN_ALLOWLIST: 'skinanalytics.co.uk',
Add a test:
it('parses ADMIN_DOMAIN_ALLOWLIST as a comma-separated list', () => {
const svc = new AppConfigService({ ...baseEnv, ADMIN_DOMAIN_ALLOWLIST: 'a.com, b.co.uk' });
expect(svc.adminDomainAllowlist).toEqual(['a.com', 'b.co.uk']);
});
Run: pnpm --filter @sa-platform/auth test -- app-config
Expected: PASS.
- [ ] Step 3: Write the failing GoogleOAuth service test
Create services/auth/src/oauth/google-oauth.service.spec.ts:
import { GoogleOAuthService, GoogleOAuthRejection } from './google-oauth.service';
describe('GoogleOAuthService', () => {
const config = {
googleClientId: 'goog-id',
adminDomainAllowlist: ['skinanalytics.co.uk'],
} as never;
const adminApi = {
resolveAdminUser: jest.fn(),
};
const idTokenVerifier = {
verify: jest.fn(),
};
const tokenIssuer = {
issueAdminToken: jest.fn(),
};
const svc = new GoogleOAuthService(
config,
idTokenVerifier as never,
adminApi as never,
tokenIssuer as never,
);
beforeEach(() => jest.clearAllMocks());
it('rejects when email_verified is false', async () => {
idTokenVerifier.verify.mockResolvedValue({
email: 'a@skinanalytics.co.uk',
email_verified: false,
name: 'A',
sub: 'g-1',
});
await expect(svc.handleCallback('id-token')).rejects.toBeInstanceOf(GoogleOAuthRejection);
});
it('rejects when domain is not allowlisted', async () => {
idTokenVerifier.verify.mockResolvedValue({
email: 'a@evil.com',
email_verified: true,
name: 'A',
sub: 'g-1',
});
await expect(svc.handleCallback('id-token')).rejects.toBeInstanceOf(GoogleOAuthRejection);
});
it('rejects when admin-api returns a non-active user', async () => {
idTokenVerifier.verify.mockResolvedValue({
email: 'a@skinanalytics.co.uk',
email_verified: true,
name: 'A',
sub: 'g-1',
});
adminApi.resolveAdminUser.mockResolvedValue({
id: 'u-1',
email: 'a@skinanalytics.co.uk',
displayName: 'A',
role: 'support',
status: 'pending',
});
await expect(svc.handleCallback('id-token')).rejects.toBeInstanceOf(GoogleOAuthRejection);
});
it('issues a token with admin scopes when everything passes', async () => {
idTokenVerifier.verify.mockResolvedValue({
email: 'a@skinanalytics.co.uk',
email_verified: true,
name: 'Alice',
sub: 'g-1',
});
adminApi.resolveAdminUser.mockResolvedValue({
id: 'u-1',
email: 'a@skinanalytics.co.uk',
displayName: 'Alice',
role: 'admin',
status: 'active',
});
tokenIssuer.issueAdminToken.mockResolvedValue({
access_token: 'jwt',
expires_in: 28800,
scope: 'admin:read admin:cross-tenant',
});
const result = await svc.handleCallback('id-token');
expect(tokenIssuer.issueAdminToken).toHaveBeenCalledWith({
userId: 'u-1',
email: 'a@skinanalytics.co.uk',
displayName: 'Alice',
role: 'admin',
scopes: ['admin:read', 'admin:cross-tenant'],
});
expect(result.access_token).toBe('jwt');
expect(result.user.role).toBe('admin');
});
});
Run: pnpm --filter @sa-platform/auth test -- google-oauth.service
Expected: FAIL — service not defined.
- [ ] Step 4: Implement
GoogleOAuthService
Create services/auth/src/oauth/google-oauth.service.ts:
import { Injectable } from '@nestjs/common';
import { AppConfigService } from '../config/app-config';
import { AdminApiClient, AdminUserDto } from './admin-api.client';
export interface IdTokenVerifier {
verify(idToken: string): Promise<{
email: string;
email_verified: boolean;
name: string;
sub: string;
}>;
}
export interface AdminTokenIssuer {
issueAdminToken(input: {
userId: string;
email: string;
displayName: string;
role: 'admin' | 'support';
scopes: string[];
}): Promise<{ access_token: string; expires_in: number; scope: string }>;
}
export class GoogleOAuthRejection extends Error {
constructor(public readonly reason: string) {
super(reason);
}
}
export interface GoogleCallbackResponse {
access_token: string;
expires_in: number;
token_type: 'Bearer';
scope: string;
user: AdminUserDto;
}
@Injectable()
export class GoogleOAuthService {
constructor(
private readonly config: AppConfigService,
private readonly verifier: IdTokenVerifier,
private readonly adminApi: AdminApiClient,
private readonly tokenIssuer: AdminTokenIssuer,
) {}
async handleCallback(idToken: string): Promise<GoogleCallbackResponse> {
const claims = await this.verifier.verify(idToken);
if (!claims.email_verified) {
throw new GoogleOAuthRejection('email_not_verified');
}
const domain = claims.email.split('@')[1] ?? '';
if (!this.config.adminDomainAllowlist.includes(domain)) {
throw new GoogleOAuthRejection(`domain_not_allowlisted: ${domain}`);
}
const user = await this.adminApi.resolveAdminUser({
email: claims.email,
displayName: claims.name,
});
if (user.status !== 'active') {
throw new GoogleOAuthRejection(`user_not_active: ${user.status}`);
}
const token = await this.tokenIssuer.issueAdminToken({
userId: user.id,
email: user.email,
displayName: user.displayName,
role: user.role,
scopes: ['admin:read', 'admin:cross-tenant'],
});
return {
access_token: token.access_token,
expires_in: token.expires_in,
token_type: 'Bearer',
scope: token.scope,
user,
};
}
}
- [ ] Step 5: Implement
IdTokenVerifier(real impl usinggoogle-auth-library)
Add google-auth-library to services/auth/package.json:
pnpm --filter @sa-platform/auth add google-auth-library
Add to the same file (bottom):
import { Injectable } from '@nestjs/common';
import { OAuth2Client } from 'google-auth-library';
import { AppConfigService } from '../config/app-config';
@Injectable()
export class GoogleIdTokenVerifier implements IdTokenVerifier {
private readonly client: OAuth2Client;
constructor(config: AppConfigService) {
this.client = new OAuth2Client(config.googleClientId);
}
async verify(idToken: string) {
const ticket = await this.client.verifyIdToken({
idToken,
audience: this.client._clientId,
});
const payload = ticket.getPayload();
if (!payload) throw new Error('No payload in id_token');
return {
email: String(payload.email ?? ''),
email_verified: Boolean(payload.email_verified),
name: String(payload.name ?? ''),
sub: String(payload.sub ?? ''),
};
}
}
- [ ] Step 6: Implement
AdminApiClient
Create services/auth/src/oauth/admin-api.client.ts:
import { HttpException, Injectable, Logger } from '@nestjs/common';
import { AppConfigService } from '../config/app-config';
export interface AdminUserDto {
id: string;
email: string;
displayName: string;
role: 'admin' | 'support';
status: 'active' | 'pending' | 'disabled';
}
@Injectable()
export class AdminApiClient {
private readonly logger = new Logger(AdminApiClient.name);
constructor(private readonly config: AppConfigService) {}
async resolveAdminUser(input: { email: string; displayName: string }): Promise<AdminUserDto> {
const url = `${this.config.adminApiBaseUrl}/internal/admin-users/resolve`;
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.config.serviceAuthToken}`,
},
body: JSON.stringify(input),
});
if (!res.ok) {
const body = await res.text();
this.logger.error(`admin-api resolve failed ${res.status}: ${body}`);
throw new HttpException(`admin-api resolve failed ${res.status}`, res.status);
}
return (await res.json()) as AdminUserDto;
}
}
- [ ] Step 7: Add the admin-api client test
Create services/auth/src/oauth/admin-api.client.spec.ts:
import * as nock from 'nock';
import { AdminApiClient } from './admin-api.client';
describe('AdminApiClient.resolveAdminUser', () => {
const config = {
adminApiBaseUrl: 'http://admin-api.test',
serviceAuthToken: 'svc',
} as never;
const client = new AdminApiClient(config);
afterEach(() => nock.cleanAll());
it('posts with the bearer and returns the parsed body', async () => {
nock('http://admin-api.test')
.matchHeader('authorization', 'Bearer svc')
.post('/internal/admin-users/resolve', { email: 'a@b.com', displayName: 'A' })
.reply(201, {
id: 'u-1',
email: 'a@b.com',
displayName: 'A',
role: 'admin',
status: 'active',
});
const r = await client.resolveAdminUser({ email: 'a@b.com', displayName: 'A' });
expect(r.id).toBe('u-1');
});
it('throws on non-2xx response', async () => {
nock('http://admin-api.test').post('/internal/admin-users/resolve').reply(500, 'oops');
await expect(client.resolveAdminUser({ email: 'a@b.com', displayName: 'A' })).rejects.toThrow();
});
});
- [ ] Step 8: Implement
AdminTokenIssuer
This calls into the existing services/auth token issuance. Look at services/auth/src/token/token.service.ts (or equivalent). Find the existing JWT signing. Wrap it as:
Add to services/auth/src/oauth/google-oauth.service.ts (bottom):
import { TokenService } from '../token/token.service'; // adjust path to actual location
@Injectable()
export class AuthAdminTokenIssuer implements AdminTokenIssuer {
private readonly TTL_SECONDS = 8 * 60 * 60;
constructor(private readonly tokens: TokenService) {}
async issueAdminToken(input: {
userId: string;
email: string;
displayName: string;
role: 'admin' | 'support';
scopes: string[];
}): Promise<{ access_token: string; expires_in: number; scope: string }> {
const token = await this.tokens.signAdminToken({
sub: input.userId,
aud: 'admin-api',
scope: input.scopes.join(' '),
ttlSeconds: this.TTL_SECONDS,
actor_context: {
email: input.email,
display_name: input.displayName,
role: input.role,
},
});
return {
access_token: token,
expires_in: this.TTL_SECONDS,
scope: input.scopes.join(' '),
};
}
}
You may need to extend TokenService (in services/auth) with a new signAdminToken({ sub, aud, scope, ttlSeconds, actor_context }) method. If the existing service has a generic sign({ ... }) that takes an arbitrary payload, use that directly instead — read the existing implementation and adapt.
- [ ] Step 9: Implement the controller
Create services/auth/src/oauth/google-oauth.controller.ts:
import { Body, Controller, ForbiddenException, Logger, Post, UseGuards } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import {
GoogleOAuthService,
GoogleOAuthRejection,
GoogleCallbackResponse,
} from './google-oauth.service';
import { ServiceTokenGuard } from '../auth/service-token.guard'; // existing guard
class CallbackDto {
@IsString()
id_token!: string;
}
@ApiTags('oauth')
@Controller('v1/oauth/google')
@UseGuards(ServiceTokenGuard)
export class GoogleOAuthController {
private readonly logger = new Logger(GoogleOAuthController.name);
constructor(private readonly oauth: GoogleOAuthService) {}
@Post('callback')
async callback(@Body() body: CallbackDto): Promise<GoogleCallbackResponse> {
try {
return await this.oauth.handleCallback(body.id_token);
} catch (err) {
if (err instanceof GoogleOAuthRejection) {
this.logger.warn(`OIDC callback rejected: ${err.reason}`);
throw new ForbiddenException(err.reason);
}
throw err;
}
}
}
(ServiceTokenGuard is the existing service-to-service guard in services/auth. If named differently, find and use the actual guard.)
- [ ] Step 10: Implement controller test
Create services/auth/src/oauth/google-oauth.controller.spec.ts:
import { ForbiddenException } from '@nestjs/common';
import { GoogleOAuthRejection, GoogleOAuthService } from './google-oauth.service';
import { GoogleOAuthController } from './google-oauth.controller';
describe('GoogleOAuthController', () => {
const oauth = { handleCallback: jest.fn() } as unknown as jest.Mocked<GoogleOAuthService>;
const controller = new GoogleOAuthController(oauth);
beforeEach(() => jest.clearAllMocks());
it('returns the response on success', async () => {
(oauth.handleCallback as jest.Mock).mockResolvedValue({
access_token: 'jwt',
expires_in: 28800,
token_type: 'Bearer',
scope: 'admin:read admin:cross-tenant',
user: { id: 'u-1', email: 'a@b.com', displayName: 'A', role: 'admin', status: 'active' },
});
const r = await controller.callback({ id_token: 'tok' });
expect(r.access_token).toBe('jwt');
});
it('translates a rejection into 403 ForbiddenException', async () => {
(oauth.handleCallback as jest.Mock).mockRejectedValue(
new GoogleOAuthRejection('domain_not_allowlisted'),
);
await expect(controller.callback({ id_token: 'tok' })).rejects.toBeInstanceOf(
ForbiddenException,
);
});
});
- [ ] Step 11: Wire
OAuthModule
Create services/auth/src/oauth/oauth.module.ts:
import { Module } from '@nestjs/common';
import { GoogleOAuthController } from './google-oauth.controller';
import {
GoogleOAuthService,
GoogleIdTokenVerifier,
AuthAdminTokenIssuer,
} from './google-oauth.service';
import { AdminApiClient } from './admin-api.client';
import { TokenModule } from '../token/token.module'; // existing
@Module({
imports: [TokenModule],
controllers: [GoogleOAuthController],
providers: [
GoogleOAuthService,
AdminApiClient,
{
provide: GoogleOAuthService,
useFactory: (config, verifier, adminApi, issuer) =>
new GoogleOAuthService(config, verifier, adminApi, issuer),
inject: ['AppConfigService', GoogleIdTokenVerifier, AdminApiClient, AuthAdminTokenIssuer],
},
GoogleIdTokenVerifier,
AuthAdminTokenIssuer,
],
})
export class OAuthModule {}
(The inject: token names must match how AppConfigService and TokenService are exported in services/auth. Read the existing app.module.ts first.)
Add OAuthModule to services/auth/src/app.module.ts imports.
- [ ] Step 12: Run all auth tests + typecheck
Run: pnpm --filter @sa-platform/auth test
Expected: PASS — including the new oauth specs.
Run: pnpm --filter @sa-platform/auth run typecheck
Expected: PASS.
- [ ] Step 13: Commit
git add services/auth/src/oauth/ services/auth/src/app.module.ts services/auth/src/config/ services/auth/package.json pnpm-lock.yaml
git commit -m "feat(auth): add Google OIDC callback for admin SSO"
Task 9: admin-api AuthModule (Google OIDC + cookies)¶
Files:
- Create:
services/admin-api/src/auth/auth.module.ts - Create:
services/admin-api/src/auth/auth.controller.ts - Create:
services/admin-api/src/auth/auth.controller.spec.ts - Create:
services/admin-api/src/auth/google-oauth.client.ts - Create:
services/admin-api/src/auth/google-oauth.client.spec.ts - Create:
services/admin-api/src/auth/auth-service.client.ts - Create:
services/admin-api/src/auth/auth-service.client.spec.ts - Create:
services/admin-api/src/me/me.controller.ts - Create:
services/admin-api/src/me/me.controller.spec.ts - Modify:
services/admin-api/src/app.module.ts
Endpoints (added)¶
GET /api/auth/google/start— generates state+nonce, stores in Redis (5min TTL), responds with 302 to Google's auth URLGET /api/auth/google/callback?code&state— completes OIDC, creates session, sets cookie, 302 to/POST /api/auth/logout— destroys session, expires cookie, 204-
GET /api/me— returns identity for the current session -
[ ] Step 1: Implement
GoogleOAuthClient(state + token exchange)
Create services/admin-api/src/auth/google-oauth.client.ts:
import { randomBytes } from 'node:crypto';
import { Inject, Injectable, Logger } from '@nestjs/common';
import Redis from 'ioredis';
import { AppConfigService } from '../config/app-config';
import { REDIS_SESSION_CLIENT } from '../session/session.service';
const STATE_PREFIX = 'admin-oauth-state:';
const STATE_TTL_SECONDS = 5 * 60;
@Injectable()
export class GoogleOAuthClient {
private readonly logger = new Logger(GoogleOAuthClient.name);
constructor(
private readonly config: AppConfigService,
@Inject(REDIS_SESSION_CLIENT) private readonly redis: Redis,
) {}
buildAuthorizationUrl(): { url: string; state: string } {
const state = randomBytes(32).toString('hex');
const params = new URLSearchParams({
client_id: this.config.googleClientId,
redirect_uri: this.config.googleRedirectUri,
response_type: 'code',
scope: 'openid email profile',
state,
access_type: 'online',
prompt: 'select_account',
});
const url = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
return { url, state };
}
async storeState(state: string): Promise<void> {
await this.redis.set(`${STATE_PREFIX}${state}`, '1', 'EX', STATE_TTL_SECONDS);
}
async consumeState(state: string): Promise<boolean> {
const deleted = await this.redis.del(`${STATE_PREFIX}${state}`);
return deleted === 1;
}
async exchangeCodeForIdToken(code: string): Promise<string> {
const params = new URLSearchParams({
code,
client_id: this.config.googleClientId,
client_secret: this.config.googleClientSecret,
redirect_uri: this.config.googleRedirectUri,
grant_type: 'authorization_code',
});
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!res.ok) {
const body = await res.text();
this.logger.error(`Google token endpoint ${res.status}: ${body}`);
throw new Error(`google_token_exchange_failed: ${res.status}`);
}
const body = (await res.json()) as { id_token?: string };
if (!body.id_token) throw new Error('google_token_exchange_no_id_token');
return body.id_token;
}
}
- [ ] Step 2: Test
GoogleOAuthClient
Create services/admin-api/src/auth/google-oauth.client.spec.ts:
import * as nock from 'nock';
import Redis from 'ioredis-mock';
import { GoogleOAuthClient } from './google-oauth.client';
describe('GoogleOAuthClient', () => {
const config = {
googleClientId: 'goog-id',
googleClientSecret: 'goog-sec',
googleRedirectUri: 'http://localhost:3008/api/auth/google/callback',
} as never;
let redis: Redis;
let client: GoogleOAuthClient;
beforeEach(() => {
redis = new Redis();
client = new GoogleOAuthClient(config, redis as never);
});
afterEach(async () => {
nock.cleanAll();
redis.disconnect();
});
it('buildAuthorizationUrl returns a Google URL with state', () => {
const { url, state } = client.buildAuthorizationUrl();
expect(url).toContain('https://accounts.google.com/o/oauth2/v2/auth?');
expect(url).toContain(`state=${state}`);
expect(state).toHaveLength(64);
});
it('store/consume state cycle', async () => {
await client.storeState('s-1');
expect(await client.consumeState('s-1')).toBe(true);
expect(await client.consumeState('s-1')).toBe(false);
});
it('exchangeCodeForIdToken posts to Google and returns the id_token', async () => {
nock('https://oauth2.googleapis.com')
.post('/token')
.reply(200, { id_token: 'tok-123', access_token: 'a', expires_in: 3600 });
const tok = await client.exchangeCodeForIdToken('code-abc');
expect(tok).toBe('tok-123');
});
it('throws when Google returns no id_token', async () => {
nock('https://oauth2.googleapis.com').post('/token').reply(200, { access_token: 'a' });
await expect(client.exchangeCodeForIdToken('code-abc')).rejects.toThrow(
'google_token_exchange_no_id_token',
);
});
});
- [ ] Step 3: Implement
AuthServiceClient
Create services/admin-api/src/auth/auth-service.client.ts:
import { HttpException, Injectable, Logger } from '@nestjs/common';
import { AppConfigService } from '../config/app-config';
export interface AuthServiceCallbackResponse {
access_token: string;
expires_in: number;
token_type: 'Bearer';
scope: string;
user: {
id: string;
email: string;
displayName: string;
role: 'admin' | 'support';
status: 'active' | 'pending' | 'disabled';
};
}
@Injectable()
export class AuthServiceClient {
private readonly logger = new Logger(AuthServiceClient.name);
constructor(private readonly config: AppConfigService) {}
async exchangeIdToken(idToken: string): Promise<AuthServiceCallbackResponse> {
const url = `${this.config.authApiBaseUrl}/v1/oauth/google/callback`;
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.config.serviceAuthToken}`,
},
body: JSON.stringify({ id_token: idToken }),
});
if (!res.ok) {
const body = await res.text();
this.logger.warn(`auth-service ${res.status}: ${body}`);
throw new HttpException(body || `auth_service_${res.status}`, res.status);
}
return (await res.json()) as AuthServiceCallbackResponse;
}
}
Add a quick test (services/admin-api/src/auth/auth-service.client.spec.ts) with nock — same shape as Task 8 Step 7. Two cases: 200 returns the body; non-2xx throws.
- [ ] Step 4: Implement the auth controller
Create services/admin-api/src/auth/auth.controller.ts:
import {
BadRequestException,
Controller,
Get,
Post,
Query,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { AppConfigService } from '../config/app-config';
import { AuditService } from '../audit/audit.service';
import { SessionGuard } from '../session/session.guard';
import { SessionService } from '../session/session.service';
import { SESSION_COOKIE_NAME } from '../session/session.types';
import { GoogleOAuthClient } from './google-oauth.client';
import { AuthServiceClient } from './auth-service.client';
@ApiTags('auth')
@Controller('api/auth')
export class AuthController {
constructor(
private readonly config: AppConfigService,
private readonly google: GoogleOAuthClient,
private readonly authService: AuthServiceClient,
private readonly sessions: SessionService,
private readonly audit: AuditService,
) {}
@Get('google/start')
async start(@Res() res: Response): Promise<void> {
const { url, state } = this.google.buildAuthorizationUrl();
await this.google.storeState(state);
res.redirect(302, url);
}
@Get('google/callback')
async callback(
@Query('code') code: string | undefined,
@Query('state') state: string | undefined,
@Res() res: Response,
): Promise<void> {
if (!code || !state) throw new BadRequestException('missing_code_or_state');
const stateOk = await this.google.consumeState(state);
if (!stateOk) throw new BadRequestException('invalid_or_replayed_state');
const idToken = await this.google.exchangeCodeForIdToken(code);
const tokenResponse = await this.authService.exchangeIdToken(idToken);
const session = await this.sessions.create({
userId: tokenResponse.user.id,
email: tokenResponse.user.email,
displayName: tokenResponse.user.displayName,
role: tokenResponse.user.role,
jwt: tokenResponse.access_token,
jwtExp: Math.floor(Date.now() / 1000) + tokenResponse.expires_in,
});
await this.audit.record({
actorId: tokenResponse.user.id,
action: 'auth.login',
target: null,
});
res.cookie(SESSION_COOKIE_NAME, session.sessionId, {
httpOnly: true,
secure: this.config.nodeEnv !== 'development',
sameSite: 'lax',
domain: this.config.sessionCookieDomain,
maxAge: this.config.sessionTtlHours * 60 * 60 * 1000,
path: '/',
});
res.redirect(302, '/');
}
@Post('logout')
@UseGuards(SessionGuard)
async logout(@Req() req: Request, @Res() res: Response): Promise<void> {
const session = req.session!;
await this.sessions.destroy(session.sessionId);
await this.audit.record({
actorId: session.userId,
action: 'auth.logout',
target: null,
});
res.clearCookie(SESSION_COOKIE_NAME, {
domain: this.config.sessionCookieDomain,
path: '/',
});
res.status(204).send();
}
}
- [ ] Step 5: Implement the
mecontroller
Create services/admin-api/src/me/me.controller.ts:
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
import { SessionGuard } from '../session/session.guard';
@ApiTags('me')
@Controller('api/me')
@UseGuards(SessionGuard)
export class MeController {
@Get()
me(@Req() req: Request): { id: string; email: string; displayName: string; role: string } {
const s = req.session!;
return {
id: s.userId,
email: s.email,
displayName: s.displayName,
role: s.role,
};
}
}
Test in services/admin-api/src/me/me.controller.spec.ts:
import { MeController } from './me.controller';
describe('MeController', () => {
it('returns the session-bound identity', () => {
const controller = new MeController();
const req = {
session: { userId: 'u-1', email: 'a@b.com', displayName: 'A B', role: 'admin' },
} as any;
expect(controller.me(req)).toEqual({
id: 'u-1',
email: 'a@b.com',
displayName: 'A B',
role: 'admin',
});
});
});
- [ ] Step 6: Test the auth controller (integration-style with mocked deps)
Create services/admin-api/src/auth/auth.controller.spec.ts:
import { Test } from '@nestjs/testing';
import { Response } from 'express';
import { AppConfigService } from '../config/app-config';
import { AuditService } from '../audit/audit.service';
import { SessionService } from '../session/session.service';
import { GoogleOAuthClient } from './google-oauth.client';
import { AuthServiceClient } from './auth-service.client';
import { AuthController } from './auth.controller';
describe('AuthController.callback', () => {
let controller: AuthController;
const google = {
consumeState: jest.fn(),
exchangeCodeForIdToken: jest.fn(),
buildAuthorizationUrl: jest.fn(),
storeState: jest.fn(),
};
const authService = { exchangeIdToken: jest.fn() };
const sessions = { create: jest.fn(), destroy: jest.fn() };
const audit = { record: jest.fn() };
const config = {
nodeEnv: 'development',
sessionCookieDomain: 'localhost',
sessionTtlHours: 8,
};
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{ provide: AppConfigService, useValue: config },
{ provide: GoogleOAuthClient, useValue: google },
{ provide: AuthServiceClient, useValue: authService },
{ provide: SessionService, useValue: sessions },
{ provide: AuditService, useValue: audit },
],
}).compile();
controller = moduleRef.get(AuthController);
jest.clearAllMocks();
});
it('rejects an invalid state', async () => {
google.consumeState.mockResolvedValue(false);
const res = {
redirect: jest.fn(),
cookie: jest.fn(),
status: jest.fn(),
clearCookie: jest.fn(),
send: jest.fn(),
} as unknown as Response;
await expect(controller.callback('code', 'state', res)).rejects.toThrow(
'invalid_or_replayed_state',
);
});
it('creates a session and sets the cookie on success', async () => {
google.consumeState.mockResolvedValue(true);
google.exchangeCodeForIdToken.mockResolvedValue('id-tok');
authService.exchangeIdToken.mockResolvedValue({
access_token: 'jwt',
expires_in: 3600,
token_type: 'Bearer',
scope: 'admin:read admin:cross-tenant',
user: { id: 'u-1', email: 'a@b.com', displayName: 'A', role: 'admin', status: 'active' },
});
sessions.create.mockResolvedValue({ sessionId: 'sess-1' });
const cookie = jest.fn();
const redirect = jest.fn();
const res = { cookie, redirect } as unknown as Response;
await controller.callback('code', 'state', res);
expect(sessions.create).toHaveBeenCalled();
expect(cookie).toHaveBeenCalledWith(
'admin_session',
'sess-1',
expect.objectContaining({ httpOnly: true, sameSite: 'lax' }),
);
expect(redirect).toHaveBeenCalledWith(302, '/');
expect(audit.record).toHaveBeenCalledWith(expect.objectContaining({ action: 'auth.login' }));
});
});
- [ ] Step 7: Implement
AuditService(stub for now — Task 12 fleshes it out)
Create services/admin-api/src/audit/audit.service.ts:
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
export interface AuditEntry {
actorId: string;
action: string;
target: string | null;
metadata?: Record<string, unknown>;
}
@Injectable()
export class AuditService {
constructor(private readonly prisma: PrismaService) {}
async record(entry: AuditEntry): Promise<void> {
await this.prisma.adminAuditLog.create({
data: {
actorId: entry.actorId,
action: entry.action,
target: entry.target ?? undefined,
metadata: entry.metadata ?? undefined,
},
});
}
}
Create services/admin-api/src/audit/audit.module.ts:
import { Global, Module } from '@nestjs/common';
import { AuditService } from './audit.service';
@Global()
@Module({
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}
(Task 12 will add an interceptor that records dashboard reads automatically. For now, AuthController calls audit.record() directly.)
- [ ] Step 8: Wire
AuthModule
Create services/admin-api/src/auth/auth.module.ts:
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { GoogleOAuthClient } from './google-oauth.client';
import { AuthServiceClient } from './auth-service.client';
@Module({
controllers: [AuthController],
providers: [GoogleOAuthClient, AuthServiceClient],
exports: [GoogleOAuthClient, AuthServiceClient],
})
export class AuthModule {}
- [ ] Step 9: Wire everything into
AppModule
Edit services/admin-api/src/app.module.ts:
import { Module } from '@nestjs/common';
import { ConfigModule } from './config/config.module';
import { PrismaModule } from './prisma/prisma.module';
import { SessionModule } from './session/session.module';
import { AuthModule } from './auth/auth.module';
import { AuditModule } from './audit/audit.module';
import { AdminUsersModule } from './admin-users/admin-users.module';
import { HealthController } from './health/health.controller';
import { MeController } from './me/me.controller';
@Module({
imports: [ConfigModule, PrismaModule, SessionModule, AuditModule, AuthModule, AdminUsersModule],
controllers: [HealthController, MeController],
})
export class AppModule {}
- [ ] Step 10: Run all admin-api tests + typecheck
Run: pnpm --filter @sa-platform/admin-api test
Expected: PASS — all specs.
Run: pnpm --filter @sa-platform/admin-api run typecheck
Expected: PASS.
- [ ] Step 11: Commit
git add services/admin-api/src/auth/ services/admin-api/src/me/ services/admin-api/src/audit/ services/admin-api/src/app.module.ts services/admin-api/package.json
git commit -m "feat(admin-api): add Google OIDC auth flow, session cookies, /api/me, /api/auth/logout"
Task 10: admin-api typed clients for backing services¶
Files:
- Create:
services/admin-api/src/clients/clinical-api.client.ts - Create:
services/admin-api/src/clients/clinical-api.client.spec.ts - Create:
services/admin-api/src/clients/ai-review.client.ts - Create:
services/admin-api/src/clients/ai-review.client.spec.ts - Create:
services/admin-api/src/clients/human-review.client.ts - Create:
services/admin-api/src/clients/human-review.client.spec.ts - Create:
services/admin-api/src/clients/clients.module.ts
Each client wraps fetch to a backing service's /v1/admin/stats, attaches Authorization: Bearer <jwt>, propagates the actor-context header, and returns the typed shape from the corresponding earlier task. They are used by the dashboard service in Task 11.
- [ ] Step 1: Implement
ClinicalApiClient
Create services/admin-api/src/clients/clinical-api.client.ts:
import { Injectable, Logger } from '@nestjs/common';
import { AppConfigService } from '../config/app-config';
export type StatsRange = 'today' | '7d' | '30d';
export interface ClinicalStatsResponse {
range: StatsRange;
scope: 'platform' | 'org';
orgId: string | null;
generatedAt: string;
cases: { today: number; thisWeek: number; thisMonth: number };
perOrg: Array<{ orgId: string; name: string; count: number }>;
perProduct: Array<{ productCode: string; count: number }>;
}
@Injectable()
export class ClinicalApiClient {
private readonly logger = new Logger(ClinicalApiClient.name);
constructor(private readonly config: AppConfigService) {}
async getStats(opts: {
jwt: string;
actorContext: string;
range: StatsRange;
org: string | null;
}): Promise<ClinicalStatsResponse> {
const params = new URLSearchParams({ range: opts.range });
if (opts.org) params.set('org', opts.org);
const url = `${this.config.clinicalApiBaseUrl}/v1/admin/stats?${params.toString()}`;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${opts.jwt}`,
'X-Actor-Context': opts.actorContext,
},
});
if (!res.ok) {
const body = await res.text();
this.logger.warn(`clinical-api stats ${res.status}: ${body}`);
throw new Error(`clinical_api_${res.status}`);
}
return (await res.json()) as ClinicalStatsResponse;
}
}
- [ ] Step 2: Test
ClinicalApiClient
Create services/admin-api/src/clients/clinical-api.client.spec.ts:
import * as nock from 'nock';
import { ClinicalApiClient } from './clinical-api.client';
describe('ClinicalApiClient.getStats', () => {
const client = new ClinicalApiClient({
clinicalApiBaseUrl: 'http://clin.test',
} as never);
afterEach(() => nock.cleanAll());
it('GETs the stats endpoint with bearer + actor-context headers', async () => {
nock('http://clin.test')
.matchHeader('authorization', 'Bearer jwt')
.matchHeader('x-actor-context', 'ac')
.get('/v1/admin/stats')
.query({ range: '7d', org: 'org-1' })
.reply(200, {
range: '7d',
scope: 'org',
orgId: 'org-1',
generatedAt: 'now',
cases: { today: 1, thisWeek: 7, thisMonth: 30 },
perOrg: [],
perProduct: [],
});
const r = await client.getStats({ jwt: 'jwt', actorContext: 'ac', range: '7d', org: 'org-1' });
expect(r.cases.thisWeek).toBe(7);
});
it('throws on non-2xx', async () => {
nock('http://clin.test').get('/v1/admin/stats').query(true).reply(500, 'oops');
await expect(
client.getStats({ jwt: 'jwt', actorContext: 'ac', range: '7d', org: null }),
).rejects.toThrow('clinical_api_500');
});
});
- [ ] Step 3: Implement
AiReviewClient(analogous)
Create services/admin-api/src/clients/ai-review.client.ts. Same shape as ClinicalApiClient with the response type from Task 2 (AiReviewAdminStats — copy the interface verbatim into this file). Base URL: aiReviewApiBaseUrl.
import { Injectable, Logger } from '@nestjs/common';
import { AppConfigService } from '../config/app-config';
export type StatsRange = 'today' | '7d' | '30d';
export interface AiReviewStatsResponse {
range: StatsRange;
scope: 'platform' | 'org';
orgId: string | null;
generatedAt: string;
inferences: {
today: number;
success24h: number;
failure24h: number;
successRate24h: number;
avgLatencyMs24h: number | null;
};
queueDepth: number;
recentFailures: Array<{ at: string; reason: string }>;
}
@Injectable()
export class AiReviewClient {
private readonly logger = new Logger(AiReviewClient.name);
constructor(private readonly config: AppConfigService) {}
async getStats(opts: {
jwt: string;
actorContext: string;
range: StatsRange;
org: string | null;
}): Promise<AiReviewStatsResponse> {
const params = new URLSearchParams({ range: opts.range });
if (opts.org) params.set('org', opts.org);
const url = `${this.config.aiReviewApiBaseUrl}/v1/admin/stats?${params.toString()}`;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${opts.jwt}`,
'X-Actor-Context': opts.actorContext,
},
});
if (!res.ok) {
const body = await res.text();
this.logger.warn(`ai-review stats ${res.status}: ${body}`);
throw new Error(`ai_review_${res.status}`);
}
return (await res.json()) as AiReviewStatsResponse;
}
}
Test in services/admin-api/src/clients/ai-review.client.spec.ts mirroring the clinical-api spec but with the ai-review shape.
- [ ] Step 4: Implement
HumanReviewClient(analogous)
Create services/admin-api/src/clients/human-review.client.ts:
import { Injectable, Logger } from '@nestjs/common';
import { AppConfigService } from '../config/app-config';
export type StatsRange = 'today' | '7d' | '30d';
export interface HumanReviewStatsResponse {
range: StatsRange;
scope: 'platform' | 'org';
orgId: string | null;
generatedAt: string;
queue: { open: number; claimed: number; submitted24h: number };
avgTimeToDecisionMs24h: number | null;
declineCount24h: number;
}
@Injectable()
export class HumanReviewClient {
private readonly logger = new Logger(HumanReviewClient.name);
constructor(private readonly config: AppConfigService) {}
async getStats(opts: {
jwt: string;
actorContext: string;
range: StatsRange;
org: string | null;
}): Promise<HumanReviewStatsResponse> {
const params = new URLSearchParams({ range: opts.range });
if (opts.org) params.set('org', opts.org);
const url = `${this.config.humanReviewApiBaseUrl}/v1/admin/stats?${params.toString()}`;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${opts.jwt}`,
'X-Actor-Context': opts.actorContext,
},
});
if (!res.ok) {
const body = await res.text();
this.logger.warn(`human-review stats ${res.status}: ${body}`);
throw new Error(`human_review_${res.status}`);
}
return (await res.json()) as HumanReviewStatsResponse;
}
}
Test in services/admin-api/src/clients/human-review.client.spec.ts (mirror the clinical-api spec shape).
- [ ] Step 5: Wire
ClientsModule
Create services/admin-api/src/clients/clients.module.ts:
import { Global, Module } from '@nestjs/common';
import { ClinicalApiClient } from './clinical-api.client';
import { AiReviewClient } from './ai-review.client';
import { HumanReviewClient } from './human-review.client';
@Global()
@Module({
providers: [ClinicalApiClient, AiReviewClient, HumanReviewClient],
exports: [ClinicalApiClient, AiReviewClient, HumanReviewClient],
})
export class ClientsModule {}
Add ClientsModule to services/admin-api/src/app.module.ts imports.
- [ ] Step 6: Run tests + typecheck
Run: pnpm --filter @sa-platform/admin-api test
Expected: PASS — including all three client specs.
Run: pnpm --filter @sa-platform/admin-api run typecheck
Expected: PASS.
- [ ] Step 7: Commit
git add services/admin-api/src/clients/ services/admin-api/src/app.module.ts
git commit -m "feat(admin-api): add typed HTTP clients for clinical-api, ai-review, human-review"
Task 11: Dashboard module — fan-out + cache + per-endpoint controllers¶
Files:
- Create:
services/admin-api/src/dashboard/dashboard.types.ts - Create:
services/admin-api/src/dashboard/dashboard.service.ts - Create:
services/admin-api/src/dashboard/dashboard.service.spec.ts - Create:
services/admin-api/src/dashboard/dashboard.controller.ts - Create:
services/admin-api/src/dashboard/dashboard.controller.spec.ts - Create:
services/admin-api/src/dashboard/dashboard.module.ts - Modify:
services/admin-api/src/app.module.ts
Endpoint contract¶
All under /api/dashboard/*, all @UseGuards(SessionGuard). Each endpoint accepts optional ?range=today|7d|30d (default 7d) and ?org=<orgId> (only applied if the session's JWT carries admin:cross-tenant — admin-api respects the same rule the backing services do).
| Path | Source | Returns shape |
|---|---|---|
GET /api/dashboard/health |
each /health + each stats queue |
{ services: [{name, status, queueDepth?}], partial } |
GET /api/dashboard/volume |
clinical-api stats | { data: ClinicalStatsResponse, partial: false } |
GET /api/dashboard/ai-review |
ai-review stats | { data: AiReviewStatsResponse, partial: false } |
GET /api/dashboard/human-review |
human-review stats | { data: HumanReviewStatsResponse, partial: false } |
GET /api/dashboard/orgs/:orgId |
all three stats with org=:orgId |
{ volume, aiReview, humanReview, partial, degradedFor[] } |
When a single backing service errors, the affected endpoint includes partial: true and degradedFor: ['ai-review' | …] and replaces the missing data shape with null.
Caching: each successful aggregation is cached in Redis under key admin-dashboard:{path}:{org|all}:{range} with TTL = dashboardCacheTtlSeconds (default 30s). Shared cache across users since the data isn't user-specific within a scope.
- [ ] Step 1: Write the dashboard types
Create services/admin-api/src/dashboard/dashboard.types.ts:
import { ClinicalStatsResponse } from '../clients/clinical-api.client';
import { AiReviewStatsResponse } from '../clients/ai-review.client';
import { HumanReviewStatsResponse } from '../clients/human-review.client';
export type StatsRange = 'today' | '7d' | '30d';
export interface ServiceHealth {
name: 'admin-api' | 'clinical-api' | 'ai-review' | 'human-review';
status: 'healthy' | 'degraded' | 'unreachable';
queueDepth?: number;
}
export interface HealthResponse {
services: ServiceHealth[];
partial: boolean;
generatedAt: string;
}
export interface VolumeResponse {
data: ClinicalStatsResponse | null;
partial: boolean;
degradedFor: string[];
generatedAt: string;
}
export interface AiReviewResponse {
data: AiReviewStatsResponse | null;
partial: boolean;
degradedFor: string[];
generatedAt: string;
}
export interface HumanReviewResponse {
data: HumanReviewStatsResponse | null;
partial: boolean;
degradedFor: string[];
generatedAt: string;
}
export interface OrgDetailResponse {
orgId: string;
volume: ClinicalStatsResponse | null;
aiReview: AiReviewStatsResponse | null;
humanReview: HumanReviewStatsResponse | null;
partial: boolean;
degradedFor: string[];
generatedAt: string;
}
- [ ] Step 2: Write the failing service test (cache + partial-failure)
Create services/admin-api/src/dashboard/dashboard.service.spec.ts:
import Redis from 'ioredis-mock';
import { DashboardService } from './dashboard.service';
describe('DashboardService', () => {
let redis: Redis;
const config = { dashboardCacheTtlSeconds: 30 } as never;
const clinical = { getStats: jest.fn() };
const aiReview = { getStats: jest.fn() };
const humanReview = { getStats: jest.fn() };
const audit = { record: jest.fn() };
let svc: DashboardService;
beforeEach(() => {
redis = new Redis();
svc = new DashboardService(
config,
clinical as never,
aiReview as never,
humanReview as never,
audit as never,
redis as never,
);
jest.clearAllMocks();
});
afterEach(() => redis.disconnect());
const session = {
sessionId: 'sess',
userId: 'u-1',
email: 'a@b.com',
displayName: 'A',
role: 'admin',
jwt: 'jwt',
jwtExp: Math.floor(Date.now() / 1000) + 3600,
createdAt: 0,
expiresAt: 0,
} as never;
it('returns volume from clinical-api and caches it', async () => {
clinical.getStats.mockResolvedValue({
range: '7d',
scope: 'platform',
orgId: null,
generatedAt: 'g',
cases: { today: 1, thisWeek: 7, thisMonth: 30 },
perOrg: [],
perProduct: [],
});
const a = await svc.getVolume(session, '7d', null);
const b = await svc.getVolume(session, '7d', null);
expect(clinical.getStats).toHaveBeenCalledTimes(1);
expect(a.data?.cases.thisWeek).toBe(7);
expect(b.data?.cases.thisWeek).toBe(7);
});
it('marks partial=true and degradedFor when ai-review fails', async () => {
clinical.getStats.mockResolvedValue({
range: '7d',
scope: 'platform',
orgId: null,
generatedAt: 'g',
cases: { today: 0, thisWeek: 0, thisMonth: 0 },
perOrg: [],
perProduct: [],
});
aiReview.getStats.mockRejectedValue(new Error('ai_review_500'));
humanReview.getStats.mockResolvedValue({
range: '7d',
scope: 'platform',
orgId: null,
generatedAt: 'g',
queue: { open: 0, claimed: 0, submitted24h: 0 },
avgTimeToDecisionMs24h: null,
declineCount24h: 0,
});
const r = await svc.getOrgDetail(session, 'org-1', '7d');
expect(r.partial).toBe(true);
expect(r.degradedFor).toContain('ai-review');
expect(r.aiReview).toBeNull();
expect(r.volume?.cases).toBeDefined();
expect(r.humanReview?.queue).toBeDefined();
});
});
Run: pnpm --filter @sa-platform/admin-api test -- dashboard.service
Expected: FAIL.
- [ ] Step 3: Implement
DashboardService
Create services/admin-api/src/dashboard/dashboard.service.ts:
import { Inject, Injectable, Logger } from '@nestjs/common';
import Redis from 'ioredis';
import { AppConfigService } from '../config/app-config';
import { AuditService } from '../audit/audit.service';
import { ClinicalApiClient } from '../clients/clinical-api.client';
import { AiReviewClient } from '../clients/ai-review.client';
import { HumanReviewClient } from '../clients/human-review.client';
import { REDIS_SESSION_CLIENT } from '../session/session.service';
import { SessionRecord } from '../session/session.types';
import {
AiReviewResponse,
HumanReviewResponse,
OrgDetailResponse,
StatsRange,
VolumeResponse,
} from './dashboard.types';
const PREFIX = 'admin-dashboard:';
@Injectable()
export class DashboardService {
private readonly logger = new Logger(DashboardService.name);
constructor(
private readonly config: AppConfigService,
private readonly clinical: ClinicalApiClient,
private readonly aiReview: AiReviewClient,
private readonly humanReview: HumanReviewClient,
private readonly audit: AuditService,
@Inject(REDIS_SESSION_CLIENT) private readonly redis: Redis,
) {}
private cacheKey(path: string, org: string | null, range: StatsRange): string {
return `${PREFIX}${path}:${org ?? 'all'}:${range}`;
}
private async withCache<T>(key: string, loader: () => Promise<T>): Promise<T> {
const cached = await this.redis.get(key);
if (cached) return JSON.parse(cached) as T;
const fresh = await loader();
await this.redis.set(key, JSON.stringify(fresh), 'EX', this.config.dashboardCacheTtlSeconds);
return fresh;
}
private actorHeader(session: SessionRecord): string {
return JSON.stringify({
external_user_id: session.userId,
display_name: session.displayName,
email: session.email,
role: session.role,
});
}
async getVolume(
session: SessionRecord,
range: StatsRange,
org: string | null,
): Promise<VolumeResponse> {
const key = this.cacheKey('volume', org, range);
const result = await this.withCache(key, async () => {
try {
const data = await this.clinical.getStats({
jwt: session.jwt,
actorContext: this.actorHeader(session),
range,
org,
});
return {
data,
partial: false,
degradedFor: [],
generatedAt: new Date().toISOString(),
} as VolumeResponse;
} catch (err) {
this.logger.warn(`getVolume failed: ${(err as Error).message}`);
return {
data: null,
partial: true,
degradedFor: ['clinical-api'],
generatedAt: new Date().toISOString(),
} as VolumeResponse;
}
});
await this.audit.record({
actorId: session.userId,
action: 'dashboard.volume.read',
target: org ?? 'ALL',
});
return result;
}
async getAiReview(
session: SessionRecord,
range: StatsRange,
org: string | null,
): Promise<AiReviewResponse> {
const key = this.cacheKey('ai-review', org, range);
const result = await this.withCache(key, async () => {
try {
const data = await this.aiReview.getStats({
jwt: session.jwt,
actorContext: this.actorHeader(session),
range,
org,
});
return {
data,
partial: false,
degradedFor: [],
generatedAt: new Date().toISOString(),
} as AiReviewResponse;
} catch (err) {
this.logger.warn(`getAiReview failed: ${(err as Error).message}`);
return {
data: null,
partial: true,
degradedFor: ['ai-review'],
generatedAt: new Date().toISOString(),
} as AiReviewResponse;
}
});
await this.audit.record({
actorId: session.userId,
action: 'dashboard.ai-review.read',
target: org ?? 'ALL',
});
return result;
}
async getHumanReview(
session: SessionRecord,
range: StatsRange,
org: string | null,
): Promise<HumanReviewResponse> {
const key = this.cacheKey('human-review', org, range);
const result = await this.withCache(key, async () => {
try {
const data = await this.humanReview.getStats({
jwt: session.jwt,
actorContext: this.actorHeader(session),
range,
org,
});
return {
data,
partial: false,
degradedFor: [],
generatedAt: new Date().toISOString(),
} as HumanReviewResponse;
} catch (err) {
this.logger.warn(`getHumanReview failed: ${(err as Error).message}`);
return {
data: null,
partial: true,
degradedFor: ['human-review'],
generatedAt: new Date().toISOString(),
} as HumanReviewResponse;
}
});
await this.audit.record({
actorId: session.userId,
action: 'dashboard.human-review.read',
target: org ?? 'ALL',
});
return result;
}
async getOrgDetail(
session: SessionRecord,
orgId: string,
range: StatsRange,
): Promise<OrgDetailResponse> {
const actor = this.actorHeader(session);
const [volumeR, aiR, hrR] = await Promise.allSettled([
this.clinical.getStats({ jwt: session.jwt, actorContext: actor, range, org: orgId }),
this.aiReview.getStats({ jwt: session.jwt, actorContext: actor, range, org: orgId }),
this.humanReview.getStats({ jwt: session.jwt, actorContext: actor, range, org: orgId }),
]);
const degradedFor: string[] = [];
const volume =
volumeR.status === 'fulfilled' ? volumeR.value : (degradedFor.push('clinical-api'), null);
const aiReview = aiR.status === 'fulfilled' ? aiR.value : (degradedFor.push('ai-review'), null);
const humanReview =
hrR.status === 'fulfilled' ? hrR.value : (degradedFor.push('human-review'), null);
await this.audit.record({
actorId: session.userId,
action: 'dashboard.org-detail.read',
target: orgId,
});
return {
orgId,
volume,
aiReview,
humanReview,
partial: degradedFor.length > 0,
degradedFor,
generatedAt: new Date().toISOString(),
};
}
async getHealth(session: SessionRecord): Promise<{
services: Array<{ name: string; status: string; queueDepth?: number }>;
partial: boolean;
generatedAt: string;
}> {
const actor = this.actorHeader(session);
const probes = await Promise.allSettled([
this.aiReview.getStats({ jwt: session.jwt, actorContext: actor, range: 'today', org: null }),
this.humanReview.getStats({
jwt: session.jwt,
actorContext: actor,
range: 'today',
org: null,
}),
this.clinical.getStats({ jwt: session.jwt, actorContext: actor, range: 'today', org: null }),
]);
const services = [
{ name: 'admin-api', status: 'healthy' as const },
probes[0].status === 'fulfilled'
? { name: 'ai-review', status: 'healthy' as const, queueDepth: probes[0].value.queueDepth }
: { name: 'ai-review', status: 'unreachable' as const },
probes[1].status === 'fulfilled'
? {
name: 'human-review',
status: 'healthy' as const,
queueDepth: probes[1].value.queue.open + probes[1].value.queue.claimed,
}
: { name: 'human-review', status: 'unreachable' as const },
probes[2].status === 'fulfilled'
? { name: 'clinical-api', status: 'healthy' as const }
: { name: 'clinical-api', status: 'unreachable' as const },
];
const partial = services.some((s) => s.status === 'unreachable');
return { services, partial, generatedAt: new Date().toISOString() };
}
}
- [ ] Step 4: Implement the controller
Create services/admin-api/src/dashboard/dashboard.controller.ts:
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
import { SessionGuard } from '../session/session.guard';
import { DashboardService } from './dashboard.service';
import {
StatsRange,
AiReviewResponse,
HumanReviewResponse,
HealthResponse,
OrgDetailResponse,
VolumeResponse,
} from './dashboard.types';
function parseRange(raw?: string): StatsRange {
return raw === 'today' || raw === '30d' ? raw : '7d';
}
@ApiTags('dashboard')
@Controller('api/dashboard')
@UseGuards(SessionGuard)
export class DashboardController {
constructor(private readonly svc: DashboardService) {}
@Get('health')
async health(@Req() req: Request): Promise<HealthResponse> {
return this.svc.getHealth(req.session!);
}
@Get('volume')
async volume(
@Req() req: Request,
@Query('range') range?: string,
@Query('org') org?: string,
): Promise<VolumeResponse> {
return this.svc.getVolume(req.session!, parseRange(range), org ?? null);
}
@Get('ai-review')
async aiReview(
@Req() req: Request,
@Query('range') range?: string,
@Query('org') org?: string,
): Promise<AiReviewResponse> {
return this.svc.getAiReview(req.session!, parseRange(range), org ?? null);
}
@Get('human-review')
async humanReview(
@Req() req: Request,
@Query('range') range?: string,
@Query('org') org?: string,
): Promise<HumanReviewResponse> {
return this.svc.getHumanReview(req.session!, parseRange(range), org ?? null);
}
@Get('orgs/:orgId')
async orgDetail(
@Req() req: Request,
@Param('orgId') orgId: string,
@Query('range') range?: string,
): Promise<OrgDetailResponse> {
return this.svc.getOrgDetail(req.session!, orgId, parseRange(range));
}
}
- [ ] Step 5: Test the controller (range parsing + delegation)
Create services/admin-api/src/dashboard/dashboard.controller.spec.ts:
import { DashboardController } from './dashboard.controller';
describe('DashboardController', () => {
const svc = {
getVolume: jest
.fn()
.mockResolvedValue({ data: null, partial: false, degradedFor: [], generatedAt: 'g' }),
getAiReview: jest
.fn()
.mockResolvedValue({ data: null, partial: false, degradedFor: [], generatedAt: 'g' }),
getHumanReview: jest
.fn()
.mockResolvedValue({ data: null, partial: false, degradedFor: [], generatedAt: 'g' }),
getHealth: jest.fn().mockResolvedValue({ services: [], partial: false, generatedAt: 'g' }),
getOrgDetail: jest.fn().mockResolvedValue({
orgId: 'o-1',
volume: null,
aiReview: null,
humanReview: null,
partial: false,
degradedFor: [],
generatedAt: 'g',
}),
};
const controller = new DashboardController(svc as never);
const req = { session: { userId: 'u' } } as never;
beforeEach(() => jest.clearAllMocks());
it('defaults range to 7d when omitted', async () => {
await controller.volume(req, undefined, undefined);
expect(svc.getVolume).toHaveBeenCalledWith(req.session, '7d', null);
});
it('passes through valid range and org', async () => {
await controller.aiReview(req, '30d', 'org-7');
expect(svc.getAiReview).toHaveBeenCalledWith(req.session, '30d', 'org-7');
});
it('falls back to 7d for invalid range', async () => {
await controller.humanReview(req, 'forever', undefined);
expect(svc.getHumanReview).toHaveBeenCalledWith(req.session, '7d', null);
});
});
- [ ] Step 6: Wire the module
Create services/admin-api/src/dashboard/dashboard.module.ts:
import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
@Module({
controllers: [DashboardController],
providers: [DashboardService],
})
export class DashboardModule {}
Add DashboardModule to services/admin-api/src/app.module.ts imports.
- [ ] Step 7: Run tests + typecheck
Run: pnpm --filter @sa-platform/admin-api test
Expected: PASS — all dashboard specs.
Run: pnpm --filter @sa-platform/admin-api run typecheck
Expected: PASS.
- [ ] Step 8: Commit
git add services/admin-api/src/dashboard/ services/admin-api/src/app.module.ts
git commit -m "feat(admin-api): add /api/dashboard endpoints with fan-out, cache, and partial-failure handling"
Task 12: admin-api integration tests (real Redis, mocked downstream)¶
Files:
- Create:
services/admin-api/jest-integration.config.js - Create:
services/admin-api/test/integration/setup.ts - Create:
services/admin-api/test/integration/auth.e2e.spec.ts - Create:
services/admin-api/test/integration/dashboard.e2e.spec.ts
Pattern to mirror¶
services/human-review/test/integration/ — same Jest config, forceExit: true, real ioredis pointing at a Docker Redis container, downstream services mocked with nock.
- [ ] Step 1: Create
jest-integration.config.js
/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
rootDir: '.',
moduleFileExtensions: ['ts', 'js'],
testRegex: 'test/integration/.*\\.spec\\.ts$',
setupFilesAfterEnv: ['<rootDir>/test/integration/setup.ts'],
forceExit: true, // see project memory: required for CI
testTimeout: 30000,
transform: { '^.+\\.ts$': ['ts-jest', { isolatedModules: true }] },
};
- [ ] Step 2: Implement
setup.ts
Create services/admin-api/test/integration/setup.ts:
import Redis from 'ioredis';
beforeAll(async () => {
// Wait for Redis to be reachable (CI may bring it up shortly before tests).
const client = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379');
let ready = false;
for (let i = 0; i < 30; i++) {
try {
await client.ping();
ready = true;
break;
} catch {
await new Promise((r) => setTimeout(r, 1000));
}
}
client.disconnect();
if (!ready) throw new Error('Redis not reachable');
});
afterAll(async () => {
const client = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379');
await client.flushdb();
client.disconnect();
});
- [ ] Step 3: Auth integration test
Create services/admin-api/test/integration/auth.e2e.spec.ts:
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as cookieParser from 'cookie-parser';
import * as nock from 'nock';
import * as request from 'supertest';
import { AppModule } from '../../src/app.module';
describe('Auth flow (integration)', () => {
let app: INestApplication;
beforeAll(async () => {
process.env.NODE_ENV = 'development';
process.env.PORT = '0';
process.env.DATABASE_URL = 'mysql://test:test@localhost:3306/admin';
process.env.REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379';
process.env.JWKS_URL = 'http://localhost:3000/.well-known/jwks.json';
process.env.SERVICE_AUTH_TOKEN = 'service-dev-token';
process.env.AUTH_API_BASE_URL = 'http://auth.test';
process.env.CLINICAL_API_BASE_URL = 'http://clin.test';
process.env.AI_REVIEW_API_BASE_URL = 'http://ai.test';
process.env.HUMAN_REVIEW_API_BASE_URL = 'http://hr.test';
process.env.GOOGLE_CLIENT_ID = 'goog-id';
process.env.GOOGLE_CLIENT_SECRET = 'goog-sec';
process.env.GOOGLE_REDIRECT_URI = 'http://localhost/callback';
process.env.ADMIN_DOMAIN_ALLOWLIST = 'skinanalytics.co.uk';
process.env.SESSION_COOKIE_DOMAIN = 'localhost';
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleRef.createNestApplication();
app.use(cookieParser());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
});
afterAll(async () => {
await app?.close();
nock.cleanAll();
});
it('GET /api/auth/google/start redirects to Google with a state param', async () => {
const res = await request(app.getHttpServer()).get('/api/auth/google/start');
expect(res.status).toBe(302);
expect(res.headers.location).toContain('https://accounts.google.com/o/oauth2/v2/auth');
expect(res.headers.location).toMatch(/state=[a-f0-9]{64}/);
});
it('GET /api/auth/google/callback rejects an unknown state', async () => {
const res = await request(app.getHttpServer())
.get('/api/auth/google/callback')
.query({ code: 'c', state: 'unknown' });
expect(res.status).toBe(400);
});
it('rejects /api/me without a session cookie', async () => {
const res = await request(app.getHttpServer()).get('/api/me');
expect(res.status).toBe(401);
});
});
- [ ] Step 4: Dashboard integration test
Create services/admin-api/test/integration/dashboard.e2e.spec.ts:
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as cookieParser from 'cookie-parser';
import * as nock from 'nock';
import * as request from 'supertest';
import Redis from 'ioredis';
import { AppModule } from '../../src/app.module';
import { SessionService } from '../../src/session/session.service';
describe('Dashboard (integration)', () => {
let app: INestApplication;
let sessionId: string;
beforeAll(async () => {
// (env from auth.e2e.spec.ts — duplicated for isolation)
process.env.NODE_ENV = 'development';
process.env.PORT = '0';
process.env.DATABASE_URL = 'mysql://test:test@localhost:3306/admin';
process.env.REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379';
process.env.JWKS_URL = 'http://localhost:3000/.well-known/jwks.json';
process.env.SERVICE_AUTH_TOKEN = 'service-dev-token';
process.env.AUTH_API_BASE_URL = 'http://auth.test';
process.env.CLINICAL_API_BASE_URL = 'http://clin.test';
process.env.AI_REVIEW_API_BASE_URL = 'http://ai.test';
process.env.HUMAN_REVIEW_API_BASE_URL = 'http://hr.test';
process.env.GOOGLE_CLIENT_ID = 'goog-id';
process.env.GOOGLE_CLIENT_SECRET = 'goog-sec';
process.env.GOOGLE_REDIRECT_URI = 'http://localhost/callback';
process.env.ADMIN_DOMAIN_ALLOWLIST = 'skinanalytics.co.uk';
process.env.SESSION_COOKIE_DOMAIN = 'localhost';
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleRef.createNestApplication();
app.use(cookieParser());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
// Seed a session manually (bypass OIDC for integration tests)
const sessions = app.get(SessionService);
const session = await sessions.create({
userId: 'u-1',
email: 'a@skinanalytics.co.uk',
displayName: 'A',
role: 'admin',
jwt: 'fake-jwt',
jwtExp: Math.floor(Date.now() / 1000) + 3600,
});
sessionId = session.sessionId;
});
afterAll(async () => {
nock.cleanAll();
const redis = new Redis(process.env.REDIS_URL!);
await redis.flushdb();
redis.disconnect();
await app?.close();
});
it('returns volume from clinical-api with caching', async () => {
nock('http://clin.test')
.get('/v1/admin/stats')
.query(true)
.times(1)
.reply(200, {
range: '7d',
scope: 'platform',
orgId: null,
generatedAt: 'g',
cases: { today: 1, thisWeek: 7, thisMonth: 30 },
perOrg: [],
perProduct: [],
});
const a = await request(app.getHttpServer())
.get('/api/dashboard/volume')
.set('Cookie', `admin_session=${sessionId}`);
expect(a.status).toBe(200);
expect(a.body.data.cases.thisWeek).toBe(7);
expect(a.body.partial).toBe(false);
// Second hit should be cached (nock only set up once)
const b = await request(app.getHttpServer())
.get('/api/dashboard/volume')
.set('Cookie', `admin_session=${sessionId}`);
expect(b.status).toBe(200);
expect(b.body.data.cases.thisWeek).toBe(7);
});
it('marks partial=true when ai-review is unreachable', async () => {
nock('http://clin.test')
.get('/v1/admin/stats')
.query(true)
.reply(200, {
range: '7d',
scope: 'org',
orgId: 'org-7',
generatedAt: 'g',
cases: { today: 0, thisWeek: 0, thisMonth: 0 },
perOrg: [],
perProduct: [],
});
nock('http://ai.test').get('/v1/admin/stats').query(true).reply(500);
nock('http://hr.test')
.get('/v1/admin/stats')
.query(true)
.reply(200, {
range: '7d',
scope: 'org',
orgId: 'org-7',
generatedAt: 'g',
queue: { open: 0, claimed: 0, submitted24h: 0 },
avgTimeToDecisionMs24h: null,
declineCount24h: 0,
});
const r = await request(app.getHttpServer())
.get('/api/dashboard/orgs/org-7')
.set('Cookie', `admin_session=${sessionId}`);
expect(r.status).toBe(200);
expect(r.body.partial).toBe(true);
expect(r.body.degradedFor).toContain('ai-review');
expect(r.body.aiReview).toBeNull();
expect(r.body.volume).not.toBeNull();
expect(r.body.humanReview).not.toBeNull();
});
});
- [ ] Step 5: Add the integration script to root and turbo pipeline
Verify services/admin-api/package.json has "test:integration": "jest --config jest-integration.config.js --forceExit" (added in Task 4 Step 1). The root pnpm turbo run test:integration should pick it up automatically.
- [ ] Step 6: Run integration tests with a Redis instance
Locally:
docker run -d --rm --name admin-api-redis -p 6379:6379 redis:7
REDIS_URL=redis://localhost:6379 pnpm --filter @sa-platform/admin-api run test:integration
docker stop admin-api-redis
Expected: PASS — both auth.e2e and dashboard.e2e specs.
- [ ] Step 7: Commit
git add services/admin-api/jest-integration.config.js services/admin-api/test/
git commit -m "test(admin-api): add integration suite (auth flow + dashboard fan-out)"
Task 13: apps/admin-ui scaffold (Vite + Mantine + Router)¶
Files:
- Create:
apps/admin-ui/package.json - Create:
apps/admin-ui/tsconfig.json - Create:
apps/admin-ui/vite.config.ts - Create:
apps/admin-ui/vitest.config.ts - Create:
apps/admin-ui/index.html - Create:
apps/admin-ui/src/main.tsx - Create:
apps/admin-ui/src/app.tsx - Create:
apps/admin-ui/src/theme.ts - Create:
apps/admin-ui/src/api/client.ts - Create:
apps/admin-ui/src/api/session.ts - Create:
apps/admin-ui/src/components/require-session.tsx - Create:
apps/admin-ui/src/components/require-session.test.tsx - Create:
apps/admin-ui/src/components/app-shell.tsx - Create:
apps/admin-ui/src/components/app-shell.test.tsx - Create:
apps/admin-ui/src/components/stub-page.tsx - Create:
apps/admin-ui/src/pages/login.tsx - Create:
apps/admin-ui/src/pages/login.test.tsx - Create:
apps/admin-ui/src/routes.tsx -
Modify:
pnpm-workspace.yaml(addapps/*) -
[ ] Step 1: Add
apps/*to the workspace
Edit pnpm-workspace.yaml:
packages:
- 'services/*'
- 'packages/*'
- 'apps/*'
- [ ] Step 2: Create
apps/admin-ui/package.json
{
"name": "@sa-platform/admin-ui",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx"
},
"dependencies": {
"@mantine/core": "^7.13.0",
"@mantine/hooks": "^7.13.0",
"@mantine/charts": "^7.13.0",
"@tabler/icons-react": "^3.17.0",
"@tanstack/react-query": "^5.59.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^6.27.0"
},
"devDependencies": {
"@playwright/test": "^1.48.0",
"@sa-platform/eslint-config": "workspace:^",
"@sa-platform/tsconfig": "workspace:^",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"jsdom": "^25.0.1",
"typescript": "^5.5.0",
"vite": "^5.4.0",
"vitest": "^2.1.0"
}
}
- [ ] Step 3: Create
tsconfig.json
{
"extends": "@sa-platform/tsconfig/base.json",
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"noEmit": true,
"isolatedModules": true,
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src", "test"]
}
- [ ] Step 4: Create
vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:3008',
},
},
build: {
outDir: 'dist',
sourcemap: true,
},
});
- [ ] Step 5: Create
vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./test/setup.ts'],
},
});
- [ ] Step 6: Create
test/setup.ts
import '@testing-library/jest-dom/vitest';
- [ ] Step 7: Create
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Skin Analytics — Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
- [ ] Step 8: Theme + main
Create apps/admin-ui/src/theme.ts:
import { createTheme } from '@mantine/core';
export const theme = createTheme({
primaryColor: 'blue',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
defaultRadius: 'md',
});
Create apps/admin-ui/src/main.tsx:
import '@mantine/core/styles.css';
import { MantineProvider } from '@mantine/core';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './app';
import { theme } from './theme';
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
});
const root = document.getElementById('root');
if (!root) throw new Error('No #root');
createRoot(root).render(
<StrictMode>
<MantineProvider theme={theme}>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</MantineProvider>
</StrictMode>,
);
- [ ] Step 9: API client + session hooks
Create apps/admin-ui/src/api/client.ts:
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly body: string,
) {
super(`api_error_${status}`);
}
}
export async function apiGet<T>(path: string): Promise<T> {
const res = await fetch(path, { credentials: 'same-origin' });
if (!res.ok) throw new ApiError(res.status, await res.text());
return (await res.json()) as T;
}
export async function apiPost<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(path, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new ApiError(res.status, await res.text());
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
Create apps/admin-ui/src/api/session.ts:
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost } from './client';
export interface MeResponse {
id: string;
email: string;
displayName: string;
role: 'admin' | 'support';
}
export function useMe() {
return useQuery({
queryKey: ['me'],
queryFn: () => apiGet<MeResponse>('/api/me'),
retry: false,
});
}
export function useLogout() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => apiPost<void>('/api/auth/logout'),
onSuccess: () => {
qc.clear();
window.location.href = '/login';
},
});
}
- [ ] Step 10:
RequireSessionboundary
Create apps/admin-ui/src/components/require-session.tsx:
import { Center, Loader } from '@mantine/core';
import type { ReactNode } from 'react';
import { Navigate } from 'react-router-dom';
import { useMe } from '../api/session';
interface Props {
children: ReactNode;
}
export function RequireSession({ children }: Props) {
const { data, isLoading, isError } = useMe();
if (isLoading) {
return (
<Center h="100vh">
<Loader />
</Center>
);
}
if (isError || !data) return <Navigate to="/login" replace />;
return <>{children}</>;
}
Test in require-session.test.tsx:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import { RequireSession } from './require-session';
vi.mock('../api/session', () => ({
useMe: vi.fn(),
}));
import { useMe } from '../api/session';
function wrap(ui: React.ReactElement) {
const qc = new QueryClient();
return (
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route path="/" element={ui} />
<Route path="/login" element={<div>LOGIN PAGE</div>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
);
}
describe('RequireSession', () => {
it('renders children when authenticated', () => {
(useMe as ReturnType<typeof vi.fn>).mockReturnValue({
data: { id: 'u-1' },
isLoading: false,
isError: false,
});
render(
wrap(
<RequireSession>
<div>SECRET</div>
</RequireSession>,
),
);
expect(screen.getByText('SECRET')).toBeInTheDocument();
});
it('redirects to /login on error', () => {
(useMe as ReturnType<typeof vi.fn>).mockReturnValue({
data: null,
isLoading: false,
isError: true,
});
render(
wrap(
<RequireSession>
<div>SECRET</div>
</RequireSession>,
),
);
expect(screen.getByText('LOGIN PAGE')).toBeInTheDocument();
});
});
- [ ] Step 11:
AppShellwith sidebar nav
Create apps/admin-ui/src/components/app-shell.tsx:
import { AppShell as MantineAppShell, Burger, Group, NavLink, Text, Title } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconChartBar,
IconBuildingCommunity,
IconUsers,
IconHierarchy,
IconSettings,
IconLogout,
} from '@tabler/icons-react';
import type { ReactNode } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useLogout, useMe } from '../api/session';
const navItems: Array<{
to: string;
label: string;
icon: React.FC<{ size?: number }>;
disabled?: boolean;
}> = [
{ to: '/', label: 'Dashboard', icon: IconChartBar },
{ to: '/orgs', label: 'Organisations', icon: IconBuildingCommunity, disabled: true },
{ to: '/users', label: 'Users', icon: IconUsers, disabled: true },
{ to: '/workflows', label: 'Workflows', icon: IconHierarchy, disabled: true },
{ to: '/settings', label: 'Settings', icon: IconSettings, disabled: true },
];
interface Props {
children: ReactNode;
}
export function AppShell({ children }: Props) {
const [opened, { toggle }] = useDisclosure();
const { pathname } = useLocation();
const me = useMe();
const logout = useLogout();
return (
<MantineAppShell
header={{ height: 56 }}
navbar={{ width: 260, breakpoint: 'sm', collapsed: { mobile: !opened } }}
padding="md"
>
<MantineAppShell.Header>
<Group h="100%" px="md" justify="space-between">
<Group>
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
<Title order={4}>Skin Analytics — Admin</Title>
</Group>
<Group>
<Text size="sm" c="dimmed">
{me.data?.email}
</Text>
<NavLink
label="Sign out"
leftSection={<IconLogout size={16} />}
onClick={() => logout.mutate()}
/>
</Group>
</Group>
</MantineAppShell.Header>
<MantineAppShell.Navbar p="md">
{navItems.map((item) => (
<NavLink
key={item.to}
component={item.disabled ? 'button' : Link}
to={item.disabled ? undefined : item.to}
label={item.disabled ? `${item.label} (coming soon)` : item.label}
leftSection={<item.icon size={18} />}
active={pathname === item.to}
disabled={item.disabled}
/>
))}
</MantineAppShell.Navbar>
<MantineAppShell.Main>{children}</MantineAppShell.Main>
</MantineAppShell>
);
}
Test in app-shell.test.tsx:
import { MantineProvider } from '@mantine/core';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import { AppShell } from './app-shell';
vi.mock('../api/session', () => ({
useMe: () => ({ data: { id: 'u-1', email: 'a@b.com', displayName: 'A B', role: 'admin' } }),
useLogout: () => ({ mutate: vi.fn() }),
}));
describe('AppShell', () => {
it('renders Dashboard nav item enabled and Phase-2 stubs disabled', () => {
render(
<QueryClientProvider client={new QueryClient()}>
<MantineProvider>
<MemoryRouter>
<AppShell>
<div>BODY</div>
</AppShell>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
expect(screen.getByText('Dashboard')).toBeInTheDocument();
expect(screen.getByText('Organisations (coming soon)')).toBeInTheDocument();
});
});
- [ ] Step 12:
StubPage+ login page
Create apps/admin-ui/src/components/stub-page.tsx:
import { Alert, Stack, Title, Text } from '@mantine/core';
export function StubPage({ name }: { name: string }) {
return (
<Stack>
<Title order={2}>{name}</Title>
<Alert color="blue" title="Coming soon">
<Text>
This view is part of a later phase. The Phase 1 admin UI ships dashboard read-only views.
</Text>
</Alert>
</Stack>
);
}
Create apps/admin-ui/src/pages/login.tsx:
import { Button, Card, Center, Stack, Text, Title } from '@mantine/core';
import { IconBrandGoogle } from '@tabler/icons-react';
export function LoginPage() {
return (
<Center h="100vh">
<Card shadow="md" padding="xl" maw={420} w="100%">
<Stack gap="md">
<Title order={3}>Sign in</Title>
<Text c="dimmed" size="sm">
Skin Analytics admin console — internal staff only.
</Text>
<Button
component="a"
href="/api/auth/google/start"
leftSection={<IconBrandGoogle size={18} />}
size="md"
>
Sign in with Google
</Button>
</Stack>
</Card>
</Center>
);
}
Test in login.test.tsx:
import { MantineProvider } from '@mantine/core';
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { LoginPage } from './login';
describe('LoginPage', () => {
it('renders the Google sign-in button pointing at the OIDC start endpoint', () => {
render(
<MantineProvider>
<LoginPage />
</MantineProvider>,
);
const link = screen.getByRole('link', { name: /sign in with google/i });
expect(link).toHaveAttribute('href', '/api/auth/google/start');
});
});
- [ ] Step 13: Routes + App
Create apps/admin-ui/src/routes.tsx:
import { Route, Routes } from 'react-router-dom';
import { AppShell } from './components/app-shell';
import { RequireSession } from './components/require-session';
import { StubPage } from './components/stub-page';
import { LoginPage } from './pages/login';
// Dashboard + OrgDetail are added in Tasks 14/15. For now, stub them so routing is testable.
function PlaceholderDashboard() {
return <StubPage name="Dashboard" />;
}
function PlaceholderOrgDetail() {
return <StubPage name="Organisation detail" />;
}
export function AppRoutes() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="*"
element={
<RequireSession>
<AppShell>
<Routes>
<Route path="/" element={<PlaceholderDashboard />} />
<Route path="/orgs/:orgId" element={<PlaceholderOrgDetail />} />
<Route path="/orgs" element={<StubPage name="Organisations" />} />
<Route path="/users" element={<StubPage name="Users" />} />
<Route path="/workflows" element={<StubPage name="Workflows" />} />
<Route path="/settings" element={<StubPage name="Settings" />} />
</Routes>
</AppShell>
</RequireSession>
}
/>
</Routes>
);
}
Create apps/admin-ui/src/app.tsx:
import { AppRoutes } from './routes';
export function App() {
return <AppRoutes />;
}
- [ ] Step 14: Run tests + typecheck + build
Run: pnpm --filter @sa-platform/admin-ui run test
Expected: PASS — login, require-session, app-shell specs.
Run: pnpm --filter @sa-platform/admin-ui run typecheck
Expected: PASS.
Run: pnpm --filter @sa-platform/admin-ui run build
Expected: PASS — dist/ produced with index.html + asset bundles.
- [ ] Step 15: Commit
git add apps/ pnpm-workspace.yaml pnpm-lock.yaml
git commit -m "feat(admin-ui): scaffold SPA with Mantine, Router, login page, and Phase-2 stubs"
Task 14: Dashboard page — metric cards + degraded-state UI¶
Files:
- Create:
apps/admin-ui/src/api/dashboard.ts - Create:
apps/admin-ui/src/components/metric-card.tsx - Create:
apps/admin-ui/src/components/metric-card.test.tsx - Create:
apps/admin-ui/src/components/degraded-banner.tsx - Create:
apps/admin-ui/src/pages/dashboard.tsx - Create:
apps/admin-ui/src/pages/dashboard.test.tsx - Modify:
apps/admin-ui/src/routes.tsx(replacePlaceholderDashboardwith the real page)
Layout¶
Mantine Grid, four metric cards across (Health · Volume · AI · Human Review), then a per-org table sourced from clinical-api's perOrg. Cards each have their own TanStack Query — failures are isolated to that card with a small "stats unavailable" indicator.
- [ ] Step 1: Implement the API hooks
Create apps/admin-ui/src/api/dashboard.ts:
import { useQuery } from '@tanstack/react-query';
import { apiGet } from './client';
export type StatsRange = 'today' | '7d' | '30d';
export interface DashboardHealth {
services: Array<{
name: string;
status: 'healthy' | 'degraded' | 'unreachable';
queueDepth?: number;
}>;
partial: boolean;
generatedAt: string;
}
export interface VolumeData {
range: StatsRange;
scope: 'platform' | 'org';
orgId: string | null;
cases: { today: number; thisWeek: number; thisMonth: number };
perOrg: Array<{ orgId: string; name: string; count: number }>;
perProduct: Array<{ productCode: string; count: number }>;
}
export interface VolumeResponse {
data: VolumeData | null;
partial: boolean;
degradedFor: string[];
generatedAt: string;
}
export interface AiReviewData {
inferences: {
today: number;
success24h: number;
failure24h: number;
successRate24h: number;
avgLatencyMs24h: number | null;
};
queueDepth: number;
recentFailures: Array<{ at: string; reason: string }>;
}
export interface AiReviewResponse {
data: AiReviewData | null;
partial: boolean;
degradedFor: string[];
generatedAt: string;
}
export interface HumanReviewData {
queue: { open: number; claimed: number; submitted24h: number };
avgTimeToDecisionMs24h: number | null;
declineCount24h: number;
}
export interface HumanReviewResponse {
data: HumanReviewData | null;
partial: boolean;
degradedFor: string[];
generatedAt: string;
}
export interface OrgDetailResponse {
orgId: string;
volume: VolumeData | null;
aiReview: AiReviewData | null;
humanReview: HumanReviewData | null;
partial: boolean;
degradedFor: string[];
generatedAt: string;
}
export function useHealth() {
return useQuery({
queryKey: ['dashboard', 'health'],
queryFn: () => apiGet<DashboardHealth>('/api/dashboard/health'),
});
}
export function useVolume(range: StatsRange = '7d', org: string | null = null) {
return useQuery({
queryKey: ['dashboard', 'volume', range, org],
queryFn: () =>
apiGet<VolumeResponse>(`/api/dashboard/volume?range=${range}${org ? `&org=${org}` : ''}`),
});
}
export function useAiReview(range: StatsRange = '7d', org: string | null = null) {
return useQuery({
queryKey: ['dashboard', 'ai-review', range, org],
queryFn: () =>
apiGet<AiReviewResponse>(
`/api/dashboard/ai-review?range=${range}${org ? `&org=${org}` : ''}`,
),
});
}
export function useHumanReview(range: StatsRange = '7d', org: string | null = null) {
return useQuery({
queryKey: ['dashboard', 'human-review', range, org],
queryFn: () =>
apiGet<HumanReviewResponse>(
`/api/dashboard/human-review?range=${range}${org ? `&org=${org}` : ''}`,
),
});
}
export function useOrgDetail(orgId: string, range: StatsRange = '7d') {
return useQuery({
queryKey: ['dashboard', 'org-detail', orgId, range],
queryFn: () => apiGet<OrgDetailResponse>(`/api/dashboard/orgs/${orgId}?range=${range}`),
});
}
- [ ] Step 2:
MetricCard
Create apps/admin-ui/src/components/metric-card.tsx:
import { Card, Group, Loader, Stack, Text, Title } from '@mantine/core';
import { IconAlertTriangle } from '@tabler/icons-react';
import type { ReactNode } from 'react';
interface Props {
title: string;
isLoading: boolean;
isError: boolean;
partial?: boolean;
children?: ReactNode;
}
export function MetricCard({ title, isLoading, isError, partial = false, children }: Props) {
return (
<Card shadow="sm" padding="lg" h="100%">
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm" c="dimmed" tt="uppercase">
{title}
</Text>
{partial && <IconAlertTriangle size={16} color="orange" aria-label="partial" />}
</Group>
{isLoading ? (
<Loader size="sm" />
) : isError ? (
<Text c="red">Stats unavailable</Text>
) : (
children
)}
</Stack>
</Card>
);
}
Test (metric-card.test.tsx):
import { MantineProvider } from '@mantine/core';
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { MetricCard } from './metric-card';
function wrap(ui: React.ReactElement) {
return <MantineProvider>{ui}</MantineProvider>;
}
describe('MetricCard', () => {
it('shows loading state', () => {
render(wrap(<MetricCard title="Cases" isLoading isError={false} />));
// Mantine Loader has role 'presentation'; just check the title is present
expect(screen.getByText('Cases')).toBeInTheDocument();
});
it('shows error state', () => {
render(wrap(<MetricCard title="Cases" isLoading={false} isError />));
expect(screen.getByText('Stats unavailable')).toBeInTheDocument();
});
it('shows partial indicator', () => {
render(
wrap(
<MetricCard title="Cases" isLoading={false} isError={false} partial>
{<span>1234</span>}
</MetricCard>,
),
);
expect(screen.getByLabelText('partial')).toBeInTheDocument();
});
});
- [ ] Step 3:
DegradedBanner
Create apps/admin-ui/src/components/degraded-banner.tsx:
import { Alert } from '@mantine/core';
import { IconAlertTriangle } from '@tabler/icons-react';
interface Props {
degradedFor: string[];
}
export function DegradedBanner({ degradedFor }: Props) {
if (degradedFor.length === 0) return null;
return (
<Alert color="orange" icon={<IconAlertTriangle size={18} />} title="Some data unavailable">
Couldn't reach: {degradedFor.join(', ')}. Other cards reflect the most recent successful
response.
</Alert>
);
}
- [ ] Step 4: Dashboard page
Create apps/admin-ui/src/pages/dashboard.tsx:
import { Anchor, Group, Select, SimpleGrid, Stack, Table, Text, Title } from '@mantine/core';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { DegradedBanner } from '../components/degraded-banner';
import { MetricCard } from '../components/metric-card';
import {
useAiReview,
useHealth,
useHumanReview,
useVolume,
type StatsRange,
} from '../api/dashboard';
function formatMs(ms: number | null): string {
if (ms === null) return '—';
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60_000).toFixed(1)}m`;
}
export function DashboardPage() {
const [range, setRange] = useState<StatsRange>('7d');
const health = useHealth();
const volume = useVolume(range);
const aiReview = useAiReview(range);
const humanReview = useHumanReview(range);
const allDegraded = [
...(volume.data?.degradedFor ?? []),
...(aiReview.data?.degradedFor ?? []),
...(humanReview.data?.degradedFor ?? []),
...(health.data?.partial ? ['health'] : []),
];
return (
<Stack gap="lg">
<Group justify="space-between">
<Title order={2}>Platform overview</Title>
<Select
data={[
{ value: 'today', label: 'Today' },
{ value: '7d', label: 'Last 7 days' },
{ value: '30d', label: 'Last 30 days' },
]}
value={range}
onChange={(v) => v && setRange(v as StatsRange)}
w={180}
/>
</Group>
<DegradedBanner degradedFor={allDegraded} />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
<MetricCard
title="Operational health"
isLoading={health.isLoading}
isError={health.isError}
partial={health.data?.partial}
>
<Text fw={700}>
{health.data?.services.filter((s) => s.status === 'healthy').length ?? '—'} /{' '}
{health.data?.services.length ?? '—'} healthy
</Text>
</MetricCard>
<MetricCard
title="Cases"
isLoading={volume.isLoading}
isError={volume.isError || (volume.data?.data === null && volume.data?.partial)}
partial={volume.data?.partial}
>
<Text fw={700}>{volume.data?.data?.cases.thisWeek ?? '—'}</Text>
<Text size="xs" c="dimmed">
this week
</Text>
</MetricCard>
<MetricCard
title="AI inferences"
isLoading={aiReview.isLoading}
isError={aiReview.isError || (aiReview.data?.data === null && aiReview.data?.partial)}
partial={aiReview.data?.partial}
>
<Text fw={700}>{aiReview.data?.data?.inferences.today ?? '—'}</Text>
<Text size="xs" c="dimmed">
today — {((aiReview.data?.data?.inferences.successRate24h ?? 0) * 100).toFixed(1)}%
success
</Text>
</MetricCard>
<MetricCard
title="Human review queue"
isLoading={humanReview.isLoading}
isError={
humanReview.isError || (humanReview.data?.data === null && humanReview.data?.partial)
}
partial={humanReview.data?.partial}
>
<Text fw={700}>{humanReview.data?.data?.queue.open ?? '—'}</Text>
<Text size="xs" c="dimmed">
open — avg {formatMs(humanReview.data?.data?.avgTimeToDecisionMs24h ?? null)}
</Text>
</MetricCard>
</SimpleGrid>
<Stack gap="xs">
<Title order={4}>Per organisation (this week)</Title>
<Table withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Organisation</Table.Th>
<Table.Th style={{ width: 120 }}>Cases</Table.Th>
<Table.Th style={{ width: 120 }}>Drill-down</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(volume.data?.data?.perOrg ?? []).map((org) => (
<Table.Tr key={org.orgId}>
<Table.Td>{org.name}</Table.Td>
<Table.Td>{org.count}</Table.Td>
<Table.Td>
<Anchor component={Link} to={`/orgs/${org.orgId}`}>
open
</Anchor>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
</Stack>
);
}
- [ ] Step 5: Test the dashboard page
Create apps/admin-ui/src/pages/dashboard.test.tsx:
import { MantineProvider } from '@mantine/core';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import { DashboardPage } from './dashboard';
vi.mock('../api/dashboard', () => ({
useHealth: vi.fn(),
useVolume: vi.fn(),
useAiReview: vi.fn(),
useHumanReview: vi.fn(),
}));
import { useAiReview, useHealth, useHumanReview, useVolume } from '../api/dashboard';
function ok<T>(data: T) {
return { data, isLoading: false, isError: false };
}
function wrap(ui: React.ReactElement) {
return (
<MantineProvider>
<MemoryRouter>{ui}</MemoryRouter>
</MantineProvider>
);
}
describe('DashboardPage', () => {
it('renders all four metric cards with values', () => {
(useHealth as ReturnType<typeof vi.fn>).mockReturnValue(
ok({
services: [
{ name: 'admin-api', status: 'healthy' },
{ name: 'clinical-api', status: 'healthy' },
],
partial: false,
generatedAt: 'g',
}),
);
(useVolume as ReturnType<typeof vi.fn>).mockReturnValue(
ok({
data: {
range: '7d',
scope: 'platform',
orgId: null,
cases: { today: 1, thisWeek: 7, thisMonth: 30 },
perOrg: [{ orgId: 'o-1', name: 'Acme', count: 5 }],
perProduct: [],
},
partial: false,
degradedFor: [],
generatedAt: 'g',
}),
);
(useAiReview as ReturnType<typeof vi.fn>).mockReturnValue(
ok({
data: {
inferences: {
today: 12,
success24h: 100,
failure24h: 0,
successRate24h: 1,
avgLatencyMs24h: 1800,
},
queueDepth: 3,
recentFailures: [],
},
partial: false,
degradedFor: [],
generatedAt: 'g',
}),
);
(useHumanReview as ReturnType<typeof vi.fn>).mockReturnValue(
ok({
data: {
queue: { open: 4, claimed: 1, submitted24h: 8 },
avgTimeToDecisionMs24h: 90_000,
declineCount24h: 0,
},
partial: false,
degradedFor: [],
generatedAt: 'g',
}),
);
render(wrap(<DashboardPage />));
expect(screen.getByText('Platform overview')).toBeInTheDocument();
expect(screen.getByText('7')).toBeInTheDocument(); // weekly cases
expect(screen.getByText('12')).toBeInTheDocument(); // ai inferences
expect(screen.getByText('4')).toBeInTheDocument(); // hr open
expect(screen.getByText('Acme')).toBeInTheDocument(); // per-org table
});
it('shows the degraded banner when any source returned partial data', () => {
(useHealth as ReturnType<typeof vi.fn>).mockReturnValue(
ok({ services: [], partial: false, generatedAt: 'g' }),
);
(useVolume as ReturnType<typeof vi.fn>).mockReturnValue(
ok({ data: null, partial: true, degradedFor: ['clinical-api'], generatedAt: 'g' }),
);
(useAiReview as ReturnType<typeof vi.fn>).mockReturnValue(
ok({ data: null, partial: false, degradedFor: [], generatedAt: 'g' }),
);
(useHumanReview as ReturnType<typeof vi.fn>).mockReturnValue(
ok({ data: null, partial: false, degradedFor: [], generatedAt: 'g' }),
);
render(wrap(<DashboardPage />));
expect(screen.getByText(/Some data unavailable/i)).toBeInTheDocument();
expect(screen.getByText(/clinical-api/)).toBeInTheDocument();
});
});
- [ ] Step 6: Wire the dashboard page into routes
Edit apps/admin-ui/src/routes.tsx. Replace PlaceholderDashboard with DashboardPage:
import { DashboardPage } from './pages/dashboard';
// ... drop the PlaceholderDashboard function ...
// ... in the routes:
<Route path="/" element={<DashboardPage />} />;
- [ ] Step 7: Run tests + typecheck
Run: pnpm --filter @sa-platform/admin-ui test
Expected: PASS — including dashboard.test and metric-card.test.
Run: pnpm --filter @sa-platform/admin-ui run typecheck
Expected: PASS.
- [ ] Step 8: Commit
git add apps/admin-ui/src/api/dashboard.ts apps/admin-ui/src/components/metric-card.tsx apps/admin-ui/src/components/metric-card.test.tsx apps/admin-ui/src/components/degraded-banner.tsx apps/admin-ui/src/pages/dashboard.tsx apps/admin-ui/src/pages/dashboard.test.tsx apps/admin-ui/src/routes.tsx
git commit -m "feat(admin-ui): dashboard page with health/volume/AI/HR metric cards and degraded-state banner"
Task 15: Per-organisation drill-down page¶
Files:
- Create:
apps/admin-ui/src/pages/org-detail.tsx - Create:
apps/admin-ui/src/pages/org-detail.test.tsx -
Modify:
apps/admin-ui/src/routes.tsx -
[ ] Step 1: Implement the page
Create apps/admin-ui/src/pages/org-detail.tsx:
import { Anchor, Group, SimpleGrid, Stack, Text, Title } from '@mantine/core';
import { Link, useParams } from 'react-router-dom';
import { DegradedBanner } from '../components/degraded-banner';
import { MetricCard } from '../components/metric-card';
import { useOrgDetail } from '../api/dashboard';
export function OrgDetailPage() {
const { orgId = '' } = useParams<{ orgId: string }>();
const { data, isLoading, isError } = useOrgDetail(orgId);
return (
<Stack gap="lg">
<Group justify="space-between">
<div>
<Title order={2}>Organisation: {orgId}</Title>
<Anchor component={Link} to="/" size="sm">
← back to overview
</Anchor>
</div>
</Group>
{data && <DegradedBanner degradedFor={data.degradedFor} />}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
<MetricCard
title="Cases (this week)"
isLoading={isLoading}
isError={isError || (data?.volume === null && data?.partial)}
>
<Text fw={700}>{data?.volume?.cases.thisWeek ?? '—'}</Text>
</MetricCard>
<MetricCard
title="AI success rate (24h)"
isLoading={isLoading}
isError={isError || (data?.aiReview === null && data?.partial)}
>
<Text fw={700}>
{data?.aiReview
? `${(data.aiReview.inferences.successRate24h * 100).toFixed(1)}%`
: '—'}
</Text>
</MetricCard>
<MetricCard
title="Human review queue"
isLoading={isLoading}
isError={isError || (data?.humanReview === null && data?.partial)}
>
<Text fw={700}>{data?.humanReview?.queue.open ?? '—'}</Text>
</MetricCard>
</SimpleGrid>
</Stack>
);
}
- [ ] Step 2: Test
Create apps/admin-ui/src/pages/org-detail.test.tsx:
import { MantineProvider } from '@mantine/core';
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import { OrgDetailPage } from './org-detail';
vi.mock('../api/dashboard', () => ({
useOrgDetail: vi.fn(),
}));
import { useOrgDetail } from '../api/dashboard';
function wrap(orgId: string) {
return (
<MantineProvider>
<MemoryRouter initialEntries={[`/orgs/${orgId}`]}>
<Routes>
<Route path="/orgs/:orgId" element={<OrgDetailPage />} />
</Routes>
</MemoryRouter>
</MantineProvider>
);
}
describe('OrgDetailPage', () => {
it('renders metrics for the requested org', () => {
(useOrgDetail as ReturnType<typeof vi.fn>).mockReturnValue({
data: {
orgId: 'org-1',
volume: {
range: '7d',
scope: 'org',
orgId: 'org-1',
cases: { today: 0, thisWeek: 12, thisMonth: 50 },
perOrg: [],
perProduct: [],
},
aiReview: {
inferences: {
today: 0,
success24h: 99,
failure24h: 1,
successRate24h: 0.99,
avgLatencyMs24h: 2000,
},
queueDepth: 0,
recentFailures: [],
},
humanReview: {
queue: { open: 3, claimed: 0, submitted24h: 5 },
avgTimeToDecisionMs24h: 60000,
declineCount24h: 1,
},
partial: false,
degradedFor: [],
generatedAt: 'g',
},
isLoading: false,
isError: false,
});
render(wrap('org-1'));
expect(screen.getByText(/Organisation: org-1/)).toBeInTheDocument();
expect(screen.getByText('12')).toBeInTheDocument();
expect(screen.getByText('99.0%')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
});
});
- [ ] Step 3: Wire routes
Edit apps/admin-ui/src/routes.tsx. Replace PlaceholderOrgDetail with OrgDetailPage:
import { OrgDetailPage } from './pages/org-detail';
// drop PlaceholderOrgDetail
<Route path="/orgs/:orgId" element={<OrgDetailPage />} />;
- [ ] Step 4: Run tests + typecheck
Run: pnpm --filter @sa-platform/admin-ui test
Expected: PASS — including org-detail.test.
Run: pnpm --filter @sa-platform/admin-ui run typecheck
Expected: PASS.
- [ ] Step 5: Commit
git add apps/admin-ui/src/pages/org-detail.tsx apps/admin-ui/src/pages/org-detail.test.tsx apps/admin-ui/src/routes.tsx
git commit -m "feat(admin-ui): add /orgs/:orgId drill-down page"
Task 16: Playwright e2e smoke (login → dashboard → drill-down → logout)¶
Files:
- Create:
apps/admin-ui/playwright.config.ts - Create:
apps/admin-ui/test/e2e/login-to-dashboard.spec.ts - Modify:
apps/admin-ui/package.json(adde2escript)
The smoke runs against the Vite dev server with /api/* mocked via Playwright route handlers. No real backend required — this is an SPA-side regression test that walks the happy path.
- [ ] Step 1: Add the e2e script and Playwright config
Edit apps/admin-ui/package.json scripts:
"e2e": "playwright test"
Create apps/admin-ui/playwright.config.ts:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './test/e2e',
timeout: 30_000,
use: {
baseURL: 'http://localhost:5173',
trace: 'retain-on-failure',
},
webServer: {
command: 'pnpm dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
});
- [ ] Step 2: Write the smoke test
Create apps/admin-ui/test/e2e/login-to-dashboard.spec.ts:
import { expect, test } from '@playwright/test';
test('happy path: login → dashboard → org drill-down → logout', async ({ page }) => {
// Mock /api/me — first call 401 (logged out), then 200 after we "log in"
let loggedIn = false;
await page.route('**/api/me', (route) => {
if (loggedIn) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: 'u-1',
email: 'a@skinanalytics.co.uk',
displayName: 'Alice',
role: 'admin',
}),
});
}
return route.fulfill({ status: 401, body: '' });
});
// Mock /api/auth/google/start to "log us in" without leaving the page
await page.route('**/api/auth/google/start', (route) => {
loggedIn = true;
return route.fulfill({ status: 302, headers: { location: '/' } });
});
// Mock /api/auth/logout
await page.route('**/api/auth/logout', (route) => {
loggedIn = false;
return route.fulfill({ status: 204 });
});
// Mock dashboard endpoints
await page.route('**/api/dashboard/health', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
services: [
{ name: 'admin-api', status: 'healthy' },
{ name: 'clinical-api', status: 'healthy' },
{ name: 'ai-review', status: 'healthy', queueDepth: 2 },
{ name: 'human-review', status: 'healthy', queueDepth: 5 },
],
partial: false,
generatedAt: '2026-04-27T00:00:00Z',
}),
}),
);
await page.route('**/api/dashboard/volume**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: {
range: '7d',
scope: 'platform',
orgId: null,
cases: { today: 1, thisWeek: 12, thisMonth: 50 },
perOrg: [{ orgId: 'org-1', name: 'Acme', count: 5 }],
perProduct: [],
},
partial: false,
degradedFor: [],
generatedAt: 'g',
}),
}),
);
await page.route('**/api/dashboard/ai-review**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: {
inferences: {
today: 0,
success24h: 100,
failure24h: 0,
successRate24h: 1,
avgLatencyMs24h: 1500,
},
queueDepth: 2,
recentFailures: [],
},
partial: false,
degradedFor: [],
generatedAt: 'g',
}),
}),
);
await page.route('**/api/dashboard/human-review**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: {
queue: { open: 4, claimed: 1, submitted24h: 8 },
avgTimeToDecisionMs24h: 90_000,
declineCount24h: 0,
},
partial: false,
degradedFor: [],
generatedAt: 'g',
}),
}),
);
await page.route('**/api/dashboard/orgs/org-1', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
orgId: 'org-1',
volume: {
range: '7d',
scope: 'org',
orgId: 'org-1',
cases: { today: 0, thisWeek: 5, thisMonth: 20 },
perOrg: [],
perProduct: [],
},
aiReview: null,
humanReview: {
queue: { open: 2, claimed: 0, submitted24h: 3 },
avgTimeToDecisionMs24h: 60000,
declineCount24h: 0,
},
partial: true,
degradedFor: ['ai-review'],
generatedAt: 'g',
}),
}),
);
// 1. Visit root → redirected to /login
await page.goto('/');
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByText(/Sign in/i)).toBeVisible();
// 2. Click "Sign in with Google" → /api/auth/google/start mocked above
await page.getByRole('link', { name: /Sign in with Google/i }).click();
// 3. Land on dashboard
await expect(page.getByText('Platform overview')).toBeVisible();
await expect(page.getByText('Acme')).toBeVisible();
// 4. Drill down into Acme
await page.getByRole('link', { name: 'open' }).click();
await expect(page.getByText('Organisation: org-1')).toBeVisible();
await expect(page.getByText(/Some data unavailable/i)).toBeVisible();
// 5. Sign out
await page.getByText('Sign out').click();
// After logout, /api/me returns 401 → next page load goes to /login
await page.goto('/');
await expect(page).toHaveURL(/\/login$/);
});
- [ ] Step 3: Install Playwright browsers locally (one-time)
pnpm --filter @sa-platform/admin-ui exec playwright install --with-deps chromium
- [ ] Step 4: Run the e2e test
pnpm --filter @sa-platform/admin-ui run e2e
Expected: PASS — single spec, single test case.
- [ ] Step 5: Commit
git add apps/admin-ui/playwright.config.ts apps/admin-ui/test/e2e/ apps/admin-ui/package.json
git commit -m "test(admin-ui): playwright e2e smoke (login → dashboard → drill-down → logout)"
Task 17: Documentation updates¶
Files:
- Create:
docs/audiences/tech/services/admin-api.md - Create:
docs/audiences/tech/services/admin-ui.md - Modify:
docs/audiences/tech/README.md - Modify:
docs/audiences/smt/capability-map.md - Modify:
docs/audiences/smt/architecture-glance.md - Modify:
docs/diagrams/architecture-glance.mmd - Modify:
docs/audiences/smt/roadmap.md - Modify:
docs/audiences/compliance/security-model.md - Modify:
docs/audiences/compliance/audit-trail.md - Modify:
mkdocs.yml - Modify:
scripts/docs/soup-classification.yaml
Documentation must reflect the new components before this branch ships, and mkdocs build --strict must remain green (the docs PR pattern from the prior strategy work).
- [ ] Step 1: Create
docs/audiences/tech/services/admin-api.md
---
title: admin-api
audience: tech
owner: service-owner
last_reviewed: 2026-04-27
review_cycle_days: 90
---
# admin-api
## Purpose
Backend-for-frontend (BFF) for the internal admin SPA. Holds the platform JWT
server-side, brokers Google OIDC sign-in via the auth service, fans out
read-only dashboard data from clinical-api / ai-review / human-review, and
records every admin action to its own `AdminAuditLog` table.
## Key endpoints
`GET /api/auth/google/start` — redirects to Google's OIDC consent
`GET /api/auth/google/callback` — completes OIDC, sets the session cookie
`POST /api/auth/logout` — destroys the session
`GET /api/me` — returns the current admin's identity
`GET /api/dashboard/health` — service health + queue depths
`GET /api/dashboard/volume` — case volume metrics from clinical-api
`GET /api/dashboard/ai-review` — AI throughput + failure metrics
`GET /api/dashboard/human-review` — human-review queue metrics
`GET /api/dashboard/orgs/:orgId` — per-org drill-down (parallel fan-out)
`POST /internal/admin-users/resolve` — internal-only; called by services/auth
during login
## Database tables
`AdminUser` — source of truth for who is allowed to sign in (email-keyed,
role + status fields).
`AdminAuditLog` — append-only audit row per admin action (login, logout,
dashboard reads).
## Events
None emitted, none consumed. The dashboard reads via HTTP, not the event bus.
## Dependencies
- `services/auth` — issues platform JWTs after Google OIDC succeeds.
- `services/clinical-api`, `services/ai-review`, `services/human-review`
— `/v1/admin/stats` endpoints for dashboard data.
- Redis — Session store and dashboard fan-out cache (30-second TTL by
default).
- MySQL — admin-api owns its own database with the two tables above.
## Where to learn more
- [Design spec](../../../superpowers/specs/2026-04-27-admin-ui-phase1-design.md)
- [Implementation plan](../../../superpowers/plans/2026-04-27-admin-ui-phase1.md)
- Source: `services/admin-api/` (in this repo)
- [ ] Step 2: Create
docs/audiences/tech/services/admin-ui.md
---
title: admin-ui
audience: tech
owner: service-owner
last_reviewed: 2026-04-27
review_cycle_days: 90
---
# admin-ui
## Purpose
The Phase 1 internal admin SPA. Vite + React + Mantine. Talks only to
`services/admin-api` over same-origin `/api/*`. Renders the read-only
dashboard, organisation drill-down, and login page. Phase 2 will add
organisation / user CRUD; Phase 3 the workflow definition editor — both
deferred behind nav stubs.
## Tech stack
- **Build:** Vite 5
- **Framework:** React 18, TypeScript
- **UI:** Mantine 7 (`AppShell` provides the sidebar + header layout)
- **Routing:** React Router 6
- **Data fetching:** TanStack Query 5 (single source of cache; no Redux)
- **Charts:** Mantine Charts (recharts wrapper)
- **Tests:** Vitest + React Testing Library; Playwright for one e2e smoke
## Key routes
| Path | Purpose |
| -------------- | ------------------------------------------------------ |
| `/login` | Google sign-in (redirects to `/api/auth/google/start`) |
| `/` | Dashboard with health / volume / AI / HR cards |
| `/orgs/:orgId` | Per-organisation drill-down |
| `/orgs` | Phase 2 stub |
| `/users` | Phase 2 stub |
| `/workflows` | Phase 3 stub |
| `/settings` | Future |
## Auth boundary
`<RequireSession>` wraps every authenticated route. Reads `/api/me`; on 401
redirects to `/login`. The session cookie is httpOnly so the SPA never
touches the platform JWT.
## Where to learn more
- [Design spec](../../../superpowers/specs/2026-04-27-admin-ui-phase1-design.md)
- [Implementation plan](../../../superpowers/plans/2026-04-27-admin-ui-phase1.md)
- Source: `apps/admin-ui/` (in this repo)
- [ ] Step 3: Update
docs/audiences/tech/README.md
Find the service catalogue table and add two rows (or one merged row for the SPA + BFF — preserve the existing table style):
| `admin-api` | Admin BFF: Google OIDC sessions, dashboard aggregation | [`services/admin-api.md`](services/admin-api.md) |
| `admin-ui` | Internal admin SPA (Vite + React + Mantine) | [`services/admin-ui.md`](services/admin-ui.md) |
Keep existing rows alphabetically ordered if the table is sorted; otherwise append.
- [ ] Step 4: Update
docs/audiences/smt/capability-map.md
Add two rows to the ## Services table (preserve table style):
| `admin-ui` | Internal admin web console (Phase 1: dashboard) | Operational visibility for SA staff |
| `admin-api` | Admin BFF — auth, fan-out, audit trail | Single secure surface for admin actions |
Update the "8 services" intro to "9 services + 1 SPA" (or whatever phrasing matches the existing copy).
- [ ] Step 5: Update the architecture-glance Mermaid diagram
Edit docs/diagrams/architecture-glance.mmd to add admin-ui (in Clients) and admin-api (in Workflow / Platform tier — pick whichever group reads best):
graph TB
subgraph "Clients"
Mobile[Mobile App]
Portal[Customer Portal]
Partner[Partner Integrations]
Admin[Admin SPA]
end
subgraph "API Tier"
CA[clinical-api]
AdminApi[admin-api]
end
...
Admin --> AdminApi
AdminApi --> Auth
AdminApi -.-> CA
AdminApi -.-> AI
AdminApi -.-> HR
(Dotted arrows for the read-only fan-out connections.)
- [ ] Step 6: Update
docs/audiences/smt/architecture-glance.md
The page embeds the same diagram inline. Update the inline Mermaid block in the markdown to mirror the changes from Step 5 — keep the two in sync (the comment in the file says so).
- [ ] Step 7: Update
docs/audiences/smt/roadmap.md
In the "Shipped" table, add:
| Internal admin console — Phase 1: dashboard | `admin-ui` + `admin-api` |
In the "Deferred / future" list, replace the existing "Reviewer UI (currently API-only)" entry context with explicit Phase 2 / Phase 3 items if they're not already there:
- Admin console Phase 2 — organisation / user CRUD
- Admin console Phase 3 — workflow definition editor
- [ ] Step 8: Update
docs/audiences/compliance/security-model.md
Find the §1 Authentication section. Add a sub-section:
### Admin SSO (Google OIDC)
Internal admin staff sign in to the admin console via Google OpenID Connect.
The flow is brokered by `services/admin-api` (the admin BFF) and finalised by
`services/auth`:
1. Browser → admin-api `GET /api/auth/google/start` → redirect to Google.
2. Google → admin-api `GET /api/auth/google/callback`. admin-api validates
the state nonce, exchanges the OAuth code for an `id_token` against
`https://oauth2.googleapis.com/token`, and forwards the `id_token` to
the auth service over a service-to-service call.
3. auth service verifies the id_token signature against Google's JWKS, asserts
`email_verified === true`, checks the email's domain against
`ADMIN_DOMAIN_ALLOWLIST`, calls back to admin-api to resolve the
`AdminUser` row, and rejects pending or disabled users.
4. auth service issues a platform JWT with scopes `admin:read
admin:cross-tenant`, returns it to admin-api over the service channel.
5. admin-api stores the JWT in a Redis-backed session, sets an `admin_session`
httpOnly cookie on the browser, and redirects to the SPA root.
The browser never sees the platform JWT. The session cookie is `httpOnly`,
`Secure` (in production), `SameSite=Lax`. MFA is enforced upstream by Google
Workspace at the IDP level.
Code: `services/auth/src/oauth/`, `services/admin-api/src/auth/`,
`services/admin-api/src/session/`.
- [ ] Step 9: Update
docs/audiences/compliance/audit-trail.md
Find §1 ("What's logged"). Add:
- **AdminAuditLog** in admin-api — one row per admin action (sign-in,
sign-out, dashboard read, per-org drill-down). Fields: `actorId`, `action`
(e.g. `auth.login`, `dashboard.volume.read`), `target` (org id or `ALL`),
`metadata`, `at`. Drives both incident review and the compliance evidence
trail for who looked at what data.
In §4 ("How to query"), add:
- `GET /admin-audit/...` (Phase 2) — admin-api queryable audit-log endpoint;
not yet exposed via UI in Phase 1. Direct DB access in the meantime.
- [ ] Step 10: Update
mkdocs.ymlnav
In the "Services" sub-list under "Tech", add admin-api.md and admin-ui.md (alphabetical):
- Services:
- audiences/tech/services/admin-api.md
- audiences/tech/services/admin-ui.md
- audiences/tech/services/ai-review.md
- audiences/tech/services/auth.md
...
- [ ] Step 11: Update
scripts/docs/soup-classification.yaml
Add classification stanzas for the new prod dependencies:
'@mantine/core':
risk: low
provenance: Active OSS project, popular React UI library
used_in: admin-ui
'@mantine/hooks':
risk: low
provenance: Same author as @mantine/core
used_in: admin-ui
'@mantine/charts':
risk: low
provenance: Same author as @mantine/core; thin wrapper over recharts
used_in: admin-ui
'@tanstack/react-query':
risk: low
provenance: Active OSS project, very widely used data-fetching library
used_in: admin-ui
'react-router-dom':
risk: low
provenance: Remix Software / OSS, defacto React routing library
used_in: admin-ui
'google-auth-library':
risk: low
provenance: First-party from Google; verifies Google id_tokens
used_in: services/auth
- [ ] Step 12: Run docs strict build
mkdocs build --strict
Expected: exit 0, no warnings.
- [ ] Step 13: Run staleness check
bash scripts/docs/check-staleness.sh
Expected: 0 stale (0 missing frontmatter). The doc count goes up by 2 (admin-api.md + admin-ui.md).
- [ ] Step 14: Commit
git add docs/ mkdocs.yml scripts/docs/soup-classification.yaml
git commit -m "docs: document admin-ui Phase 1 (admin-api + admin-ui pages, security/audit updates, capability map)"
Task 18: Pre-PR verification¶
No new files. This task runs every gate in sequence to catch regressions before opening the PR.
- [ ] Step 1: Workspace install (frozen)
pnpm install --frozen-lockfile
Expected: clean install, no warnings about missing peers in the new packages.
- [ ] Step 2: Typecheck the entire workspace
pnpm turbo run typecheck
Expected: PASS for every package, including admin-api and admin-ui.
- [ ] Step 3: Unit tests across the workspace
pnpm turbo run test
Expected: PASS for every package. Service unit specs (admin-api, the three stats endpoints, auth oauth) plus admin-ui Vitest specs all green.
- [ ] Step 4: Integration tests (admin-api)
docker run -d --rm --name admin-api-redis -p 6379:6379 redis:7
REDIS_URL=redis://localhost:6379 pnpm --filter @sa-platform/admin-api run test:integration
docker stop admin-api-redis
Expected: PASS — auth.e2e + dashboard.e2e specs.
- [ ] Step 5: Playwright e2e (admin-ui)
pnpm --filter @sa-platform/admin-ui run e2e
Expected: PASS.
- [ ] Step 6: Docs strict build
mkdocs build --strict
Expected: exit 0, no warnings.
- [ ] Step 7: Format check
pnpm format:check
Expected: PASS. If any new file fails, run pnpm format then re-stage and add a follow-up commit style: prettier on admin-ui phase 1 files.
- [ ] Step 8: Lint
pnpm turbo run lint
Expected: PASS for every package.
- [ ] Step 9: Doc generation pipeline (smoke)
GENERATE_OPENAPI=1 \
DATABASE_URL="mysql://test:test@localhost:3306/admin" \
REDIS_URL="redis://localhost:6379" \
bash scripts/generate-docs.sh
Expected: SBOM + licenses + SOUP + diagrams + 9 OpenAPI specs (now including admin-api). The new services/admin-api should appear in the output.
- [ ] Step 10: Sanity-walk the SPA against a running BFF (optional but recommended)
This step is manual. If you have access to dev infra:
# Terminal 1: admin-api against dev backing services
docker run -d --rm --name admin-api-redis -p 6379:6379 redis:7
NODE_ENV=development \
PORT=3008 \
DATABASE_URL=mysql://... \
REDIS_URL=redis://localhost:6379 \
... (full env per the spec) ...
pnpm --filter @sa-platform/admin-api run start:dev
# Terminal 2: SPA
pnpm --filter @sa-platform/admin-ui run dev
Open http://localhost:5173, click sign in, walk through dashboard + drill-down + sign-out. If anything is visibly broken, fix in a small follow-up commit before opening the PR; otherwise proceed.
If dev infra isn't accessible, skip — the integration tests + e2e smoke cover the same paths.
Task 19: Open the pull request¶
- [ ] Step 1: Confirm branch state
git status
git log --oneline main..HEAD | wc -l
Expected: clean tree (or only .claude/settings.local.json modified, which doesn't ship). Commit count ≈ 18 (one per task, plus any review-fix commits along the way).
- [ ] Step 2: Push the branch
git push -u origin feat/admin-ui-phase1
If push is rejected because the branch already exists, STOP and ask the user before force-pushing.
- [ ] Step 3: Open the PR via gh
gh pr create --title "feat: admin UI Phase 1 — internal SPA + BFF + Google OIDC SSO" --body "$(cat <<'EOF'
## Summary
First UI surface in the platform: an internal admin console for SA staff.
Vite + React + Mantine SPA backed by a new NestJS BFF (`services/admin-api`),
authenticated via Google OIDC SSO, surfacing a read-only dashboard
(operational health, volume, AI throughput, human-review queue,
per-organisation drill-down).
### What shipped
- **`apps/admin-ui/`** — first frontend in the repo. Vite + React + Mantine
+ TanStack Query + React Router. Sidebar nav with placeholder slots for
Phase 2 (Orgs/Users) and Phase 3 (Workflows) — they render "coming soon"
pages.
- **`services/admin-api/`** — new NestJS BFF. Holds the platform JWT
server-side, manages Redis-backed sessions, fans out dashboard data,
emits an `AdminAuditLog` row per admin action.
- **`services/auth/` extension** — new `POST /v1/oauth/google/callback`
endpoint. Verifies Google `id_token`, checks email domain allowlist,
resolves AdminUser via admin-api, issues platform JWT with `admin:read
admin:cross-tenant` scopes.
- **`/v1/admin/stats` endpoints** — added to `clinical-api`, `ai-review`,
and `human-review`. Each returns a small typed shape consumed by the BFF.
- **Documentation** — service summaries (admin-api + admin-ui), capability
map + roadmap updates, security model section on Google OIDC, audit-trail
entry for `AdminAuditLog`, mkdocs nav, SOUP classifications for the new
Mantine/TanStack/React Router/google-auth-library deps.
### Side fixes
- `@sa-platform/auth-client` gains `admin:read` and `admin:write` scopes.
- New auth scope `admin:cross-tenant` honoured by all three new
`/v1/admin/stats` endpoints.
- `pnpm-workspace.yaml` now includes `apps/*`.
### Out of scope (future)
- Phase 2 — organisation / user CRUD (admin-api gains write paths; SPA gains
routes; reuses the BFF auth + audit primitives).
- Phase 3 — workflow definition editor (orchestrator's JSON DSL — its own
spec).
- Customer-organisation admin login (different IDPs per org) — Phase 4.
- MFA enforcement at the application layer (handled by Google Workspace).
### Test plan
- [x] Workspace typecheck + unit tests + lint + format all green
- [x] admin-api integration suite (auth + dashboard fan-out) passes against
a real Redis container
- [x] admin-ui Playwright e2e smoke passes (login → dashboard → drill-down →
logout)
- [x] `mkdocs build --strict` clean
- [x] `bash scripts/generate-docs.sh` produces 9 OpenAPI specs
### Operator notes (post-merge)
1. Provision a Google OIDC client in Google Cloud Console; set
`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI` in
admin-api's prod environment, and `GOOGLE_CLIENT_ID` in auth service's
prod environment.
2. Run admin-api Prisma migration once at deploy time (creates `AdminUser`
+ `AdminAuditLog` tables).
3. Insert the first `AdminUser` row with `status='active'` and
`role='admin'` (manual SQL or `prisma db execute`). Subsequent admins
can be added via Phase 2 once it ships.
4. Verify the `ADMIN_DOMAIN_ALLOWLIST` env var matches your organisation's
Google Workspace domain(s).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
If gh is not authenticated or the create step fails, STOP and report.
- [ ] Step 4: Capture the PR URL and report
The gh pr create output prints the PR URL on success. Note it for the next person to review.
Notes for the implementer¶
- Branch: Implementation runs on
feat/admin-ui-phase1. Don't merge tomaindirectly — open the PR from this branch. - Service patterns: When in doubt, read
services/human-review/first. Every convention (Prisma 7 driver-adapter, AppConfigService with useFactory, ioredis with OnApplicationShutdown, integration tests with forceExit=true) is taken from there. - Mantine specifics: Mantine 7 requires
@mantine/core/styles.cssto be imported once at the entry.<MantineProvider>must wrap the entire tree before any Mantine component renders, including in tests — wrap test subjects in<MantineProvider>to avoid console warnings. - OIDC verification:
google-auth-library'sOAuth2Client.verifyIdTokendoes the heavy lifting (JWKS fetch + cache, signature, exp, aud, iss). Don't reimplement. - Cookie domain: In dev,
SESSION_COOKIE_DOMAIN=localhostworks because Vite's proxy forwards/api/*to admin-api on the same origin. In production set the apex domain (e.g..cdm.skinanalytics.co.uk) so the cookie applies to both the SPA's hostname and the API hostname if they differ. - Cache invalidation: Phase 1 has no manual cache-bust button. The 30s
TTL covers most cases; the SPA's TanStack Query
staleTimematches. Phase 2 may add a manual refresh control. - Generated artifacts gitignored: Don't commit anything from
docs/audiences/*/generated/,docs/diagrams/*.svg, ordist/docs/. - Scope drift: Resist the urge to scaffold Phase 2 endpoints (org/user CRUD) "while we're here." Each phase ships with its own spec. The nav stubs in admin-ui are deliberately inert.