/** * TASK-0133 — org admin console API client. * * One client per console tab. All routes are `/orgs/:orgId/admin/*` and are * `assertOrgAdmin`-gated server-side (the API 403s a non-admin; the UI is * never trusted for authorization). The API-keys / webhooks tabs are * WORKSPACE-scoped in the API (`/workspaces/:id/...`), so they take the * workspace id picked from `orgAdminApi.workspaces()`. */ import { apiFetch, mutatingApiFetch, buildQuery, apiFetchRaw, ApiError } from "./api"; /* --------------------------------------------- types */ export interface OrgOverview { memberCount: number; activeMemberCount: number; workspaceCount: number; pageCount: number; databaseCount: number; fileCount: number; storageBytes: number; storageTrend: { day: string; bytes: number }[]; auditTrend: { day: string; count: number }[]; policy: OrgPolicy; } /** Phase 8 — org analytics (read-only, org-scoped, admin-only). */ export interface OrgAnalytics { workspaces: number; archivedWorkspaces: number; members: number; membersByRole: Record; pages: Record; pagesTotal: number; newPages: { last30d: number; last7d: number }; newDatabases30d: number; comments: { open: number; last30d: number }; files: { count: number; storageBytes: number; last30dBytes: number }; audit: { last30d: number; topActions: { action: string; count: number }[] }; trends: { newPages: { day: string; count: number }[]; storage: { day: string; bytes: number }[]; members: { day: string; count: number }[]; audit: { day: string; count: number }[]; }; } export type OrgMemberRole = "OWNER" | "ADMIN" | "MEMBER" | "GUEST"; export interface OrgMemberRow { userId: string; name: string; email: string; role: OrgMemberRole; status: string; // ACTIVE | SUSPENDED | … avatarUrl: string | null; lastLoginAt: string | null; lastActiveAt: string | null; joinedAt: string; } export interface OrgPolicy { require2FA: boolean; ssoEnforced: boolean; disableGuestPublicShare: boolean; maxFileSizeMB: number | null; sessionLifetimeHours: number | null; allowExternalEmbeds: boolean; } export interface OrgPolicyView { policy: OrgPolicy; effective: { maxFileSizeMB: number; billingPlan: string }; } export type RetentionScope = "trash" | "comments" | "audit_logs" | "files"; export type RetentionType = "PURGE" | "HOLD"; export interface RetentionScopeRow { scope: RetentionScope; type: RetentionType; retainDays: number; default: { type: RetentionType; retainDays: number }; lastRun: string | null; lastDryRun: string | null; updatedAt: string | null; } export interface AuditRow { id: string; action: string; actorId: string | null; actorName?: string; resourceType: string; resourceId: string | null; workspaceId: string | null; ipAddress: string | null; userAgent: string | null; metadata: Record | null; createdAt: string; resourceLink: { type: "page" | "database" | "record" } | null; } export interface AuditListResult { data: AuditRow[]; total: number; page: number; pageSize: number; } /** * The CSV export's two response shapes (the API switches on row count, so the * CALLER cannot know which it gets — it inspects `Content-Type`): * sync — `200` + `text/csv` (≤10k rows), the whole CSV in the body * async — `202` + JSON `{exportId, cap, total}` (a worker job streams the * >10k rows to S3; poll `auditExportStatus` for the single-use URL) */ export type AuditExportStart = | { kind: "sync"; total: number; csv: string } | { kind: "async"; total: number; exportId: string; cap: number }; export interface AuditExportStatus { status: "processing" | "ready" | "failed"; exportId: string; totalRows: number; rows: number; truncated: boolean; error?: string | null; downloadUrl?: string; urlTtlSec?: number; } export interface OrgWorkspaceRow { id: string; name: string; slug: string; } export interface ApiKeyRow { id: string; name: string; prefix: string; scopes: string[]; rateLimit: number | null; createdAt: string; expiresAt: string | null; lastUsedAt: string | null; revokedAt: string | null; } export interface WebhookRow { id: string; url: string; events: string[]; active: boolean; createdAt: string; updatedAt: string; } /* ---- health tabs (T-176 jobs / T-177 search / T-178 notifications) ---- */ /** The seven worker queues (mirrors the API's `AdminQueueName` enum). */ export type AdminQueueName = | "files" | "search-index" | "webhooks" | "automations" | "email" | "ai" | "maintenance"; export interface OrgJobsQueueCount { name: AdminQueueName; waiting: number; active: number; completed: number; failed: number; } export interface OrgJobsOverview { scope: "cluster"; queues: OrgJobsQueueCount[]; } export interface OrgFailedJob { queue: AdminQueueName; jobId: string | null; name: string; payload: string | null; failedReason: string | null; failedAt: string | null; attemptsMade: number; } export interface OrgJobsFailed { scope: "cluster"; jobs: OrgFailedJob[]; } export interface OrgRequeueResult { queued: true; queue: AdminQueueName; jobId: string; } export interface OrgSearchHealth { healthy: boolean; error: string | null; workspaces: number; sourceCount: number; docCount: number | null; drift: number | null; lagMs: number | null; lastIndexedAt: string | null; lastReconcileAt: string | null; } export interface OrgNotificationFailedJob { notificationId: string; jobId: string; name: string; failedReason: string | null; failedAt: string | null; attemptsMade: number; } export interface OrgNotificationsHealth { workspaces: number; inAppTotal: number; inAppUnread: number; emailQueued: number; emailFailed: number; failedJobs: OrgNotificationFailedJob[]; } /* --------------------------------------------- client */ export const orgAdminApi = { /** Overview counts + 30-day storage/audit trend. */ overview(orgId: string): Promise { return apiFetch(`/orgs/${orgId}/admin/overview`); }, /** Phase 8 — org analytics (counts + 30-day trends, read-only). */ analytics(orgId: string): Promise<{ data: OrgAnalytics }> { return apiFetch<{ data: OrgAnalytics }>(`/orgs/${orgId}/admin/analytics`); }, /** The org's workspaces (for the API-keys / webhooks tab picker). */ workspaces(orgId: string): Promise<{ data: OrgWorkspaceRow[] }> { return apiFetch<{ data: OrgWorkspaceRow[] }>(`/orgs/${orgId}/admin/workspaces`); }, /* ---- members */ members(orgId: string, q?: string): Promise<{ data: OrgMemberRow[] }> { return apiFetch<{ data: OrgMemberRow[] }>(`/orgs/${orgId}/admin/members${buildQuery({ q })}`); }, memberUpdate(orgId: string, userId: string, body: { role?: OrgMemberRole; suspend?: boolean }): Promise { return mutatingApiFetch(`/orgs/${orgId}/admin/members/${userId}`, { method: "PATCH", body }); }, /* ---- policy */ policy(orgId: string): Promise { return apiFetch(`/orgs/${orgId}/admin/policy`); }, policyPatch(orgId: string, body: Partial): Promise { return mutatingApiFetch(`/orgs/${orgId}/admin/policy`, { method: "PATCH", body }); }, /* ---- audit viewer */ audit(orgId: string, filters: Record): Promise { return apiFetch(`/orgs/${orgId}/admin/audit${buildQuery(filters)}`); }, auditActions(orgId: string): Promise<{ data: string[] }> { return apiFetch<{ data: string[] }>(`/orgs/${orgId}/admin/audit/actions`); }, /** * Start a CSV export. The API switches on the matching row count, so the * shape is only known from the response: `Content-Type: text/csv` → the * whole CSV is inline (`kind:"sync"`); JSON `{exportId, cap, total}` → * `kind:"async"` (a worker job streams the rows; poll `auditExportStatus`). */ async auditExport(orgId: string, filters: Record = {}): Promise { const res = await apiFetchRaw(`/orgs/${orgId}/admin/audit/export`, { method: "POST", body: filters }); const isCsv = (res.headers.get("content-type") ?? "").includes("text/csv"); if (isCsv) { if (!res.ok) throw new ApiError("EXPORT_FAILED", res.status, `CSV export failed (${res.status})`); const csv = await res.text(); // The header is the first line; the data rows are the rest. const total = Math.max(0, csv.split("\n").length - (csv.endsWith("\n") ? 2 : 1)); return { kind: "sync", total, csv }; } if (!res.ok) { const err = (await res.json().catch(() => null)) as { error?: { code?: string; message?: string; details?: unknown; requestId?: string }; } | null; throw new ApiError( err?.error?.code ?? "EXPORT_FAILED", res.status, err?.error?.message ?? `Export failed (${res.status})`, err?.error?.details, err?.error?.requestId ); } const body = (await res.json()) as { exportId: string; cap: number; total: number }; return { kind: "async", total: body.total, exportId: body.exportId, cap: body.cap }; }, auditExportStatus(orgId: string, exportId: string, redownload = false): Promise { return apiFetch(`/orgs/${orgId}/admin/audit/exports/${exportId}${buildQuery({ redownload: redownload ? 1 : null })}`); }, /* ---- retention */ retention(orgId: string): Promise<{ data: RetentionScopeRow[] }> { return apiFetch<{ data: RetentionScopeRow[] }>(`/orgs/${orgId}/admin/retention`); }, retentionPatch(orgId: string, body: Partial>): Promise<{ ok: boolean; changes: unknown }> { return mutatingApiFetch<{ ok: boolean; changes: unknown }>(`/orgs/${orgId}/admin/retention`, { method: "PATCH", body }); }, /** Enqueue a run (dry or execute) → `202 {runId, status}`. */ retentionRun(orgId: string, scope: RetentionScope, mode: "dry" | "execute"): Promise<{ runId: string; status: string }> { return mutatingApiFetch<{ runId: string; status: string }>(`/orgs/${orgId}/admin/retention/run`, { method: "POST", body: { scope, mode } }); }, /** Poll a run's report (worker writes it to Redis, TTL 7d). */ retentionRunStatus(orgId: string, runId: string): Promise> { return apiFetch>(`/orgs/${orgId}/admin/retention/runs/${runId}`); }, /* ---- health tabs (T-176 / T-177 / T-178) ---- */ /** Per-queue BullMQ state counts (cluster scope — see the API note). */ jobsOverview(orgId: string): Promise { return apiFetch(`/orgs/${orgId}/admin/jobs`); }, /** Recent failed jobs (the DLQ view); optional queue filter. */ jobsFailed( orgId: string, opts?: { queue?: AdminQueueName; limit?: number } ): Promise { return apiFetch( `/orgs/${orgId}/admin/jobs/failed${buildQuery({ queue: opts?.queue, limit: opts?.limit })}` ); }, /** Re-run ONE failed job (audited) → `200 {queued, queue, jobId}`. */ jobsRequeue(orgId: string, body: { queue: AdminQueueName; jobId: string }): Promise { return mutatingApiFetch(`/orgs/${orgId}/admin/jobs/requeue`, { method: "POST", body }); }, /** Search index health: source vs doc count, drift, lag, last reconcile. */ searchHealth(orgId: string): Promise { return apiFetch(`/orgs/${orgId}/admin/search/health`); }, /** Enqueue an org-scoped full reindex (T-156) → `202 {jobId, status}`. */ searchReindex(orgId: string, workspaceId?: string): Promise<{ jobId: string; status: string; workspaceId: string | null }> { return mutatingApiFetch<{ jobId: string; status: string; workspaceId: string | null }>( `/orgs/${orgId}/admin/search/reindex${buildQuery({ workspaceId })}`, { method: "POST" } ); }, /** Notification delivery health: in-app volume + email send/fail. */ notificationsHealth(orgId: string): Promise { return apiFetch(`/orgs/${orgId}/admin/notifications/health`); }, }; /* -------- workspace-scoped tabs (API keys / webhooks) */ export const orgWorkspaceApi = { listApiKeys(wsId: string): Promise<{ data: ApiKeyRow[] }> { return apiFetch<{ data: ApiKeyRow[] }>(`/workspaces/${wsId}/api-keys`); }, createApiKey(wsId: string, body: { name: string; scopes: string[]; rateLimit?: number; expiresAt?: string }): Promise<{ key: string; id: string }> { return mutatingApiFetch<{ key: string; id: string }>(`/workspaces/${wsId}/api-keys`, { method: "POST", body }); }, deleteApiKey(wsId: string, id: string): Promise { return mutatingApiFetch(`/workspaces/${wsId}/api-keys/${id}`, { method: "DELETE" }); }, listWebhooks(wsId: string): Promise<{ data: WebhookRow[] }> { return apiFetch<{ data: WebhookRow[] }>(`/workspaces/${wsId}/webhooks`); }, createWebhook(wsId: string, body: { url: string; events?: string[] }): Promise { return mutatingApiFetch(`/workspaces/${wsId}/webhooks`, { method: "POST", body }); }, deleteWebhook(wsId: string, id: string): Promise { return mutatingApiFetch(`/workspaces/${wsId}/webhooks/${id}`, { method: "DELETE" }); }, };