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>
017 claimed to be idempotent but only dropped the *legacy* policy names
before recreating; the new policies (contacts_select, …) were created
unguarded. A second run therefore failed with
`42710: policy "contacts_select" for table "contacts" already exists`.
Drop every policy on the tables 017 manages (it owns all RLS on them —
no later migration adds others) in a DO block before the CREATEs, so the
rewrite re-establishes the full set on every run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The pre-017 signup trigger (001) inserted the profiles row inside an
`EXCEPTION WHEN OTHERS ... RAISE WARNING; RETURN NEW` block, so a signup
could leave an auth.users row with no matching profiles row.
The 017 account-sharing backfill keyed accounts off `profiles`, so such
an orphaned user got no account and was never linked. If that user owned
any domain rows (pre-017 RLS only required `auth.uid() = user_id`, not a
profile), their account_id stayed NULL and the subsequent
`ALTER TABLE ... SET NOT NULL` aborted the whole migration — exactly the
upgrade failure reported in #274.
Add a step (0) to the backfill DO block that creates the missing profile
for every auth.users row lacking one, before accounts are created. This
keys the backfill off auth.users instead of profiles, so every
authenticated user is migrated and no domain row can be left without an
account. Idempotent and safe to re-run, matching the rest of 017.
Fixes#274
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>
The page wrapper around the message thread already carries `min-w-0`
(issue #165), but the MessageThread root itself kept the default
`min-width: auto`. A single wide message (a long unbroken URL or word)
expanded the whole thread past its flex share, so at lg+ the chat
painted on top of the contact sidebar: outgoing bubbles were clipped on
the right and the hover toolbar floated over the Tags panel.
Adding `min-w-0` to the thread root lets it shrink to its column so the
bubbles' existing break-words / max-w-[75%] caps actually apply. Verified
in-browser: a 1272px-wide column went from 962px of overlap to 0.
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.