// Cross-origin API client. All requests send the session cookie (connect.sid) // via credentials: "include". The API origin comes from NEXT_PUBLIC_API_ORIGIN. import type { AuthUser, BalanceFilter, BalanceListResponse, BalanceSort, BankAccount, BankCleared, BankDirection, BankFacets, BankInstitution, BankListResponse, BankSort, BankStats, BankSummary, BatchCreateInput, ConfirmBatchInput, ConfirmBatchResult, BatchCreateResponse, BillingFacets, BillingStats, BusinessLine, ByCheckResponse, CreateBankAccountInput, CreateBankInput, CreateBankMovementInput, CreateMovementInput, DiscardBatchResult, UpdateBankAccountInput, ResolveOutstandingInput, ReviewDocumentInput, StatementBatch, StatementBatchDetail, StatementDocument, StatementDocumentStatus, CustomerDetail, CustomerInput, CustomerListResponse, CustomerStats, LedgerCurrency, LedgerDirection, MovementListResponse, MovementSort, PolicyDetail, PolicyFacets, PolicyInput, PolicyListResponse, PolicySort, PolicyStats, PolicyStatus, PolicyOcrBatch, PolicyOcrBatchDetail, PolicyOcrDocument, PolicyOcrReviewInput, PolicyOcrConfirmInput, PolicyOcrConfirmResult, LookupsResponse, OpsJob, OpsJobKind, ReplicationStatus, VerifyResult, IngestFile, BackupFile, PropertyDetail, PropertyFacets, PropertyInput, PropertyListResponse, PropertySort, PropertyStats, ReportCatalog, ReportRunResult, ServiceInput, TrustInput, Role, ServiceKind, Statement, Transaction, TransactionDomain, TrustFilter, UserRow, } from "./types"; // Resolve the API origin at runtime, not build time — so one built image serves // any deployment and the app follows the box when it moves (tailnet today, // 192.168.1.x office LAN later) with no config change. // // In the browser, derive the origin from the page's own location, the way a PHP // app would. An explicit API_ORIGIN (injected as window.__API_ORIGIN__ by the // root layout) still wins when a deployment genuinely splits the two hosts. // On the server (SSR) read process.env directly — a derived origin is // browser-only, and "/api" is not fetchable server-side. function resolveApiOrigin(): string { if (typeof window !== "undefined") { const injected = (window as { __API_ORIGIN__?: string }).__API_ORIGIN__; if (injected) return injected; const { protocol, hostname } = window.location; // Over TLS the API must share the page's origin or the browser blocks the // call as mixed active content. The reverse proxy maps /api to the API. if (protocol === "https:") return "/api"; // Plain HTTP: same host, API port. 3001 is the port the API container // publishes everywhere (deploy/galactus/jorgecuadros-app.compose.yml). return `http://${hostname}:3001`; } return ( process.env.API_ORIGIN ?? process.env.NEXT_PUBLIC_API_ORIGIN ?? "http://localhost:3001" ); } export const API_ORIGIN = resolveApiOrigin(); export class ApiError extends Error { status: number; constructor(status: number, message: string) { super(message); this.status = status; this.name = "ApiError"; } } export async function apiFetch( path: string, init?: RequestInit, ): Promise { let res: Response; try { res = await fetch(`${API_ORIGIN}${path}`, { credentials: "include", headers: { "Content-Type": "application/json", ...(init?.headers ?? {}), }, ...init, }); } catch (e) { throw new ApiError( 0, "No se pudo conectar con el servidor. Verifica tu conexión.", ); } if (!res.ok) { let message = `Error ${res.status}`; try { const body = await res.json(); if (body?.message) message = body.message; } catch { /* ignore non-JSON error bodies */ } throw new ApiError(res.status, message); } if (res.status === 204) return undefined as T; return (await res.json()) as T; } export function login(email: string, password: string): Promise { return apiFetch("/auth/login", { method: "POST", body: JSON.stringify({ email, password }), }); } export function me(): Promise { return apiFetch("/auth/me"); } /** Persist the caller's own text-size preference on their account. */ export function updateUiScale(uiScale: number): Promise { return apiFetch("/auth/preferences", { method: "PATCH", body: JSON.stringify({ uiScale }), }); } export function logout(): Promise<{ success: boolean }> { return apiFetch<{ success: boolean }>("/auth/logout", { method: "POST" }); } export interface ServiceVersion { service: string; version: string; gitSha: string; buildDate: string; } /** What the API container reports it is running. Unauthenticated by design. */ export function getApiVersion(): Promise { return apiFetch("/version"); } export function getStats(): Promise { return apiFetch("/customers/stats"); } export interface CustomerQuery { query?: string; page?: number; pageSize?: number; line?: BusinessLine; } export function listCustomers( q: CustomerQuery, ): Promise { const params = new URLSearchParams(); if (q.query) params.set("query", q.query); if (q.page) params.set("page", String(q.page)); if (q.pageSize) params.set("pageSize", String(q.pageSize)); if (q.line) params.set("line", q.line); const qs = params.toString(); return apiFetch(`/customers${qs ? `?${qs}` : ""}`); } export function getCustomer(id: string): Promise { return apiFetch(`/customers/${id}`); } export function createCustomer(input: CustomerInput): Promise { return apiFetch("/customers", { method: "POST", body: JSON.stringify(input), }); } export function updateCustomer( id: string, input: Partial, ): Promise { return apiFetch(`/customers/${id}`, { method: "PATCH", body: JSON.stringify(input), }); } export function archiveCustomer(id: string): Promise { return apiFetch(`/customers/${id}`, { method: "DELETE" }); } export function restoreCustomer(id: string): Promise { return apiFetch(`/customers/${id}/restore`, { method: "POST" }); } export interface NumidAllocation { numid: string; /** "existing" when the customer already had one — the call is idempotent. */ origin: "existing" | "new" | "recycled"; previousCustomerId?: string; } /** Give a customer the portal NUMid they log in to my.jorgecuadros.com with. */ export function grantPortalAccess(id: string): Promise { return apiFetch(`/customers/${id}/portal-access`, { method: "POST", }); } /* ------------------------------------------------------ Policies module */ /** Renewal horizon in days, shared by the list, stats and detail calls so the * "por vencer" bucket means the same thing everywhere. */ export const EXPIRY_WINDOW_DAYS = 30; export interface PolicyQuery { query?: string; page?: number; pageSize?: number; status?: PolicyStatus; days?: number; typeId?: string; providerId?: string; liquidated?: boolean; sort?: PolicySort; } export function listPolicies(q: PolicyQuery): Promise { const params = new URLSearchParams(); if (q.query) params.set("query", q.query); if (q.page) params.set("page", String(q.page)); if (q.pageSize) params.set("pageSize", String(q.pageSize)); if (q.status) params.set("status", q.status); if (q.days) params.set("days", String(q.days)); if (q.typeId) params.set("typeId", q.typeId); if (q.providerId) params.set("providerId", q.providerId); if (q.liquidated !== undefined) params.set("liquidated", String(q.liquidated)); if (q.sort) params.set("sort", q.sort); const qs = params.toString(); return apiFetch(`/policies${qs ? `?${qs}` : ""}`); } export function getPolicyStats( days: number = EXPIRY_WINDOW_DAYS, ): Promise { return apiFetch(`/policies/stats?days=${days}`); } export function getPolicyFacets(): Promise { return apiFetch("/policies/facets"); } export function getPolicy( id: string, days: number = EXPIRY_WINDOW_DAYS, ): Promise { return apiFetch(`/policies/${id}?days=${days}`); } export function createPolicy(input: PolicyInput): Promise { return apiFetch("/policies", { method: "POST", body: JSON.stringify(input), }); } export function updatePolicy( id: string, input: Partial, ): Promise { return apiFetch(`/policies/${id}`, { method: "PATCH", body: JSON.stringify(input), }); } export function archivePolicy(id: string): Promise { return apiFetch(`/policies/${id}`, { method: "DELETE" }); } export function restorePolicy(id: string): Promise { return apiFetch(`/policies/${id}/restore`, { method: "POST" }); } // Generic policy-child CRUD. `kind` is the URL segment // (installments|vehicles|drivers|beneficiaries|claims). export function addPolicyChild( policyId: string, kind: string, input: T, ): Promise { return apiFetch(`/policies/${policyId}/${kind}`, { method: "POST", body: JSON.stringify(input), }); } export function updatePolicyChild( policyId: string, kind: string, childId: string, input: T, ): Promise { return apiFetch(`/policies/${policyId}/${kind}/${childId}`, { method: "PATCH", body: JSON.stringify(input), }); } export function removePolicyChild( policyId: string, kind: string, childId: string, ): Promise { return apiFetch(`/policies/${policyId}/${kind}/${childId}`, { method: "DELETE", }); } /* ------------------------------------------------- Lookups (insurance ref) */ export function getLookups(): Promise { return apiFetch("/lookups"); } export function createLookup(kind: string, input: unknown): Promise { return apiFetch(`/lookups/${kind}`, { method: "POST", body: JSON.stringify(input) }); } export function updateLookup( kind: string, id: string, input: unknown, ): Promise { return apiFetch(`/lookups/${kind}/${id}`, { method: "PATCH", body: JSON.stringify(input), }); } export function removeLookup(kind: string, id: string): Promise { return apiFetch(`/lookups/${kind}/${id}`, { method: "DELETE" }); } /* ----------------------------------------------------- Utilities module */ export interface PropertyQuery { query?: string; page?: number; pageSize?: number; serviceKind?: ServiceKind; municipality?: string; bank?: string; trust?: TrustFilter; hasServices?: boolean; customerId?: string; days?: number; sort?: PropertySort; } export function listProperties(q: PropertyQuery): Promise { const params = new URLSearchParams(); if (q.query) params.set("query", q.query); if (q.page) params.set("page", String(q.page)); if (q.pageSize) params.set("pageSize", String(q.pageSize)); if (q.serviceKind) params.set("serviceKind", q.serviceKind); if (q.municipality) params.set("municipality", q.municipality); if (q.bank) params.set("bank", q.bank); if (q.trust) params.set("trust", q.trust); if (q.hasServices !== undefined) params.set("hasServices", String(q.hasServices)); if (q.customerId) params.set("customerId", q.customerId); if (q.days) params.set("days", String(q.days)); if (q.sort) params.set("sort", q.sort); const qs = params.toString(); return apiFetch(`/properties${qs ? `?${qs}` : ""}`); } export function getPropertyStats( days: number = EXPIRY_WINDOW_DAYS, ): Promise { return apiFetch(`/properties/stats?days=${days}`); } export function getPropertyFacets(): Promise { return apiFetch("/properties/facets"); } export function getProperty( id: string, days: number = EXPIRY_WINDOW_DAYS, ): Promise { return apiFetch(`/properties/${id}?days=${days}`); } export function createProperty(input: PropertyInput): Promise { return apiFetch("/properties", { method: "POST", body: JSON.stringify(input), }); } export function updateProperty( id: string, input: Partial, ): Promise { return apiFetch(`/properties/${id}`, { method: "PATCH", body: JSON.stringify(input), }); } export function archiveProperty(id: string): Promise { return apiFetch(`/properties/${id}`, { method: "DELETE" }); } export function restoreProperty(id: string): Promise { return apiFetch(`/properties/${id}/restore`, { method: "POST" }); } // Service child CRUD. export function addService(propertyId: string, input: ServiceInput): Promise { return apiFetch(`/properties/${propertyId}/services`, { method: "POST", body: JSON.stringify(input), }); } export function updateService( propertyId: string, serviceId: string, input: Partial, ): Promise { return apiFetch(`/properties/${propertyId}/services/${serviceId}`, { method: "PATCH", body: JSON.stringify(input), }); } export function removeService(propertyId: string, serviceId: string): Promise { return apiFetch(`/properties/${propertyId}/services/${serviceId}`, { method: "DELETE", }); } // Trust account (1:1 upsert). export function upsertTrust(propertyId: string, input: TrustInput): Promise { return apiFetch(`/properties/${propertyId}/trust`, { method: "PUT", body: JSON.stringify(input), }); } export function removeTrust(propertyId: string): Promise { return apiFetch(`/properties/${propertyId}/trust`, { method: "DELETE" }); } export function removePropertyDocument( propertyId: string, documentId: string, ): Promise { return apiFetch(`/properties/${propertyId}/documents/${documentId}`, { method: "DELETE", }); } export function propertyDocumentDownloadUrl( propertyId: string, documentId: string, ): string { return `${API_ORIGIN}/properties/${propertyId}/documents/${documentId}/download`; } export function uploadPropertyDocument( propertyId: string, file: File, type?: string, ): Promise { const q = type ? `?type=${encodeURIComponent(type)}` : ""; return uploadFile(`/properties/${propertyId}/documents${q}`, file); } export function removePolicyDocument( policyId: string, documentId: string, ): Promise { return apiFetch(`/policies/${policyId}/documents/${documentId}`, { method: "DELETE", }); } export function policyDocumentDownloadUrl( policyId: string, documentId: string, ): string { return `${API_ORIGIN}/policies/${policyId}/documents/${documentId}/download`; } export function uploadPolicyDocument( policyId: string, file: File, type?: string, ): Promise { const q = type ? `?type=${encodeURIComponent(type)}` : ""; return uploadFile(`/policies/${policyId}/documents${q}`, file); } /* ------------------------------------------- Billing / statements module */ export interface MovementQuery { query?: string; page?: number; pageSize?: number; domain?: TransactionDomain; currency?: LedgerCurrency; direction?: LedgerDirection; typeId?: string; source?: string; customerId?: string; /** Restrict to captured-but-unpaid rows (the NOPAGO worklist). */ outstanding?: boolean; /** Exact check number — the by-check reconciliation lookup. */ checkNumber?: string; /** `YYYY-MM-DD`, inclusive on both ends. */ from?: string; to?: string; sort?: MovementSort; } export function listMovements(q: MovementQuery): Promise { const params = new URLSearchParams(); if (q.query) params.set("query", q.query); if (q.page) params.set("page", String(q.page)); if (q.pageSize) params.set("pageSize", String(q.pageSize)); if (q.domain) params.set("domain", q.domain); if (q.currency) params.set("currency", q.currency); if (q.direction) params.set("direction", q.direction); if (q.typeId) params.set("typeId", q.typeId); if (q.source) params.set("source", q.source); if (q.customerId) params.set("customerId", q.customerId); if (q.outstanding !== undefined) params.set("outstanding", String(q.outstanding)); if (q.checkNumber) params.set("checkNumber", q.checkNumber); if (q.from) params.set("from", q.from); if (q.to) params.set("to", q.to); if (q.sort) params.set("sort", q.sort); const qs = params.toString(); return apiFetch(`/billing${qs ? `?${qs}` : ""}`); } export interface BalanceQuery { query?: string; page?: number; pageSize?: number; currency?: LedgerCurrency; balance?: BalanceFilter; domain?: TransactionDomain; sort?: BalanceSort; } export function listBalances(q: BalanceQuery): Promise { const params = new URLSearchParams(); if (q.query) params.set("query", q.query); if (q.page) params.set("page", String(q.page)); if (q.pageSize) params.set("pageSize", String(q.pageSize)); if (q.currency) params.set("currency", q.currency); if (q.balance) params.set("balance", q.balance); if (q.domain) params.set("domain", q.domain); if (q.sort) params.set("sort", q.sort); const qs = params.toString(); return apiFetch(`/billing/balances${qs ? `?${qs}` : ""}`); } export function getBillingStats(): Promise { return apiFetch("/billing/stats"); } export function getBillingFacets(): Promise { return apiFetch("/billing/facets"); } /** `year` omitted reads the current period; earlier years come from an archive. */ export function getStatement( customerId: string, year?: number, ): Promise { const q = year === undefined ? "" : `?year=${year}`; return apiFetch(`/billing/customers/${customerId}${q}`); } /** Append a new ledger movement. Booked movements are never edited — fix * mistakes with voidMovement + a fresh capture. */ export function createMovement( input: CreateMovementInput, ): Promise { return apiFetch("/billing", { method: "POST", body: JSON.stringify(input), }); } /** Reverse a movement by marking it voided; totals drop it. 400 if already void. */ export function voidMovement(id: string): Promise { return apiFetch(`/billing/${id}/void`, { method: "POST" }); } /** Capture many customers' receipts against one check, in one transaction. The * returned `items` are positionally parallel to `input.lines`. */ export function createMovementBatch( input: BatchCreateInput, ): Promise { return apiFetch("/billing/batch", { method: "POST", body: JSON.stringify(input), }); } /** Clear an outstanding (NOPAGO) row: stamps the check number + resolution date * and starts counting it toward the balance. 400 if not outstanding or voided. */ export function resolveOutstanding( id: string, input: ResolveOutstandingInput, ): Promise { return apiFetch(`/billing/${id}/resolve-outstanding`, { method: "POST", body: JSON.stringify(input), }); } /** Everything captured against one check, with its reconciliation total. */ export function getByCheck(checkNumber: string): Promise { return apiFetch( `/billing/by-check?checkNumber=${encodeURIComponent(checkNumber)}`, ); } /* ------------------------------------------------- Bank register (chequera) */ /** * Every read below is scoped to one chequera. `bankAccountId` is required, not * defaulted to "all accounts": the office's registers are in different * currencies, and a combined total would be a figure that never existed. */ export interface BankQuery { bankAccountId: string; query?: string; page?: number; pageSize?: number; direction?: BankDirection; cleared?: BankCleared; /** `YYYY-MM-DD`, inclusive on both ends. */ from?: string; to?: string; sort?: BankSort; } export function listBankMovements(q: BankQuery): Promise { const params = new URLSearchParams({ bankAccountId: q.bankAccountId }); if (q.query) params.set("query", q.query); if (q.page) params.set("page", String(q.page)); if (q.pageSize) params.set("pageSize", String(q.pageSize)); if (q.direction) params.set("direction", q.direction); if (q.cleared) params.set("cleared", q.cleared); if (q.from) params.set("from", q.from); if (q.to) params.set("to", q.to); if (q.sort) params.set("sort", q.sort); return apiFetch(`/bank?${params.toString()}`); } export function getBankStats(bankAccountId: string): Promise { return apiFetch( `/bank/stats?bankAccountId=${encodeURIComponent(bankAccountId)}`, ); } export function getBankFacets(bankAccountId: string): Promise { return apiFetch( `/bank/facets?bankAccountId=${encodeURIComponent(bankAccountId)}`, ); } export function getBankSummary( bankAccountId: string, year?: number, ): Promise { const params = new URLSearchParams({ bankAccountId }); if (year) params.set("year", String(year)); return apiFetch(`/bank/summary?${params.toString()}`); } /* --------------------------------------------- Chequera accounts (catalog) */ /** The account picker's source. Includes closed accounts, which stay readable. */ export function listBankAccounts(): Promise { return apiFetch("/bank/accounts"); } export function listBankInstitutions(): Promise { return apiFetch("/bank/banks"); } export function createBankInstitution( input: CreateBankInput, ): Promise { return apiFetch("/bank/banks", { method: "POST", body: JSON.stringify(input), }); } export function updateBankInstitution( id: string, input: Partial, ): Promise { return apiFetch(`/bank/banks/${id}`, { method: "PATCH", body: JSON.stringify(input), }); } export function createBankAccount( input: CreateBankAccountInput, ): Promise { return apiFetch("/bank/accounts", { method: "POST", body: JSON.stringify(input), }); } /** No `currency` — an account's booked movements are denominated in it. */ export function updateBankAccount( id: string, input: UpdateBankAccountInput, ): Promise { return apiFetch(`/bank/accounts/${id}`, { method: "PATCH", body: JSON.stringify(input), }); } /** Append a new chequera movement. Booked rows are never edited — fix mistakes * with voidBankMovement + a fresh capture. */ export function createBankMovement( input: CreateBankMovementInput, ): Promise { return apiFetch("/bank", { method: "POST", body: JSON.stringify(input), }); } /** Reverse a chequera movement by marking it voided; totals drop it. */ export function voidBankMovement(id: string): Promise { return apiFetch(`/bank/${id}/void`, { method: "POST" }); } /* ------------------------------------------------- Users / administration */ export function listUsers(): Promise { return apiFetch("/users"); } export interface CreateUserInput { name: string; email: string; password: string; role: Role; active?: boolean; } export function createUser(input: CreateUserInput): Promise { return apiFetch("/users", { method: "POST", body: JSON.stringify(input), }); } export interface UpdateUserInput { name?: string; email?: string; role?: Role; active?: boolean; } export function updateUser(id: string, input: UpdateUserInput): Promise { return apiFetch(`/users/${id}`, { method: "PATCH", body: JSON.stringify(input), }); } export function resetUserPassword(id: string, password: string): Promise { return apiFetch(`/users/${id}/reset-password`, { method: "POST", body: JSON.stringify({ password }), }); } export function deleteUser(id: string): Promise { return apiFetch(`/users/${id}`, { method: "DELETE" }); } /* ------------------------------------------- DB operations (admin only) */ export function listIngest(): Promise { return apiFetch("/ops/ingest"); } /** Live upload stats reported to `uploadFile`'s `onProgress` callback. */ export type UploadProgress = { loaded: number; /** 0 when the browser can't compute the request size. */ total: number; /** 0..1, or null when `total` is unknown. */ fraction: number | null; /** Smoothed transfer rate. */ bytesPerSecond: number; /** null until a rate and a total are both known. */ secondsRemaining: number | null; /** True once the bytes are sent and we're waiting on the server's reply. */ finishing: boolean; }; /** * Multipart upload — not JSON, so it bypasses apiFetch's Content-Type. `path` * is API-relative (may include a query string); `filename` overrides the part * name sent to the server. Uses XHR rather than fetch because fetch has no way * to report request-body progress. */ export function uploadFile( path: string, file: File, filename?: string, onProgress?: (p: UploadProgress) => void, ): Promise { const body = new FormData(); body.append("file", file, filename ?? file.name); return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("POST", `${API_ORIGIN}${path}`); xhr.withCredentials = true; if (onProgress) { // Exponentially smoothed rate — raw per-chunk deltas jump around too // much to read. let lastAt = performance.now(); let lastLoaded = 0; let rate = 0; xhr.upload.onprogress = (e) => { const now = performance.now(); const dt = (now - lastAt) / 1000; if (dt >= 0.15) { const sample = (e.loaded - lastLoaded) / dt; rate = rate === 0 ? sample : rate * 0.7 + sample * 0.3; lastAt = now; lastLoaded = e.loaded; } const total = e.lengthComputable ? e.total : 0; onProgress({ loaded: e.loaded, total, fraction: total ? e.loaded / total : null, bytesPerSecond: rate, secondsRemaining: total && rate > 0 ? (total - e.loaded) / rate : null, finishing: false, }); }; // Bytes are out the door; the server still has to write the file. xhr.upload.onload = () => { onProgress({ loaded: file.size, total: file.size, fraction: 1, bytesPerSecond: rate, secondsRemaining: 0, finishing: true, }); }; } xhr.onload = () => { let parsed: unknown; try { parsed = xhr.responseText ? JSON.parse(xhr.responseText) : undefined; } catch { parsed = undefined; } if (xhr.status >= 200 && xhr.status < 300) { resolve(parsed); return; } const message = (parsed as { message?: string } | undefined)?.message ?? `Error ${xhr.status}`; reject(new ApiError(xhr.status, message)); }; xhr.onerror = () => reject(new ApiError(0, "Fallo de red durante la carga.")); xhr.onabort = () => reject(new ApiError(0, "Carga cancelada.")); xhr.ontimeout = () => reject(new ApiError(0, "Tiempo de carga agotado.")); xhr.send(body); }); } export function uploadIngest( name: string, file: File, onProgress?: (p: UploadProgress) => void, ): Promise { return uploadFile( `/ops/ingest/${encodeURIComponent(name)}`, file, name, onProgress, ); } export function deleteIngest(name: string): Promise { return apiFetch(`/ops/ingest/${encodeURIComponent(name)}`, { method: "DELETE" }); } export function listBackups(): Promise { return apiFetch("/ops/backups"); } export function backupDownloadUrl(name: string): string { return `${API_ORIGIN}/ops/backups/${encodeURIComponent(name)}/download`; } export function deleteBackup(name: string): Promise { return apiFetch(`/ops/backups/${encodeURIComponent(name)}`, { method: "DELETE" }); } /** * Health of the read replica my.jorgecuadros.com serves customers from. * * A stopped replica does not error — it answers with stale balances — so this * is the only place the failure is visible. */ export function getReplicationStatus(): Promise { return apiFetch("/ops/replication"); } /** * Compare every customer-visible table against the master, row by row. * * Slow by nature — it is a full scan of both servers — so it is a button, not * part of the poll. Answers the question replication status cannot: GTIDs prove * the replica applied everything the master sent, not that nothing else changed * the rows here. */ export function verifyReplication(): Promise { return apiFetch("/ops/replication/verify", { method: "POST" }); } export function listOpsJobs(): Promise { return apiFetch("/ops/jobs"); } export function getOpsJob(id: string): Promise { return apiFetch(`/ops/jobs/${id}`); } /** Start a mutating op. `file` is required for RESTORE. 409 if one is running. */ /** `forceFull` applies to REIMPORT only: proceed even though the rebuild * deletes rows that exist only in the platform. */ export function startOpsJob( kind: OpsJobKind, file?: string, forceFull?: boolean, ): Promise { return apiFetch("/ops/jobs", { method: "POST", body: JSON.stringify({ kind, file, forceFull }), }); } /* ----------------------------------------------------------- Reports module */ export function getReportCatalog(): Promise { return apiFetch("/reports"); } export function runReport( slug: string, params: Record, ): Promise { const qs = new URLSearchParams(); for (const [k, v] of Object.entries(params)) { if (v != null && v !== "") qs.set(k, v); } const tail = qs.toString(); return apiFetch(`/reports/${slug}${tail ? `?${tail}` : ""}`); } /* ------------------------------------------------- Mass email notifications */ export type NotificationType = | "OUTSTANDING_PAYMENT" | "PAYMENT_CONFIRMATION" | "ACCOUNT_STATUS" | "TRUST_PAYMENT_CONFIRMATION" | "RENEWAL_NOTICE"; export type NotificationServicio = "CUSTOMERS" | "TRUST" | "POLICIES"; /** Which servicios each /notificaciones tab reads out of the shared log. */ export const SERVICIOS_LOG_SCOPE: NotificationServicio[] = ["CUSTOMERS", "TRUST"]; export const POLIZAS_LOG_SCOPE: NotificationServicio[] = ["POLICIES"]; export type NotificationStatus = | "SENT" | "FAILED" | "SKIPPED_NO_EMAIL" | "SKIPPED_GATE"; export interface NotificationLogRow { id: string; sendDate: string; notificationType: NotificationType; level: number | null; servicio: NotificationServicio; customerId: string | null; customerName: string; customerEmail: string; subject: string; debug: boolean; status: NotificationStatus; providerMessageId: string | null; error: string | null; } export interface NotificationLogPage { items: NotificationLogRow[]; total: number; page: number; pageSize: number; pageCount: number; } export interface NotificationStats { byType: { notificationType: NotificationType; status: NotificationStatus; _count: { _all: number } }[]; byStatus: { status: NotificationStatus; _count: { _all: number } }[]; byServicio: { servicio: NotificationServicio; status: NotificationStatus; _count: { _all: number } }[]; lastRun: { sendDate: string; notificationType: NotificationType } | null; transport: { available: boolean; devFallback: boolean }; } export type NotificationFlags = { debug?: boolean; ignoreDayRestriction?: boolean; useEmailLimit?: boolean; }; /** Job 1 (Outstanding) response — legacy `result` field. */ export interface OutstandingResponse { result: "success"; notificationType: "sendPaymentConfirmation"; reason: string; statusCode: 200; sent: number; skipped: number; failed: number; debug: boolean; type: "OUTSTANDING_PAYMENT"; } /** Job 2 (Payment Confirmation) response. */ export interface PaymentConfirmResponse { request: "success"; notificationType: "sendPaymentConfirmation"; confirmationSent: string; statusCode: 200; sent: number; skipped: number; failed: number; debug: boolean; type: "PAYMENT_CONFIRMATION"; } /** Job 3 (Account Status) response. */ export interface AccountStatusResponse { request: "success"; notificationType: "sendAccountStatus"; statusSent: string; statusReport: string; statusCode: 200; red: number; yellow: number; total: number; sent: number; skipped: number; failed: number; debug: boolean; type: "ACCOUNT_STATUS"; } /** Job 4 (Trust Confirmation) response. */ export interface TrustConfirmResponse { request: "success"; notificationType: "sendTrustPaymentConfirmation"; confirmationSent: string; statusCode: 200; sent: number; skipped: number; failed: number; debug: boolean; type: "TRUST_PAYMENT_CONFIRMATION"; } export type NotificationJobResponse = | OutstandingResponse | PaymentConfirmResponse | AccountStatusResponse | TrustConfirmResponse; export function runOutstandingPayments( flags: NotificationFlags = {}, ): Promise { return apiFetch("/notifications/outstanding-payments", { method: "POST", body: JSON.stringify(flags), }); } export function runPaymentConfirmation( flags: NotificationFlags = {}, ): Promise { return apiFetch("/notifications/payment-confirmation", { method: "POST", body: JSON.stringify(flags), }); } export function runAccountStatus( flags: NotificationFlags = {}, ): Promise { return apiFetch("/notifications/account-status", { method: "POST", body: JSON.stringify(flags), }); } export function runTrustConfirmation( flags: NotificationFlags = {}, ): Promise { return apiFetch("/notifications/trust-payment-confirmation", { method: "POST", body: JSON.stringify(flags), }); } export type NotificationJobKind = "outstanding" | "payment" | "account" | "trust"; export interface NotificationRunAllJobResult { kind: NotificationJobKind; ok: boolean; result?: NotificationJobResponse; error?: string; } /** Aggregate response of the "Ejecutar todos" sweep. */ export interface NotificationRunAllResponse { request: "success"; notificationType: "runAllNotifications"; statusCode: 200; debug: boolean; sent: number; skipped: number; failed: number; errors: number; jobs: NotificationRunAllJobResult[]; type: "RUN_ALL"; } export function runAllNotifications( flags: NotificationFlags = {}, ): Promise { return apiFetch("/notifications/run-all", { method: "POST", body: JSON.stringify(flags), }); } export interface NotificationLogQuery { page?: number; pageSize?: number; type?: NotificationType; /** One or more servicios; omitted = the whole log. */ servicio?: NotificationServicio[]; status?: NotificationStatus; view?: "sent" | "failed" | "skipped" | "all"; } export function listNotificationLog( q: NotificationLogQuery = {}, ): Promise { const qs = new URLSearchParams(); if (q.page) qs.set("page", String(q.page)); if (q.pageSize) qs.set("pageSize", String(q.pageSize)); if (q.type) qs.set("type", q.type); if (q.servicio?.length) qs.set("servicio", q.servicio.join(",")); if (q.status) qs.set("status", q.status); if (q.view) qs.set("view", q.view); const tail = qs.toString(); return apiFetch(`/notifications/log${tail ? `?${tail}` : ""}`); } /** Where a setting's current value came from — shown so an operator can tell * "nobody has set this, you are seeing the deploy's value" from "somebody * set this on purpose". */ export type SettingSource = "db" | "env" | "default"; export interface NotificationAdminEmails { value: string[]; source: SettingSource; updatedAt: string | null; updatedById: string | null; } export function getNotificationAdminEmails(): Promise { return apiFetch("/notifications/settings/admin-emails"); } export function setNotificationAdminEmails( emails: string[], ): Promise { return apiFetch("/notifications/settings/admin-emails", { method: "PUT", body: JSON.stringify({ emails }), }); } /* ----------------------------------------------------- envío scheduling */ /** The two automatic envíos, one per /notificaciones tab. */ export type ScheduleKind = "servicios" | "polizas"; export interface NotificationSchedule { enabled: boolean; /** Local hour/minute in America/Tijuana. */ hour: number; minute: number; /** 0 = domingo … 6 = sábado. Vacío = todos los días. */ weekdays: number[]; } export interface ResolvedSchedule { value: NotificationSchedule; source: SettingSource; updatedAt: string | null; updatedById: string | null; /** Expression the value compiles to, shown verbatim in the UI. */ cron: string; nextRun: string | null; } export type NotificationSchedules = Record; export function getNotificationSchedules(): Promise { return apiFetch("/notifications/settings/schedule"); } export function setNotificationSchedule( kind: ScheduleKind, schedule: NotificationSchedule, ): Promise { return apiFetch(`/notifications/settings/schedule/${kind}`, { method: "PUT", body: JSON.stringify(schedule), }); } export function getNotificationStats( servicio?: NotificationServicio[], ): Promise { const tail = servicio?.length ? `?servicio=${encodeURIComponent(servicio.join(","))}` : ""; return apiFetch(`/notifications/stats${tail}`); } /** Build a download URL for a report's file output. The session cookie * travels with the browser's same-origin navigation, so a plain `href` * is enough — no fetch-with-credentials dance. */ export function reportDownloadUrl( slug: string, format: "csv" | "xlsx" | "pdf" | "print", params: Record, ): string { const qs = new URLSearchParams(); for (const [k, v] of Object.entries(params)) { if (v != null && v !== "") qs.set(k, v); } const tail = qs.toString(); return `${API_ORIGIN}/reports/${slug}/${format}${tail ? `?${tail}` : ""}`; } /* ------------------------------------- Statement OCR intake (recibos) */ /** * Whether this deployment can ingest scans — automatic capture is hidden * without it. OCR reads the page, object storage keeps it; both are required. */ export function getStatementStatus(): Promise<{ ocrAvailable: boolean; storageAvailable: boolean; }> { return apiFetch("/statements/status"); } export function listStatementBatches( page = 1, pageSize = 25, ): Promise<{ items: StatementBatch[]; total: number; page: number; pageSize: number; pageCount: number; }> { return apiFetch(`/statements/batches?page=${page}&pageSize=${pageSize}`); } export function getStatementBatch(id: string): Promise { return apiFetch(`/statements/batches/${id}`); } export function listStatementDocuments( batchId: string, status?: StatementDocumentStatus, ): Promise { const q = status ? `?status=${status}` : ""; return apiFetch(`/statements/batches/${batchId}/documents${q}`); } /** Multi-file upload — one batch is usually several multi-page scans. */ export async function uploadStatementBatch( files: File[], serviceKind: ServiceKind, label?: string, ): Promise { const body = new FormData(); for (const f of files) body.append("files", f, f.name); const qs = new URLSearchParams({ serviceKind }); if (label) qs.set("label", label); const res = await fetch(`${API_ORIGIN}/statements/batches?${qs}`, { method: "POST", credentials: "include", body, }); if (!res.ok) { let message = `Error ${res.status}`; try { const b = await res.json(); if (b?.message) message = b.message; } catch { /* non-JSON error body */ } throw new Error(message); } return res.json(); } export function reviewStatementDocument( id: string, input: ReviewDocumentInput, ): Promise { return apiFetch(`/statements/documents/${id}`, { method: "PATCH", body: JSON.stringify(input), }); } export function rejectStatementDocument(id: string): Promise { return apiFetch(`/statements/documents/${id}/reject`, { method: "POST" }); } export function confirmStatementBatch( batchId: string, input: ConfirmBatchInput, ): Promise { return apiFetch(`/statements/batches/${batchId}/confirm`, { method: "POST", body: JSON.stringify(input), }); } /** Abandon a batch pending review; rejects every page that is not posted. */ export function discardStatementBatch(batchId: string): Promise { return apiFetch(`/statements/batches/${batchId}/discard`, { method: "POST" }); } /** The rendered page image. A plain — the cookie rides along. */ export function statementPageUrl(documentId: string): string { return `${API_ORIGIN}/statements/documents/${documentId}/page`; } /* ----------------------------------------------- Policy OCR (GMX / ANA) */ export function getPolicyOcrStatus(): Promise<{ ocrAvailable: boolean; storageAvailable: boolean; }> { return apiFetch("/policy-ocr/status"); } export function listPolicyOcrBatches( page = 1, pageSize = 25, ): Promise<{ items: PolicyOcrBatch[]; total: number; page: number; pageSize: number; pageCount: number; }> { return apiFetch(`/policy-ocr/batches?page=${page}&pageSize=${pageSize}`); } export function getPolicyOcrBatch(id: string): Promise { return apiFetch(`/policy-ocr/batches/${id}`); } export function listPolicyOcrDocuments(batchId: string): Promise { return apiFetch(`/policy-ocr/batches/${batchId}/documents`); } export async function uploadPolicyOcrBatch( files: File[], label?: string, ): Promise { const body = new FormData(); for (const f of files) body.append("files", f, f.name); const qs = new URLSearchParams(); if (label) qs.set("label", label); const res = await fetch( `${API_ORIGIN}/policy-ocr/batches${qs.toString() ? `?${qs}` : ""}`, { method: "POST", credentials: "include", body, }, ); if (!res.ok) { let message = `Error ${res.status}`; try { const b = await res.json(); if (b?.message) message = b.message; } catch { /* non-JSON error body */ } throw new Error(message); } return res.json(); } export function reviewPolicyOcrDocument( id: string, input: PolicyOcrReviewInput, ): Promise { return apiFetch(`/policy-ocr/documents/${id}`, { method: "PATCH", body: JSON.stringify(input), }); } export function rejectPolicyOcrDocument(id: string): Promise { return apiFetch(`/policy-ocr/documents/${id}/reject`, { method: "POST" }); } export function confirmPolicyOcrBatch( batchId: string, input: PolicyOcrConfirmInput, ): Promise { return apiFetch(`/policy-ocr/batches/${batchId}/confirm`, { method: "POST", body: JSON.stringify(input), }); } /** Abandon a batch pending review; rejects every page that is not applied. */ export function discardPolicyOcrBatch(batchId: string): Promise { return apiFetch(`/policy-ocr/batches/${batchId}/discard`, { method: "POST" }); } /** * URL for the source PDF of a parsed policy document. The endpoint returns * the original upload (one PDF = one parsed policy), not a rendered page * image, so the review screen embeds it in an iframe. */ export function policyOcrDocumentUrl(documentId: string): string { return `${API_ORIGIN}/policy-ocr/documents/${documentId}/page`; }