/** * TASK-0133 — the ORG ADMIN console API (all routes behind * `requireOrgAdmin`; the org-scoped audit viewer, the org policy editor, * retention policies + run-now, members + overview). * * Routes (all `requireUser` via the global guard → user JWT only): * GET /orgs/:orgId/admin/overview * GET /orgs/:orgId/admin/members (?q=) * PATCH /orgs/:orgId/admin/members/:userId {role?, suspend?} * GET /orgs/:orgId/admin/policy * PATCH /orgs/:orgId/admin/policy (partial merge + audit) * GET /orgs/:orgId/admin/audit (+filters, paged) * GET /orgs/:orgId/admin/audit/actions (autocomplete) * POST /orgs/:orgId/admin/audit/export (sync CSV ≤10k | 202 async) * GET /orgs/:orgId/admin/audit/exports/:id (status + single-use URL) * GET /orgs/:orgId/admin/retention * PATCH /orgs/:orgId/admin/retention * POST /orgs/:orgId/admin/retention/run {scope, mode} * * Read-only guarantee (audit immutability): this controller + * `audit.service.ts` expose NO audit mutation route — the integration * router-walk test asserts it. */ import { Body, Controller, Get, HttpCode, Inject, Param, Post, Patch, Query, Req, Res } from "@nestjs/common"; import type { Request, Response } from "express"; import { z } from "zod"; import { OrgAuditService } from "./audit.service.js"; import { OrgPolicyService } from "./org-policy.service.js"; import { OrgRetentionService } from "./retention.service.js"; import { OrgSearchService } from "./search.service.js"; import { OrgMembersService } from "./members.service.js"; import { OrgOverviewService } from "./overview.service.js"; import { OrgJobsService } from "./jobs.service.js"; import { OrgNotificationsService } from "./notifications.service.js"; import { OrgAnalyticsService } from "./analytics.service.js"; import { OrgPolicyUpdateSchema } from "./policy.schema.js"; const MembersQuery = z.object({ q: z.string().max(200).optional() }); const MemberUpdate = z .object({ role: z.enum(["OWNER", "ADMIN", "MEMBER", "GUEST"]).optional(), suspend: z.boolean().optional() }) .refine((v) => v.role !== undefined || v.suspend !== undefined, { message: "Provide role and/or suspend" }); const RequeueJobSchema = z.object({ queue: z.enum(["files", "search-index", "webhooks", "automations", "email", "ai", "maintenance"]), jobId: z.string().min(1).max(128) }); const RetentionRun = z.object({ scope: z.enum(["trash", "comments", "audit_logs", "files"]), mode: z.enum(["dry", "execute"]) }); const RetentionUpdate = z .object({ trash: z.object({ type: z.enum(["PURGE", "HOLD"]), retainDays: z.number().int().min(0).max(3650) }).optional(), comments: z.object({ type: z.enum(["PURGE", "HOLD"]), retainDays: z.number().int().min(0).max(3650) }).optional(), audit_logs: z.object({ type: z.enum(["PURGE", "HOLD"]), retainDays: z.number().int().min(0).max(3650) }).optional(), files: z.object({ type: z.enum(["PURGE", "HOLD"]), retainDays: z.number().int().min(0).max(3650) }).optional() }) .refine((v) => Object.keys(v).length > 0, { message: "No retention scopes provided" }); @Controller("orgs/:orgId/admin") export class OrgAdminController { // Explicit @Inject tokens (NOT bare params): vitest/esbuild strips // `design:paramtypes` (no emitDecoratorMetadata under the test runner), so // a bare constructor param resolves to `undefined` at runtime under the // integration suite (the repo convention — see orgs.controller.ts). constructor( @Inject(OrgAuditService) private readonly audit: OrgAuditService, @Inject(OrgPolicyService) private readonly policy: OrgPolicyService, @Inject(OrgRetentionService) private readonly retention: OrgRetentionService, @Inject(OrgSearchService) private readonly search: OrgSearchService, @Inject(OrgMembersService) private readonly members: OrgMembersService, @Inject(OrgOverviewService) private readonly overview: OrgOverviewService, @Inject(OrgJobsService) private readonly jobs: OrgJobsService, @Inject(OrgNotificationsService) private readonly notifications: OrgNotificationsService, @Inject(OrgAnalyticsService) private readonly analytics: OrgAnalyticsService ) {} // ── overview ──────────────────────────────────────────────────────────── @Get("overview") async overviewRoute(@Param("orgId") orgId: string, @Req() req: Request) { return this.overview.get(orgId, req); } /** * The org's workspace list — the API-keys / webhooks tabs are * WORKSPACE-scoped in the API, so the console picks a workspace from here. */ @Get("workspaces") async orgWorkspaces(@Param("orgId") orgId: string, @Req() req: Request) { return this.overview.workspaces(orgId, req); } // ── analytics (Phase 8) ───────────────────────────────────────────────── /** Read-only org-scoped usage/health analytics (counts + 30-day trends). * Admin-only; additive to `overview` (does not change that payload). */ @Get("analytics") async analyticsRoute(@Param("orgId") orgId: string, @Req() req: Request) { return this.analytics.analytics(orgId, req); } // ── members ───────────────────────────────────────────────────────────── @Get("members") async membersList( @Param("orgId") orgId: string, @Query() query: Record, @Req() req: Request ) { const { q } = MembersQuery.parse(query); return this.members.list(orgId, q, req); } @Patch("members/:userId") async memberUpdate( @Param("orgId") orgId: string, @Param("userId") userId: string, @Body() body: Record, @Req() req: Request ) { const parsed = MemberUpdate.parse(body); return this.members.update(orgId, userId, parsed, req); } // ── policy ────────────────────────────────────────────────────────────── @Get("policy") async policyGet(@Param("orgId") orgId: string, @Req() req: Request) { return this.policy.get(orgId, req); } @Patch("policy") async policyPatch( @Param("orgId") orgId: string, @Body() body: Record, @Req() req: Request ) { const parsed = OrgPolicyUpdateSchema.parse(body); return this.policy.update(orgId, parsed, req); } // ── audit viewer ──────────────────────────────────────────────────────── @Get("audit") async auditList( @Param("orgId") orgId: string, @Query() query: Record, @Req() req: Request ) { return this.audit.list(orgId, query, req); } @Get("audit/actions") async auditActions(@Param("orgId") orgId: string, @Req() req: Request) { return this.audit.distinctActions(orgId, req); } @Post("audit/export") async auditExport( @Param("orgId") orgId: string, @Body() body: Record, @Req() req: Request, @Res() res: Response ) { // The body carries the SAME filter set as the query (the UI sends its // current filter bar). `page`/`pageSize` are ignored for exports. const result = await this.audit.startExport(orgId, body ?? {}, req); if (result.kind === "sync") { res .status(200) .setHeader("Content-Type", "text/csv; charset=utf-8") .setHeader("Content-Disposition", `attachment; filename="audit-${orgId.slice(0, 8)}.csv"`) .send(result.csv); return; } // Enqueue the worker job (maintenance queue) — the single async path. await this.audit.enqueueExportJob(result.exportId, orgId); res.status(202).json({ exportId: result.exportId, cap: result.cap, total: result.total }); return; } @Get("audit/exports/:exportId") async exportStatus( @Param("orgId") orgId: string, @Param("exportId") exportId: string, @Query() query: Record, @Req() req: Request ) { return this.audit.exportStatus(orgId, exportId, query.redownload === "1" || query.redownload === true, req); } // ── retention ─────────────────────────────────────────────────────────── @Get("retention") async retentionGet(@Param("orgId") orgId: string, @Req() req: Request) { return this.retention.list(orgId, req); } @Patch("retention") async retentionPatch( @Param("orgId") orgId: string, @Body() body: Record, @Req() req: Request ) { const parsed = RetentionUpdate.parse(body); return this.retention.update(orgId, parsed, req); } @Post("retention/run") async retentionRun( @Param("orgId") orgId: string, @Body() body: Record, @Req() req: Request, @Res() res: Response ) { const parsed = RetentionRun.parse(body); const { runId } = await this.retention.enqueueRun(orgId, parsed.scope, parsed.mode, req); res.status(202).json({ runId, status: "processing" }); return; } @Get("retention/runs/:runId") async retentionRunStatus( @Param("orgId") orgId: string, @Param("runId") runId: string, @Req() req: Request ) { return this.retention.runStatus(orgId, runId, req); } // ── jobs (T-176: background job status + failures) ───────────────────── @Get("jobs") async jobsOverview(@Param("orgId") orgId: string, @Req() req: Request) { return this.jobs.overview(orgId, req); } @Get("jobs/failed") async jobsFailed( @Param("orgId") orgId: string, @Query("queue") queue: string | undefined, @Query("limit") limit: string | undefined, @Req() req: Request ) { return this.jobs.failed(orgId, req, queue, limit ? Number(limit) : 20); } @Post("jobs/requeue") @HttpCode(200) // quick synchronous mutation (house style; Nest defaults POST to 201) async jobsRequeue(@Param("orgId") orgId: string, @Body() body: Record, @Req() req: Request) { const { queue, jobId } = RequeueJobSchema.parse(body); return this.jobs.requeue(orgId, req, { queue, jobId }); } // ── search health (T-177: doc count / last reconcile / lag) ───────────── @Get("search/health") async searchHealth(@Param("orgId") orgId: string, @Req() req: Request) { return this.search.health(orgId, req); } // ── notifications health (T-178: delivery send/fail + failures) ───────── @Get("notifications/health") async notificationsHealth(@Param("orgId") orgId: string, @Req() req: Request) { return this.notifications.health(orgId, req); } // ── search (T-156: admin reindex trigger) ─────────────────────────────── /** * T-156 — `POST /orgs/:orgId/admin/search/reindex` — enqueue an org-scoped * full content-index rebuild (idempotent source→index; async). Optional * `workspaceId` narrows to one workspace of the org. The worker does the * work; this endpoint only gates + audits + enqueues. */ @Post("search/reindex") searchReindex( @Param("orgId") orgId: string, @Query("workspaceId") workspaceId: string | undefined, @Req() req: Request ) { return this.search.enqueueReindex(orgId, req, workspaceId); } }