378 lines
10 KiB
TypeScript
378 lines
10 KiB
TypeScript
/**
|
||
* API Client — ارتباط frontend با Core Backend (پنل ادمین / tenant).
|
||
* توکن SSO مرکزی (Keycloak) از sessionStorage استفاده میشود.
|
||
*/
|
||
|
||
import { getValidAccessToken } from "@/lib/auth";
|
||
|
||
const SERVER_API_BASE =
|
||
process.env.NEXT_PUBLIC_API_BASE_URL ||
|
||
process.env.NEXT_PUBLIC_BACKEND_URL ||
|
||
"http://localhost:8000";
|
||
const API_BASE = typeof window === "undefined" ? SERVER_API_BASE : "/api/core";
|
||
|
||
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 } = await import("@/lib/auth");
|
||
const refreshed = await refreshSession({ force: true });
|
||
if (refreshed) {
|
||
headers.set("Authorization", `Bearer ${refreshed}`);
|
||
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;
|
||
}
|
||
|
||
// ---- مدیریت پلتفرم (پنل ادمین): سرویسها ----
|
||
// commercial plans/features/subscriptions → /api/v1/commercial/* only
|
||
|
||
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 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" }),
|
||
},
|
||
|
||
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),
|
||
}),
|
||
},
|
||
|
||
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 }),
|
||
}),
|
||
},
|
||
};
|