Commit Graph

46 Commits

Author SHA1 Message Date
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
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
Arnas Donauskas
3615e8cacf
Merge pull request #17 from ArnasDon/fix/inbox-reclick-clears-messages
fix(inbox): re-clicking active conversation no longer clears messages
2026-04-16 22:14:05 +03:00
Arnas Donauskas
5467c3e1e3
fix(inbox): re-clicking active conversation no longer clears messages
Selecting the already-active conversation called setMessages([]) but the
fetch effect in MessageThread only re-runs when conversationId changes,
so messages stayed empty until the user left the page and came back.
Bail out of handleSelectConversation when the clicked conversation is
already active.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 22:11:57 +03:00
Arnas Donauskas
0a9d9f2bb3
Merge pull request #15 from ArnasDon/refactor/meta-api-named-params
refactor: Meta API helpers take named-params objects (prevents entire bug class)
2026-04-16 22:00:38 +03:00
Arnas Donauskas
f3c68967f1
refactor: Meta API helpers use named params (prevents swapped-args bugs)
Over the course of the project the same swapped-args bug was found
four separate times against the Meta API helpers (config in PR #8,
send in PR #12, broadcast in PR #13, webhook media in PR #14). Each
time the compiler accepted the call because all the args were
strings, so the bug only surfaced at runtime as an opaque Meta error.

This refactor eliminates the entire bug class by converting every
helper to a single options object. A typo like
  sendTextMessage({ accessToken: id, phoneNumberId: tok, ... })
is now a TypeScript error at the call site rather than a 400 from
Meta two layers deep.

Helpers updated in src/lib/whatsapp/meta-api.ts:
- verifyPhoneNumber({ phoneNumberId, accessToken })
- sendTextMessage({ phoneNumberId, accessToken, to, text })
- sendTemplateMessage({ phoneNumberId, accessToken, to, templateName, language?, params? })
- getMediaUrl({ mediaId, accessToken })
- downloadMedia({ downloadUrl, accessToken })

Each exported Args interface so routes can type local variables
holding partial argument sets (useful for the broadcast retry loop).

Also factored the duplicated "parse Meta error JSON" block into a
shared throwMetaError() helper — was repeated in 5 places.

Call sites updated (all were already correct after prior fixes, but
now the types enforce it):
- src/app/api/whatsapp/config/route.ts (GET + POST)
- src/app/api/whatsapp/send/route.ts (attempt() closure)
- src/app/api/whatsapp/broadcast/route.ts (retry loop)
- src/app/api/whatsapp/webhook/route.ts (verifyAndBuildUrl)
- src/app/api/whatsapp/media/[mediaId]/route.ts (proxy pipeline)

No behavior change. Build passes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:52:25 +03:00
Arnas Donauskas
03b40e0e8e
Merge pull request #14 from ArnasDon/fix/media-messages-pipeline
fix: media messages (image/video/audio/document/sticker) now render
2026-04-16 21:43:59 +03:00
Arnas Donauskas
870e25f0ba
fix: media messages (image/video/audio/document/sticker) now render
Incoming photos showed up as empty bubbles in the Inbox (only the
timestamp, no image). Investigation found three compounding bugs in
the media pipeline:

1. Swapped getMediaUrl args in webhook parseMessageContent
   The helper signature is getMediaUrl(mediaId, accessToken), but every
   media case (image/video/document/audio) called it as
   getMediaUrl(accessToken, message.X.id). Meta got requests for
     GET /v21.0/<ACCESS_TOKEN>
   with Authorization: Bearer <MEDIA_ID>
   Every verification failed → the catch block returned mediaUrl: null
   → the message was stored in the DB with no media_url → MessageBubble
   had nothing to render.

   Refactored to a single verifyAndBuildUrl(mediaId) helper called per
   media type, with the arguments in the correct order.

2. No fallback UI when media_url is null
   MessageBubble's image/video/audio/document cases all did
   `{message.media_url && <render />}` with no else branch. So a media
   message without a URL rendered an empty <div> — the user saw only
   the timestamp.

   Added a <MediaUnavailable label="..."> component that shows the icon
   + "Image/Video/Audio/Document unavailable" text when media_url is
   null. Applied to all four media types.

3. Sticker type would fail the CHECK constraint on insert
   WhatsApp sends image stickers as type: "sticker", but the
   messages.content_type CHECK constraint only allows:
     text, image, document, audio, video, location, template
   Inserting content_type: 'sticker' silently failed.

   processMessage now validates content_type against an allow-list
   before insert. Stickers are mapped to 'image' (they ARE images).
   Reactions and any other unknown type are mapped to 'text'.

Also added the sticker media handling to parseMessageContent so sticker
images actually render (with the verified proxy URL).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:43:07 +03:00
Arnas Donauskas
641031fd5c
Merge pull request #13 from ArnasDon/feat/phone-variant-retry
feat: auto-retry with phone variants on Meta 'not in allowed list' (#131030)
2026-04-16 21:36:00 +03:00
Arnas Donauskas
e97d5b5064
feat: auto-retry with phone variants on Meta "not in allowed list"
Meta WhatsApp returns error #131030 ("Recipient phone number not in
allowed list") in two situations:
  1. Sandbox: the recipient isn't registered as a test number.
  2. The registered number format differs from what we sent, usually
     because of trunk-prefix 0 quirks (e.g. Lithuanian numbers stored
     as +370063949836 vs international +37063949836).

Today that error bubbles up to the UI and the user has to manually
edit the contact. This PR makes the send pipeline self-heal: on 131030
it retries with alternate phone-number formats, and if an alternate
works it persists the corrected format back to the contact so future
sends are a single API call.

src/lib/whatsapp/phone-utils.ts
- Added phoneVariants(sanitized) — yields original, then variants with
  a trunk 0 inserted/removed after each plausible country-code length
  (1-3 digits). Deduplicated. Original is always first so the fast
  path for correctly-formatted numbers is one attempt.
- Added isRecipientNotAllowedError(message) helper — matches
  code 131030 or the English error text, so we only retry when the
  failure is actually about the recipient (other errors fail fast).

src/app/api/whatsapp/send/route.ts
- Reply send now iterates phoneVariants() and stops at the first that
  succeeds. If a non-original variant works, contacts.phone is updated
  to the working format.

src/app/api/whatsapp/broadcast/route.ts
- Same retry applied per recipient in the broadcast loop.
- Also fixed a separate bug: broadcast was calling
  sendTemplateMessage(accessToken, phone_number_id, ...) — wrong order.
  Correct signature is (phoneNumberId, accessToken, to, templateName,
  language?, params?). This was silently rejecting every broadcast.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:34:53 +03:00
Arnas Donauskas
0a5d274e02
Merge pull request #12 from ArnasDon/fix/send-route-and-handler
fix: outbound messages silently failing (Meta args + schema + silent errors)
2026-04-16 21:25:40 +03:00
Arnas Donauskas
cf706a8c9d
fix: outbound messages silently failing (wrong Meta args + schema)
Two silent bugs in the send pipeline that made agent messages
never reach WhatsApp AND disappear on page reload:

1. /api/whatsapp/send passed Meta API args in the wrong order.
   The helpers have signature (phoneNumberId, accessToken, to, ...)
   but the route was calling sendTextMessage(accessToken, phoneNumberId,
   to, text). So the request went to
     POST https://graph.facebook.com/v21.0/<ACCESS_TOKEN>/messages
   with Authorization: Bearer <PHONE_NUMBER_ID>
   which Meta rejects. The try block caught the error and the outer
   catch returned a generic "Failed to send message" with no detail.

2. Even if Meta had succeeded, the DB insert would have failed because
   the route was using field names that don't exist in the messages
   table — same class of bug as the webhook had before PR #10:
     user_id           → not a column on messages
     whatsapp_message_id → should be message_id
     direction          → should be sender_type ('customer'|'agent'|'bot')
     message_type       → should be content_type
     timestamp          → should be created_at
   Insert would have failed, DB row would never exist, and the optimistic
   message in the UI would vanish on the next page load / fetchMessages.

3. The frontend swallowed errors silently (console.error only), so the
   UI looked like the message had been sent even when nothing reached
   Meta and nothing was saved.

Changes:

src/app/api/whatsapp/send/route.ts
- Swap Meta API args to the correct order
  (phoneNumberId first, then accessToken)
- Wrap the Meta call in its own try/catch so the error message from
  Meta bubbles up to the client (returned as 502 with the actual text)
- Fix messages insert to use schema-correct columns: sender_type,
  content_type, content_text, media_url, template_name, message_id,
  status. Dropped non-existent user_id/direction/message_type/timestamp.
- Also surface the underlying DB error if the insert fails.

src/components/inbox/message-thread.tsx
- Added onUpdateMessage prop.
- handleSend parses the error JSON, toasts the actual reason, and
  flips the optimistic bubble's status to 'failed' so MessageBubble
  renders the red XCircle. On success flips to 'sent' immediately
  (realtime INSERT still replaces the temp bubble with the real row).

src/app/(dashboard)/inbox/page.tsx
- Added handleUpdateMessage that mutates one message by id.
- Passed as onUpdateMessage to MessageThread.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:21:01 +03:00
Arnas Donauskas
2446056842
Merge pull request #11 from ArnasDon/fix/inbox-refresh-loop-and-banner
fix: inbox refresh loop, wrong connection status, overlapping banner
2026-04-16 21:13:40 +03:00
Arnas Donauskas
976f1ff8ac
fix: inbox refresh loop, wrong connection status, overlapping banner
Three issues in the Inbox page:

1. "WhatsApp not connected" banner always showed even when connected.
   The connection check queried .from("whatsapp_configs") (plural) but
   the actual table is whatsapp_config (singular). Query always returned
   no rows, so status was never "connected". Changed to the correct
   table name and also switched .single() → .maybeSingle() so a missing
   row returns null cleanly instead of throwing PGRST116.

2. The banner overlapped message content — it used absolute positioning
   at top-14, left-60, which floated over the thread's header. Restructured
   the page layout so the banner is part of the flex column (pushes panels
   down) instead of overlapping them.

3. The inbox "kept refreshing every second" — an infinite loop between
   MessageThread's fetch and the realtime subscription in the page:

   MessageThread useEffect depended on a fetchMessages callback that had
   [conversation, onMessagesLoaded] deps. After fetching messages, it
   always issued a conversations.unread_count=0 UPDATE. That UPDATE fired
   a postgres_changes event. The page's handleConversationEvent received
   it and called setActiveConversation({...prev, ...conv}) — creating a
   NEW OBJECT REFERENCE for the conversation prop. That changed the
   conversation prop passed to MessageThread → fetchMessages changed →
   useEffect re-fired → reset unread → fired another realtime event →
   loop forever (visible as ~1-2 refreshes per second).

   Fix:
   - useEffect now depends on conversation?.id only (a stable string),
     not the full object reference.
   - onMessagesLoaded is read through a ref so it doesn't need to be
     a dependency.
   - unread_count reset only runs when the conversation actually has
     unread messages (avoids UPDATE-thrashing realtime).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:11:10 +03:00
Arnas Donauskas
b8923c6056
Merge pull request #10 from ArnasDon/fix/webhook-messages-schema
fix: webhook message insert uses wrong schema field names
2026-04-16 21:04:26 +03:00
Arnas Donauskas
ffdf2d1c80
fix: webhook message insert uses wrong schema field names
When a WhatsApp message arrives, the webhook creates/updates the contact
and conversation fine (so the conversation appears in the Inbox), but
every message INSERT fails silently because the field names don't match
the messages table schema:

  Webhook wrote              Schema has
  -----------------          ----------
  user_id                    (doesn't exist)
  whatsapp_message_id        message_id
  direction: 'inbound'       sender_type ('customer' | 'agent' | 'bot')
  message_type               content_type
  media_type                 (doesn't exist)
  status: 'received'         status ('sending'|'sent'|'delivered'|'read'|'failed')
  timestamp                  created_at

The insert error was caught and logged with console.error but silently
swallowed — the conversation update succeeded (its fields were right),
so the Inbox showed the contact and last message text, but opening the
thread showed zero messages. The 24-hour session timer also read
"no customer messages ever" and immediately marked the session expired.

Fixes in src/app/api/whatsapp/webhook/route.ts:
- processMessage() — message insert now uses the correct schema columns:
    sender_type: 'customer'
    content_type: message.type
    message_id: message.id
    status: 'delivered'
    created_at: <ISO from Meta timestamp>
- handleStatusUpdate() — lookup uses message_id (not whatsapp_message_id)
  and no longer sets updated_at (column doesn't exist on messages).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:03:08 +03:00
Arnas Donauskas
82fc18e460
Merge pull request #9 from ArnasDon/fix/better-token-corruption-handling
fix: proper WhatsApp config save/reset flow (fixes persistent token corruption)
2026-04-16 20:55:32 +03:00
Arnas Donauskas
43b8e93d6d
fix: properly handle WhatsApp config save/corruption/reset flow
Root cause of the persistent 'Stored access token is corrupted' error:
handleSave was writing the access_token DIRECTLY to Supabase via the
browser client — WITHOUT calling the server-side POST /api/whatsapp/config
handler. The server handler is what actually encrypts the token with
ENCRYPTION_KEY. So every save stored the token in PLAINTEXT, and every
subsequent decrypt attempt threw 'corrupted'.

This meant no amount of re-saving could ever fix it — each save
re-introduced plaintext data that would fail decryption again.

Changes in src/components/settings/whatsapp-config.tsx:
- handleSave now POSTs to /api/whatsapp/config (server verifies with
  Meta AND encrypts the token before storing).
- On page load, after pulling form values from Supabase, we also
  call GET /api/whatsapp/config to check the config's health. If the
  health check reports needs_reset (decrypt failure), we show a
  prominent amber banner with a "Reset Configuration" button.
- Added handleReset that calls DELETE /api/whatsapp/config to clear
  the broken row and reset local state.
- Added a "Reset Configuration" button in the main action row
  (available whenever a config exists, for manual cleanup).
- Better error messages — shows the actual server-side reason
  (token_corrupted, meta_api_error, no_config).
- Save flow now requires re-entering the access token when updating
  (can't update without a token to verify against Meta).

Changes in src/app/api/whatsapp/config/route.ts:
- GET now returns 200 with { connected, reason, message, needs_reset }
  instead of 500 on decrypt failure. The UI can handle this gracefully.
- Added DELETE handler to remove the user's config row.
- POST also surfaces encryption errors (e.g. missing ENCRYPTION_KEY).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:54:43 +03:00
Arnas Donauskas
0bcbdc2a43
Merge pull request #8 from ArnasDon/fix/test-api-connection-get-handler
fix: add GET /api/whatsapp/config + fix swapped Meta API args
2026-04-16 20:41:25 +03:00
Arnas Donauskas
04934519fd
fix: add GET handler for /api/whatsapp/config + fix swapped args
Fixes two bugs in the WhatsApp config API route:

1. Test API Connection button returned 405 Method Not Allowed
   The "Test API Connection" button in Settings calls
   GET /api/whatsapp/config but only a POST handler existed.
   Added a GET handler that:
   - Fetches the user's saved config from Supabase
   - Decrypts the stored access token
   - Pings Meta's /v21.0/{phone_number_id} endpoint to verify
     the credentials still work
   - Returns { connected: true, phone_info: {...} } on success,
     or { connected: false, error: "..." } with a useful message

2. POST handler passed verifyPhoneNumber() args in wrong order
   The function signature is verifyPhoneNumber(phoneNumberId, accessToken)
   but the POST was calling verifyPhoneNumber(access_token, phone_number_id).
   This meant saving a config called Meta API with the token as the URL
   and the phone number ID as the auth header — which would either fail
   silently or throw an opaque error. Fixed to pass args in the correct
   order.

3. While fixing, also improved POST error handling:
   - Meta API errors now surface the actual error message to the user
     instead of returning a generic 500
   - Sets status="connected" and connected_at on successful save
   - Uses .maybeSingle() instead of .single() for the existence check
     so it doesn't throw on first-time save

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:40:28 +03:00
Arnas Donauskas
9ccc53e24c
Merge pull request #7 from ArnasDon/fix/auth-provider-context
fix: share auth via React Context to prevent getSession() timeouts
2026-04-16 20:27:25 +03:00
Arnas Donauskas
186c9f0e1e
fix: share auth via React Context to prevent getSession() timeouts
Fixes the cascading warnings:
  [useAuth] getSession() timed out after 3s, clearing loading state
  [useAuth] getSession() timed out after 3s, clearing loading state
  [useAuth] getSession() timed out after 3s, clearing loading state

The warning appeared 3x per page load because 3 components each called
useAuth() independently (DashboardLayout, Sidebar, Header), each firing
their own supabase.auth.getSession() call. The Supabase client has
internal mutex locks around session reads, so concurrent calls from
multiple components queue up and timeout.

Fix: introduce an AuthProvider React Context that calls getSession()
ONCE for the whole subtree. useAuth() now reads from context instead
of making its own call.

Also fixes the Settings page stuck on loading: WhatsAppConfig,
TemplateManager, and TagManager were making their own getSession()
calls in fetch functions. They now read the user from useAuth()
context — no redundant session calls.

Changes:
- src/hooks/use-auth.ts -> use-auth.tsx (JSX for the Provider)
  Added AuthProvider component and converted useAuth() to a
  context consumer. Renamed because the file now exports JSX.
- src/app/(dashboard)/layout.tsx — wraps children in <AuthProvider>
  with a DashboardShell inner component that consumes the context.
- src/components/settings/whatsapp-config.tsx — uses useAuth() for
  user instead of calling getSession() itself.
- src/components/settings/template-manager.tsx — same.
- src/components/settings/tag-manager.tsx — same.
- Profile fetch is now fire-and-forget (doesn't block setLoading),
  so the UI renders as soon as the session is known.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:26:09 +03:00
Arnas Donauskas
7cc6a840d5
Merge pull request #6 from ArnasDon/fix/use-auth-getsession
fix: use getSession() in useAuth hook to stop 10s timeout
2026-04-16 20:11:58 +03:00
Arnas Donauskas
8f0f8afae3
fix: use getSession() in useAuth hook (fixes 10s timeout)
PR #5 switched client components to getSession() but missed
src/hooks/use-auth.ts. The auth hook was still calling
supabase.auth.getUser() which makes a network round-trip to
Supabase, and on some deployments the request was hanging long
enough to trigger the 10-second safety timeout:

  [browser] [useAuth] getUser() timed out after 10s, clearing loading state

This caused the dashboard to sit on the loading spinner for the
full 10 seconds on every page load.

Changes:
- Replaced getUser() with getSession() in init() — reads from
  localStorage synchronously (no network).
- Reduced safety timer from 10s to 3s. getSession() shouldn't take
  more than a few hundred ms; 3s is a reasonable ceiling.

Security note (same reasoning as PR #5):
- The middleware server-side STILL uses getUser() to verify the JWT
  against Supabase on every request, so unauthenticated users can't
  reach protected routes.
- RLS on the database is the actual authorization boundary.
- getSession() on the client is only used to decide what UI to render.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:10:49 +03:00
Arnas Donauskas
aaaddf0d5a
Merge pull request #5 from ArnasDon/perf/use-get-session-client-side
perf: use getSession() instead of getUser() in client components
2026-04-16 20:06:11 +03:00
Arnas Donauskas
2e7d7a6282
perf: use getSession() instead of getUser() in client components
Major performance improvement. Every client component that needed the
current user was calling supabase.auth.getUser(), which makes a
network round-trip to Supabase to validate the JWT. On a page that
fetches data conditional on the user, the flow was:

  Page mount
    -> getUser() [network ~150-400ms]
    -> query data [network ~150-400ms]
    -> render

That's 2 serial round-trips before the page renders.

supabase.auth.getSession() reads the session from localStorage with
NO network call. Using it in client code cuts per-page load by one
full round-trip (~150-400ms depending on region latency).

Replaced in 10 client files:
- src/app/(dashboard)/inbox/page.tsx
- src/app/(dashboard)/pipelines/page.tsx
- src/components/contacts/contact-detail-view.tsx
- src/components/contacts/contact-form.tsx
- src/components/contacts/import-modal.tsx
- src/components/inbox/contact-sidebar.tsx
- src/components/pipelines/deal-form.tsx
- src/components/pipelines/pipeline-settings.tsx
- src/components/settings/tag-manager.tsx
- src/components/settings/template-manager.tsx
- src/components/settings/whatsapp-config.tsx

NOT changed (correctly continue using getUser):
- src/middleware.ts (server-side, needs JWT validation)
- src/app/api/whatsapp/*/route.ts (server-side, security-critical)
- src/hooks/use-auth.ts (PR #4 handles this file)

Security note: getSession() trusts the client-stored session. For
API routes and middleware that make authorization decisions, we still
use getUser() which verifies with Supabase. Client components only
use the session's user ID to filter RLS-protected queries — the RLS
on the database is the actual authorization boundary, so a forged
user ID in the browser wouldn't bypass server-side policies.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:05:00 +03:00
Arnas Donauskas
928a67e12b
Merge pull request #4 from ArnasDon/fix/auth-hook-infinite-loading
Fix: dashboard stuck on infinite 'Loading...' spinner
2026-04-16 19:53:17 +03:00
Arnas Donauskas
7d7deb5e0b
fix: dashboard stuck on infinite 'Loading...' spinner
Three bugs in src/hooks/use-auth.ts that caused the dashboard layout's
auth loading state to never resolve:

1. fetchProfile queried profiles.id = userId, but profiles.id is the
   profile's own UUID. The correct column is profiles.user_id (FK to
   auth.users.id). This meant no profile was ever found and — combined
   with .single() throwing on 0 rows — could propagate errors.

2. No try/catch/finally around supabase.auth.getUser(). If getUser()
   threw (e.g. during auth lock contention), setLoading(false) never
   ran and the spinner showed forever with no visible error.

3. useEffect had supabase and fetchProfile in its deps. Since
   createClient() previously returned a new instance each call,
   both deps changed every render and the effect re-ran forever,
   subscribing to onAuthStateChange repeatedly and racing getUser()
   calls against the auth lock.

Changes:
- fetchProfile now uses .eq("user_id", userId) and .maybeSingle() so
  a missing profile returns { data: null } instead of throwing.
- init() wrapped in try/catch/finally — setLoading(false) ALWAYS runs.
- 10-second safety timer clears the loading state if anything hangs.
- useEffect runs once on mount (createClient is a singleton; deps
  intentionally empty).
- Logs Supabase error fields explicitly (message/details/hint/code)
  instead of the raw object which serializes as {}.
- AuthSessionMissingError (normal for logged-out users) is suppressed
  from error logs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 19:52:04 +03:00
Arnas Donauskas
582af0fbca
Merge pull request #3 from ArnasDon/fix/idempotent-migration
Fix: make Supabase migration idempotent
2026-04-16 19:48:49 +03:00
Arnas Donauskas
aee1621e44
fix: make migration idempotent
The Supabase GitHub integration re-runs migrations on every push.
The original 001_initial_schema.sql used plain CREATE TABLE without
IF NOT EXISTS, so it failed with "relation 'profiles' already exists"
after the first successful run.

Changes:
- CREATE TABLE -> CREATE TABLE IF NOT EXISTS (all 16 tables)
- CREATE INDEX -> CREATE INDEX IF NOT EXISTS
- DROP POLICY IF EXISTS before each CREATE POLICY
  (Postgres has no CREATE POLICY IF NOT EXISTS)
- DROP TRIGGER IF EXISTS before each CREATE TRIGGER
- Wrapped ALTER PUBLICATION in DO block that checks pg_publication_tables
- handle_new_user() trigger now uses SECURITY DEFINER, SET search_path,
  EXCEPTION handler, and owner=postgres to properly bypass RLS on the
  profiles INSERT during signup (fixes "Database error saving new user")

The migration is now safe to re-run any number of times.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 19:46:41 +03:00
Arnas Donauskas
b9647822ff
Merge pull request #2 from ArnasDon/fix/supabase-client-singleton
Fix auth lock contention and empty error logs (runtime errors)
2026-04-16 19:40:46 +03:00
Arnas Donauskas
31e1d83447
Merge pull request #1 from ArnasDon/fix/routing-remove-dashboard-prefix
Fix 404 errors: remove /dashboard prefix from nav links
2026-04-16 19:40:32 +03:00
Arnas Donauskas
4de6b7719e
fix: make Supabase browser client a singleton
Fixes two runtime errors:

1. "Lock 'lock:sb-xxx-auth-token' was released because another request
   stole it" — caused by multiple Supabase client instances competing
   for the same auth storage lock.

2. "Failed to fetch conversations: {}" — related to #1. When the auth
   lock is stolen mid-request, the session becomes temporarily
   unavailable and queries fail.

The codebase had 65 call sites invoking createClient() — each creating
a new browser client with its own auth listener. This change makes
createClient() cache and return a single shared instance.

Also improved error logging in conversation-list.tsx to show Supabase
error fields (message/details/hint/code) instead of an empty {}.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 19:39:24 +03:00
Arnas Donauskas
a4c966334a
fix: remove /dashboard prefix from nav links and redirects
Next.js route groups like (dashboard) don't appear in URLs, so the
sidebar, header, and dashboard page were linking to /dashboard/inbox,
/dashboard/contacts, etc. which don't exist and returned 404.

Fixed paths in:
- src/components/layout/sidebar.tsx (nav hrefs)
- src/components/layout/header.tsx (page title map)
- src/app/(dashboard)/dashboard/page.tsx (router.push calls)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 19:29:34 +03:00
Arnas Donauskas
658d070811
Initial scaffold: WhatsApp CRM with Next.js + Supabase
Complete WhatsApp CRM application built with Next.js 14 (App Router),
Supabase (PostgreSQL + Auth + Realtime), and shadcn/ui.

Features:
- Auth: Login, signup, forgot password with Supabase Auth
- Dashboard: Analytics overview with stats, recent activity
- Inbox: Real-time WhatsApp conversations with message threading
- Contacts: CRUD with tags, custom fields, notes, CSV import
- Broadcasts: 4-step wizard with audience selection, template sending
- Pipelines: Kanban board with deal management
- Settings: WhatsApp API config, templates, tags
- WhatsApp API: Webhook (verify + receive), send, broadcast, media proxy
- Database: 16 tables with RLS policies, indexes, realtime subscriptions
- Dark theme throughout with emerald accents

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:17:31 +03:00
Arnas Donauskas
de3deb32da
feat: initial commit 2026-04-16 17:53:27 +03:00
Arnas Donauskas
c70bfe0139
Initial commit from Create Next App 2026-04-16 17:52:39 +03:00