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:
2026-07-20 07:44:23 +00:00
commit a718592291
699 changed files with 324602 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest';
import type { SupabaseClient } from '@supabase/supabase-js';
import {
serializeContact,
findOrCreateContact,
ContactError,
} from './contacts';
describe('serializeContact', () => {
it('flattens contact_tags(tags(*)) onto a tags array and nulls missing fields', () => {
const row = {
id: 'c1',
phone: '+14155550123',
name: 'Jane',
email: null,
company: 'Acme',
avatar_url: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-02T00:00:00Z',
contact_tags: [
{ tags: { id: 't1', name: 'vip', color: '#fff' } },
{ tags: null }, // orphaned join — dropped
],
};
expect(serializeContact(row)).toEqual({
id: 'c1',
phone: '+14155550123',
name: 'Jane',
email: null,
company: 'Acme',
avatar_url: null,
tags: [{ id: 't1', name: 'vip', color: '#fff' }],
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-02T00:00:00Z',
});
});
it('tolerates a row with no contact_tags key', () => {
const row = {
id: 'c2',
phone: '+1',
name: null,
email: null,
company: null,
avatar_url: null,
created_at: 'a',
updated_at: 'b',
};
expect(serializeContact(row).tags).toEqual([]);
});
});
describe('findOrCreateContact', () => {
const noopDb = {} as SupabaseClient;
it('rejects a non-E.164 phone with a 400 ContactError', async () => {
await expect(
findOrCreateContact(noopDb, 'acc', 'user', { phone: 'not-a-number' })
).rejects.toMatchObject({ status: 400 });
await expect(
findOrCreateContact(noopDb, 'acc', 'user', { phone: 'not-a-number' })
).rejects.toBeInstanceOf(ContactError);
});
});

View File

@@ -0,0 +1,223 @@
// ============================================================
// Shared contact logic for the public API (v1) contact endpoints.
//
// Kept out of the route files so `GET/POST /api/v1/contacts` and
// `GET/PATCH /api/v1/contacts/{id}` share one serializer, one
// find-or-create (built on the same `findExistingContact` dedupe the
// webhook and send path use), and one tag-sync routine.
// ============================================================
import type { SupabaseClient } from '@supabase/supabase-js';
import { findExistingContact, isUniqueViolation } from '@/lib/contacts/dedupe';
import { resolveImportTagIds } from '@/lib/contacts/resolve-import-tags';
import { sanitizePhoneForMeta, isValidE164 } from '@/lib/whatsapp/phone-utils';
/** Row select that embeds the contact's tags for serialization. */
export const CONTACT_SELECT = '*, contact_tags(tags(*))';
export interface ApiContact {
id: string;
phone: string;
name: string | null;
email: string | null;
company: string | null;
avatar_url: string | null;
tags: { id: string; name: string; color: string }[];
created_at: string;
updated_at: string;
}
/** Thrown by the helpers below; routes map `.status`/`.message`. */
export class ContactError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'ContactError';
this.status = status;
}
}
type RawTagJoin = { tags: { id: string; name: string; color: string } | null };
/** Flatten a `CONTACT_SELECT` row into the public contact shape. */
export function serializeContact(row: Record<string, unknown>): ApiContact {
const joins = (row.contact_tags as RawTagJoin[] | undefined) ?? [];
return {
id: row.id as string,
phone: row.phone as string,
name: (row.name as string | null) ?? null,
email: (row.email as string | null) ?? null,
company: (row.company as string | null) ?? null,
avatar_url: (row.avatar_url as string | null) ?? null,
tags: joins
.map((j) => j.tags)
.filter((t): t is NonNullable<RawTagJoin['tags']> => t != null)
.map((t) => ({ id: t.id, name: t.name, color: t.color })),
created_at: row.created_at as string,
updated_at: row.updated_at as string,
};
}
/**
* Resolve the audit `user_id` for API-created rows — the SINGLE source
* of truth used by every public-API write (contacts, messages,
* broadcasts, resolve-conversation), so the same key's writes are
* always attributed to the same human. API callers have no logged-in
* user, so — like the inbound webhook — we attribute writes to the
* **WhatsApp config owner** (the webhook's own convention). Contacts
* can be created before WhatsApp is connected, so we fall back to the
* account owner when there's no config yet.
*/
export async function resolveAuditUserId(
db: SupabaseClient,
accountId: string
): Promise<string> {
const { data: config } = await db
.from('whatsapp_config')
.select('user_id')
.eq('account_id', accountId)
.maybeSingle();
const configOwner = config?.user_id as string | undefined;
if (configOwner) return configOwner;
const { data: account } = await db
.from('accounts')
.select('owner_user_id')
.eq('id', accountId)
.maybeSingle();
const owner = account?.owner_user_id as string | undefined;
if (!owner) {
throw new ContactError('Account owner could not be resolved', 500);
}
return owner;
}
export interface ContactInput {
phone: string;
name?: string | null;
email?: string | null;
company?: string | null;
}
/**
* Find (by fuzzy phone match) or create a contact in `accountId`.
* Returns the contact id and whether it was created. Reuses the shared
* `findExistingContact` dedupe + unique-violation race backstop so an
* API-created contact is indistinguishable from a webhook-created one.
*/
export async function findOrCreateContact(
db: SupabaseClient,
accountId: string,
auditUserId: string,
input: ContactInput
): Promise<{ id: string; created: boolean }> {
const sanitized = sanitizePhoneForMeta(input.phone);
if (!isValidE164(sanitized)) {
throw new ContactError(
"'phone' must be a valid phone number in E.164 format (e.g. +14155550123)",
400
);
}
const existing = await findExistingContact(db, accountId, sanitized);
if (existing) return { id: existing.id, created: false };
const { data: created, error } = await db
.from('contacts')
.insert({
account_id: accountId,
user_id: auditUserId,
phone: sanitized,
name: input.name ?? sanitized,
email: input.email ?? null,
company: input.company ?? null,
})
.select('id')
.single();
if (error || !created) {
// Lost a race against a concurrent create — the unique index
// rejected the duplicate. Re-resolve to the winner.
if (isUniqueViolation(error)) {
const raced = await findExistingContact(db, accountId, sanitized);
if (raced) return { id: raced.id, created: false };
}
console.error('[api/v1/contacts] create error:', error);
throw new ContactError('Failed to create contact', 500);
}
return { id: created.id, created: true };
}
/**
* Replace a contact's tags to exactly match `tagNames` (case-
* insensitive; missing tags are created). A no-op when `tagNames` is
* undefined — pass `[]` to clear all tags. Reuses `resolveImportTagIds`
* so API and CSV-import tag handling stay consistent.
*/
export async function setContactTags(
db: SupabaseClient,
accountId: string,
auditUserId: string,
contactId: string,
tagNames: string[]
): Promise<void> {
const { tagIdByKey } = await resolveImportTagIds(db, {
accountId,
userId: auditUserId,
tagNames,
canCreateTags: true,
});
const desired = new Set(tagIdByKey.values());
// Diff against the current joins rather than delete-all-then-insert:
// a diff only touches tags that actually change, so a mid-operation
// failure can never wipe tags that were meant to stay. Every write
// is error-checked and surfaced as a ContactError (→ 500) instead of
// being swallowed behind a misleading 200.
const { data: current, error: readErr } = await db
.from('contact_tags')
.select('tag_id')
.eq('contact_id', contactId);
if (readErr) {
throw new ContactError('Failed to read contact tags', 500);
}
const existing = new Set(
(current ?? []).map((r) => r.tag_id as string)
);
const toAdd = [...desired].filter((id) => !existing.has(id));
const toRemove = [...existing].filter((id) => !desired.has(id));
if (toRemove.length > 0) {
const { error } = await db
.from('contact_tags')
.delete()
.eq('contact_id', contactId)
.in('tag_id', toRemove);
if (error) throw new ContactError('Failed to update contact tags', 500);
}
if (toAdd.length > 0) {
const { error } = await db
.from('contact_tags')
.insert(toAdd.map((tag_id) => ({ contact_id: contactId, tag_id })));
if (error) throw new ContactError('Failed to update contact tags', 500);
}
}
/** Fetch + serialize a single contact scoped to the account, or null. */
export async function getContactById(
db: SupabaseClient,
accountId: string,
contactId: string
): Promise<ApiContact | null> {
const { data, error } = await db
.from('contacts')
.select(CONTACT_SELECT)
.eq('id', contactId)
.eq('account_id', accountId)
.maybeSingle();
if (error || !data) return null;
return serializeContact(data as Record<string, unknown>);
}

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import type { Conversation, Message } from '@/types';
import { serializeConversation, serializeMessage } from './conversations';
describe('serializeConversation', () => {
it('projects public fields + nested contact/tags and drops internals', () => {
const conv = {
id: 'conv1',
user_id: 'internal-user',
account_id: 'internal-acct',
contact_id: 'c1',
status: 'open',
last_message_text: 'hi',
last_message_at: '2026-01-01T00:00:00Z',
unread_count: 2,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
contact: {
id: 'c1',
phone: '+1',
name: 'Jane',
tags: [{ id: 't1', name: 'vip', color: '#fff' }],
},
} as unknown as Conversation;
const out = serializeConversation(conv);
expect(out).not.toHaveProperty('user_id');
expect(out).not.toHaveProperty('account_id');
expect(out.contact?.tags).toEqual([{ id: 't1', name: 'vip', color: '#fff' }]);
expect(out.unread_count).toBe(2);
});
});
describe('serializeMessage', () => {
it('maps message_id → whatsapp_message_id and derives direction', () => {
const inbound = {
id: 'm1',
conversation_id: 'conv1',
sender_type: 'customer',
content_type: 'text',
content_text: 'hello',
message_id: 'wamid.123',
status: 'delivered',
created_at: '2026-01-01T00:00:00Z',
} as unknown as Message;
const outMsg = serializeMessage(inbound);
expect(outMsg.direction).toBe('inbound');
expect(outMsg.whatsapp_message_id).toBe('wamid.123');
expect(outMsg).not.toHaveProperty('message_id');
const agent = { ...inbound, sender_type: 'agent' } as unknown as Message;
expect(serializeMessage(agent).direction).toBe('outbound');
});
});

View File

@@ -0,0 +1,100 @@
// ============================================================
// Public API (v1) serializers for conversations + messages.
//
// The dashboard's `Conversation`/`Message` rows carry internal columns
// (account_id, user_id, sender_id) that shouldn't leak onto the public
// wire. These serializers project the stable public subset and rename
// the Meta id (`message_id` → `whatsapp_message_id`) to match the send
// endpoint's response vocabulary.
// ============================================================
import type { Conversation, Message } from '@/types';
export interface ApiConversation {
id: string;
contact_id: string;
status: string;
assigned_agent_id: string | null;
last_message_text: string | null;
last_message_at: string | null;
unread_count: number;
created_at: string;
updated_at: string;
contact: {
id: string;
phone: string;
name: string | null;
email: string | null;
company: string | null;
tags: { id: string; name: string; color: string }[];
} | null;
}
export interface ApiMessage {
id: string;
conversation_id: string;
direction: 'inbound' | 'outbound';
sender_type: string;
content_type: string;
content_text: string | null;
media_url: string | null;
template_name: string | null;
whatsapp_message_id: string | null;
status: string;
reply_to_message_id: string | null;
interactive_reply_id: string | null;
created_at: string;
}
/**
* Project a normalized `Conversation` (from `normalizeConversation`,
* which has already flattened `contact.tags`) into the public shape.
*/
export function serializeConversation(conv: Conversation): ApiConversation {
const c = conv.contact;
return {
id: conv.id,
contact_id: conv.contact_id,
status: conv.status,
assigned_agent_id: conv.assigned_agent_id ?? null,
last_message_text: conv.last_message_text ?? null,
last_message_at: conv.last_message_at ?? null,
unread_count: conv.unread_count ?? 0,
created_at: conv.created_at,
updated_at: conv.updated_at,
contact: c
? {
id: c.id,
phone: c.phone,
name: c.name ?? null,
email: c.email ?? null,
company: c.company ?? null,
tags: (c.tags ?? []).map((t) => ({
id: t.id,
name: t.name,
color: t.color,
})),
}
: null,
};
}
/** Project a `messages` row into the public shape. */
export function serializeMessage(m: Message): ApiMessage {
return {
id: m.id,
conversation_id: m.conversation_id,
// `customer` = inbound (from the contact); anything else is outbound.
direction: m.sender_type === 'customer' ? 'inbound' : 'outbound',
sender_type: m.sender_type,
content_type: m.content_type,
content_text: m.content_text ?? null,
media_url: m.media_url ?? null,
template_name: m.template_name ?? null,
whatsapp_message_id: m.message_id ?? null,
status: m.status,
reply_to_message_id: m.reply_to_message_id ?? null,
interactive_reply_id: m.interactive_reply_id ?? null,
created_at: m.created_at,
};
}

View File

@@ -0,0 +1,112 @@
import { describe, it, expect } from 'vitest';
import {
parseListParams,
encodeCursor,
decodeCursor,
keysetFilter,
buildPage,
DEFAULT_LIMIT,
MAX_LIMIT,
} from './pagination';
const req = (qs: string) => new Request(`https://x.test/api/v1/contacts${qs}`);
describe('parseListParams', () => {
it('defaults limit and cursor', () => {
expect(parseListParams(req(''))).toEqual({
limit: DEFAULT_LIMIT,
cursor: null,
});
});
it('clamps limit to MAX_LIMIT and floors it', () => {
expect(parseListParams(req('?limit=9999')).limit).toBe(MAX_LIMIT);
expect(parseListParams(req('?limit=10.9')).limit).toBe(10);
});
it('falls back to default on non-positive / NaN limit', () => {
expect(parseListParams(req('?limit=0')).limit).toBe(DEFAULT_LIMIT);
expect(parseListParams(req('?limit=-5')).limit).toBe(DEFAULT_LIMIT);
expect(parseListParams(req('?limit=abc')).limit).toBe(DEFAULT_LIMIT);
});
it('decodes a valid cursor and ignores a malformed one', () => {
const c = encodeCursor({
created_at: '2026-01-01T00:00:00Z',
id: '11111111-1111-4111-8111-111111111111',
});
expect(parseListParams(req(`?cursor=${c}`)).cursor).toEqual({
createdAt: '2026-01-01T00:00:00Z',
id: '11111111-1111-4111-8111-111111111111',
});
expect(parseListParams(req('?cursor=@@notbase64@@')).cursor).toBeNull();
});
});
describe('encode/decodeCursor round-trip', () => {
it('round-trips a real (ISO timestamp, UUID) cursor', () => {
const row = {
created_at: '2026-06-30T12:00:00.123Z',
id: 'abcdef01-2345-4678-8abc-def012345678',
};
expect(decodeCursor(encodeCursor(row))).toEqual({
createdAt: '2026-06-30T12:00:00.123Z',
id: 'abcdef01-2345-4678-8abc-def012345678',
});
});
it('returns null for empty / separator-less input', () => {
expect(decodeCursor(null)).toBeNull();
expect(decodeCursor('')).toBeNull();
expect(decodeCursor(Buffer.from('nosep').toString('base64url'))).toBeNull();
});
it('rejects a crafted cursor whose id is not a UUID (filter-injection guard)', () => {
// A hand-built cursor trying to smuggle PostgREST filter syntax
// through keysetFilter must be refused, not decoded.
const evil = Buffer.from(
'2026-01-01T00:00:00Z|x),or(account_id.neq.0',
'utf8'
).toString('base64url');
expect(decodeCursor(evil)).toBeNull();
});
it('rejects a cursor whose timestamp is not parseable', () => {
const bad = Buffer.from(
'not-a-date|11111111-1111-4111-8111-111111111111',
'utf8'
).toString('base64url');
expect(decodeCursor(bad)).toBeNull();
});
});
describe('keysetFilter', () => {
it('is null on the first page', () => {
expect(keysetFilter(null)).toBeNull();
});
it('walks strictly past the cursor row (older, or same-ts smaller id)', () => {
expect(keysetFilter({ createdAt: '2026-01-01T00:00:00Z', id: 'x' })).toBe(
'created_at.lt.2026-01-01T00:00:00Z,and(created_at.eq.2026-01-01T00:00:00Z,id.lt.x)'
);
});
});
describe('buildPage', () => {
const rows = Array.from({ length: 6 }, (_, i) => ({
created_at: `2026-01-0${i + 1}T00:00:00Z`,
id: `id${i + 1}`,
}));
it('returns all rows and null cursor when not over-fetched', () => {
const page = buildPage(rows.slice(0, 3), 5);
expect(page.items).toHaveLength(3);
expect(page.nextCursor).toBeNull();
});
it('trims to limit and emits a cursor for the last kept row', () => {
const page = buildPage(rows, 5);
expect(page.items).toHaveLength(5);
expect(page.nextCursor).toBe(encodeCursor(rows[4]));
});
});

View File

@@ -0,0 +1,123 @@
// ============================================================
// Cursor pagination for public API (v1) list endpoints.
//
// Every `/api/v1` list route (contacts, conversations, messages,
// broadcasts) pages the same way so integrators write one loop:
//
// GET /api/v1/contacts?limit=50
// → { "data": [...], "meta": { "next_cursor": "…" } }
// GET /api/v1/contacts?limit=50&cursor=… // next page
// → { "data": [...], "meta": { "next_cursor": null } } // last page
//
// Cursors are **keyset** (not offset): rows are ordered by
// `(created_at, id)` descending and the cursor encodes the last row's
// `(created_at, id)`. This is stable under concurrent inserts (an
// offset would skip/repeat rows when new data lands mid-scan) and
// stays fast at any depth. The cursor is an opaque base64 string —
// clients pass it back verbatim and never parse it.
// ============================================================
export const DEFAULT_LIMIT = 50;
export const MAX_LIMIT = 100;
export interface Cursor {
createdAt: string;
id: string;
}
export interface ListParams {
/** Clamped to [1, MAX_LIMIT]. */
limit: number;
/** Decoded cursor, or null on the first page. */
cursor: Cursor | null;
}
/**
* Parse `?limit` and `?cursor` off a request URL. `limit` is clamped
* to [1, MAX_LIMIT] (default {@link DEFAULT_LIMIT}); a malformed or
* unparseable `cursor` is treated as absent (first page) rather than
* erroring — the worst case is the client re-reads from the top.
*/
export function parseListParams(request: Request): ListParams {
const url = new URL(request.url);
const rawLimit = Number(url.searchParams.get('limit'));
const limit =
Number.isFinite(rawLimit) && rawLimit > 0
? Math.min(Math.floor(rawLimit), MAX_LIMIT)
: DEFAULT_LIMIT;
return { limit, cursor: decodeCursor(url.searchParams.get('cursor')) };
}
/** Encode a row's `(created_at, id)` into an opaque cursor string. */
export function encodeCursor(row: { created_at: string; id: string }): string {
return Buffer.from(`${row.created_at}|${row.id}`, 'utf8').toString(
'base64url'
);
}
// A cursor is only ever minted by `encodeCursor` from a real row's
// `created_at` (ISO timestamp) + `id` (UUID). We re-validate both on
// decode so a hand-crafted cursor can't smuggle PostgREST filter
// syntax into `keysetFilter`'s `.or()` string (the values are
// interpolated raw). Anything that doesn't look server-issued is
// treated as "no cursor" — the documented tolerance is to restart
// from the first page, never to run an attacker-shaped query.
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** Decode a cursor string, or null if missing/malformed/untrusted. */
export function decodeCursor(value: string | null): Cursor | null {
if (!value) return null;
try {
const decoded = Buffer.from(value, 'base64url').toString('utf8');
const sep = decoded.indexOf('|');
if (sep === -1) return null;
const createdAt = decoded.slice(0, sep);
const id = decoded.slice(sep + 1);
// Reject anything that isn't a plausible server-issued cursor: an
// ISO-8601 timestamp and a UUID. This is what keeps the raw
// interpolation in `keysetFilter` safe.
if (!UUID_RE.test(id)) return null;
const ts = Date.parse(createdAt);
if (Number.isNaN(ts)) return null;
return { createdAt, id };
} catch {
return null;
}
}
/**
* PostgREST `.or()` expression that walks *past* the cursor row under
* a `(created_at desc, id desc)` ordering: strictly-older rows, plus
* same-timestamp rows with a smaller id (the tie-breaker). Returns
* null on the first page. Apply as:
*
* let q = db.from('contacts').select('*').eq('account_id', accountId)
* .order('created_at', { ascending: false })
* .order('id', { ascending: false })
* .limit(limit + 1) // fetch one extra to detect a next page
* const f = keysetFilter(cursor)
* if (f) q = q.or(f)
*/
export function keysetFilter(cursor: Cursor | null): string | null {
if (!cursor) return null;
return `created_at.lt.${cursor.createdAt},and(created_at.eq.${cursor.createdAt},id.lt.${cursor.id})`;
}
/**
* Trim an over-fetched result set (query ran with `limit + 1`) down to
* `limit` and derive the `next_cursor`. When fewer than `limit + 1`
* rows came back, this is the last page and `nextCursor` is null.
*/
export function buildPage<T extends { created_at: string; id: string }>(
rows: T[],
limit: number
): { items: T[]; nextCursor: string | null } {
if (rows.length <= limit) {
return { items: rows, nextCursor: null };
}
const items = rows.slice(0, limit);
return { items, nextCursor: encodeCursor(items[items.length - 1]) };
}

View 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 }
);
}