Skip to content

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 on docs/admin-ui-phase1-spec and 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 with useFactory, ioredis with OnApplicationShutdown, integration tests with forceExit: 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 (add services/* 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 ConfigModule with the useFactory pattern

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) and AppModule

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 (import PrismaModule)

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 PrismaModule 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 { 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"

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 (import SessionModule)

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 SessionModule 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 { 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 (add ADMIN_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:

  1. Verify id_token signature against Google's JWKS (https://www.googleapis.com/oauth2/v3/certs)
  2. Verify iss in ['https://accounts.google.com', 'accounts.google.com'], aud === GOOGLE_CLIENT_ID, email_verified === true, exp not past
  3. Check email domain ∈ ADMIN_DOMAIN_ALLOWLIST
  4. Call admin-api POST /internal/admin-users/resolve { email, displayName } (using SERVICE_AUTH_TOKEN)
  5. Reject if response status ≠ 'active'
  6. Issue platform JWT: scope='admin:read admin:cross-tenant', sub=user.id, aud='admin-api', exp=now+8h, plus actor_context: { email, displayName, role }
  7. Return token + user

  8. [ ] Step 1: Extend services/auth AppConfigService

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/auth config 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 using google-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 URL
  • GET /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 me controller

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 (add apps/*)

  • [ ] 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: RequireSession boundary

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: AppShell with 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 (replace PlaceholderDashboard with 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 (add e2e script)

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.yml nav

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 to main directly — 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.css to 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's OAuth2Client.verifyIdToken does the heavy lifting (JWKS fetch + cache, signature, exp, aud, iss). Don't reimplement.
  • Cookie domain: In dev, SESSION_COOKIE_DOMAIN=localhost works 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 staleTime matches. Phase 2 may add a manual refresh control.
  • Generated artifacts gitignored: Don't commit anything from docs/audiences/*/generated/, docs/diagrams/*.svg, or dist/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.