wacrm/src/lib/api/v1/conversations.ts
Arnas Donauskas 8cb5792823 feat(api): public REST API v1 — messages, contacts, conversations, broadcasts (#245)
Complete the public API roadmap on top of the existing key-auth layer
(#290). Re-lands POST /api/v1/messages (lost when the stacked #291 never
reached main) by extracting the dashboard send route's core into
`sendMessageToConversation`, then adds:

- Contacts: GET/POST /api/v1/contacts, GET/PATCH /api/v1/contacts/{id}
- Conversations: GET list + /{id} + /{id}/messages
- Broadcasts: POST /api/v1/broadcasts + GET /{id} (persist rows, fan out
  in after(), poll for status)

All list endpoints share one keyset-pagination contract
({ data, meta: { next_cursor } }). No migration required — scopes and
tables already exist. Versioned as 0.3.0 (folded into [Unreleased]);
docs/public-api.md updated; outbound webhooks left as the sole roadmap
item. API stays v1 (additive).

Includes fixes from an adversarial code review: cursor validation
against filter injection, broadcast counts deferred to the DB aggregate
trigger, terminal broadcast status reflects failures, duplicate-phone
de-dup, up-front send-param validation (no orphan conversations),
PATCH null-clear semantics, error-checked tag sync, bounded tag filter,
unified audit-user attribution, and a maxDuration on the broadcast
fan-out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:10:19 +03:00

101 lines
3.0 KiB
TypeScript

// ============================================================
// 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,
};
}