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:
65
wacrm/src/lib/api/v1/contacts.test.ts
Normal file
65
wacrm/src/lib/api/v1/contacts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
223
wacrm/src/lib/api/v1/contacts.ts
Normal file
223
wacrm/src/lib/api/v1/contacts.ts
Normal 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>);
|
||||
}
|
||||
54
wacrm/src/lib/api/v1/conversations.test.ts
Normal file
54
wacrm/src/lib/api/v1/conversations.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
100
wacrm/src/lib/api/v1/conversations.ts
Normal file
100
wacrm/src/lib/api/v1/conversations.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
112
wacrm/src/lib/api/v1/pagination.test.ts
Normal file
112
wacrm/src/lib/api/v1/pagination.test.ts
Normal 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]));
|
||||
});
|
||||
});
|
||||
123
wacrm/src/lib/api/v1/pagination.ts
Normal file
123
wacrm/src/lib/api/v1/pagination.ts
Normal 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]) };
|
||||
}
|
||||
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