- Add 'Broadcasts' namespace to en.json for all messaging flows
- Localize broadcast list and detail views (stats, recipient tables, funnel)
- Localize 4-step Broadcast creation wizard (Choose Template, Select Audience, Personalize, Schedule)
- Refactor status configuration to use translation keys instead of hardcoded labels
- Pass full typechecking across all wizard components
Promotes the AI assistant out of Settings into a first-class "AI Agents"
sidebar area.
- Sidebar: new "AI Agents" item → /agents.
- /agents page with two tabs:
- Playground — a test chat (POST /api/ai/playground) that runs the
real auto-reply path (KB retrieval + provider, auto_reply prompt,
multi-turn, shows handoff). loadAiConfig gains requireActive so you
can test before enabling.
- Setup — the existing provider/key + knowledge-base config, relocated.
- Removed the AI section from Settings (settings-sections + page); same
account config, new home. No migration.
Tests +6 (query, loadAiConfig requireActive). 593 pass; typecheck, lint,
build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Security: REVOKE ALL FROM PUBLIC + GRANT EXECUTE (authenticated,
service_role) on the match RPCs so anon can't read a KB by supplying
an account_id (mirrors migrations 018/025).
- Ingest: store chunks even when embedding fails (insert first, rethrow
after) so a doc stays lexically searchable; the "lexical still works"
warning is now truthful.
- Retrieval: short-circuit on an empty KB (cheap COUNT) to avoid a paid
query embedding + 2 RPCs on every draft/auto-reply.
- Migration: HNSW instead of IVFFlat (robust on small/growing tables);
language-neutral 'simple' FTS config (was 'english'); semantic RPC
takes text and casts ::vector(1536) to remove PostgREST bind ambiguity.
- Embeddings key: distinguish corrupt-from-absent (loadEmbeddingsKey
returns {key,corrupt}); log on decrypt failure; reindex now reports
failure instead of false success; POST/PATCH warn on keyword-only save.
- embedTexts: fail loud when the provider omits result indices (was
silently misaligning chunks with vectors).
Tests: +3 (empty-KB guard, embed-failure-still-indexes, missing-index).
587 pass; typecheck/lint/build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Grounds the AI assistant (0.5.0) in the account's own content so it can
answer instead of handing off.
- Hybrid retrieval: Postgres full-text search for every account; optional
semantic search (pgvector + OpenAI text-embedding-3-small) when an
embeddings key is set. Semantic-primary, topped up with FTS.
- src/lib/ai: chunk, embeddings, knowledge (ingest + retrieve), query;
buildSystemPrompt gains a knowledge block; config carries an
(optional) embeddings key.
- Wired into /api/ai/draft and the auto-reply bot (best-effort — never
blocks a reply).
- KB API (/api/ai/knowledge CRUD + /reindex) and a Settings → AI
Assistant knowledge-base manager + embeddings-key field.
- Migration 030: pgvector, ai_knowledge_documents, ai_knowledge_chunks
(fts + embedding), match RPCs, ai_configs.embeddings_api_key.
Migration required: supabase/migrations/030_ai_knowledge.sql.
Stacked on feat/ai-reply-assistant (#329).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an AI reply assistant powered by the account's own OpenAI or
Anthropic key (stored AES-256-GCM-encrypted; no per-seat AI fee).
- AI-drafted replies in the inbox composer (✨ button, agent+), via
read-only POST /api/ai/draft.
- Optional auto-reply bot on inbound messages that no Flow consumed and
no agent owns, with a per-conversation cap and clean [[HANDOFF]] to a
human. Stands down when a message-level automation would also reply.
- Settings → AI Assistant (admin+): provider/model, key, business
context, toggles, and a Test-key button that verifies before saving.
- Provider layer (src/lib/ai) with OpenAI + Anthropic adapters behind
one interface; migration 029 (ai_configs + per-conversation columns +
atomic claim_ai_reply_slot RPC).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This introduces the base i18n setup using next-intl without routing. It adds the default messages/en.json and sets up the app layout and next.config.ts to consume the locale from the environment. The login page has been migrated as a first example.
Completes the public API roadmap (#245): register HTTPS endpoints to be
POSTed to when things happen in an account, so automations can react to
inbound activity instead of polling.
- Migration 028: account-scoped webhook_endpoints (secret stored
AES-GCM-encrypted) + an atomic record_webhook_failure() function.
- Scope webhooks:manage; management API GET/POST /api/v1/webhooks and
GET/PATCH/DELETE /api/v1/webhooks/{id} (secret returned once).
- Events message.received / conversation.created / message.status_updated
dispatched from the inbound webhook handler's after() block.
- Signed delivery (X-Wacrm-Signature = HMAC-SHA256 over t.body), single
best-effort attempt, auto-disable after consecutive failures.
- Versioned 0.4.0; docs/public-api.md Webhooks section; CHANGELOG.
Fixes from the adversarial code review of this diff:
- SSRF: block private/loopback/link-local (incl. cloud metadata) targets
at delivery time + redirect:'manual' so a public URL can't bounce
internal (src/lib/webhooks/ssrf.ts).
- Status mirror UPDATE no longer uses .select().maybeSingle() (message_id
is non-unique); account resolved via a bounded limit(1) read.
- Atomic failure counter via SQL RPC (was a racy read-modify-write).
- conversation.created emitted before the reaction short-circuit.
- Per-delivery id added for receiver dedupe; docs state at-least-once /
unordered delivery.
- Signature verify tolerates hex case / header whitespace.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Complete the public API roadmap on top of the existing key-auth layer
(#290). Re-lands POST /api/v1/messages (lost when the stacked #291 never
reached main) by extracting the dashboard send route's core into
`sendMessageToConversation`, then adds:
- Contacts: GET/POST /api/v1/contacts, GET/PATCH /api/v1/contacts/{id}
- Conversations: GET list + /{id} + /{id}/messages
- Broadcasts: POST /api/v1/broadcasts + GET /{id} (persist rows, fan out
in after(), poll for status)
All list endpoints share one keyset-pagination contract
({ data, meta: { next_cursor } }). No migration required — scopes and
tables already exist. Versioned as 0.3.0 (folded into [Unreleased]);
docs/public-api.md updated; outbound webhooks left as the sole roadmap
item. API stays v1 (additive).
Includes fixes from an adversarial code review: cursor validation
against filter injection, broadcast counts deferred to the DB aggregate
trigger, terminal broadcast status reflects failures, duplicate-phone
de-dup, up-front send-param validation (no orphan conversations),
PATCH null-clear semantics, error-checked tag sync, bounded tag filter,
unified audit-user attribution, and a maxDuration on the broadcast
fan-out.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds contact-based filters to the Inbox conversation list (issue #272):
- Multi-select tag filter with OR logic (a conversation matches if its
contact has any selected tag), consistent with Broadcast audiences.
- Company filter (exact match) over companies present in the inbox.
- Filters stack with the existing status filter and search; active
selections show as removable chips with a clear-all control.
Tags are hydrated via a shared CONVERSATION_SELECT embed and normalized
onto contact.tags, so the realtime hydrate path keeps new conversations
matching an active filter. Filtering logic lives in a pure, unit-tested
matchesContactFilters helper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a "Send template" action to the contact detail view so the
business can initiate (or re-open) a WhatsApp conversation with one
known contact by sending an approved template — without the awkward
single-recipient broadcast workaround.
The /api/whatsapp/send route now accepts `contact_id` as an
alternative to `conversation_id`: it verifies the contact is in the
caller's account, find-or-creates the contact's conversation
server-side, then runs the existing send path (phone-variant retry,
template send-builder, message + conversation persistence, flow
pause). The contact view reuses the existing inbox TemplatePicker for
template selection + variable/header/button input, keeping Meta
compliance unchanged (approved templates only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The POST handler ack'd Meta with 200 and then ran processWebhook() as a
detached promise. On Vercel the function can be frozen/terminated the moment
the response is sent, so those DB writes were not guaranteed to complete.
This dropped a non-deterministic subset of inbound messages: the contact and
conversation rows (written first) landed, but the message insert (written
after) often did not — leaving conversations that appear in the inbox with an
empty thread, with no durable logs to explain it (issue #301).
Move the work into next/server `after()`, which keeps the function alive until
the callback resolves (within the route's maxDuration) while still ack'ing Meta
fast enough to avoid retry-driven duplicate inserts. Add maxDuration=60 for
headroom since processing can fan out to per-media Meta verification calls.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Templates with an IMAGE/VIDEO/DOCUMENT header could not be broadcast:
the wizard never offered a place to provide the header media URL, so
the send either shipped the template's stale stored URL or — when none
was stored — threw "image header requires a media link or id at send
time", failing the whole broadcast. The send path already supported
`messageParams.headerMediaUrl`; only the UI/hook never collected it.
- step3-personalize: show a required "Header media" URL field (with
http(s) validation and an image preview) when the selected template
has a media header; seed it from the template's stored sample URL and
block Next until a valid URL is provided.
- new-broadcast page: thread headerMediaUrl through wizard state.
- use-broadcast-sending: pass it as messageParams.headerMediaUrl to the
broadcast API for media-header templates.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the machine-to-machine auth layer the public REST API needs so
external scripts and automations can drive wacrm (issue #245). This is
the groundwork release; data endpoints land one at a time on top of it.
- Migration 026: account-scoped `api_keys` table. SHA-256 hash at rest
(plaintext shown once), display `key_prefix`, `scopes[]`, RLS via
`is_account_member` (members read, admin+ manage).
- `src/lib/api-keys/`: key gen/hash (wacrm_live_ prefix, timing-safe),
scope vocabulary, service-role auth-path store.
- `requireApiKey(request, scope?)` (src/lib/auth/api-context.ts):
bearer parse -> hash lookup -> liveness -> per-key rate limit ->
scopes-only authz. Returns a service-role-scoped account context.
- `/api/v1` envelope ({data} / {error:{code,message}}) + GET /api/v1/me
reference endpoint that exercises the whole stack.
- Dashboard: admin+ key management routes + Settings -> API keys tab.
- Docs (docs/public-api.md), CHANGELOG, README, .env note.
Checks: typecheck clean, lint 0 errors, full suite green (+27 tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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 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>
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>
`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>