Adds a "Filter by tags" popover (OR across selected tags) next to the
search box, with active-tag chips and clear-all. Resolves#270.
Filtering is resolved server-side via the filter_contacts_by_tags RPC so
pagination and the total count stay accurate even when a tag covers many
contacts (avoids PostgREST's row/IN-clause caps). A fetch-sequence guard
prevents out-of-order responses from rendering stale rows, and stale tag
selections are pruned when the tag list refreshes.
Note: requires migration 025_filter_contacts_by_tags.sql to be applied.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a live presence layer so the Settings → Team members roster and the
inbox Assign dropdown show who is actively using the dashboard, idle, or
gone. Implements #269.
How it works
- New `member_presence` table + `touch_presence` SECURITY DEFINER RPC
(migration 024). Account-scoped SELECT RLS mirrors the read-only
roster; the RPC derives account_id server-side, never client-supplied.
- The active client heartbeats 'online'/'away' every ~30s; 'offline' is
derived by viewers from staleness, so a closed tab resolves to offline
without an unreliable unload write.
- Pure, unit-tested derivation/formatting in src/lib/presence.ts.
- `usePresence` reads the table, subscribes to Realtime (subscribe-then-
merge so events landing mid-fetch aren't clobbered), and re-derives
offline on a 15s tick.
- Roster: avatar badge + tooltip + "N online · N away · N offline"
summary, with an aria-label so AT/keyboard users get presence too.
Inbox Assign dropdown: matching dot before each teammate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Creating a keyword_match automation with Match type left on the default
"Contains" failed activation with `match type must be "exact" or
"contains"` (path trigger.match_type). The dropdown showed "Contains"
via a display-only fallback, but the value was never written to the
config, so the saved payload omitted match_type entirely.
Both engines already treat a missing match_type as "contains"
(automations/engine.ts, flows/engine.ts), so the activation validator
was stricter than runtime. Two coordinated fixes:
- validate.ts: accept a missing match_type (defaults to "contains");
only an explicit, unrecognised value is rejected. Aligns the
validator with the engines and any non-UI client.
- automation-builder.tsx: seed match_type: "contains" on mount of
KeywordMatchConfig so the displayed default is actually persisted,
keeping the saved record explicit and self-describing.
Adds a regression test that a keyword_match config with no match_type
passes activation validation.
Fixes#271
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a desktop-only toggle in the message-thread header to show/hide the
contact sidebar (tags, deals, notes). On smaller laptops the always-on
panel crowds the message thread; agents who only want to read and reply
can now reclaim that horizontal space.
The page owns the open/closed state and conditionally renders the
sidebar; the thread renders the toggle button and reflects the state.
The choice is persisted to localStorage (device-scoped, like the theme
prefs) so it survives reloads. To avoid a hydration mismatch we default
to open on the server and reconcile from storage in an effect after
mount. Mobile is unchanged — the sidebar is never a permanent panel
there and the toggle is hidden below lg.
Closes#258
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Settings page was the only dashboard route capped at
max-w-[1040px] mx-auto, so it floated centered with large empty
gutters on wide monitors while every other page is full-bleed.
Remove the cap so the page fills the available width like the rest of
the app, and spend the space where it helps readability:
- Overview status tiles go 3-up on xl (from 2-up).
- Templates list goes 2-up on xl.
- Form-heavy panels (Profile, Login & security, Deals) keep a readable
max-w-2xl; Fields & tags and Appearance use max-w-3xl — so inputs
don't stretch edge-to-edge on large displays.
- Grid uses minmax(0,1fr) to avoid blowout from long content.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the flat 8-tab Settings page with a cleaner information
architecture: a grouped left rail (Account / Workspace) plus a new
Overview landing with live status tiles. Related concerns are merged —
"Login & security" (password + sessions) and "Fields & tags" (tags +
custom fields).
- New shell: settings-sections (section model + legacy ?tab= mapping,
tags/custom-fields -> fields), settings-rail, settings-overview
(live counts + identity), security-panel, fields-and-tags-panel,
settings-panel-head, settings-chip, role-meta (shared role chip
metadata, de-duped from members-tab).
- Restyle the light panels to the mockup; reframe the heavy panels
(WhatsApp, Templates) with the shared panel header, internals intact.
- Tokens: add --card-2 (light+dark) and @theme mappings for
primary-hover/soft/soft-2 so the redesign stays fully tokenized.
- ?tab= remains the URL source of truth, keeping existing sidebar/header
deep-links working.
Overview decouples the slow WhatsApp Meta health-ping from the cheap
count queries so a flaky ping never blanks the whole landing; template
counts use head-only count queries.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Creating a template with an image header failed because the code sent
`example.header_url` — Meta only accepts a Resumable-Upload media handle
(`example.header_handle`) at creation time, never a plain URL.
- Resumable Upload: new `uploadResumableMedia` (meta-api) implements the
two-step `/{app_id}/uploads` flow → media handle. Needs META_APP_ID.
- Handle derivation: `ensureImageHeaderHandle` fetches the header image
(uploaded file OR pasted URL) and turns it into a handle before submit.
Wired into both the submit and edit (PATCH) routes with actionable 400s
(missing META_APP_ID, unreachable URL, wrong type, >5 MB). This fixes
BOTH the new upload path and the previously-broken manual-URL path.
- Template builder: add an image file upload (account-scoped chat-media
bucket via uploadAccountMedia) with preview + JPEG/PNG + 5 MB validation,
alongside the URL field.
- Send-time fix: an approved image template now sends the public
header_media_url as `link`; the resumable header_handle is NO LONGER
used as a send media id (it's invalid for sends and would fail).
- META_APP_ID documented as required for image-header templates.
Scope: image headers (the issue). Helper is generic for video/document.
typecheck/lint/build green; 421 tests (10 new).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
A Condition (If/Else) step rendered three add-step connectors: Yes, No,
and a trailing linear "+". ConditionBranches already renders the Yes/No
branch StepLists (each with its own AddButton), while StepRenderer also
emitted a trailing AddButton after every step — including conditions.
Skip the trailing AddButton when step_type === "condition", since a
condition branches into Yes/No and has no linear continue path. Steps
after a condition are added inside one of its branches.
Fixes#256
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
Adds an "Unread" option to the Inbox conversation-list filter dropdown,
showing only conversations with unread messages (unread_count > 0).
Closes#233
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sweep over-tokenized the neutral status badges in the three status
data modules to bg-muted/border-border, which broke
broadcast-status.test.ts ("each variant has the dark-theme class
triple") — it asserts every badge is bg-*/10 + text-* + border-*/20.
The badge system intentionally uses fixed palette colors at low opacity
(translucent fills are mode-tolerant). Restore the original slate fills
(so dark is identical and the triple contract holds) but keep
text-muted-foreground for the label, so neutral statuses stay legible in
light mode where a solid slate-400 would be too faint on white.
- broadcast-status: draft / recipient pending
- template-status: DRAFT / PENDING_DELETION
- trigger-meta: time_based / fallback
All 400 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
- Reformatted code in `import-modal.tsx` for better readability, including consistent indentation and line breaks.
- Enhanced `parse-contact-csv.ts` and `parse-contact-csv.test.ts` with consistent string delimiters and improved whitespace handling.
- Updated comments for clarity and consistency across the import functionality.
- Ensured consistent use of single quotes for strings throughout the files.
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>
- Updated `parseContactCsv` to include a flag for the presence of a company column in the CSV.
- Modified the import modal state management to handle the new company column, improving user experience during contact import.
- Enhanced the `ImportPreviewTags` component to conditionally render based on the presence of company data.
- Adjusted the UI to better display tag and company information in the import preview.
- Added `ImportPreviewTags` component to display tags with color coding in the import modal.
- Updated `parseContactCsv` to return an object containing both parsed rows and a flag indicating the presence of a tags column.
- Modified state management in the import modal to handle tags and their colors, improving user experience during contact import.
- Adjusted the import modal to conditionally render the tags column based on the presence of tags in the preview.
- Introduced `parseContactCsv` for improved CSV parsing, replacing the previous inline parsing logic.
- Added support for importing tags alongside contacts, allowing users to assign tags during the import process.
- Implemented `assignImportedContactTags` to handle tag assignments after contact creation.
- Updated the import result state to include the number of tags assigned and added relevant success and info notifications for users.
- Enhanced the import modal description to clarify the format for tag input in CSV files.
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>
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>
The list-view keyword trigger input parsed its value into the keywords
array on every keystroke (split/trim/filter(Boolean)) and re-rendered
from keywords.join(", "). Typing a comma produced a trailing empty
segment that filter(Boolean) dropped, so the comma was stripped the
instant it was typed — making it impossible to enter a second keyword.
Extract a KeywordsInput component that holds a local draft string and
only commits (parse + dedupe-empties + rejoin) on blur / Enter, mirroring
the automations builder's KeywordMatchConfig. Multi-keyword entry like
"support, help, hi" now works.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The conversation/contact list on the Inbox renders inside a flex
column with a fixed height, with the list in a `<ScrollArea
className="flex-1">`. A flex child defaults to `min-height: auto`, so
the ScrollArea grew to fit every conversation rather than shrinking to
the remaining space. The list then overflowed its container and was
clipped by the parent's `overflow-hidden`, with no vertical scrollbar —
so contacts past the fold were unreachable (issue #229).
Add `min-h-0` to the ScrollArea so it can shrink below its content and
the viewport scrolls.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
deals.currency has no DB CHECK (only accounts.default_currency does),
so legacy rows, imports, or hand-edited data can hold a structurally
invalid code (e.g. "United States"). Intl.NumberFormat throws a
RangeError on such codes, which would crash any deal-card / contact
render that passes deal.currency straight through.
Guard formatCurrency with a try/catch that falls back to "CODE 1,234"
so a bad value degrades gracefully instead of taking down the view.
Add currency.test.ts locking in the defaults, the no-throw contract,
and formatCurrencyShort's symbol/fallback behavior.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
The automation builder exposed several developer-facing fields that
made automations hard to configure from the UI (issue #216):
- Keyword Match parsed/trimmed on every keystroke, so typing a
comma-separated, multi-word list like "SEO, search engine
optimization" was impossible — the comma and trailing space were
stripped immediately.
- Tag Added / Add Tag / Remove Tag asked for a raw tag_id.
- Assign Conversation → Specific agent asked for a raw agent_id.
- Send Template asked for a manually typed template name + language.
Changes:
- Keyword input keeps a local draft and only parses into the keywords
array on blur / Enter, then re-displays the cleaned form.
- New tag / agent / template pickers select existing resources by name
while still storing ids / names internally, so the stored config
shape (and the engine/validation) is unchanged. Resources load once
at the builder root and are shared via context.
- Tags & approved templates load from Supabase (RLS-scoped, matching
the broadcast picker); members load from /api/account/members.
- Every picker falls back to its original raw input when its list is
empty (fresh account / older deployment), so automations stay
authorable. Saved-but-missing ids are preserved when editing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pipeline board uses overflow-x-auto but hid the scrollbar on fine
pointers, assuming the full board always fits on lg+. That assumption
breaks once a pipeline has many stages: columns keep a 260px min-width,
so the row overflows the viewport and the rightmost stage gets clipped
with no scrollbar or affordance to reveal it (issue #214).
Scope the scrollbar-hiding to touch devices (where the snap/peek layout
already hints at more content) and show a thin, themed horizontal
scrollbar on desktop so the overflow is discoverable and usable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`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>
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>
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>
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>
The automation engine executes steps via the service-role client, which
bypasses RLS. POST /api/automations/engine reads contact_id straight from
the request body, so an authenticated attacker could supply another
tenant's contact UUID and have the engine modify it (update_contact_field),
tag it, or read it back through a condition — bypassing the contacts RLS
policy. (GHSA-63cv-2c49-m5v3)
- Primary: verify the supplied contactId belongs to the dispatching
account before any step runs; refuse silently otherwise.
- Defense in depth: scope the service-role contacts writes/reads
(update_contact_field, contact_field condition) by account_id. Tag
steps rely on the entry-point guard since contact_tags has no
account_id column — documented inline.
- Add engine.test.ts covering the guard (PoC inverted), the happy path,
and the account-scoped write.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
Re-applies PR #182's next-intl scaffold with defensive measures aimed
at the most-likely-but-unconfirmed causes of the 503 that hit prod
after that PR merged. PR #183 reverted #182 to restore service; this
branch carries the same i18n payload plus the safeguards below.
Defensive changes vs PR #182:
1. **`outputFileTracingRoot: __dirname`** in next.config.ts.
Builds were warning that Next.js inferred the workspace root as
the parent GHWebhosting directory (because of a stray lockfile
there). With the wrong root, file tracing for the production
artifact can include the wrong files or skip ours. Pinning the
root to the wacrm directory silences the warning and makes tracing
deterministic across local / CI / Hostinger.
2. **`outputFileTracingIncludes: { "/**/*": ["./messages/**/*.json"] }`**.
Belt-and-suspenders: explicitly force the i18n message dictionaries
into the production artifact even if static-import tracing somehow
misses them in Hostinger's build environment.
3. **Static locale import map in `src/i18n/request.ts`**.
Replaces `import(\`../../messages/\${locale}.json\`)` with an
explicit `{ en: () => import(...), es: ..., lt: ... }` map.
Template-literal dynamic imports are well-supported by Turbopack's
tracer but the static form leaves zero room for the tracer to
skip a locale.
4. **Try/catch around the intl middleware call**.
If `intlMiddleware(request)` ever throws at runtime, fall through
to auth-only handling and log the error rather than 503-ing every
page request behind a single bad call.
5. **Try/catch around `LOADERS[locale]()`** with English fallback.
Same idea: a missing locale bundle at runtime returns English
content instead of crashing the render path.
6. **`engines.node: ">=20.10.0"`** (was `>=20.0.0`). Next 16.2's
actual minimum. Tells Hostinger's Node.js app picker not to land
us on a too-old subversion.
Verified locally:
- npm run lint → 0 errors (same 18 pre-existing warnings)
- npm run typecheck → clean
- npm run test → 366 passing
- npm run build → 62 prerendered pages across en/es/lt; workspace
root warning gone; messages bundled into `.next/server/chunks/ssr/
messages_{en,es,lt}_json_[json]_cjs_*.js`
- npm start probes:
/ → 307 → /en
/en/login → 200
/es/login → 200
/lt/login → 200
/en/dashboard → 307 → /en/login (signed out)
/api/whatsapp/templates → 401
If 503 reappears in prod after this lands, the local repro is clean,
so the next step is hPanel build/runtime logs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>