Commit Graph

567 Commits

Author SHA1 Message Date
Arnas Donauskas
b013861160
feat(api): invitation create + peek + redeem endpoints
PR 4 of the multi-user accounts series. Lands the link-only
invitation flow — admin generates a shareable URL, copies it,
sends it via any channel. No email service needed.

API surface
- GET    /api/account/invitations              — list pending  (admin+)
- POST   /api/account/invitations              — create        (admin+)
- DELETE /api/account/invitations/[id]         — revoke        (admin+)
- GET    /api/invitations/[token]/peek         — public, rate-limited per-IP
- POST   /api/invitations/[token]/redeem       — authenticated, rate-limited per-IP

Token lifecycle
- POST returns the plaintext token + share URL exactly ONCE in
  the response. We store only the SHA-256 hash. There is no
  endpoint that resurfaces it; if the admin dismisses the dialog
  without copying, the only recourse is to revoke and re-issue.
- Peek and redeem take the plaintext token in the URL path, hash
  it in TS via `hashInviteToken`, and look up by `token_hash`.

Two new SECURITY DEFINER RPCs back the cross-RLS work
(migration 019):
- `peek_invitation` — anonymous read. Returns a uniform JSON
  envelope: `{ ok, account_name, role, expires_at }` or
  `{ ok: false, reason: not_found|used|expired }`. The route
  forwards verbatim so the join page can branch cleanly.
- `redeem_invitation` — authenticated. Locks the invite row
  `FOR UPDATE`, validates expiry/usage, refuses if the caller is
  already in a shared account OR their personal account has any
  domain rows (checked across all 11 user-creatable parent
  tables), then atomically moves profile.account_id +
  account_role, stamps the invitation accepted, and deletes the
  orphan personal account. Single transaction.

Error contract
- 22023 (invalid_parameter_value)  → 400 — not_found / used / expired
- 42501 (insufficient_privilege)   → 401 — caller not authenticated
- 23505 (unique_violation, reused) → 409 — caller's account has data,
                                           or they're already a
                                           member of the inviter's
                                           account / another shared
                                           account

Rate limiting
- Two new buckets in `RATE_LIMITS`: invitationPeek (30/min/IP)
  and invitationRedeem (10/min/IP). Belt-and-braces against
  token enumeration even though 256-bit entropy makes it
  theoretical. Uses the existing fixed-window in-memory limiter.

Why no peek-then-redeem two-step on the server
- Peek is read-only and idempotent; redeem locks `FOR UPDATE` so
  two concurrent redemptions can't both win. The peek result is
  cosmetic ("show me what I'm joining"); the actual write
  happens entirely inside the redeem RPC's transaction, so a
  TOCTOU between peek and redeem just means the user sees the
  same `{ ok: false, reason }` on the next render.

Verification
- `npm run lint`      → 0 errors (same 20 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test`          → 366/366 pass
- `npm run build`     → succeeds; the 4 new routes register as ƒ

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 17:32:46 +03:00
Arnas Donauskas
dabddcfa05
Merge pull request #170 from ArnasDon/feat/account-sharing-members-api
feat(api): account + members management endpoints
2026-05-31 14:09:06 +02:00
Arnas Donauskas
0a1cf97d6b
feat(api): account + members management endpoints
PR 3 of the multi-user accounts series. Lands the server-side
endpoints the upcoming Members tab UI will call. Built on the
helpers from #169 (`requireRole`, `getCurrentAccount`,
`toErrorResponse`).

API surface
- GET    /api/account                     — caller's account + role
- PATCH  /api/account                     — rename                  (admin+)
- GET    /api/account/members             — roster; email visible to admin+ only
- PATCH  /api/account/members/[userId]    — change role             (admin+)
- DELETE /api/account/members/[userId]    — remove                  (admin+)
- POST   /api/account/transfer-ownership  — atomic owner handoff    (owner)

Member-role writes can't ride on the regular `profiles_update` RLS
policy (which only allows self-edits — admins can't modify a
teammate's row that way). The three SECURITY DEFINER RPCs in the
new migration are the scoped escape hatches:

- `set_member_role(p_user_id, p_new_role)` — admin+ only. Blocks
  promotion to / demotion from owner (use transfer instead) and
  self-targeting.
- `remove_account_member(p_user_id)` — admin+ only. Creates a
  fresh personal account for the removed user and reassigns
  their profile as its 'owner' — mirror of the signup flow, so
  they keep their login and just "start over". Blocks removing
  the owner or self.
- `transfer_account_ownership(p_new_owner_user_id)` — owner only.
  Atomically demotes current owner → admin, promotes target →
  owner, and points accounts.owner_user_id at the new owner.
  Demote-first ordering means the temporary "zero owners" state
  is never visible.

Error contract
- Each RPC raises with SQLSTATE 42501 (forbidden) or 22023 (bad
  input) and a human-readable RAISE message. The route handlers
  map these onto 403 / 400 respectively; anything else collapses
  to a generic 500.
- The TS guards (e.g. `requireRole('owner')` on the transfer
  route, `isAccountRole(role)` on the PATCH route) are
  belt-and-braces — they fail fast before the Supabase round
  trip but the RPCs would catch the same violations regardless.

Field visibility
- `GET /api/account/members` returns `email` only when the
  caller is admin+. Agents and viewers see name + avatar + role
  + joined date — matches the planning decision so a viewer
  can't harvest the team's email list.

Verification
- `npm run lint`      → 0 errors (same 20 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test`          → 366/366 pass
- `npm run build`     → succeeds; the 4 new routes register as ƒ
                        (dynamic — they all use `next/headers`
                        cookies via the SSR client)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 15:00:27 +03:00
Arnas Donauskas
0d95272999
Merge pull request #169 from ArnasDon/feat/account-sharing-server-helpers
feat(auth): account-sharing server helpers + types
2026-05-31 13:53:15 +02:00
Arnas Donauskas
2a46d7deca
feat(auth): account-sharing server helpers + types
PR 2 of the multi-user accounts series. Pure helpers and types
that the upcoming API routes (account, members, invitations,
redeem) all depend on. No new API surface yet — this commit is
intentionally a self-contained library + tests so the next PR
can focus on routes without re-litigating the foundations.

What landed
- `src/lib/auth/roles.ts` — `AccountRole` type mirroring the
  Postgres enum, `roleRank` / `hasMinRole`, plus the capability
  predicates (`canManageMembers`, `canEditSettings`,
  `canSendMessages`, `canViewOnly`, `canDeleteAccount`,
  `canTransferOwnership`). Pure functions; no I/O.
- `src/lib/auth/invitations.ts` — token generation
  (`crypto.randomBytes(32).toString('base64url')`), SHA-256
  hashing for at-rest storage, `inviteUrl` builder, and
  `inviteExpiresAt` / `clampExpiryDays` for the redeem deadline.
  Pure; no Supabase.
- `src/lib/auth/account.ts` — server-only. `getCurrentAccount()`
  resolves caller's user + profile + account in one Supabase
  round trip; `requireRole(min)` wraps it with a minimum-role
  check. Custom `UnauthorizedError` / `ForbiddenError` classes
  and a `toErrorResponse(err)` helper that maps them to
  `NextResponse` 401/403 with a generic 500 fallback (no
  uncategorised error leakage on the wire).
- `src/types/index.ts` — extends `Profile` with `account_id` +
  `account_role`; adds `Account`, `AccountMember`, and
  `AccountInvitation` interfaces matching migration 017's
  shapes. The legacy `profile.role TEXT` column is annotated
  for future cleanup.

Tests
- `roles.test.ts` — 16-row hierarchy matrix + every predicate's
  boundaries. Includes a "matches the SQL helper's numeric
  mapping" check so the JS rank and Postgres `is_account_member`
  CASE can't silently drift.
- `invitations.test.ts` — token length / charset / uniqueness,
  hash determinism, a known SHA-256 vector (regression net
  against the hash function ever changing and orphaning every
  stored token_hash), URL formation edge cases, and the
  `clampExpiryDays` envelope (default / non-finite / negative /
  oversize).

Verification
- `npm run lint`      → 0 errors (same 20 pre-existing warnings).
- `npm run typecheck` → clean.
- `npm test`          → 366/366 pass (319 prior + 47 new).
- `npm run build`     → succeeds.

What's NOT in this PR
- No API routes yet — `/api/account/*` and `/api/invitations/*`
  land in PR 3 + 4 of the series.
- No UI changes — useAuth extension, Members tab, and Join page
  come later (PRs 5-7).
- No WhatsApp webhook account routing (PR 8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 14:51:21 +03:00
Arnas Donauskas
078df59016
Merge pull request #168 from ArnasDon/feat/tremor-charts
feat(dashboard): adopt Tremor for charts (first integration)
2026-05-31 13:44:09 +02:00
Arnas Donauskas
bf90c370fc
feat(dashboard): adopt Tremor for charts — first integration
Brings Tremor (Apache-2.0) in as the project's chart component
library and converts the response-time chart as the first
integration. Tremor is distributed copy-paste ("Tremor Raw") so
the components live under `src/components/tremor/`; the only
runtime peer dependency added is `recharts`. Two small local
adaptations to the vendored `BarChart`:

- `cx` (Tremor's `clsx`+`twMerge` helper) → our `cn` from
  `@/lib/utils`, aliased locally to keep diff vs upstream minimal.
- `@remixicon/react` legend-slider icons →
  `lucide-react`'s `ChevronLeft` / `ChevronRight`, since
  `lucide-react` is already a project dep.
- `ChartTooltipProps.label` widened from `string` to
  `string | number | undefined` to match recharts 3.x (Tremor's
  source still types it for recharts 2.x).

Why Tremor and not a wider switch (Mantine, HeroUI):
- The dashboard ships ~700 lines of hand-rolled SVG across three
  chart files — a focused charting library is the highest-ROI
  addition without touching the wider component surface.
- Tremor is Tailwind-native and built on the same Radix/Base UI
  layer as the existing `src/components/ui/*`, so it composes
  rather than displaces.
- Apache-2.0 + copy-paste distribution means no upgrade lock-in.

What this PR does
- Adds `src/components/tremor/{bar-chart.tsx,chart-colors.ts,
  get-y-axis-domain.ts,use-on-window-resize.ts}` (vendored from
  tremorlabs/tremor with the adaptations above).
- Installs `recharts@^3.8.1` (React 19 + Next.js 16 compatible).
- Rewrites `src/components/dashboard/response-time-chart.tsx`:
  drops 130 lines of bespoke SVG math (axis scaling, bar
  positioning, grid lines, tooltip rendering), reuses the same
  card/header/empty-state shell.

Deferred (own follow-up PRs)
- `pipeline-donut.tsx` — has user-customisable per-stage hex
  colours; Tremor's palette would override them. Needs either a
  small upstream patch or a `colors` override path.
- `conversations-chart.tsx` — area-chart conversion (largest of
  the three, 341 lines).
- The previous hand-rolled bar chart drew a horizontal dashed
  "target N minutes" reference line. Tremor BarChart doesn't
  expose Recharts primitives, so for now the target is promoted
  to a header pill (`target 5m`). On-chart reference line is a
  follow-up — either via a positioned overlay or an upstream
  `referenceLines` prop.

Verification
- `npm run lint`   → 0 errors (same 20 pre-existing warnings).
- `npm run typecheck` → clean.
- `npm test`       → 319/319 pass.
- `npm run build`  → succeeds, dashboard remains a static route.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 14:42:10 +03:00
Arnas Donauskas
df29707abf
Merge pull request #167 from ArnasDon/feat/account-sharing-schema
feat(db): account-sharing schema foundation
2026-05-31 13:22:07 +02:00
Arnas Donauskas
55d4fedab9
fix(db): create profile.account_id before is_account_member helper
`is_account_member` is a `LANGUAGE sql` function, so Postgres parses
and validates its body at CREATE time — references to `profiles.account_id`
and `profiles.account_role` must already exist in the schema. The
original ordering created the helper before the ALTER TABLE that
added those columns, causing Supabase to abort with

    ERROR: column p.account_id does not exist (SQLSTATE 42703)

on every fresh apply. Reordering: the profile column-add now runs
immediately after the `accounts` / `account_invitations` tables and
before the helper. No semantic change elsewhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 13:06:14 +03:00
Arnas Donauskas
2f0334b0de
feat(db): account-sharing schema foundation
Turns every wacrm install multi-tenant on the database side. A single
user's signup now creates an `accounts` row, links the profile to it
as `owner`, and every domain row is scoped via a new `account_id`
column instead of `user_id`.

Why now: the next handful of PRs add the invite/members user surface
(gated by the `account_sharing` beta flag); that surface needs RLS to
already speak "membership" rather than "ownership". Doing the schema
flip in isolation keeps the diff reviewable and lets the follow-up
PRs stay focused on TS/UI.

What the migration does:
- Introduces `account_role_enum` + `accounts` + `account_invitations`.
- Adds `account_id` to every parent tenant table (16 tables incl.
  profiles) and `account_role` to profiles.
- Backfills one account per existing user, then sets NOT NULL.
- Replaces every `auth.uid() = user_id` RLS policy with a membership
  check via a SECURITY DEFINER `is_account_member(account_id, min)`
  helper. Three tiers: viewers read, agents write operational data,
  admins write settings-class tables (whatsapp_config, templates,
  pipelines, tags, custom_fields).
- Swaps `whatsapp_config.UNIQUE(user_id)` → `UNIQUE(account_id)` and
  `flow_runs` active-run idempotency key to `(account_id, contact_id)`.
- Replaces `handle_new_user` so new signups bootstrap their own
  account atomically.

Backwards compatibility: every existing user is the sole `owner` of
their freshly-created account, every existing row is linked to it,
and the legacy `user_id` columns stay (still used for assignment /
audit, no longer for isolation). Net behavioural change on its own:
zero.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 12:59:07 +03:00
Arnas Donauskas
c7ea1af684
Merge pull request #166 from ArnasDon/fix/reply-quote-overflow
fix(inbox): reply quote no longer overflows or single-lines (#165)
2026-05-31 11:31:10 +02:00
Arnas Donauskas
13bafa5747
fix(inbox): reply quote no longer overflows or single-lines (#165)
A long reply quote was forcing the entire 3-panel inbox layout
wider, pushing the contact sidebar off-screen and making the page
horizontally scrollable. Two root causes (both correctly diagnosed
in the issue by @tekrexllp):

1. ReplyQuote applied `truncate` to the preview text, which sets
   `white-space: nowrap`. Long previews became one impossibly-wide
   line instead of wrapping.

2. The flex chain from MessageBubble outward (MessageActions →
   middle-panel wrapper → page) had no `min-w-0` on its flex
   children. Default `min-width: auto` means a flex child grows to
   its content's natural width, so the long quote propagated all the
   way up to the inbox-page flex container and pushed the contact
   sidebar past the viewport.

Fix:
  - ReplyQuote preview now uses `whitespace-pre-wrap break-words`
    instead of `truncate`. The author label stays single-line (it's
    a name, ellipsis is right for it). `overflow-hidden` on the
    inner text container as belt-and-braces.
  - MessageActions' .group/actions wrapper gets `min-w-0` so the
    `max-w-[75%]` cap actually holds even with wide content.
  - inbox/page.tsx middle-panel wrapper gets `min-w-0` so the
    MessageThread can shrink below its natural content width when
    the conversation list and contact sidebar are also flexing.

Audited the other 38 `truncate` usages across the codebase —
they're all on intentionally single-line content (contact names,
file paths, last-message previews in lists, headers, node
summaries) where ellipsis is the right behavior. ReplyQuote was
the only multi-line `truncate` misuse.

Reported and diagnosed by @tekrexllp in #165.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 12:28:49 +03:00
dhruv465
3854502652 fix: smooth flow dragging and refresh chunks 2026-05-30 17:01:14 +05:30
Arnas Donauskas
1b66431acf
Merge pull request #161 from ArnasDon/feat/flow-canvas-polish
feat(flows): canvas / mind-map view, PR 3 of 3 — polish
2026-05-29 21:09:03 +02:00
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
62d5d98f06
Merge pull request #160 from ArnasDon/feat/flow-canvas-editable-pt2
feat(flows): canvas / mind-map view, PR 2b of 3 — full editing
2026-05-29 20:35:04 +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
90d18c8dbd
Merge pull request #159 from ArnasDon/feat/flow-canvas-editable-pt1
feat(flows): canvas / mind-map view, PR 2a of 3 — drag-to-position + side-panel edit
2026-05-29 20:19:31 +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
f9a1fb52e7
Merge pull request #158 from ArnasDon/feat/flow-canvas-readonly
feat(flows): canvas / mind-map view, PR 1 of 3 — read-only
2026-05-29 19:43:40 +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
cd5dea1e4e
Merge pull request #157 from ArnasDon/docs/changelog-send-media
docs(changelog): 0.2.2 — send_media flow node + migration 016
2026-05-29 19:23:16 +02:00
Arnas Donauskas
78e0f25911
docs(changelog): 0.2.2 — send_media flow node + migration 016
New users / self-hosters pulling this version need to apply migration
016 before deploying — the entry flags it under "Migration required"
and explains exactly what it does (CHECK constraint + storage bucket
+ RLS policies) so the operator knows why each piece exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 19:22:22 +02:00
Arnas Donauskas
44f80ac9b3
Merge pull request #156 from ArnasDon/feat/flow-send-media-node
feat(flows): add send_media node for image / video / document sends
2026-05-29 19:15:23 +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
6c0c70486c
Merge pull request #155 from ArnasDon/fix/whatsapp-multi-number-registration
fix(whatsapp): register phone numbers on Meta + verify subscription state
2026-05-29 18:20:08 +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
260af1b901
Merge pull request #154 from ArnasDon/chore/templates-cleanup-pass
chore(templates): polish pass from senior backend / FE / UX review
2026-05-29 17:52:36 +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
e60fea701e
Merge pull request #153 from ArnasDon/feat/template-send-media-buttons
feat(templates): media headers + URL/copy-code button params on send
2026-05-29 16:41:30 +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
a8d430f3e9
Merge pull request #152 from ArnasDon/feat/template-webhook-events
feat(templates): webhook handler for real-time status / quality updates
2026-05-29 16:22:53 +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
26d65303e5
Merge pull request #151 from ArnasDon/feat/template-lifecycle-endpoints
feat(templates): edit / resubmit / delete lifecycle endpoints
2026-05-29 16:02:01 +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
f329b8268f
Merge pull request #150 from ArnasDon/feat/template-submit-and-form
feat(templates): submit-to-Meta endpoint + component-based form builder
2026-05-29 15:37:34 +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
318085ee9e
Merge pull request #149 from ArnasDon/feat/template-meta-schema
feat(templates): raw Meta status enum + schema groundwork for submission
2026-05-29 12:23:12 +02:00
Arnas Donauskas
ddf15184c8
fix(migration-014): drop subquery from buttons CHECK (Postgres disallows it)
Postgres rejects CHECK constraints that contain subqueries with
SQLSTATE 0A000 — `NOT EXISTS (SELECT 1 FROM jsonb_array_elements(...))`
inside CHECK is illegal even though it parses.

Move per-element validation entirely to the API layer (the upcoming
template-validators in PR 3). The DB CHECK keeps the cheap shape
guard: NULL or an array of length <= 10. Per-element validation
needs specific error messages anyway ("button #3 has no `text` field")
which API-layer validators do much better than a CHECK violation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 12:21:14 +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
3b0d841533
Merge pull request #148 from ArnasDon/fix/template-header-none-sentinel
fix(templates): map "None" header sentinel to null before save
2026-05-29 11:48:25 +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
e828372fe3
Merge pull request #142 from ArnasDon/dependabot/npm_and_yarn/lucide-react-1.16.0
chore(deps): bump lucide-react from 1.8.0 to 1.16.0
2026-05-26 15:27:58 +02:00
dependabot[bot]
56345ca986
chore(deps): bump lucide-react from 1.8.0 to 1.16.0
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 1.8.0 to 1.16.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.16.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-26 13:27:34 +00:00
Arnas Donauskas
ddfc1b601f
Merge pull request #141 from ArnasDon/dependabot/npm_and_yarn/base-ui/react-1.5.0
chore(deps): bump @base-ui/react from 1.4.1 to 1.5.0
2026-05-26 15:26:23 +02:00
Arnas Donauskas
08b6b2154d
Merge pull request #140 from ArnasDon/dependabot/npm_and_yarn/date-fns-4.3.0
chore(deps): bump date-fns from 4.1.0 to 4.3.0
2026-05-26 15:25:14 +02:00
dependabot[bot]
857595a81f
chore(deps): bump date-fns from 4.1.0 to 4.3.0
Bumps [date-fns](https://github.com/date-fns/date-fns) from 4.1.0 to 4.3.0.
- [Release notes](https://github.com/date-fns/date-fns/releases)
- [Commits](https://github.com/date-fns/date-fns/compare/v4.1.0...v4.3.0)

---
updated-dependencies:
- dependency-name: date-fns
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-26 13:23:44 +00:00
dependabot[bot]
c3c17dbfac
chore(deps): bump @base-ui/react from 1.4.1 to 1.5.0
Bumps [@base-ui/react](https://github.com/mui/base-ui/tree/HEAD/packages/react) from 1.4.1 to 1.5.0.
- [Release notes](https://github.com/mui/base-ui/releases)
- [Changelog](https://github.com/mui/base-ui/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mui/base-ui/commits/v1.5.0/packages/react)

---
updated-dependencies:
- dependency-name: "@base-ui/react"
  dependency-version: 1.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-26 13:23:41 +00:00
Arnas Donauskas
af8f17973f
Merge pull request #139 from ArnasDon/dependabot/npm_and_yarn/dev-dependencies-3c17d0768b
chore(deps-dev): bump the dev-dependencies group with 2 updates
2026-05-26 15:22:41 +02:00
Arnas Donauskas
79bddda164
Merge pull request #138 from ArnasDon/dependabot/npm_and_yarn/supabase-cb5af0f2dc
chore(deps): bump @supabase/supabase-js from 2.106.0 to 2.106.2 in the supabase group
2026-05-26 15:21:17 +02:00