/** * Admin orgs UI client — users + companies directory, staff roles, plan assign, * clone-catalog, and Sync A1 (`POST …/sync-a1`; fix-catalog is a compat alias). * Flash Sync A1: result.prompts / hashes / categories / dump_* → flash.admin.syncA1Success. * Contract: docs/admin-roles-support/04-contract.md · Docs: 10-admin-orgs-ui.md */ import { api, ApiError } from "$lib/api"; import { assignAdminPlan, classifyAdminPlanVisibility, adminPlanVisibilityBadgeVariant, adminPlanVisibilityLabel, planOptionLabel, type AdminBillingPlan, type AdminPlanVisibility, ADMIN_PLANS_PATH } from "$lib/admin-billing-plans"; export const ADMIN_USERS_PATH = "/api/admin/users"; export const ADMIN_COMPANIES_PATH = "/api/admin/companies"; export const ADMIN_CLONE_CATALOG_PATH = (companyId: string) => `${ADMIN_COMPANIES_PATH}/${encodeURIComponent(companyId)}/clone-catalog`; export const ADMIN_FIX_CATALOG_PATH = (companyId: string) => `${ADMIN_COMPANIES_PATH}/${encodeURIComponent(companyId)}/fix-catalog`; export const ADMIN_SYNC_A1_PATH = (companyId: string) => `${ADMIN_COMPANIES_PATH}/${encodeURIComponent(companyId)}/sync-a1`; export const ADMIN_STAFF_ROLE_PATH = (userId: string) => `/api/admin/users/${encodeURIComponent(userId)}/staff-role`; /** Matches API `platformDemoCompanyName` (catalog/prompt_repair.go). */ export const PLATFORM_DEMO_COMPANY_NAME = "Platform Demo"; export const PAGE_SIZE = 25; export type PlatformStaffRole = "admin" | "developer" | "support_staff"; export const STAFF_ROLE_OPTIONS: { value: "" | PlatformStaffRole; label: string }[] = [ { value: "", label: "No staff role" }, { value: "admin", label: "Admin" }, { value: "developer", label: "Developer" }, { value: "support_staff", label: "Support staff" } ]; export type AdminOrgUser = { id: string; email: string; name?: string | null; must_set_password?: boolean; is_platform_admin?: boolean; staff_role?: string | null; resolved_role?: string; is_active?: boolean; created_at?: string; }; export type AdminOrgCompany = { id: string; name: string; language?: string; created_at?: string; total_credits?: number; used_credits?: number; has_active_plan?: boolean; plan_id?: number | null; plan_name?: string | null; plan_is_custom?: boolean; plan_is_legacy?: boolean; /** False when the company has no non-revoked api_keys (cutover reissue gap). */ has_api_key?: boolean; }; export type PaginatedUsers = { users: AdminOrgUser[]; total: number; limit: number; offset: number; }; export type PaginatedCompanies = { companies: AdminOrgCompany[]; total: number; limit: number; offset: number; without_active_plan?: boolean; without_api_keys?: boolean; }; export function staffRoleLabel(role: string | null | undefined): string { switch ((role ?? "").trim()) { case "admin": return "Admin"; case "developer": return "Developer"; case "support_staff": return "Support staff"; default: return "User"; } } export function staffRoleBadgeVariant( role: string | null | undefined ): "default" | "secondary" | "outline" | "warning" { switch ((role ?? "").trim()) { case "admin": return "default"; case "developer": return "secondary"; case "support_staff": return "warning"; default: return "outline"; } } export function companyPlanVisibility( company: AdminOrgCompany ): AdminPlanVisibility | "none" { if (!company.has_active_plan || !company.plan_name) return "none"; if (company.plan_is_legacy) return "legacy"; return classifyAdminPlanVisibility({ name: company.plan_name, is_custom: Boolean(company.plan_is_custom) }); } export function companyPlanBadge(company: AdminOrgCompany): { label: string; variant: "outline" | "warning" | "secondary" | "default"; } { if (!company.has_active_plan || !company.plan_name) { return { label: "No plan", variant: "warning" }; } if (company.plan_is_legacy) { return { label: `Legacy · ${company.plan_name}`, variant: "warning" }; } const kind = classifyAdminPlanVisibility({ name: company.plan_name, is_custom: Boolean(company.plan_is_custom) }); return { label: `${adminPlanVisibilityLabel(kind)} · ${company.plan_name}`, variant: adminPlanVisibilityBadgeVariant(kind) }; } export async function listAdminUsers(opts: { limit?: number; offset?: number; q?: string; staff_only?: boolean; active_only?: boolean; inactive_only?: boolean; }): Promise { const params = new URLSearchParams(); params.set("limit", String(opts.limit ?? PAGE_SIZE)); params.set("offset", String(opts.offset ?? 0)); if (opts.q?.trim()) params.set("q", opts.q.trim()); if (opts.staff_only) params.set("staff_only", "1"); if (opts.active_only) params.set("active_only", "1"); if (opts.inactive_only) params.set("inactive_only", "1"); const res = await api(`${ADMIN_USERS_PATH}?${params}`); return { users: res.users ?? [], total: Number(res.total ?? res.users?.length ?? 0), limit: Number(res.limit ?? opts.limit ?? PAGE_SIZE), offset: Number(res.offset ?? opts.offset ?? 0) }; } export async function listAdminCompanies(opts: { limit?: number; offset?: number; q?: string; without_active_plan?: boolean; without_api_keys?: boolean; }): Promise { const params = new URLSearchParams(); params.set("limit", String(opts.limit ?? PAGE_SIZE)); params.set("offset", String(opts.offset ?? 0)); if (opts.q?.trim()) params.set("q", opts.q.trim()); if (opts.without_active_plan) params.set("without_active_plan", "1"); if (opts.without_api_keys) params.set("without_api_keys", "1"); const res = await api(`${ADMIN_COMPANIES_PATH}?${params}`); return { companies: res.companies ?? [], total: Number(res.total ?? res.companies?.length ?? 0), limit: Number(res.limit ?? opts.limit ?? PAGE_SIZE), offset: Number(res.offset ?? opts.offset ?? 0), without_active_plan: Boolean(res.without_active_plan), without_api_keys: Boolean(res.without_api_keys) }; } export async function listAdminPlansForAssign(): Promise { const res = await api<{ plans: AdminBillingPlan[] }>(ADMIN_PLANS_PATH); return res.plans ?? []; } export async function setAdminStaffRole( userId: string, staffRole: "" | PlatformStaffRole ): Promise { const body = staffRole === "" ? { staff_role: null } : { staff_role: staffRole }; const res = await api<{ user: AdminOrgUser }>(ADMIN_STAFF_ROLE_PATH(userId), { method: "PATCH", body }); return res.user; } export type CloneCatalogResult = { status: string; source_company_id: string; dest_company_id: string; active_company_id?: string; counts: Record; }; /** Copy source company catalog into dest (explicit id preferred). Source is unchanged. */ export async function cloneAdminCompanyCatalog( sourceCompanyId: string, opts?: { destCompanyId?: string } ): Promise { const body: { confirm: true; dest_company_id?: string } = { confirm: true }; if (opts?.destCompanyId?.trim()) { body.dest_company_id = opts.destCompanyId.trim(); } return api(ADMIN_CLONE_CATALOG_PATH(sourceCompanyId), { method: "POST", body }); } export type SyncA1Result = { company_id: string; company_name: string; a1_cohort?: boolean; category_attribute_orphans_removed?: number; category_attribute_links?: number; /** Alias of category_prompts_updated for flash.admin.syncA1Success {prompts}. */ prompts?: number; category_prompts_updated?: number; product_enhance_languages?: number; /** Alias of weak_hashes_cleared for flash {hashes}. */ hashes?: number; weak_hashes_cleared?: number; /** Alias of categories_backfilled for flash {categories}. */ categories?: number; categories_backfilled?: number; /** Alias of mapped_categories_backfilled for flash {mapped_backfilled}. */ mapped_backfilled?: number; mapped_categories_backfilled?: number; taxonomy_categories?: number; mapped_with_category?: number; mapped_without_category?: number; processed_with_category?: number; processed_without_category?: number; attributes_sanitized?: number; products_scanned?: number; reprocess_needed_count?: number; reprocess_sample_raw_product_ids?: string[]; dump_found?: boolean; dump_path?: string; dump_skipped_reason?: string; dump_pairs?: number; dump_mapped_updated?: number; dump_processed_updated?: number; dump_status?: string; }; export type SyncA1Response = { status: string; result: SyncA1Result; note?: string; }; /** @deprecated Use SyncA1Result — kept for type aliases during UI rename. */ export type FixCatalogResult = SyncA1Result; /** @deprecated Use SyncA1Response */ export type FixCatalogResponse = SyncA1Response; /** Sync A1: dump category backfill (when dump on API host) + Fix hygiene. No mass reprocess. */ export async function syncAdminCompanyA1( companyId: string, opts?: { backfillCategories?: boolean; reprocessSampleLimit?: number; skipDumpBackfill?: boolean } ): Promise { const body: { confirm: true; backfill_categories?: boolean; reprocess_sample_limit?: number; skip_dump_backfill?: boolean; } = { confirm: true }; if (opts?.backfillCategories === false) { body.backfill_categories = false; } if (typeof opts?.reprocessSampleLimit === "number") { body.reprocess_sample_limit = opts.reprocessSampleLimit; } if (opts?.skipDumpBackfill) { body.skip_dump_backfill = true; } return api(ADMIN_SYNC_A1_PATH(companyId), { method: "POST", body }); } /** Compat alias — same as syncAdminCompanyA1 (fix-catalog route). */ export async function fixAdminCompanyCatalog( companyId: string, opts?: { backfillCategories?: boolean; reprocessSampleLimit?: number } ): Promise { return syncAdminCompanyA1(companyId, opts); } export function isStaffRoleApiUnavailable(err: unknown): boolean { return err instanceof ApiError && (err.status === 404 || err.status === 501); } export { assignAdminPlan, planOptionLabel }; export type { AdminBillingPlan };