wacrm/src/components/inbox/message-thread.tsx
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

376 lines
12 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { createClient } from "@/lib/supabase/client";
import { cn } from "@/lib/utils";
import type { Conversation, Message, Contact, ConversationStatus } from "@/types";
import {
MessageSquare,
ChevronDown,
UserPlus,
Clock,
} from "lucide-react";
import { format, isToday, isYesterday, differenceInHours } from "date-fns";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import { MessageBubble } from "./message-bubble";
import { MessageComposer } from "./message-composer";
import { toast } from "sonner";
interface MessageThreadProps {
conversation: Conversation | null;
contact: Contact | null;
messages: Message[];
onMessagesLoaded: (messages: Message[]) => void;
onNewMessage: (message: Message) => void;
onUpdateMessage: (id: string, updates: Partial<Message>) => void;
onStatusChange: (conversationId: string, status: ConversationStatus) => void;
}
function formatDateSeparator(dateStr: string): string {
const date = new Date(dateStr);
if (isToday(date)) return "Today";
if (isYesterday(date)) return "Yesterday";
return format(date, "MMMM d, yyyy");
}
function groupMessagesByDate(messages: Message[]) {
const groups: { date: string; messages: Message[] }[] = [];
let currentDate = "";
for (const msg of messages) {
const day = format(new Date(msg.created_at), "yyyy-MM-dd");
if (day !== currentDate) {
currentDate = day;
groups.push({ date: msg.created_at, messages: [msg] });
} else {
groups[groups.length - 1].messages.push(msg);
}
}
return groups;
}
const STATUS_OPTIONS: { label: string; value: ConversationStatus; color: string }[] = [
{ label: "Open", value: "open", color: "text-emerald-400" },
{ label: "Pending", value: "pending", color: "text-amber-400" },
{ label: "Closed", value: "closed", color: "text-slate-400" },
];
export function MessageThread({
conversation,
contact,
messages,
onMessagesLoaded,
onNewMessage,
onUpdateMessage,
onStatusChange,
}: MessageThreadProps) {
const [loading, setLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const [templateModalOpen, setTemplateModalOpen] = useState(false);
// 24-hour session timer
const sessionInfo = useMemo(() => {
if (!messages.length) return { expired: false, remaining: "" };
// Find last customer message
const lastCustomerMsg = [...messages]
.reverse()
.find((m) => m.sender_type === "customer");
if (!lastCustomerMsg) return { expired: true, remaining: "No customer messages" };
const hoursSince = differenceInHours(new Date(), new Date(lastCustomerMsg.created_at));
const expired = hoursSince >= 24;
if (expired) {
return { expired: true, remaining: "Expired" };
}
const hoursLeft = 24 - hoursSince;
const remaining =
hoursLeft >= 1
? `${Math.floor(hoursLeft)}h remaining`
: `${Math.floor(hoursLeft * 60)}m remaining`;
return { expired, remaining };
}, [messages]);
// Store latest callback in a ref so fetchMessages doesn't need to
// depend on `onMessagesLoaded` — otherwise parent re-renders cause
// fetchMessages to change → useEffect re-fires → refetch → realtime
// UPDATE on conversations.unread_count → parent re-renders → LOOP.
const onMessagesLoadedRef = useRef(onMessagesLoaded);
onMessagesLoadedRef.current = onMessagesLoaded;
const conversationId = conversation?.id;
const hasUnread = (conversation?.unread_count ?? 0) > 0;
useEffect(() => {
if (!conversationId) return;
const supabase = createClient();
let cancelled = false;
(async () => {
setLoading(true);
const { data, error } = await supabase
.from("messages")
.select("*")
.eq("conversation_id", conversationId)
.order("created_at", { ascending: true });
if (cancelled) return;
if (error) {
console.error("Failed to fetch messages:", error);
} else {
onMessagesLoadedRef.current(data ?? []);
}
// Only issue the unread_count reset when there's actually something
// to reset. Unconditional updates fire a realtime UPDATE event every
// time, which — combined with the parent's conversation-event handler
// — used to retrigger this effect in a loop.
if (hasUnread) {
await supabase
.from("conversations")
.update({ unread_count: 0 })
.eq("id", conversationId);
}
if (!cancelled) setLoading(false);
})();
return () => {
cancelled = true;
};
// Re-fetch only when the selected conversation changes. `hasUnread`
// is used inside but is a boolean derived from conversation; the
// conversationId dep is the real trigger.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conversationId]);
// Auto-scroll to bottom on new messages
useEffect(() => {
if (scrollRef.current) {
const el = scrollRef.current;
el.scrollTop = el.scrollHeight;
}
}, [messages]);
const handleSend = useCallback(
async (text: string) => {
if (!conversation) return;
const tempId = `temp-${Date.now()}`;
// Optimistic update — shows the message immediately with "sending" status
const optimisticMsg: Message = {
id: tempId,
conversation_id: conversation.id,
sender_type: "agent",
content_type: "text",
content_text: text,
status: "sending",
created_at: new Date().toISOString(),
};
onNewMessage(optimisticMsg);
try {
const res = await fetch("/api/whatsapp/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
conversation_id: conversation.id,
message_type: "text",
content_text: text,
}),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
const reason = payload?.error || `HTTP ${res.status}`;
console.error("Failed to send message:", reason);
toast.error(`Failed to send: ${reason}`);
// Mark the optimistic bubble as failed so the user sees what happened
onUpdateMessage(tempId, { status: "failed" });
return;
}
// Success — the realtime INSERT event will replace the temp bubble
// with the real DB row. If realtime hasn't arrived yet, at least
// flip status to 'sent' so the UI stops showing "sending".
onUpdateMessage(tempId, { status: "sent" });
} catch (err) {
console.error("Failed to send message:", err);
const reason = err instanceof Error ? err.message : "network error";
toast.error(`Failed to send: ${reason}`);
onUpdateMessage(tempId, { status: "failed" });
}
},
[conversation, onNewMessage, onUpdateMessage]
);
const handleStatusChange = useCallback(
async (status: ConversationStatus) => {
if (!conversation) return;
const supabase = createClient();
await supabase
.from("conversations")
.update({ status })
.eq("id", conversation.id);
onStatusChange(conversation.id, status);
},
[conversation, onStatusChange]
);
const handleOpenTemplates = useCallback(() => {
setTemplateModalOpen(true);
// Template modal implementation would go here
}, []);
// Empty state
if (!conversation || !contact) {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-slate-950">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-800">
<MessageSquare className="h-8 w-8 text-slate-600" />
</div>
<h3 className="mt-4 text-sm font-medium text-slate-400">
Select a conversation
</h3>
<p className="mt-1 text-xs text-slate-600">
Choose a conversation from the left to start messaging
</p>
</div>
);
}
const displayName = contact.name || contact.phone;
const messageGroups = groupMessagesByDate(messages);
const currentStatus = STATUS_OPTIONS.find(
(s) => s.value === conversation.status
);
return (
<div className="flex flex-1 flex-col bg-slate-950">
{/* Header */}
<div className="flex items-center justify-between border-b border-slate-800 bg-slate-900 px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-slate-700 text-sm font-medium text-white">
{displayName.charAt(0).toUpperCase()}
</div>
<div>
<h2 className="text-sm font-semibold text-white">{displayName}</h2>
<p className="text-xs text-slate-400">{contact.phone}</p>
</div>
{/* Session timer badge */}
<Badge
variant="outline"
className={cn(
"ml-2 gap-1 border-slate-700 text-[10px]",
sessionInfo.expired ? "text-red-400" : "text-emerald-400"
)}
>
<Clock className="h-3 w-3" />
{sessionInfo.remaining}
</Badge>
</div>
<div className="flex items-center gap-2">
{/* Status dropdown */}
<DropdownMenu>
<DropdownMenuTrigger className={cn(
"inline-flex items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-slate-800",
currentStatus?.color ?? "text-slate-400"
)}>
{currentStatus?.label ?? "Status"}
<ChevronDown className="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="border-slate-700 bg-slate-800"
>
{STATUS_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.value}
onClick={() => handleStatusChange(opt.value)}
className={cn("text-sm", opt.color)}
>
{opt.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Assign button */}
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 text-xs text-slate-400 hover:text-white"
>
<UserPlus className="h-3 w-3" />
Assign
</Button>
</div>
</div>
{/* Messages Area */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4">
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
</div>
) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<p className="text-sm text-slate-500">No messages yet</p>
<p className="text-xs text-slate-600">
Send a template to start the conversation
</p>
</div>
) : (
<div className="space-y-4">
{messageGroups.map((group) => (
<div key={group.date}>
{/* Date separator */}
<div className="mb-4 flex items-center justify-center">
<span className="rounded-full bg-slate-800 px-3 py-1 text-[10px] font-medium text-slate-400">
{formatDateSeparator(group.date)}
</span>
</div>
{/* Messages */}
<div className="space-y-2">
{group.messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
</div>
</div>
))}
</div>
)}
</div>
{/* Composer */}
<MessageComposer
conversationId={conversation.id}
sessionExpired={sessionInfo.expired}
onSend={handleSend}
onOpenTemplates={handleOpenTemplates}
/>
</div>
);
}