Commit Graph

435 Commits

Author SHA1 Message Date
Arnas Donauskas
b1eb02f48e
Merge pull request #43 from ArnasDon/fix/dashboard-pipeline-card-height
fix(dashboard): pipeline card stretches to match the chart row height
2026-04-17 22:37:26 +03:00
Arnas Donauskas
96debd193b
fix(dashboard): paginate recent activity feed with a size picker
With real data the feed showed 20 rows by default and filled most of
the viewport. Switched to a 5-row default with an inline size picker
(5 / 10 / 20 / 50) so users choose how much history is visible.

- Dashboard page bumps the fetch from 20 → 50 so every size option is
  already in memory. Switching sizes is a pure client-side slice, no
  round trip.
- ActivityFeed owns the pageSize state, renders "Showing N of M"
  plus a button group in the footer. The smallest option is always
  enabled; larger options unlock only once we have enough rows to
  meaningfully reveal more (e.g. "10" enables once loaded > 5).
- Existing "View all" header link left alone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:35:42 +03:00
Arnas Donauskas
2954123d9b
fix(dashboard): pipeline card stretches to match the chart row height
With grid items-stretch (the default) the two cards in the charts
row try to share the tallest sibling's height — but the Pipeline
Value card was rendering at its natural (shorter) height because
neither its col-wrapper nor its inner section had h-full. So the
line chart's taller content drove the row but the pipeline card
hung at the top with trailing whitespace.

Added h-full on both col wrappers and flex h-full flex-col on
each inner section so they actually fill the row. The existing
flex-1 wrapper inside PipelineDonut now has a defined height to
stretch against, so the empty state (and the populated donut +
legend) span the card cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:29:54 +03:00
Arnas Donauskas
3e02098579
Merge pull request #42 from ArnasDon/feat/dashboard-analytics
feat(dashboard): replace placeholder with real analytics
2026-04-17 22:20:50 +03:00
Arnas Donauskas
ed669424b6
feat(dashboard): replace placeholder with real analytics
Rewrites the dashboard page as a data-driven analytics surface using
existing Supabase tables only — no schema changes, no other pages
touched.

Widgets (all render independent skeletons while their data fetches,
and show a shared empty state when there's not enough data to be
useful):

- Metric cards (Active Conversations, New Contacts Today, Open Deals
  Value, Messages Sent Today) with up/down/no-change delta indicator.
- Quick actions row (New Contact / Deal / Broadcast / Automation).
- Conversations Over Time: SVG line chart, 7/30/90-day tabs, per-day
  hover crosshair + tooltip. Cached per range so tab switches are
  instant.
- Pipeline Value: SVG donut with per-stage arc segments, total in the
  center, legend with deal count + value.
- Average First Response Time: SVG bar chart by weekday with a
  configurable dashed target line and this-week / last-week summary.
- Activity feed: latest 20 items merged across messages, contacts,
  deals, broadcasts, and automation logs — click-through deep-links
  where applicable.

Deviations from spec, noted up-front:
- Strictly dark theme — spec's #f9fafb alternating rows translate to
  slate-900 / slate-900/40 stripes.
- Kept Inter (tabular-nums, bold 28px) for metric values instead of
  Space Grotesk to avoid touching root layout.
- Quick actions link to the relevant list pages (/contacts, etc.)
  rather than auto-opening their create modals — that would require
  modifying those pages.

All aggregation is client-side via RLS-scoped queries. If a tenant's
dataset outgrows a few thousand messages per range window, the
response-time and line-chart queries should migrate to SQL RPCs —
noted as follow-up, not shipped here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:16:00 +03:00
Arnas Donauskas
5be2bad51d
Merge pull request #41 from ArnasDon/fix/automations-validate-step-config-on-activate
fix(automations): reject activation of invalid step/trigger config
2026-04-17 21:58:46 +03:00
Arnas Donauskas
348dbb7d6c
Merge pull request #40 from ArnasDon/fix/automations-atomic-execution-counter
fix(automations): atomic execution_count via SQL function (migration 007)
2026-04-17 21:56:37 +03:00
Arnas Donauskas
98751b8fe6
Merge pull request #39 from ArnasDon/fix/automations-welcome-template-uses-first-inbound
fix(automations): Welcome Message template uses first_inbound_message
2026-04-17 21:54:28 +03:00
Arnas Donauskas
3758711f5a
Merge pull request #38 from ArnasDon/fix/automations-meta-send-contact-ownership
fix(automations): scope meta-send contact lookup by user_id
2026-04-17 21:53:05 +03:00
Arnas Donauskas
53bb056803
Merge pull request #37 from ArnasDon/fix/automations-template-param-ordering
fix(automations): order send_template params numerically, not lexicographically
2026-04-17 21:51:36 +03:00
Arnas Donauskas
570fc1234c
fix(automations): reject activation of invalid step/trigger config
Activating an automation with obviously-broken config (add_tag step
with empty tag_id, send_message with empty text, keyword_match
trigger with no keywords, etc.) used to succeed — and then every
dispatch produced a cryptic failed log row that users often missed.

- New validate.ts centralises the checks (mirrors engine.ts's
  runtime guards one layer earlier so failures surface at save time).
  Returns { path, message } issues so the UI can point users at the
  exact problem.
- POST /api/automations rejects with 400 when is_active === true and
  the merged config fails validation.
- PATCH /api/automations/[id] computes the post-patch "effective"
  state (merging body fields over the existing row, loading current
  steps if body.steps isn't provided) and applies the same gate.
  Drafts (is_active=false) remain free to be incomplete so users can
  still save progress mid-build.
- Builder surfaces the first issue in a toast with path as the
  description, so users don't need DevTools to find what to fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:50:13 +03:00
Arnas Donauskas
635b5c1df6
fix(automations): atomic execution_count via SQL function (migration 007)
The engine did a client-side read-modify-write on
automations.execution_count, so two concurrent dispatches of the
same automation (e.g. fired for two different contacts within the
same second) could both read N and both write N+1, permanently
losing one count.

- Migration 007 adds increment_automation_execution_count(p_automation_id)
  as a SECURITY DEFINER sql function. Only service_role can EXECUTE
  it — an authenticated user can't juice someone else's counter by
  calling the RPC directly.
- Engine now calls this RPC instead of reading automation.execution_count
  and adding 1. Atomic in one statement; concurrent dispatches both
  land correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:45:27 +03:00
Arnas Donauskas
a3d59d8ea3
fix(automations): Welcome Message template uses first_inbound_message
The template seeded trigger_type as new_contact_created, which only
fires when the webhook auto-creates a contact row. A user who added
or imported the contact manually before the contact first messaged
would not hit the welcome flow — that's almost certainly not what the
user setting up "Welcome Message" wants.

first_inbound_message (added in PR #33) is the superset: fires the
first time any contact — auto-created or pre-existing — sends us a
customer message. Correct semantic for a welcome/greeting template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:42:20 +03:00
Arnas Donauskas
4afa9bea32
fix(automations): scope meta-send contact lookup by user_id
The engine runs with the service-role client (bypassing RLS) and the
public POST /api/automations/engine endpoint accepts contact_id from
the request body. Without a user_id filter on the contacts lookup,
an authenticated user could fire their own automations against
another tenant's contact UUID and send via their own WhatsApp config
to that contact's phone. UUIDs are unguessable so practical risk is
low, but this is cheap defense-in-depth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:39:33 +03:00
Arnas Donauskas
f25f20ed63
fix(automations): order send_template params numerically, not lexicographically
Meta's template placeholders are positional ({{1}}, {{2}}, …). We
previously sorted the keys of step_config.variables lexicographically,
so a template with ≥10 variables got params in the order
"1", "10", "2", …, silently scrambling every render past the 9th
placeholder. Fix: sort numeric-looking keys by numeric value, fall back
to locale compare for non-numeric keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:36:40 +03:00
Arnas Donauskas
59d0ea6e17
Merge pull request #36 from ArnasDon/fix/inbox-unread-indicator
fix(inbox): reliable unread reset + sidebar green-dot indicator
2026-04-17 21:23:22 +03:00
Arnas Donauskas
5943c59193
Merge pull request #35 from ArnasDon/fix/dashboard-deep-link-to-conversation
fix(dashboard): open the clicked conversation when deep-linking from dashboard
2026-04-17 21:20:57 +03:00
Arnas Donauskas
bd62322da4
Merge pull request #34 from ArnasDon/fix/builder-dropdown-width
fix(automations): widen step-picker dropdown so labels don't wrap
2026-04-17 21:19:04 +03:00
Arnas Donauskas
c2ef80c7df
fix(inbox): reliable unread reset + sidebar green-dot indicator
Two issues:

1. Unread count didn't always clear when opening / viewing a
   conversation. The reset effect keyed off conversationId only, so
   a new inbound message arriving while the thread was already open
   would get the server to bump unread_count to 1, but the reset
   effect wouldn't re-fire. Split the fetch and unread-reset effects:
   fetch stays keyed on conversationId (no unnecessary refetches on
   every new message), and a second effect keyed on [conversationId,
   hasUnread] flips unread back to 0 whenever it surfaces on the
   active conversation. The hasUnread gate still prevents the
   reset→update→realtime→re-run loop.

2. No indicator elsewhere in the app when new messages arrive. Added
   useTotalUnread() hook — one-time query + realtime subscription on
   the conversations table — and a pulsing green dot on the sidebar
   Inbox entry when totalUnread > 0. Dot is hidden while on /inbox
   itself (redundant with the per-conversation badges there).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:17:28 +03:00
Arnas Donauskas
03c53905c1
fix(dashboard): open the clicked conversation when deep-linking from dashboard
Clicking a row in the dashboard's "Recent Conversations" routed to
/inbox with no hint about which conversation to open, so the user
landed on the empty center panel and had to find the thread again.

- Dashboard now routes to /inbox?c=<conversation_id> for per-row clicks.
  "View all" still goes to plain /inbox.
- Inbox page reads ?c=<id> and auto-selects the matching conversation
  in handleConversationsLoaded (event handler, not an effect, so
  react-hooks/set-state-in-effect stays happy). A ref guards against
  resnapping the selection on later realtime list refreshes.
- Inbox also now replaces the URL with ?c=<id> when the user picks a
  conversation, so a page refresh / shared link lands on the same
  thread.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:11:05 +03:00
Arnas Donauskas
ad7137ece8
fix(automations): widen step-picker dropdown so labels don't wrap
The add-step dropdown was inheriting its width from the "+" button
(≈32px), hitting the base Menu min-w-32 (128px) and wrapping every
multi-word label ("Send Message", "Update Contact Field", etc.).
Set min-w-56 and explicit align="start" so the list anchors to the
button's left edge and each label fits on one line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:04:20 +03:00
Arnas Donauskas
a5f2a40b37
Merge pull request #33 from ArnasDon/feat/first-inbound-message-trigger
feat(automations): add 'First Message from Contact' trigger
2026-04-17 20:51:50 +03:00
Arnas Donauskas
5243188d0c
feat(automations): add 'First Message from Contact' trigger
new_contact_created only fires when the webhook auto-creates the
contact row, so it misses the case of manually-added / imported
contacts sending their first-ever message.

first_inbound_message fires whenever the incoming message is the
contact's first customer-sent message (checked by counting prior
customer-sender messages in the conversation before insert), which
covers both auto-created and pre-existing contacts.

- Added first_inbound_message to AutomationTriggerType.
- New pill color + dropdown entry labeled "First Message from Contact".
- Webhook now counts prior customer messages before inserting the
  incoming one; dispatches first_inbound_message when the count is 0.
- new_contact_created still fires for brand-new contacts; they're
  independent triggers with overlapping coverage, so users pick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 20:47:13 +03:00
Arnas Donauskas
da809ced5f
Merge pull request #32 from ArnasDon/fix/automations-actually-send-via-meta
fix(automations): actually send messages via Meta
2026-04-17 20:37:13 +03:00
Arnas Donauskas
0e75eb8d11
fix(automations): actually send messages via Meta
send_message and send_template steps were inserting a row into
messages with status='sending' but never calling Meta's WhatsApp API,
so automation replies appeared in the inbox but never reached the
phone. send_template was a plain noop ("template send recorded").

- New helper src/lib/automations/meta-send.ts mirrors the logic in
  /api/whatsapp/send (decrypt config, phone-variant retry, persist
  messages row with status='sent' and the real whatsapp message id).
  Kept as a separate helper to avoid touching the working manual-send
  route; the two can converge in a later refactor.
- Engine's send_message / send_template now await the helper and
  surface Meta errors through the existing per-step failure path,
  so a bad token / invalid template shows up in automation_logs with
  status='failed' instead of silently "sending" forever.
- Consolidated automation dispatches in the webhook to fire after the
  conversation is created. Previously new_contact_created raced the
  conversation insert, which could have made send_message fail to
  resolve a target conversation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 20:34:52 +03:00
Arnas Donauskas
e8c952b129
Merge pull request #31 from ArnasDon/feat/automations
feat(automations): visual flow builder, engine, and logs viewer
2026-04-17 20:10:02 +03:00
Arnas Donauskas
b7b362ae42
feat(automations): visual flow builder, engine, and logs viewer
Replaces the /automations placeholder with a full feature:
- Migration 006 adds automations, automation_steps, automation_logs,
  and automation_pending_executions with RLS matching existing tables.
- List page with active/inactive switch, quick-start templates,
  duplicate, delete, and View Logs actions.
- Full-screen vertical flow builder with Trigger + Action + Condition
  (Yes/No branches) + Wait cards. Inline edit forms, up/down reorder,
  dotted-grid canvas.
- Execution engine (src/lib/automations/engine.ts) dispatched from the
  WhatsApp webhook for new_message_received, new_contact_created, and
  keyword_match. Wait steps enqueue to automation_pending_executions;
  /api/automations/cron drains them behind AUTOMATION_CRON_SECRET.
- Logs viewer with per-step checkmarks and expandable detail.

Existing files touched only in webhook/route.ts (fire-and-forget
trigger dispatch) and types/index.ts (appended automation types).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 19:39:02 +03:00
Arnas Donauskas
8e61aa5302
Merge pull request #30 from ArnasDon/fix/broadcast-hardening-review
fix(broadcasts): senior-review hardening pass
2026-04-17 17:45:30 +03:00
Arnas Donauskas
66dd4ef97e
fix(broadcasts): senior-review hardening pass
Fixes 8 real issues found during a thorough review of the broadcast
feature (PRs #21-#29).

Security
  * Webhook POSTs had zero spoof protection. Anyone could forge a
    status update and drift broadcast counts arbitrarily. Added
    HMAC-SHA256 signature verification via the X-Hub-Signature-256
    header (Meta's standard). New helper src/lib/whatsapp/webhook-
    signature.ts with timing-safe comparison. Rollout fails open
    with a warning when META_APP_SECRET is unset so existing
    deploys don't break at rollout; set the env var to enforce.

Correctness
  * CSV audience was completely broken — synthesized "csv-N" strings
    as contact_id, which failed the UUID cast on insert. Every CSV
    broadcast silently created zero recipients. Replaced with a
    real find-or-create against the contacts table (batched lookup
    by phone, single INSERT for missing rows, de-duplicates CSV
    inputs). CSV contacts now end up as real contacts.rows with
    real UUIDs flowing through the rest of the pipeline.

  * Recipient insert errors were logged with console.error and the
    loop marched on — broadcasts then ran with incomplete recipient
    sets, webhook updates couldn't find rows, and aggregate counts
    drifted. Now: on batch insert failure, flip the broadcast to
    failed + failed_count=total + throw so handleSend's toast fires.

  * Webhook status guard ordered "failed" above "replied", which
    meant a webhook replay delivering "failed" for an already-
    replied recipient would upgrade replied → failed. Split the
    ladder: forward-only ordering across pending → sent →
    delivered → read → replied; failed is a terminal side branch
    that can only be reached from pending or sent (never from any
    success state). Net effect: a message that was read can no
    longer regress to failed on a replay.

  * Template sync only pulled page 1 (ignored paging.next). Users
    with >100 templates silently missed some, and broadcasts with
    those names failed with Meta's "template not found". Now
    follows paging.next with a 20-page safety cap, reports
    truncated=true in the response if hit.

  * Template sync reported success:true even when individual
    upserts failed. Now accumulates per-template errors and
    surfaces them via a separate toast.error in the UI — green
    toast no longer hides silent drift.

Performance
  * Migration 005: replace the broadcast_recipients aggregate
    trigger installed in migration 003. Old version ran
    COUNT(*) FILTER on every row change — O(n²) across a send
    loop (10k recipients × 10k index scans). New version
    increments / decrements by ±1 per status transition: O(1)
    per change. Kept the old recompute_broadcast_counts() as a
    callable safety-net in case of drift.

  * List page poll kept running while the tab was hidden. Added
    visibilitychange handler — polling pauses when hidden,
    resumes (and does an immediate fetch) on refocus.

UX
  * Step 3 (Personalize) let users skip past unmapped placeholders
    → broadcasts then sent to Meta with empty-string params.
    Next is now disabled until every {{N}} has a value; an amber
    warning lists the missing placeholders.

Documented accepted risks in the PR body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 17:38:52 +03:00
Arnas Donauskas
a328ef1f19
Merge pull request #29 from ArnasDon/feat/broadcasts-delete-and-meta-template-sync
feat(broadcasts): delete campaign + sync templates from Meta
2026-04-17 17:10:47 +03:00
Arnas Donauskas
b37229f6a9
feat(broadcasts): delete campaign + sync templates from Meta
Addresses two recurring issues:

1) Delete broadcast campaign
   Broadcast detail page gets a Delete button (red, in the header, next
   to the status badge) with an inline confirm pattern matching
   pipeline-settings. Disabled while status='sending' — we don't want
   to orphan in-flight Meta messages. On confirm, supabase delete
   cascades broadcast_recipients and redirects to /broadcasts.

2) Persistent Meta error #132001 "Template name does not exist in the
   translation"
   Root cause that wasn't fixed by PR #27's language default: the local
   template manager never actually submitted templates to Meta. Users
   would create welcome_message locally, try to broadcast, and get
   rejected because Meta had never seen the template.

   Meta template submission requires a human review flow in Meta's
   WhatsApp Manager — we can't automate that from inside WaCRM. What
   we CAN do is pull Meta's approved list so the local catalog never
   drifts from reality.

   New: POST /api/whatsapp/templates/sync
   - Reads whatsapp_config for waba_id + access_token (decrypted)
   - GET /v21.0/{waba_id}/message_templates?limit=100 with BODY/HEADER/
     FOOTER components
   - Normalizes Meta's UPPER_SNAKE category/status to our TitleCase
   - Upserts each row in message_templates by (user_id, name, language)
   - Locally-created rows with no Meta counterpart stay visible so the
     user can notice drift and clean up

   Template manager UI: "Sync from Meta" button next to New Template,
   with a spinning refresh icon while in flight and a toast summarizing
   how many inserted / updated. Header copy clarifies that approval
   happens in Meta's WhatsApp Manager.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 17:08:33 +03:00
Arnas Donauskas
6bae86bd66
Merge pull request #28 from ArnasDon/fix/contact-deletion-fk-constraints
fix(contacts): allow deletion — SET NULL on broadcast_recipients + deals FKs
2026-04-17 16:58:42 +03:00
Arnas Donauskas
5f488e129a
fix(contacts): allow deletion — SET NULL on broadcast_recipients, deals
Root cause (error 23503):
  broadcast_recipients.contact_id and deals.contact_id were declared
  NOT NULL REFERENCES contacts(id) with no ON DELETE clause, so
  Postgres defaulted to NO ACTION. Any contact who'd ever received a
  broadcast or been attached to a deal became un-deletable with
  "violates foreign key constraint broadcast_recipients_contact_id_fkey".

Fix (migration 004):
  Relax both FKs to ON DELETE SET NULL + drop NOT NULL on both
  columns. History rows survive with contact_id = NULL. The UI is
  already null-safe:
    - broadcast detail:  contact?.name ?? 'Unknown'
    - deal card:         contact?.name || contact?.phone || 'No contact'
    - CSV export:        contact?.name ?? ''

Why not CASCADE:
  CASCADE would wipe broadcast recipient rows and trigger the
  aggregate recount on broadcasts (migration 003), retroactively
  changing sent_count / delivered_count / read_count. It would also
  delete deal history. SET NULL preserves both counts and history.

Type/code:
  - Deal.contact_id + BroadcastRecipient.contact_id widen to
    string | null
  - DealForm: setContactId(deal.contact_id ?? "") — the contact
    picker uses "" to mean unselected

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 16:56:45 +03:00
Arnas Donauskas
5c2f87fd01
Merge pull request #27 from ArnasDon/fix/favicon-and-template-language
fix: WaCRM favicon + template language defaults to en_US
2026-04-17 16:04:38 +03:00
Arnas Donauskas
980ce6aa25
fix: WaCRM favicon + template language defaults to en_US
1) Favicon
   Next.js was serving its default triangle icon because src/app/
   favicon.ico was the stock placeholder. Replaced with a dynamic
   icon route (src/app/icon.tsx) that renders the sidebar mark —
   emerald-500 rounded square with a white MessageSquare glyph.
   Next.js auto-injects <link rel="icon"> pointing to the generated
   PNG.

2) Template language default
   Users hit Meta error #132001 "Template name does not exist in the
   translation" because the template manager defaulted new templates'
   language to "en" while Meta's WhatsApp templates are typically
   approved under "en_US". Meta treats "en" and "en_US" as distinct
   language codes — a template approved under one is rejected if sent
   with the other.

   Fixes:
   - Default template form language to "en_US" (matches the DB
     default on message_templates.language and the broadcast
     sender's fallback).
   - Swap the freeform <Input> for a datalist-backed input
     autocompleting the ~17 most common Meta language codes.
   - Inline help text: "Must match the exact language code the
     template is approved under on Meta — e.g. en_US and en are
     distinct."

   For the user's existing welcome_message template (language="en"),
   the fix is to edit it in Settings → Message Templates and change
   Language to whatever Meta shows in the WhatsApp Manager
   (typically en_US). One-time data fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 16:03:02 +03:00
Arnas Donauskas
f8543270d1
Merge pull request #26 from ArnasDon/fix/cache-control-headers
fix(deploy): Cache-Control headers so CDN stops serving stale HTML
2026-04-17 14:36:39 +03:00
Arnas Donauskas
56c76af739
fix(deploy): Cache-Control headers so CDN stops serving stale HTML
Root cause: Hostinger's CDN was applying s-maxage=31536000 (1 year)
to prerendered HTML pages by default. Each deploy produced fresh
Turbopack chunk hashes, but the edge kept serving year-old HTML that
referenced chunk filenames the new build no longer had on disk —
resulting in HTML 200 + every /_next/static/*.{js,css} 404, page
rendered unstyled. Private/incognito didn't help because the cache
is at the edge, not the browser.

Fix: explicit Cache-Control via next.config.ts headers():
- /_next/static/*  — public, max-age=31536000, immutable
  Content-hashed filenames; safe forever.
- /api/*           — no-store
  Per-user, must never be shared at the edge.
- everything else  — public, max-age=0, s-maxage=300,
                     stale-while-revalidate=86400
  Edge serves instantly from cache for 5 min, then keeps serving
  cached content while refreshing in the background for up to 24h.
  Chunk-hash drift after a deploy self-heals within ~5 min.

Verification after deploy:
  curl -I https://<host>/login | grep -i cache-control
  Expect: public, max-age=0, s-maxage=300, stale-while-revalidate=86400
  (If Hostinger overwrites this, fall back to configuring
  purge-on-deploy at the platform layer.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:34:46 +03:00
Arnas Donauskas
ed8dd6de84
Merge pull request #25 from ArnasDon/fix/broadcasts-promote-b-c-and-send
fix(broadcasts): promote B+C + attach user_id on send
2026-04-17 13:57:30 +03:00
Arnas Donauskas
cd04e0682c
fix(broadcasts): attach user_id on send, surface errors with toast
The INSERT into broadcasts in use-broadcast-sending.ts never set
user_id, but broadcasts.user_id is NOT NULL + RLS-guarded. The
INSERT rejected (23502 / 42501), the hook threw, handleSend caught
with console.error only. UI showed nothing — the wizard stayed on
Step 4 and the flow appeared dead.

Fix:
- Resolve session.user at the top of createAndSendBroadcast and
  include user_id on the broadcasts INSERT.
- handleSend in /broadcasts/new now toasts the error via sonner so
  future failures are visible immediately instead of vanishing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 13:55:18 +03:00
Arnas Donauskas
5dbb26e4fc
Merge pull request #23 from ArnasDon/feat/broadcasts-audience-c
feat(broadcasts) C: custom-field audience, exclude tags, live preview
2026-04-17 11:44:49 +03:00
Arnas Donauskas
db6e853faf
Merge pull request #21 from ArnasDon/fix/broadcasts-correctness-a
fix(broadcasts) A: per-recipient vars, webhook status sync, rate limit
2026-04-17 11:41:26 +03:00
Arnas Donauskas
296a905976
feat(broadcasts) C: custom-field audience, exclude tags, live preview
Stacked on PR B (#22). Merge A → B → C in order.

Step 2 — Audience
- Implement the "Custom Field" audience option (was a stub): field
  picker + operator (is / is not / contains) + value input. Estimated
  count query joins contact_custom_values with the operator mapped
  to eq / neq / ilike. PostgREST ilike handles "contains" case-
  insensitively.
- New "Exclude contacts with these tags" section — applies to every
  audience type. Estimated count subtracts excluded contacts
  client-side against the resolved base set.
- Switching audience type clears stale shape fields (tagIds /
  customField / csvContacts) so old selections don't leak across.

Step 3 — Personalize
- Add "Custom Field" mapping option alongside static + contact field.
- Live Preview now pulls the most recent real contact (+ their custom
  values) instead of hard-coded sample data. Falls back to sample if
  the user has no contacts yet. Rendered in a WhatsApp-style bubble
  so the user gets a realistic feel for how the recipient sees it.

Hook — use-broadcast-sending
- AudienceConfig gains customField + excludeTagIds.
- resolveAudience: custom-field branch + exclude-tag subtraction
  applied after the base resolution (works for all / tags / custom
  field).
- resolveVariables accepts a pre-built (fieldId → value) map per
  contact. Before the send loop we bulk-fetch contact_custom_values
  for all resolved contacts in one query (paged by 500 for PostgREST
  limits) so we never N+1 per recipient.

new/page.tsx — passes customField + excludeTagIds into the hook;
variables state widens to allow type='custom_field'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 11:39:24 +03:00
Arnas Donauskas
244d04ca93
feat(broadcasts) B: list rates, detail funnel + CSV, save-as-draft
UI polish for the Broadcasts feature. Stacked on PR A (correctness).

List page /broadcasts
- Replaces raw Sent/Delivered/Read/Failed columns with Delivery Rate +
  Read Rate cells (percent + inline mini progress bar). Raw counts
  still available on the detail page.
- Sending badge gets a live-looking pulsing dot.
- Indeterminate top-of-page progress bar appears while any broadcast
  has status='sending'. Page polls the broadcasts table every 5 s
  while that condition holds, and stops polling otherwise.
- Mobile: table wrapped in overflow-x-auto.

Detail page /broadcasts/[id]
- Adds a Replied stat card (was missing). Final grid: Total / Sent /
  Delivered / Read / Replied / Failed — 6 cards on desktop, 2 on
  mobile. Color spectrum matches the spec's funnel gradient.
- Pure-CSS horizontal funnel chart: Sent → Delivered → Read → Replied
  with decreasing widths in emerald → teal → blue → indigo. Each bar
  shows its absolute value and percent of Sent.
- Status filter dropdown above the recipients table. Filters
  client-side; header reflects filtered / total.
- "Export CSV" downloads all recipients with status + every milestone
  timestamp + error_message. RFC-4180 quoting.
- Recipients table gains Delivered / Read timestamp columns so the
  CSV columns are also visible at a glance.

Wizard Step 4
- Save as Draft button next to Send Broadcast. Persists a broadcasts
  row with status='draft' and zero recipients — enough for the user
  to recognize the draft in the list. Full resume-into-wizard is a
  future follow-up.
- Confirmation modal before send was already in place; kept as-is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 11:31:13 +03:00
Arnas Donauskas
5d5c5f0a20
fix(broadcasts): per-recipient variables, webhook status sync, rate limit
Three correctness bugs were making the existing broadcast flow wrong,
not just incomplete.

1. Per-recipient variables were broken
   use-broadcast-sending.ts computed templateParams per contact but shipped
   only templateParams[0] to the API, so every contact in a batch received
   contact 0's personalization (name/email/etc).

   Fix: extend POST /api/whatsapp/broadcast to accept a per-recipient
   shape `{ recipients: [{ phone, params }], ... }`. Legacy
   `{ phone_numbers, template_params }` still accepted. Hook sends per-
   recipient payloads; API returns whatsapp_message_id per phone.

2. Webhook never updated broadcast_recipients
   handleStatusUpdate in webhook/route.ts only mirrored Meta status onto
   the `messages` table. broadcast_recipients had no correlation column,
   so delivered_count / read_count / replied_count never advanced for
   real broadcasts.

   Fix (migration 003):
     - broadcast_recipients.whatsapp_message_id (+ UNIQUE index) for
       Meta-message-id correlation
     - composite index (broadcast_id, status) for fast aggregate scans
     - AFTER INSERT/UPDATE/DELETE trigger that recomputes parent
       broadcasts counts via COUNT(*) FILTER — sender + webhook only
       touch the recipient row, aggregates stay consistent

   Webhook:
     - handleStatusUpdate also updates broadcast_recipients with forward-
       only status guard (read cannot regress to sent on a replay)
     - processMessage flags the replied recipient row when an inbound
       message arrives from a recent broadcast contact

3. Rate limiting did not match spec
   Batch 50 / no delay → Batch 10 / 1s between batches, matching the spec
   and Meta's per-phone-number rate ceiling.

Also: extract statusConfig from both page components into
src/lib/broadcast-status.ts (single source of truth for PR B). Add
whatsapp_message_id to BroadcastRecipient type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 11:22:38 +03:00
Arnas Donauskas
f746299df3
Merge pull request #20 from ArnasDon/fix/pipelines-sizing-and-review
fix(pipelines): column sizing + senior-review pass
2026-04-16 23:02:28 +03:00
Arnas Donauskas
03fbead9c8
fix(pipelines): size columns to fill, batch stage saves, inline delete confirm
Sizing:
- Board columns grow to fill wide viewports (flex-1 basis-[260px])
  instead of being capped at 300px; still overflow-scroll on narrow screens.
- Analytics grid: 2 cols mobile → 3 cols sm → 6 cols xl (was lg, which
  cramped labels on 1024-1280px widths).

Senior-review pass:
- pipeline-settings handleSave: replace N sequential UPDATEs in a for-loop
  with a single upsert (onConflict: "id"). Previously latency scaled
  linearly with stage count; now constant.
- deal-form delete: swap window.confirm for an inline confirm row that
  matches the app's pattern (pipeline-settings uses the same inline
  confirmation for pipeline deletion).
- pipeline-board: add KeyboardSensor alongside PointerSensor so deal cards
  are draggable via keyboard for a11y (Space to pick up, arrows to move).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 22:59:28 +03:00
Arnas Donauskas
4229c94ec5
Merge pull request #19 from ArnasDon/fix/pipelines-dark-theme-and-tooltips
fix(pipelines): dark theme, analytics above board, metric tooltips
2026-04-16 22:52:01 +03:00
Arnas Donauskas
2cc03fcdfa
fix(pipelines): dark-theme the board + cards, put analytics above, add metric tooltips
Three follow-ups to the Pipelines page that shipped in #18:

1. Analytics strip now sits ABOVE the Kanban board rather than below, so
   top-line numbers read first and the board flows beneath.
2. Re-theme the board to match the rest of the dark app: slate-900 columns
   with slate-800 borders, slate-800 deal cards with stage-colored accent
   bar, emerald dollar values, and slate-500/slate-400 secondary text. The
   empty-column "Drop a deal here" and drop-zone highlight stay but in
   slate/emerald tones instead of white/light-gray.
3. Each analytics metric now has an info icon + tooltip explaining how it
   is calculated in plain language (e.g. Weighted Value explains the
   10% → 90% stage interpolation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 22:50:52 +03:00
Arnas Donauskas
57f05fb143
Merge pull request #18 from ArnasDon/feat/pipelines-kanban-dnd
feat(pipelines): Kanban drag-drop, analytics, deal sheet
2026-04-16 22:43:11 +03:00
Arnas Donauskas
f1eafd5d27
feat(pipelines): Kanban drag-drop, analytics, deal sheet, contact deals tab
Rebuild the Pipelines page to match spec.

Highlights:
- Drag-and-drop deal cards between stages via @dnd-kit (200ms drop animation,
  emerald dashed drop zone, opacity fade while dragging)
- First-visit seed: auto-creates "Sales Pipeline" with spec-defined stages
  (New Lead / Qualified / Proposal Sent / Negotiation / Won)
- Top bar: always-visible pipeline dropdown with "Manage Pipelines" option,
  plus "Add Pipeline" and emerald "Add Deal" buttons
- Deal form converted from Dialog to right-side Sheet, adds assigned_to
  (profile FK), "Link to Conversation" shortcut, "Mark as Won/Lost" status
  buttons, and "Delete Deal"
- Pipeline settings modal: drag-to-reorder stages (@dnd-kit sortable),
  per-stage color picker, "Create new pipeline" shortcut, archive
  confirmation copy
- Analytics bar below board: total deals, pipeline value, avg deal size,
  weighted pipeline value (probability interpolated across stages),
  won/lost this month
- Contact detail sheet gains a "Deals" tab showing all deals for that
  contact (value + stage badge + status)
- Migration 002: adds deals.assigned_to FK→profiles, normalizes status
  CHECK to ('open','won','lost')

Note: spec called for light-gray Kanban columns (#f9fafb) + white cards
inside an otherwise-dark app. Followed spec literally; easy to retheme.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 22:38:45 +03:00