Commit Graph

252 Commits

Author SHA1 Message Date
Arnas Donauskas
f90563b35c fix(flows): wrap add-node menu groups in DropdownMenuGroup (#336)
Clicking "Add node" in the flow builder crashed the whole page to a
"This page couldn't load" error boundary, in both the list and canvas
views.

The add-node menus render a DropdownMenuLabel per category. That is
base-ui's Menu.GroupLabel, which calls useMenuGroupRootContext() during
render and THROWS ("MenuGroupContext is missing") when it has no
Menu.Group ancestor. Both menus wrapped each category in a plain <div>
instead of a DropdownMenuGroup, so the label had no group context. The
menu content only mounts when the menu opens, which is why initial
render was fine and the crash fired exactly on click.

Wrap each category's label + items in DropdownMenuGroup (base-ui
Menu.Group), which provides the context Menu.GroupLabel needs. The
between-group separator moves into a keyed Fragment so it stays a
sibling of the groups.

Adds a regression test pinning the contract: a bare DropdownMenuLabel
throws, a group-wrapped one does not.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:45:01 +03:00
Arnas Donauskas
5dbc786fdc
Merge pull request #333 from ArnasDon/feat/ai-knowledge-base
feat: AI knowledge base (RAG grounding) — retarget to main
2026-07-02 17:50:18 +03:00
Arnas Donauskas
020dc2ae11 fix(ai-kb): address code-review findings
- 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>
2026-07-02 17:20:38 +03:00
Arnas Donauskas
ce1fa4705d feat: AI knowledge base (RAG grounding)
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>
2026-07-02 17:06:09 +03:00
Arnas Donauskas
1f24b1ff48
Merge pull request #329 from ArnasDon/feat/ai-reply-assistant
feat: AI reply assistant (bring-your-own-key)
2026-07-02 12:58:39 +03:00
Arnas Donauskas
2748028c1d feat: AI reply assistant (bring-your-own-key)
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>
2026-07-02 12:56:04 +03:00
Chris Eldon
c5f2544ff9 fix: gated button add your first contact
(cherry picked from commit 90fd9bc4cc5b8270c44a8c8d1cbc864f38eb6e97)
2026-07-01 23:33:04 +07:00
Arnas Donauskas
c0f390c401 feat(api): outbound event webhooks — /api/v1/webhooks (#245)
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>
2026-07-01 14:53:18 +03:00
Arnas Donauskas
8cb5792823 feat(api): public REST API v1 — messages, contacts, conversations, broadcasts (#245)
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>
2026-07-01 14:10:19 +03:00
Arnas Donauskas
41815acc88
Merge pull request #324 from ArnasDon/feat/inbox-tag-company-filter
feat(inbox): filter conversations by contact tags and company
2026-07-01 13:06:14 +03:00
Arnas Donauskas
f8aaac131b feat(inbox): filter conversations by contact tags and company
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>
2026-07-01 13:03:53 +03:00
Arnas Donauskas
427b4612da
Merge pull request #310 from bellopushon/feat/automation-deal-selectors
feat(automations): add deal pipeline selectors
2026-07-01 11:46:13 +03:00
Arnas Donauskas
f4beb6f368
Merge pull request #322 from RiadDeveloper/main
Prevent redundant profile fetches on refresh
2026-07-01 11:27:20 +03:00
Arnas Donauskas
6ff44644c6
Merge pull request #279 from AlexTPaul/main
feat: Notifications
2026-07-01 11:24:36 +03:00
Arnas Donauskas
7baeb94003 fix(settings): preserve WhatsApp credential drafts on tab refocus
The WhatsApp settings load effect re-ran fetchConfig() whenever `user`
or `profileLoading` changed. Supabase's onAuthStateChange fires a token
refresh (new `user` object, profileLoading toggles) when the browser
tab regains focus, so returning to the tab re-hydrated the form and
overwrote any unsaved credential input.

Guard the effect with a ref tracking the account id already loaded, so
it only fetches once per account and ignores auth-state churn that
doesn't actually change the account. Explicit reloads after save,
registration verification, and reset still call fetchConfig directly.

Closes #307

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:16:54 +03:00
Riad Developer
1fce87c4c6
Prevent redundant profile fetches on refresh
Replace `user` object with `user?.id` in dependency arrays to prevent unnecessary re-renders. Add `lastFetchedUserIdRef` to track the user ID for successfully initiated profile fetches, preventing redundant API calls when the window refocuses or the auth token is refreshed.
2026-06-30 13:01:09 -07:00
AlexTPaul
cd863cf9ee feat(notifications): implement account-based notification retrieval and update logic 2026-06-30 11:16:20 +05:30
Alex Titus Paul
cf842fcee2
Merge branch 'ArnasDon:main' into main 2026-06-30 10:44:07 +05:30
Arnas Donauskas
65b677181e
Merge pull request #320 from ArnasDon/fix/template-picker-account-scope
fix(templates): scope template picker to account, not user
2026-06-29 17:35:51 +03:00
Arnas Donauskas
3110932d78
fix(templates): scope template picker to account, not user
The inbox/contact TemplatePicker fetched approved templates filtered by
`user_id = current user`, so in a shared account a teammate only saw
templates they personally created — others' approved templates were
hidden, leaving them unable to send those templates.

message_templates is account-owned and its RLS SELECT policy already
scopes rows to account members (is_account_member, migration 017), so
the user_id filter was both redundant and over-restrictive. Drop it and
rely on RLS, matching the broadcast template picker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 17:33:41 +03:00
Arnas Donauskas
19e35accb6
feat(contacts): send an approved template to a single contact (#296)
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>
2026-06-29 17:11:02 +03:00
Arnas Donauskas
ce0d04ba80
feat(flows): group the add-step menu by node category
The canvas and list add-node menus both listed all 10 node types in
one flat dropdown. As the type list grows — especially for forks
adding their own node types (see #312) — a flat list gets hard to
scan. Bucket the types into Messaging / Logic & data / Flow control,
driven by a new `category` field on NODE_META so a new type can't go
uncategorized, and a `groupNodeTypesByCategory` helper shared by both
menus so they stay in lockstep.

No triggers/events category: in wacrm a flow is triggered by
flow-level config (trigger_type), not a canvas node, so `start` lives
under Flow control.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:21:07 +03:00
bellopushon
377c1b738a feat(automations): add deal pipeline selectors 2026-06-26 15:57:06 -04:00
Arnas Donauskas
11d1c622d0
fix(webhook): run inbound processing in after() so it survives serverless freeze
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>
2026-06-26 17:34:44 +03:00
Arnas Donauskas
1f4d1a3a11
Redesign flow editor UI to match Flow Builder design
Reworks the flow editor (/flows/[id]) to the Flow Builder design
handoff, adapted to wacrm's existing tokens, components, and
light+dark theming. Keeps all behaviour — React Flow canvas,
drag-to-connect, typed config forms, validation, save/activate/delete,
per-browser view persistence, mobile-forces-list.

- shared.tsx: per-node-type color system (nodeColors) + one-line
  blurbs, and a shared NodeIconChip component. Label color blends
  toward --foreground via color-mix so it stays readable in both modes.
- header.tsx: compact single-row toolbar (back / icon / inline name /
  status chip / edited dot · Runs / Delete / Activate / Save) plus a
  subtle inline description note.
- flow-editor-shell.tsx: app-like full-height layout (toolbar → mode
  row → stage → validation bar) with a segmented Canvas/List control
  and a complete node-type legend derived from NODE_META.
- flow-canvas.tsx: per-type colored node cards, branch ports, dot-grid
  background, rounded controls/minimap, top-left primary Add-node menu,
  colored inspector header.
- flow-builder.tsx: list rows tied to the same per-type color language.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:53:03 +03:00
Arnas Donauskas
33942dc8ac
fix(broadcasts): collect header media URL for image/video templates (#298)
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>
2026-06-25 17:17:18 +03:00
Arnas Donauskas
738db87847
fix(auth): load account context without an embedded FK join
getCurrentAccount() and the client AuthProvider resolved the caller's
account through a PostgREST embedded join (`account:accounts!inner(...)`)
on profiles. That embed requires PostgREST to have the
profiles.account_id -> accounts.id relationship in its schema cache.
When the cache is stale -- a common Supabase state right after a
migration adds the FK, or when migrations are applied out of band -- the
embed fails hard with PGRST200 and the whole account context collapses.

It is the only query shape in the app that depends on relationship
inference (everything else reads accounts with plain `.eq('id', ...)`
filters), which is why the dashboard kept working while Settings ->
API keys / Team members returned 403 "Could not load account context"
and the Overview's cards hung loading (issue #294).

Replace the embed with a plain account-by-id lookup in both the server
helper and the client AuthProvider. Same accounts RLS, no relationship
inference, graceful degradation. The client now also keeps account_id /
account_role even if the account-name lookup itself fails. Adds a
regression test for getCurrentAccount.

Fixes #294

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:34:26 +03:00
Arnas Donauskas
456321a2f2
feat(api): public API groundwork — scoped API keys + /api/v1/me
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>
2026-06-23 15:35:43 +03:00
Arnas Donauskas
1b512fb17c
fix(auth): preserve refreshed Supabase cookies across middleware redirects
When getUser() refreshes an expired access token it rotates the refresh
token and writes the new cookies onto `supabaseResponse` via setAll().
The redirect and 401-JSON branches returned brand-new NextResponse
objects that didn't carry those Set-Cookie headers, so the rotated token
never reached the browser. The next request replayed the old, now-
consumed refresh token, the refresh failed, and the session wedged —
users got a broken reload after idling and could only recover by
manually clearing cookies.

Copy the refreshed cookies onto every response the middleware returns.

Fixes #288

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 10:08:05 +03:00
Arnas Donauskas
b066182b03
feat(contacts): filter contacts by tags on the Contacts page
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>
2026-06-22 15:38:05 +03:00
Arnas Donauskas
054c9e4e47
feat(team): member presence (online / away / offline) on the roster
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>
2026-06-22 14:37:29 +03:00
AlexTPaul
ab6a511d31 feat: Notifications 2026-06-22 13:12:28 +05:30
Arnas Donauskas
ca3aa29c3a
fix(automations): persist default match_type for Keyword Match triggers
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>
2026-06-22 10:20:38 +03:00
Arnas Donauskas
9b32954d1a
feat(inbox): collapsible contact sidebar on desktop
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>
2026-06-19 14:06:02 +03:00
Arnas Donauskas
1de99be73a
fix(settings): full-bleed layout so the page fills wide screens
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>
2026-06-18 15:30:41 +03:00
Arnas Donauskas
a45cbe6822
feat(settings): redesign Settings into a grouped rail + Overview landing
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>
2026-06-18 15:08:33 +03:00
Arnas Donauskas
4d23afc563
feat(templates): upload image for template headers; fix image-URL submit (#230)
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>
2026-06-18 14:00:54 +03:00
Arnas Donauskas
ac9fc8dfc9
feat(inbox): graduate chat media send to GA (remove beta flag)
Photo/video/document/voice send has been validated in production, so it's
now available to every account. Removes the 'chat_media' beta gate:

- Composer always shows the attach menu + voice recorder (no
  useBetaFeature check).
- Send route no longer 403s media kinds for non-opted-in accounts.
- Deletes the now-unused beta-features helper, the useBetaFeature hook,
  and their test (added solely for this rollout; no other consumer).

The profiles.beta_features column (migration 011) is left in place for
future betas. typecheck/lint/build green; 411 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 11:38:54 +03:00
Arnas Donauskas
e855e6677f
Merge pull request #262 from ArnasDon/fix/chat-media-client-recording
refactor(inbox): record voice notes client-side, drop server ffmpeg
2026-06-18 11:29:10 +03:00
Arnas Donauskas
4fa29b7a45
refactor(inbox): record voice notes client-side, drop server ffmpeg
Follow-up to #259. Voice notes are encoded to Ogg/Opus in the browser via
opus-recorder (small WASM encoder, lazy-loaded on record), so there's no
server-side transcode and nothing to provision on the host. The server
stores the .ogg like any other attachment; WhatsApp renders it as a voice
note.

- Composer: MediaRecorder + /api/whatsapp/transcode-audio replaced with
  opus-recorder. Encoder worker vendored at public/opus/encoderWorker.min.js
  (no install-time download to fail), ignored by eslint + prettier.
- Removed the transcode route, the audio-transcode lib + tests, the
  ffmpeg-static dep + ambient types, the FFMPEG_PATH env doc, and the
  unused transcodeAudio rate limit.
- Added opus-recorder ambient types.

Still behind the 'chat_media' beta flag with per-kind size caps.
typecheck/lint/build green; 414 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 11:27:12 +03:00
Arnas Donauskas
07e513690f
fix(automations): remove spurious third output on condition steps
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>
2026-06-18 11:20:55 +03:00
Arnas Donauskas
873ba1f4c6
feat(inbox): gate media send behind 'chat_media' beta flag
Media sends (photo/video/document/voice) are now opt-in per account via
the existing profiles.beta_features mechanism (migration 011 — same
pattern used to dogfood Flows before GA). No migration needed.

- New shared `hasBetaFeature` + `CHAT_MEDIA_BETA` ('chat_media') key, and
  a `useBetaFeature` client hook mirroring useCan (false while loading).
- Composer: the attach menu, hidden file inputs, and voice recorder only
  render when the flag is on; the hint indent reverts so non-beta users
  see the unchanged text + template composer.
- Server: the send route rejects media kinds (403) and the transcode
  route rejects (403) unless the caller's profile is opted in — so the
  endpoints can't be driven ahead of the rollout. Text + templates stay
  available to everyone.

Enable for one account via Supabase Studio:
  UPDATE profiles SET beta_features = array_append(beta_features, 'chat_media')
  WHERE email = 'someone@example.com';

Tests: hasBetaFeature null-safety + membership (419 total). typecheck/lint/build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:44:02 +03:00
Arnas Donauskas
96e7a56e3e
fix(inbox): harden media send — GC orphans, per-type caps, voice cap
Addresses senior-review findings on the media-send feature:

- Storage lifecycle: uploads are GC'd on the abandonment paths
  (discarded draft, failed/errored send, unmount with a staged draft,
  and replacing one draft with another) via a new deleteAccountMedia
  helper. Previously every cancel/failure orphaned a public object.
- Per-kind size caps (image 5MB, video/audio/document 16MB) mirror
  Meta's limits and are enforced BEFORE upload, so a too-large image no
  longer uploads then fails at the Meta call.
- Voice notes auto-stop at a 5-min cap and always render as a voice note
  (transcode anything that isn't already OGG/Opus — Safari MP4 included),
  not just Chromium WebM.
- Send route rejects unknown message_type and captions over Meta's 1024
  cap up front; transcode route guards its output size too.
- DraftPreview hoisted to a stable module-scope component so the caption
  input no longer remounts (and drops focus) on each keystroke.

Tests: per-kind cap assertions added (418 total). typecheck/lint/build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:28:19 +03:00
Arnas Donauskas
312683ca05
feat(inbox): send photo, video, document & voice in chat (#213)
Agents can now attach a photo/video/document or record a voice note in
the inbox composer and send it live in a 1:1 thread — previously media
could only be received from customers or sent via the Flows builder.

- 023_chat_media.sql: new account-scoped `chat-media` Storage bucket
  (mirrors flow-media RLS from 020) plus Meta-accepted audio MIME types.
- meta-api: sendMediaMessage gains an `audio` kind (link-only — Meta
  rejects caption/filename on audio; WhatsApp renders OGG/Opus as a
  voice note).
- /api/whatsapp/send: media branch reusing the phone-variant retry loop.
- /api/whatsapp/transcode-audio: Node-runtime route remuxing the
  WebM/Opus that Chromium's MediaRecorder produces to OGG/Opus via
  ffmpeg-static (Firefox/Safari record accepted formats and skip it).
- composer: attach menu, file pickers, voice recorder + auto-transcode,
  preview with caption; thread: optimistic handleSendMedia.
- shared uploadAccountMedia helper (flows form refactored onto it).
- next.config: allow microphone=(self) (was fully blocked) + CSP
  media-src; document optional FFMPEG_PATH override.

Tests: meta-api audio payload shape, buildMediaPath, audio-transcode
helpers (14 new). typecheck/lint/build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:07:16 +03:00
Arnas Donauskas
ee8da11985
feat(contacts): allow bulk-deleting selected contacts (#239)
Add row selection to the contacts table so multiple contacts can be
deleted at once instead of one at a time.

- New Checkbox UI primitive wrapping @base-ui/react/checkbox, matching
  the switch.tsx token conventions (incl. indeterminate state).
- Header select-all + per-row checkboxes; row-checkbox clicks stop
  propagation so they don't open the detail sheet.
- Bulk action bar with selected count, Clear, and a role-gated
  "Delete selected" action.
- handleBulkDelete via .delete().in('id', ids) — RLS already restricts
  deletes to account members, so no migration/API route is needed.
- Selection is page-scoped and resets whenever the contact set reloads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:44:41 +03:00
Arnas Donauskas
66a5bb51d2
feat(inbox): add "Unread" filter to conversation list
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>
2026-06-17 13:40:06 +03:00
Arnas Donauskas
43b842efb6
fix(theme): keep status-badge token contract green (CI test)
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>
2026-06-17 13:08:32 +03:00
Arnas Donauskas
1474657d03
fix(theme): light-mode polish from review pass
Self-review of the light/dark sweep surfaced neutral colors that lived
in raw inline-style / <style jsx> / gradient values (so the className
sweep correctly skipped them) and a few contrast/affordance nits:

- reply-quote: adapt to the outbound (primary) bubble via onPrimary so
  the quote/author stay legible on the violet fill in light mode; wire
  it from message-bubble.
- dashboard SVG chrome -> tokens: conversations-chart gridline/crosshair,
  pipeline-donut background ring, automation-builder dot-grid gradient.
- members-tab viewer chip: bg-card -> bg-transparent (outline) so it
  doesn't blend into a card surface in light mode.
- tag-manager / pipeline-settings color-swatch selection rings: raw
  slate/white -> var(--popover)/var(--foreground) so they flip.
- pipeline-board scrollbar (<style jsx>): slate rgb -> var tokens.
- activity-feed zebra stripe: bg-card/40 -> bg-muted/40 (survives light).
- forgot-password / import-modal: restore real hover (dead no-op hovers).
- themed-toaster: gate theme via useSyncExternalStore to avoid a
  hydration mismatch on sonner's theme attribute (no setState-in-effect).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 09:28:22 +03:00
Arnas Donauskas
4aa329b527
feat(theme): add light/dark mode toggle (#243)
Introduce a light/dark MODE axis orthogonal to the existing accent
themes. Neutral surface tokens now live in html[data-mode="light|dark"]
blocks; accent tokens (--primary*, --ring, --chart-1, --sidebar-primary*)
stay in html[data-theme] blocks, so any accent works in either mode.

- globals.css: split neutrals (mode) from accent (theme); add light
  palette; dark values unchanged (dark stays the default).
- lib/themes.ts: Mode type, DEFAULT_MODE=dark, MODE_STORAGE_KEY, isMode.
- use-theme.tsx: provider owns mode/setMode/toggleMode + cross-tab sync.
- layout.tsx: boot script applies persisted mode before first paint
  (no flash); colorScheme "dark light".
- mode-toggle.tsx (header sun/moon) + themed-toaster.tsx (mode-aware).
- appearance-panel.tsx: light/dark control + updated copy.
- Tokenize hardcoded neutral colors (slate/white/gray) -> semantic
  tokens across the app so every surface flips with mode. Flow canvas
  and message-bubble timestamps made mode-aware too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:29:46 +03:00
Abhishek Sonwal
f39ca60629 refactor(contacts): improve code readability and formatting in import modal and CSV parsing
- 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.
2026-06-16 12:59:03 +05:30