mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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>
150 lines
4.7 KiB
TypeScript
150 lines
4.7 KiB
TypeScript
// ============================================================
|
|
// GET /api/v1/contacts — list contacts (scope: contacts:read)
|
|
// POST /api/v1/contacts — create a contact (scope: contacts:write)
|
|
//
|
|
// List is keyset-paginated (see src/lib/api/v1/pagination.ts) and
|
|
// supports `?search=` (name/phone) and `?tag=<tagId>` filters. Create
|
|
// is find-or-create by phone: an existing match returns 200 with
|
|
// `created: false`; a new row returns 201 with `created: true`.
|
|
// ============================================================
|
|
|
|
import { requireApiKey } from '@/lib/auth/api-context';
|
|
import { ok, okList, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
|
|
import {
|
|
parseListParams,
|
|
keysetFilter,
|
|
buildPage,
|
|
} from '@/lib/api/v1/pagination';
|
|
import {
|
|
CONTACT_SELECT,
|
|
serializeContact,
|
|
findOrCreateContact,
|
|
setContactTags,
|
|
getContactById,
|
|
resolveAuditUserId,
|
|
ContactError,
|
|
} from '@/lib/api/v1/contacts';
|
|
|
|
// PostgREST filter values are comma/paren-delimited; strip anything
|
|
// that could break the `.or()` grammar before interpolating a search
|
|
// term. Leaves the characters a phone or name legitimately contains.
|
|
function sanitizeSearch(raw: string): string {
|
|
return raw.replace(/[^\p{L}\p{N} +@.\-_]/gu, '').trim();
|
|
}
|
|
|
|
export async function GET(request: Request) {
|
|
try {
|
|
const ctx = await requireApiKey(request, 'contacts:read');
|
|
const { limit, cursor } = parseListParams(request);
|
|
const url = new URL(request.url);
|
|
const search = sanitizeSearch(url.searchParams.get('search') ?? '');
|
|
const tag = url.searchParams.get('tag');
|
|
|
|
// When filtering by tag, add an aliased INNER join on contact_tags
|
|
// used purely for the WHERE — the parent is kept only if it has the
|
|
// tag. The main `contact_tags(tags(*))` embed still returns the
|
|
// contact's FULL tag set for serialization. This filters in one
|
|
// bounded query (paged by limit+1) instead of pre-fetching an
|
|
// unbounded id list into an `.in(...)`.
|
|
const selectClause = tag
|
|
? `${CONTACT_SELECT}, tag_filter:contact_tags!inner(tag_id)`
|
|
: CONTACT_SELECT;
|
|
|
|
let query = ctx.supabase
|
|
.from('contacts')
|
|
.select(selectClause)
|
|
.eq('account_id', ctx.accountId);
|
|
|
|
if (search) {
|
|
query = query.or(`name.ilike.*${search}*,phone.ilike.*${search}*`);
|
|
}
|
|
|
|
if (tag) {
|
|
query = query.eq('tag_filter.tag_id', tag);
|
|
}
|
|
|
|
query = query
|
|
.order('created_at', { ascending: false })
|
|
.order('id', { ascending: false })
|
|
.limit(limit + 1);
|
|
|
|
const kf = keysetFilter(cursor);
|
|
if (kf) query = query.or(kf);
|
|
|
|
const { data, error } = await query;
|
|
if (error) {
|
|
console.error('[api/v1/contacts] list error:', error);
|
|
return fail('internal', 'Failed to list contacts', 500);
|
|
}
|
|
|
|
// Cast via unknown: the conditional `selectClause` (with the
|
|
// tag_filter alias) is a runtime string, so supabase-js can't infer
|
|
// a row type from it.
|
|
const { items, nextCursor } = buildPage(
|
|
(data ?? []) as unknown as Array<{ created_at: string; id: string }>,
|
|
limit
|
|
);
|
|
return okList(
|
|
items.map((r) => serializeContact(r as Record<string, unknown>)),
|
|
nextCursor
|
|
);
|
|
} catch (err) {
|
|
return toApiErrorResponse(err);
|
|
}
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
try {
|
|
const ctx = await requireApiKey(request, 'contacts:write');
|
|
|
|
const body = (await request.json().catch(() => null)) as Record<
|
|
string,
|
|
unknown
|
|
> | null;
|
|
if (!body || typeof body !== 'object') {
|
|
return fail('bad_request', 'Request body must be a JSON object', 400);
|
|
}
|
|
|
|
const phone = typeof body.phone === 'string' ? body.phone.trim() : '';
|
|
if (!phone) {
|
|
return fail('bad_request', "'phone' is required", 400);
|
|
}
|
|
|
|
const auditUserId = await resolveAuditUserId(ctx.supabase, ctx.accountId);
|
|
|
|
const { id, created } = await findOrCreateContact(
|
|
ctx.supabase,
|
|
ctx.accountId,
|
|
auditUserId,
|
|
{
|
|
phone,
|
|
name: typeof body.name === 'string' ? body.name : undefined,
|
|
email: typeof body.email === 'string' ? body.email : undefined,
|
|
company: typeof body.company === 'string' ? body.company : undefined,
|
|
}
|
|
);
|
|
|
|
if (Array.isArray(body.tags)) {
|
|
await setContactTags(
|
|
ctx.supabase,
|
|
ctx.accountId,
|
|
auditUserId,
|
|
id,
|
|
body.tags.filter((t): t is string => typeof t === 'string')
|
|
);
|
|
}
|
|
|
|
const contact = await getContactById(ctx.supabase, ctx.accountId, id);
|
|
return ok(contact, created ? 201 : 200);
|
|
} catch (err) {
|
|
if (err instanceof ContactError) {
|
|
return fail(
|
|
err.status === 400 ? 'bad_request' : 'internal',
|
|
err.message,
|
|
err.status
|
|
);
|
|
}
|
|
return toApiErrorResponse(err);
|
|
}
|
|
}
|