Commit Graph

155 Commits

Author SHA1 Message Date
dhruv465
242e2c2073 fix: keep auto-layout hydration clean 2026-05-31 21:49:14 +05:30
dhruv465
3854502652 fix: smooth flow dragging and refresh chunks 2026-05-30 17:01:14 +05:30
Arnas Donauskas
c5a4439f99
feat(flows): canvas / mind-map view (PR 3 of 3 — polish)
Closes the trilogy. Three high-value gaps from PRs 2a/2b addressed:

  1. Canvas users couldn't save. The Header (with Save / Activate /
     Pause / Delete buttons) lived inside FlowBuilder so canvas-only
     users had to toggle to List to persist any edit. Lifted into a
     new components/flows/header.tsx that consumes useFlowEditor()
     and is mounted ONCE in FlowEditorShell, above both views.

  2. Validator issues weren't clickable from canvas. The
     ValidationPanel was also FlowBuilder-internal. Lifted into
     components/flows/validation-panel.tsx + mounted in the shell,
     pinned sticky-bottom in either view. Clicks now go through the
     editor context's new requestFlash(key) — both views subscribe:
       - List view derives an "expanded with flash" set + an effect
         scrolls the row into view. The flash class is rendered when
         the card's key matches context.flashKey.
       - Canvas view's pan-on-flash effect calls
         reactFlow.setCenter() to bring the node into the viewport,
         and the card renders an amber ring while flashed.
     The flash auto-clears in the context after 1600ms; rapid
     clicks on different issues cancel the previous timeout.

  3. Canvas was unusable on phones — handles are ~10px and live
     finger-drags between them aren't a practical workflow. Added
     a useMatchMedia('(max-width: 767px)') guard in the shell that
     forces list view and hides the toggle below the breakpoint.
     The user's preferred view is preserved (just shadowed) so it
     comes back when they widen.

Plus minor cleanup: the canvas's empty-state used to direct users
to switch to List view to add a node, but the floating "+" button
shipped in PR 2b made that stale — now shows the same Add-node
dropdown inline.

flow-builder.tsx shrinks by another ~370 lines (Header + StatusBadge
+ ValidationPanel + IssueLine + the local flashedKey + jumpToNode
all moved or replaced by context subscription).

No new tests — the change is structural + UI-only. Existing 319
still pass; typecheck, lint, build all clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 21:04:20 +02:00
Arnas Donauskas
dcdfff938d
feat(flows): canvas / mind-map view (PR 2b of 3 — full editing)
Closes the editing-affordance gap from PR 2a. The canvas is now in
full parity with the list view for everything that fits in a node
graph: connections, deletes, and adding nodes all work on-canvas.

Adds:

  - Per-slot Handle components on the custom node renderer. Single-
    outgoing types get one handle on the right edge; condition gets
    `true` / `false` handles; send_buttons and send_list render slot
    rows inside the card with one handle each, so the user can SEE
    which button or list row they're wiring. Every non-start node
    gets a target handle on the left.

  - Drag-to-connect (`onConnect`). Looks up the source node, dispatches
    the right config patch via the new `applyEdgeConnection(node,
    sourceHandle, target)` helper, and routes through updateNodeConfig.
    Rejects self-loops silently (a button whose target is its own node
    = infinite reprompt).

  - Keyboard delete on nodes (Backspace + Delete, both for cross-OS
    parity). Routes through removeNode, which now ALSO unlinks every
    inbound reference via the new `unlinkNodeReferences(nodes, key)`
    helper. No more dangling arrows after a delete — the validator
    previously flagged these as errors and the user had to chase them
    down manually.

  - Edge delete. Selecting an edge + Delete clears just that slot's
    next_node_key (rather than removing anything), via the same
    applyEdgeConnection helper passing "" as the target.

  - Floating "+ Add node" button (React-Flow Panel, bottom-right).
    Same dropdown as the list view; drops the new node at the visible
    viewport center via reactFlow.screenToFlowPosition.

New pure helpers in src/lib/flows/edges.ts (no React, fully unit-
tested — 18 new tests, 319 total passing):

  - outgoingSlots(node) — list of {id, label} per outgoing slot.
  - applyEdgeConnection(node, sourceHandle, targetKey) — config patch
    for a drag-to-connect, or null for unrecognised handles.
  - unlinkNodeReferences(nodes, deletedKey) — clears every direct +
    nested reference to deletedKey. Returns nodes-by-identity when
    no reference matches to avoid needless re-renders.

The hook's removeNode is the only consumer of unlinkNodeReferences
right now, which means LIST-view deletes also stop leaving dangling
refs — strict improvement for both views.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 20:32:59 +02:00
Arnas Donauskas
1645034e1a
feat(flows): canvas / mind-map view (PR 2a of 3 — editable foundation)
Builds on PR 1's read-only canvas. This PR brings the canvas to ~75%
of list-view parity: drag-to-reposition (persisted) and click-to-edit
via a side panel that mounts the same per-node forms the list view
uses. Drag-to-connect + add/delete from canvas land in PR 2b.

Architecture (the bulk of the diff):

  - Extracted the per-node forms (SendButtonsForm, SendListForm,
    SendMediaForm, ConditionForm, SetTagForm + inline forms for the
    smaller types) plus the shared field components (TextRow,
    NextNodeRow, NodeKeySelect) from flow-builder.tsx into
    src/components/flows/forms/. Both views now mount the SAME
    NodeConfigForm dispatcher so behaviour can't drift between
    "what the list editor shows" and "what the canvas side panel
    shows".
  - Lifted all editor state into a context/hook
    (src/components/flows/flow-editor-state.tsx) with a
    FlowEditorProvider that wraps both views in flow-editor-shell.
    Single source of truth: toggling Canvas ⇄ List never resets
    unsaved edits, and a drag on the canvas updates the same nodes
    array the list view reads.
  - flow-builder.tsx shrinks from ~2400 lines to ~900 (extracted
    forms) and stops owning state (now consumes via useFlowEditor()).
    List-only UI state — expanded cards, scroll refs, jump-to-node
    flash — stays internal.

Canvas additions:

  - onNodeDragStop writes the new position to context via
    updateNodePosition (flips dirty; serialized in the existing PUT
    /api/flows/[id] body — the route already accepts position_x /
    position_y per migration 010).
  - onNodeClick opens a controlled shadcn Sheet (right-side panel)
    with the per-node form, plus "Set as entry" and "Delete node"
    buttons. Esc / overlay / close button all close.

What's intentionally still NOT in this PR (PR 2b):

  - Per-slot Handle components on the custom node renderer.
  - Drag-to-connect handler that maps target → updated
    next_node_key for the right slot.
  - Floating "+" button to add nodes from canvas.
  - Keyboard / context-menu delete.

Tests: 9 new pure-helper tests (uniqueNodeKey + defaultConfigFor
self-consistency). Hook integration tests are deferred — would
require @testing-library/react + jsdom infra. Existing 271 tests
still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 20:17:15 +02:00
Arnas Donauskas
bc859bcbcd
feat(flows): canvas / mind-map view (PR 1 of 3 — read-only)
The second half of the user feedback that drove send_media: the
linear-list editor reads as a checklist, not a branching flow, which
made conversational structures hard to understand at a glance. This
introduces a canvas view alongside the list — boxes + arrows, drag-
zoom-pan, labeled edges per branch slot. Canvas is the new default;
the list stays as a fallback toggle (per-browser preference,
persisted to localStorage).

PR 1 scope is intentionally read-only: render, don't edit. The
foundation is testable in isolation before adding drag-to-persist,
side-panel editing, and drag-to-connect (PR 2) and polish like
keyboard shortcuts, mobile fallback, and validator integration
(PR 3).

Pieces:
  - New @xyflow/react (~30 KB gz) + @dagrejs/dagre (auto-layout for
    pre-existing flows whose position_x / position_y are all zero —
    flow_nodes already reserved those columns in migration 010
    precisely for this).
  - Shared module src/components/flows/shared.tsx lifts NodeType,
    BuilderNode, NODE_META, summarizeNode, truncate so both views
    render nodes identically.
  - Pure helpers (no React, fully unit-tested):
      src/lib/flows/edges.ts   — derive labeled canvas edges per
                                 outgoing slot (button, list row,
                                 condition branch)
      src/lib/flows/layout.ts  — dagre wrapper + shouldAutoLayout
                                 guard so manually-positioned flows
                                 never get rearranged on open
  - FlowCanvas — client component, custom node renderer matching the
    list view's collapsed-card styling, MiniMap + Controls, attribution
    hidden.
  - FlowEditorShell — owns the Canvas / List toggle, conditionally
    mounts the right view.

21 new unit tests (layout + edges); existing 271 still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 19:41:37 +02:00
Arnas Donauskas
62073d9915
feat(flows): add send_media node for image / video / document sends
Lets flows attach an image, video, or document (e.g. PDF invoice /
receipt) to a customer message — the most-requested gap in v1 was that
nodes were text-only. Single node with a `media_type` discriminator
rather than three node types: Meta's wire payload differs only in the
top-level key, so three types would triple the surface area (forms,
engine cases, menu entries) for no behavioural difference.

Files are uploaded directly from the builder to a new public
`flow-media` Supabase Storage bucket (per-user RLS, 16 MB cap, MIME
allowlist) and the resulting public URL is what Meta fetches at send
time. Same trade-off as the existing avatars bucket.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 19:11:29 +02:00
Arnas Donauskas
7bd112f61c
fix(whatsapp): register phone numbers on Meta + verify subscription state
Closes the silent-failure bug a user reported: when multiple phone
numbers are connected to the same WABA, only the first one actually
receives webhook events from Meta. The wacrm UI shows "Connected"
because the metadata probe succeeds, but Meta's activity log is
empty for every subsequent number.

Root cause
----------
The POST /api/whatsapp/config handler verified credentials with a
GET /{phone_number_id} probe and saved them to Supabase — but
never called Meta's two registration endpoints that actually wire
a number for inbound events:

  1. POST /{phone_number_id}/register
       Subscribes the number to THIS app's webhook. Requires a
       6-digit 2FA PIN the user previously set in Meta WhatsApp
       Manager → Two-step verification. Without it, Meta routes
       events for that number to whichever app last claimed it.

  2. POST /{waba_id}/subscribed_apps
       Subscribes the WABA itself to this app. Required once per
       WABA, but idempotent on Meta's side so we call on every
       save without harm.

The first number "worked" because someone (the user, Embedded
Signup, or Meta dashboard) had already done both calls for it
out-of-band. Every subsequent number silently failed.

What this PR does
-----------------
* New helpers in meta-api.ts: registerPhoneNumber (treats Meta's
  "already registered" 400 as success), subscribeWabaToApp
  (idempotent), getSubscribedApps (for the diagnostic).

* POST /api/whatsapp/config now:
    - Accepts an optional `pin` (6-digit, validated).
    - Calls subscribeWabaToApp on every save (idempotent).
    - Calls registerPhoneNumber on first save and whenever a PIN
      is supplied. Skipped when the same number is already
      registered locally and no fresh PIN is provided — re-running
      /register with a stale PIN would actually undo the active
      subscription.
    - Persists registered_at / subscribed_apps_at timestamps and
      last_registration_error for diagnostic surfacing.
    - Returns {success, registered, registration_error} so the UI
      can distinguish "creds valid" from "actually live".
    - Saves the row even when /register fails so the user can
      retry without re-entering everything.

* New diagnostic GET /api/whatsapp/config/verify-registration —
  hits Meta to confirm the saved number is reachable + the WABA
  is subscribed. Returns a per-check breakdown the UI renders.
  Solves the misleading-green-banner UX failure that caused the
  bug to go unnoticed in the first place.

* Migration 015 adds registered_at, subscribed_apps_at,
  last_registration_error to whatsapp_config. All nullable so
  existing rows survive — the UI labels them "registration status
  unknown" until the user clicks Verify or re-saves.

* whatsapp-config.tsx:
    - New PIN input with help text pointing to the exact Meta
      Manager setting and explaining why it's needed.
    - Two-tier status: existing "Credentials valid" alert (relabeled
      from "Connected" since it was misleadingly broad) PLUS a new
      Registration Status alert that flips emerald/amber based on
      the registered_at timestamp.
    - "Verify with Meta" button on the registration alert pings
      the diagnostic endpoint and renders the per-check result
      inline. Surfaces last_registration_error verbatim with a
      pointer to retry.
    - PIN clears from state after a successful save so it can't
      accidentally retrigger /register on the next save.

Tests + checks
--------------
* 9 new fetch-mock tests cover registerPhoneNumber (happy path,
  already-registered passthrough, PIN-required error verbatim,
  generic Meta error), subscribeWabaToApp (URL + bearer + error),
  getSubscribedApps (data, empty, error).
* 265/265 tests pass. Lint 0 errors. Typecheck clean. Build
  registers both /api/whatsapp/config and
  /api/whatsapp/config/verify-registration.

Out of scope
------------
* Multi-number-per-CRM-user (current schema is UNIQUE(user_id)
  so one CRM user = one number). The reported bug was
  multi-number-per-WABA across multiple CRM users, which this
  fully resolves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 18:12:38 +02:00
Arnas Donauskas
c63a68c01e
chore(templates): polish pass from senior backend / FE / UX review
A focused cleanup PR that addresses the high-signal findings from
a self-review of PRs #148-153. Every change is local to the
templates surface; no schema or wire-format changes.

Safety
------
* Delete now goes through a confirmation Dialog (mirrors the
  existing tag-manager pattern). Copy explicitly tells the user
  when Meta will be hit, so a slip on the trash icon doesn't take
  the template off Meta as well as locally.
* AUTHENTICATION submit is now blocked by a persistent in-dialog
  banner + disabled submit button. The disappearing toast that
  used to fire on submit is gone — defensive `return` remains in
  handleSubmit as a second line of defense.
* updateButton uses a per-variant switch + ButtonPatch union
  instead of the partial-spread + `as TemplateButton` cast. Each
  variant's spread keeps its discriminant pinned; TS enforces
  exhaustiveness.
* New template-row-guard.ts surfaces malformed Supabase rows at
  the route boundary instead of letting buildSendComponents crash
  deep in the stack with an opaque TypeError.
* UUID regex check on /[id] PATCH+DELETE so non-UUID payloads
  return a clean 400 before hitting Supabase.

Submit-route cleanup
--------------------
* Two near-identical upsert blocks (error path + success path)
  collapsed into a buildUpsertRow + upsertTemplateRow helper.
  Adding a column now touches one place, not two.

UX copy + feedback
------------------
* Submit success toast sets the right expectation: "typical
  review time is 24 hours. Status updates automatically." Old
  copy just said "status will update once approved."
* Header media URL helper text is now actually useful: HTTPS
  requirement, ~24h lifetime, per-format size/format recommendations
  (JPEG/PNG ≥800×418 ≤5 MB for images, MP4 ≤16 MB for video, etc).
* Truncated-sync toast switches from `warning` (which auto-dismisses
  fast) to `error` with `duration: 10000` and concrete copy: "Synced
  the first 2000 templates only — your account has more. Sync again
  to continue."
* Edit and Resubmit buttons gain visible text labels alongside
  their icons + better aria-label/title pairs. A lone rotate icon
  wasn't self-explanatory.
* Broadcast picker drops the redundant "Approved" chip — every
  template in that list is APPROVED by filter, so the chip carried
  zero information. Unused templateStatusConfig import removed.

State + a11y
------------
* handleSubmit now awaits fetchTemplates BEFORE closing the dialog
  so re-opening immediately doesn't see a stale list.
* Header content / media URL / sample are preserved across format
  switches in the New Template form. The payload builder is already
  format-aware, so orphan values on hidden fields are harmless —
  and keeping them lets users switch formats to compare without
  losing typing.
* Button-type Select gains the same null-guard the header Select
  got in PR #148 (per @base-ui semantics, onValueChange can fire
  with null on deselect).
* Dynamic body-sample inputs get id + aria-label so each one is
  individually addressable (WCAG A — screen-reader announcement
  beat "edit text"). Header text + header sample inputs too.

Deliberately NOT here
---------------------
* The 940-line template-manager component is not split. It's
  cohesive (one feature surface) and splitting for size alone
  would be over-engineering.
* extractVariableIndices in the buttons loop is not memoized.
  Regex on a short URL string is sub-microsecond; a real profiler
  would never flag it.
* No JS heap secret-clearing for access tokens. Strings are
  immutable in JS — there's no way to actually clear one.

Tests: 256/256 pass (no new tests required — pure refactor +
copy + new UI elements). Lint 0 errors. Typecheck clean. Build
succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 17:50:11 +02:00
Arnas Donauskas
11475840e1
feat(templates): media headers + URL/copy-code button params on send
Closes the last hole left by PR 3 — until now, sendTemplateMessage
only emitted a body component, so templates with image/video/
document headers or URL-with-{{1}} buttons failed at send time
with Meta's "missing component" 400. After this PR, every
APPROVED template that wacrm can submit is also fully sendable.

New helper
----------

src/lib/whatsapp/template-send-builder.ts builds the per-send
Meta components array from a MessageTemplate row + caller-supplied
SendTimeParams. It's a different beast from template-components.ts
(which builds the SUBMISSION shape) because the send-time payload
has a different schema: header type-specific parameter objects,
button components with sub_type + index, body_text without a 2D
example wrapper, etc.

Auto-fills as much as possible from the template:

  * static IMAGE/VIDEO/DOCUMENT headers ride along using the row's
    header_media_url (or header_handle when present — preferred)
  * URL buttons without {{1}} need no per-send payload
  * QUICK_REPLY / PHONE_NUMBER buttons need nothing
  * COPY_CODE buttons fall back to the template's example so the
    most common "static promo code" case Just Works without UI

Variable-bearing fields require per-send values:

  * TEXT header with {{1}}        → messageParams.headerText
  * Body variables                 → messageParams.body[]
  * URL button with {{1}}          → messageParams.buttonParams[i]
  * COPY_CODE override             → messageParams.buttonParams[i]

19 unit tests assert the wire shape per scenario — header types,
URL button at correct index when QR buttons precede it, example
fallback, and the throw-with-specific-message paths.

sendTemplateMessage extension
-----------------------------

src/lib/whatsapp/meta-api.ts gains optional `template` +
`messageParams` args. When `template` is provided, the full
components array is built via the new helper. Legacy `params:
string[]` (body only) path is preserved as-is — old callers
behave identically. When both shapes are present, structured
messageParams.body wins.

Route + UI wiring
-----------------

POST /api/whatsapp/send and /api/whatsapp/broadcast now load the
template row by (user_id, name, language) and pass it through.
Broadcast loads once per request (not per recipient — avoids N+1).
Both accept `template_message_params` / per-recipient
`messageParams` from the client.

src/components/inbox/template-picker.tsx scans the selected
template for variable-bearing fields and renders an input per
need: header text variable, body variables (existing), URL
button suffixes. Per-button preview shows the final URL with
the substitution so the user sees what the recipient will see.
The callback shape changes to `(template, { body, headerText?,
buttonParams? })`; message-thread.tsx updated accordingly.

Out of scope
------------

Broadcast composer doesn't yet collect per-recipient header /
button params — the legacy body-only flow continues to work for
broadcasts. Templates with header / URL-button variables broadcast
fine if every recipient gets the same value via API call shape;
the UI for varying it per recipient lands in a follow-up.

This is the last PR of the Issue #147 overhaul sequence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 16:38:15 +02:00
Arnas Donauskas
c7d780631b
feat(templates): webhook handler for real-time status / quality updates
Wires Meta's three template-lifecycle webhook events into the
existing /api/whatsapp/webhook route so APPROVED / REJECTED /
PAUSED transitions land locally without a manual Sync click.

Handled events
--------------

  message_template_status_update
    → flip status to the raw Meta enum (re-using normalizeStatus
      from PR 3 so PENDING_REVIEW collapses to PENDING etc.)
    → persist `reason` to rejection_reason on REJECTED so the
      red banner in template-manager shows what to fix
    → clear rejection_reason + submission_error on any other
      transition so a stale REJECTED message doesn't linger
      after re-approval

  message_template_quality_update
    → set quality_score (GREEN/YELLOW/RED). Unknown values land
      as NULL so the chip just disappears.

  message_template_components_update
    → info-log only for v1. Auto-persisting Meta's modified
      components would silently change what the user thought
      they submitted; instead we point them to "Sync from Meta"
      in the log line. A follow-up PR could surface a banner.

Lookup by meta_template_id (indexed in migration 014) — globally
unique per WABA, so no user_id filter needed. Unknown rows log a
warning instead of failing; >1 matches log a warning too in case
of cross-tenant drift.

New module
----------

src/lib/whatsapp/template-webhook.ts isolates the dispatch + each
handler. The route handler just calls
`handleTemplateWebhookChange(change, supabaseAdmin())` when
`isTemplateWebhookField(change.field)` matches, and the existing
messaging branches are short-circuited so a template event doesn't
try to read message-shaped fields off the value.

12 new tests cover: status APPROVED happy path, REJECTED with
reason, REJECTED missing reason → generic fallback, PENDING_REVIEW
normalization, missing meta_template_id no-op, unknown row warning,
quality score update, invalid quality value → NULL, components
update no-op, unknown field defensive no-op, and the field detector.

Setup requirement
-----------------

Subscribing to these fields is a one-time manual step in Meta App
Dashboard → WhatsApp → Configuration → Webhooks (no API for Cloud
API apps). Documented in a top-of-file comment block in
template-webhook.ts. Until it's done, status updates still arrive
via the manual "Sync from Meta" button — that fallback stays.

Out of scope (final PR in sequence):
  * sendTemplateMessage media + button param support (PR 6)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 16:20:50 +02:00
Arnas Donauskas
a2100a3076
feat(templates): edit / resubmit / delete lifecycle endpoints
Adds the missing lifecycle operations so users can manage existing
templates without leaving wacrm.

API surface
-----------

PATCH /api/whatsapp/templates/[id]
  Edits (and re-submits) an existing Meta-side template. Used by
  the "Edit" action on APPROVED rows and "Resubmit" on REJECTED /
  PAUSED rows — same operation, different UI labels. Meta replaces
  components wholesale and flips status back to PENDING for
  re-review. Status guard rejects edits on PENDING / DISABLED /
  IN_APPEAL / PENDING_DELETION and on local-only (no
  meta_template_id) rows. Honors WHATSAPP_TEMPLATES_DRY_RUN.

DELETE /api/whatsapp/templates/[id]
  Calls Meta `DELETE /{waba_id}/message_templates?name=X&hsm_id=Y`
  when meta_template_id is set — hsm_id is critical, without it
  Meta deletes EVERY language variant of the same name. Treats
  Meta 404 as a no-op (template already gone). Always removes the
  local row.

New Meta helpers
----------------

src/lib/whatsapp/meta-api.ts gains editMessageTemplate and
deleteMessageTemplate following the established named-args +
throwMetaError pattern. Eleven new fetch-mock tests assert the
exact wire shape — especially that hsm_id always rides along when
provided, which is the difference between "delete one variant"
and "delete every variant."

UI changes
----------

src/components/settings/template-manager.tsx:
  * Edit (pencil) button visible for APPROVED rows
  * Resubmit (rotate) button visible for REJECTED / PAUSED rows
    — same flow as Edit, different label
  * Delete now calls the route handler (so Meta gets the call too)
    with a spinner during the in-flight request
  * Dialog pre-fills form from the row when opened in edit mode
  * Dialog title / CTA / description switch between "New Message
    Template" + "Submit for Approval" and "Edit Message Template"
    + "Save & Resubmit"
  * Name + language inputs are disabled in edit mode (Meta keys
    templates by id; changing either is effectively a new template)
  * Closing the dialog clears editingId so the next open starts
    fresh

Design decision
---------------

One PATCH endpoint instead of the two endpoints the plan called
for (PATCH for edit, POST for resubmit). The Meta call is the same
operation either way — the difference is UI semantics, handled in
the component. Cuts duplication; easy to split later if needed.

Out of scope (final PR remaining in the sequence):
  * Webhook handler for real-time status (PR 5)
  * sendTemplateMessage media + button param support (PR 6)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 15:57:24 +02:00
Arnas Donauskas
3113738c7e
feat(templates): submit-to-Meta endpoint + component-based form builder
Closes the second half of #147. Users can now create a template in
wacrm and submit it to Meta for approval without leaving the app —
no more dummy local rows that fail with Meta error #132001 on send.

API surface
-----------

POST /api/whatsapp/templates/submit
  Auth → fetch whatsapp_config → validate payload → build Meta
  components → POST to /{waba_id}/message_templates → persist row
  with meta_template_id + status + sample_values +
  last_submitted_at. Per-field validators throw with specific
  messages before the network call, so misconfigured templates
  fail with "button #3 missing url" instead of a 400 from Meta.
  429s are detected and surfaced as "rate limit hit (100/hr)".

WHATSAPP_TEMPLATES_DRY_RUN=true short-circuits the Meta call and
issues a synthetic `dry-run-<uuid>` meta_template_id so CI and
local dev can exercise the full form without a real WABA.

New modules
-----------

src/lib/whatsapp/template-validators.ts
  Pure functions encoding Meta's caps: 1024-char body, 60-char
  footer/header, 25-char button text, 10 buttons max, 2 URL / 1
  PHONE_NUMBER / 1 COPY_CODE, QUICK_REPLY contiguity rule, URL
  example required when {{1}} is present, COPY_CODE example
  required, header media URL must be http(s), variables must be
  contiguous starting at {{1}}. 41 unit tests cover each rule.

src/lib/whatsapp/template-components.ts
  Translates our local row shape into Meta's components array
  (HEADER → BODY → FOOTER → BUTTONS, body_text examples as a 2D
  array per spec, header_handle preferred over header_url when
  both are present). Snapshot tests assert the wire shape.

src/lib/whatsapp/template-status-normalize.ts
  Single source of truth for upstream→local status mapping.
  Replaces the duplicated normalizer in sync/route.ts; the
  upcoming webhook handler will share it too.

src/lib/whatsapp/meta-api.ts
  New submitMessageTemplate() helper following the existing
  named-args + throwMetaError pattern.

Form rewrite
------------

src/components/settings/template-manager.tsx replaces the flat
form with a component-based builder:

  * Header: format select (None / Text / Image / Video / Document)
    + conditional inputs — text + variable-aware sample row for
    TEXT, URL paste for media (file upload slot ready for the
    Resumable Upload follow-up)
  * Body: textarea with live {{N}} extraction → sample-value rows
    auto-appear and shrink as variables are added/removed
  * Footer: 60-char capped input
  * Buttons editor: add up to 10, per-row type picker
    (Quick Reply / URL / Phone / Copy Code), conditional URL +
    example + phone + code fields, X to remove
  * Card display now surfaces rejection_reason / submission_error
    (red banner) and quality_score (GREEN/YELLOW/RED chip)
  * AUTHENTICATION shows a "create in Meta Manager + Sync" banner
    and disables Submit — that category has a different shape
    (fixed body, mandatory OTP button) and lands in a follow-up

Side effects
------------

  * Broadcast Step 1 picker (step1-choose-template.tsx) now
    filters to status='APPROVED' to match the inbox picker —
    selecting a non-approved template would 400 on send.
  * .env.local.example documents META_APP_ID (Resumable Upload
    follow-up) and WHATSAPP_TEMPLATES_DRY_RUN.

Out of scope (subsequent PRs):
  * Edit / resubmit / delete endpoints (PR 4)
  * Webhook handler for real-time status (PR 5)
  * sendTemplateMessage media + button param support (PR 6)
  * Resumable Upload + AUTHENTICATION builder (follow-up)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 15:34:58 +02:00
Arnas Donauskas
aa4f34c94c
feat(templates): store raw Meta status enum + lay schema groundwork for submission
Switches message_templates.status from the local TitleCase set
(Draft|Pending|Approved|Rejected) to Meta's raw enum (DRAFT|PENDING|
APPROVED|REJECTED|PAUSED|DISABLED|IN_APPEAL|PENDING_DELETION) and
adds the columns the upcoming submit / edit / resubmit / webhook
flows need. No new user-facing behavior — this is groundwork.

Migration 014 also adds:

  * sample_values JSONB        Meta requires these for variable templates
  * meta_template_id TEXT      hsm_id for scoped edit/delete (avoids
                               nuking sibling-language variants)
  * rejection_reason TEXT      surfaced from webhook on REJECTED
  * quality_score TEXT         GREEN | YELLOW | RED, from webhook
  * header_handle TEXT         from Resumable Upload (media headers)
  * header_media_url TEXT      URL fallback for media headers (v1 path)
  * submission_error TEXT      last 4xx from Meta, for retry surface
  * last_submitted_at          rate-limit awareness (100 creates/hour)
  * buttons JSONB shape CHECK  permissive array-of-typed-objects guard
                               (strict per-type validation lives in API)
  * UNIQUE (user_id, name, language)
                               so sync upsert is atomic and prevents
                               duplicate language variants
  * idx on meta_template_id    fast webhook lookup

Sync route now:
  * persists Meta's status verbatim (PAUSED no longer collapses to
    REJECTED — these are different states with different recoverability)
  * parses BUTTONS components into the new discriminated-union shape
  * extracts sample_values from body.example.body_text + header.example
  * persists meta_template_id, quality_score, header_handle

Shared template-status.ts gives the manager / inbox picker / broadcast
picker one source of truth for labels + dark-theme badge classes.

Part of the Issue #147 overhaul plan (PR 2 of 6 in the sequence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 12:12:01 +02:00
Arnas Donauskas
266e487ac8
fix(templates): map "None" header sentinel to null before save
The header type Select uses value="none" as a sentinel (Radix disallows
value=""), but the Save handler passed it through verbatim to Supabase.
The DB CHECK constraint on message_templates.header_type only allows
text|image|video|document, so picking "None" raised a constraint
violation and the template never saved.

Normalize "none" (and any falsy value) to '' at the input boundary so
form.header_type is always either '' (→ null on save) or a valid type.

Closes #147 (the header-dropdown half — Meta submission is tracked separately).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 11:45:59 +02:00
Arnas Donauskas
71ae95f441
fix(whatsapp): prevent silent inbound-message drop when two users share a phone_number_id
The webhook routes inbound messages by phone_number_id and uses
`.single()` to find the owning config. If two users save the same
phone_number_id, `.single()` errors PGRST116 ("multiple rows") and every
inbound message is silently dropped while the log misleadingly reports
"No config found".

Three layers of fix:

- POST /api/whatsapp/config now rejects with 409 when another user has
  already claimed the phone_number_id. The check uses a service-role
  client because RLS hides the conflicting row from the saving user.
- Webhook lookup no longer uses `.single()`. It distinguishes 0 rows
  (true "no config") from ≥2 rows (duplicate claim) so logs name the
  real cause and list conflicting user_ids.
- Migration 013 adds UNIQUE(phone_number_id) to whatsapp_config so the
  same can't happen via direct DB writes or a race. Fails loudly with a
  copy-pasteable resolution hint if duplicates already exist, rather
  than auto-deduping (which would destroy tokens).

Closes #136.
2026-05-26 10:35:59 +02:00
Arnas Donauskas
4e89b2b902
feat(flows): soft-GA — open to all users + Beta label
The Flows feature was previously gated on a per-account
`profiles.beta_features` opt-in: the sidebar entry was invisible
to non-beta accounts, every /api/flows route 404'd, the webhook
dispatcher was a no-op, and the agent-handoff pause was skipped.

This PR opens the surface to every authenticated user. A small
"Beta" chip in the sidebar nav + on the /flows page H1 keeps the
"young feature" signal without holding anyone out.

Removed:
- `src/lib/flows/feature-flag.ts` + its tests (-11 tests).
- The `requireFlowsBeta` / `requireFlowsBetaAndOwnership` helpers
  in the API routes — collapsed to plain `requireUser` / RLS
  ownership.
- The `isProfileFlowsEnabled` helper + branch in the webhook
  dispatcher — the runner takes the cheap "no_match" exit on
  accounts with no active flows (single indexed SELECT).
- The agent-handoff pause gate in /api/whatsapp/send — the UPDATE
  matches zero rows when no active run exists, also cheap.
- Client-side beta gates in the 3 flows pages (and the redundant
  `useAuth` + `profileLoading` plumbing they needed).
- The defensive message-bubble gate that would have rendered
  interactive replies as plain text for non-beta accounts.
- The `betaKey` field on the sidebar NavItem schema, replaced by a
  simpler boolean `beta` that purely drives the visual chip.

Kept:
- `profiles.beta_features` column. Generic mechanism for future
  beta gates; no current feature reads it. Comments in
  `src/types/index.ts` + `use-auth.tsx` updated to say so.
- The chip can be removed in a one-liner once the team's satisfied
  Flows has bedded in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 17:16:58 +03:00
Arnas Donauskas
32022c13fe
feat(theme): sweep remaining violet-* → primary across all surfaces
PR #132 (color themes) only tokenized the high-visibility chrome.
Inbox / contacts / pipelines / broadcasts / automations / auth /
settings sub-forms still had ~150 hard-coded `violet-*` classes
across ~32 files, so picking a non-violet theme felt half-broken —
sidebar and header changed colour, but message bubbles, deal cards,
and the broadcast wizard all stayed violet.

This is the mechanical follow-up: replace every `violet-N(/N)?`
with `primary(/N)?` so the theme picker themes the whole app.

Two non-trivial swaps inside the pass:
- `text-white` on a `bg-primary` button → `text-primary-foreground`.
  Emerald and Amber define a *dark* `--primary-foreground` because
  their primaries are bright; `text-white` over those would tank
  contrast.
- `bg-primary hover:bg-primary` (hover identical to base — original
  code had this latent bug from `hover:bg-violet-500`) → adds `/90`
  on hover so the lift is visible.

Test update:
- `broadcast-status.test.ts` asserted shade numbers (`bg-X-N/10`).
  Relaxed the regex to also accept token form (`bg-X/10`) since
  brand-accent status badges now ride the active theme.

Files touched: 49. Lint clean (existing warnings only), 173/173
tests, typecheck + build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 17:00:32 +03:00
Arnas Donauskas
54a969c56d
feat(settings): color theme picker (5 themes)
Adds a new "Appearance" tab in Settings with 5 dark color themes
(Violet, Emerald, Cobalt, Amber, Rose). Pick a theme → applied
immediately and persisted to localStorage. Boot script in the root
layout replays the pick before first paint so there's no
flash-of-default on subsequent loads.

Implementation, ordered the way it's read:

1. `globals.css` — 5 `html[data-theme="..."]` blocks extracted
   verbatim from the design handoff at project/Color Themes.html.
   `:root` mirrors Violet so SSR / first-paint renders correctly
   even before the boot script runs. Three new tokens
   (`--primary-hover`, `--primary-soft`, `--primary-soft-2`) added
   to all themes — used by tinted-primary surfaces.

2. `src/lib/themes.ts` — single source of truth for the catalog
   (id, name, tagline, swatch). Adding a theme = append a CSS
   block + append an entry here.

3. `src/hooks/use-theme.tsx` — ThemeProvider client component. Reads
   what the boot script already applied, syncs across tabs via the
   `storage` event, persists to localStorage. Falls back to a no-op
   setter when used outside the provider.

4. `src/app/layout.tsx` — inline `beforeInteractive` script that
   reads localStorage and sets `documentElement.dataset.theme`
   before React hydrates. Knowledge of valid theme IDs comes from
   the THEME_IDS constant so adding a theme doesn't silently break
   the boot path. Wraps the tree in ThemeProvider.

5. `src/components/settings/appearance-panel.tsx` — picker UI. 5
   cards, swatch + name + tagline + a 4-tone preview strip. Active
   card carries a primary-tinted ring + "Active" chip.

6. `src/app/(dashboard)/settings/page.tsx` — new "appearance" tab
   alongside profile/whatsapp/templates/tags.

7. Key-surface tokenization — replaced hard-coded `violet-*` with
   `primary` tokens on the high-visibility surfaces so the theme
   switch feels real: sidebar logo + active nav + unread dot +
   avatar fallback, header avatar, dashboard-shell loading
   spinner, Flows header icon + entry-node picker icon + entry
   badge + flash ring, Flows list page (template card hover,
   FlowCard icon), Flows editor + runs "back" links, Settings tabs
   active state.

NOT touched (intentional follow-ups):
- Per-node-type colors in flow-builder.tsx NODE_META — those are a
  deliberate per-type rainbow, not a brand accent.
- The inbox/contacts/pipelines surfaces — many `violet-*` usages
  there. Sweep is a contained follow-up; the user explicitly
  scoped this PR to "tokens + tab + key surfaces".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:48:40 +03:00
Arnas Donauskas
df6c8e34ac
fix(flows): create-flow dialog was still clamped to 384px — override sm: variant
PR #129 added `max-w-3xl` to the create-flow DialogContent thinking
it would widen the modal. It was a no-op. shadcn's DialogContent has
`sm:max-w-sm` baked into its default classes, and Tailwind-merge
treats `max-w-*` (base) and `sm:max-w-*` (sm-prefixed) as different
variants — both survive, and at every real desktop breakpoint the
sm-scoped 384px wins.

So the modal stayed at 384px regardless. Template cards continued to
look squished because the dialog never actually grew.

Override at the sm: variant directly. Use 4xl (896px) — gives each of
the three template cards roughly 270px of breathing room, enough for
the description to wrap to 4-5 lines instead of 10+.

Left a comment at the call site so the next person doesn't repeat
the same mistake.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:25:22 +03:00
Arnas Donauskas
6522b9b485
Merge pull request #130 from ArnasDon/fix/flows-sticky-validation
fix(flows): pin validation panel to viewport bottom while editing
2026-05-22 16:21:26 +03:00
Arnas Donauskas
47836d2c08
fix(flows): pin validation panel to viewport bottom while editing
"No issues. Ready to activate." (and the equivalent error count)
sat at the very bottom of the editor. With even a handful of nodes
the panel was below the fold — the user had to scroll past every
node card to see whether they could activate.

Wrap the panel in a sticky-bottom container so the activate-readiness
status follows the user as they scroll. Shadow-xl gives it the lift
to read as "pinned" against the node list behind it.

Side fix while I was in there: the success-state panel used
bg-emerald-500/10 (10% emerald on whatever's behind). Fine when
nothing's behind, but sticky-positioned over a slate-900 node card
the green tint bled through ugly. Switched both states to a
bg-slate-950 base with the severity expressed in the border + text
color only — readable on any backdrop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:19:19 +03:00
Arnas Donauskas
a6cc847636
fix(flows): widen create-flow dialog so template cards are readable
The dialog was max-w-2xl with sm:grid-cols-3, leaving each template
card ~150px wide. With p-3 padding and line-clamp-3 the descriptions
truncated to four-word fragments — the gallery looked squished even
on desktop.

- Dialog: max-w-2xl → max-w-3xl
- Grid: stack on mobile, 2-col on sm, 3-col on lg (matches the
  flow-card list grid elsewhere in the page).
- Card: p-3 → p-4, gap-2 → gap-2.5, h-4 icon → h-5.
- Drop the line-clamp on the description — at the new width it fits.
- Move the "X nodes" footer above a top-border so it reads as
  metadata, not part of the description.
- Add a divider above the "Or start blank" section so the two
  paths feel like distinct choices.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:15:39 +03:00
Arnas Donauskas
6fcf77de8c
fix(flows): bounce-to-dashboard race when navigating into /flows
use-auth's `loading` flips false as soon as the session settles —
the profile fetch is fire-and-forget so the page renders quickly
with just the user object. The flows pages, though, gate access on
`isFlowsEnabled(profile.beta_features)`. During the gap between
session-ready and profile-ready they saw `{ loading: false,
profile: null }` and read it as "not opted in", redirecting a
legitimate beta user back to /dashboard.

Expose a separate `profileLoading` boolean from the auth context
that stays true until fetchProfile settles (success, missing row,
or error). Pages that branch on `profile.*` gate on it; chrome
that only needs the user object keeps using `loading` as before.

Three call sites updated: /flows, /flows/[id], /flows/[id]/runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:05:59 +03:00
Arnas Donauskas
1a6419b815
Merge pull request #127 from ArnasDon/feat/flows-cron-timing-safe
fix(flows): constant-time compare for cron secret
2026-05-22 15:58:38 +03:00
Arnas Donauskas
dcaba6e612
Merge remote-tracking branch 'origin/main' into feat/flows-drop-reread-and-limit
# Conflicts:
#	src/lib/flows/engine.ts
2026-05-22 15:56:39 +03:00
Arnas Donauskas
fe4e0117da
fix(flows): constant-time compare for cron secret
The `/api/flows/cron` route gated on `supplied !== expected`, which
compares strings byte-by-byte with early exit on first mismatch.
An attacker who can hit the endpoint could in principle recover the
secret one byte at a time by measuring response-time deltas. With
a high-entropy 64-char secret this is mostly theoretical, but the
upgrade is a one-liner.

Switch to crypto.timingSafeEqual with a length pre-check (the
function throws on mismatched lengths; the length itself isn't
sensitive when the expected value is set by the operator).

Not touching the equivalent line in /api/automations/cron in this
PR — one fix per branch keeps the diff easy to review and revert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:51:34 +03:00
Arnas Donauskas
56ec96d50d
perf(flows): drop wasted re-read after reprompt reset + defensive limit(1)
Two small things noticed in review:

1. handleReplyForActiveRun did UPDATE reprompt_count=0 → SELECT *
   the whole row → use it. The re-read existed because the
   collect_input capture branch wrote `vars` to the DB without
   mirroring the change to the local `run` object — so the next
   advance step would interpolate stale vars otherwise.

   Mirror the UPDATE in-memory instead. Also skip the reset UPDATE
   when reprompt_count is already 0 (the common case after a
   successful collect_input — that branch already zeroed it).

2. loadActiveRunForContact used .maybeSingle() which throws on >1
   row. The partial unique index makes that impossible by design,
   but a migration glitch or manual SQL could produce one and would
   crash dispatch for that contact's webhook forever. Switch to
   .order().limit(1) so the runner picks the newest and lets the
   cron sweep clean up duplicates if any ever appear.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:48:09 +03:00
Arnas Donauskas
8ffb043303
Merge pull request #125 from ArnasDon/feat/flows-preload-nodes
perf(flows): preload all flow_nodes once per dispatch
2026-05-22 15:46:38 +03:00
Arnas Donauskas
dc58f71925
perf(flows): preload all flow_nodes once per dispatch
The advance loop called loadNode() on every iteration — one DB
round trip per node visited. A flow with 5 chained auto-advance
nodes (send_message → condition → set_tag → send_message → end)
cost 5 sequential round trips synchronous in the webhook hot path.
At ~50ms each that's 250ms before the customer sees anything.

Switch to a single upfront SELECT into a Map<node_key, FlowNodeRow>
keyed on the flow_id, passed through advanceFromNodeKey and
handleReplyForActiveRun. The advance loop is now in-memory and the
hot path costs one round trip regardless of chain length.

node_not_found semantics are preserved: missing keys still end the
run with that reason, same as the old per-node lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:44:40 +03:00
Arnas Donauskas
4b368139f3
Merge pull request #124 from ArnasDon/feat/flows-atomic-execution-count
fix(flows): atomic execution_count increment via SECURITY DEFINER RPC
2026-05-22 15:42:38 +03:00
Arnas Donauskas
e83538678a
fix(flows): atomic execution_count increment via SECURITY DEFINER RPC
startNewRun read flows.execution_count from a cached row and wrote
`cached + 1`. Two concurrent webhooks starting runs on the same flow
for different contacts both read N and both write N+1 — one count
permanently lost.

Mirrors the automations engine's solution (migration 007): a tiny
SECURITY DEFINER function that does the increment server-side, locked
to the service role. The engine's call becomes a single RPC.

The counter is best-effort: if the RPC fails we log + continue rather
than failing the run for a stat error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:39:00 +03:00
Arnas Donauskas
8ac764e3b8
fix(flows): redact customer text from reply_received event payload
The `reply_received` event was persisting `text: message.text` verbatim
into flow_run_events.payload. A customer prompted "what's your card
number?" by a collect_input node would leave the PAN sitting in the
events table forever, surfaced via the runs viewer to anyone with
access.

Switch to `text_length` only. The `node_entered` event already records
`captured_length` for the same reason after a successful capture; this
makes the asymmetry symmetric.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:35:42 +03:00
Arnas Donauskas
31bd967861
Merge remote-tracking branch 'origin/main' into feat/flows-unsaved-changes-guard
# Conflicts:
#	src/components/flows/flow-builder.tsx
2026-05-22 15:16:35 +03:00
Arnas Donauskas
323e0f06cb
Merge pull request #121 from ArnasDon/feat/flows-clickable-validation-issues
feat(flows): clickable validation issues jump to node
2026-05-22 15:10:30 +03:00
Arnas Donauskas
ad91d205fb
feat(flows): unsaved-changes indicator + reload guard
Editing 5 node configs and then refreshing the tab silently wiped them.
Activate also silently invoked Save first — useful, but invisible: users
had no way to know if pending edits had landed.

Adds:
- A `dirty` boolean flipped true on any user edit, false on a successful
  PUT. Internal `state.status` changes from the activate API path use
  raw setState and don't dirty the form.
- An "● Edited" amber chip next to the StatusBadge while dirty.
- A `beforeunload` listener that prompts the native confirm on
  reload / tab-close / external-link click when dirty.

SPA navigation (sidebar, back button) isn't covered — Next 16's App
Router doesn't fire beforeunload on client-side routes. Worth a
follow-up but the accidental-refresh case is the noisier one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:10:06 +03:00
Arnas Donauskas
b95ce8b372
Merge pull request #120 from ArnasDon/feat/flows-node-card-previews
feat(flows): show content preview in collapsed node cards
2026-05-22 15:06:38 +03:00
Arnas Donauskas
edbbb894f6
feat(flows): make validation issues clickable to jump to node
The validation panel sits at the bottom of the editor. When it surfaced
an error ("node X is missing next_node_key") the user had to scroll up,
find that node, expand it, find the broken field. Long feedback loop.

Each node-scoped IssueLine becomes a button: click it and the editor
expands the offending node, scrolls it into view, and briefly rings
its border so the eye lands on the right card. Trigger-scoped issues
stay non-interactive (the trigger panel is already at the top of the
page).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:05:44 +03:00
Arnas Donauskas
9c7f635a4d
Merge pull request #119 from ArnasDon/feat/flows-send-list-add-section
feat(flows): add multi-section support to send_list builder
2026-05-22 15:02:20 +03:00
Arnas Donauskas
6079bf2046
feat(flows): show content preview in collapsed node cards
A flow with 8 send_message nodes used to show 8 identical-looking pills
in the editor — the only way to tell them apart was to expand each
one. Adds a one-line summary below the meta label when the card is
collapsed:

- send_message → first 80 chars of text
- send_buttons → text + button titles
- send_list → text + option count
- collect_input → prompt + target var
- condition → "var.email == 'support@…'"
- set_tag → "Add tag 1f8c…"
- handoff → first 80 chars of note

Empty nodes (just added, no fields set) keep the old compact look so
you can tell at a glance what still needs configuration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:01:53 +03:00
Arnas Donauskas
5a9a69a3c5
feat(flows): add multi-section support to send_list builder
The data model and the runner already supported multiple sections in a
WhatsApp interactive list, but the builder UI exposed no way to add one
— it only let you add rows within the single default section. So users
couldn't build a categorized menu (Billing / Support / Sales) without
editing the DB directly.

Adds an "Add section" button below the sections list (capped at the
WhatsApp spec's 10) and a per-section remove button (shown when more
than one section exists).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:56:52 +03:00
Arnas Donauskas
268ae7076f
feat(flows): hide internal node_key + reply_id behind Advanced disclosure
Node key and per-button/row reply IDs are auto-generated and consumed
only by the runner — exposing them in the main form read as developer
leakage. Move them behind a per-node "Show advanced" toggle so the
default UI shows only customer-facing fields. Power users who need
stable IDs for analytics can still get them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:52:44 +03:00
Arnas Donauskas
ed15493f95
feat(flows): templates + v1.5 nodes (collect_input/condition/set_tag) + run-history viewer (PR 4/4)
Final feature PR before GA cleanup. Closes the gap between "flow
runner works" (PR 2) and "users have everything they need to build
real automation" — three new node types, plain-text sends actually
sending, three starter templates, a run-history page for debugging.

What landed:

  1. engineSendText helper (src/lib/flows/meta-send.ts) — wraps
     sendTextMessage with the phone-variant retry + DB persistence
     pattern. Unblocks send_message + collect_input nodes that
     previously auto-advanced without sending.

  2. Three new node types in the engine:
     - collect_input: sends a prompt, suspends, captures the next
       text reply into flow_runs.vars[var_key], advances. Supports
       {{vars.X}} interpolation in downstream prompts + handoff
       notes.
     - condition: branches on subject (var / tag / contact_field)
       with operators (equals / contains / present / absent).
       Auto-advancing, no Meta call. Pure predicate logic exported
       as evaluateConditionPredicate with 7 new unit tests.
     - set_tag: upsert/delete on contact_tags then advance. Non-
       fatal on write failure — logged + advances anyway so a tag
       glitch doesn't strand the customer.

  3. Real send_message — calls engineSendText, logs the result,
     handles errors by ending the run with status='failed'. Same
     {{vars.X}} interpolation.

  4. Three starter templates (src/lib/flows/templates.ts):
     - Welcome menu (the owner's brief example)
     - FAQ bot (keyword → list → answers → end)
     - Lead capture (first_inbound → collect_input chain → handoff
       with vars in the note)
     POST /api/flows now accepts {template_slug} to clone a
     template into a fresh draft. GET /api/flows/templates exposes
     the gallery shape (no nodes payload — fetched on actual clone).

  5. Builder UI extensions:
     - NODE_META + config sub-forms for collect_input, condition,
       set_tag.
     - Template gallery on the /flows New-flow dialog (loads from
       /api/flows/templates, falls back gracefully if absent).
     - "Runs" button in the editor header → /flows/[id]/runs.

  6. Run-history viewer at /flows/[id]/runs — newest-first list of
     the 50 most recent runs, with expandable per-run event
     timeline (started, node_entered, message_sent,
     reply_received, fallback_fired, handoff, timeout, error,
     completed). Useful for debugging "why didn't my flow advance?"
     from the brief's use case.

  7. Validator updates: new error rules for each new node type
     (prompt_text required, var_key alphanumeric, operator valid,
     true_next/false_next exist, etc.). outgoingEdges helper now
     covers collect_input/condition/set_tag for reachability.

All gated behind isFlowsEnabled(profile). Non-beta accounts see no
change. Run-history API is also 404 for non-beta.

Validation: 173/173 tests pass (added 7 new for the predicate);
typecheck + lint + build all clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:27:01 +03:00
Arnas Donauskas
4871d7aa06
feat(flows): no-code builder UI + flows API + validator (PR 3/4)
Ships the surface that lets opted-in beta accounts build, save, and
activate WhatsApp conversation flows entirely from the web UI — no
SQL fixtures needed. Sidebar gets a "Flows" entry (hidden for
non-beta accounts), the /flows list page surfaces existing flows,
/flows/[id] is the linear-list editor.

Wraps the runner from PR 2 with everything a real user needs to
build the welcome-menu example end-to-end.

What landed:

  1. `src/lib/flows/validate.ts` — pure validator. Checks entry
     node exists, all next_node_key refs resolve, no unreachable
     nodes, Meta-limit violations (button title ≤20, ≤3 buttons,
     ≤10 list rows, ≤24 chars per list row). 24 unit tests.

  2. `src/app/api/flows/route.ts` — GET (list user's flows),
     POST (create draft).

  3. `src/app/api/flows/[id]/route.ts` — GET (load with nodes),
     PUT (replace name + trigger + nodes; delete-then-insert),
     DELETE (cascade clean-up).

  4. `src/app/api/flows/[id]/activate/route.ts` — status toggle.
     Activation runs the validator; returns 422 + the full issue
     list when blocked.

  5. `src/app/(dashboard)/flows/page.tsx` — list of cards, "New
     flow" dialog, delete with confirm.

  6. `src/app/(dashboard)/flows/[id]/page.tsx` — editor shell
     that loads data and hands it to FlowBuilder.

  7. `src/components/flows/flow-builder.tsx` — the editor itself.
     Header with name + save/activate/pause/delete; trigger
     panel; entry-node picker; node list with expandable per-node
     config forms (start, send_buttons, send_list, send_message,
     handoff, end); bottom validation panel that mirrors what the
     server's activate endpoint would reject.

  8. `src/components/layout/sidebar.tsx` — Flows entry between
     Automations and Settings, rendered only when isFlowsEnabled.

Every API route + page is gated server-side by isFlowsEnabled —
non-beta accounts get 404 (not 403) so the routes don't even hint
at existing. UI gate is for snappy redirect; security is at the
API layer.

Validation: 166/166 tests pass (added 24 validator tests);
typecheck + lint + build all clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:57:38 +03:00
Arnas Donauskas
d0b50f4e61
feat(flows): runner engine + webhook integration + agent-handoff + cron sweep (PR 2/4)
Brings the Flows feature alive end-to-end for beta-opted-in accounts.
Still no UI — flows are defined via direct SQL inserts in v1; the
no-code builder lands in PR 3. Everything in this PR is gated behind
`isFlowsEnabled(profile)` so non-beta accounts behave identically to
before.

What landed:

  1. `src/lib/flows/types.ts` — DB row + discriminated-union node
     config types, plus the `ParsedInbound` shape and a
     `DEFAULT_FALLBACK_POLICY`.

  2. `src/lib/flows/fallback.ts` — pure policy resolver. Decides
     whether an unknown reply triggers a reprompt (up to N times),
     a handoff, or end. 11 unit tests.

  3. `src/lib/flows/engine.ts` — `dispatchInboundToFlows()` entry
     point. Loads the active run (if any), checks idempotency via
     `flow_run_events`, advances the run through a synchronous
     auto-advance loop that suspends only at nodes that need
     customer input. Each suspending node calls
     `engineSendInteractive{Buttons,List}` and persists the new
     current_node_key via an optimistic UPDATE with a precondition
     on the OLD key — concurrent inbounds collide cleanly.
     11 unit tests for the pure-logic helpers.

  4. `src/app/api/whatsapp/webhook/route.ts` — invokes the runner
     between the broadcast-reply flag and the automation dispatch.
     When the runner consumes the inbound (started a flow, advanced
     one, applied a fallback), suppresses `new_message_received`
     and `keyword_match` automation triggers — the customer is
     navigating the bot menu, not throwing a keyword. Relationship
     triggers (new_contact_created, first_inbound_message) still
     fire regardless.

  5. `src/app/api/whatsapp/send/route.ts` — after a successful
     agent message, marks any active flow run for that contact as
     `paused_by_agent`. Best-effort: a write failure is logged but
     doesn't block the send response.

  6. `src/app/api/flows/cron/route.ts` — sweeps active runs whose
     `last_advanced_at` is older than the flow's
     `fallback_policy.on_timeout_hours` (default 24h). Marks them
     `timed_out` and writes a `flow_run_events.timeout` row. Re-uses
     `AUTOMATION_CRON_SECRET` so operators don't have to provision
     a second secret.

Test surface in this PR:
  - 142/142 tests pass (added 22 new unit tests across fallback +
    engine pure logic)
  - typecheck clean
  - 0 new lint errors
  - build clean

Manual end-to-end verification arrives via the SQL fixture in the
PR description (a 3-node Welcome → buttons → handoff flow).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:36:39 +03:00
Arnas Donauskas
208b096cb1
chore(flows): defensive gate on interactive bubble render
Wraps the new `case 'interactive'` rendering with `isFlowsEnabled(profile)`
from the beta-features module that landed in #113. Non-beta accounts
fall back to the plain-text rendering for `content_type='interactive'`
rows — they get the option title as text, no special "↩ Button reply"
affordance.

Why this gate exists even though the case is unreachable today:
  - No code path on this branch produces `content_type='interactive'`
    rows. The flow runner that does is upcoming (PR #2), and it'll be
    behind the same gate.
  - But: if a stray interactive row ever shows up in a non-beta
    account (manual SQL insert, data import, future bug), we'd rather
    the account see boring plain text than UI for a feature they
    haven't opted into.
  - The cost is ~3 LoC and one hook call. Pure paranoia insurance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:21:22 +03:00
Arnas Donauskas
dc245d5914
feat(flows): foundation for interactive WhatsApp messages (PR 1/4)
Foundation for the upcoming WhatsApp chatbot flow builder. No runner,
no UI yet — just the plumbing that lets the codebase SEND interactive
messages (button replies + lists) and RECEIVE the taps as structured
message rows. Plan: ~/.claude/plans/understood-research-such-functionality-binary-lake.md

## What's in this PR

### Migration 010_flows.sql
- New tables: flows, flow_nodes, flow_runs, flow_run_events
- Linchpin partial unique index on flow_runs(user_id, contact_id) WHERE status='active' — at most one active run per contact, makes concurrent webhook deliveries idempotent at the DB layer
- Widens messages.content_type CHECK to allow 'interactive'
- New messages.interactive_reply_id column for the tapped button/row id
- RLS on every table; flow_runs added to supabase_realtime publication
- Idempotent throughout — matches the 009_message_actions.sql template

### Interactive Meta senders (src/lib/whatsapp/meta-api.ts)
- sendInteractiveButtons: type='interactive', interactive.type='button', up to 3 reply buttons
- sendInteractiveList: type='interactive', interactive.type='list', up to 10 rows across sections
- Limits exported as INTERACTIVE_LIMITS so flow validation can share the constants
- All Meta caps validated BEFORE the network call (button title ≤20 chars, list row title ≤24 chars, ≤10 total rows, no duplicate row ids, body ≤1024 chars, etc.) — misconfigured flows fail at save time instead of as a customer-facing 400 mid-conversation

### Webhook (src/app/api/whatsapp/webhook/route.ts)
- Extends WhatsAppMessage interface with `interactive?: { type, button_reply?, list_reply? }`
- parseMessageContent returns a new `interactiveReplyId` field; refactored to use a shared `empty` base so every return site picks up the new field without 9 explicit copies
- New `case 'interactive'`: uses the tapped option's TITLE as content_text (so the inbox bubble reads "Existing customer", not a UUID) and stashes the id separately
- 'interactive' added to ALLOWED_CONTENT_TYPES so the messages insert no longer coerces it to 'text'

### Inbox bubble (src/components/inbox/message-bubble.tsx)
- New `case "interactive"` in MessageContent renders the tap with a small "↩ Button reply" affordance so agents reading the inbox can tell at a glance that this was a tap rather than the customer typing the same words

### Types (src/types/index.ts)
- ContentType union extended with 'interactive'
- Message.interactive_reply_id field

### Engine helpers (src/lib/flows/)
- admin-client.ts: thin service-role client wrapper, mirrors automations/admin-client.ts
- meta-send.ts: engineSendInteractiveButtons / engineSendInteractiveList — adapts the Meta senders for service-role use, persists messages with sender_type='bot' content_type='interactive', uses the same phone-variant retry as automations/meta-send.ts. Unused in PR #1; ready for the runner in PR #2

### Tests
- 13 new vitest cases in meta-api.test.ts covering Meta's limit validation + payload-shape assertions for both interactive senders

## Validation
- npm test → 102/102 (was 89; 13 new)
- npm run lint → 0 errors
- npm run typecheck → clean
- npm run build → clean

## What's NOT in this PR
- The runner (dispatchInboundToFlows) — PR #2
- Builder UI / API surface — PR #3
- collect_input / condition / set_tag nodes + templates — PR #4
- react-flow canvas + http_fetch — v2 (PR #5)

## Risks tracked from the plan
- messages.content_type CHECK widening: grep'd every consumer; message-bubble.tsx, reply-quote.tsx, send/route.ts, automations/meta-send.ts all handle the new variant correctly (or accept it as a defaulted fallthrough)
- engineSendText / engineSendInteractive share ~80% of code; deliberately not pre-factored yet — let both stabilize, extract a shared base in PR #2 once the shape settles

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:12:27 +03:00
Arnas Donauskas
9c4a46d990
feat(beta): per-account beta_features flag on profiles, gating Flows
Scaffolding for the upcoming WhatsApp Flows feature. The whole Flows
stack (interactive button/list send + parse, server-side runner, no-
code builder, templates) will land across the next ~4 PRs *behind*
this gate so the owner can dogfood the feature on their own account
before exposing it to teammates or downstream pullers of the OSS.

What this lands:

  1. `profiles.beta_features TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]`
     — generic opt-in column, lives on the existing profiles table.
     Empty by default for every existing row.

  2. `src/lib/flows/feature-flag.ts` — `isFlowsEnabled(profile)`
     predicate + `BETA_FEATURE_FLOWS` constant. Single source of
     truth for the gate; later PRs import this in every guarded
     surface (sidebar nav, /flows page, /api/flows routes, webhook
     dispatcher).

  3. `Profile.beta_features` added to the shared TS type and the
     auth hook's local copy. Auth hook loads the column and
     defaults it to `[]` defensively if the DB read returns null.

  4. 11 unit tests covering null/undefined/empty/mismatched cases,
     including case sensitivity (only lowercase `'flows'` opts in)
     and defensive guards against malformed input.

How the owner enables it for their own account, once merged:

    UPDATE profiles
    SET beta_features = array_append(beta_features, 'flows')
    WHERE user_id = '<their-auth-uid>';

How we ship it to everyone, when ready: grep-and-delete the
`isFlowsEnabled()` callsites in one cleanup PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:57:33 +03:00
Arnas Donauskas
a701d932d2
Merge pull request #111 from ArnasDon/feat/inbox-doodle-background
feat(inbox): WhatsApp-style doodle background in the chat area
2026-05-22 10:09:16 +03:00
Arnas Donauskas
598ac7e66d
feat(inbox): WhatsApp-style doodle background in the chat area
Adds a subtle, repeating SVG pattern behind the message thread and the
"select a conversation" empty state — same visual language WhatsApp uses
in their own chat UI, themed for a CRM (message bubble, phone, mail,
calendar, lightbulb, briefcase, trophy, dollar sign, percent, etc).

Asset: public/inbox-doodle.svg, 6 KB, 240×240 tile that repeats
seamlessly. Icon shapes are taken verbatim from lucide-react (already
a dependency, every other icon in the UI uses it) so the doodle reads
as part of the same visual system, not a separate clip-art import.
Stroke is slate-500 at 22% opacity over bg-slate-950 — visible but
not competing with message text.

Wiring: a single DOODLE_BG_CLASSES constant in message-thread.tsx
applied to both the empty-state and active-thread containers, so they
can't drift. The header keeps bg-slate-900 (solid) and the composer
keeps its own background, so the doodle only shows in the actual
message scroll area — exactly like WhatsApp.

No JS, no perf hit beyond a one-off 6 KB asset fetch that's then
browser-cached forever (it's served from /public with Next's default
caching).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:06:34 +03:00