mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-03 01:39:46 +00:00
2e911d5e5a
8 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2e911d5e5a
|
fix(multi-user): share whatsapp_config + templates by account
Reported by @ArnasDon: a teammate accepting an invite landed in the
shared account but the WhatsApp Config tab still showed "not
connected." Inbox banner read "WhatsApp not connected" too.
Cause: PR 8 of the multi-user series converted the *webhook* and
the *automations/flows engines* to account-scoped lookups, but
left every user-facing API route + UI page still filtering by
`user_id`. Post-017 `whatsapp_config` and `message_templates` are
account-scoped (one row per account, with `account_id NOT NULL`),
so `.eq('user_id', user.id)` returned zero rows for any teammate
who didn't personally save the config — including everyone who
joined via invite.
This PR completes the conversion across the user-facing surface.
Surface (11 files)
- `src/components/settings/whatsapp-config.tsx` — read accountId
from `useAuth()`; trigger fetch on profile-ready, not just
user-ready; pass accountId through to the supabase query.
- `src/app/(dashboard)/inbox/page.tsx` — resolve account_id from
profiles, query whatsapp_config by account_id for the
connected-banner state.
- `src/app/api/whatsapp/config/route.ts` — GET/POST/DELETE all
resolve account_id from profile. The phone_number_id collision
check (service-role) now compares against `account_id`, not
`user_id` — two accounts still can't share a Meta number, two
teammates in one account share one row by design. INSERT
populates `account_id` (tenancy) + `user_id` (audit).
- `src/app/api/whatsapp/config/verify-registration/route.ts` —
same conversion.
- `src/app/api/whatsapp/send/route.ts` — conversation,
whatsapp_config, message_templates lookups all switch to
account_id; rate-limit key stays per-user.
- `src/app/api/whatsapp/react/route.ts` — conversation +
whatsapp_config switch to account_id.
- `src/app/api/whatsapp/broadcast/route.ts` — whatsapp_config +
message_templates + broadcasts switch to account_id.
- `src/app/api/whatsapp/media/[mediaId]/route.ts` — config
lookup switches to account_id.
- `src/app/api/whatsapp/templates/submit/route.ts` — config
lookup + every message_templates SELECT/upsert now uses
account_id. The upsert ROW gains `account_id` so the
NOT NULL constraint added by migration 017 stops rejecting
new templates (this was an incidental bug — every new
template post-017 would have been broken regardless of
multi-user).
- `src/app/api/whatsapp/templates/sync/route.ts` — same.
- `src/app/api/whatsapp/templates/[id]/route.ts` PATCH +
DELETE — same.
Pattern at every route:
const { data: profile } = await supabase
.from('profiles')
.select('account_id')
.eq('user_id', user.id)
.maybeSingle()
const accountId = profile?.account_id as string | undefined
if (!accountId) return 403
then every downstream `.eq('user_id', user.id)` → `.eq('account_id', accountId)`.
Known follow-up not in this PR
- `message_templates` still has its UNIQUE index on
`(user_id, name, language)` from migration 014; the submit
upsert's `onConflict` targets that same triple. As a result,
two teammates can still INSERT same-named templates without
conflicting. The fix needs a small migration (drop legacy
index, add `(account_id, name, language)` index, switch the
`onConflict` target) — TODO comment left in the route file so
it's not forgotten.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
c63a68c01e
|
chore(templates): polish pass from senior backend / FE / UX review
A focused cleanup PR that addresses the high-signal findings from a self-review of PRs #148-153. Every change is local to the templates surface; no schema or wire-format changes. Safety ------ * Delete now goes through a confirmation Dialog (mirrors the existing tag-manager pattern). Copy explicitly tells the user when Meta will be hit, so a slip on the trash icon doesn't take the template off Meta as well as locally. * AUTHENTICATION submit is now blocked by a persistent in-dialog banner + disabled submit button. The disappearing toast that used to fire on submit is gone — defensive `return` remains in handleSubmit as a second line of defense. * updateButton uses a per-variant switch + ButtonPatch union instead of the partial-spread + `as TemplateButton` cast. Each variant's spread keeps its discriminant pinned; TS enforces exhaustiveness. * New template-row-guard.ts surfaces malformed Supabase rows at the route boundary instead of letting buildSendComponents crash deep in the stack with an opaque TypeError. * UUID regex check on /[id] PATCH+DELETE so non-UUID payloads return a clean 400 before hitting Supabase. Submit-route cleanup -------------------- * Two near-identical upsert blocks (error path + success path) collapsed into a buildUpsertRow + upsertTemplateRow helper. Adding a column now touches one place, not two. UX copy + feedback ------------------ * Submit success toast sets the right expectation: "typical review time is 24 hours. Status updates automatically." Old copy just said "status will update once approved." * Header media URL helper text is now actually useful: HTTPS requirement, ~24h lifetime, per-format size/format recommendations (JPEG/PNG ≥800×418 ≤5 MB for images, MP4 ≤16 MB for video, etc). * Truncated-sync toast switches from `warning` (which auto-dismisses fast) to `error` with `duration: 10000` and concrete copy: "Synced the first 2000 templates only — your account has more. Sync again to continue." * Edit and Resubmit buttons gain visible text labels alongside their icons + better aria-label/title pairs. A lone rotate icon wasn't self-explanatory. * Broadcast picker drops the redundant "Approved" chip — every template in that list is APPROVED by filter, so the chip carried zero information. Unused templateStatusConfig import removed. State + a11y ------------ * handleSubmit now awaits fetchTemplates BEFORE closing the dialog so re-opening immediately doesn't see a stale list. * Header content / media URL / sample are preserved across format switches in the New Template form. The payload builder is already format-aware, so orphan values on hidden fields are harmless — and keeping them lets users switch formats to compare without losing typing. * Button-type Select gains the same null-guard the header Select got in PR #148 (per @base-ui semantics, onValueChange can fire with null on deselect). * Dynamic body-sample inputs get id + aria-label so each one is individually addressable (WCAG A — screen-reader announcement beat "edit text"). Header text + header sample inputs too. Deliberately NOT here --------------------- * The 940-line template-manager component is not split. It's cohesive (one feature surface) and splitting for size alone would be over-engineering. * extractVariableIndices in the buttons loop is not memoized. Regex on a short URL string is sub-microsecond; a real profiler would never flag it. * No JS heap secret-clearing for access tokens. Strings are immutable in JS — there's no way to actually clear one. Tests: 256/256 pass (no new tests required — pure refactor + copy + new UI elements). Lint 0 errors. Typecheck clean. Build succeeds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
11475840e1
|
feat(templates): media headers + URL/copy-code button params on send
Closes the last hole left by PR 3 — until now, sendTemplateMessage
only emitted a body component, so templates with image/video/
document headers or URL-with-{{1}} buttons failed at send time
with Meta's "missing component" 400. After this PR, every
APPROVED template that wacrm can submit is also fully sendable.
New helper
----------
src/lib/whatsapp/template-send-builder.ts builds the per-send
Meta components array from a MessageTemplate row + caller-supplied
SendTimeParams. It's a different beast from template-components.ts
(which builds the SUBMISSION shape) because the send-time payload
has a different schema: header type-specific parameter objects,
button components with sub_type + index, body_text without a 2D
example wrapper, etc.
Auto-fills as much as possible from the template:
* static IMAGE/VIDEO/DOCUMENT headers ride along using the row's
header_media_url (or header_handle when present — preferred)
* URL buttons without {{1}} need no per-send payload
* QUICK_REPLY / PHONE_NUMBER buttons need nothing
* COPY_CODE buttons fall back to the template's example so the
most common "static promo code" case Just Works without UI
Variable-bearing fields require per-send values:
* TEXT header with {{1}} → messageParams.headerText
* Body variables → messageParams.body[]
* URL button with {{1}} → messageParams.buttonParams[i]
* COPY_CODE override → messageParams.buttonParams[i]
19 unit tests assert the wire shape per scenario — header types,
URL button at correct index when QR buttons precede it, example
fallback, and the throw-with-specific-message paths.
sendTemplateMessage extension
-----------------------------
src/lib/whatsapp/meta-api.ts gains optional `template` +
`messageParams` args. When `template` is provided, the full
components array is built via the new helper. Legacy `params:
string[]` (body only) path is preserved as-is — old callers
behave identically. When both shapes are present, structured
messageParams.body wins.
Route + UI wiring
-----------------
POST /api/whatsapp/send and /api/whatsapp/broadcast now load the
template row by (user_id, name, language) and pass it through.
Broadcast loads once per request (not per recipient — avoids N+1).
Both accept `template_message_params` / per-recipient
`messageParams` from the client.
src/components/inbox/template-picker.tsx scans the selected
template for variable-bearing fields and renders an input per
need: header text variable, body variables (existing), URL
button suffixes. Per-button preview shows the final URL with
the substitution so the user sees what the recipient will see.
The callback shape changes to `(template, { body, headerText?,
buttonParams? })`; message-thread.tsx updated accordingly.
Out of scope
------------
Broadcast composer doesn't yet collect per-recipient header /
button params — the legacy body-only flow continues to work for
broadcasts. Templates with header / URL-button variables broadcast
fine if every recipient gets the same value via API call shape;
the UI for varying it per recipient lands in a follow-up.
This is the last PR of the Issue #147 overhaul sequence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
1f295a831a
|
security: rate-limit send and broadcast per user
A fork running WaCRM publicly had no server-side guard on the
WhatsApp send endpoints — a compromised session or an aggressive UI
loop could call /api/whatsapp/send or /broadcast as fast as the
network allowed and blow through Meta's per-number quota (or worse,
get the number suspended).
This PR adds a minimal in-memory fixed-window rate limiter keyed by
user id.
Budgets (tuned for a template default — forkers can edit):
- /api/whatsapp/send 60 per minute per user (1/s sustained)
- /api/whatsapp/broadcast 5 per minute per user (campaign dispatch
rate, not fan-out)
Behaviour:
- Check runs right after the Supabase auth check; unauthenticated
calls 401 as before and never consume a bucket entry.
- When a user exceeds the budget the route returns 429 with
Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining,
X-RateLimit-Reset headers.
- Send and broadcast buckets are independent; two users are
independent.
Design trade-off:
The limiter lives in a process-local Map. On a single Hostinger
Managed Node.js instance — the template's default deployment —
that's the whole limit. On a horizontally-scaled deploy (multiple
regions, serverless fan-out) each instance has its own Map and the
effective rate is N * limit. Swap src/lib/rate-limit.ts for a
Redis/Upstash/Durable-Objects backend if you scale past one
instance; keep the checkRateLimit return shape and the call sites
don't move.
Memory is self-cleaning: a 1-in-1000 opportunistic sweep drops
expired keys without a background timer, so the Map stays bounded
without running anything outside the request path.
Smoke test (tsx):
- 7 broadcasts → 5 ok, 2 denied.
- Different user → independent bucket.
- Same user, different route → independent bucket.
- Retry-after = 60s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
5d5c5f0a20
|
fix(broadcasts): per-recipient variables, webhook status sync, rate limit
Three correctness bugs were making the existing broadcast flow wrong,
not just incomplete.
1. Per-recipient variables were broken
use-broadcast-sending.ts computed templateParams per contact but shipped
only templateParams[0] to the API, so every contact in a batch received
contact 0's personalization (name/email/etc).
Fix: extend POST /api/whatsapp/broadcast to accept a per-recipient
shape `{ recipients: [{ phone, params }], ... }`. Legacy
`{ phone_numbers, template_params }` still accepted. Hook sends per-
recipient payloads; API returns whatsapp_message_id per phone.
2. Webhook never updated broadcast_recipients
handleStatusUpdate in webhook/route.ts only mirrored Meta status onto
the `messages` table. broadcast_recipients had no correlation column,
so delivered_count / read_count / replied_count never advanced for
real broadcasts.
Fix (migration 003):
- broadcast_recipients.whatsapp_message_id (+ UNIQUE index) for
Meta-message-id correlation
- composite index (broadcast_id, status) for fast aggregate scans
- AFTER INSERT/UPDATE/DELETE trigger that recomputes parent
broadcasts counts via COUNT(*) FILTER — sender + webhook only
touch the recipient row, aggregates stay consistent
Webhook:
- handleStatusUpdate also updates broadcast_recipients with forward-
only status guard (read cannot regress to sent on a replay)
- processMessage flags the replied recipient row when an inbound
message arrives from a recent broadcast contact
3. Rate limiting did not match spec
Batch 50 / no delay → Batch 10 / 1s between batches, matching the spec
and Meta's per-phone-number rate ceiling.
Also: extract statusConfig from both page components into
src/lib/broadcast-status.ts (single source of truth for PR B). Add
whatsapp_message_id to BroadcastRecipient type.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
f3c68967f1
|
refactor: Meta API helpers use named params (prevents swapped-args bugs)
Over the course of the project the same swapped-args bug was found four separate times against the Meta API helpers (config in PR #8, send in PR #12, broadcast in PR #13, webhook media in PR #14). Each time the compiler accepted the call because all the args were strings, so the bug only surfaced at runtime as an opaque Meta error. This refactor eliminates the entire bug class by converting every helper to a single options object. A typo like sendTextMessage({ accessToken: id, phoneNumberId: tok, ... }) is now a TypeScript error at the call site rather than a 400 from Meta two layers deep. Helpers updated in src/lib/whatsapp/meta-api.ts: - verifyPhoneNumber({ phoneNumberId, accessToken }) - sendTextMessage({ phoneNumberId, accessToken, to, text }) - sendTemplateMessage({ phoneNumberId, accessToken, to, templateName, language?, params? }) - getMediaUrl({ mediaId, accessToken }) - downloadMedia({ downloadUrl, accessToken }) Each exported Args interface so routes can type local variables holding partial argument sets (useful for the broadcast retry loop). Also factored the duplicated "parse Meta error JSON" block into a shared throwMetaError() helper — was repeated in 5 places. Call sites updated (all were already correct after prior fixes, but now the types enforce it): - src/app/api/whatsapp/config/route.ts (GET + POST) - src/app/api/whatsapp/send/route.ts (attempt() closure) - src/app/api/whatsapp/broadcast/route.ts (retry loop) - src/app/api/whatsapp/webhook/route.ts (verifyAndBuildUrl) - src/app/api/whatsapp/media/[mediaId]/route.ts (proxy pipeline) No behavior change. Build passes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
e97d5b5064
|
feat: auto-retry with phone variants on Meta "not in allowed list"
Meta WhatsApp returns error #131030 ("Recipient phone number not in allowed list") in two situations: 1. Sandbox: the recipient isn't registered as a test number. 2. The registered number format differs from what we sent, usually because of trunk-prefix 0 quirks (e.g. Lithuanian numbers stored as +370063949836 vs international +37063949836). Today that error bubbles up to the UI and the user has to manually edit the contact. This PR makes the send pipeline self-heal: on 131030 it retries with alternate phone-number formats, and if an alternate works it persists the corrected format back to the contact so future sends are a single API call. src/lib/whatsapp/phone-utils.ts - Added phoneVariants(sanitized) — yields original, then variants with a trunk 0 inserted/removed after each plausible country-code length (1-3 digits). Deduplicated. Original is always first so the fast path for correctly-formatted numbers is one attempt. - Added isRecipientNotAllowedError(message) helper — matches code 131030 or the English error text, so we only retry when the failure is actually about the recipient (other errors fail fast). src/app/api/whatsapp/send/route.ts - Reply send now iterates phoneVariants() and stops at the first that succeeds. If a non-original variant works, contacts.phone is updated to the working format. src/app/api/whatsapp/broadcast/route.ts - Same retry applied per recipient in the broadcast loop. - Also fixed a separate bug: broadcast was calling sendTemplateMessage(accessToken, phone_number_id, ...) — wrong order. Correct signature is (phoneNumberId, accessToken, to, templateName, language?, params?). This was silently rejecting every broadcast. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
658d070811
|
Initial scaffold: WhatsApp CRM with Next.js + Supabase
Complete WhatsApp CRM application built with Next.js 14 (App Router), Supabase (PostgreSQL + Auth + Realtime), and shadcn/ui. Features: - Auth: Login, signup, forgot password with Supabase Auth - Dashboard: Analytics overview with stats, recent activity - Inbox: Real-time WhatsApp conversations with message threading - Contacts: CRUD with tags, custom fields, notes, CSV import - Broadcasts: 4-step wizard with audience selection, template sending - Pipelines: Kanban board with deal management - Settings: WhatsApp API config, templates, tags - WhatsApp API: Webhook (verify + receive), send, broadcast, media proxy - Database: 16 tables with RLS policies, indexes, realtime subscriptions - Dark theme throughout with emerald accents Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |