Commit Graph

8 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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