mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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>
376 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|