2026-08-09 22:47:43 +02:00
|
|
|
/**
|
2026-08-16 16:57:36 +02:00
|
|
|
* Admin orgs UI client — users + companies directory, staff roles, plan assign,
|
2026-08-16 18:36:56 +02:00
|
|
|
* 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.
|
2026-08-09 22:47:43 +02:00
|
|
|
* 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";
|
2026-08-16 11:37:42 +02:00
|
|
|
export const ADMIN_CLONE_CATALOG_PATH = (companyId: string) =>
|
|
|
|
|
`${ADMIN_COMPANIES_PATH}/${encodeURIComponent(companyId)}/clone-catalog`;
|
2026-08-16 16:57:36 +02:00
|
|
|
export const ADMIN_FIX_CATALOG_PATH = (companyId: string) =>
|
|
|
|
|
`${ADMIN_COMPANIES_PATH}/${encodeURIComponent(companyId)}/fix-catalog`;
|
2026-08-16 18:36:56 +02:00
|
|
|
export const ADMIN_SYNC_A1_PATH = (companyId: string) =>
|
|
|
|
|
`${ADMIN_COMPANIES_PATH}/${encodeURIComponent(companyId)}/sync-a1`;
|
2026-08-09 22:47:43 +02:00
|
|
|
export const ADMIN_STAFF_ROLE_PATH = (userId: string) =>
|
|
|
|
|
`/api/admin/users/${encodeURIComponent(userId)}/staff-role`;
|
|
|
|
|
|
2026-08-16 18:49:02 +02:00
|
|
|
/** Matches API `platformDemoCompanyName` (catalog/prompt_repair.go). */
|
|
|
|
|
export const PLATFORM_DEMO_COMPANY_NAME = "Platform Demo";
|
|
|
|
|
|
2026-08-09 22:47:43 +02:00
|
|
|
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<PaginatedUsers> {
|
|
|
|
|
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<PaginatedUsers>(`${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<PaginatedCompanies> {
|
|
|
|
|
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<PaginatedCompanies>(`${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<AdminBillingPlan[]> {
|
|
|
|
|
const res = await api<{ plans: AdminBillingPlan[] }>(ADMIN_PLANS_PATH);
|
|
|
|
|
return res.plans ?? [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function setAdminStaffRole(
|
|
|
|
|
userId: string,
|
|
|
|
|
staffRole: "" | PlatformStaffRole
|
|
|
|
|
): Promise<AdminOrgUser> {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 11:37:42 +02:00
|
|
|
export type CloneCatalogResult = {
|
|
|
|
|
status: string;
|
|
|
|
|
source_company_id: string;
|
|
|
|
|
dest_company_id: string;
|
2026-08-16 12:00:28 +02:00
|
|
|
active_company_id?: string;
|
2026-08-16 11:37:42 +02:00
|
|
|
counts: Record<string, number>;
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-16 18:49:02 +02:00
|
|
|
/** Copy source company catalog into dest (explicit id preferred). Source is unchanged. */
|
2026-08-16 11:37:42 +02:00
|
|
|
export async function cloneAdminCompanyCatalog(
|
|
|
|
|
sourceCompanyId: string,
|
|
|
|
|
opts?: { destCompanyId?: string }
|
|
|
|
|
): Promise<CloneCatalogResult> {
|
|
|
|
|
const body: { confirm: true; dest_company_id?: string } = { confirm: true };
|
|
|
|
|
if (opts?.destCompanyId?.trim()) {
|
|
|
|
|
body.dest_company_id = opts.destCompanyId.trim();
|
|
|
|
|
}
|
|
|
|
|
return api<CloneCatalogResult>(ADMIN_CLONE_CATALOG_PATH(sourceCompanyId), {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 18:36:56 +02:00
|
|
|
export type SyncA1Result = {
|
2026-08-16 16:57:36 +02:00
|
|
|
company_id: string;
|
|
|
|
|
company_name: string;
|
|
|
|
|
a1_cohort?: boolean;
|
|
|
|
|
category_attribute_orphans_removed?: number;
|
|
|
|
|
category_attribute_links?: number;
|
2026-08-16 18:36:56 +02:00
|
|
|
/** Alias of category_prompts_updated for flash.admin.syncA1Success {prompts}. */
|
2026-08-16 16:57:36 +02:00
|
|
|
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;
|
2026-08-16 18:18:39 +02:00
|
|
|
/** 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;
|
2026-08-16 16:57:36 +02:00
|
|
|
attributes_sanitized?: number;
|
|
|
|
|
products_scanned?: number;
|
|
|
|
|
reprocess_needed_count?: number;
|
|
|
|
|
reprocess_sample_raw_product_ids?: string[];
|
2026-08-16 18:36:56 +02:00
|
|
|
dump_found?: boolean;
|
|
|
|
|
dump_path?: string;
|
|
|
|
|
dump_skipped_reason?: string;
|
|
|
|
|
dump_pairs?: number;
|
|
|
|
|
dump_mapped_updated?: number;
|
|
|
|
|
dump_processed_updated?: number;
|
|
|
|
|
dump_status?: string;
|
2026-08-16 16:57:36 +02:00
|
|
|
};
|
|
|
|
|
|
2026-08-16 18:36:56 +02:00
|
|
|
export type SyncA1Response = {
|
2026-08-16 16:57:36 +02:00
|
|
|
status: string;
|
2026-08-16 18:36:56 +02:00
|
|
|
result: SyncA1Result;
|
2026-08-16 16:57:36 +02:00
|
|
|
note?: string;
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-16 18:36:56 +02:00
|
|
|
/** @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(
|
2026-08-16 16:57:36 +02:00
|
|
|
companyId: string,
|
2026-08-16 18:36:56 +02:00
|
|
|
opts?: { backfillCategories?: boolean; reprocessSampleLimit?: number; skipDumpBackfill?: boolean }
|
|
|
|
|
): Promise<SyncA1Response> {
|
2026-08-16 16:57:36 +02:00
|
|
|
const body: {
|
|
|
|
|
confirm: true;
|
|
|
|
|
backfill_categories?: boolean;
|
|
|
|
|
reprocess_sample_limit?: number;
|
2026-08-16 18:36:56 +02:00
|
|
|
skip_dump_backfill?: boolean;
|
2026-08-16 16:57:36 +02:00
|
|
|
} = { confirm: true };
|
|
|
|
|
if (opts?.backfillCategories === false) {
|
|
|
|
|
body.backfill_categories = false;
|
|
|
|
|
}
|
|
|
|
|
if (typeof opts?.reprocessSampleLimit === "number") {
|
|
|
|
|
body.reprocess_sample_limit = opts.reprocessSampleLimit;
|
|
|
|
|
}
|
2026-08-16 18:36:56 +02:00
|
|
|
if (opts?.skipDumpBackfill) {
|
|
|
|
|
body.skip_dump_backfill = true;
|
|
|
|
|
}
|
|
|
|
|
return api<SyncA1Response>(ADMIN_SYNC_A1_PATH(companyId), {
|
2026-08-16 16:57:36 +02:00
|
|
|
method: "POST",
|
|
|
|
|
body
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 18:36:56 +02:00
|
|
|
/** Compat alias — same as syncAdminCompanyA1 (fix-catalog route). */
|
|
|
|
|
export async function fixAdminCompanyCatalog(
|
|
|
|
|
companyId: string,
|
|
|
|
|
opts?: { backfillCategories?: boolean; reprocessSampleLimit?: number }
|
|
|
|
|
): Promise<SyncA1Response> {
|
|
|
|
|
return syncAdminCompanyA1(companyId, opts);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 22:47:43 +02:00
|
|
|
export function isStaffRoleApiUnavailable(err: unknown): boolean {
|
|
|
|
|
return err instanceof ApiError && (err.status === 404 || err.status === 501);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export { assignAdminPlan, planOptionLabel };
|
|
|
|
|
export type { AdminBillingPlan };
|