Escribir una server action
El puente entre el admin y la API mediante el api-client, headers de auth desde cookies y refresh automático.
En el admin (y en console/portal) toda comunicación con la API pasa por server
actions. No hay fetch del lado del cliente. Una action es una función "use server" en
apps/admin/src/actions/<dominio>.ts que llama al api-client.
El api-client
apps/admin/src/lib/api-client.ts expone cinco funciones genéricas:
| Función | Uso |
|---|---|
apiGet<T>(path) | Lecturas. Usa cache: "no-store". |
apiPost<T>(path, body?) | Crear / acciones. |
apiPatch<T>(path, body?) | Actualizaciones parciales. |
apiPut<T>(path, body?) | Reemplazos. |
apiDelete<T>(path) | Borrar (maneja 204 No Content). |
Lo que hacen por ti, para que tu action no lo repita:
- Headers de auth:
getAuthHeaders()leeaccessTokende las cookies (next/headers) y arma elAuthorization: Bearer …. - Refresh automático en 401: si la API responde 401,
tryRefreshToken()usa elrefreshToken(conservandotenantId/branchIddel switcher de sucursal) para renovar elaccessToken, reescribe la cookie httpOnly y reintenta la request una vez. Si no hay refresh válido, redirige a/login. - Errores estructurados: el payload de error de la API (
statusCode,code,message, y campos extra comooccupiedBy/idleMin) se preserva en elError, para que tu action pueda ramificar (p. ej. el "tomar caja ocupada" del módulo cash).
Nota:
apiPatchyapiDeleteomitenContent-Typecuando no hay body, porque Fastify rechaza un body JSON vacío (FST_ERR_CTP_EMPTY_JSON_BODY). No fuerces el header.
Anatomía de un archivo de actions
"use server";
import { apiGet, apiPost } from "@/lib/api-client";
import { revalidatePath } from "next/cache";- La directiva
"use server"va al inicio del archivo. - Importa solo las funciones del api-client que necesites.
- Tras una escritura, llama
revalidatePath(...)para refrescar la pantalla que muestra esos datos.
Ejemplo de lectura
"use server";
import { apiGet } from "@/lib/api-client";
export async function getWidgets(filters?: { status?: string; limit?: number }) {
const params = new URLSearchParams();
if (filters?.status) params.set("status", filters.status);
if (filters?.limit) params.set("limit", String(filters.limit));
const qs = params.toString();
return apiGet<Widget[]>(`/api/widgets${qs ? `?${qs}` : ""}`);
}
export async function getWidget(id: string) {
return apiGet<Widget>(`/api/widgets/${id}`);
}Ejemplo de escritura
"use server";
import { apiPost } from "@/lib/api-client";
import { revalidatePath } from "next/cache";
export async function createWidget(data: { name: string; color?: string }) {
const widget = await apiPost<Widget>("/api/widgets", data);
revalidatePath("/widgets");
return widget;
}Ramificar sobre un error estructurado
Cuando la API responde un 409 con código (como hace cash), captura y devuelve un objeto que la UI pueda interpretar, en vez de dejar que reviente:
export type CreateResult =
| { success: true; widget: Widget }
| { error: string; conflict?: true };
export async function createWidgetSafe(data: { name: string }): Promise<CreateResult> {
try {
const widget = await apiPost<Widget>("/api/widgets", data);
revalidatePath("/widgets");
return { success: true, widget };
} catch (e: unknown) {
const err = e as { statusCode?: number; code?: string; message?: string };
if (err.statusCode === 409) {
return { error: err.message ?? "Conflicto", conflict: true };
}
return { error: e instanceof Error ? e.message : "Error al crear" };
}
}El mismo patrón en console y portal
- Console (
apps/console): mismo patrón, pero su api-client (apps/console/src/lib/api-client.ts) usa cookies con prefijosaas-(saas-accessToken,saas-refreshToken) y pega a endpoints/api/saas/*. Las actions viven enapps/console/src/actions/. - Portal (
apps/portal): su propio api-client (apps/portal/src/lib/api-client.ts), con flujos de auth de paciente y de empresa, y el tenant resuelto víaapps/portal/src/lib/tenant.ts. Actions enapps/portal/src/actions/.
Checklist
- Archivo
apps/admin/src/actions/<dominio>.tscon"use server"arriba. - Importa solo las funciones del api-client que uses.
- Lecturas con
apiGet, escrituras conapiPost/apiPatch/apiPut/apiDelete. revalidatePath(...)después de cada escritura.- Si la API devuelve errores con código, ramifica sobre
statusCode/code.
Anterior: Añadir un módulo a la API · Siguiente: Añadir una pantalla al admin.