mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
ac9fc8dfc9
127 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ac9fc8dfc9
|
feat(inbox): graduate chat media send to GA (remove beta flag)
Photo/video/document/voice send has been validated in production, so it's now available to every account. Removes the 'chat_media' beta gate: - Composer always shows the attach menu + voice recorder (no useBetaFeature check). - Send route no longer 403s media kinds for non-opted-in accounts. - Deletes the now-unused beta-features helper, the useBetaFeature hook, and their test (added solely for this rollout; no other consumer). The profiles.beta_features column (migration 011) is left in place for future betas. typecheck/lint/build green; 411 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
4fa29b7a45
|
refactor(inbox): record voice notes client-side, drop server ffmpeg
Follow-up to #259. Voice notes are encoded to Ogg/Opus in the browser via opus-recorder (small WASM encoder, lazy-loaded on record), so there's no server-side transcode and nothing to provision on the host. The server stores the .ogg like any other attachment; WhatsApp renders it as a voice note. - Composer: MediaRecorder + /api/whatsapp/transcode-audio replaced with opus-recorder. Encoder worker vendored at public/opus/encoderWorker.min.js (no install-time download to fail), ignored by eslint + prettier. - Removed the transcode route, the audio-transcode lib + tests, the ffmpeg-static dep + ambient types, the FFMPEG_PATH env doc, and the unused transcodeAudio rate limit. - Added opus-recorder ambient types. Still behind the 'chat_media' beta flag with per-kind size caps. typecheck/lint/build green; 414 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
873ba1f4c6
|
feat(inbox): gate media send behind 'chat_media' beta flag
Media sends (photo/video/document/voice) are now opt-in per account via
the existing profiles.beta_features mechanism (migration 011 — same
pattern used to dogfood Flows before GA). No migration needed.
- New shared `hasBetaFeature` + `CHAT_MEDIA_BETA` ('chat_media') key, and
a `useBetaFeature` client hook mirroring useCan (false while loading).
- Composer: the attach menu, hidden file inputs, and voice recorder only
render when the flag is on; the hint indent reverts so non-beta users
see the unchanged text + template composer.
- Server: the send route rejects media kinds (403) and the transcode
route rejects (403) unless the caller's profile is opted in — so the
endpoints can't be driven ahead of the rollout. Text + templates stay
available to everyone.
Enable for one account via Supabase Studio:
UPDATE profiles SET beta_features = array_append(beta_features, 'chat_media')
WHERE email = 'someone@example.com';
Tests: hasBetaFeature null-safety + membership (419 total). typecheck/lint/build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
96e7a56e3e
|
fix(inbox): harden media send — GC orphans, per-type caps, voice cap
Addresses senior-review findings on the media-send feature: - Storage lifecycle: uploads are GC'd on the abandonment paths (discarded draft, failed/errored send, unmount with a staged draft, and replacing one draft with another) via a new deleteAccountMedia helper. Previously every cancel/failure orphaned a public object. - Per-kind size caps (image 5MB, video/audio/document 16MB) mirror Meta's limits and are enforced BEFORE upload, so a too-large image no longer uploads then fails at the Meta call. - Voice notes auto-stop at a 5-min cap and always render as a voice note (transcode anything that isn't already OGG/Opus — Safari MP4 included), not just Chromium WebM. - Send route rejects unknown message_type and captions over Meta's 1024 cap up front; transcode route guards its output size too. - DraftPreview hoisted to a stable module-scope component so the caption input no longer remounts (and drops focus) on each keystroke. Tests: per-kind cap assertions added (418 total). typecheck/lint/build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
312683ca05
|
feat(inbox): send photo, video, document & voice in chat (#213)
Agents can now attach a photo/video/document or record a voice note in the inbox composer and send it live in a 1:1 thread — previously media could only be received from customers or sent via the Flows builder. - 023_chat_media.sql: new account-scoped `chat-media` Storage bucket (mirrors flow-media RLS from 020) plus Meta-accepted audio MIME types. - meta-api: sendMediaMessage gains an `audio` kind (link-only — Meta rejects caption/filename on audio; WhatsApp renders OGG/Opus as a voice note). - /api/whatsapp/send: media branch reusing the phone-variant retry loop. - /api/whatsapp/transcode-audio: Node-runtime route remuxing the WebM/Opus that Chromium's MediaRecorder produces to OGG/Opus via ffmpeg-static (Firefox/Safari record accepted formats and skip it). - composer: attach menu, file pickers, voice recorder + auto-transcode, preview with caption; thread: optimistic handleSendMedia. - shared uploadAccountMedia helper (flows form refactored onto it). - next.config: allow microphone=(self) (was fully blocked) + CSP media-src; document optional FFMPEG_PATH override. Tests: meta-api audio payload shape, buildMediaPath, audio-transcode helpers (14 new). typecheck/lint/build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
ee8da11985
|
feat(contacts): allow bulk-deleting selected contacts (#239)
Add row selection to the contacts table so multiple contacts can be
deleted at once instead of one at a time.
- New Checkbox UI primitive wrapping @base-ui/react/checkbox, matching
the switch.tsx token conventions (incl. indeterminate state).
- Header select-all + per-row checkboxes; row-checkbox clicks stop
propagation so they don't open the detail sheet.
- Bulk action bar with selected count, Clear, and a role-gated
"Delete selected" action.
- handleBulkDelete via .delete().in('id', ids) — RLS already restricts
deletes to account members, so no migration/API route is needed.
- Selection is page-scoped and resets whenever the contact set reloads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
1474657d03
|
fix(theme): light-mode polish from review pass
Self-review of the light/dark sweep surfaced neutral colors that lived in raw inline-style / <style jsx> / gradient values (so the className sweep correctly skipped them) and a few contrast/affordance nits: - reply-quote: adapt to the outbound (primary) bubble via onPrimary so the quote/author stay legible on the violet fill in light mode; wire it from message-bubble. - dashboard SVG chrome -> tokens: conversations-chart gridline/crosshair, pipeline-donut background ring, automation-builder dot-grid gradient. - members-tab viewer chip: bg-card -> bg-transparent (outline) so it doesn't blend into a card surface in light mode. - tag-manager / pipeline-settings color-swatch selection rings: raw slate/white -> var(--popover)/var(--foreground) so they flip. - pipeline-board scrollbar (<style jsx>): slate rgb -> var tokens. - activity-feed zebra stripe: bg-card/40 -> bg-muted/40 (survives light). - forgot-password / import-modal: restore real hover (dead no-op hovers). - themed-toaster: gate theme via useSyncExternalStore to avoid a hydration mismatch on sonner's theme attribute (no setState-in-effect). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4aa329b527
|
feat(theme): add light/dark mode toggle (#243)
Introduce a light/dark MODE axis orthogonal to the existing accent themes. Neutral surface tokens now live in html[data-mode="light|dark"] blocks; accent tokens (--primary*, --ring, --chart-1, --sidebar-primary*) stay in html[data-theme] blocks, so any accent works in either mode. - globals.css: split neutrals (mode) from accent (theme); add light palette; dark values unchanged (dark stays the default). - lib/themes.ts: Mode type, DEFAULT_MODE=dark, MODE_STORAGE_KEY, isMode. - use-theme.tsx: provider owns mode/setMode/toggleMode + cross-tab sync. - layout.tsx: boot script applies persisted mode before first paint (no flash); colorScheme "dark light". - mode-toggle.tsx (header sun/moon) + themed-toaster.tsx (mode-aware). - appearance-panel.tsx: light/dark control + updated copy. - Tokenize hardcoded neutral colors (slate/white/gray) -> semantic tokens across the app so every surface flips with mode. Flow canvas and message-bubble timestamps made mode-aware too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
3b924c85bc
|
fix(whatsapp): allow connecting Meta test numbers without a PIN
The WhatsApp config POST handler hard-failed with a 400 whenever a number needed registration (first save or number change) and no 6-digit two-step-verification PIN was supplied. Meta test numbers (Developer Console) are pre-registered by Meta and expose no PIN to set, so test-number users could never complete setup (issue #242). The PIN-based /{phone_number_id}/register call only governs inbound webhook routing for production numbers under a shared WABA (issue #136) — it has nothing to do with credential validity or outbound sends. So treat it as best-effort: when no PIN is supplied, skip /register, save the (already Meta-verified) credentials as connected, and leave registered_at null. The response now reports `registered` accurately and adds `registration_skipped` so the UI can react. UI: the save handler no longer falsely claims the number is "Live" on a skipped registration — it points at the existing "Not registered" banner, which already offers a PIN-retry path. The PIN field is relabelled (optional) with copy explaining it's only for production inbound routing and that test numbers have no PIN. Fixes #242 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
599b21eed0
|
fix(settings): add Custom Fields tab to Settings (#238)
Custom-field definitions are account-wide config — a sibling of Tags, Templates and Deals, all of which live under Settings. But the field catalogue could only be managed via an admin-only button on the Contacts page, so users looking under Settings found nothing (#238). Surface it as a "Custom Fields" tab in Settings: - Extract the manager's body into a reusable `CustomFieldsPanel`; `CustomFieldsManager` keeps its dialog signature and now wraps the panel, so the Contacts page is unchanged. Radix unmounts dialog content on close, so the panel still refetches on each open. - New `CustomFieldsSettings` renders the panel inline with a settings-style header. - Tab is admin+ gated (mirrors the Contacts gate); deep-linking to it as a non-admin falls back to Profile. `custom_fields` RLS still enforces writes server-side. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d477a7a429
|
feat(automations): support custom contact fields in Update Contact Field action
The "Update Contact Field" automation action could only write the built-in
name/email/company columns. This wires up the existing custom_fields /
contact_custom_values tables so automations can read and update arbitrary
custom fields, and adds the missing UI to define them.
- Add a "Custom fields" manager dialog on the Contacts page (admin+ gated)
to create / rename / delete field definitions. Inserts carry account_id.
- Load account custom fields into the automation builder's ResourcesContext
and offer them in the Field dropdown, encoded as `custom:<id>`. A saved
field that's since been deleted is preserved as an "(unknown field)"
option so editing doesn't silently drop it.
- Engine: interpolate the value ({{ vars.* }} / {{ message.text }}) for all
fields, and for `custom:<id>` verify the field belongs to the automation's
account (defense in depth) before upserting into contact_custom_values.
- Tests: cover the custom-value upsert, variable interpolation, and
cross-account rejection.
Closes #235
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
3dce1b56cd
|
feat(contacts): prevent duplicate phone numbers on create/import (#212)
The same phone could become multiple contacts: only the WhatsApp webhook de-duped, while manual create and CSV import inserted raw un-normalized phones, and the DB had no uniqueness. That fragmented conversations, deals, and tags across duplicates. - Migration 022: generated `contacts.phone_normalized` (digits-only), a one-shot `merge_duplicate_contacts()` that collapses existing dupes into the oldest row (re-pointing conversations, deals, notes, tags, custom values, broadcast recipients — no data loss), then a UNIQUE (account_id, phone_normalized) index as the authoritative guarantee for every write path. - Shared `src/lib/contacts/dedupe.ts`: findExistingContact (suffix prefilter + phonesMatch), isExactMatch, isUniqueViolation, dedupeByPhone. The webhook now reuses findExistingContact (and recovers from a unique-violation race instead of dropping the message). - Contact form: on-blur duplicate check — hard-block + "view existing" link on an exact match, soft warning on a trunk-variant near-match; unique-violation backstop on submit. - CSV import: de-dupes within the file and against existing contacts, reports "imported / skipped / failed", treats 23505 as skipped. - Contact type gains account_id (was missing) + phone_normalized. - Unit tests for the dedupe helpers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
3abecb433b
|
feat(deals): make default deal currency configurable per account (#218)
wacrm hardcoded USD across every deal-value formatter, the new-deal form, and automation-created deals, making deal tracking unhelpful for non-US self-hosted users. Add a per-account default currency. - Migration 021: accounts.default_currency TEXT NOT NULL DEFAULT 'USD' with a 3-letter-code CHECK. The existing admin-only accounts_update RLS policy already gates writes. - New src/lib/currency.ts: single source of truth (CURRENCIES list, formatCurrency, formatCurrencyShort), replacing ~6 duplicated USD-hardcoded helpers. - useAuth exposes defaultCurrency (joined from accounts), falling back to USD while loading / on pre-021 schemas. - Settings → Deals tab to pick the currency (admin-only, read-only for others to match RLS). - Deal form defaults new deals to the account currency and sources its dropdown from CURRENCIES; existing deals keep their saved currency. - Pipeline board/analytics, dashboard "Open Deals Value", and the pipeline donut format totals in the account currency. Individual deal cards still show each deal's own currency. - Automation create_deal sets the new deal's currency from the account default instead of the static DB default. One currency per account (no FX conversion) per the issue's recommended first pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
48fc10d084
|
fix(account-sharing): stamp account_id on flow and pipeline inserts (#210)
`flows.account_id` and `pipelines.account_id` are NOT NULL post-017 with no DB default, so inserts omitting account_id fail with a not-null violation (same class as #206 / #205). - POST /api/flows: resolve the caller's profiles.account_id and stamp it on both insert paths (template clone + blank/manual). Closes #210. - Pipelines page: stamp account_id (from useAuth) on the auto-seed and the manual "create pipeline" inserts — same bug, found while auditing other account-scoped insert flows. Audited every insert into the 15 NOT-NULL-account_id tables; all other sites already stamp account_id. typecheck + 370 tests pass; lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7c373c8e0a
|
fix(theme): suppress hydration warning on root html data-theme (#207)
The `theme-boot` beforeInteractive script in layout.tsx rewrites
`data-theme` on <html> from localStorage before React hydrates, so for
any non-default theme the client DOM intentionally differs from the
server-rendered DEFAULT_THEME ("violet"). React then logged a hydration
mismatch on <html> on every load where the saved theme differed.
Add suppressHydrationWarning to the root <html>. It's scoped to that
element's own attributes (child mismatches still surface) and preserves
the documented localStorage-only/device-scoped theme design — unlike a
cookie-based SSR approach, which would change that behavior.
Verified in-browser (Next 16.2.6 / Turbopack): with a non-default theme
in localStorage, the reproduced "+ data-theme=violet / - data-theme=cobalt"
hydration error is gone; removing the prop brings it back (negative
control). typecheck, lint (0 errors), 370 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
c5ee04ce9e
|
fix(account-sharing): stamp account_id on account-scoped inserts (#205)
Migration 017 made domain tables account-scoped: account_id is NOT NULL and INSERT RLS is WITH CHECK (is_account_member(account_id, ...)). Several client- and server-side create flows still inserted only user_id, so on a fresh install they failed with 403 / "new row violates row-level security policy" / not-null violations. Resolve account_id from useAuth() (client) or the caller's profile (server) and include it on every account-scoped insert: - contacts: contact-form, import-modal, broadcast CSV upsert - contact_notes: contact-sidebar, contact-detail-view - tags: tag-manager - broadcasts: broadcasts/new page, use-broadcast-sending - deals: deal-form - automations: POST /api/automations, POST /api/automations/[id]/duplicate Each path also guards on a missing accountId with a clear message instead of letting the insert fail opaquely. pipelines/page.tsx and api/flows/route.ts are intentionally left to the already-open PR #193, which fixes those same paths (and adds tests). Verified: a full sweep of every account-scoped insert/upsert across src/ now includes account_id (except the two paths owned by #193). typecheck, lint (0 errors), and 370 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5332f77831
|
feat(members): GA the Members tab — remove account_sharing beta gate
The multi-user account-sharing surface (Members tab + invite/role management APIs + /join redeem flow) is complete, so drop the `account_sharing` beta flag that hid it during development. Mirrors the Flows soft-GA in 0.2.0. - settings: Members tab is now always rendered (no beta_features check, no stale-link fallback). The `profiles.beta_features` column itself stays for future betas. - sidebar: the active-account strip no longer keys off the flag. It now shows whenever the account name differs from the user's own name — i.e. once you've renamed the account or joined a shared one — preserving the original "don't duplicate a solo user's name" behaviour without the flag. - CHANGELOG/README: document Members as a shipped, all-users feature. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d23a5a5017
|
Merge pull request #190 from ArnasDon/feat/rate-limit-admin-account-endpoints
feat(api): per-user rate limits on admin account endpoints |
||
|
|
01715c7d6d
|
feat(api): per-user rate limits on admin account endpoints
Addresses the L1 finding from the final security review. The six admin-class write endpoints under /api/account had no rate limits, so a compromised admin session (or a script run in a loop) could spam invite creation / role flips / member removes fast enough to flood the DB or audit logs. Adds a new `RATE_LIMITS.adminAction` bucket — 30/min/user — and wires it into: - POST /api/account/invitations → admin:inviteCreate - DELETE /api/account/invitations/[id] → admin:inviteRevoke - PATCH /api/account/members/[userId] → admin:memberRole - DELETE /api/account/members/[userId] → admin:memberRemove - POST /api/account/transfer-ownership → admin:transferOwnership - PATCH /api/account → admin:rename Each endpoint keys its own bucket so spamming one doesn't starve another. 30/min is comfortably above any realistic human pace (the Members tab is a clicks-only UI; a legit admin would struggle to hit ten of these per minute by hand) while still bounding accidental abuse. The bucket joins the existing send / broadcast / react / invitationPeek / invitationRedeem buckets in `RATE_LIMITS` — same in-memory fixed-window limiter, same `rateLimitResponse` helper for the 429 shape. Severity in the final review: LOW. The admin-trust model means this isn't blocking — but the cost of adding it is six near-identical 4-line additions and zero net risk, so worth landing for hardening. Verification - `npm run lint` → 0 errors (same 19 pre-existing warnings) - `npm run typecheck` → clean - `npm test` → 366/366 pass Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
44f5c550e1
|
harden(invite): optional ALLOWED_INVITE_HOSTS allow-list
Defense-in-depth for the request-derived branch of `getBaseUrl()` in /api/account/invitations. The threat model - On a typical proxied deploy (Vercel / Hostinger / Cloudflare / nginx with a fixed server_name), the proxy overwrites the `Host` and `X-Forwarded-Host` headers with the deployment's canonical hostname, so the request-derived path produces the right URL. NEXT_PUBLIC_SITE_URL pinning is also unnecessary in that case. - On a *bare* deployment exposed to the public internet (no proxy in front, no SITE_URL set), an attacker can POST directly to /api/account/invitations with `Host: phishing.example` and receive an invite URL pointing at their site. They'd still need admin role on *some* account to call the endpoint, and they'd need to set up `phishing.example/join/<token>` to actually phish anyone — but the resolution chain shouldn't trust un-validated client input by default. The fix - New env var `ALLOWED_INVITE_HOSTS` (comma-separated hostnames). - When set, `getBaseUrl` validates each candidate host against the list and falls through to wacrm.tech if neither matches. - When unset, behavior is unchanged — the request-derived host is trusted (preserves the "just works" experience for the typical proxied case). - Distinct console.warn for the rejection path vs the no-Host-at-all path so operators can tell their allow-list is actually catching something. Documented in .env.local.example with a clear explanation of when operators should set it (bare deploys + multi-tenant hosting) and when they probably don't need to (the typical case where NEXT_PUBLIC_SITE_URL already pins the host). Severity: MEDIUM in the final review, downgraded in practice because (a) the attack window is narrow, (b) requires admin role + a phishing site, and (c) most deployments are behind a proxy that strips the spoofable headers. Still worth the small amount of code to give security-conscious operators the knob. Verification - `npm run lint` → 0 errors (same 19 pre-existing warnings) - `npm run typecheck` → clean - `npm test` → 366/366 pass Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
7d466ca865
|
fix(invite): derive invite-URL origin from the request, not wacrm.tech
Pre-fix: `getBaseUrl()` in /api/account/invitations defaulted to `https://wacrm.tech` whenever `NEXT_PUBLIC_SITE_URL` was unset. wacrm.tech is the docs/marketing site (separate ArnasDon/wacrm-site repo); it has no `/join/[token]` route. Any fork that didn't set the env var got invite links pointing into the marketing site, which 404'd on click. Reported by @ArnasDon while testing the flow on production. Post-fix: `getBaseUrl(request)` resolves in this order: 1. `NEXT_PUBLIC_SITE_URL` — explicit operator config, still wins. 2. `X-Forwarded-Host` (+ `X-Forwarded-Proto`) — set by every reverse proxy in front of the app (Hostinger Managed Node.js, Vercel, Cloudflare). This is what makes invite links Just Work in production without any env-var fiddling. 3. `Host` header + the request's protocol — bare deployments without a proxy in front. 4. `https://wacrm.tech` last-resort fallback with a console.warn so operators spot the misconfig in logs. Effectively unreachable from a real browser; only triggers if there's no Host header at all. Also tightened `.env.local.example` so the variable's purpose matches the new behavior: it's now only needed when the request- derived origin would be wrong (cron jobs, background workers). Day-to-day forks don't have to remember it. Verification - `npm run lint` → 0 errors (same 19 pre-existing warnings) - `npm run typecheck` → clean (after `rm -rf .next` to clear stale [locale]/* type stubs from the i18n revert that landed on main) - `npm test` → 366/366 pass - `npm run build` → succeeds Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
2fbeeee3ac
|
Revert "feat(i18n): re-attempt next-intl with hardened artifact tracing" | ||
|
|
8124640d80
|
Revert "Merge pull request #183 from ArnasDon/revert/i18n-pr-182"
This reverts commit |
||
|
|
a30f04b5a4
|
Revert "Merge pull request #182 from ArnasDon/feat/i18n-next-intl"
This reverts commit |
||
|
|
8a8758a487
|
feat(i18n): scaffold next-intl with [locale] routing + extract auth/layout
Introduces an internationalization layer so wacrm can ship in multiple
languages — initially en (canonical), es, lt (untranslated mirrors of
en, ready for translators).
Foundation
- next-intl ^4.13 (Next 16 + React 19 compatible)
- src/i18n/{routing,navigation,request}.ts — locale config, locale-aware
Link/router/redirect, server-side message loader
- next.config.ts wired with `createNextIntlPlugin`
- src/app/* moved under src/app/[locale]/ (excluding /api and globals.css)
- [locale]/layout.tsx is now the root layout: <html lang={locale}>,
hasLocale guard, setRequestLocale, NextIntlClientProvider,
generateStaticParams for build-time prerendering of all locales
- middleware.ts composes next-intl's createMiddleware with the existing
Supabase auth check: intl middleware runs first to handle locale
prefix; auth redirects retain the active locale (e.g. signed-out user
on /es/dashboard → /es/login); intl cookies (NEXT_LOCALE) are carried
onto auth-issued responses so the sticky preference survives
Strings extracted (~80 keys across en/es/lt.json)
- common, nav (sidebar + header), roles (Owner/Admin/Agent/Viewer chips)
- auth.login, auth.signup, auth.forgotPassword (all forms + success states)
- join (all four UI states: loading, fail, accept, sign-up CTA + conflict modal)
- localeSwitcher
UI
- New `<LocaleSwitcher />` in the header — globe icon + locale dropdown
using next-intl's useLocale + locale-aware useRouter
Out of scope for this PR (intentional — keeps diff reviewable)
- Dashboard feature pages (dashboard, inbox, contacts, pipelines,
broadcasts, automations, flows, settings) and their components/
namespaces still have hardcoded English; they continue to render
fine because next/link redirects through the locale middleware
- Shared UI a11y strings (dialog close, etc.)
- Translating es.json / lt.json — they currently mirror en.json so the
app runs in any locale without crashes (English fallback)
- Renaming middleware.ts → proxy.ts per Next 16 deprecation
Verification
- npm run build → 62 static pages prerendered across en/es/lt
- npm run typecheck → clean
- npm run test → 366 passing
- npm run lint → 0 errors (18 pre-existing warnings untouched)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
962e4fb27d
|
Merge pull request #181 from ArnasDon/feat/account-sharing-visual-polish
feat(ui): visual polish from multi-user review (P1-P5, P7, P10) |
||
|
|
03d4c26b89
|
feat(ui): visual polish from multi-user review (P1-P5, P7, P10)
PR D — the final pass of the post-review fix series. Bundles the
seven visual / UX polish items the design review flagged.
P1 + P5 — sidebar account strip (`src/components/layout/sidebar.tsx`)
- Owner used to be invisible in the role-chip slot (the previous
check was `accountRole && accountRole !== "owner"`), so owners
looked indistinguishable from admins. Now the chip always
renders, with a per-role icon + colour pulled from a new
`ROLE_CHIP` table.
- Added `title={account.name}` on the truncated name so the full
account name is visible on hover when it's clipped (long names
+ narrow sidebars).
P2 + P3 + P10 — members tab (`members-tab.tsx`)
- **P2** — destructive Remove / Revoke buttons used a neutral
default that only turned red on hover. The destructive intent
was invisible until the user moused over. Both buttons now have
a red default state (`border-red-500/40 bg-red-500/10
text-red-300`) with a darker shade on hover.
- **P3** — role chips were all slate. Replaced with a per-role
colour scale: owner amber (scarce, immutable), admin primary
(significant), agent slate (operational default), viewer muted
slate (read-only). Mirrors the sidebar's `ROLE_CHIP` table so
the two surfaces don't drift; once the surface stabilises this
should hoist into a shared module.
- **P10** — pending invitations section now opens with a one-line
hint: "The plaintext invite URL is only shown once at creation
for security — to re-share, revoke the invite below and create
a new one." Pre-fix, admins kept looking for a "copy link"
button on the pending rows; the design decision (don't store
plaintext) is now self-explanatory.
- Cleanup: removed the now-unused `ROLE_LABELS` constant
(subsumed by `ROLE_CHIP[role].label`).
P4 — amber "Save this link now" callout contrast
- Pre-fix: `bg-amber-500/10 + text-amber-200` on slate-900 was
borderline for WCAG AAA body-text contrast.
- Post-fix: `bg-amber-500/15`, border bumped to `/50`, strong
intro promoted to `amber-100` so the urgent copy reads with
proper weight against the dark backdrop.
P7 — Try-again button on join page server_error
- Pre-fix: the only recourse for a server_error peek was to
follow "Create a new account" / "Sign in" links — both lose
the invite token.
- Post-fix: extracted the peek+auth fetch as `loadPeekAndAuth`,
call it from a new "Try again" primary button on the
server_error card. Other failure reasons (not_found / used /
expired) are terminal for this token so they keep the
signup/sign-in escape hatches; only server_error gets retry.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds
Closes out the post-review fix series. Combined with PRs A
(#178, backend perf), B (#179, UI blocking), and C (#180,
front-end correctness), every item from the consolidated review
report is addressed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
4390b8b41f
|
Merge pull request #180 from ArnasDon/feat/account-sharing-fe-correctness
feat(ui): front-end correctness pass (S1,S2,S6-S8,P8,P9) |
||
|
|
cbcad71fda
|
feat(ui): front-end correctness pass from multi-user review (S1,S2,S6-S8,P8,P9)
PR C of the post-review fix series. Bundles six related front-end
correctness / portability fixes. No behavior change for happy-path
users — these are belt-and-braces against edge cases.
S1 — useCan exhaustiveness (`src/hooks/use-can.ts`)
- Add a `default` branch with `const _: never = action` so adding
a new `CanAction` without a case fails the typecheck instead of
silently returning `undefined` at runtime.
S2 + P8 — GatedButton helper (`src/components/ui/gated-button.tsx`)
- `<Button disabled title="...">` doesn't render a tooltip in
Safari or older Firefox: those browsers don't fire mouse events
on disabled controls, so the title never surfaces. Viewers on
those browsers saw silently-disabled CTAs with no explanation.
- New `<GatedButton canAct={...} gateReason="...">` wraps the
Button in a non-disabled `<span>` that owns the `title`. The
span receives mouseover everywhere; the tooltip works.
- Also subsumes the duplicated `READ_ONLY_TITLE = "..."` constants
across 5 list pages. `gateReason` is the verb phrase that
completes "Read-only — your role can't <gateReason>".
- Migrated call sites: broadcasts / automations / flows / contacts
/ pipelines list pages + inbox composer (Send + Templates
buttons). The composer's textarea keeps its inline title since
the GatedButton pattern only applies to buttons.
S6 — Revert optimistic role-change on error (`members-tab.tsx`)
- Pre-fix: PATCH failure showed a toast and left the local
member-row state with the new role. Next interaction
(re-toggling to the same role) was a no-op via the
`member.role === nextRole` guard at the top, so the row
appeared stuck. Now we capture `previousRole` before the
optimistic flip and revert on both 4xx and network failure.
S7 — Sidebar flag check matches settings (`sidebar.tsx`)
- Pre-fix: sidebar's `accountSharingEnabled` didn't guard on
`profileLoading`, so the account-name strip briefly was hidden
during the initial profile fetch then popped in. Settings page
did this guard correctly. Now they match.
S8 — Referrer-Policy: no-referrer on /join (`join/layout.tsx`)
- The plaintext invite token lives in the URL path. Belt-and-
braces against any future external resource on the join page
leaking the token via the Referer header. Set via Next 16's
`metadata` export → `<meta name="referrer" content="no-referrer">`.
Also marks the route `noindex, nofollow` so a join URL can't end
up in search results.
P9 — Client-side label length validation (`invite-member-dialog.tsx`)
- Server caps `label` at 80 chars and returns 400 on overflow.
Add a `MAX_LABEL_LEN` constant on the client, point both the
`<Input maxLength>` and the pre-submit check at it. Catches the
paste-then-submit path where a paste lands an over-limit string
in state before the keystroke-level cap engages.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean (the new `never` cast catches typos)
- `npm test` → 366/366 pass
- `npm run build` → succeeds
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
d56c4176b5
|
Merge pull request #179 from ArnasDon/feat/account-sharing-ui-blocking-fixes
feat(ui): user-facing fixes from multi-user review (M3, M4, S3) |
||
|
|
bff8ec6765
|
feat(ui): user-facing fixes from multi-user review (M3, M4, S3)
PR B of the post-review fix series. Closes the three user-blocking UI gaps the design / UX review flagged. M3 — Charlie's 409 redeem dead-end (join page) - Pre-fix: a user with existing data clicking an invite saw a 3-second toast then the Accept button re-enabled. The same retry would 409 forever. - Post-fix: 409 status is detected explicitly and surfaces a blocking dialog with an explanation + "Sign out & use a different email" action. The action calls supabase.signOut() then reloads the page; the page rebuilds on the signed-out branch with the "Create account & join" CTA. The invite token stays in the URL through the reload so the recovery is one click. Non-409 errors still take the regular toast path. - Files: `src/app/join/[token]/page.tsx` — new state `conflictMessage`, new handler `handleSignOutAndRetry`, new Dialog component in the auth-in render branch. M4 — WhatsApp share message includes the account name - Pre-fix: "Join our wacrm account using this link…" — generic, no team context. In multi-team contexts the recipient couldn't tell which team they were being invited to. - Post-fix: "Join <Account Name> on wacrm using this link…". The account name is snapshotted into the `CreatedInvite` result at creation time so a later rename can't retroactively rewrite the wa.me message. - Files: `src/components/settings/invite-member-dialog.tsx` — read `account` from `useAuth()`, thread `accountName` through `CreatedInvite`, update `whatsappShareUrl` copy. S3 — Members tab roster mobile reflow - Pre-fix: the member row used a single flex-row with a fixed- width 128px role Select and a 36px Remove button. On a 375px viewport the name + email got crushed to ~50px. - Post-fix: at base width the row stacks vertically — identity (avatar + name + email) on top, actions (role + Remove) below. At sm+ it returns to the original inline layout. The joined- date column stays desktop-only. - Files: `src/components/settings/members-tab.tsx` — `flex-col` base, `sm:flex-row sm:items-center` for desktop. Actions wrapped in their own flex container so the role Select and Remove button cluster together on both breakpoints. 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> |
||
|
|
55da996e52
|
perf(db,webhook): backend follow-ups from the multi-user review
PR A of the post-review fix series. Addresses the two must-fix
backend findings (flow-media account scoping; webhook contact
lookup at scale) plus the two should-fix composite-index
additions, all in one migration + two TS files.
Migration 020 (`supabase/migrations/020_account_sharing_followups.sql`)
- Composite partial index `idx_automations_account_active_trigger`
on `automations(account_id, trigger_type) WHERE is_active`.
The 006-era partial index was account-blind so the planner had
to post-filter by account_id on every webhook dispatch. With
100+ admins in a shared account the post-filter step shows up
in query plans; this index answers all three predicates from
one lookup.
- Same shape for flows: `idx_flows_account_active` on
`flows(account_id) WHERE status='active'` — paired to the
`findEntryFlow` query in the runner.
- `flow-media` storage RLS rewritten to allow uploads / updates
/ deletes by any member of the account whose folder name
matches `account-<account_id>/...`. Legacy `<auth.uid()>/...`
paths remain writable by the original uploader so files
uploaded pre-020 don't break. Reads stay public (the bucket
was already public so Meta can fetch URLs without creds).
Webhook contact lookup (`src/app/api/whatsapp/webhook/route.ts`)
- The PR-8 conversion swapped `eq('user_id', userId)` for
`eq('account_id', accountId)` but kept "pull every row, filter
in JS" semantics. That was fine when account = one user but
scales linearly with the team size × per-user contact count.
- Now pre-filters in SQL by the phone's last-8-digit suffix
(mirroring `phonesMatch`'s trunk-prefix tolerance). The
trailing-suffix `like` can't use a B-tree index, but the
account_id filter on top of `idx_contacts_account` keeps
the scan to a small account-scoped subset. Re-applies the
strict `phonesMatch` check in JS on the candidate set for
correctness — typically 0-2 rows, basically free.
Flow-media upload path (`src/components/flows/forms/node-config-form.tsx`)
- Resolves the caller's `profile.account_id` before upload and
pathes the file under `account-<account_id>/...` to match the
new 020 RLS policy. Legacy `<user_id>/...` paths keep working
via the back-compat clause in the policy.
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>
|
||
|
|
14cdbf457b
|
feat(ui): role-aware gating sweep + account-name in sidebar
PR 9 of the multi-user accounts series — the final piece. Visual
polish so non-owners see consistent disabled states across the
app and so every user always knows which account they're acting in.
Sidebar
- New account-name strip above the user dropdown, gated behind
the `account_sharing` beta flag (same flag the Members tab
from PR 6 reads). For solo users (the default) the account is
named after them, so showing it would just duplicate the user
name below — flag-gating keeps the original layout untouched
for single-user installs.
- When the caller's role is admin/agent/viewer (not owner), the
role is surfaced as a small uppercase chip alongside the
account name so they have an unambiguous signal of their
permission level without opening Settings.
- Nav items stay always-visible per the planning doc's
"show-but-disable" decision — hiding nav rows by role would
make the app feel broken.
Inbox composer
- Send button + Templates button + textarea are disabled for
viewers with a "Read-only — viewers can browse but not reply"
placeholder and tooltip. The existing session-expired branch
still wins when it applies (session-expired is the more
specific condition and surfaces template-only sending).
List-page create buttons
- broadcasts/page.tsx: "New Broadcast" CTA (header + empty
state).
- automations/page.tsx: "Create Automation" CTA.
- flows/page.tsx: "New flow" CTA (header + empty-state).
- contacts/page.tsx: "Add Contact" + "Import" CTAs.
- pipelines/page.tsx: "Add Pipeline" (gates on edit-settings —
pipelines are settings-class under the 017 RLS) and "Add
Deal" (gates on send-messages).
All use `useCan(...)` from PR 5. Disabled buttons get a tooltip
explaining why (`title="Read-only — your role can't ..."`).
Solo users see no visual change — single-owner accounts pass
every capability check.
Why show-but-disable, not hide
- A viewer scanning the app should still see what's possible
in the product, both to understand what they can ask for an
upgrade for and to avoid the UI feeling silently broken
("the New button just disappeared?"). The tooltip carries
the explanation cheaply.
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>
|
||
|
|
0ed07ee6c9
|
feat(webhook,engines): account-scoped routing
PR 8 of the multi-user accounts series. Flips the WhatsApp webhook,
automations engine, and flows engine from per-user tenancy to
per-account so a shared inbox actually behaves like one.
Why this matters
- Pre-this-PR: an inbound message resolved the recipient by
whatsapp_config.user_id and dispatched only that user's
automations / flows. Teammates joining via an invite would see
the message in the shared inbox but their automations / flows
never fired.
- Also fixes incipient NOT NULL violations from migration 017.
That migration added `account_id NOT NULL` to automation_logs,
automation_pending_executions, flow_runs, and deals. Inserts in
the engines hadn't been updated yet — any production run would
500 with a column violation. This PR adds account_id to every
such insert.
Surface
- `src/app/api/whatsapp/webhook/route.ts`
- Resolves `config.account_id` AND `config.user_id` from the
matched whatsapp_config row. Passes accountId through every
downstream call. user_id is kept as `configOwnerUserId` and
used only for NOT NULL FK audit columns on contact /
conversation inserts.
- `findOrCreateContact` / `findOrCreateConversation` look up by
account_id and insert with both columns.
- `flagBroadcastReplyIfAny` matches broadcasts by account_id so
any teammate's broadcast is updated when a customer replies.
- `dispatchInboundToFlows` and `runAutomationsForTrigger` now
take `accountId` instead of `userId`.
- `src/lib/automations/engine.ts`
- `DispatchInput.userId` → `accountId`.
- `runAutomationsForTrigger` queries automations by account_id.
- `executeAutomation` inserts into automation_logs with both
account_id (tenancy) and user_id (audit, from automation row).
- `wait` step inserts into automation_pending_executions with
account_id.
- `create_deal` step inserts into deals with account_id.
- `assign_conversation` / `close_conversation` /
`resolveConversationId` filter by account_id.
- `resumePendingExecution` row shape now includes account_id;
cron passes it through.
- engineSendText / engineSendTemplate calls pass
`accountId: args.automation.account_id`.
- `src/lib/flows/engine.ts`
- `DispatchInboundInput.userId` is kept (audit) and
`accountId` is added (tenancy).
- `loadActiveRunForContact`, `isDuplicateInbound`,
`findEntryFlow` all query by account_id.
- `startNewRun` inserts into flow_runs with account_id.
- All `engineSend*` calls pass `accountId: run.account_id`.
- `src/lib/automations/meta-send.ts` + `src/lib/flows/meta-send.ts`
- All four engine senders (text, template, media, interactive
buttons/list) accept `accountId` and look up the contact +
whatsapp_config by account_id. user_id stays for inserts.
- `src/lib/flows/types.ts`
- `FlowRow`, `FlowRunRow`, `DispatchInboundInput` gain
`account_id` / `accountId`.
- `src/types/index.ts`
- `Automation` gains `account_id`. Doc note on the user_id
field clarifying it's audit-only post-multi-user.
- `src/app/api/automations/cron/route.ts`
- Pass account_id from the pending row to resumePendingExecution.
- `src/app/api/automations/engine/route.ts`
- Uses `getCurrentAccount()` from #169 to resolve the caller's
account, then dispatches with accountId.
- `CHANGELOG.md` — Fixed entry explaining the inbox / NOT NULL fix.
Backwards compatibility
- All affected tables already have account_id from migration 017.
This PR populates it on insert paths that hadn't been updated.
- The `user_id` columns are kept everywhere — they still identify
the agent / author for audit display. They just no longer
enforce tenancy.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean (had 4 errors mid-refactor; all
resolved by the call-site updates)
- `npm test` → 366/366 pass (engine tests are pure unit
tests over helper functions; not affected)
- `npm run build` → succeeds
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
47104042fd
|
feat(join): /join/[token] page + signup/login invite redirects
PR 7 of the multi-user accounts series. Closes the invite loop —
PR 6 lets admins generate links, this PR is what the recipient
sees when they click one.
What lands
- `src/app/join/layout.tsx` — minimal centered dark shell. Sits
outside both `(auth)` and `(dashboard)` route groups because
the page is hybrid: it renders for anonymous AND signed-in
visitors. Reusing either neighbouring layout would funnel one
audience through the wrong middleware redirect.
- `src/app/join/[token]/page.tsx` — client component. Calls
/api/invitations/[token]/peek + supabase.auth.getUser() in
parallel on mount and routes through four UI states:
peek | auth | render
--------------|--------------|----------------------------------
loading | — | spinner
ok:false (any)| — | friendly error + signup/signin CTAs
ok:true | signed out | "Sign up & join" + "Sign in" links
ok:true | signed in | "Accept invitation" button → redeem
Redeem is intentionally manual — auto-accepting on mount would
race with the signup → email-verification → return flow and
hide what's being accepted from the visitor.
AuthContext doesn't reach this route group (AuthProvider lives
inside (dashboard)), so we call Supabase directly here the same
way /login and /signup already do.
After a successful accept, the page does a full reload to
/dashboard (not router.push) so AuthProvider re-fetches the
profile with the new account_id and account_role.
- `src/app/(auth)/signup/page.tsx`:
- Reads `?invite=<token>` via useSearchParams.
- Passes emailRedirectTo: `${origin}/join/<token>` to
supabase.auth.signUp so the verification email returns the
user to the redeem step.
- Re-skins the card header for invite signups ("Create account
& join", different icon).
- Preserves the token through the "Sign in" link and the
"Back to sign in" button on the success screen.
- Default export now wraps the form in `<Suspense>` — Next 16
refuses to prerender pages calling useSearchParams without
it.
- `src/app/(auth)/login/page.tsx`:
- Reads `?invite=<token>`.
- On successful sign-in, routes to /join/<token> instead of
/dashboard.
- Re-skins the card header for invite logins.
- Preserves the token through the "Create account" link.
- Default export wrapped in `<Suspense>` for the same prerender
reason.
- `src/middleware.ts`:
- When an authenticated user hits /login?invite=<t> or
/signup?invite=<t>, redirect to /join/<t> instead of the
default /dashboard. Without this, a forwarded invite link
landing on an already-signed-in browser would silently drop
on /dashboard and the visitor would never see the accept
prompt.
- Both pre-existing redirect cases (no invite present, OR
/forgot-password with an invite — which doesn't make sense)
fall through to /dashboard as before. The search string is
cleared on both paths so we don't leak the invite token
into the dashboard URL bar.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds:
ƒ /join/[token] — dynamic (peek + auth fetch)
○ /login — static, hydrates the form
○ /signup — static, hydrates the form
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
7554ce7cd8
|
feat(settings): members tab + invite member dialog
PR 6 of the multi-user accounts series. First user-facing surface
for the feature — the Members tab in Settings.
What lands
- `src/components/settings/members-tab.tsx` — roster + pending
invitations. Every member can see the roster (read-only).
Admin+ gets an inline role-select dropdown (excluding the
owner row and self), a Remove button (same restrictions), and
the "Pending invitations" section with revoke.
- `src/components/settings/invite-member-dialog.tsx` — two-step
modal. Form (role + expiry + optional label) → result
(read-only URL with copy-to-clipboard + a "Send via WhatsApp"
deep link). The result step is dismissed-once: the plaintext
token is only ever shown here and never resurfaced, so the
copy carries a warning ("Save this link now").
- `src/app/(dashboard)/settings/page.tsx` — adds `members` to
the tab list, gated behind the existing
`profiles.beta_features.includes('account_sharing')` flag
(same column migration 011 added; flip it via Supabase Studio
on a per-profile basis to dogfood). When the flag is off the
tab trigger AND content render nothing — the page degrades
to the original 5-tab layout. A user landing on
`/settings?tab=members` without the flag is silently bounced
to `?tab=profile` rather than seeing an empty pane.
Why a feature flag at all
- Locked decision from the planning phase: ship the schema and
API unconditionally (they're structural, harmless to existing
single-user installs) but keep the *user-visible* invite
surface behind a flag until the full UI series ships (Join
page, sidebar gating, webhook account routing). Mirrors how
migration 011 / Flows beta worked.
Role-gating
- The tab is reachable by any member of an account. Inside the
tab, mutation controls (Invite member button, role dropdown,
Remove button, pending invitations section + revoke buttons)
are wrapped in `<RequireRole min="admin">` or hidden via
`useAuth().canManageMembers` from #172.
- Server-side RPCs (set_member_role, remove_account_member from
#170; invitation create/revoke from #171) double-check role
anyway — the client gate is UX, not security.
Owner row
- Non-editable. Inline role select and Remove button are hidden
on the owner's row across the board. Owner role changes go
through the (deferred) Transfer Ownership flow.
Self row
- "You" badge on the caller's own row. Role select and Remove
are hidden on it — admins can't accidentally demote or remove
themselves, even though the server RPC would also catch it.
Email visibility
- Admin+ sees teammate emails; agent/viewer sees name + avatar
+ role + joined date only. Implemented server-side by the
`/api/account/members` route in #170; the component just
renders `member.email` when present.
UI patterns
- Mirrors existing tag-manager / template-manager card +
divide-y list layout and the slate-900/slate-700/primary
color tokens from the dark-only theme.
- Dialogs follow the established `bg-slate-900 border-slate-700`
pattern. Destructive actions get the `bg-red-600 hover:bg-red-700`
treatment with `Loader2` spinner.
- "Send via WhatsApp" anchor uses `buttonVariants` directly
rather than `<Button asChild>` — the wacrm Button is the
Base UI ButtonPrimitive and has no Radix-style asChild.
- Base UI Select's `onValueChange` is
`(value: string | null) => void`; every consumer guards on
null. We don't expose a clear affordance, so null shouldn't
fire at runtime — but typescript requires the guard.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds; /settings stays static (data
fetches happen client-side via useEffect)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
b013861160
|
feat(api): invitation create + peek + redeem endpoints
PR 4 of the multi-user accounts series. Lands the link-only
invitation flow — admin generates a shareable URL, copies it,
sends it via any channel. No email service needed.
API surface
- GET /api/account/invitations — list pending (admin+)
- POST /api/account/invitations — create (admin+)
- DELETE /api/account/invitations/[id] — revoke (admin+)
- GET /api/invitations/[token]/peek — public, rate-limited per-IP
- POST /api/invitations/[token]/redeem — authenticated, rate-limited per-IP
Token lifecycle
- POST returns the plaintext token + share URL exactly ONCE in
the response. We store only the SHA-256 hash. There is no
endpoint that resurfaces it; if the admin dismisses the dialog
without copying, the only recourse is to revoke and re-issue.
- Peek and redeem take the plaintext token in the URL path, hash
it in TS via `hashInviteToken`, and look up by `token_hash`.
Two new SECURITY DEFINER RPCs back the cross-RLS work
(migration 019):
- `peek_invitation` — anonymous read. Returns a uniform JSON
envelope: `{ ok, account_name, role, expires_at }` or
`{ ok: false, reason: not_found|used|expired }`. The route
forwards verbatim so the join page can branch cleanly.
- `redeem_invitation` — authenticated. Locks the invite row
`FOR UPDATE`, validates expiry/usage, refuses if the caller is
already in a shared account OR their personal account has any
domain rows (checked across all 11 user-creatable parent
tables), then atomically moves profile.account_id +
account_role, stamps the invitation accepted, and deletes the
orphan personal account. Single transaction.
Error contract
- 22023 (invalid_parameter_value) → 400 — not_found / used / expired
- 42501 (insufficient_privilege) → 401 — caller not authenticated
- 23505 (unique_violation, reused) → 409 — caller's account has data,
or they're already a
member of the inviter's
account / another shared
account
Rate limiting
- Two new buckets in `RATE_LIMITS`: invitationPeek (30/min/IP)
and invitationRedeem (10/min/IP). Belt-and-braces against
token enumeration even though 256-bit entropy makes it
theoretical. Uses the existing fixed-window in-memory limiter.
Why no peek-then-redeem two-step on the server
- Peek is read-only and idempotent; redeem locks `FOR UPDATE` so
two concurrent redemptions can't both win. The peek result is
cosmetic ("show me what I'm joining"); the actual write
happens entirely inside the redeem RPC's transaction, so a
TOCTOU between peek and redeem just means the user sees the
same `{ ok: false, reason }` on the next render.
Verification
- `npm run lint` → 0 errors (same 20 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds; the 4 new routes register as ƒ
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
0a1cf97d6b
|
feat(api): account + members management endpoints
PR 3 of the multi-user accounts series. Lands the server-side endpoints the upcoming Members tab UI will call. Built on the helpers from #169 (`requireRole`, `getCurrentAccount`, `toErrorResponse`). API surface - GET /api/account — caller's account + role - PATCH /api/account — rename (admin+) - GET /api/account/members — roster; email visible to admin+ only - PATCH /api/account/members/[userId] — change role (admin+) - DELETE /api/account/members/[userId] — remove (admin+) - POST /api/account/transfer-ownership — atomic owner handoff (owner) Member-role writes can't ride on the regular `profiles_update` RLS policy (which only allows self-edits — admins can't modify a teammate's row that way). The three SECURITY DEFINER RPCs in the new migration are the scoped escape hatches: - `set_member_role(p_user_id, p_new_role)` — admin+ only. Blocks promotion to / demotion from owner (use transfer instead) and self-targeting. - `remove_account_member(p_user_id)` — admin+ only. Creates a fresh personal account for the removed user and reassigns their profile as its 'owner' — mirror of the signup flow, so they keep their login and just "start over". Blocks removing the owner or self. - `transfer_account_ownership(p_new_owner_user_id)` — owner only. Atomically demotes current owner → admin, promotes target → owner, and points accounts.owner_user_id at the new owner. Demote-first ordering means the temporary "zero owners" state is never visible. Error contract - Each RPC raises with SQLSTATE 42501 (forbidden) or 22023 (bad input) and a human-readable RAISE message. The route handlers map these onto 403 / 400 respectively; anything else collapses to a generic 500. - The TS guards (e.g. `requireRole('owner')` on the transfer route, `isAccountRole(role)` on the PATCH route) are belt-and-braces — they fail fast before the Supabase round trip but the RPCs would catch the same violations regardless. Field visibility - `GET /api/account/members` returns `email` only when the caller is admin+. Agents and viewers see name + avatar + role + joined date — matches the planning decision so a viewer can't harvest the team's email list. Verification - `npm run lint` → 0 errors (same 20 pre-existing warnings) - `npm run typecheck` → clean - `npm test` → 366/366 pass - `npm run build` → succeeds; the 4 new routes register as ƒ (dynamic — they all use `next/headers` cookies via the SSR client) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
13bafa5747
|
fix(inbox): reply quote no longer overflows or single-lines (#165)
A long reply quote was forcing the entire 3-panel inbox layout
wider, pushing the contact sidebar off-screen and making the page
horizontally scrollable. Two root causes (both correctly diagnosed
in the issue by @tekrexllp):
1. ReplyQuote applied `truncate` to the preview text, which sets
`white-space: nowrap`. Long previews became one impossibly-wide
line instead of wrapping.
2. The flex chain from MessageBubble outward (MessageActions →
middle-panel wrapper → page) had no `min-w-0` on its flex
children. Default `min-width: auto` means a flex child grows to
its content's natural width, so the long quote propagated all the
way up to the inbox-page flex container and pushed the contact
sidebar past the viewport.
Fix:
- ReplyQuote preview now uses `whitespace-pre-wrap break-words`
instead of `truncate`. The author label stays single-line (it's
a name, ellipsis is right for it). `overflow-hidden` on the
inner text container as belt-and-braces.
- MessageActions' .group/actions wrapper gets `min-w-0` so the
`max-w-[75%]` cap actually holds even with wide content.
- inbox/page.tsx middle-panel wrapper gets `min-w-0` so the
MessageThread can shrink below its natural content width when
the conversation list and contact sidebar are also flexing.
Audited the other 38 `truncate` usages across the codebase —
they're all on intentionally single-line content (contact names,
file paths, last-message previews in lists, headers, node
summaries) where ellipsis is the right behavior. ReplyQuote was
the only multi-line `truncate` misuse.
Reported and diagnosed by @tekrexllp in #165.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
bc859bcbcd
|
feat(flows): canvas / mind-map view (PR 1 of 3 — read-only)
The second half of the user feedback that drove send_media: the
linear-list editor reads as a checklist, not a branching flow, which
made conversational structures hard to understand at a glance. This
introduces a canvas view alongside the list — boxes + arrows, drag-
zoom-pan, labeled edges per branch slot. Canvas is the new default;
the list stays as a fallback toggle (per-browser preference,
persisted to localStorage).
PR 1 scope is intentionally read-only: render, don't edit. The
foundation is testable in isolation before adding drag-to-persist,
side-panel editing, and drag-to-connect (PR 2) and polish like
keyboard shortcuts, mobile fallback, and validator integration
(PR 3).
Pieces:
- New @xyflow/react (~30 KB gz) + @dagrejs/dagre (auto-layout for
pre-existing flows whose position_x / position_y are all zero —
flow_nodes already reserved those columns in migration 010
precisely for this).
- Shared module src/components/flows/shared.tsx lifts NodeType,
BuilderNode, NODE_META, summarizeNode, truncate so both views
render nodes identically.
- Pure helpers (no React, fully unit-tested):
src/lib/flows/edges.ts — derive labeled canvas edges per
outgoing slot (button, list row,
condition branch)
src/lib/flows/layout.ts — dagre wrapper + shouldAutoLayout
guard so manually-positioned flows
never get rearranged on open
- FlowCanvas — client component, custom node renderer matching the
list view's collapsed-card styling, MiniMap + Controls, attribution
hidden.
- FlowEditorShell — owns the Canvas / List toggle, conditionally
mounts the right view.
21 new unit tests (layout + edges); existing 271 still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
7bd112f61c
|
fix(whatsapp): register phone numbers on Meta + verify subscription state
Closes the silent-failure bug a user reported: when multiple phone
numbers are connected to the same WABA, only the first one actually
receives webhook events from Meta. The wacrm UI shows "Connected"
because the metadata probe succeeds, but Meta's activity log is
empty for every subsequent number.
Root cause
----------
The POST /api/whatsapp/config handler verified credentials with a
GET /{phone_number_id} probe and saved them to Supabase — but
never called Meta's two registration endpoints that actually wire
a number for inbound events:
1. POST /{phone_number_id}/register
Subscribes the number to THIS app's webhook. Requires a
6-digit 2FA PIN the user previously set in Meta WhatsApp
Manager → Two-step verification. Without it, Meta routes
events for that number to whichever app last claimed it.
2. POST /{waba_id}/subscribed_apps
Subscribes the WABA itself to this app. Required once per
WABA, but idempotent on Meta's side so we call on every
save without harm.
The first number "worked" because someone (the user, Embedded
Signup, or Meta dashboard) had already done both calls for it
out-of-band. Every subsequent number silently failed.
What this PR does
-----------------
* New helpers in meta-api.ts: registerPhoneNumber (treats Meta's
"already registered" 400 as success), subscribeWabaToApp
(idempotent), getSubscribedApps (for the diagnostic).
* POST /api/whatsapp/config now:
- Accepts an optional `pin` (6-digit, validated).
- Calls subscribeWabaToApp on every save (idempotent).
- Calls registerPhoneNumber on first save and whenever a PIN
is supplied. Skipped when the same number is already
registered locally and no fresh PIN is provided — re-running
/register with a stale PIN would actually undo the active
subscription.
- Persists registered_at / subscribed_apps_at timestamps and
last_registration_error for diagnostic surfacing.
- Returns {success, registered, registration_error} so the UI
can distinguish "creds valid" from "actually live".
- Saves the row even when /register fails so the user can
retry without re-entering everything.
* New diagnostic GET /api/whatsapp/config/verify-registration —
hits Meta to confirm the saved number is reachable + the WABA
is subscribed. Returns a per-check breakdown the UI renders.
Solves the misleading-green-banner UX failure that caused the
bug to go unnoticed in the first place.
* Migration 015 adds registered_at, subscribed_apps_at,
last_registration_error to whatsapp_config. All nullable so
existing rows survive — the UI labels them "registration status
unknown" until the user clicks Verify or re-saves.
* whatsapp-config.tsx:
- New PIN input with help text pointing to the exact Meta
Manager setting and explaining why it's needed.
- Two-tier status: existing "Credentials valid" alert (relabeled
from "Connected" since it was misleadingly broad) PLUS a new
Registration Status alert that flips emerald/amber based on
the registered_at timestamp.
- "Verify with Meta" button on the registration alert pings
the diagnostic endpoint and renders the per-check result
inline. Surfaces last_registration_error verbatim with a
pointer to retry.
- PIN clears from state after a successful save so it can't
accidentally retrigger /register on the next save.
Tests + checks
--------------
* 9 new fetch-mock tests cover registerPhoneNumber (happy path,
already-registered passthrough, PIN-required error verbatim,
generic Meta error), subscribeWabaToApp (URL + bearer + error),
getSubscribedApps (data, empty, error).
* 265/265 tests pass. Lint 0 errors. Typecheck clean. Build
registers both /api/whatsapp/config and
/api/whatsapp/config/verify-registration.
Out of scope
------------
* Multi-number-per-CRM-user (current schema is UNIQUE(user_id)
so one CRM user = one number). The reported bug was
multi-number-per-WABA across multiple CRM users, which this
fully resolves.
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>
|
||
|
|
c7d780631b
|
feat(templates): webhook handler for real-time status / quality updates
Wires Meta's three template-lifecycle webhook events into the
existing /api/whatsapp/webhook route so APPROVED / REJECTED /
PAUSED transitions land locally without a manual Sync click.
Handled events
--------------
message_template_status_update
→ flip status to the raw Meta enum (re-using normalizeStatus
from PR 3 so PENDING_REVIEW collapses to PENDING etc.)
→ persist `reason` to rejection_reason on REJECTED so the
red banner in template-manager shows what to fix
→ clear rejection_reason + submission_error on any other
transition so a stale REJECTED message doesn't linger
after re-approval
message_template_quality_update
→ set quality_score (GREEN/YELLOW/RED). Unknown values land
as NULL so the chip just disappears.
message_template_components_update
→ info-log only for v1. Auto-persisting Meta's modified
components would silently change what the user thought
they submitted; instead we point them to "Sync from Meta"
in the log line. A follow-up PR could surface a banner.
Lookup by meta_template_id (indexed in migration 014) — globally
unique per WABA, so no user_id filter needed. Unknown rows log a
warning instead of failing; >1 matches log a warning too in case
of cross-tenant drift.
New module
----------
src/lib/whatsapp/template-webhook.ts isolates the dispatch + each
handler. The route handler just calls
`handleTemplateWebhookChange(change, supabaseAdmin())` when
`isTemplateWebhookField(change.field)` matches, and the existing
messaging branches are short-circuited so a template event doesn't
try to read message-shaped fields off the value.
12 new tests cover: status APPROVED happy path, REJECTED with
reason, REJECTED missing reason → generic fallback, PENDING_REVIEW
normalization, missing meta_template_id no-op, unknown row warning,
quality score update, invalid quality value → NULL, components
update no-op, unknown field defensive no-op, and the field detector.
Setup requirement
-----------------
Subscribing to these fields is a one-time manual step in Meta App
Dashboard → WhatsApp → Configuration → Webhooks (no API for Cloud
API apps). Documented in a top-of-file comment block in
template-webhook.ts. Until it's done, status updates still arrive
via the manual "Sync from Meta" button — that fallback stays.
Out of scope (final PR in sequence):
* sendTemplateMessage media + button param support (PR 6)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
a2100a3076
|
feat(templates): edit / resubmit / delete lifecycle endpoints
Adds the missing lifecycle operations so users can manage existing
templates without leaving wacrm.
API surface
-----------
PATCH /api/whatsapp/templates/[id]
Edits (and re-submits) an existing Meta-side template. Used by
the "Edit" action on APPROVED rows and "Resubmit" on REJECTED /
PAUSED rows — same operation, different UI labels. Meta replaces
components wholesale and flips status back to PENDING for
re-review. Status guard rejects edits on PENDING / DISABLED /
IN_APPEAL / PENDING_DELETION and on local-only (no
meta_template_id) rows. Honors WHATSAPP_TEMPLATES_DRY_RUN.
DELETE /api/whatsapp/templates/[id]
Calls Meta `DELETE /{waba_id}/message_templates?name=X&hsm_id=Y`
when meta_template_id is set — hsm_id is critical, without it
Meta deletes EVERY language variant of the same name. Treats
Meta 404 as a no-op (template already gone). Always removes the
local row.
New Meta helpers
----------------
src/lib/whatsapp/meta-api.ts gains editMessageTemplate and
deleteMessageTemplate following the established named-args +
throwMetaError pattern. Eleven new fetch-mock tests assert the
exact wire shape — especially that hsm_id always rides along when
provided, which is the difference between "delete one variant"
and "delete every variant."
UI changes
----------
src/components/settings/template-manager.tsx:
* Edit (pencil) button visible for APPROVED rows
* Resubmit (rotate) button visible for REJECTED / PAUSED rows
— same flow as Edit, different label
* Delete now calls the route handler (so Meta gets the call too)
with a spinner during the in-flight request
* Dialog pre-fills form from the row when opened in edit mode
* Dialog title / CTA / description switch between "New Message
Template" + "Submit for Approval" and "Edit Message Template"
+ "Save & Resubmit"
* Name + language inputs are disabled in edit mode (Meta keys
templates by id; changing either is effectively a new template)
* Closing the dialog clears editingId so the next open starts
fresh
Design decision
---------------
One PATCH endpoint instead of the two endpoints the plan called
for (PATCH for edit, POST for resubmit). The Meta call is the same
operation either way — the difference is UI semantics, handled in
the component. Cuts duplication; easy to split later if needed.
Out of scope (final PR remaining in the sequence):
* Webhook handler for real-time status (PR 5)
* sendTemplateMessage media + button param support (PR 6)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
3113738c7e
|
feat(templates): submit-to-Meta endpoint + component-based form builder
Closes the second half of #147. Users can now create a template in wacrm and submit it to Meta for approval without leaving the app — no more dummy local rows that fail with Meta error #132001 on send. API surface ----------- POST /api/whatsapp/templates/submit Auth → fetch whatsapp_config → validate payload → build Meta components → POST to /{waba_id}/message_templates → persist row with meta_template_id + status + sample_values + last_submitted_at. Per-field validators throw with specific messages before the network call, so misconfigured templates fail with "button #3 missing url" instead of a 400 from Meta. 429s are detected and surfaced as "rate limit hit (100/hr)". WHATSAPP_TEMPLATES_DRY_RUN=true short-circuits the Meta call and issues a synthetic `dry-run-<uuid>` meta_template_id so CI and local dev can exercise the full form without a real WABA. New modules ----------- src/lib/whatsapp/template-validators.ts Pure functions encoding Meta's caps: 1024-char body, 60-char footer/header, 25-char button text, 10 buttons max, 2 URL / 1 PHONE_NUMBER / 1 COPY_CODE, QUICK_REPLY contiguity rule, URL example required when {{1}} is present, COPY_CODE example required, header media URL must be http(s), variables must be contiguous starting at {{1}}. 41 unit tests cover each rule. src/lib/whatsapp/template-components.ts Translates our local row shape into Meta's components array (HEADER → BODY → FOOTER → BUTTONS, body_text examples as a 2D array per spec, header_handle preferred over header_url when both are present). Snapshot tests assert the wire shape. src/lib/whatsapp/template-status-normalize.ts Single source of truth for upstream→local status mapping. Replaces the duplicated normalizer in sync/route.ts; the upcoming webhook handler will share it too. src/lib/whatsapp/meta-api.ts New submitMessageTemplate() helper following the existing named-args + throwMetaError pattern. Form rewrite ------------ src/components/settings/template-manager.tsx replaces the flat form with a component-based builder: * Header: format select (None / Text / Image / Video / Document) + conditional inputs — text + variable-aware sample row for TEXT, URL paste for media (file upload slot ready for the Resumable Upload follow-up) * Body: textarea with live {{N}} extraction → sample-value rows auto-appear and shrink as variables are added/removed * Footer: 60-char capped input * Buttons editor: add up to 10, per-row type picker (Quick Reply / URL / Phone / Copy Code), conditional URL + example + phone + code fields, X to remove * Card display now surfaces rejection_reason / submission_error (red banner) and quality_score (GREEN/YELLOW/RED chip) * AUTHENTICATION shows a "create in Meta Manager + Sync" banner and disables Submit — that category has a different shape (fixed body, mandatory OTP button) and lands in a follow-up Side effects ------------ * Broadcast Step 1 picker (step1-choose-template.tsx) now filters to status='APPROVED' to match the inbox picker — selecting a non-approved template would 400 on send. * .env.local.example documents META_APP_ID (Resumable Upload follow-up) and WHATSAPP_TEMPLATES_DRY_RUN. Out of scope (subsequent PRs): * Edit / resubmit / delete endpoints (PR 4) * Webhook handler for real-time status (PR 5) * sendTemplateMessage media + button param support (PR 6) * Resumable Upload + AUTHENTICATION builder (follow-up) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
aa4f34c94c
|
feat(templates): store raw Meta status enum + lay schema groundwork for submission
Switches message_templates.status from the local TitleCase set
(Draft|Pending|Approved|Rejected) to Meta's raw enum (DRAFT|PENDING|
APPROVED|REJECTED|PAUSED|DISABLED|IN_APPEAL|PENDING_DELETION) and
adds the columns the upcoming submit / edit / resubmit / webhook
flows need. No new user-facing behavior — this is groundwork.
Migration 014 also adds:
* sample_values JSONB Meta requires these for variable templates
* meta_template_id TEXT hsm_id for scoped edit/delete (avoids
nuking sibling-language variants)
* rejection_reason TEXT surfaced from webhook on REJECTED
* quality_score TEXT GREEN | YELLOW | RED, from webhook
* header_handle TEXT from Resumable Upload (media headers)
* header_media_url TEXT URL fallback for media headers (v1 path)
* submission_error TEXT last 4xx from Meta, for retry surface
* last_submitted_at rate-limit awareness (100 creates/hour)
* buttons JSONB shape CHECK permissive array-of-typed-objects guard
(strict per-type validation lives in API)
* UNIQUE (user_id, name, language)
so sync upsert is atomic and prevents
duplicate language variants
* idx on meta_template_id fast webhook lookup
Sync route now:
* persists Meta's status verbatim (PAUSED no longer collapses to
REJECTED — these are different states with different recoverability)
* parses BUTTONS components into the new discriminated-union shape
* extracts sample_values from body.example.body_text + header.example
* persists meta_template_id, quality_score, header_handle
Shared template-status.ts gives the manager / inbox picker / broadcast
picker one source of truth for labels + dark-theme badge classes.
Part of the Issue #147 overhaul plan (PR 2 of 6 in the sequence).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
71ae95f441
|
fix(whatsapp): prevent silent inbound-message drop when two users share a phone_number_id
The webhook routes inbound messages by phone_number_id and uses
`.single()` to find the owning config. If two users save the same
phone_number_id, `.single()` errors PGRST116 ("multiple rows") and every
inbound message is silently dropped while the log misleadingly reports
"No config found".
Three layers of fix:
- POST /api/whatsapp/config now rejects with 409 when another user has
already claimed the phone_number_id. The check uses a service-role
client because RLS hides the conflicting row from the saving user.
- Webhook lookup no longer uses `.single()`. It distinguishes 0 rows
(true "no config") from ≥2 rows (duplicate claim) so logs name the
real cause and list conflicting user_ids.
- Migration 013 adds UNIQUE(phone_number_id) to whatsapp_config so the
same can't happen via direct DB writes or a race. Fails loudly with a
copy-pasteable resolution hint if duplicates already exist, rather
than auto-deduping (which would destroy tokens).
Closes #136.
|
||
|
|
4e89b2b902
|
feat(flows): soft-GA — open to all users + Beta label
The Flows feature was previously gated on a per-account `profiles.beta_features` opt-in: the sidebar entry was invisible to non-beta accounts, every /api/flows route 404'd, the webhook dispatcher was a no-op, and the agent-handoff pause was skipped. This PR opens the surface to every authenticated user. A small "Beta" chip in the sidebar nav + on the /flows page H1 keeps the "young feature" signal without holding anyone out. Removed: - `src/lib/flows/feature-flag.ts` + its tests (-11 tests). - The `requireFlowsBeta` / `requireFlowsBetaAndOwnership` helpers in the API routes — collapsed to plain `requireUser` / RLS ownership. - The `isProfileFlowsEnabled` helper + branch in the webhook dispatcher — the runner takes the cheap "no_match" exit on accounts with no active flows (single indexed SELECT). - The agent-handoff pause gate in /api/whatsapp/send — the UPDATE matches zero rows when no active run exists, also cheap. - Client-side beta gates in the 3 flows pages (and the redundant `useAuth` + `profileLoading` plumbing they needed). - The defensive message-bubble gate that would have rendered interactive replies as plain text for non-beta accounts. - The `betaKey` field on the sidebar NavItem schema, replaced by a simpler boolean `beta` that purely drives the visual chip. Kept: - `profiles.beta_features` column. Generic mechanism for future beta gates; no current feature reads it. Comments in `src/types/index.ts` + `use-auth.tsx` updated to say so. - The chip can be removed in a one-liner once the team's satisfied Flows has bedded in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |