Commit Graph

280 Commits

Author SHA1 Message Date
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
7227802114
Merge pull request #118 from ArnasDon/feat/flows-hide-internal-keys
feat(flows): hide internal node_key + reply_id behind Advanced disclosure
2026-05-22 14:56:03 +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
4cb6849291
Merge pull request #117 from ArnasDon/feat/flows-templates-and-v1.5
feat(flows): templates + v1.5 nodes + run-history viewer (PR 4/4)
2026-05-22 14:29:41 +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
f9d13c42a7
Merge pull request #115 from ArnasDon/feat/flows-builder-ui
feat(flows): no-code builder UI + flows API + validator (PR 3/4)
2026-05-22 14:00:38 +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
2c029cb687
Merge pull request #114 from ArnasDon/feat/flows-runner
feat(flows): runner engine + webhook integration (PR 2/4)
2026-05-22 13:38:56 +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
66ef059b96
Merge pull request #112 from ArnasDon/feat/flows-foundation-interactive-messages
feat(flows): foundation for interactive WhatsApp messages (PR 1/4)
2026-05-22 13:23:30 +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
981a164cbc
Merge pull request #113 from ArnasDon/feat/beta-features-flag
feat(beta): per-account beta_features flag on profiles
2026-05-22 13:03:22 +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
Arnas Donauskas
f758a3e621
Merge pull request #109 from ArnasDon/feat/inbox-manual-refresh-button
feat(inbox): add a refresh button to the conversation header
2026-05-22 09:37:22 +03:00
Arnas Donauskas
c35f2a1cb4
feat(inbox): add a refresh button to the conversation header
A small icon button (lucide RefreshCw) in the message-thread header,
sitting just left of the Status dropdown. Clicking it bumps the
parent's `resyncToken` — the same channel that already drives the
WS-reconnect and tab-visibility-resume refetches. So the button goes
through the existing dedupe + fetch plumbing in ConversationList and
MessageThread; no parallel code path to keep in sync.

The icon spins for 700ms as click feedback (the refetch itself is
fire-and-forget — the children just refire their fetch effects). The
button is gated behind the optional `onRefresh` prop, so existing
callers of MessageThread (and tests, if any are added later) keep
working unchanged.

Useful when the agent suspects realtime missed something — or just
wants to be sure the list and thread reflect the latest DB state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:34:39 +03:00
Arnas Donauskas
2b3393b49a
Merge pull request #108 from ArnasDon/fix/inbox-unread-badge-clears-on-select
fix(inbox): clear unread badge instantly when opening a conversation
2026-05-22 09:30:12 +03:00
Arnas Donauskas
4e3bb707d2
fix(inbox): clear unread badge instantly when opening a conversation
The badge used to linger for the duration of the
  click → unread-reset effect → server UPDATE → realtime UPDATE → setState
round-trip — long enough to be noticeable, and visible indefinitely if
the realtime UPDATE was ever dropped. There was also a brief flicker
when a customer sent a new message while the user was already viewing
the thread: the webhook bumps unread_count on the server, the realtime
UPDATE arrives at the client and overwrites the local 0 with a positive
value for the ~100ms it takes the reset effect's server UPDATE to
round-trip back.

Fix in two places:

1. handleSelectConversation (and the deep-link auto-select path)
   optimistically zero the list copy's unread_count immediately. We
   deliberately do *not* touch activeConversation.unread_count — the
   reset effect inside MessageThread keys off that value to decide
   whether to fire the server UPDATE, so zeroing it locally would
   silently skip the persist.

2. handleConversationEvent UPDATE suppresses the incoming unread_count
   for the currently-active conv. Non-active convs take the value
   as-is; the active one stays at 0 until the user navigates away.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:27:04 +03:00
Arnas Donauskas
3de2da6cca
Merge pull request #107 from ArnasDon/fix/inbox-realtime-permanent
fix(inbox): permanent fix for realtime instability after #105
2026-05-21 19:50:51 +03:00
Arnas Donauskas
138ae8d6f4
fix(inbox): replace broken updater-closure flag, add reconnect/visibility resync
Addresses #106 (issue stays open until the reporter confirms in prod).

#105's `let foundInList = false; setState(p => { foundInList = ...; ... })`
pattern is broken: setState updaters run during reconciliation, *after*
the synchronous handler returns, so `foundInList` reads `false` in the
same tick and `hydrateConversation()` fires on every code path the
flag was meant to gate. Every incoming message and every conversation
UPDATE (read receipts, status flips, agent assignment) triggered a
redundant DB fetch, queuing HTTP behind realtime WS frames on the same
supabase client and starving the channel — which surfaced as exactly
what #106 reports: messages that never arrive, threads stuck on "No
messages yet", events landing out of order.

The fix:

1. Synchronous known-IDs ref. `knownConvIdsRef` mirrors the conv ids
   currently in state, kept in sync via a useEffect on `conversations`.
   Event handlers check it synchronously to decide between a cheap
   in-place patch and a hydrate refetch — no more closure-flag pattern.

2. Reconnect + visibility resync. Realtime is best-effort: events sent
   while the WS was disconnected or while the tab was background-throttled
   are gone. A `resyncToken` state bumped on (a) `isConnected` false→true
   transitions after the initial connect and (b) tab visibility →
   visible drives `ConversationList` and `MessageThread` to refire their
   fetch effects. The reactions effect in MessageThread is split into
   fetch + channel-subscribe so a resync only refetches the rows
   without tearing down and rebuilding the realtime channel.

3. Keep the conv-INSERT hydrate. Still the right fix for #104 (realtime
   payloads never carry the `contact` join); now it only fires when the
   conversation is genuinely unknown to state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:30:48 +03:00
Arnas Donauskas
aecad2e769
Merge pull request #105 from ArnasDon/fix/inbox-realtime-hydrate-new-conversations
fix(inbox): hydrate new conversations from realtime so they surface
2026-05-21 17:36:48 +03:00
Arnas Donauskas
206e110373
fix(inbox): hydrate new conversations from realtime so they surface in the list
Closes #104.

Supabase Realtime payloads only carry the row's own columns — the
`contact:contacts(*)` join the initial fetch uses is absent. A
freshly-inserted conversation arrived in state with `contact: undefined`,
displaying "Unknown" and a blank contact sidebar. When the
message-INSERT for that conversation arrived before the conv-INSERT
event had landed in state, the preview update was a silent no-op and
the conversation appeared stuck on "No messages yet" until something
else triggered a refetch (typically the user sending a reply, which is
why replying made the missing inbound messages "suddenly appear").

Add a deduped `hydrateConversation(id)` helper that fetches the row
with the contact joined and merges it into state. Call it from:
  - conv-INSERT realtime handler — to backfill the contact join
  - message-INSERT handler when the conv isn't in state yet
  - conv-UPDATE handler when the row is missing (self-heal for any
    realtime event ever dropped)

Existing fields in state win on merge: a realtime UPDATE may have
patched last_message_text / unread_count to fresher values than the
single-row read returns, and we don't want to clobber them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:33:32 +03:00
Arnas Donauskas
cac97fe029
Merge pull request #103 from ArnasDon/docs/deploy-on-hostinger-button
docs: add Deploy on Hostinger button to README
2026-05-19 17:08:02 +03:00
Arnas Donauskas
4ed9faffaa
docs: add Deploy on Hostinger button to README
Self-host docs already point to Hostinger Managed Node.js as the
fastest path, but the README had no clickable starting point for that
journey. Adds a CTA button (shields.io + SimpleIcons Hostinger logo +
brand purple) right under the tagline, linking to Hostinger's web-apps
hosting landing page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:04:25 +03:00
Arnas Donauskas
e84f7413e6
Merge pull request #102 from ArnasDon/chore/changelog-migration-note
docs: add CHANGELOG with migration-required note
2026-05-19 16:41:45 +03:00
Arnas Donauskas
faa128f0fd
docs: add CHANGELOG with migration-required note
Self-hosters pulling updates have no signal today that a new release
needs a Supabase migration applied — they only find out when /react
starts returning 500s in prod. CHANGELOG.md fixes that for v-current
(chat actions, requires migration 009) and gives future entries a home.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:39:16 +03:00
Arnas Donauskas
3e9276b00f
Merge pull request #101 from ArnasDon/feature/chat-action-fixes
fix(inbox): scope hover to bubble; fix stale-closure reactions
2026-05-19 16:34:53 +03:00
Arnas Donauskas
d41652d748
fix(inbox): scope hover to the bubble; fix stale-closure reaction flow
Two follow-ups to the chat-actions PR:

1. Action toolbar was revealing across the whole message row, not just
   over the bubble. The `group/actions` div in MessageActions wrapped
   the bubble's full-width `flex w-full justify-end/start` row, so
   anywhere in that row triggered group-hover. Moved row alignment +
   max-w-[75%] cap onto MessageActions and reduced MessageBubble to a
   content-sized stack. Hover area now matches the bubble shape.

2. Agent reactions weren't appearing locally. `handleAddReaction` was
   wrapped in `useCallback` with a `[]` dep array, so it captured the
   first-render `postReaction` — whose own closure held a stale
   `reactions = []`, stale `conversation`, and stale `user`. Every
   optimistic update replaced the reactions list with `[onlyMyTemp]`
   and the API POST may have hit a null conversation. Dropped both
   wrappers; postReaction is now called directly from JSX and uses a
   functional `setReactions((prev) => ...)` so each call sees fresh
   state. Toggle semantics for pill-click are computed at the call site
   where `msgReactions` and `user?.id` are already in scope. Realtime
   INSERT now swaps a matching optimistic temp row instead of stacking
   a duplicate.

Also: reject `temp-*` message IDs with a friendly toast so users can't
react to a bubble that hasn't reached Meta yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:32:34 +03:00
Arnas Donauskas
a1348ebcbd
Merge pull request #100 from ArnasDon/feature/chat-actions
feat(inbox): reactions, reply-to, and copy on chat messages
2026-05-19 16:22:02 +03:00
Arnas Donauskas
339b1fe94f
feat(inbox): reactions, reply-to, and copy on chat messages
Adds three WhatsApp-standard affordances to the inbox: per-message
emoji reactions, reply-with-quote, and copy-text. Bubbles stay pure
presenters — actions layer on by composition (MessageActions wraps
MessageBubble). Hover toolbar on desktop, long-press (onContextMenu)
on touch.

Data model (migration 009):
- messages.reply_to_message_id (self-FK, ON DELETE SET NULL) so replies
  survive a deleted parent
- message_reactions table — one row per (message, actor) with a unique
  constraint, conversation_id denormalised so Supabase Realtime can
  filter without a join
- RLS scoped via conversation ownership; service-role bypass for the
  webhook

API:
- /api/whatsapp/send accepts reply_to_message_id, resolves the parent's
  Meta message_id and forwards it as context.message_id
- /api/whatsapp/react (new) sends Meta reaction payloads and mirrors
  the upsert/delete into message_reactions
- Webhook stops storing inbound reactions as fake text messages and
  instead upserts/deletes message_reactions; inbound replies (context.id)
  resolve to internal UUID and populate reply_to_message_id

UI:
- MessageActions hover toolbar with inline 6-emoji Popover (static set;
  no emoji-picker library)
- ReplyQuote shared between the in-bubble quote and the composer chip
- MessageReactions pill row with click-to-toggle
- Thread subscribes to message_reactions filtered by conversation_id;
  optimistic upsert with rollback on failure

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:17:26 +03:00
Arnas Donauskas
bf14934ce3
Merge pull request #96 from ArnasDon/dependabot/npm_and_yarn/types-68ebe471cf
chore(deps-dev): bump @types/node from 25.6.0 to 25.9.0 in the types group
2026-05-18 23:10:31 +03:00
Arnas Donauskas
4e333af793
Merge pull request #97 from ArnasDon/dependabot/npm_and_yarn/dev-dependencies-4f40fd48be
chore(deps-dev): bump the dev-dependencies group with 2 updates
2026-05-18 23:09:08 +03:00
Arnas Donauskas
fee1da3167
Merge pull request #98 from ArnasDon/dependabot/npm_and_yarn/tailwind-merge-3.6.0
chore(deps): bump tailwind-merge from 3.5.0 to 3.6.0
2026-05-18 23:07:50 +03:00
Arnas Donauskas
ee2af6d4c8
Merge pull request #95 from ArnasDon/dependabot/npm_and_yarn/supabase-10427234a2
chore(deps): bump @supabase/supabase-js from 2.105.4 to 2.106.0 in the supabase group
2026-05-18 23:05:59 +03:00
dependabot[bot]
c718c62194
chore(deps): bump tailwind-merge from 3.5.0 to 3.6.0
Bumps [tailwind-merge](https://github.com/dcastil/tailwind-merge) from 3.5.0 to 3.6.0.
- [Release notes](https://github.com/dcastil/tailwind-merge/releases)
- [Commits](https://github.com/dcastil/tailwind-merge/compare/v3.5.0...v3.6.0)

---
updated-dependencies:
- dependency-name: tailwind-merge
  dependency-version: 3.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 19:42:42 +00:00
dependabot[bot]
0926ddee8d
chore(deps-dev): bump the dev-dependencies group with 2 updates
Bumps the dev-dependencies group with 2 updates: [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) and [prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss).


Updates `@tailwindcss/postcss` from 4.2.2 to 4.3.0
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.0/packages/@tailwindcss-postcss)

Updates `prettier-plugin-tailwindcss` from 0.7.2 to 0.8.0
- [Release notes](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.7.2...v0.8.0)

---
updated-dependencies:
- dependency-name: "@tailwindcss/postcss"
  dependency-version: 4.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: prettier-plugin-tailwindcss
  dependency-version: 0.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 19:42:35 +00:00
dependabot[bot]
24989d4a41
chore(deps-dev): bump @types/node in the types group
Bumps the types group with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `@types/node` from 25.6.0 to 25.9.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.9.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: types
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 19:42:15 +00:00
dependabot[bot]
4bbb30f135
chore(deps): bump @supabase/supabase-js in the supabase group
Bumps the supabase group with 1 update: [@supabase/supabase-js](https://github.com/supabase/supabase-js/tree/HEAD/packages/core/supabase-js).


Updates `@supabase/supabase-js` from 2.105.4 to 2.106.0
- [Release notes](https://github.com/supabase/supabase-js/releases)
- [Changelog](https://github.com/supabase/supabase-js/blob/master/packages/core/supabase-js/CHANGELOG.md)
- [Commits](https://github.com/supabase/supabase-js/commits/v2.106.0/packages/core/supabase-js)

---
updated-dependencies:
- dependency-name: "@supabase/supabase-js"
  dependency-version: 2.106.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: supabase
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 19:42:05 +00:00
Arnas Donauskas
fa01210c21
Merge pull request #94 from ArnasDon/security/override-transitive-vulns
Pin transitive vulns: postcss / ip-address / fast-uri / hono
2026-05-18 21:33:39 +03:00
Arnas Donauskas
06ef8f2206
Override transitive vulns: postcss, ip-address, fast-uri, hono
Patches 11 open Dependabot alerts in dependencies pulled in through
the shadcn CLI tree (shadcn → @modelcontextprotocol/sdk →
@hono/node-server, express-rate-limit, ajv-formats, …). These aren't
loaded by the running Next.js app — they only execute when you invoke
the shadcn CLI — but the alerts still surface in GitHub's UI, so it's
cleaner to pin them.

Uses npm `overrides` to force the patched version regardless of what
the upstream MCP SDK happens to request:

  postcss     ^8.5.10   — XSS via unescaped </style>
                          (GHSA-qx2v-qp2m-jg93, medium)
  ip-address  ^10.1.1   — XSS in Address6 HTML-emitting methods
                          (GHSA-v2v4-37r5-5v8g, medium)
  fast-uri    ^3.1.2    — path traversal + host confusion via
                          percent-encoded segments / authority
                          delimiters (GHSA-q3j6-qgpj-74h6 high,
                          GHSA-v39h-62p7-jpjc high)
  hono        ^4.12.18  — JSX HTML injection, bodyLimit bypass,
                          Cache middleware Vary leak, CSS injection
                          via style objects, JWT NumericDate
                          validation (GHSA-69xw-7hcm-h432 / 9vqf-7f2p-gf9v
                          / p77w-8qqv-26rm / qp7p-654g-cw7p /
                          hm8q-7f3q-5f36, low–medium)

After overrides:
  postcss     8.5.14   (was nested 8.4.31 inside next + top 8.5.14)
  ip-address  10.2.0   (was 10.1.0)
  fast-uri    3.1.2    (was 3.1.0)
  hono        4.12.19  (was 4.12.14)

Verified locally:
  npm run lint    — 0 errors (23 pre-existing warnings)
  npm run typecheck — clean
  npm test         — 89/89 passed
  npm run build    — succeeded

Pair this with #93 (next 16.2.6 bump) to clear all 22 alerts. Each PR
is independently revertible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:29:48 +03:00
Arnas Donauskas
66d9d9ef3e
Merge pull request #93 from ArnasDon/security/bump-next-16.2.6
Bump next 16.2.4 → 16.2.6 (security)
2026-05-18 21:27:45 +03:00
Arnas Donauskas
742d9e0caa
Bump next 16.2.4 → 16.2.6 (security)
Patches 11 open Dependabot alerts on Next.js — 10 High + 1 Low:

- SSRF in WebSocket upgrades (GHSA-c4j6-fc7j-m34r)
- DoS in Server Components (GHSA-8h8q-6873-q5fj)
- DoS in Cache Components via connection exhaustion (GHSA-mg66-mrh9-m8jx)
- DoS in Image Optimization API (GHSA-h64f-5h5j-jqjh)
- Middleware/Proxy bypass via segment-prefetch routes
  (GHSA-267c-6grr-h53f, GHSA-26hh-7cqf-hhc6 follow-up)
- Middleware/Proxy bypass via i18n in Pages Router
  (GHSA-36qx-fr4f-26g5)
- Middleware/Proxy bypass via dynamic route param injection
  (GHSA-492v-c6pp-mqqv)
- XSS in beforeInteractive scripts (GHSA-gx5p-jg67-6x7h)
- XSS in CSP-nonce App Router apps (GHSA-ffhc-5mcf-pf4q)
- Cache poisoning in RSC responses (GHSA-wfc6-r584-vfw7)
- Cache-busting collision in RSC (GHSA-vfv6-92ff-j949, Low)
- Cache-poisoned Middleware redirects (GHSA-3g8h-86w9-wvmq, Low)

Also bumps eslint-config-next to 16.2.6 to keep major/minor lockstep
with next, per Vercel's docs.

Verified locally:
  npm run lint    — 0 errors (23 pre-existing warnings)
  npm run typecheck — clean
  npm test         — 89 passed, 7 files
  npm run build    — succeeded

The remaining 11 alerts (postcss/ip-address/fast-uri/hono) are
transitive and ship via the shadcn CLI's tree; they'll be patched
separately via npm overrides.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:25:03 +03:00
Arnas Donauskas
e80189ec97
Merge pull request #92 from ArnasDon/test/foundational-vitest-suite
test: foundational Vitest suite for security + critical pure logic
2026-05-18 16:59:32 +03:00
Arnas Donauskas
db11f76226
test: foundational Vitest suite for security + critical pure logic
Adds the first test layer to the repo. Vitest is wired into the
existing CI workflow between typecheck and build so every PR gets the
same gating before merge. No source code under test was modified; this
PR only adds the test framework + tests.

Coverage focuses on the highest-impact pure modules — security-critical
crypto/HMAC, the phone-number pipeline every outbound message touches,
the rate-limiter that protects send/broadcast, automation activation
validation, and dashboard date math. 89 tests across 7 files; full run
is ~300ms.

Modules covered:
- lib/whatsapp/encryption — GCM round-trip, tamper detection (auth tag,
  ciphertext, IV length, auth-tag length), legacy CBC compat, format
  detection, malformed input
- lib/whatsapp/webhook-signature — valid/invalid HMAC, missing header,
  wrong prefix, length-mismatch guard, fail-closed when secret unset
- lib/whatsapp/phone-utils — sanitize, normalize, phonesMatch with
  trunk-prefix tail fallback, isValidE164 boundaries (7–15 digits,
  non-zero start), phoneVariants insert/remove with dedupe,
  isRecipientNotAllowedError detection
- lib/rate-limit — first-request math, exact-limit boundary, per-key
  isolation, window expiry with fake timers, 429 response shape,
  Retry-After clamp
- lib/automations/validate — every step type's required fields, wait
  amount/unit boundaries, webhook URL validation (missing / non-http /
  malformed), assign_conversation mode branching, recursive condition
  walking with stable dot-paths, unknown step types
- lib/broadcast-status — known status lookups, pulse flag, dark-theme
  class shape, fallback for unknown strings
- lib/dashboard/date-utils — startOfLocalDay, daysAgoStart, localDayKey
  zero-padding, lastNDayKeys (1, N, month crossing), Monday-first DOW

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