Dependabot's lockfile regeneration drops the nested
node_modules/next-intl/node_modules/@swc/helpers@0.5.23 optional-peer
entry that CI's `npm ci` (Node 20 / npm 10) requires, so the job failed
at install before lint/typecheck/test/build ran. Regenerated
package-lock.json with npm 10 to restore it; no dependency versions change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An inbound message could create a second conversation for a contact
under a race (Meta retries a delivery, or a batch fans out to concurrent
after() runs). There was no UNIQUE index and no unique-violation
backstop on conversations, unlike contacts (migration 022). Worse, once
two conversations existed the `.single()` lookup errored on every later
message and the webhook created yet another conversation each time,
snowballing into a wall of duplicate chats.
- findOrCreateConversation now resolves oldest-first with .limit(1)
instead of .single(), so pre-existing duplicates converge, and catches
23505 on insert to re-resolve the winning row.
- Same hardening applied to the public-API resolver (resolve-conversation).
- Migration 036 merges existing duplicate conversations into the oldest
thread (re-pointing messages, reactions, deals, flow_runs,
notifications, ai_usage_log — no data loss) then adds a
UNIQUE (account_id, contact_id) index as the authoritative guarantee.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add full support for WhatsApp Cloud API interactive messages across the
inbox, automations, and a new quick-replies feature. Extends the
interactive plumbing that previously existed only for the Flows engine.
Sending
- send-message core handles `interactive` (reply buttons ≤3, list ≤10
rows) via the existing Meta senders; validated server-side before send.
- Inbox composer gains a "+" menu → builder dialog with live WhatsApp-style
preview + Meta char limits (button 20, list row 24, body 1024).
- Automation "Send Buttons" / "Send List" steps in the builder.
- Public v1 /messages accepts type=interactive.
Storage / round-trip
- migration 035: messages.interactive_payload JSONB + quick_replies table.
- Outbound interactive persists the structured payload (composer,
automations, and Flows) so the thread re-renders the buttons/rows.
Receiving
- Bubble renders outbound interactive via the shared preview; inbound taps
(button_reply/list_reply) keep the "chosen option" affordance.
Automations
- New `interactive_reply` trigger matches on tapped button/list IDs
(exact), enabling chained multi-step menus.
Quick replies
- New account-scoped quick_replies (text or interactive snippets), managed
in Settings and insertable from the composer; "Save as quick reply" in
the builder.
Shared core in src/lib/whatsapp/interactive.ts + src/components/interactive/.
Includes unit tests for the validator, send-param validation, the
interactive_reply trigger match, and the new automation step/trigger
validation. typecheck + lint clean; full suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The MCP registry namespace is case-sensitive and keyed to the GitHub
login: the owner is `ArnasDon`, not `arnasdon`. Publishing as
io.github.arnasdon/wacrm-mcp is rejected 403. Fix the name in
server.json and the matching mcpName in package.json to
io.github.ArnasDon/wacrm-mcp.
Bump to 0.1.1 because the published 0.1.0 baked in the lowercase
mcpName; the registry validates the published package's mcpName
against server.json, so a fresh npm version is required.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Shorten description to the registry's 100-char limit and migrate the
$schema to the current 2025-12-11 version (2025-10-17 is deprecated).
`mcp-publisher validate` now passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduce mcp-server/ — a stdio Model Context Protocol server that
wraps the /api/v1 public API so MCP clients (Claude, Cursor, etc.) can
drive a self-hosted wacrm instance in natural language.
- Thin wrapper over the existing REST API; all auth, scoping, and rate
limiting stay server-side. No new backend surface.
- Read-only by default: write and broadcast tools are only registered
when WACRM_ENABLE_WRITES / WACRM_ENABLE_BROADCASTS are set, layered
on top of the API key's own scopes. send_broadcast also requires an
explicit confirm and is marked destructive.
- Registry-ready: server.json manifest, package.json mcpName linked to
it (io.github.arnasdon/wacrm-mcp), and glama.json for discovery.
- Docs: docs/mcp.md + README bullet + mcp-server/README.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Brings in the i18n PR (#348) that landed on main and localizes the new
strings this branch added so nothing regresses the new next-intl
convention:
- ai-config.tsx: resolved import conflict; handoff-target picker strings
moved to Settings.aiConfig (handoffTo / handoffToDesc / handoffQueue).
- message-bubble.tsx: "AI" badge + tooltip → Inbox.bubble
(aiBadge / aiBadgeTitle).
- ai-thread-banner.tsx: take-over/resume banner + toasts → new
Inbox.aiBanner namespace.
The AI Agents section (/agents playground, setup tabs, usage card) is
still English on main, so those strings are left as-is for a future
section-wide i18n pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The security PR (#352) shipped 031_fix_profiles_update_rls.sql while
031_ai_reply_slot_grant.sql (#345) already held version 031. Supabase
keys schema_migrations on the numeric prefix, so applying both to a fresh
DB fails with `duplicate key ... (version)=(031)` (SQLSTATE 23505) — which
is what broke the Supabase Preview check on this PR.
Rename the newer, colliding file to 034 to restore unique versions. It's
idempotent and independent of the AI tables, so re-sequencing after 033 is
safe; a prod DB that already applied 031 will apply this fresh as 034
without side effects.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Correctness:
- Resume AI now releases ANY assignment (not just the caller's own).
A handoff-assigned thread previously stayed assigned after resume, so
the eligibility gate silently kept the bot muted — Resume was a no-op.
- GET /api/ai/usage: guard days >= 1 (Number(null)===0 is finite, so the
30-day default was unreachable and the window collapsed to 1 day).
- GET /api/ai/usage: align the query cutoff to the start of the oldest
charted local day so rows in the oldest partial day no longer count in
totals while missing from the daily series (totals now match the bars).
- ai-thread-banner: cache account AI-status per accountId and never cache
a transient fetch failure — fixes cross-account leak and the banner
staying hidden all session after one failed /api/ai/config call.
- /api/ai/draft: construct the service-role client inside a guard and
fire-and-forget the usage log, so a missing service-role key (or any
log failure) can't 500 an already-generated, already-billed draft.
Efficiency:
- Don't await usage logging on the reply hot path (auto-reply + draft);
logAiUsage swallows its own errors, so fire-and-forget is safe and
removes a DB round-trip from the customer-facing send.
Reuse / consistency:
- usage route reuses date-utils (daysAgoStart/lastNDayKeys/localDayKey)
and now buckets by LOCAL day like every other dashboard chart (was UTC).
- extract formatCompactNumber (shared with formatCurrencyShort); drop the
hand-rolled month table in favour of date-fns; extract
fetchAccountMembers/memberLabel and use them in the AI settings picker.
Resume still resets the per-conversation reply count (a deliberate,
manual, rate-limited re-engagement — documented in the handler).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surfaces the ai_usage_log data added in the previous commit:
- GET /api/ai/usage — admin-only aggregation (totals, per-mode,
per-model, zero-filled daily series) over a 7/30/90-day window,
mirroring the ai_usage_log SELECT policy
- AiUsageCard — stat tiles + daily token bar chart + by-model list,
rendered under a new admin-only "Usage" tab on the AI Agents page
Counts only; no message content is stored or shown.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Makes the AI auto-reply bot visible and controllable from the inbox,
completes the handoff, and records token spend. Builds on the existing
lib/ai engine rather than replacing it.
Track A — user-facing
- messages.ai_generated flag → small "AI" badge on bot-generated bubbles
- "Take over" / "Resume AI" banner per conversation
(POST /api/ai/autoreply/[id]) — take-over pauses + assigns to the agent
- handoff now assigns to a configurable target (agent or unassigned
queue) and writes an internal note summarizing the exchange; the
existing on_conversation_assigned trigger notifies the assignee
- Settings → AI: "Hand off to" target picker
Track B — observability + safety
- providers return normalized token usage; logged per run to ai_usage_log
(admin-readable) from both auto-reply and draft
- account-wide auto-reply rate limit (30/min) on top of the
per-conversation cap
Migration: 033_ai_reply_polish.sql (messages.ai_generated,
ai_configs.handoff_agent_id, conversations.ai_handoff_summary,
ai_usage_log). Tests + typecheck + lint pass (607 tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The send_webhook automation step fetched an account-controlled URL with
attacker-controlled headers and no SSRF protection, following redirects
by default — letting an agent+ reach cloud metadata (169.254.169.254),
loopback, and RFC1918 hosts from the server's network (status code
surfaces in automation logs). The separate webhook_endpoints delivery
path already guards this; the automation path did not.
Route send_webhook through the existing isDeliverableUrl guard before the
fetch, and mirror the deliver.ts hardening: redirect: 'manual' (a public
URL can't 3xx-bounce to an internal one) and a 10s timeout. Add an engine
regression test asserting a link-local target never reaches fetch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Flows and automations write routes verify only authentication/membership
(via the RLS client, whose *_select policies have no minimum role) and
then mutate with the service-role client, which bypasses the agent-gated
*_insert/update/delete RLS policies. A read-only `viewer` therefore had
full write power.
H3 (High): add requireRole('agent') to the flows write handlers —
PUT/DELETE (flows/[id]), activate (flows/[id]/activate), and create
(flows POST). GET/list stay open to viewers.
M2 (Medium): same gate on automations POST, the engine trigger
(automations/engine, which sends outbound WhatsApp), and the [id]
mutation handlers (PATCH/DELETE/duplicate). The cron route is unchanged —
it is gated by the x-cron-secret shared secret, not user auth.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
C1 (Critical): the profiles_update RLS policy gates only on user_id,
leaving account_role/account_id — the source of truth for
is_account_member() — freely writable by the row's own user. A
browser-client UPDATE could self-promote a viewer to owner or move
into another tenant. Add a BEFORE UPDATE trigger that rejects changes
to those columns when current_user = 'authenticated'; the SECURITY
DEFINER member/invitation RPCs (postgres) and the service_role backend
are unaffected.
H2 (High): match_ai_knowledge_fts/_semantic were SECURITY DEFINER,
filtered only on the caller-supplied p_account_id with no membership
check, and were granted to authenticated — letting any logged-in user
read another account's knowledge base via a foreign p_account_id.
Recreate both as SECURITY INVOKER so the existing
ai_knowledge_chunks_select RLS policy applies; the service_role
auto-reply bot still bypasses RLS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Integrated next-intl into ai-config.tsx and ai-knowledge.tsx panels.
- Defined translation schemas for Settings.aiConfig and Settings.aiKnowledge in en.json.
- Migrated hardcoded role labels from role-meta.ts to use translation keys dynamically in settings-overview.tsx using the 'roles' namespace.
- All Typescript and Linter checks successfully passed without regressions.
- Implemented next-intl across Appearance, Security, API Keys, and Deals settings panels.
- Defined translation schemas for Settings.appearance, Settings.security, Settings.apiKeys, and Settings.deals in en.json.
- Removed explicit 'any' types in components by utilizing local useTranslations hooks directly inside sub-components (ModeCard, ThemeCard, CreateKeyDialog).
- Eliminated all new typescript and eslint errors related to the localization migrations.
- Updated progress trackers to reflect completion.
- Add 'Broadcasts' namespace to en.json for all messaging flows
- Localize broadcast list and detail views (stats, recipient tables, funnel)
- Localize 4-step Broadcast creation wizard (Choose Template, Select Audience, Personalize, Schedule)
- Refactor status configuration to use translation keys instead of hardcoded labels
- Pass full typechecking across all wizard components
- Added `Sidebar`, `Header`, and `ModeToggle` namespaces to English dictionary.
- Refactored `sidebar.tsx` and `header.tsx` to use dynamic translations.
- Updated accessibility labels for navigation and theme switcher.
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>