1
10import { apiFetch, mutatingApiFetch, buildQuery, apiFetchRaw, ApiError } from "./api";
11
12
13
14export interface OrgOverview {
15 memberCount: number;
16 activeMemberCount: number;
17 workspaceCount: number;
18 pageCount: number;
19 databaseCount: number;
20 fileCount: number;
21 storageBytes: number;
22 storageTrend: { day: string; bytes: number }[];
23 auditTrend: { day: string; count: number }[];
24 policy: OrgPolicy;
25}
26
27
28export interface OrgAnalytics {
29 workspaces: number;
30 archivedWorkspaces: number;
31 members: number;
32 membersByRole: Record<string, number>;
33 pages: Record<string, number>;
34 pagesTotal: number;
35 newPages: { last30d: number; last7d: number };
36 newDatabases30d: number;
37 comments: { open: number; last30d: number };
38 files: { count: number; storageBytes: number; last30dBytes: number };
39 audit: { last30d: number; topActions: { action: string; count: number }[] };
40 trends: {
41 newPages: { day: string; count: number }[];
42 storage: { day: string; bytes: number }[];
43 members: { day: string; count: number }[];
44 audit: { day: string; count: number }[];
45 };
46}
47
48export type OrgMemberRole = "OWNER" | "ADMIN" | "MEMBER" | "GUEST";
49
50export interface OrgMemberRow {
51 userId: string;
52 name: string;
53 email: string;
54 role: OrgMemberRole;
55 status: string;
56 avatarUrl: string | null;
57 lastLoginAt: string | null;
58 lastActiveAt: string | null;
59 joinedAt: string;
60}
61
62export interface OrgPolicy {
63 require2FA: boolean;
64 ssoEnforced: boolean;
65 disableGuestPublicShare: boolean;
66 maxFileSizeMB: number | null;
67 sessionLifetimeHours: number | null;
68 allowExternalEmbeds: boolean;
69}
70
71export interface OrgPolicyView {
72 policy: OrgPolicy;
73 effective: { maxFileSizeMB: number; billingPlan: string };
74}
75
76export type RetentionScope = "trash" | "comments" | "audit_logs" | "files";
77export type RetentionType = "PURGE" | "HOLD";
78
79export interface RetentionScopeRow {
80 scope: RetentionScope;
81 type: RetentionType;
82 retainDays: number;
83 default: { type: RetentionType; retainDays: number };
84 lastRun: string | null;
85 lastDryRun: string | null;
86 updatedAt: string | null;
87}
88
89export interface AuditRow {
90 id: string;
91 action: string;
92 actorId: string | null;
93 actorName?: string;
94 resourceType: string;
95 resourceId: string | null;
96 workspaceId: string | null;
97 ipAddress: string | null;
98 userAgent: string | null;
99 metadata: Record<string, unknown> | null;
100 createdAt: string;
101 resourceLink: { type: "page" | "database" | "record" } | null;
102}
103
104export interface AuditListResult {
105 data: AuditRow[];
106 total: number;
107 page: number;
108 pageSize: number;
109}
110
111
118export type AuditExportStart =
119 | { kind: "sync"; total: number; csv: string }
120 | { kind: "async"; total: number; exportId: string; cap: number };
121
122export interface AuditExportStatus {
123 status: "processing" | "ready" | "failed";
124 exportId: string;
125 totalRows: number;
126 rows: number;
127 truncated: boolean;
128 error?: string | null;
129 downloadUrl?: string;
130 urlTtlSec?: number;
131}
132
133export interface OrgWorkspaceRow {
134 id: string;
135 name: string;
136 slug: string;
137}
138
139export interface ApiKeyRow {
140 id: string;
141 name: string;
142 prefix: string;
143 scopes: string[];
144 rateLimit: number | null;
145 createdAt: string;
146 expiresAt: string | null;
147 lastUsedAt: string | null;
148 revokedAt: string | null;
149}
150
151export interface WebhookRow {
152 id: string;
153 url: string;
154 events: string[];
155 active: boolean;
156 createdAt: string;
157 updatedAt: string;
158}
159
160
161
162
163export type AdminQueueName =
164 | "files"
165 | "search-index"
166 | "webhooks"
167 | "automations"
168 | "email"
169 | "ai"
170 | "maintenance";
171
172export interface OrgJobsQueueCount {
173 name: AdminQueueName;
174 waiting: number;
175 active: number;
176 completed: number;
177 failed: number;
178}
179
180export interface OrgJobsOverview {
181 scope: "cluster";
182 queues: OrgJobsQueueCount[];
183}
184
185export interface OrgFailedJob {
186 queue: AdminQueueName;
187 jobId: string | null;
188 name: string;
189 payload: string | null;
190 failedReason: string | null;
191 failedAt: string | null;
192 attemptsMade: number;
193}
194
195export interface OrgJobsFailed {
196 scope: "cluster";
197 jobs: OrgFailedJob[];
198}
199
200export interface OrgRequeueResult {
201 queued: true;
202 queue: AdminQueueName;
203 jobId: string;
204}
205
206export interface OrgSearchHealth {
207 healthy: boolean;
208 error: string | null;
209 workspaces: number;
210 sourceCount: number;
211 docCount: number | null;
212 drift: number | null;
213 lagMs: number | null;
214 lastIndexedAt: string | null;
215 lastReconcileAt: string | null;
216}
217
218export interface OrgNotificationFailedJob {
219 notificationId: string;
220 jobId: string;
221 name: string;
222 failedReason: string | null;
223 failedAt: string | null;
224 attemptsMade: number;
225}
226
227export interface OrgNotificationsHealth {
228 workspaces: number;
229 inAppTotal: number;
230 inAppUnread: number;
231 emailQueued: number;
232 emailFailed: number;
233 failedJobs: OrgNotificationFailedJob[];
234}
235
236
237
238export const orgAdminApi = {
239
240 overview(orgId: string): Promise<OrgOverview> {
241 return apiFetch<OrgOverview>(`/orgs/${orgId}/admin/overview`);
242 },
243
244
245 analytics(orgId: string): Promise<{ data: OrgAnalytics }> {
246 return apiFetch<{ data: OrgAnalytics }>(`/orgs/${orgId}/admin/analytics`);
247 },
248
249
250 workspaces(orgId: string): Promise<{ data: OrgWorkspaceRow[] }> {
251 return apiFetch<{ data: OrgWorkspaceRow[] }>(`/orgs/${orgId}/admin/workspaces`);
252 },
253
254
255 members(orgId: string, q?: string): Promise<{ data: OrgMemberRow[] }> {
256 return apiFetch<{ data: OrgMemberRow[] }>(`/orgs/${orgId}/admin/members${buildQuery({ q })}`);
257 },
258 memberUpdate(orgId: string, userId: string, body: { role?: OrgMemberRole; suspend?: boolean }): Promise<unknown> {
259 return mutatingApiFetch(`/orgs/${orgId}/admin/members/${userId}`, { method: "PATCH", body });
260 },
261
262
263 policy(orgId: string): Promise<OrgPolicyView> {
264 return apiFetch<OrgPolicyView>(`/orgs/${orgId}/admin/policy`);
265 },
266 policyPatch(orgId: string, body: Partial<OrgPolicy>): Promise<OrgPolicyView> {
267 return mutatingApiFetch<OrgPolicyView>(`/orgs/${orgId}/admin/policy`, { method: "PATCH", body });
268 },
269
270
271 audit(orgId: string, filters: Record<string, string | number | null | undefined>): Promise<AuditListResult> {
272 return apiFetch<AuditListResult>(`/orgs/${orgId}/admin/audit${buildQuery(filters)}`);
273 },
274 auditActions(orgId: string): Promise<{ data: string[] }> {
275 return apiFetch<{ data: string[] }>(`/orgs/${orgId}/admin/audit/actions`);
276 },
277
283 async auditExport(orgId: string, filters: Record<string, unknown> = {}): Promise<AuditExportStart> {
284 const res = await apiFetchRaw(`/orgs/${orgId}/admin/audit/export`, { method: "POST", body: filters });
285 const isCsv = (res.headers.get("content-type") ?? "").includes("text/csv");
286 if (isCsv) {
287 if (!res.ok) throw new ApiError("EXPORT_FAILED", res.status, `CSV export failed (${res.status})`);
288 const csv = await res.text();
289
290 const total = Math.max(0, csv.split("\n").length - (csv.endsWith("\n") ? 2 : 1));
291 return { kind: "sync", total, csv };
292 }
293 if (!res.ok) {
294 const err = (await res.json().catch(() => null)) as {
295 error?: { code?: string; message?: string; details?: unknown; requestId?: string };
296 } | null;
297 throw new ApiError(
298 err?.error?.code ?? "EXPORT_FAILED",
299 res.status,
300 err?.error?.message ?? `Export failed (${res.status})`,
301 err?.error?.details,
302 err?.error?.requestId
303 );
304 }
305 const body = (await res.json()) as { exportId: string; cap: number; total: number };
306 return { kind: "async", total: body.total, exportId: body.exportId, cap: body.cap };
307 },
308 auditExportStatus(orgId: string, exportId: string, redownload = false): Promise<AuditExportStatus> {
309 return apiFetch<AuditExportStatus>(`/orgs/${orgId}/admin/audit/exports/${exportId}${buildQuery({ redownload: redownload ? 1 : null })}`);
310 },
311
312
313 retention(orgId: string): Promise<{ data: RetentionScopeRow[] }> {
314 return apiFetch<{ data: RetentionScopeRow[] }>(`/orgs/${orgId}/admin/retention`);
315 },
316 retentionPatch(orgId: string, body: Partial<Record<RetentionScope, { type: RetentionType; retainDays: number }>>): Promise<{ ok: boolean; changes: unknown }> {
317 return mutatingApiFetch<{ ok: boolean; changes: unknown }>(`/orgs/${orgId}/admin/retention`, { method: "PATCH", body });
318 },
319
320 retentionRun(orgId: string, scope: RetentionScope, mode: "dry" | "execute"): Promise<{ runId: string; status: string }> {
321 return mutatingApiFetch<{ runId: string; status: string }>(`/orgs/${orgId}/admin/retention/run`, { method: "POST", body: { scope, mode } });
322 },
323
324 retentionRunStatus(orgId: string, runId: string): Promise<Record<string, unknown>> {
325 return apiFetch<Record<string, unknown>>(`/orgs/${orgId}/admin/retention/runs/${runId}`);
326 },
327
328
329
330 jobsOverview(orgId: string): Promise<OrgJobsOverview> {
331 return apiFetch<OrgJobsOverview>(`/orgs/${orgId}/admin/jobs`);
332 },
333
334 jobsFailed(
335 orgId: string,
336 opts?: { queue?: AdminQueueName; limit?: number }
337 ): Promise<OrgJobsFailed> {
338 return apiFetch<OrgJobsFailed>(
339 `/orgs/${orgId}/admin/jobs/failed${buildQuery({ queue: opts?.queue, limit: opts?.limit })}`
340 );
341 },
342
343 jobsRequeue(orgId: string, body: { queue: AdminQueueName; jobId: string }): Promise<OrgRequeueResult> {
344 return mutatingApiFetch<OrgRequeueResult>(`/orgs/${orgId}/admin/jobs/requeue`, { method: "POST", body });
345 },
346
347 searchHealth(orgId: string): Promise<OrgSearchHealth> {
348 return apiFetch<OrgSearchHealth>(`/orgs/${orgId}/admin/search/health`);
349 },
350
351 searchReindex(orgId: string, workspaceId?: string): Promise<{ jobId: string; status: string; workspaceId: string | null }> {
352 return mutatingApiFetch<{ jobId: string; status: string; workspaceId: string | null }>(
353 `/orgs/${orgId}/admin/search/reindex${buildQuery({ workspaceId })}`,
354 { method: "POST" }
355 );
356 },
357
358 notificationsHealth(orgId: string): Promise<OrgNotificationsHealth> {
359 return apiFetch<OrgNotificationsHealth>(`/orgs/${orgId}/admin/notifications/health`);
360 },
361};
362
363
364
365export const orgWorkspaceApi = {
366 listApiKeys(wsId: string): Promise<{ data: ApiKeyRow[] }> {
367 return apiFetch<{ data: ApiKeyRow[] }>(`/workspaces/${wsId}/api-keys`);
368 },
369 createApiKey(wsId: string, body: { name: string; scopes: string[]; rateLimit?: number; expiresAt?: string }): Promise<{ key: string; id: string }> {
370 return mutatingApiFetch<{ key: string; id: string }>(`/workspaces/${wsId}/api-keys`, { method: "POST", body });
371 },
372 deleteApiKey(wsId: string, id: string): Promise<void> {
373 return mutatingApiFetch<void>(`/workspaces/${wsId}/api-keys/${id}`, { method: "DELETE" });
374 },
375
376 listWebhooks(wsId: string): Promise<{ data: WebhookRow[] }> {
377 return apiFetch<{ data: WebhookRow[] }>(`/workspaces/${wsId}/webhooks`);
378 },
379 createWebhook(wsId: string, body: { url: string; events?: string[] }): Promise<unknown> {
380 return mutatingApiFetch(`/workspaces/${wsId}/webhooks`, { method: "POST", body });
381 },
382 deleteWebhook(wsId: string, id: string): Promise<void> {
383 return mutatingApiFetch<void>(`/workspaces/${wsId}/webhooks/${id}`, { method: "DELETE" });
384 },
385};
386