TorbatYar/frontend/modules/experience/services/experience-api.ts
Mortezakoohjani 0d424c500a feat(platform): complete commercial runtime consolidation
Unify commercial runtime ownership across backend and frontend so platform, experience, and hospitality modules use the shared commercial source of truth.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 11:11:53 +03:30

499 lines
19 KiB
TypeScript

/**
* Experience Platform API client — Torbat Pages (real backend only).
* Browser → /api/experience BFF; SSR → direct upstream :8008
*/
import { getValidAccessToken, refreshSession } from "@/lib/auth";
import type {
CapabilitiesResponse,
HealthResponse,
ListParams,
MetricsResponse,
PaginatedList,
} from "@/modules/experience/types";
const EXPERIENCE_BASE =
typeof window !== "undefined"
? "/api/experience"
: process.env.EXPERIENCE_SERVICE_URL ||
process.env.NEXT_PUBLIC_EXPERIENCE_API_URL ||
"http://localhost:8008";
export class ExperienceApiError extends Error {
constructor(
public status: number,
public code: string,
message: string
) {
super(message);
this.name = "ExperienceApiError";
}
}
/** Never surface raw objects as "[object Object]" in UI toasts/ErrorState. */
export function formatExperienceError(err: unknown, fallback = "خطا در ارتباط با تربت پیجز"): string {
if (err instanceof ExperienceApiError) return err.message || fallback;
if (err instanceof Error) return err.message || fallback;
if (typeof err === "string" && err.trim()) return err;
return fallback;
}
function stringifyApiDetail(detail: unknown): string | null {
if (detail == null) return null;
if (typeof detail === "string") return detail;
if (Array.isArray(detail)) {
const parts = detail
.map((item) => {
if (typeof item === "string") return item;
if (item && typeof item === "object") {
const row = item as { msg?: unknown; loc?: unknown; message?: unknown };
const where = Array.isArray(row.loc)
? row.loc.filter((x) => x !== "body" && x !== "query").join(".")
: "";
const msg = row.msg ?? row.message;
if (typeof msg === "string") return where ? `${where}: ${msg}` : msg;
}
return null;
})
.filter(Boolean);
return parts.length ? parts.join(" · ") : null;
}
if (typeof detail === "object") {
const obj = detail as { message?: unknown; code?: unknown; msg?: unknown };
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
try {
return JSON.stringify(detail);
} catch {
return null;
}
}
return String(detail);
}
type RequestOptions = RequestInit & { tenantId: string; auth?: boolean };
async function request<T>(path: string, options: RequestOptions): Promise<T> {
const { tenantId, auth = true, headers: customHeaders, ...init } = options;
const headers = new Headers(customHeaders);
if (!headers.has("Content-Type") && init.body) {
headers.set("Content-Type", "application/json");
}
headers.set("X-Tenant-ID", tenantId);
if (auth) {
const token = await getValidAccessToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
}
const url = path.startsWith("http")
? path
: `${EXPERIENCE_BASE.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
let res: Response;
try {
res = await fetch(url, { ...init, headers });
} catch (err) {
const detail = err instanceof Error ? err.message : "network_error";
throw new Error(`اتصال به سرویس تربت پیجز برقرار نشد (${url}) — ${detail}`);
}
if (res.status === 401 && auth) {
const refreshed = await refreshSession({ force: true });
if (refreshed) {
headers.set("Authorization", `Bearer ${refreshed}`);
res = await fetch(url, { ...init, headers });
}
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const code =
(typeof body?.error?.code === "string" && body.error.code) ||
(typeof body?.code === "string" && body.code) ||
"unknown_error";
const message =
stringifyApiDetail(body?.error?.message) ||
stringifyApiDetail(body?.error?.details) ||
stringifyApiDetail(body?.detail) ||
stringifyApiDetail(body?.message) ||
res.statusText ||
`HTTP ${res.status}`;
throw new ExperienceApiError(res.status, code, message);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
function qs(params?: Record<string, string | number | boolean | undefined | null>) {
if (!params) return "";
const sp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== "") sp.set(k, String(v));
});
const s = sp.toString();
return s ? `?${s}` : "";
}
export function normalizeList<T = Record<string, unknown>>(
data: T[] | PaginatedList<T> | { items?: T[] }
): T[] {
if (Array.isArray(data)) return data;
if ("items" in data && Array.isArray(data.items)) return data.items;
return [];
}
function crudResource(basePath: string, softDelete = true) {
return {
list: (tenantId: string, params?: ListParams) =>
request<Record<string, unknown>[] | PaginatedList<Record<string, unknown>>>(
`${basePath}${qs(params)}`,
{ tenantId }
).then(normalizeList),
get: (tenantId: string, id: string) =>
request<Record<string, unknown>>(`${basePath}/${id}`, { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(basePath, {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(`${basePath}/${id}`, {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
remove: softDelete
? (tenantId: string, id: string) =>
request<Record<string, unknown>>(`${basePath}/${id}/delete`, {
tenantId,
method: "POST",
})
: (tenantId: string, id: string) =>
request<void>(`${basePath}/${id}`, { tenantId, method: "DELETE" }),
};
}
function httpDeleteResource(basePath: string) {
return {
list: (tenantId: string, params?: ListParams) =>
request<Record<string, unknown>[] | PaginatedList<Record<string, unknown>>>(
`${basePath}${qs(params)}`,
{ tenantId }
).then(normalizeList),
get: (tenantId: string, id: string) =>
request<Record<string, unknown>>(`${basePath}/${id}`, { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(basePath, {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(`${basePath}/${id}`, {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
remove: (tenantId: string, id: string) =>
request<void>(`${basePath}/${id}`, { tenantId, method: "DELETE" }),
};
}
export const experienceApi = {
health: (): Promise<HealthResponse> =>
fetch(`${EXPERIENCE_BASE}/health`).then((r) => r.json()),
capabilities: (): Promise<CapabilitiesResponse> =>
fetch(`${EXPERIENCE_BASE}/capabilities`).then((r) => r.json()),
metrics: (): Promise<MetricsResponse> =>
fetch(`${EXPERIENCE_BASE}/metrics`).then((r) => r.json()),
workspaces: crudResource("/api/v1/workspaces"),
localeProfiles: crudResource("/api/v1/locale-profiles"),
externalProviders: crudResource("/api/v1/external-providers"),
renderEngines: crudResource("/api/v1/render-engines"),
configurations: crudResource("/api/v1/configurations"),
settings: {
list: (tenantId: string, params?: ListParams) =>
request<Record<string, unknown>[]>(`/api/v1/settings${qs(params)}`, { tenantId }).then(
normalizeList
),
upsert: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(`/api/v1/settings`, {
tenantId,
method: "PUT",
body: JSON.stringify(body),
}),
},
audit: {
list: (tenantId: string, params?: ListParams) =>
request<Record<string, unknown>[]>(`/api/v1/audit${qs(params)}`, { tenantId }).then(
normalizeList
),
},
sites: crudResource("/api/v1/sites"),
pages: crudResource("/api/v1/pages"),
siteDomains: crudResource("/api/v1/site-domains"),
components: crudResource("/api/v1/components"),
componentVersions: {
...crudResource("/api/v1/component-versions", false),
publish: (tenantId: string, id: string, body: Record<string, unknown> = {}) =>
request<Record<string, unknown>>(`/api/v1/component-versions/${id}/publish`, {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
pageComponentPlacements: crudResource("/api/v1/page-component-placements"),
themes: crudResource("/api/v1/themes"),
themeVersions: crudResource("/api/v1/theme-versions", false),
layouts: crudResource("/api/v1/layouts"),
layoutVersions: crudResource("/api/v1/layout-versions", false),
siteThemeBindings: {
list: (tenantId: string, params?: ListParams) =>
request<Record<string, unknown>[]>(`/api/v1/site-theme-bindings${qs(params)}`, {
tenantId,
}).then(normalizeList),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(`/api/v1/site-theme-bindings`, {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
unassign: (tenantId: string, id: string) =>
request<Record<string, unknown>>(`/api/v1/site-theme-bindings/${id}/unassign`, {
tenantId,
method: "POST",
}),
},
pageLayoutBindings: {
list: (tenantId: string, params?: ListParams) =>
request<Record<string, unknown>[]>(`/api/v1/page-layout-bindings${qs(params)}`, {
tenantId,
}).then(normalizeList),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(`/api/v1/page-layout-bindings`, {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
unassign: (tenantId: string, id: string) =>
request<Record<string, unknown>>(`/api/v1/page-layout-bindings/${id}/unassign`, {
tenantId,
method: "POST",
}),
},
templates: crudResource("/api/v1/templates"),
templateVersions: crudResource("/api/v1/template-versions", false),
templateInstantiations: {
list: (tenantId: string, params?: ListParams) =>
request<Record<string, unknown>[]>(`/api/v1/template-instantiations${qs(params)}`, {
tenantId,
}).then(normalizeList),
get: (tenantId: string, id: string) =>
request<Record<string, unknown>>(`/api/v1/template-instantiations/${id}`, { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(`/api/v1/template-instantiations`, {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
localizations: httpDeleteResource("/api/v1/localizations"),
mediaRefs: httpDeleteResource("/api/v1/media-refs"),
forms: httpDeleteResource("/api/v1/forms"),
formSubmissions: {
list: (tenantId: string, params?: ListParams) =>
request<Record<string, unknown>[]>(`/api/v1/form-submissions${qs(params)}`, {
tenantId,
}).then(normalizeList),
get: (tenantId: string, id: string) =>
request<Record<string, unknown>>(`/api/v1/form-submissions/${id}`, { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(`/api/v1/form-submissions`, {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(`/api/v1/form-submissions/${id}`, {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
},
surveys: httpDeleteResource("/api/v1/surveys"),
surveyResponses: {
list: (tenantId: string, params?: ListParams) =>
request<Record<string, unknown>[]>(`/api/v1/survey-responses${qs(params)}`, {
tenantId,
}).then(normalizeList),
get: (tenantId: string, id: string) =>
request<Record<string, unknown>>(`/api/v1/survey-responses/${id}`, { tenantId }),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(`/api/v1/survey-responses/${id}`, {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
},
publishReleases: {
list: (tenantId: string, params?: ListParams) =>
request<Record<string, unknown>[]>(`/api/v1/publish-releases${qs(params)}`, {
tenantId,
}).then(normalizeList),
get: (tenantId: string, id: string) =>
request<Record<string, unknown>>(`/api/v1/publish-releases/${id}`, { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(`/api/v1/publish-releases`, {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(`/api/v1/publish-releases/${id}`, {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
},
domainEdgeBindings: {
list: (tenantId: string, params?: ListParams) =>
request<Record<string, unknown>[]>(`/api/v1/domain-edge-bindings${qs(params)}`, {
tenantId,
}).then(normalizeList),
get: (tenantId: string, id: string) =>
request<Record<string, unknown>>(`/api/v1/domain-edge-bindings/${id}`, { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(`/api/v1/domain-edge-bindings`, {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(`/api/v1/domain-edge-bindings/${id}`, {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
},
seoMetadata: {
list: (tenantId: string, params?: ListParams) =>
request<Record<string, unknown>[]>(`/api/v1/seo-metadata${qs(params)}`, { tenantId }).then(
normalizeList
),
get: (tenantId: string, id: string) =>
request<Record<string, unknown>>(`/api/v1/seo-metadata/${id}`, { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(`/api/v1/seo-metadata`, {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(`/api/v1/seo-metadata/${id}`, {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
},
sitemapShells: {
list: (tenantId: string, params?: ListParams) =>
request<Record<string, unknown>[]>(`/api/v1/sitemap-shells${qs(params)}`, { tenantId }).then(
normalizeList
),
get: (tenantId: string, id: string) =>
request<Record<string, unknown>>(`/api/v1/sitemap-shells/${id}`, { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(`/api/v1/sitemap-shells`, {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(`/api/v1/sitemap-shells/${id}`, {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
},
robotsShells: {
list: (tenantId: string, params?: ListParams) =>
request<Record<string, unknown>[]>(`/api/v1/robots-shells${qs(params)}`, { tenantId }).then(
normalizeList
),
get: (tenantId: string, id: string) =>
request<Record<string, unknown>>(`/api/v1/robots-shells/${id}`, { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(`/api/v1/robots-shells`, {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(`/api/v1/robots-shells/${id}`, {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
},
pwaManifests: {
list: (tenantId: string, params?: ListParams) =>
request<Record<string, unknown>[]>(`/api/v1/pwa-manifests${qs(params)}`, { tenantId }).then(
normalizeList
),
get: (tenantId: string, id: string) =>
request<Record<string, unknown>>(`/api/v1/pwa-manifests/${id}`, { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(`/api/v1/pwa-manifests`, {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(`/api/v1/pwa-manifests/${id}`, {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
},
analyticsReportDefinitions: httpDeleteResource("/api/v1/analytics-report-definitions"),
analyticsSnapshots: {
list: (tenantId: string, params?: ListParams) =>
request<Record<string, unknown>[]>(`/api/v1/analytics-snapshots${qs(params)}`, {
tenantId,
}).then(normalizeList),
get: (tenantId: string, id: string) =>
request<Record<string, unknown>>(`/api/v1/analytics-snapshots/${id}`, { tenantId }),
refresh: (tenantId: string, body: Record<string, unknown> = {}) =>
request<Record<string, unknown>>(`/api/v1/analytics-snapshots/refresh`, {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(`/api/v1/analytics-snapshots/${id}`, {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
remove: (tenantId: string, id: string) =>
request<void>(`/api/v1/analytics-snapshots/${id}`, { tenantId, method: "DELETE" }),
},
widgetDefinitions: httpDeleteResource("/api/v1/widget-definitions"),
widgetInstances: httpDeleteResource("/api/v1/widget-instances"),
};