Clicking "Add node" in the flow builder crashed the whole page to a
"This page couldn't load" error boundary, in both the list and canvas
views.
The add-node menus render a DropdownMenuLabel per category. That is
base-ui's Menu.GroupLabel, which calls useMenuGroupRootContext() during
render and THROWS ("MenuGroupContext is missing") when it has no
Menu.Group ancestor. Both menus wrapped each category in a plain <div>
instead of a DropdownMenuGroup, so the label had no group context. The
menu content only mounts when the menu opens, which is why initial
render was fine and the crash fired exactly on click.
Wrap each category's label + items in DropdownMenuGroup (base-ui
Menu.Group), which provides the context Menu.GroupLabel needs. The
between-group separator moves into a keyed Fragment so it stays a
sibling of the groups.
Adds a regression test pinning the contract: a bare DropdownMenuLabel
throws, a group-wrapped one does not.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Migration 029 created claim_ai_reply_slot(uuid, integer) as SECURITY
DEFINER but never GRANTed EXECUTE — the only function in the schema
missing its grant (007/012/017/018/019/025/030 all grant theirs).
SECURITY DEFINER sets the privileges a function runs *with*, not who may
*call* it: the caller still needs EXECUTE. On instances where the default
PUBLIC execute privilege on public-schema functions has been revoked
(hardened / self-hosted Supabase), the service_role that PostgREST uses
cannot invoke it. The AI auto-reply path runs entirely under the
service-role client (the inbound webhook has no auth.uid()), so
db.rpc('claim_ai_reply_slot') fails permission-denied, the dispatch bails
before sending, and the bot silently never answers ANY inbound message.
The Playground works because it never claims a slot — matching the report.
- 031_ai_reply_slot_grant.sql: new migration granting EXECUTE to
service_role (029 is already applied on existing installs, so it needs
its own migration to reach them). service_role only — the sole caller —
matching the 007/012 increment-counter precedent.
- 029_ai_reply.sql: add the same grant next to the definition so fresh
installs are correct.
- auto-reply.ts: log a real claim RPC error instead of returning
silently, so this class of deploy failure is diagnosable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Promotes the AI assistant out of Settings into a first-class "AI Agents"
sidebar area.
- Sidebar: new "AI Agents" item → /agents.
- /agents page with two tabs:
- Playground — a test chat (POST /api/ai/playground) that runs the
real auto-reply path (KB retrieval + provider, auto_reply prompt,
multi-turn, shows handoff). loadAiConfig gains requireActive so you
can test before enabling.
- Setup — the existing provider/key + knowledge-base config, relocated.
- Removed the AI section from Settings (settings-sections + page); same
account config, new home. No migration.
Tests +6 (query, loadAiConfig requireActive). 593 pass; typecheck, lint,
build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Security: REVOKE ALL FROM PUBLIC + GRANT EXECUTE (authenticated,
service_role) on the match RPCs so anon can't read a KB by supplying
an account_id (mirrors migrations 018/025).
- Ingest: store chunks even when embedding fails (insert first, rethrow
after) so a doc stays lexically searchable; the "lexical still works"
warning is now truthful.
- Retrieval: short-circuit on an empty KB (cheap COUNT) to avoid a paid
query embedding + 2 RPCs on every draft/auto-reply.
- Migration: HNSW instead of IVFFlat (robust on small/growing tables);
language-neutral 'simple' FTS config (was 'english'); semantic RPC
takes text and casts ::vector(1536) to remove PostgREST bind ambiguity.
- Embeddings key: distinguish corrupt-from-absent (loadEmbeddingsKey
returns {key,corrupt}); log on decrypt failure; reindex now reports
failure instead of false success; POST/PATCH warn on keyword-only save.
- embedTexts: fail loud when the provider omits result indices (was
silently misaligning chunks with vectors).
Tests: +3 (empty-KB guard, embed-failure-still-indexes, missing-index).
587 pass; typecheck/lint/build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Grounds the AI assistant (0.5.0) in the account's own content so it can
answer instead of handing off.
- Hybrid retrieval: Postgres full-text search for every account; optional
semantic search (pgvector + OpenAI text-embedding-3-small) when an
embeddings key is set. Semantic-primary, topped up with FTS.
- src/lib/ai: chunk, embeddings, knowledge (ingest + retrieve), query;
buildSystemPrompt gains a knowledge block; config carries an
(optional) embeddings key.
- Wired into /api/ai/draft and the auto-reply bot (best-effort — never
blocks a reply).
- KB API (/api/ai/knowledge CRUD + /reindex) and a Settings → AI
Assistant knowledge-base manager + embeddings-key field.
- Migration 030: pgvector, ai_knowledge_documents, ai_knowledge_chunks
(fts + embedding), match RPCs, ai_configs.embeddings_api_key.
Migration required: supabase/migrations/030_ai_knowledge.sql.
Stacked on feat/ai-reply-assistant (#329).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an AI reply assistant powered by the account's own OpenAI or
Anthropic key (stored AES-256-GCM-encrypted; no per-seat AI fee).
- AI-drafted replies in the inbox composer (✨ button, agent+), via
read-only POST /api/ai/draft.
- Optional auto-reply bot on inbound messages that no Flow consumed and
no agent owns, with a per-conversation cap and clean [[HANDOFF]] to a
human. Stands down when a message-level automation would also reply.
- Settings → AI Assistant (admin+): provider/model, key, business
context, toggles, and a Test-key button that verifies before saving.
- Provider layer (src/lib/ai) with OpenAI + Anthropic adapters behind
one interface; migration 029 (ai_configs + per-conversation columns +
atomic claim_ai_reply_slot RPC).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the public API roadmap (#245): register HTTPS endpoints to be
POSTed to when things happen in an account, so automations can react to
inbound activity instead of polling.
- Migration 028: account-scoped webhook_endpoints (secret stored
AES-GCM-encrypted) + an atomic record_webhook_failure() function.
- Scope webhooks:manage; management API GET/POST /api/v1/webhooks and
GET/PATCH/DELETE /api/v1/webhooks/{id} (secret returned once).
- Events message.received / conversation.created / message.status_updated
dispatched from the inbound webhook handler's after() block.
- Signed delivery (X-Wacrm-Signature = HMAC-SHA256 over t.body), single
best-effort attempt, auto-disable after consecutive failures.
- Versioned 0.4.0; docs/public-api.md Webhooks section; CHANGELOG.
Fixes from the adversarial code review of this diff:
- SSRF: block private/loopback/link-local (incl. cloud metadata) targets
at delivery time + redirect:'manual' so a public URL can't bounce
internal (src/lib/webhooks/ssrf.ts).
- Status mirror UPDATE no longer uses .select().maybeSingle() (message_id
is non-unique); account resolved via a bounded limit(1) read.
- Atomic failure counter via SQL RPC (was a racy read-modify-write).
- conversation.created emitted before the reaction short-circuit.
- Per-delivery id added for receiver dedupe; docs state at-least-once /
unordered delivery.
- Signature verify tolerates hex case / header whitespace.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Adds contact-based filters to the Inbox conversation list (issue #272):
- Multi-select tag filter with OR logic (a conversation matches if its
contact has any selected tag), consistent with Broadcast audiences.
- Company filter (exact match) over companies present in the inbox.
- Filters stack with the existing status filter and search; active
selections show as removable chips with a clear-all control.
Tags are hydrated via a shared CONVERSATION_SELECT embed and normalized
onto contact.tags, so the realtime hydrate path keeps new conversations
matching an active filter. Filtering logic lives in a pure, unit-tested
matchesContactFilters helper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The WhatsApp settings load effect re-ran fetchConfig() whenever `user`
or `profileLoading` changed. Supabase's onAuthStateChange fires a token
refresh (new `user` object, profileLoading toggles) when the browser
tab regains focus, so returning to the tab re-hydrated the form and
overwrote any unsaved credential input.
Guard the effect with a ref tracking the account id already loaded, so
it only fetches once per account and ignores auth-state churn that
doesn't actually change the account. Explicit reloads after save,
registration verification, and reset still call fetchConfig directly.
Closes#307
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace `user` object with `user?.id` in dependency arrays to prevent unnecessary re-renders. Add `lastFetchedUserIdRef` to track the user ID for successfully initiated profile fetches, preventing redundant API calls when the window refocuses or the auth token is refreshed.
The inbox/contact TemplatePicker fetched approved templates filtered by
`user_id = current user`, so in a shared account a teammate only saw
templates they personally created — others' approved templates were
hidden, leaving them unable to send those templates.
message_templates is account-owned and its RLS SELECT policy already
scopes rows to account members (is_account_member, migration 017), so
the user_id filter was both redundant and over-restrictive. Drop it and
rely on RLS, matching the broadcast template picker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a "Send template" action to the contact detail view so the
business can initiate (or re-open) a WhatsApp conversation with one
known contact by sending an approved template — without the awkward
single-recipient broadcast workaround.
The /api/whatsapp/send route now accepts `contact_id` as an
alternative to `conversation_id`: it verifies the contact is in the
caller's account, find-or-creates the contact's conversation
server-side, then runs the existing send path (phone-variant retry,
template send-builder, message + conversation persistence, flow
pause). The contact view reuses the existing inbox TemplatePicker for
template selection + variable/header/button input, keeping Meta
compliance unchanged (approved templates only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The canvas and list add-node menus both listed all 10 node types in
one flat dropdown. As the type list grows — especially for forks
adding their own node types (see #312) — a flat list gets hard to
scan. Bucket the types into Messaging / Logic & data / Flow control,
driven by a new `category` field on NODE_META so a new type can't go
uncategorized, and a `groupNodeTypesByCategory` helper shared by both
menus so they stay in lockstep.
No triggers/events category: in wacrm a flow is triggered by
flow-level config (trigger_type), not a canvas node, so `start` lives
under Flow control.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The POST handler ack'd Meta with 200 and then ran processWebhook() as a
detached promise. On Vercel the function can be frozen/terminated the moment
the response is sent, so those DB writes were not guaranteed to complete.
This dropped a non-deterministic subset of inbound messages: the contact and
conversation rows (written first) landed, but the message insert (written
after) often did not — leaving conversations that appear in the inbox with an
empty thread, with no durable logs to explain it (issue #301).
Move the work into next/server `after()`, which keeps the function alive until
the callback resolves (within the route's maxDuration) while still ack'ing Meta
fast enough to avoid retry-driven duplicate inserts. Add maxDuration=60 for
headroom since processing can fan out to per-media Meta verification calls.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reworks the flow editor (/flows/[id]) to the Flow Builder design
handoff, adapted to wacrm's existing tokens, components, and
light+dark theming. Keeps all behaviour — React Flow canvas,
drag-to-connect, typed config forms, validation, save/activate/delete,
per-browser view persistence, mobile-forces-list.
- shared.tsx: per-node-type color system (nodeColors) + one-line
blurbs, and a shared NodeIconChip component. Label color blends
toward --foreground via color-mix so it stays readable in both modes.
- header.tsx: compact single-row toolbar (back / icon / inline name /
status chip / edited dot · Runs / Delete / Activate / Save) plus a
subtle inline description note.
- flow-editor-shell.tsx: app-like full-height layout (toolbar → mode
row → stage → validation bar) with a segmented Canvas/List control
and a complete node-type legend derived from NODE_META.
- flow-canvas.tsx: per-type colored node cards, branch ports, dot-grid
background, rounded controls/minimap, top-left primary Add-node menu,
colored inspector header.
- flow-builder.tsx: list rows tied to the same per-type color language.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Templates with an IMAGE/VIDEO/DOCUMENT header could not be broadcast:
the wizard never offered a place to provide the header media URL, so
the send either shipped the template's stale stored URL or — when none
was stored — threw "image header requires a media link or id at send
time", failing the whole broadcast. The send path already supported
`messageParams.headerMediaUrl`; only the UI/hook never collected it.
- step3-personalize: show a required "Header media" URL field (with
http(s) validation and an image preview) when the selected template
has a media header; seed it from the template's stored sample URL and
block Next until a valid URL is provided.
- new-broadcast page: thread headerMediaUrl through wizard state.
- use-broadcast-sending: pass it as messageParams.headerMediaUrl to the
broadcast API for media-header templates.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>