TorbatYar/frontend/lib/api.ts
Mortezakoohjani 12c8615615 Ship enterprise Accounting FE/API with CRUD parity and production wiring.
Adds accounting-service PATCH/archive, fiscal helpers, COA templates and setup status, plus SuperApp Accounting UI (DS, scoreboard, masters, vouchers, ledger, ops modules) with session refresh and HTTPS public API URLs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 15:26:43 +03:30

504 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* API Client — ارتباط frontend با Core Backend (پنل ادمین / tenant).
* توکن SSO مرکزی (Keycloak) از sessionStorage استفاده می‌شود.
*/
import { getValidAccessToken } from "@/lib/auth";
const API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL ||
process.env.NEXT_PUBLIC_BACKEND_URL ||
"http://localhost:8000";
export class ApiError extends Error {
constructor(
public status: number,
public code: string,
message: string
) {
super(message);
this.name = "ApiError";
}
}
interface RequestOptions extends RequestInit {
auth?: boolean;
}
async function request<T>(
path: string,
options: RequestOptions = {}
): Promise<T> {
const { auth = true, headers: customHeaders, ...init } = options;
const headers = new Headers(customHeaders);
headers.set("Content-Type", "application/json");
if (auth) {
const token = await getValidAccessToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
}
let res: Response;
try {
res = await fetch(`${API_BASE}${path}`, { ...init, headers });
} catch {
throw new Error(`اتصال به سرور برقرار نشد (${API_BASE})`);
}
if (res.status === 401 && auth) {
const { refreshSession, getStoredToken } = await import("@/lib/auth");
const refreshed = await refreshSession();
if (refreshed || getStoredToken()) {
const token = refreshed || getStoredToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
res = await fetch(`${API_BASE}${path}`, { ...init, headers });
}
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new ApiError(
res.status,
body?.error?.code || "unknown_error",
body?.error?.message || res.statusText
);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
export interface PublicTenantSite {
tenant_id: string;
name: string;
slug: string;
status: string;
business_type: string | null;
primary_color: string | null;
secondary_color: string | null;
logo_url: string | null;
favicon_url: string | null;
primary_domain: string | null;
tagline: string;
}
export interface OtpRequestResult {
message: string;
expires_in: number;
skipped?: boolean;
}
type OtpContext = "public" | "admin";
export interface OtpVerifyResult {
access_token: string;
refresh_token: string;
token_type: string;
expires_in: number;
is_new_user: boolean;
user_id: string;
role: string;
}
export interface Tenant {
id: string;
name: string;
slug: string;
status: string;
owner_user_id: string | null;
business_type?: string | null;
default_locale?: string;
timezone?: string;
primary_color?: string | null;
secondary_color?: string | null;
logo_url?: string | null;
favicon_url?: string | null;
onboarding_completed?: boolean;
created_at: string;
updated_at: string;
}
export interface Page<T> {
items: T[];
meta: {
page: number;
page_size: number;
total_items: number;
total_pages: number;
};
}
// ---- فاز ۳: Onboarding و Tenant Context ----
export interface MembershipSummary {
tenant_id: string;
tenant_name: string;
tenant_slug: string;
tenant_status: string;
onboarding_completed: boolean;
role: string;
status: string;
is_owner: boolean;
}
export interface MeResponse {
user_id: string;
mobile: string;
email: string | null;
platform_role: string;
onboarding_required: boolean;
current_tenant_id: string | null;
memberships: MembershipSummary[];
}
export interface TenantDomain {
id: string;
tenant_id: string;
domain: string;
domain_type: string;
is_verified: boolean;
verified_at: string | null;
created_at: string;
}
export interface TenantContext {
tenant: Tenant;
role: string | null;
is_owner: boolean;
plan_code: string | null;
plan_name: string | null;
subscription_status: string | null;
domains: TenantDomain[];
primary_domain: string | null;
}
export interface OnboardingTenantCreateInput {
business_name: string;
slug: string;
business_type?: string;
default_locale?: string;
timezone?: string;
}
export interface OnboardingBrandingInput {
primary_color?: string;
secondary_color?: string;
logo_url?: string;
favicon_url?: string;
}
export interface OnboardingDomainInput {
custom_domain?: string;
}
// ---- مدیریت پلتفرم (پنل ادمین): پلن، قابلیت، اشتراک، سرویس ----
export interface Plan {
id: string;
code: string;
name: string;
description: string | null;
is_active: boolean;
created_at: string;
}
export interface Feature {
id: string;
feature_key: string;
name: string;
description: string | null;
service_key: string;
is_active: boolean;
}
export type LimitPeriod = "day" | "month" | "year" | "total";
export interface PlanFeature {
id: string;
plan_id: string;
feature_id: string;
limit_value: number | null;
limit_period: LimitPeriod | null;
is_enabled: boolean;
}
export type SubscriptionStatus =
| "trialing"
| "active"
| "past_due"
| "canceled"
| "expired";
export interface Subscription {
id: string;
tenant_id: string;
plan_id: string;
status: SubscriptionStatus;
starts_at: string | null;
ends_at: string | null;
trial_ends_at: string | null;
created_at: string;
}
export type ServiceStatus = "active" | "inactive" | "maintenance";
export interface ServiceEntry {
id: string;
service_key: string;
name: string;
base_url: string | null;
status: ServiceStatus;
health_check_url: string | null;
created_at: string;
}
export interface FeatureCheckResult {
tenant_id: string;
feature_key: string;
has_access: boolean;
reason: string | null;
}
export const api = {
public: {
tenantSite: (opts?: { slug?: string | null; host?: string | null }) => {
const params = new URLSearchParams();
if (opts?.slug) params.set("slug", opts.slug);
const q = params.toString();
const headers: Record<string, string> = {};
if (opts?.slug) headers["X-Tenant-Slug"] = opts.slug;
if (opts?.host) headers["X-Forwarded-Host"] = opts.host;
return request<PublicTenantSite>(
`/api/v1/public/tenant-site${q ? `?${q}` : ""}`,
{ auth: false, headers }
);
},
},
auth: {
requestOtp: (mobile: string, options?: { forceSms?: boolean; context?: OtpContext }) =>
request<OtpRequestResult>("/api/v1/auth/otp/request", {
method: "POST",
auth: false,
body: JSON.stringify({
mobile,
context: options?.context ?? "admin",
...(options?.forceSms ? { force_sms: true } : {}),
}),
}),
verifyOtp: (mobile: string, code: string, context: OtpContext = "admin") =>
request<OtpVerifyResult>("/api/v1/auth/otp/verify", {
method: "POST",
auth: false,
body: JSON.stringify({ mobile, code, context }),
}),
},
tenants: {
list: (page = 1, pageSize = 20) =>
request<Page<Tenant>>(
`/api/v1/admin/tenants?page=${page}&page_size=${pageSize}`
),
get: (id: string) => request<Tenant>(`/api/v1/admin/tenants/${id}`),
create: (data: { name: string; slug: string; custom_domain?: string }) =>
request<Tenant>("/api/v1/admin/tenants", {
method: "POST",
body: JSON.stringify(data),
}),
update: (id: string, data: { name?: string }) =>
request<Tenant>(`/api/v1/admin/tenants/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
}),
},
me: {
get: () => request<MeResponse>("/api/v1/me"),
tenants: () => request<MembershipSummary[]>("/api/v1/me/tenants"),
},
onboarding: {
createTenant: (data: OnboardingTenantCreateInput) =>
request<TenantContext>("/api/v1/onboarding/tenant", {
method: "POST",
body: JSON.stringify(data),
}),
updateBranding: (tenantId: string, data: OnboardingBrandingInput) =>
request<TenantContext>(`/api/v1/onboarding/tenant/${tenantId}/branding`, {
method: "PATCH",
body: JSON.stringify(data),
}),
updateDomain: (tenantId: string, data: OnboardingDomainInput) =>
request<TenantContext>(`/api/v1/onboarding/tenant/${tenantId}/domain`, {
method: "PATCH",
body: JSON.stringify(data),
}),
complete: (tenantId: string) =>
request<TenantContext>(`/api/v1/onboarding/tenant/${tenantId}/complete`, {
method: "POST",
}),
},
tenantContext: {
current: () => request<TenantContext>("/api/v1/tenant/current"),
switchTenant: (tenantId: string) =>
request<TenantContext>("/api/v1/tenant/switch", {
method: "POST",
body: JSON.stringify({ tenant_id: tenantId }),
}),
},
// مدیریت پلتفرمی tenantها (نیازمند نقش platform_admin)
platformTenants: {
list: (page = 1, pageSize = 50) =>
request<Page<Tenant>>(`/api/v1/tenants?page=${page}&page_size=${pageSize}`),
get: (id: string) => request<Tenant>(`/api/v1/tenants/${id}`),
create: (data: {
name: string;
slug: string;
owner_user_id?: string | null;
custom_domain?: string | null;
}) =>
request<Tenant>("/api/v1/tenants", {
method: "POST",
body: JSON.stringify(data),
}),
update: (
id: string,
data: { name?: string; status?: string; owner_user_id?: string | null }
) =>
request<Tenant>(`/api/v1/tenants/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
}),
suspend: (id: string) =>
request<Tenant>(`/api/v1/tenants/${id}/suspend`, { method: "POST" }),
activate: (id: string) =>
request<Tenant>(`/api/v1/tenants/${id}/activate`, { method: "POST" }),
},
plans: {
list: (page = 1, pageSize = 50) =>
request<Page<Plan>>(`/api/v1/plans?page=${page}&page_size=${pageSize}`),
create: (data: {
code: string;
name: string;
description?: string | null;
is_active?: boolean;
}) =>
request<Plan>("/api/v1/plans", {
method: "POST",
body: JSON.stringify(data),
}),
attachFeature: (
planId: string,
data: {
feature_id: string;
limit_value?: number | null;
limit_period?: LimitPeriod | null;
is_enabled?: boolean;
}
) =>
request<PlanFeature>(`/api/v1/plans/${planId}/features`, {
method: "POST",
body: JSON.stringify(data),
}),
},
features: {
list: (page = 1, pageSize = 100) =>
request<Page<Feature>>(`/api/v1/features?page=${page}&page_size=${pageSize}`),
create: (data: {
feature_key: string;
name: string;
description?: string | null;
service_key: string;
is_active?: boolean;
}) =>
request<Feature>("/api/v1/features", {
method: "POST",
body: JSON.stringify(data),
}),
},
domains: {
listForTenant: (tenantId: string) =>
request<TenantDomain[]>(`/api/v1/tenants/${tenantId}/domains`),
create: (
tenantId: string,
data: { domain: string; domain_type?: "subdomain" | "custom_domain" }
) =>
request<TenantDomain>(`/api/v1/tenants/${tenantId}/domains`, {
method: "POST",
body: JSON.stringify(data),
}),
},
subscriptions: {
get: (tenantId: string) =>
request<Subscription>(`/api/v1/tenants/${tenantId}/subscription`),
create: (
tenantId: string,
data: {
plan_id: string;
status?: SubscriptionStatus;
starts_at?: string | null;
ends_at?: string | null;
trial_ends_at?: string | null;
}
) =>
request<Subscription>(`/api/v1/tenants/${tenantId}/subscription`, {
method: "POST",
body: JSON.stringify(data),
}),
checkFeature: (tenantId: string, featureKey: string) =>
request<FeatureCheckResult>(`/api/v1/tenants/${tenantId}/features/check`, {
method: "POST",
body: JSON.stringify({ feature_key: featureKey }),
}),
},
services: {
list: (page = 1, pageSize = 100) =>
request<Page<ServiceEntry>>(`/api/v1/services?page=${page}&page_size=${pageSize}`),
create: (data: {
service_key: string;
name: string;
base_url?: string | null;
health_check_url?: string | null;
status?: ServiceStatus;
}) =>
request<ServiceEntry>("/api/v1/services", {
method: "POST",
body: JSON.stringify(data),
}),
updateStatus: (serviceId: string, status: ServiceStatus) =>
request<ServiceEntry>(`/api/v1/services/${serviceId}/status`, {
method: "PATCH",
body: JSON.stringify({ status }),
}),
},
};