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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>