// Arcadia tenants API helpers. // // Hand-rolled because /api/v1/admin/tenants isn't covered by arcadia's // OpenAPI spec (controller hasn't been wired into OpenApiSpex yet — same // "ok"-placeholder issue as some other admin endpoints). When the spec // gains coverage, switch to `arcadia.typed.GET("/api/v1/admin/tenants", ...)` // and drop these manual types. import { ArcadiaError, type ArcadiaClient } from "@crema/arcadia-core-client" export type TenantStatus = "active" | "suspended" | "deactivated" | string export interface TenantPlan { name: string limits: Record } export interface TenantBranding { logo_url: string | null favicon_url: string | null primary_color: string | null secondary_color: string | null accent_color: string | null custom_css: string | null settings: Record } export interface TenantSettings { timezone?: string currency?: string [key: string]: unknown } export interface TenantLocalization { locale: string timezone: string currency: string settings: Record } export interface Tenant { id: string slug: string name: string status: TenantStatus plan: TenantPlan branding: TenantBranding settings: TenantSettings localization: TenantLocalization email_settings: Record notification_settings: Record metadata: Record inserted_at: string updated_at: string } export interface TenantListParams { q?: string status?: TenantStatus page?: number per_page?: number } export async function listTenants( arcadia: ArcadiaClient, params?: TenantListParams, ): Promise { const queryParams: Record | undefined = params ? { q: params.q, status: params.status, page: params.page, per_page: params.per_page } : undefined const res = await arcadia.GET<{ data: Tenant[] }>("/api/v1/admin/tenants", { params: queryParams }) return res.data } export async function getTenant(arcadia: ArcadiaClient, id: string): Promise { const res = await arcadia.GET<{ data: Tenant }>(`/api/v1/admin/tenants/${id}`) return res.data } export async function suspendTenant(arcadia: ArcadiaClient, id: string): Promise { const res = await arcadia.POST<{ data: Tenant }>(`/api/v1/admin/tenants/${id}/suspend`) return res.data } export async function activateTenant(arcadia: ArcadiaClient, id: string): Promise { const res = await arcadia.POST<{ data: Tenant }>(`/api/v1/admin/tenants/${id}/activate`) return res.data } export async function deactivateTenant(arcadia: ArcadiaClient, id: string): Promise { const res = await arcadia.POST<{ data: Tenant }>(`/api/v1/admin/tenants/${id}/deactivate`) return res.data } export interface ProvisionTenantInput { tenant: { name: string; slug: string } admin_user: { email: string password: string first_name: string last_name: string } } export async function provisionTenant( arcadia: ArcadiaClient, input: ProvisionTenantInput, ): Promise { const res = await arcadia.POST<{ data: Tenant }>("/api/v1/admin/tenants/provision", { body: input, }) return res.data } // --------------------------------------------------------------------------- // Tenant detail — one screen per tab, all against /admin/tenants/:id/*. // Enum values mirror arcadia-core's Tenant schema (tenant.ex) so the pickers // only offer values the server will accept. // --------------------------------------------------------------------------- export const TENANT_PLANS = ["free", "starter", "professional", "enterprise", "custom"] export const TENANT_LOCALES = ["en", "es", "fr", "de", "pt", "ja", "zh"] export const TENANT_CURRENCIES = ["USD", "EUR", "GBP", "CAD", "AUD", "JPY", "CNY"] export const EMAIL_PROVIDERS = ["smtp", "sendgrid", "mailgun", "ses"] as const export const SMS_PROVIDERS = ["twilio", "vonage", "messagebird"] as const // A curated subset — the server accepts any IANA name, but a full 400-entry // list is worse UX than the ones operators actually pick. export const COMMON_TIMEZONES = [ "UTC", "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", "Europe/London", "Europe/Paris", "Europe/Berlin", "Asia/Tokyo", "Asia/Shanghai", "Asia/Singapore", "Australia/Sydney", "Pacific/Auckland", ] /** Rename the tenant. */ export async function updateTenant( arcadia: ArcadiaClient, id: string, tenant: { name?: string }, ): Promise { const res = await arcadia.PUT<{ data: Tenant }>(`/api/v1/admin/tenants/${id}`, { body: { tenant }, }) return res.data } export async function updateBranding( arcadia: ArcadiaClient, id: string, branding: Partial>, ): Promise { const res = await arcadia.PUT<{ data: Tenant }>(`/api/v1/admin/tenants/${id}/branding`, { body: { branding }, }) return res.data } export async function updateLocalization( arcadia: ArcadiaClient, id: string, localization: Partial>, ): Promise { const res = await arcadia.PUT<{ data: Tenant }>(`/api/v1/admin/tenants/${id}/localization`, { body: { localization }, }) return res.data } /** The plan endpoint nests its params under `plan` and validates the slug * against the server's plan list — pass one of `TENANT_PLANS`. */ export async function updatePlan( arcadia: ArcadiaClient, id: string, plan: { plan: string; plan_limits?: Record }, ): Promise { const res = await arcadia.PUT<{ data: Tenant }>(`/api/v1/admin/tenants/${id}/plan`, { body: { plan }, }) return res.data } // --- Quotas & usage --- export interface QuotaConfig { category: string calls_per_minute: number | null calls_per_day: number | null calls_per_month: number | null enabled: boolean } export interface UsageRow { category: string usage: { minute: number; day: number; month: number } quota: { enabled: boolean calls_per_minute: number | null calls_per_day: number | null calls_per_month: number | null } } export async function listUsage(arcadia: ArcadiaClient, id: string): Promise { const res = await arcadia.GET<{ data: UsageRow[] }>( `/api/v1/admin/tenants/${id}/api-metering/usage`, ) return res.data } export async function upsertQuota( arcadia: ArcadiaClient, id: string, category: string, limits: Partial>, ): Promise { const res = await arcadia.PUT<{ data: QuotaConfig }>( `/api/v1/admin/tenants/${id}/api-metering/quotas/${encodeURIComponent(category)}`, { body: limits }, ) return res.data } export async function deleteQuota( arcadia: ArcadiaClient, id: string, category: string, ): Promise { await arcadia.DELETE( `/api/v1/admin/tenants/${id}/api-metering/quotas/${encodeURIComponent(category)}`, ) } // --- Feature flags --- // // The list is one row per *platform-defined* flag, overlaid with this tenant's // override: `source` is "override" when the tenant pins a value, "default" when // it inherits `enabled_by_default`. You can only override a flag that exists at // the platform level — an override for an unknown key is stored but never shown // (define platform flags under the platform Feature-flags screen). So an empty // list means "no platform flags defined", not "no overrides". export interface TenantFeatureFlag { key: string description: string | null enabled: boolean source: "override" | "default" | string } export async function listFeatureFlags( arcadia: ArcadiaClient, id: string, ): Promise { const res = await arcadia.GET<{ data: TenantFeatureFlag[] }>( `/api/v1/admin/tenants/${id}/feature-flags`, ) return res.data } /** Pin `key` on/off for this tenant, overriding the platform default. */ export async function setFeatureFlag( arcadia: ArcadiaClient, id: string, key: string, enabled: boolean, ): Promise { await arcadia.PUT( `/api/v1/admin/tenants/${id}/feature-flags/${encodeURIComponent(key)}`, { body: { enabled } }, ) } /** Drop the tenant's override for `key`, reverting it to the platform default. */ export async function clearFeatureFlag( arcadia: ArcadiaClient, id: string, key: string, ): Promise { await arcadia.DELETE(`/api/v1/admin/tenants/${id}/feature-flags/${encodeURIComponent(key)}`) } // --- IP rules --- export interface IpRule { id: string cidr: string rule_type: "allow" | "deny" | string description: string | null enabled: boolean } export type IpRuleInput = { cidr: string rule_type: "allow" | "deny" description?: string enabled?: boolean } export async function listIpRules(arcadia: ArcadiaClient, id: string): Promise { const res = await arcadia.GET<{ data: IpRule[] }>(`/api/v1/admin/tenants/${id}/ip-rules`) return res.data } export async function createIpRule( arcadia: ArcadiaClient, id: string, input: IpRuleInput, ): Promise { const res = await arcadia.POST<{ data: IpRule }>(`/api/v1/admin/tenants/${id}/ip-rules`, { body: input, }) return res.data } export async function updateIpRule( arcadia: ArcadiaClient, id: string, ruleId: string, input: Partial, ): Promise { const res = await arcadia.PUT<{ data: IpRule }>( `/api/v1/admin/tenants/${id}/ip-rules/${ruleId}`, { body: input }, ) return res.data } export async function deleteIpRule( arcadia: ArcadiaClient, id: string, ruleId: string, ): Promise { await arcadia.DELETE(`/api/v1/admin/tenants/${id}/ip-rules/${ruleId}`) } // --- Inbound webhooks --- export interface InboundWebhookSource { id: string name: string provider: string | null signature_header: string | null signature_algorithm: string enabled: boolean event_mappings?: Record metadata?: Record inserted_at?: string } export type InboundWebhookInput = { name: string provider?: string signing_secret?: string signature_header?: string signature_algorithm?: string enabled?: boolean } export interface InboundWebhookDelivery { id: string status?: string received_at?: string inserted_at?: string [key: string]: unknown } export async function listInboundWebhooks( arcadia: ArcadiaClient, id: string, ): Promise { const res = await arcadia.GET<{ data: InboundWebhookSource[] }>( `/api/v1/admin/tenants/${id}/inbound-webhooks`, ) return res.data } export async function createInboundWebhook( arcadia: ArcadiaClient, id: string, input: InboundWebhookInput, ): Promise { const res = await arcadia.POST<{ data: InboundWebhookSource }>( `/api/v1/admin/tenants/${id}/inbound-webhooks`, { body: input }, ) return res.data } export async function updateInboundWebhook( arcadia: ArcadiaClient, id: string, sourceId: string, input: Partial, ): Promise { const res = await arcadia.PUT<{ data: InboundWebhookSource }>( `/api/v1/admin/tenants/${id}/inbound-webhooks/${sourceId}`, { body: input }, ) return res.data } export async function deleteInboundWebhook( arcadia: ArcadiaClient, id: string, sourceId: string, ): Promise { await arcadia.DELETE(`/api/v1/admin/tenants/${id}/inbound-webhooks/${sourceId}`) } export async function listInboundWebhookDeliveries( arcadia: ArcadiaClient, id: string, sourceId: string, ): Promise { const res = await arcadia.GET<{ data: InboundWebhookDelivery[] }>( `/api/v1/admin/tenants/${id}/inbound-webhooks/${sourceId}/deliveries`, ) return res.data } // --- Email & SMS delivery config --- // GET 404s when unconfigured; credentials are write-only (never returned). export interface EmailConfig { id: string provider: (typeof EMAIL_PROVIDERS)[number] | string from_email: string | null from_name: string | null reply_to: string | null enabled: boolean } export type EmailConfigInput = { provider: string from_email?: string from_name?: string reply_to?: string credentials?: Record enabled?: boolean } export interface SmsConfig { id: string provider: (typeof SMS_PROVIDERS)[number] | string from_number: string | null enabled: boolean } export type SmsConfigInput = { provider: string from_number?: string credentials?: Record enabled?: boolean } export interface TestResult { ok: boolean message: string } /** Resolves to null when no config exists (the endpoint 404s), so callers can * distinguish "unconfigured" from a real load failure. */ export async function getEmailConfig( arcadia: ArcadiaClient, id: string, ): Promise { return getOrNull(arcadia, `/api/v1/admin/tenants/${id}/email-config`) } export async function upsertEmailConfig( arcadia: ArcadiaClient, id: string, input: EmailConfigInput, ): Promise { const res = await arcadia.PUT<{ data: EmailConfig }>( `/api/v1/admin/tenants/${id}/email-config`, { body: { email_config: input } }, ) return res.data } export async function deleteEmailConfig(arcadia: ArcadiaClient, id: string): Promise { await arcadia.DELETE(`/api/v1/admin/tenants/${id}/email-config`) } export async function testEmailConfig( arcadia: ArcadiaClient, id: string, ): Promise { return arcadia.POST(`/api/v1/admin/tenants/${id}/email-config/test`) } export async function getSmsConfig( arcadia: ArcadiaClient, id: string, ): Promise { return getOrNull(arcadia, `/api/v1/admin/tenants/${id}/sms-config`) } export async function upsertSmsConfig( arcadia: ArcadiaClient, id: string, input: SmsConfigInput, ): Promise { const res = await arcadia.PUT<{ data: SmsConfig }>(`/api/v1/admin/tenants/${id}/sms-config`, { body: { sms_config: input }, }) return res.data } export async function deleteSmsConfig(arcadia: ArcadiaClient, id: string): Promise { await arcadia.DELETE(`/api/v1/admin/tenants/${id}/sms-config`) } export async function testSmsConfig(arcadia: ArcadiaClient, id: string): Promise { return arcadia.POST(`/api/v1/admin/tenants/${id}/sms-config/test`) } /** GET that treats a 404 as "not configured yet" (null) rather than an error, * and rethrows anything else so real failures still surface. */ async function getOrNull(arcadia: ArcadiaClient, path: string): Promise { try { const res = await arcadia.GET<{ data: T }>(path) return res.data } catch (err) { if (err instanceof ArcadiaError && err.status === 404) return null throw err } }