Ship the full Communication portal (real SMS API, feature-locked future channels), BFF proxy, docs, snapshot, and phase handover. Co-authored-by: Cursor <cursoragent@cursor.com>
281 lines
9.9 KiB
TypeScript
281 lines
9.9 KiB
TypeScript
/**
|
|
* Communication Platform API — Torbat Communication (real backend only).
|
|
* Browser → /api/communication BFF; SSR → direct upstream :8005
|
|
*/
|
|
|
|
import { getValidAccessToken, refreshSession } from "@/lib/auth";
|
|
import type {
|
|
CapabilitiesRawResponse,
|
|
HealthResponse,
|
|
MetricsResponse,
|
|
MonitoringMetrics,
|
|
MonitoringStats,
|
|
CommunicationListParams,
|
|
} from "@/modules/communication/types";
|
|
|
|
const COMM_BASE =
|
|
typeof window !== "undefined"
|
|
? "/api/communication"
|
|
: process.env.COMMUNICATION_SERVICE_URL ||
|
|
process.env.NEXT_PUBLIC_COMMUNICATION_API_URL ||
|
|
"http://localhost:8005";
|
|
|
|
export class CommunicationApiError extends Error {
|
|
constructor(
|
|
public status: number,
|
|
public code: string,
|
|
message: string
|
|
) {
|
|
super(message);
|
|
this.name = "CommunicationApiError";
|
|
}
|
|
}
|
|
|
|
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
|
|
: `${COMM_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(() => ({}));
|
|
throw new CommunicationApiError(
|
|
res.status,
|
|
body?.error?.code || body?.detail || "unknown_error",
|
|
body?.error?.message || body?.detail || res.statusText
|
|
);
|
|
}
|
|
if (res.status === 204) return undefined as T;
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
function qs(params?: CommunicationListParams) {
|
|
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 const communicationApi = {
|
|
health: (): Promise<HealthResponse> =>
|
|
fetch(`${COMM_BASE}/health`).then((r) => r.json()),
|
|
|
|
healthReady: (): Promise<{ status: string; database: string }> =>
|
|
fetch(`${COMM_BASE}/health/ready`).then((r) => r.json()),
|
|
|
|
capabilities: (): Promise<CapabilitiesRawResponse> =>
|
|
fetch(`${COMM_BASE}/capabilities`).then((r) => r.json()),
|
|
|
|
metrics: (): Promise<MetricsResponse> =>
|
|
fetch(`${COMM_BASE}/metrics`).then((r) => r.json()),
|
|
|
|
providers: {
|
|
list: (tenantId: string) =>
|
|
request<Record<string, unknown>[]>("/api/v1/providers", { tenantId }),
|
|
get: (tenantId: string, id: string) =>
|
|
request<Record<string, unknown>>(`/api/v1/providers/${id}`, { tenantId }),
|
|
create: (tenantId: string, body: Record<string, unknown>) =>
|
|
request<Record<string, unknown>>("/api/v1/providers", {
|
|
tenantId,
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
|
|
request<Record<string, unknown>>(`/api/v1/providers/${id}`, {
|
|
tenantId,
|
|
method: "PATCH",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
remove: (tenantId: string, id: string) =>
|
|
request<void>(`/api/v1/providers/${id}`, { tenantId, method: "DELETE" }),
|
|
status: (tenantId: string) =>
|
|
request<Record<string, unknown>[]>("/api/v1/providers/status", { tenantId }),
|
|
balance: (tenantId: string, id: string) =>
|
|
request<Record<string, unknown>>(`/api/v1/providers/${id}/balance`, { tenantId }),
|
|
healthCheck: (tenantId: string, id: string) =>
|
|
request<Record<string, unknown>>(`/api/v1/providers/${id}/health`, {
|
|
tenantId,
|
|
method: "POST",
|
|
}),
|
|
senders: {
|
|
list: (tenantId: string) =>
|
|
request<Record<string, unknown>[]>("/api/v1/providers/senders/list", { tenantId }),
|
|
create: (tenantId: string, body: Record<string, unknown>) =>
|
|
request<Record<string, unknown>>("/api/v1/providers/senders", {
|
|
tenantId,
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
},
|
|
},
|
|
|
|
templates: {
|
|
list: (tenantId: string) =>
|
|
request<Record<string, unknown>[]>("/api/v1/templates", { tenantId }),
|
|
get: (tenantId: string, id: string) =>
|
|
request<Record<string, unknown>>(`/api/v1/templates/${id}`, { tenantId }),
|
|
create: (tenantId: string, body: Record<string, unknown>) =>
|
|
request<Record<string, unknown>>("/api/v1/templates", {
|
|
tenantId,
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
submit: (tenantId: string, id: string) =>
|
|
request<Record<string, unknown>>(`/api/v1/templates/${id}/submit`, {
|
|
tenantId,
|
|
method: "POST",
|
|
}),
|
|
approve: (tenantId: string, id: string) =>
|
|
request<Record<string, unknown>>(`/api/v1/templates/${id}/approve`, {
|
|
tenantId,
|
|
method: "POST",
|
|
}),
|
|
reject: (tenantId: string, id: string, reason?: string) =>
|
|
request<Record<string, unknown>>(
|
|
`/api/v1/templates/${id}/reject${reason ? qs({ reason }) : ""}`,
|
|
{ tenantId, method: "POST" }
|
|
),
|
|
preview: (tenantId: string, id: string, variables: Record<string, unknown>) =>
|
|
request<{ rendered: string; subject?: string }>(`/api/v1/templates/${id}/preview`, {
|
|
tenantId,
|
|
method: "POST",
|
|
body: JSON.stringify({ variables }),
|
|
}),
|
|
},
|
|
|
|
contacts: {
|
|
manual: {
|
|
list: (tenantId: string) =>
|
|
request<Record<string, unknown>[]>("/api/v1/contacts/manual", { tenantId }),
|
|
create: (tenantId: string, body: Record<string, unknown>) =>
|
|
request<Record<string, unknown>>("/api/v1/contacts/manual", {
|
|
tenantId,
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
importCsv: (tenantId: string, body: { csv_text: string }) =>
|
|
request<Record<string, unknown>[]>("/api/v1/contacts/manual/import-csv", {
|
|
tenantId,
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
},
|
|
sources: {
|
|
list: (tenantId: string) =>
|
|
request<Record<string, unknown>[]>("/api/v1/contacts/sources", { tenantId }),
|
|
create: (tenantId: string, body: Record<string, unknown>) =>
|
|
request<Record<string, unknown>>("/api/v1/contacts/sources", {
|
|
tenantId,
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
},
|
|
resolve: (tenantId: string, body: Record<string, unknown>) =>
|
|
request<Record<string, unknown>[]>("/api/v1/contacts/resolve", {
|
|
tenantId,
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
},
|
|
|
|
messages: {
|
|
send: (tenantId: string, body: Record<string, unknown>) =>
|
|
request<Record<string, unknown>[]>("/api/v1/messages/send", {
|
|
tenantId,
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
list: (tenantId: string, params?: CommunicationListParams) =>
|
|
request<Record<string, unknown>[]>(`/api/v1/messages${qs(params)}`, { tenantId }),
|
|
get: (tenantId: string, id: string) =>
|
|
request<Record<string, unknown>>(`/api/v1/messages/${id}`, { tenantId }),
|
|
stats: (tenantId: string) =>
|
|
request<Record<string, unknown>>("/api/v1/messages/stats", { tenantId }),
|
|
timeline: (tenantId: string, id: string) =>
|
|
request<Record<string, unknown>[]>(`/api/v1/messages/${id}/timeline`, { tenantId }),
|
|
cancel: (tenantId: string, id: string) =>
|
|
request<Record<string, unknown>>(`/api/v1/messages/${id}/cancel`, {
|
|
tenantId,
|
|
method: "POST",
|
|
}),
|
|
queue: {
|
|
process: (tenantId: string, limit = 20) =>
|
|
request<{ processed: number }>(`/api/v1/messages/queue/process${qs({ limit })}`, {
|
|
tenantId,
|
|
method: "POST",
|
|
}),
|
|
deadLetters: (tenantId: string) =>
|
|
request<Record<string, unknown>[]>("/api/v1/messages/queue/dead-letters", { tenantId }),
|
|
stats: (tenantId: string) =>
|
|
request<Record<string, unknown>>("/api/v1/messages/queue/stats", { tenantId }),
|
|
},
|
|
},
|
|
|
|
otp: {
|
|
request: (tenantId: string, body: Record<string, unknown>) =>
|
|
request<Record<string, unknown>>("/api/v1/otp/request", {
|
|
tenantId,
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
verify: (tenantId: string, body: Record<string, unknown>) =>
|
|
request<Record<string, unknown>>("/api/v1/otp/verify", {
|
|
tenantId,
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
},
|
|
|
|
monitoring: {
|
|
stats: (tenantId: string) =>
|
|
request<MonitoringStats>("/api/v1/monitoring/stats", { tenantId }),
|
|
metrics: (tenantId: string) =>
|
|
request<MonitoringMetrics>("/api/v1/monitoring/metrics", { tenantId }),
|
|
},
|
|
};
|
|
|
|
export function normalizeCapabilities(raw: CapabilitiesRawResponse) {
|
|
const features: Record<string, boolean> = {};
|
|
for (const f of raw.features ?? []) features[f] = true;
|
|
for (const ch of raw.channels ?? []) {
|
|
features[`channel_${ch.channel}`] = ch.status === "active";
|
|
}
|
|
return {
|
|
...raw,
|
|
commercial_product: "Torbat Communication",
|
|
phase: "8",
|
|
features,
|
|
};
|
|
}
|