Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica
- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind - Frontend Homenest (versión alternativa) - Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp) - WACRM fork (Next.js 16 + Supabase) - Hermes + Bridge + Skills (Qwen3.6 via Nan Builders) - Scripts de migración y operación - Documentación extensiva en docs/
This commit is contained in:
133
wacrm/src/lib/api/v1/respond.ts
Normal file
133
wacrm/src/lib/api/v1/respond.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// ============================================================
|
||||
// Public API (v1) response envelope.
|
||||
//
|
||||
// Every `/api/v1/*` route speaks one shape so external integrators
|
||||
// can write a single response parser:
|
||||
//
|
||||
// success → { "data": <payload> }
|
||||
// failure → { "error": { "code": "<machine_code>", "message": "<human>" } }
|
||||
//
|
||||
// `code` is a stable, machine-matchable string (clients branch on
|
||||
// it); `message` is human-facing and may be reworded freely. This is
|
||||
// intentionally distinct from the internal `{ error: string }` shape
|
||||
// used by the dashboard's own `/api/*` routes — the public contract
|
||||
// is versioned and shouldn't inherit internal wording changes.
|
||||
// ============================================================
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { RateLimitResult } from '@/lib/rate-limit';
|
||||
|
||||
export type ApiErrorCode =
|
||||
| 'unauthorized' // missing / malformed / unknown / revoked / expired key
|
||||
| 'forbidden' // valid key, but missing the required scope
|
||||
| 'rate_limited' // per-key budget exhausted
|
||||
| 'bad_request' // malformed input
|
||||
| 'not_found'
|
||||
| 'internal';
|
||||
|
||||
/**
|
||||
* Typed error a route (or `requireApiKey`) can throw and have mapped
|
||||
* to the envelope by `toApiErrorResponse`. Carries an HTTP status, a
|
||||
* machine code, and optional extra headers (used for the rate-limit
|
||||
* `Retry-After` / `X-RateLimit-*` set).
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
readonly code: ApiErrorCode;
|
||||
readonly status: number;
|
||||
readonly headers?: Record<string, string>;
|
||||
|
||||
constructor(
|
||||
code: ApiErrorCode,
|
||||
message: string,
|
||||
status: number,
|
||||
headers?: Record<string, string>
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
this.headers = headers;
|
||||
}
|
||||
}
|
||||
|
||||
/** 401 — no usable credential. */
|
||||
export function unauthorized(message = 'Missing or invalid API key'): ApiError {
|
||||
return new ApiError('unauthorized', message, 401);
|
||||
}
|
||||
|
||||
/** 403 — authenticated, but the key lacks the scope this route needs. */
|
||||
export function forbidden(message: string): ApiError {
|
||||
return new ApiError('forbidden', message, 403);
|
||||
}
|
||||
|
||||
/** 400 — bad input. */
|
||||
export function badRequest(message: string): ApiError {
|
||||
return new ApiError('bad_request', message, 400);
|
||||
}
|
||||
|
||||
/** 429 — built from a `checkRateLimit` miss, with the standard headers. */
|
||||
export function rateLimited(result: RateLimitResult): ApiError {
|
||||
const retryAfter = Math.max(1, Math.ceil((result.reset - Date.now()) / 1000));
|
||||
return new ApiError(
|
||||
'rate_limited',
|
||||
'Rate limit exceeded for this API key',
|
||||
429,
|
||||
{
|
||||
'Retry-After': String(retryAfter),
|
||||
'X-RateLimit-Limit': String(result.limit),
|
||||
'X-RateLimit-Remaining': String(result.remaining),
|
||||
'X-RateLimit-Reset': String(Math.ceil(result.reset / 1000)),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Success envelope: `{ data: <payload> }`. */
|
||||
export function ok<T>(data: T, status = 200): NextResponse {
|
||||
return NextResponse.json({ data }, { status });
|
||||
}
|
||||
|
||||
/**
|
||||
* List envelope: `{ data: [...], meta: { next_cursor } }`. The `meta`
|
||||
* block is the pagination contract shared by every v1 list endpoint —
|
||||
* `next_cursor` is an opaque string to pass back as `?cursor=`, or
|
||||
* `null` on the last page. See `src/lib/api/v1/pagination.ts`.
|
||||
*/
|
||||
export function okList<T>(items: T[], nextCursor: string | null): NextResponse {
|
||||
return NextResponse.json({ data: items, meta: { next_cursor: nextCursor } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Failure envelope from an explicit (code, message, status). Use for
|
||||
* domain errors whose codes live outside `ApiErrorCode` (e.g. the
|
||||
* send pipeline's `meta_error` / `whatsapp_not_configured`) — the
|
||||
* wire `code` is a free string, so any machine-meaningful value is
|
||||
* fine. `headers` is rarely needed; omit unless you have a
|
||||
* `Retry-After`-style set.
|
||||
*/
|
||||
export function fail(
|
||||
code: string,
|
||||
message: string,
|
||||
status: number,
|
||||
headers?: Record<string, string>
|
||||
): NextResponse {
|
||||
return NextResponse.json({ error: { code, message } }, { status, headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Map any thrown value to the failure envelope. `ApiError` keeps its
|
||||
* code/status/headers; anything else collapses to a generic 500 so we
|
||||
* never leak internal error text onto the public wire.
|
||||
*/
|
||||
export function toApiErrorResponse(err: unknown): NextResponse {
|
||||
if (err instanceof ApiError) {
|
||||
return NextResponse.json(
|
||||
{ error: { code: err.code, message: err.message } },
|
||||
{ status: err.status, headers: err.headers }
|
||||
);
|
||||
}
|
||||
console.error('[api/v1] uncategorized error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'internal', message: 'Internal server error' } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user