import { Type } from 'class-transformer'; import { IsEnum, IsInt, IsISO8601, IsOptional, IsString, Max, MaxLength, Min, } from 'class-validator'; /** * Query parameters for `GET /api/admin/users`. Mirrors the * audit-viewer DTO's pagination posture per ADR-0020 §"User list" — * offset/limit with hard caps, every filter optional. Bound from * the URL via Nest's `@Query()` + global ValidationPipe so unknown * keys are rejected by `forbidNonWhitelisted` before reaching the * handler. * * Filters target the `public.users` directory (PR #140). The * `username` filter is exact-prefix match (Prisma `startsWith`) * because audit users typically search by exact known prefix — * `jane.doe`, `admin`, etc. `displayName` filter uses * case-insensitive contains since display names have natural * variation in casing. */ export const MAX_LIMIT = 200; export const DEFAULT_LIMIT = 50; const ADMIN_USERS_AUDIENCES = ['workforce', 'customer'] as const; type AudienceFilter = (typeof ADMIN_USERS_AUDIENCES)[number]; export class AdminUsersQueryDto { /** Exact-prefix match on `username`. */ @IsOptional() @IsString() @MaxLength(128) username?: string; /** Case-insensitive `contains` match on `display_name`. */ @IsOptional() @IsString() @MaxLength(128) displayName?: string; @IsOptional() @IsEnum(ADMIN_USERS_AUDIENCES) audience?: AudienceFilter; /** ISO-8601 lower bound on `last_seen_at` (inclusive). */ @IsOptional() @IsISO8601() lastSeenAtFrom?: string; /** ISO-8601 upper bound on `last_seen_at` (exclusive). */ @IsOptional() @IsISO8601() lastSeenAtTo?: string; @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(MAX_LIMIT) limit?: number; @IsOptional() @Type(() => Number) @IsInt() @Min(0) offset?: number; }