Files
jorgecuadros-platform/apps/web/src/lib/api.ts
T
rmancinasandClaude Opus 4.8 f1ef1c70b3 wip: ops admin panel + migration sync + crud/rbac phase-5 snapshot
Working-tree checkpoint of in-progress work carried across prior
sessions on the feat/crud-rbac branch, committed so it lands on the
remote alongside the CI changes.

- Operaciones admin panel: apps/api/src/ops (ingest upload, backup /
  restore / re-import jobs) wired into app.module + RBAC abilities, and
  the apps/web/src/app/operaciones page. docker-compose gets INGEST_DIR
  / BACKUP_DIR volumes; .gitignore excludes migration/ingest + backups.
- migration/sync.py plus transform_*.py / run_all / config / dbenv /
  blob_extract adjustments for the additive sync path.
- crud/rbac phase-5 web bits: AppShell, api/labels/types libs, globals.
- schema.prisma + PLAN/RESUME doc updates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 19:01:36 -07:00

659 lines
19 KiB
TypeScript

// 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,
BankCleared,
BankDirection,
BankFacets,
BankListResponse,
BankSort,
BankStats,
BankSummary,
BillingFacets,
BillingStats,
BusinessLine,
CreateBankMovementInput,
CreateMovementInput,
CustomerDetail,
CustomerInput,
CustomerListResponse,
CustomerStats,
LedgerCurrency,
LedgerDirection,
MovementListResponse,
MovementSort,
PolicyDetail,
PolicyFacets,
PolicyInput,
PolicyListResponse,
PolicySort,
PolicyStats,
PolicyStatus,
LookupsResponse,
OpsJob,
OpsJobKind,
IngestFile,
BackupFile,
PropertyDetail,
PropertyFacets,
PropertyInput,
PropertyListResponse,
PropertySort,
PropertyStats,
ServiceInput,
TrustInput,
Role,
ServiceKind,
Statement,
Transaction,
TransactionDomain,
TrustFilter,
UserRow,
} from "./types";
export const API_ORIGIN =
process.env.NEXT_PUBLIC_API_ORIGIN ?? "http://localhost:3001";
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.name = "ApiError";
}
}
async function apiFetch<T>(
path: string,
init?: RequestInit,
): Promise<T> {
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<AuthUser> {
return apiFetch<AuthUser>("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
}
export function me(): Promise<AuthUser> {
return apiFetch<AuthUser>("/auth/me");
}
export function logout(): Promise<{ success: boolean }> {
return apiFetch<{ success: boolean }>("/auth/logout", { method: "POST" });
}
export function getStats(): Promise<CustomerStats> {
return apiFetch<CustomerStats>("/customers/stats");
}
export interface CustomerQuery {
query?: string;
page?: number;
pageSize?: number;
line?: BusinessLine;
}
export function listCustomers(
q: CustomerQuery,
): Promise<CustomerListResponse> {
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<CustomerListResponse>(`/customers${qs ? `?${qs}` : ""}`);
}
export function getCustomer(id: string): Promise<CustomerDetail> {
return apiFetch<CustomerDetail>(`/customers/${id}`);
}
export function createCustomer(input: CustomerInput): Promise<CustomerDetail> {
return apiFetch<CustomerDetail>("/customers", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateCustomer(
id: string,
input: Partial<CustomerInput>,
): Promise<CustomerDetail> {
return apiFetch<CustomerDetail>(`/customers/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function archiveCustomer(id: string): Promise<CustomerDetail> {
return apiFetch<CustomerDetail>(`/customers/${id}`, { method: "DELETE" });
}
export function restoreCustomer(id: string): Promise<CustomerDetail> {
return apiFetch<CustomerDetail>(`/customers/${id}/restore`, { 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<PolicyListResponse> {
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<PolicyListResponse>(`/policies${qs ? `?${qs}` : ""}`);
}
export function getPolicyStats(
days: number = EXPIRY_WINDOW_DAYS,
): Promise<PolicyStats> {
return apiFetch<PolicyStats>(`/policies/stats?days=${days}`);
}
export function getPolicyFacets(): Promise<PolicyFacets> {
return apiFetch<PolicyFacets>("/policies/facets");
}
export function getPolicy(
id: string,
days: number = EXPIRY_WINDOW_DAYS,
): Promise<PolicyDetail> {
return apiFetch<PolicyDetail>(`/policies/${id}?days=${days}`);
}
export function createPolicy(input: PolicyInput): Promise<PolicyDetail> {
return apiFetch<PolicyDetail>("/policies", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updatePolicy(
id: string,
input: Partial<PolicyInput>,
): Promise<PolicyDetail> {
return apiFetch<PolicyDetail>(`/policies/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function archivePolicy(id: string): Promise<PolicyDetail> {
return apiFetch<PolicyDetail>(`/policies/${id}`, { method: "DELETE" });
}
export function restorePolicy(id: string): Promise<PolicyDetail> {
return apiFetch<PolicyDetail>(`/policies/${id}/restore`, { method: "POST" });
}
// Generic policy-child CRUD. `kind` is the URL segment
// (installments|vehicles|drivers|beneficiaries|claims).
export function addPolicyChild<T>(
policyId: string,
kind: string,
input: T,
): Promise<unknown> {
return apiFetch(`/policies/${policyId}/${kind}`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function updatePolicyChild<T>(
policyId: string,
kind: string,
childId: string,
input: T,
): Promise<unknown> {
return apiFetch(`/policies/${policyId}/${kind}/${childId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function removePolicyChild(
policyId: string,
kind: string,
childId: string,
): Promise<unknown> {
return apiFetch(`/policies/${policyId}/${kind}/${childId}`, {
method: "DELETE",
});
}
/* ------------------------------------------------- Lookups (insurance ref) */
export function getLookups(): Promise<LookupsResponse> {
return apiFetch<LookupsResponse>("/lookups");
}
export function createLookup(kind: string, input: unknown): Promise<unknown> {
return apiFetch(`/lookups/${kind}`, { method: "POST", body: JSON.stringify(input) });
}
export function updateLookup(
kind: string,
id: string,
input: unknown,
): Promise<unknown> {
return apiFetch(`/lookups/${kind}/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function removeLookup(kind: string, id: string): Promise<unknown> {
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<PropertyListResponse> {
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<PropertyListResponse>(`/properties${qs ? `?${qs}` : ""}`);
}
export function getPropertyStats(
days: number = EXPIRY_WINDOW_DAYS,
): Promise<PropertyStats> {
return apiFetch<PropertyStats>(`/properties/stats?days=${days}`);
}
export function getPropertyFacets(): Promise<PropertyFacets> {
return apiFetch<PropertyFacets>("/properties/facets");
}
export function getProperty(
id: string,
days: number = EXPIRY_WINDOW_DAYS,
): Promise<PropertyDetail> {
return apiFetch<PropertyDetail>(`/properties/${id}?days=${days}`);
}
export function createProperty(input: PropertyInput): Promise<PropertyDetail> {
return apiFetch<PropertyDetail>("/properties", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateProperty(
id: string,
input: Partial<PropertyInput>,
): Promise<PropertyDetail> {
return apiFetch<PropertyDetail>(`/properties/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function archiveProperty(id: string): Promise<PropertyDetail> {
return apiFetch<PropertyDetail>(`/properties/${id}`, { method: "DELETE" });
}
export function restoreProperty(id: string): Promise<PropertyDetail> {
return apiFetch<PropertyDetail>(`/properties/${id}/restore`, { method: "POST" });
}
// Service child CRUD.
export function addService(propertyId: string, input: ServiceInput): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/services`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateService(
propertyId: string,
serviceId: string,
input: Partial<ServiceInput>,
): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/services/${serviceId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function removeService(propertyId: string, serviceId: string): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/services/${serviceId}`, {
method: "DELETE",
});
}
// Trust account (1:1 upsert).
export function upsertTrust(propertyId: string, input: TrustInput): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/trust`, {
method: "PUT",
body: JSON.stringify(input),
});
}
export function removeTrust(propertyId: string): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/trust`, { method: "DELETE" });
}
export function removePropertyDocument(
propertyId: string,
documentId: string,
): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/documents/${documentId}`, {
method: "DELETE",
});
}
/* ------------------------------------------- Billing / statements module */
export interface MovementQuery {
query?: string;
page?: number;
pageSize?: number;
domain?: TransactionDomain;
currency?: LedgerCurrency;
direction?: LedgerDirection;
typeId?: string;
source?: string;
customerId?: string;
/** `YYYY-MM-DD`, inclusive on both ends. */
from?: string;
to?: string;
sort?: MovementSort;
}
export function listMovements(q: MovementQuery): Promise<MovementListResponse> {
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.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<MovementListResponse>(`/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<BalanceListResponse> {
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<BalanceListResponse>(`/billing/balances${qs ? `?${qs}` : ""}`);
}
export function getBillingStats(): Promise<BillingStats> {
return apiFetch<BillingStats>("/billing/stats");
}
export function getBillingFacets(): Promise<BillingFacets> {
return apiFetch<BillingFacets>("/billing/facets");
}
export function getStatement(customerId: string): Promise<Statement> {
return apiFetch<Statement>(`/billing/customers/${customerId}`);
}
/** Append a new ledger movement. Booked movements are never edited — fix
* mistakes with voidMovement + a fresh capture. */
export function createMovement(
input: CreateMovementInput,
): Promise<Transaction> {
return apiFetch<Transaction>("/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<Transaction> {
return apiFetch<Transaction>(`/billing/${id}/void`, { method: "POST" });
}
/* ------------------------------------------------- Bank register (chequera) */
export interface BankQuery {
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<BankListResponse> {
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.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);
const qs = params.toString();
return apiFetch<BankListResponse>(`/bank${qs ? `?${qs}` : ""}`);
}
export function getBankStats(): Promise<BankStats> {
return apiFetch<BankStats>("/bank/stats");
}
export function getBankFacets(): Promise<BankFacets> {
return apiFetch<BankFacets>("/bank/facets");
}
export function getBankSummary(year?: number): Promise<BankSummary> {
return apiFetch<BankSummary>(`/bank/summary${year ? `?year=${year}` : ""}`);
}
/** Append a new chequera movement. Booked rows are never edited — fix mistakes
* with voidBankMovement + a fresh capture. */
export function createBankMovement(
input: CreateBankMovementInput,
): Promise<unknown> {
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<unknown> {
return apiFetch(`/bank/${id}/void`, { method: "POST" });
}
/* ------------------------------------------------- Users / administration */
export function listUsers(): Promise<UserRow[]> {
return apiFetch<UserRow[]>("/users");
}
export interface CreateUserInput {
name: string;
email: string;
password: string;
role: Role;
active?: boolean;
}
export function createUser(input: CreateUserInput): Promise<UserRow> {
return apiFetch<UserRow>("/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<UserRow> {
return apiFetch<UserRow>(`/users/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function resetUserPassword(id: string, password: string): Promise<UserRow> {
return apiFetch<UserRow>(`/users/${id}/reset-password`, {
method: "POST",
body: JSON.stringify({ password }),
});
}
/* ------------------------------------------- DB operations (admin only) */
export function listIngest(): Promise<IngestFile[]> {
return apiFetch<IngestFile[]>("/ops/ingest");
}
/** Multipart upload — not JSON, so it bypasses apiFetch's Content-Type. */
export async function uploadIngest(name: string, file: File): Promise<void> {
const body = new FormData();
body.append("file", file, name);
const res = await fetch(`${API_ORIGIN}/ops/ingest/${encodeURIComponent(name)}`, {
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 {
/* ignore */
}
throw new ApiError(res.status, message);
}
}
export function deleteIngest(name: string): Promise<unknown> {
return apiFetch(`/ops/ingest/${encodeURIComponent(name)}`, { method: "DELETE" });
}
export function listBackups(): Promise<BackupFile[]> {
return apiFetch<BackupFile[]>("/ops/backups");
}
export function backupDownloadUrl(name: string): string {
return `${API_ORIGIN}/ops/backups/${encodeURIComponent(name)}/download`;
}
export function deleteBackup(name: string): Promise<unknown> {
return apiFetch(`/ops/backups/${encodeURIComponent(name)}`, { method: "DELETE" });
}
export function listOpsJobs(): Promise<OpsJob[]> {
return apiFetch<OpsJob[]>("/ops/jobs");
}
export function getOpsJob(id: string): Promise<OpsJob> {
return apiFetch<OpsJob>(`/ops/jobs/${id}`);
}
/** Start a mutating op. `file` is required for RESTORE. 409 if one is running. */
export function startOpsJob(kind: OpsJobKind, file?: string): Promise<OpsJob> {
return apiFetch<OpsJob>("/ops/jobs", {
method: "POST",
body: JSON.stringify({ kind, file }),
});
}