/** * CRM Service API client — real backend only (Phase 6.0–6.3). * Browser → /api/crm BFF; SSR → direct upstream. */ import { getValidAccessToken, refreshSession } from "@/lib/auth"; import type { Attachment, AuditEntry, CallLog, Comment, Contact, CustomField, Forecast, Goal, HealthResponse, Lead, ListParams, LookupRead, Meeting, Mention, Note, Opportunity, Organization, Pipeline, PipelineStage, Playbook, Quote, SalesActivity, Tag, Target, Task, TeamMember, TimelineEvent, } from "@/modules/crm/types"; const CRM_BASE = typeof window !== "undefined" ? "/api/crm" : process.env.CRM_SERVICE_URL || process.env.NEXT_PUBLIC_CRM_API_URL || "http://localhost:8003"; export class CrmApiError extends Error { constructor( public status: number, public code: string, message: string ) { super(message); this.name = "CrmApiError"; } } type RequestOptions = RequestInit & { tenantId: string; auth?: boolean }; async function request(path: string, options: RequestOptions): Promise { 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 : `${CRM_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(`اتصال به سرویس CRM برقرار نشد (${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 CrmApiError( 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; } function qs(params?: ListParams) { 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}` : ""; } function apiPath(segment: string) { return `/api/v1${segment.startsWith("/") ? segment : `/${segment}`}`; } export const crmApi = { health: () => request("/health", { tenantId: "00000000-0000-0000-0000-000000000000", auth: false, }), leads: { list: (tenantId: string, params?: ListParams) => request(`${apiPath("/leads")}${qs(params)}`, { tenantId }), get: (tenantId: string, id: string) => request(apiPath(`/leads/${id}`), { tenantId }), create: (tenantId: string, body: Record) => request(apiPath("/leads"), { tenantId, method: "POST", body: JSON.stringify(body) }), update: (tenantId: string, id: string, body: Record) => request(apiPath(`/leads/${id}`), { tenantId, method: "PATCH", body: JSON.stringify(body), }), assign: (tenantId: string, id: string, body: { assigned_user_id: string; note?: string }) => request(apiPath(`/leads/${id}/assign`), { tenantId, method: "POST", body: JSON.stringify(body), }), delete: (tenantId: string, id: string) => request(apiPath(`/leads/${id}/delete`), { tenantId, method: "POST" }), restore: (tenantId: string, id: string) => request(apiPath(`/leads/${id}/restore`), { tenantId, method: "POST" }), }, contacts: { list: (tenantId: string, params?: ListParams) => request(`${apiPath("/contacts")}${qs(params)}`, { tenantId }), get: (tenantId: string, id: string) => request(apiPath(`/contacts/${id}`), { tenantId }), create: (tenantId: string, body: Record) => request(apiPath("/contacts"), { tenantId, method: "POST", body: JSON.stringify(body), }), update: (tenantId: string, id: string, body: Record) => request(apiPath(`/contacts/${id}`), { tenantId, method: "PATCH", body: JSON.stringify(body), }), delete: (tenantId: string, id: string) => request(apiPath(`/contacts/${id}/delete`), { tenantId, method: "POST" }), }, organizations: { list: (tenantId: string, params?: ListParams) => request(`${apiPath("/organizations")}${qs(params)}`, { tenantId }), get: (tenantId: string, id: string) => request(apiPath(`/organizations/${id}`), { tenantId }), create: (tenantId: string, body: Record) => request(apiPath("/organizations"), { tenantId, method: "POST", body: JSON.stringify(body), }), update: (tenantId: string, id: string, body: Record) => request(apiPath(`/organizations/${id}`), { tenantId, method: "PATCH", body: JSON.stringify(body), }), delete: (tenantId: string, id: string) => request(apiPath(`/organizations/${id}/delete`), { tenantId, method: "POST", }), }, opportunities: { list: (tenantId: string, params?: ListParams) => request(`${apiPath("/opportunities")}${qs(params)}`, { tenantId }), get: (tenantId: string, id: string) => request(apiPath(`/opportunities/${id}`), { tenantId }), create: (tenantId: string, body: Record) => request(apiPath("/opportunities"), { tenantId, method: "POST", body: JSON.stringify(body), }), update: (tenantId: string, id: string, body: Record) => request(apiPath(`/opportunities/${id}`), { tenantId, method: "PATCH", body: JSON.stringify(body), }), changeStage: (tenantId: string, id: string, body: { stage_id: string; note?: string }) => request(apiPath(`/opportunities/${id}/stage`), { tenantId, method: "POST", body: JSON.stringify(body), }), win: (tenantId: string, id: string, body?: Record) => request(apiPath(`/opportunities/${id}/win`), { tenantId, method: "POST", body: JSON.stringify(body ?? {}), }), lose: (tenantId: string, id: string, body: Record) => request(apiPath(`/opportunities/${id}/lose`), { tenantId, method: "POST", body: JSON.stringify(body), }), stageHistory: (tenantId: string, id: string) => request[]>(apiPath(`/opportunities/${id}/stage-history`), { tenantId, }), }, pipelines: { list: (tenantId: string, params?: ListParams) => request(`${apiPath("/pipelines")}${qs(params)}`, { tenantId }), get: (tenantId: string, id: string) => request(apiPath(`/pipelines/${id}`), { tenantId }), create: (tenantId: string, body: Record) => request(apiPath("/pipelines"), { tenantId, method: "POST", body: JSON.stringify(body), }), update: (tenantId: string, id: string, body: Record) => request(apiPath(`/pipelines/${id}`), { tenantId, method: "PATCH", body: JSON.stringify(body), }), stages: (tenantId: string, pipelineId: string, params?: ListParams) => request( `${apiPath(`/pipelines/${pipelineId}/stages`)}${qs(params)}`, { tenantId } ), createStage: (tenantId: string, body: Record) => request(apiPath("/pipelines/stages"), { tenantId, method: "POST", body: JSON.stringify(body), }), updateStage: (tenantId: string, stageId: string, body: Record) => request(apiPath(`/pipelines/stages/${stageId}`), { tenantId, method: "PATCH", body: JSON.stringify(body), }), getStage: (tenantId: string, stageId: string) => request(apiPath(`/pipelines/stages/${stageId}`), { tenantId }), }, activities: { list: (tenantId: string, params?: ListParams) => request(`${apiPath("/activities")}${qs(params)}`, { tenantId }), get: (tenantId: string, id: string) => request(apiPath(`/activities/${id}`), { tenantId }), create: (tenantId: string, body: Record) => request(apiPath("/activities"), { tenantId, method: "POST", body: JSON.stringify(body), }), update: (tenantId: string, id: string, body: Record) => request(apiPath(`/activities/${id}`), { tenantId, method: "PATCH", body: JSON.stringify(body), }), complete: (tenantId: string, id: string) => request(apiPath(`/activities/${id}/complete`), { tenantId, method: "POST", }), }, quotes: { list: (tenantId: string, params?: ListParams) => request(`${apiPath("/quotes")}${qs(params)}`, { tenantId }), get: (tenantId: string, id: string) => request(apiPath(`/quotes/${id}`), { tenantId }), create: (tenantId: string, body: Record) => request(apiPath("/quotes"), { tenantId, method: "POST", body: JSON.stringify(body), }), update: (tenantId: string, id: string, body: Record) => request(apiPath(`/quotes/${id}`), { tenantId, method: "PATCH", body: JSON.stringify(body), }), }, tags: { list: (tenantId: string, params?: ListParams) => request(`${apiPath("/tags")}${qs(params)}`, { tenantId }), create: (tenantId: string, body: Record) => request(apiPath("/tags"), { tenantId, method: "POST", body: JSON.stringify(body) }), assignLead: (tenantId: string, leadId: string, tagId: string) => request>(apiPath(`/tags/leads/${leadId}`), { tenantId, method: "POST", body: JSON.stringify({ tag_id: tagId }), }), assignContact: (tenantId: string, contactId: string, tagId: string) => request>(apiPath(`/tags/contacts/${contactId}`), { tenantId, method: "POST", body: JSON.stringify({ tag_id: tagId }), }), assignOrganization: (tenantId: string, orgId: string, tagId: string) => request>(apiPath(`/tags/organizations/${orgId}`), { tenantId, method: "POST", body: JSON.stringify({ tag_id: tagId }), }), }, notes: { list: (tenantId: string, entityType: string, entityId: string) => request( `${apiPath("/notes")}?entity_type=${entityType}&entity_id=${entityId}`, { tenantId } ), create: (tenantId: string, body: Record) => request(apiPath("/notes"), { tenantId, method: "POST", body: JSON.stringify(body) }), update: (tenantId: string, id: string, body: Record) => request(apiPath(`/notes/${id}`), { tenantId, method: "PATCH", body: JSON.stringify(body), }), }, attachments: { list: (tenantId: string, entityType: string, entityId: string) => request( `${apiPath("/attachments")}?entity_type=${entityType}&entity_id=${entityId}`, { tenantId } ), create: (tenantId: string, body: Record) => request(apiPath("/attachments"), { tenantId, method: "POST", body: JSON.stringify(body), }), }, customFields: { list: (tenantId: string, entityType: string) => request(`${apiPath("/custom-fields")}?entity_type=${entityType}`, { tenantId, }), create: (tenantId: string, body: Record) => request(apiPath("/custom-fields"), { tenantId, method: "POST", body: JSON.stringify(body), }), upsertValue: (tenantId: string, body: Record) => request>(apiPath("/custom-fields/values"), { tenantId, method: "PUT", body: JSON.stringify(body), }), }, audit: { list: (tenantId: string, entityType: string, entityId: string) => request( `${apiPath("/audit")}?entity_type=${entityType}&entity_id=${entityId}`, { tenantId } ), }, lookups: { leadSources: { list: (tenantId: string, params?: ListParams) => request(`${apiPath("/lookups/lead-sources")}${qs(params)}`, { tenantId }), create: (tenantId: string, body: Record) => request(apiPath("/lookups/lead-sources"), { tenantId, method: "POST", body: JSON.stringify(body), }), }, leadStatuses: { list: (tenantId: string, params?: ListParams) => request(`${apiPath("/lookups/lead-statuses")}${qs(params)}`, { tenantId }), create: (tenantId: string, body: Record) => request(apiPath("/lookups/lead-statuses"), { tenantId, method: "POST", body: JSON.stringify(body), }), }, contactTypes: { list: (tenantId: string, params?: ListParams) => request(`${apiPath("/lookups/contact-types")}${qs(params)}`, { tenantId }), create: (tenantId: string, body: Record) => request(apiPath("/lookups/contact-types"), { tenantId, method: "POST", body: JSON.stringify(body), }), }, organizationTypes: { list: (tenantId: string, params?: ListParams) => request( `${apiPath("/lookups/organization-types")}${qs(params)}`, { tenantId } ), create: (tenantId: string, body: Record) => request(apiPath("/lookups/organization-types"), { tenantId, method: "POST", body: JSON.stringify(body), }), }, organizationIndustries: { list: (tenantId: string, params?: ListParams) => request( `${apiPath("/lookups/organization-industries")}${qs(params)}`, { tenantId } ), create: (tenantId: string, body: Record) => request(apiPath("/lookups/organization-industries"), { tenantId, method: "POST", body: JSON.stringify(body), }), }, }, tasks: { list: (tenantId: string, params?: ListParams) => request(`${apiPath("/tasks")}${qs(params)}`, { tenantId }), create: (tenantId: string, body: Record) => request(apiPath("/tasks"), { tenantId, method: "POST", body: JSON.stringify(body) }), complete: (tenantId: string, id: string) => request(apiPath(`/tasks/${id}/complete`), { tenantId, method: "POST" }), createChecklistItem: (tenantId: string, body: Record) => request>(apiPath("/tasks/checklist"), { tenantId, method: "POST", body: JSON.stringify(body), }), completeChecklistItem: (tenantId: string, itemId: string) => request>(apiPath(`/tasks/checklist/${itemId}/complete`), { tenantId, method: "POST", }), }, meetings: { list: (tenantId: string, params?: ListParams) => request(`${apiPath("/meetings")}${qs(params)}`, { tenantId }), create: (tenantId: string, body: Record) => request(apiPath("/meetings"), { tenantId, method: "POST", body: JSON.stringify(body), }), finish: (tenantId: string, id: string, body?: Record) => request(apiPath(`/meetings/${id}/finish`), { tenantId, method: "POST", body: JSON.stringify(body ?? {}), }), addAttendee: (tenantId: string, body: Record) => request>(apiPath("/meetings/attendees"), { tenantId, method: "POST", body: JSON.stringify(body), }), }, calls: { list: (tenantId: string, params?: ListParams) => request(`${apiPath("/calls")}${qs(params)}`, { tenantId }), create: (tenantId: string, body: Record) => request(apiPath("/calls"), { tenantId, method: "POST", body: JSON.stringify(body) }), }, emails: { createThread: (tenantId: string, body: Record) => request>(apiPath("/email-threads"), { tenantId, method: "POST", body: JSON.stringify(body), }), createMessage: (tenantId: string, body: Record) => request>(apiPath("/emails"), { tenantId, method: "POST", body: JSON.stringify(body), }), }, timeline: { list: (tenantId: string, params?: ListParams) => request(`${apiPath("/timeline")}${qs(params)}`, { tenantId }), forOpportunity: (tenantId: string, opportunityId: string) => request(apiPath(`/timeline/opportunities/${opportunityId}`), { tenantId }), }, comments: { list: (tenantId: string, entityType: string, entityId: string) => request( `${apiPath("/comments")}?entity_type=${entityType}&entity_id=${entityId}`, { tenantId } ), create: (tenantId: string, body: Record) => request(apiPath("/comments"), { tenantId, method: "POST", body: JSON.stringify(body), }), update: (tenantId: string, id: string, body: Record) => request(apiPath(`/comments/${id}`), { tenantId, method: "PATCH", body: JSON.stringify(body), }), }, mentions: { unread: (tenantId: string) => request(apiPath("/mentions/unread"), { tenantId }), markRead: (tenantId: string, id: string) => request(apiPath(`/mentions/${id}/read`), { tenantId, method: "POST" }), }, team: { members: { list: (tenantId: string, params?: ListParams) => request(`${apiPath("/team-members")}${qs(params)}`, { tenantId }), create: (tenantId: string, body: Record) => request(apiPath("/team-members"), { tenantId, method: "POST", body: JSON.stringify(body), }), }, roles: { create: (tenantId: string, body: Record) => request>(apiPath("/team-roles"), { tenantId, method: "POST", body: JSON.stringify(body), }), }, assignments: { create: (tenantId: string, body: Record) => request>(apiPath("/team-assignments"), { tenantId, method: "POST", body: JSON.stringify(body), }), }, }, bookmarks: { list: (tenantId: string, params?: ListParams) => request[]>(`${apiPath("/bookmarks")}${qs(params)}`, { tenantId }), create: (tenantId: string, body: Record) => request>(apiPath("/bookmarks"), { tenantId, method: "POST", body: JSON.stringify(body), }), }, favorites: { create: (tenantId: string, body: Record) => request>(apiPath("/favorites"), { tenantId, method: "POST", body: JSON.stringify(body), }), }, playbooks: { list: (tenantId: string, params?: ListParams) => request(`${apiPath("/playbooks")}${qs(params)}`, { tenantId }), create: (tenantId: string, body: Record) => request(apiPath("/playbooks"), { tenantId, method: "POST", body: JSON.stringify(body), }), steps: (tenantId: string, playbookId: string) => request[]>(apiPath(`/playbooks/${playbookId}/steps`), { tenantId }), createStep: (tenantId: string, body: Record) => request>(apiPath("/playbooks/steps"), { tenantId, method: "POST", body: JSON.stringify(body), }), assign: (tenantId: string, body: Record) => request>(apiPath("/playbooks/assignments"), { tenantId, method: "POST", body: JSON.stringify(body), }), completeStep: (tenantId: string, assignmentId: string, body: Record) => request>( apiPath(`/playbooks/assignments/${assignmentId}/complete-step`), { tenantId, method: "POST", body: JSON.stringify(body) } ), }, forecasts: { list: (tenantId: string, params?: ListParams) => request(`${apiPath("/forecasts")}${qs(params)}`, { tenantId }), compute: (tenantId: string, body: Record) => request(apiPath("/forecasts/compute"), { tenantId, method: "POST", body: JSON.stringify(body), }), }, goals: { list: (tenantId: string, params?: ListParams) => request(`${apiPath("/goals")}${qs(params)}`, { tenantId }), create: (tenantId: string, body: Record) => request(apiPath("/goals"), { tenantId, method: "POST", body: JSON.stringify(body) }), }, targets: { list: (tenantId: string, params?: ListParams) => request(`${apiPath("/targets")}${qs(params)}`, { tenantId }), create: (tenantId: string, body: Record) => request(apiPath("/targets"), { tenantId, method: "POST", body: JSON.stringify(body), }), }, salesProcesses: { list: (tenantId: string, params?: ListParams) => request[]>(`${apiPath("/sales-processes")}${qs(params)}`, { tenantId, }), create: (tenantId: string, body: Record) => request>(apiPath("/sales-processes"), { tenantId, method: "POST", body: JSON.stringify(body), }), }, winLoss: { categories: { list: (tenantId: string, params?: ListParams) => request[]>( `${apiPath("/win-loss/categories")}${qs(params)}`, { tenantId } ), create: (tenantId: string, body: Record) => request>(apiPath("/win-loss/categories"), { tenantId, method: "POST", body: JSON.stringify(body), }), }, reasons: { list: (tenantId: string, params?: ListParams) => request[]>( `${apiPath("/win-loss/reasons")}${qs(params)}`, { tenantId } ), create: (tenantId: string, body: Record) => request>(apiPath("/win-loss/reasons"), { tenantId, method: "POST", body: JSON.stringify(body), }), }, forOpportunity: (tenantId: string, opportunityId: string) => request>( apiPath(`/win-loss/opportunities/${opportunityId}`), { tenantId } ), }, collaborationPreferences: { upsert: (tenantId: string, userId: string, body: Record) => request>(apiPath(`/collaboration-preferences/${userId}`), { tenantId, method: "PUT", body: JSON.stringify(body), }), }, }; /** Fetch all pages client-side (backend has no total count). */ export async function crmFetchAll( fetchPage: (params: ListParams) => Promise, pageSize = 500 ): Promise { const all: T[] = []; let page = 1; for (;;) { const batch = await fetchPage({ page, page_size: pageSize }); all.push(...batch); if (batch.length < pageSize) break; page += 1; if (page > 50) break; } return all; }