TorbatYar/frontend/modules/crm/services/crm-api.ts
Mortezakoohjani e140908034 Ship enterprise CRM frontend with real API wiring and thin app routes.
Connect all CRM pages to the BFF and CRM service, add module docs, and mark CRM available in the SuperApp catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 20:58:17 +03:30

735 lines
26 KiB
TypeScript
Raw Permalink 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.

/**
* CRM Service API client — real backend only (Phase 6.06.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<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
: `${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<T>;
}
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<HealthResponse>("/health", {
tenantId: "00000000-0000-0000-0000-000000000000",
auth: false,
}),
leads: {
list: (tenantId: string, params?: ListParams) =>
request<Lead[]>(`${apiPath("/leads")}${qs(params)}`, { tenantId }),
get: (tenantId: string, id: string) =>
request<Lead>(apiPath(`/leads/${id}`), { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Lead>(apiPath("/leads"), { tenantId, method: "POST", body: JSON.stringify(body) }),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Lead>(apiPath(`/leads/${id}`), {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
assign: (tenantId: string, id: string, body: { assigned_user_id: string; note?: string }) =>
request<Lead>(apiPath(`/leads/${id}/assign`), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
delete: (tenantId: string, id: string) =>
request<Lead>(apiPath(`/leads/${id}/delete`), { tenantId, method: "POST" }),
restore: (tenantId: string, id: string) =>
request<Lead>(apiPath(`/leads/${id}/restore`), { tenantId, method: "POST" }),
},
contacts: {
list: (tenantId: string, params?: ListParams) =>
request<Contact[]>(`${apiPath("/contacts")}${qs(params)}`, { tenantId }),
get: (tenantId: string, id: string) =>
request<Contact>(apiPath(`/contacts/${id}`), { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Contact>(apiPath("/contacts"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Contact>(apiPath(`/contacts/${id}`), {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
delete: (tenantId: string, id: string) =>
request<Contact>(apiPath(`/contacts/${id}/delete`), { tenantId, method: "POST" }),
},
organizations: {
list: (tenantId: string, params?: ListParams) =>
request<Organization[]>(`${apiPath("/organizations")}${qs(params)}`, { tenantId }),
get: (tenantId: string, id: string) =>
request<Organization>(apiPath(`/organizations/${id}`), { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Organization>(apiPath("/organizations"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Organization>(apiPath(`/organizations/${id}`), {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
delete: (tenantId: string, id: string) =>
request<Organization>(apiPath(`/organizations/${id}/delete`), {
tenantId,
method: "POST",
}),
},
opportunities: {
list: (tenantId: string, params?: ListParams) =>
request<Opportunity[]>(`${apiPath("/opportunities")}${qs(params)}`, { tenantId }),
get: (tenantId: string, id: string) =>
request<Opportunity>(apiPath(`/opportunities/${id}`), { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Opportunity>(apiPath("/opportunities"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Opportunity>(apiPath(`/opportunities/${id}`), {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
changeStage: (tenantId: string, id: string, body: { stage_id: string; note?: string }) =>
request<Opportunity>(apiPath(`/opportunities/${id}/stage`), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
win: (tenantId: string, id: string, body?: Record<string, unknown>) =>
request<Opportunity>(apiPath(`/opportunities/${id}/win`), {
tenantId,
method: "POST",
body: JSON.stringify(body ?? {}),
}),
lose: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Opportunity>(apiPath(`/opportunities/${id}/lose`), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
stageHistory: (tenantId: string, id: string) =>
request<Record<string, unknown>[]>(apiPath(`/opportunities/${id}/stage-history`), {
tenantId,
}),
},
pipelines: {
list: (tenantId: string, params?: ListParams) =>
request<Pipeline[]>(`${apiPath("/pipelines")}${qs(params)}`, { tenantId }),
get: (tenantId: string, id: string) =>
request<Pipeline>(apiPath(`/pipelines/${id}`), { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Pipeline>(apiPath("/pipelines"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Pipeline>(apiPath(`/pipelines/${id}`), {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
stages: (tenantId: string, pipelineId: string, params?: ListParams) =>
request<PipelineStage[]>(
`${apiPath(`/pipelines/${pipelineId}/stages`)}${qs(params)}`,
{ tenantId }
),
createStage: (tenantId: string, body: Record<string, unknown>) =>
request<PipelineStage>(apiPath("/pipelines/stages"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
updateStage: (tenantId: string, stageId: string, body: Record<string, unknown>) =>
request<PipelineStage>(apiPath(`/pipelines/stages/${stageId}`), {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
getStage: (tenantId: string, stageId: string) =>
request<PipelineStage>(apiPath(`/pipelines/stages/${stageId}`), { tenantId }),
},
activities: {
list: (tenantId: string, params?: ListParams) =>
request<SalesActivity[]>(`${apiPath("/activities")}${qs(params)}`, { tenantId }),
get: (tenantId: string, id: string) =>
request<SalesActivity>(apiPath(`/activities/${id}`), { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<SalesActivity>(apiPath("/activities"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<SalesActivity>(apiPath(`/activities/${id}`), {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
complete: (tenantId: string, id: string) =>
request<SalesActivity>(apiPath(`/activities/${id}/complete`), {
tenantId,
method: "POST",
}),
},
quotes: {
list: (tenantId: string, params?: ListParams) =>
request<Quote[]>(`${apiPath("/quotes")}${qs(params)}`, { tenantId }),
get: (tenantId: string, id: string) =>
request<Quote>(apiPath(`/quotes/${id}`), { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Quote>(apiPath("/quotes"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Quote>(apiPath(`/quotes/${id}`), {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
},
tags: {
list: (tenantId: string, params?: ListParams) =>
request<Tag[]>(`${apiPath("/tags")}${qs(params)}`, { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Tag>(apiPath("/tags"), { tenantId, method: "POST", body: JSON.stringify(body) }),
assignLead: (tenantId: string, leadId: string, tagId: string) =>
request<Record<string, unknown>>(apiPath(`/tags/leads/${leadId}`), {
tenantId,
method: "POST",
body: JSON.stringify({ tag_id: tagId }),
}),
assignContact: (tenantId: string, contactId: string, tagId: string) =>
request<Record<string, unknown>>(apiPath(`/tags/contacts/${contactId}`), {
tenantId,
method: "POST",
body: JSON.stringify({ tag_id: tagId }),
}),
assignOrganization: (tenantId: string, orgId: string, tagId: string) =>
request<Record<string, unknown>>(apiPath(`/tags/organizations/${orgId}`), {
tenantId,
method: "POST",
body: JSON.stringify({ tag_id: tagId }),
}),
},
notes: {
list: (tenantId: string, entityType: string, entityId: string) =>
request<Note[]>(
`${apiPath("/notes")}?entity_type=${entityType}&entity_id=${entityId}`,
{ tenantId }
),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Note>(apiPath("/notes"), { tenantId, method: "POST", body: JSON.stringify(body) }),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Note>(apiPath(`/notes/${id}`), {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
},
attachments: {
list: (tenantId: string, entityType: string, entityId: string) =>
request<Attachment[]>(
`${apiPath("/attachments")}?entity_type=${entityType}&entity_id=${entityId}`,
{ tenantId }
),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Attachment>(apiPath("/attachments"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
customFields: {
list: (tenantId: string, entityType: string) =>
request<CustomField[]>(`${apiPath("/custom-fields")}?entity_type=${entityType}`, {
tenantId,
}),
create: (tenantId: string, body: Record<string, unknown>) =>
request<CustomField>(apiPath("/custom-fields"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
upsertValue: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(apiPath("/custom-fields/values"), {
tenantId,
method: "PUT",
body: JSON.stringify(body),
}),
},
audit: {
list: (tenantId: string, entityType: string, entityId: string) =>
request<AuditEntry[]>(
`${apiPath("/audit")}?entity_type=${entityType}&entity_id=${entityId}`,
{ tenantId }
),
},
lookups: {
leadSources: {
list: (tenantId: string, params?: ListParams) =>
request<LookupRead[]>(`${apiPath("/lookups/lead-sources")}${qs(params)}`, { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<LookupRead>(apiPath("/lookups/lead-sources"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
leadStatuses: {
list: (tenantId: string, params?: ListParams) =>
request<LookupRead[]>(`${apiPath("/lookups/lead-statuses")}${qs(params)}`, { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<LookupRead>(apiPath("/lookups/lead-statuses"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
contactTypes: {
list: (tenantId: string, params?: ListParams) =>
request<LookupRead[]>(`${apiPath("/lookups/contact-types")}${qs(params)}`, { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<LookupRead>(apiPath("/lookups/contact-types"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
organizationTypes: {
list: (tenantId: string, params?: ListParams) =>
request<LookupRead[]>(
`${apiPath("/lookups/organization-types")}${qs(params)}`,
{ tenantId }
),
create: (tenantId: string, body: Record<string, unknown>) =>
request<LookupRead>(apiPath("/lookups/organization-types"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
organizationIndustries: {
list: (tenantId: string, params?: ListParams) =>
request<LookupRead[]>(
`${apiPath("/lookups/organization-industries")}${qs(params)}`,
{ tenantId }
),
create: (tenantId: string, body: Record<string, unknown>) =>
request<LookupRead>(apiPath("/lookups/organization-industries"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
},
tasks: {
list: (tenantId: string, params?: ListParams) =>
request<Task[]>(`${apiPath("/tasks")}${qs(params)}`, { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Task>(apiPath("/tasks"), { tenantId, method: "POST", body: JSON.stringify(body) }),
complete: (tenantId: string, id: string) =>
request<Task>(apiPath(`/tasks/${id}/complete`), { tenantId, method: "POST" }),
createChecklistItem: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(apiPath("/tasks/checklist"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
completeChecklistItem: (tenantId: string, itemId: string) =>
request<Record<string, unknown>>(apiPath(`/tasks/checklist/${itemId}/complete`), {
tenantId,
method: "POST",
}),
},
meetings: {
list: (tenantId: string, params?: ListParams) =>
request<Meeting[]>(`${apiPath("/meetings")}${qs(params)}`, { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Meeting>(apiPath("/meetings"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
finish: (tenantId: string, id: string, body?: Record<string, unknown>) =>
request<Meeting>(apiPath(`/meetings/${id}/finish`), {
tenantId,
method: "POST",
body: JSON.stringify(body ?? {}),
}),
addAttendee: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(apiPath("/meetings/attendees"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
calls: {
list: (tenantId: string, params?: ListParams) =>
request<CallLog[]>(`${apiPath("/calls")}${qs(params)}`, { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<CallLog>(apiPath("/calls"), { tenantId, method: "POST", body: JSON.stringify(body) }),
},
emails: {
createThread: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(apiPath("/email-threads"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
createMessage: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(apiPath("/emails"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
timeline: {
list: (tenantId: string, params?: ListParams) =>
request<TimelineEvent[]>(`${apiPath("/timeline")}${qs(params)}`, { tenantId }),
forOpportunity: (tenantId: string, opportunityId: string) =>
request<TimelineEvent[]>(apiPath(`/timeline/opportunities/${opportunityId}`), { tenantId }),
},
comments: {
list: (tenantId: string, entityType: string, entityId: string) =>
request<Comment[]>(
`${apiPath("/comments")}?entity_type=${entityType}&entity_id=${entityId}`,
{ tenantId }
),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Comment>(apiPath("/comments"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
update: (tenantId: string, id: string, body: Record<string, unknown>) =>
request<Comment>(apiPath(`/comments/${id}`), {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
},
mentions: {
unread: (tenantId: string) =>
request<Mention[]>(apiPath("/mentions/unread"), { tenantId }),
markRead: (tenantId: string, id: string) =>
request<Mention>(apiPath(`/mentions/${id}/read`), { tenantId, method: "POST" }),
},
team: {
members: {
list: (tenantId: string, params?: ListParams) =>
request<TeamMember[]>(`${apiPath("/team-members")}${qs(params)}`, { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<TeamMember>(apiPath("/team-members"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
roles: {
create: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(apiPath("/team-roles"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
assignments: {
create: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(apiPath("/team-assignments"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
},
bookmarks: {
list: (tenantId: string, params?: ListParams) =>
request<Record<string, unknown>[]>(`${apiPath("/bookmarks")}${qs(params)}`, { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(apiPath("/bookmarks"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
favorites: {
create: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(apiPath("/favorites"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
playbooks: {
list: (tenantId: string, params?: ListParams) =>
request<Playbook[]>(`${apiPath("/playbooks")}${qs(params)}`, { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Playbook>(apiPath("/playbooks"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
steps: (tenantId: string, playbookId: string) =>
request<Record<string, unknown>[]>(apiPath(`/playbooks/${playbookId}/steps`), { tenantId }),
createStep: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(apiPath("/playbooks/steps"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
assign: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(apiPath("/playbooks/assignments"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
completeStep: (tenantId: string, assignmentId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(
apiPath(`/playbooks/assignments/${assignmentId}/complete-step`),
{ tenantId, method: "POST", body: JSON.stringify(body) }
),
},
forecasts: {
list: (tenantId: string, params?: ListParams) =>
request<Forecast[]>(`${apiPath("/forecasts")}${qs(params)}`, { tenantId }),
compute: (tenantId: string, body: Record<string, unknown>) =>
request<Forecast>(apiPath("/forecasts/compute"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
goals: {
list: (tenantId: string, params?: ListParams) =>
request<Goal[]>(`${apiPath("/goals")}${qs(params)}`, { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Goal>(apiPath("/goals"), { tenantId, method: "POST", body: JSON.stringify(body) }),
},
targets: {
list: (tenantId: string, params?: ListParams) =>
request<Target[]>(`${apiPath("/targets")}${qs(params)}`, { tenantId }),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Target>(apiPath("/targets"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
salesProcesses: {
list: (tenantId: string, params?: ListParams) =>
request<Record<string, unknown>[]>(`${apiPath("/sales-processes")}${qs(params)}`, {
tenantId,
}),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(apiPath("/sales-processes"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
winLoss: {
categories: {
list: (tenantId: string, params?: ListParams) =>
request<Record<string, unknown>[]>(
`${apiPath("/win-loss/categories")}${qs(params)}`,
{ tenantId }
),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(apiPath("/win-loss/categories"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
reasons: {
list: (tenantId: string, params?: ListParams) =>
request<Record<string, unknown>[]>(
`${apiPath("/win-loss/reasons")}${qs(params)}`,
{ tenantId }
),
create: (tenantId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(apiPath("/win-loss/reasons"), {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
forOpportunity: (tenantId: string, opportunityId: string) =>
request<Record<string, unknown>>(
apiPath(`/win-loss/opportunities/${opportunityId}`),
{ tenantId }
),
},
collaborationPreferences: {
upsert: (tenantId: string, userId: string, body: Record<string, unknown>) =>
request<Record<string, unknown>>(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<T>(
fetchPage: (params: ListParams) => Promise<T[]>,
pageSize = 500
): Promise<T[]> {
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;
}