1
24import { Body, Controller, Get, HttpCode, Inject, Param, Post, Patch, Query, Req, Res } from "@nestjs/common";
25import type { Request, Response } from "express";
26import { z } from "zod";
27import { OrgAuditService } from "./audit.service.js";
28import { OrgPolicyService } from "./org-policy.service.js";
29import { OrgRetentionService } from "./retention.service.js";
30import { OrgSearchService } from "./search.service.js";
31import { OrgMembersService } from "./members.service.js";
32import { OrgOverviewService } from "./overview.service.js";
33import { OrgJobsService } from "./jobs.service.js";
34import { OrgNotificationsService } from "./notifications.service.js";
35import { OrgAnalyticsService } from "./analytics.service.js";
36import { OrgPolicyUpdateSchema } from "./policy.schema.js";
37
38const MembersQuery = z.object({ q: z.string().max(200).optional() });
39const MemberUpdate = z
40 .object({
41 role: z.enum(["OWNER", "ADMIN", "MEMBER", "GUEST"]).optional(),
42 suspend: z.boolean().optional()
43 })
44 .refine((v) => v.role !== undefined || v.suspend !== undefined, {
45 message: "Provide role and/or suspend"
46 });
47const RequeueJobSchema = z.object({
48 queue: z.enum(["files", "search-index", "webhooks", "automations", "email", "ai", "maintenance"]),
49 jobId: z.string().min(1).max(128)
50});
51const RetentionRun = z.object({
52 scope: z.enum(["trash", "comments", "audit_logs", "files"]),
53 mode: z.enum(["dry", "execute"])
54});
55const RetentionUpdate = z
56 .object({
57 trash: z.object({ type: z.enum(["PURGE", "HOLD"]), retainDays: z.number().int().min(0).max(3650) }).optional(),
58 comments: z.object({ type: z.enum(["PURGE", "HOLD"]), retainDays: z.number().int().min(0).max(3650) }).optional(),
59 audit_logs: z.object({ type: z.enum(["PURGE", "HOLD"]), retainDays: z.number().int().min(0).max(3650) }).optional(),
60 files: z.object({ type: z.enum(["PURGE", "HOLD"]), retainDays: z.number().int().min(0).max(3650) }).optional()
61 })
62 .refine((v) => Object.keys(v).length > 0, { message: "No retention scopes provided" });
63
64@Controller("orgs/:orgId/admin")
65export class OrgAdminController {
66
67
68
69
70 constructor(
71 @Inject(OrgAuditService) private readonly audit: OrgAuditService,
72 @Inject(OrgPolicyService) private readonly policy: OrgPolicyService,
73 @Inject(OrgRetentionService) private readonly retention: OrgRetentionService,
74 @Inject(OrgSearchService) private readonly search: OrgSearchService,
75 @Inject(OrgMembersService) private readonly members: OrgMembersService,
76 @Inject(OrgOverviewService) private readonly overview: OrgOverviewService,
77 @Inject(OrgJobsService) private readonly jobs: OrgJobsService,
78 @Inject(OrgNotificationsService) private readonly notifications: OrgNotificationsService,
79 @Inject(OrgAnalyticsService) private readonly analytics: OrgAnalyticsService
80 ) {}
81
82
83 @Get("overview")
84 async overviewRoute(@Param("orgId") orgId: string, @Req() req: Request) {
85 return this.overview.get(orgId, req);
86 }
87
88
92 @Get("workspaces")
93 async orgWorkspaces(@Param("orgId") orgId: string, @Req() req: Request) {
94 return this.overview.workspaces(orgId, req);
95 }
96
97
98
100 @Get("analytics")
101 async analyticsRoute(@Param("orgId") orgId: string, @Req() req: Request) {
102 return this.analytics.analytics(orgId, req);
103 }
104
105
106 @Get("members")
107 async membersList(
108 @Param("orgId") orgId: string,
109 @Query() query: Record<string, unknown>,
110 @Req() req: Request
111 ) {
112 const { q } = MembersQuery.parse(query);
113 return this.members.list(orgId, q, req);
114 }
115
116 @Patch("members/:userId")
117 async memberUpdate(
118 @Param("orgId") orgId: string,
119 @Param("userId") userId: string,
120 @Body() body: Record<string, unknown>,
121 @Req() req: Request
122 ) {
123 const parsed = MemberUpdate.parse(body);
124 return this.members.update(orgId, userId, parsed, req);
125 }
126
127
128 @Get("policy")
129 async policyGet(@Param("orgId") orgId: string, @Req() req: Request) {
130 return this.policy.get(orgId, req);
131 }
132
133 @Patch("policy")
134 async policyPatch(
135 @Param("orgId") orgId: string,
136 @Body() body: Record<string, unknown>,
137 @Req() req: Request
138 ) {
139 const parsed = OrgPolicyUpdateSchema.parse(body);
140 return this.policy.update(orgId, parsed, req);
141 }
142
143
144 @Get("audit")
145 async auditList(
146 @Param("orgId") orgId: string,
147 @Query() query: Record<string, unknown>,
148 @Req() req: Request
149 ) {
150 return this.audit.list(orgId, query, req);
151 }
152
153 @Get("audit/actions")
154 async auditActions(@Param("orgId") orgId: string, @Req() req: Request) {
155 return this.audit.distinctActions(orgId, req);
156 }
157
158 @Post("audit/export")
159 async auditExport(
160 @Param("orgId") orgId: string,
161 @Body() body: Record<string, unknown>,
162 @Req() req: Request,
163 @Res() res: Response
164 ) {
165
166
167 const result = await this.audit.startExport(orgId, body ?? {}, req);
168 if (result.kind === "sync") {
169 res
170 .status(200)
171 .setHeader("Content-Type", "text/csv; charset=utf-8")
172 .setHeader("Content-Disposition", `attachment; filename="audit-${orgId.slice(0, 8)}.csv"`)
173 .send(result.csv);
174 return;
175 }
176
177 await this.audit.enqueueExportJob(result.exportId, orgId);
178 res.status(202).json({ exportId: result.exportId, cap: result.cap, total: result.total });
179 return;
180 }
181
182 @Get("audit/exports/:exportId")
183 async exportStatus(
184 @Param("orgId") orgId: string,
185 @Param("exportId") exportId: string,
186 @Query() query: Record<string, unknown>,
187 @Req() req: Request
188 ) {
189 return this.audit.exportStatus(orgId, exportId, query.redownload === "1" || query.redownload === true, req);
190 }
191
192
193 @Get("retention")
194 async retentionGet(@Param("orgId") orgId: string, @Req() req: Request) {
195 return this.retention.list(orgId, req);
196 }
197
198 @Patch("retention")
199 async retentionPatch(
200 @Param("orgId") orgId: string,
201 @Body() body: Record<string, unknown>,
202 @Req() req: Request
203 ) {
204 const parsed = RetentionUpdate.parse(body);
205 return this.retention.update(orgId, parsed, req);
206 }
207
208 @Post("retention/run")
209 async retentionRun(
210 @Param("orgId") orgId: string,
211 @Body() body: Record<string, unknown>,
212 @Req() req: Request,
213 @Res() res: Response
214 ) {
215 const parsed = RetentionRun.parse(body);
216 const { runId } = await this.retention.enqueueRun(orgId, parsed.scope, parsed.mode, req);
217 res.status(202).json({ runId, status: "processing" });
218 return;
219 }
220
221 @Get("retention/runs/:runId")
222 async retentionRunStatus(
223 @Param("orgId") orgId: string,
224 @Param("runId") runId: string,
225 @Req() req: Request
226 ) {
227 return this.retention.runStatus(orgId, runId, req);
228 }
229
230
231 @Get("jobs")
232 async jobsOverview(@Param("orgId") orgId: string, @Req() req: Request) {
233 return this.jobs.overview(orgId, req);
234 }
235
236 @Get("jobs/failed")
237 async jobsFailed(
238 @Param("orgId") orgId: string,
239 @Query("queue") queue: string | undefined,
240 @Query("limit") limit: string | undefined,
241 @Req() req: Request
242 ) {
243 return this.jobs.failed(orgId, req, queue, limit ? Number(limit) : 20);
244 }
245
246 @Post("jobs/requeue")
247 @HttpCode(200)
248 async jobsRequeue(@Param("orgId") orgId: string, @Body() body: Record<string, unknown>, @Req() req: Request) {
249 const { queue, jobId } = RequeueJobSchema.parse(body);
250 return this.jobs.requeue(orgId, req, { queue, jobId });
251 }
252
253
254 @Get("search/health")
255 async searchHealth(@Param("orgId") orgId: string, @Req() req: Request) {
256 return this.search.health(orgId, req);
257 }
258
259
260 @Get("notifications/health")
261 async notificationsHealth(@Param("orgId") orgId: string, @Req() req: Request) {
262 return this.notifications.health(orgId, req);
263 }
264
265
266
272 @Post("search/reindex")
273 searchReindex(
274 @Param("orgId") orgId: string,
275 @Query("workspaceId") workspaceId: string | undefined,
276 @Req() req: Request
277 ) {
278 return this.search.enqueueReindex(orgId, req, workspaceId);
279 }
280}
281