feat(whatsapp): Evolution Go provider, connection reliability, and inbox UX - #528
Open
karlmarxmint wants to merge 17 commits into
Open
feat(whatsapp): Evolution Go provider, connection reliability, and inbox UX#528karlmarxmint wants to merge 17 commits into
karlmarxmint wants to merge 17 commits into
Conversation
…tive Lets an account connect via Evolution Go (self-hosted, whatsmeow-based, unofficial WhatsApp API) instead of Meta — QR pairing, its own inbound webhook, admin-gated instance lifecycle, and a danger-zone conversation reset for switching numbers/providers cleanly. send-message.ts and the automation/flow senders branch on whatsapp_config.provider so messages are indistinguishable in the CRM regardless of transport. Also adds a pt-BR locale (full parity with en.json) and a UI language switcher, independent of account settings so it works pre-login too. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…, not container TZ new Date().getHours() reads the container's own clock — in production wacrm-app-1 runs with no TZ set (defaults to UTC) while the business operates on America/Sao_Paulo, so every out-of-office condition was shifted 3 hours and silently skipped sending during part of the real off-hours window (confirmed live: automation_logs showed branch=no at 09:56 BRT for a 22:05-10:59 schedule, with no error surfaced anywhere). Also labels the automation log detail with the real send provider (Evolution vs Meta) instead of a hardcoded "sent via Meta", since that made this incident harder to read.
- Add readBodyWithLimit: the Meta and Evolution Go inbound webhooks are unauthenticated until after the body is buffered (signature/token check needs the raw bytes). Without a cap, an anonymous POST with a multi-GB body exhausts the single Node process serving every tenant before any auth check runs. Meta payloads cap at 5MB; Evolution Go gets 25MB since it inlines media as base64. - Cap /api/whatsapp/broadcast (dashboard route) at the same MAX_RECIPIENTS the public /api/v1/broadcasts already enforces — it only rate-limited how often a campaign starts, not how many messages one call fans out, so an agent-role account could blast an unbounded list and risk the WhatsApp number getting banned for spam.
…ole/ownership checks
- /api/automations/[id] (GET/PATCH/DELETE) and .../duplicate filtered
by user_id, which is more restrictive than the actual
automations_update/_delete RLS policy (agent+ on the account, not
just the creator) — a teammate who didn't author an automation got a
404 trying to open/edit/duplicate/delete it even though it shows up
in their own account's list. Switched all four to account_id, matching
how flows/quick-replies/templates already behave.
- /api/whatsapp/config POST/DELETE had no requireRole('admin'), unlike
every sibling settings route (templates/sync, evolution/qr, ...).
RLS already blocks non-admin writes, but the service-role client used
here to detect cross-user phone_number_id conflicts bypasses RLS, so
the app needs its own gate too — added canEditSettings() check.
- /api/whatsapp/media/[mediaId] resolved account_id but never checked
that the requested mediaId actually belongs to a message in one of
that account's conversations before spending the account's Meta
token on it. Added a lookup against messages/conversations before
calling Meta — defense in depth, since relying on Meta's own token
scoping for cross-tenant isolation was never a documented contract.
- header_media_url (message template validator) accepted http:// and
https://; the rest of the app (webhook_endpoints) is https-only for
any server-side fetch of a user-supplied URL. Tightened to https-only
and fixed the validator's try/catch swallowing its own scheme error
message.
…h to 8
forgot-password/page.tsx has always redirected to /auth/callback?next=
/reset-password, but neither route existed — every reset-email link
404'd and password recovery didn't actually work. Added:
- src/app/auth/callback/route.ts: exchanges the emailed PKCE code for
a session (standard @supabase/ssr Next.js pattern), then redirects
to `next` (validated as an in-app path, not trusted as-is since it
rides in a public email link).
- src/app/(auth)/reset-password/page.tsx: plain updateUser({password})
form, same call password-form.tsx already uses for the logged-in
change-password flow.
Also raised the signup minimum password length from 6 to 8, matching
MIN_PASSWORD already used by the settings change-password form —
6 was inconsistent with the rest of the app and had no complexity
backstop. Note: this is a client-side/UX gate only — the authoritative
minimum lives in this Supabase project's Auth settings, which isn't
something this repo controls.
Live-tested the reset flow end-to-end (admin-generated recovery link -> verify -> session -> password update -> sign-in with new password). The server-only route.ts committed earlier only handled ?code=... (PKCE) and would silently fail on this project: a real recovery link comes back as #access_token=...&refresh_token=...&type=recovery in the URL *fragment*, which a Route Handler can never see (fragments never reach the server). Replaced it with a client page that tries the PKCE exchange first and falls back to reading the fragment + setSession() when there's no code — covers whichever shape actually fires without having to trust which flow Supabase picks for a given link.
…s burn links)
Live-tested the link-based flow against a real account after fixing
Site URL / Redirect URLs in Supabase: recovery_sent_at and
last_sign_in_at were 42 seconds apart — far too fast to be the human
opening the email — meaning some automated scanner (common with
Gmail/Outlook/corporate mail security) followed the single-use
recovery link before the user could, burning the token. The user's
own click then correctly got "invalid or expired", with no way to
tell why.
forgot-password/page.tsx now does a two-step flow: request a code,
then verifyOtp({ email, token, type: 'recovery' }) with the code from
the same email. A scanner has nothing to click; the code only works
for whoever actually reads the email and types it in. The link stays
in the email too (redirectTo unchanged) as a fallback via
/auth/callback, but the code is what the UI leads with.
Also caught live: this project's configured OTP length is 8 digits,
not the commonly-assumed 6 — confirmed via admin API's `email_otp`
field before shipping, otherwise the maxLength={6} input would have
silently truncated every real code.
… handling Evolution Go's whatsmeow session can go silently dead (network blip, stale websocket) without ever firing a webhook event — confirmed live, a session sat disconnected ~3h with whatsapp_config.status still reporting "connected". Adds a polling health-check (/api/whatsapp/evolution/health-check, cron-secret gated like /api/automations/cron) that asks Evolution Go's own GET /instance/status directly and resumes a dropped session with backoff (migration 038 tracks the attempt count/timestamp so a prolonged outage settles into a steady poll instead of a retry storm). Also, in the same reliability pass: - Disable Evolution's auto read-receipt and always-online flags, which were marking messages "read" and suppressing the linked phone's push notifications before any human looked at anything; wacrm now sends the read receipt explicitly when an agent opens a thread (/api/whatsapp/evolution/mark-read). - Upload inbound media (Evolution inlines it as base64, never a URL) to the chat-media bucket so it gets a real fetchable URL, including a fix for voice notes being rejected outright by the bucket's strict mime-type match. - Handle messages the agent sent from the linked phone directly (previously dropped entirely), resolve LID-addressed chats to a real phone number instead of fragmenting the conversation, and catch group messages that arrive without IsGroup set. - Cap the webhook body at 25MB against the inlined-media payload shape.
…validation errors Evolution Go has no template-approval concept — it's a paired WhatsApp session, not the Cloud API, and any template renders down to plain text. /submit and PATCH now skip Meta entirely for an Evolution-provider account (no waba_id check, no resumable-upload header handle, no Meta network call) and mark the row APPROVED immediately so it's usable right away, instead of forcing every account through checks only a Meta account can satisfy. Also moves template validation errors from thrown plain-English Error(message) to a TemplateValidationError carrying an i18n code + values, so a validation failure renders in the user's own language via Settings.templates instead of always in English. Status labels and category labels (template-status.ts) move to the same pattern.
…erts - "Send to pipeline" button on a message opens a dialog to create a deal for that contact directly from the thread (send-to-pipeline-dialog.tsx). - Wires the explicit Evolution read-receipt call (added in the reliability commit) into MessageThread: fires when an agent actually views a thread, gated on tab visibility rather than document.hasFocus() (confirmed live that hasFocus() misreports and can permanently wedge the send). Also skips the Meta-only 24h session-timer badge for Evolution accounts, which have no such Cloud API restriction. - New-message chime (synthesized via Web Audio, no asset to ship) plus a native desktop notification when the inbox tab isn't the visible one, both toggleable and persisted per device. Alerts only on customer-sent messages, not an agent's own or a bot reply. - Conversation list now re-sorts after realtime patches (position was frozen at initial fetch order) and sorts a message-less conversation to the bottom instead of Postgres's default NULLS FIRST. - Pipeline deal card's "open conversation" now find-or-creates via /api/contacts/[id]/conversation (new) instead of erroring when a contact has no conversation left after a reset — race-safe on migration 036's unique index.
…eatures Covers Settings.templates (Evolution-specific copy, validation-error keys, status/category labels) and Inbox.sendToPipeline / Inbox.conversationList.*Sound added across the last three commits. ko.json still needs these keys — messages.test.ts currently fails on it.
… features messages.test.ts's catalogue-parity check was failing — ko.json was missing the 76 keys added alongside the Evolution template support and inbox send-to-pipeline/sound features. Translates them to match.
Covers the Evolution Go reliability, template support, and inbox features from the last four commits — includes the migration-required and cron-required notes self-hosters need before/after pulling this.
037_whatsapp_provider.sql and 038_evolution_health_check.sql collided with unrelated upstream migrations that landed under the same numbers (037_webhook_broadcast_reliability.sql, 038_broadcast_resume.sql) while this branch sat unpushed. Renumbered ours to 040/041 (continuing after upstream's 039) and updated the code comments that pointed at the old numbers by filename.
- broadcast-resume.ts still built BroadcastPlan with the old phoneNumberId/accessToken fields; switch it to resolveEngineProviderCreds like broadcast-core.ts and the broadcast route already do post-merge. - webhook/route.test.ts's fake Request only implemented .text() — the new readBodyWithLimit() reads the real body stream, so every test silently saw an empty body and never reached the insert/upsert logic. Give the fixture a real ReadableStream body. - pt-BR.json (new in this branch) predates upstream's AccountAccess, broadcast-resume, and inbound-media-mirror strings; translate the 19 keys the catalogue-parity test flagged as missing.
added 2 commits
August 26, 2026 16:15
…coded strings
The Flow Builder editor toolbar (header.tsx: Back, flow name, status
chip, Runs/Delete/Pause/Activate/Save, description) had zero i18n —
100% hardcoded English regardless of locale. Wired it through
next-intl under the existing Flows.builder namespace.
Also swept the rest of the flow editor for strings that looked
translated (NODE_META/NODE_CATEGORIES in shared.tsx use dynamic
t(`nodes.${type}.label`) lookups at their call sites, so those were
already fine) but weren't:
- edges.ts's "Next"/"true"/"false" edge labels were hardcoded in a
framework-free lib module; it now takes an optional translator
function (defaults to the old English strings, so existing
callers/tests are unaffected) that flow-canvas.tsx supplies from
its own next-intl hook.
- node-config-form.tsx: default seed titles for new buttons/list rows,
the "Remove section" button, the duplicated "Pick a tag…"
placeholder (now reusing the Flows.builder.form.pickTag key that
already existed but was never wired to this one), SendListForm's
body-text label, and the contact-field picker's four raw English
option words (name/email/phone/company).
- flow-editor-shell.tsx's Canvas/List toggle group aria-label.
New keys added to en.json, ko.json, and pt-BR.json identically so the
catalogue-parity test stays green.
…tent
The three starter flow templates (Welcome menu, FAQ bot, Lead capture)
were plain hardcoded-English data — both the gallery card text (name/
description, shown when picking a template) and, worse, every piece of
customer-facing copy in the flow a template clones (message bodies,
button/row titles, collect_input prompts, handoff notes): cloning a
template always produced a flow that would message customers in
English regardless of the account's locale.
Mirrors the existing src/lib/automations/templates.ts pattern (which
already solved this for automation templates): each template is now a
resolve(t) function built from a translator instead of a literal
object, with the copy living in messages/*.json under
`Flows.templates.<slug>.*`. Both call sites —
GET /api/flows/templates (gallery) and POST /api/flows with
template_slug (clone) — resolve it server-side via
getTranslations('Flows.templates') so the result already matches the
caller's locale.
Two fields carry the flow engine's own {{vars.x}} interpolation syntax
(lead_capture's ask-email prompt and handoff note) — read those with
t.raw() instead of t(), since a plain t() call would try to parse the
double braces as ICU and fail.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
send-message.tsand the automation/flow senders branch onwhatsapp_config.providerso messages are indistinguishable in the CRM regardless of transport./api/whatsapp/evolution/health-check) detects and recovers a silently-dropped whatsmeow session, backing off between reconnect attempts. Explicit read receipts (sent only when an agent opens the conversation) and inline media upload for outbound sends.time_of_dayautomation condition now evaluates in the account's business timezone instead of the container's; account-scoped ownership checks on automations that were previously author-scoped; the OTP-based password reset flow (superseded upstream — see note below).Notes for the reviewer
main(38 commits happened here in the meantime, several overlapping the same files). Two migrations collided on number with ones already merged upstream and were renumbered:037_whatsapp_provider.sql→040_whatsapp_provider.sql,038_evolution_health_check.sql→041_evolution_health_check.sql.automation-builder.tsx, PR [bug] Send Buttons Issue in Automations Section #474) — the upstream version was kept, this branch's duplicate work was dropped in the rebase.main; kept it since Evolution accounts still hit the same Gmail-link-scanner issue, but flagging in case upstream already addressed this differently.npm run typecheck,npm run lint, andnpm test(835/835) all pass post-rebase.Test plan
npm run typecheck && npm run lint && npm test🤖 Generated with Claude Code
https://claude.ai/code/session_01DP3Ua7KpuW7g4yZ9Gm1eAE