mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #100 from ArnasDon/feature/chat-actions
feat(inbox): reactions, reply-to, and copy on chat messages
This commit is contained in:
commit
a1348ebcbd
178
src/app/api/whatsapp/react/route.ts
Normal file
178
src/app/api/whatsapp/react/route.ts
Normal file
@ -0,0 +1,178 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { sendReactionMessage } from '@/lib/whatsapp/meta-api';
|
||||
import { decrypt } from '@/lib/whatsapp/encryption';
|
||||
import { sanitizePhoneForMeta } from '@/lib/whatsapp/phone-utils';
|
||||
import {
|
||||
checkRateLimit,
|
||||
rateLimitResponse,
|
||||
RATE_LIMITS,
|
||||
} from '@/lib/rate-limit';
|
||||
|
||||
/**
|
||||
* POST /api/whatsapp/react
|
||||
*
|
||||
* Body: { message_id: <internal UUID>, emoji: <single emoji or "" to remove> }
|
||||
*
|
||||
* Sends the reaction to Meta and mirrors it into `message_reactions`
|
||||
* (delete on empty emoji). Customer-side reactions are handled by the
|
||||
* webhook — this route only writes `actor_type = 'agent'` rows.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const limit = checkRateLimit(`react:${user.id}`, RATE_LIMITS.react);
|
||||
if (!limit.success) {
|
||||
return rateLimitResponse(limit);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { message_id, emoji } = body as {
|
||||
message_id?: string;
|
||||
emoji?: string;
|
||||
};
|
||||
|
||||
if (!message_id || typeof emoji !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'message_id and emoji are required' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve target message + its conversation; verify ownership.
|
||||
const { data: targetMessage, error: msgError } = await supabase
|
||||
.from('messages')
|
||||
.select('id, message_id, conversation_id')
|
||||
.eq('id', message_id)
|
||||
.maybeSingle();
|
||||
|
||||
if (msgError || !targetMessage) {
|
||||
return NextResponse.json({ error: 'Message not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!targetMessage.message_id) {
|
||||
// No Meta ID yet — usually a sending/failed agent message. We can't
|
||||
// tell Meta to react to a message it never received.
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot react to a message that has not been sent to WhatsApp' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { data: conversation, error: convError } = await supabase
|
||||
.from('conversations')
|
||||
.select('id, user_id, contact:contacts(phone)')
|
||||
.eq('id', targetMessage.conversation_id)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (convError || !conversation) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Conversation not found' },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const contact = Array.isArray(conversation.contact)
|
||||
? conversation.contact[0]
|
||||
: conversation.contact;
|
||||
if (!contact?.phone) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Contact phone number not found' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// WhatsApp config + access token
|
||||
const { data: config, error: configError } = await supabase
|
||||
.from('whatsapp_config')
|
||||
.select('phone_number_id, access_token')
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
|
||||
if (configError || !config) {
|
||||
return NextResponse.json(
|
||||
{ error: 'WhatsApp not configured.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const accessToken = decrypt(config.access_token);
|
||||
const sanitizedPhone = sanitizePhoneForMeta(contact.phone);
|
||||
|
||||
try {
|
||||
await sendReactionMessage({
|
||||
phoneNumberId: config.phone_number_id,
|
||||
accessToken,
|
||||
to: sanitizedPhone,
|
||||
targetMessageId: targetMessage.message_id,
|
||||
emoji,
|
||||
});
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Unknown Meta API error';
|
||||
console.error('[whatsapp/react] Meta send failed:', message);
|
||||
return NextResponse.json(
|
||||
{ error: `Meta API error: ${message}` },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
// Mirror into DB. Empty emoji = removal.
|
||||
if (emoji === '') {
|
||||
const { error: delError } = await supabase
|
||||
.from('message_reactions')
|
||||
.delete()
|
||||
.eq('message_id', targetMessage.id)
|
||||
.eq('actor_type', 'agent')
|
||||
.eq('actor_id', user.id);
|
||||
|
||||
if (delError) {
|
||||
console.error('[whatsapp/react] DB delete failed:', delError.message);
|
||||
return NextResponse.json(
|
||||
{ error: 'Reaction sent to Meta but DB delete failed' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Upsert. The unique constraint (message_id, actor_type, actor_id)
|
||||
// lets us swap emoji in a single statement.
|
||||
const { error: upsertError } = await supabase.from('message_reactions').upsert(
|
||||
{
|
||||
message_id: targetMessage.id,
|
||||
conversation_id: targetMessage.conversation_id,
|
||||
actor_type: 'agent',
|
||||
actor_id: user.id,
|
||||
emoji,
|
||||
},
|
||||
{ onConflict: 'message_id,actor_type,actor_id' },
|
||||
);
|
||||
|
||||
if (upsertError) {
|
||||
console.error('[whatsapp/react] DB upsert failed:', upsertError.message);
|
||||
return NextResponse.json(
|
||||
{ error: 'Reaction sent to Meta but DB upsert failed' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error in WhatsApp react POST:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to react to message' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -45,6 +45,7 @@ export async function POST(request: Request) {
|
||||
media_url,
|
||||
template_name,
|
||||
template_params,
|
||||
reply_to_message_id,
|
||||
} = body
|
||||
|
||||
if (!conversation_id || !message_type) {
|
||||
@ -136,6 +137,37 @@ export async function POST(request: Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// Resolve the reply target (if any) to its Meta message_id, which is
|
||||
// what `context.message_id` on the outgoing Meta payload needs. The
|
||||
// parent must belong to this same conversation — otherwise a caller
|
||||
// could quote messages they can't see by guessing UUIDs.
|
||||
let contextMessageId: string | undefined
|
||||
if (reply_to_message_id) {
|
||||
const { data: parent, error: parentError } = await supabase
|
||||
.from('messages')
|
||||
.select('message_id, conversation_id')
|
||||
.eq('id', reply_to_message_id)
|
||||
.eq('conversation_id', conversation_id)
|
||||
.maybeSingle()
|
||||
|
||||
if (parentError || !parent) {
|
||||
return NextResponse.json(
|
||||
{ error: 'reply_to_message_id not found in this conversation' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
if (!parent.message_id) {
|
||||
// Parent never reached Meta (still in 'sending' or 'failed') — we
|
||||
// can't quote it on WhatsApp. Send without context rather than
|
||||
// dropping the message entirely.
|
||||
console.warn(
|
||||
'[whatsapp/send] reply target has no Meta message_id; sending without context'
|
||||
)
|
||||
} else {
|
||||
contextMessageId = parent.message_id
|
||||
}
|
||||
}
|
||||
|
||||
// Send via Meta API — retry with phone-number variants if Meta rejects
|
||||
// with "recipient not in allowed list" (common in sandbox / when a
|
||||
// number was registered with/without a trunk 0). If an alternate
|
||||
@ -152,6 +184,7 @@ export async function POST(request: Request) {
|
||||
to: phone,
|
||||
templateName: template_name,
|
||||
params: template_params || [],
|
||||
contextMessageId,
|
||||
})
|
||||
return result.messageId
|
||||
}
|
||||
@ -160,6 +193,7 @@ export async function POST(request: Request) {
|
||||
accessToken,
|
||||
to: phone,
|
||||
text: content_text,
|
||||
contextMessageId,
|
||||
})
|
||||
return result.messageId
|
||||
}
|
||||
@ -225,6 +259,7 @@ export async function POST(request: Request) {
|
||||
template_name: template_name || null,
|
||||
message_id: waMessageId,
|
||||
status: 'sent',
|
||||
reply_to_message_id: reply_to_message_id || null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
@ -32,6 +32,8 @@ interface WhatsAppMessage {
|
||||
sticker?: { id: string; mime_type: string }
|
||||
location?: { latitude: number; longitude: number; name?: string; address?: string }
|
||||
reaction?: { message_id: string; emoji: string }
|
||||
/** Present when the customer swipe-replies to one of our messages. */
|
||||
context?: { id: string }
|
||||
}
|
||||
|
||||
interface WhatsAppWebhookEntry {
|
||||
@ -351,6 +353,87 @@ async function flagBroadcastReplyIfAny(userId: string, contactId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Meta-side message_id into the matching internal UUID, scoped
|
||||
* to one conversation. Returns null when we never received the parent
|
||||
* (e.g. a swipe-reply to a message older than this CRM install).
|
||||
*/
|
||||
async function lookupInternalIdByMetaId(
|
||||
metaId: string,
|
||||
conversationId: string
|
||||
): Promise<string | null> {
|
||||
const { data, error } = await supabaseAdmin()
|
||||
.from('messages')
|
||||
.select('id')
|
||||
.eq('message_id', metaId)
|
||||
.eq('conversation_id', conversationId)
|
||||
.maybeSingle()
|
||||
if (error) {
|
||||
console.error('[webhook] lookupInternalIdByMetaId failed:', error.message)
|
||||
return null
|
||||
}
|
||||
return data?.id ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist an inbound reaction. WhatsApp reactions are not new messages —
|
||||
* they're per-(target, actor) state. We upsert / delete on
|
||||
* `message_reactions`, never write a row into `messages`.
|
||||
*
|
||||
* Best-effort: a missing parent (we never received it) is logged and
|
||||
* skipped so the webhook still acks 200 to Meta.
|
||||
*/
|
||||
async function handleReaction(
|
||||
message: WhatsAppMessage,
|
||||
conversationId: string,
|
||||
contactId: string
|
||||
) {
|
||||
const reaction = message.reaction
|
||||
if (!reaction?.message_id) return
|
||||
|
||||
const targetInternalId = await lookupInternalIdByMetaId(
|
||||
reaction.message_id,
|
||||
conversationId
|
||||
)
|
||||
if (!targetInternalId) {
|
||||
console.warn(
|
||||
'[webhook] reaction target message not found; skipping',
|
||||
reaction.message_id
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Empty emoji = removal (per Meta's Cloud API spec).
|
||||
if (!reaction.emoji) {
|
||||
const { error: delError } = await supabaseAdmin()
|
||||
.from('message_reactions')
|
||||
.delete()
|
||||
.eq('message_id', targetInternalId)
|
||||
.eq('actor_type', 'customer')
|
||||
.eq('actor_id', contactId)
|
||||
if (delError) {
|
||||
console.error('[webhook] reaction delete failed:', delError.message)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const { error: upsertError } = await supabaseAdmin()
|
||||
.from('message_reactions')
|
||||
.upsert(
|
||||
{
|
||||
message_id: targetInternalId,
|
||||
conversation_id: conversationId,
|
||||
actor_type: 'customer',
|
||||
actor_id: contactId,
|
||||
emoji: reaction.emoji,
|
||||
},
|
||||
{ onConflict: 'message_id,actor_type,actor_id' }
|
||||
)
|
||||
if (upsertError) {
|
||||
console.error('[webhook] reaction upsert failed:', upsertError.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function processMessage(
|
||||
message: WhatsAppMessage,
|
||||
contact: { profile: { name: string }; wa_id: string },
|
||||
@ -360,12 +443,6 @@ async function processMessage(
|
||||
const senderPhone = normalizePhone(message.from)
|
||||
const contactName = contact.profile.name
|
||||
|
||||
// Parse message content based on type
|
||||
const { contentText, mediaUrl, mediaType } = await parseMessageContent(
|
||||
message,
|
||||
accessToken
|
||||
)
|
||||
|
||||
// Find or create contact
|
||||
const contactOutcome = await findOrCreateContact(
|
||||
userId,
|
||||
@ -382,6 +459,36 @@ async function processMessage(
|
||||
)
|
||||
if (!conversation) return
|
||||
|
||||
// Reactions short-circuit here — they aren't messages. We never insert
|
||||
// into `messages`, never bump unread_count, never update last_message_text.
|
||||
// Done before parseMessageContent so the media-URL fetch is skipped.
|
||||
if (message.type === 'reaction') {
|
||||
await handleReaction(message, conversation.id, contactRecord.id)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse message content based on type
|
||||
const { contentText, mediaUrl, mediaType } = await parseMessageContent(
|
||||
message,
|
||||
accessToken
|
||||
)
|
||||
|
||||
// Resolve swipe-reply context if present. A missing parent is fine —
|
||||
// we just store NULL and the UI renders the message without a quote.
|
||||
let replyToInternalId: string | null = null
|
||||
if (message.context?.id) {
|
||||
replyToInternalId = await lookupInternalIdByMetaId(
|
||||
message.context.id,
|
||||
conversation.id
|
||||
)
|
||||
if (!replyToInternalId) {
|
||||
console.warn(
|
||||
'[webhook] reply context parent not found:',
|
||||
message.context.id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert message — field names MUST match the messages table schema
|
||||
// (see supabase/migrations/001_initial_schema.sql):
|
||||
// conversation_id, sender_type, content_type, content_text,
|
||||
@ -424,6 +531,7 @@ async function processMessage(
|
||||
message_id: message.id,
|
||||
status: 'delivered',
|
||||
created_at: new Date(parseInt(message.timestamp) * 1000).toISOString(),
|
||||
reply_to_message_id: replyToInternalId,
|
||||
})
|
||||
|
||||
if (msgError) {
|
||||
|
||||
135
src/components/inbox/message-actions.tsx
Normal file
135
src/components/inbox/message-actions.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { CornerUpLeft, Copy, SmilePlus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import type { Message } from "@/types";
|
||||
|
||||
// WhatsApp's own quick-reaction bar starts with these six. Picking the same
|
||||
// set keeps the affordance familiar without pulling in a 300KB emoji library.
|
||||
const QUICK_EMOJIS = ["👍", "❤️", "😂", "😮", "😢", "🙏"];
|
||||
|
||||
interface MessageActionsProps {
|
||||
message: Message;
|
||||
onReply: () => void;
|
||||
onReact: (emoji: string) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover/long-press toolbar wrapper around a `<MessageBubble>`. The bubble
|
||||
* itself stays a pure presenter — this component owns the action surface so
|
||||
* the bubble's render path is unaffected when the toolbar isn't visible.
|
||||
*/
|
||||
export function MessageActions({
|
||||
message,
|
||||
onReply,
|
||||
onReact,
|
||||
children,
|
||||
}: MessageActionsProps) {
|
||||
// Touch devices have no hover. Long-press fires `contextmenu`; we capture
|
||||
// it, suppress the native menu, and pin the toolbar open until the user
|
||||
// interacts elsewhere.
|
||||
const [touchOpen, setTouchOpen] = useState(false);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const isAgent =
|
||||
message.sender_type === "agent" || message.sender_type === "bot";
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setTouchOpen(true);
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
const text = message.content_text ?? "";
|
||||
if (!text) {
|
||||
toast.error("Nothing to copy");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success("Copied");
|
||||
} catch {
|
||||
toast.error("Copy failed");
|
||||
}
|
||||
setTouchOpen(false);
|
||||
};
|
||||
|
||||
const handlePickEmoji = (emoji: string) => {
|
||||
onReact(emoji);
|
||||
setPickerOpen(false);
|
||||
setTouchOpen(false);
|
||||
};
|
||||
|
||||
const handleReply = () => {
|
||||
onReply();
|
||||
setTouchOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group/actions relative"
|
||||
onContextMenu={handleContextMenu}
|
||||
onBlur={() => setTouchOpen(false)}
|
||||
>
|
||||
{children}
|
||||
<div
|
||||
data-touch-open={touchOpen || pickerOpen ? "true" : undefined}
|
||||
className={cn(
|
||||
"absolute -top-3 z-10 flex h-7 items-center gap-0.5 rounded-full border border-slate-700 bg-slate-900/95 px-1 shadow-md backdrop-blur-sm transition-opacity",
|
||||
"opacity-0 group-hover/actions:opacity-100 group-focus-within/actions:opacity-100",
|
||||
"data-[touch-open=true]:opacity-100",
|
||||
isAgent ? "right-3" : "left-3",
|
||||
)}
|
||||
>
|
||||
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<PopoverTrigger
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full text-slate-300 hover:bg-slate-700 hover:text-white"
|
||||
aria-label="React"
|
||||
>
|
||||
<SmilePlus className="h-3.5 w-3.5" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="flex w-auto flex-row gap-1 p-1.5"
|
||||
sideOffset={6}
|
||||
>
|
||||
{QUICK_EMOJIS.map((e) => (
|
||||
<button
|
||||
key={e}
|
||||
type="button"
|
||||
onClick={() => handlePickEmoji(e)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-lg leading-none transition-transform hover:scale-125 hover:bg-slate-700"
|
||||
aria-label={`React with ${e}`}
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReply}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full text-slate-300 hover:bg-slate-700 hover:text-white"
|
||||
aria-label="Reply"
|
||||
>
|
||||
<CornerUpLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full text-slate-300 hover:bg-slate-700 hover:text-white"
|
||||
aria-label="Copy"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Message } from "@/types";
|
||||
import type { Message, MessageReaction } from "@/types";
|
||||
import {
|
||||
Clock,
|
||||
Check,
|
||||
@ -14,9 +14,16 @@ import {
|
||||
ImageOff,
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { ReplyQuote } from "./reply-quote";
|
||||
import { MessageReactions } from "./message-reactions";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
/** Pre-computed quote info for messages that reply to another. */
|
||||
reply?: { authorLabel: string; preview: string } | null;
|
||||
reactions?: MessageReaction[];
|
||||
currentUserId?: string;
|
||||
onToggleReaction?: (emoji: string) => void;
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: Message["status"] }) {
|
||||
@ -214,7 +221,13 @@ function MessageContent({ message }: { message: Message }) {
|
||||
}
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
export function MessageBubble({
|
||||
message,
|
||||
reply,
|
||||
reactions,
|
||||
currentUserId,
|
||||
onToggleReaction,
|
||||
}: MessageBubbleProps) {
|
||||
const isAgent = message.sender_type === "agent" || message.sender_type === "bot";
|
||||
const time = format(new Date(message.created_at), "HH:mm");
|
||||
|
||||
@ -224,23 +237,40 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative max-w-[75%] rounded-2xl px-3 py-2",
|
||||
isAgent
|
||||
? "rounded-br-md bg-violet-600 text-white"
|
||||
: "rounded-bl-md bg-slate-800 text-slate-100"
|
||||
"flex max-w-[75%] flex-col",
|
||||
isAgent ? "items-end" : "items-start",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative rounded-2xl px-3 py-2",
|
||||
isAgent
|
||||
? "rounded-br-md bg-violet-600 text-white"
|
||||
: "rounded-bl-md bg-slate-800 text-slate-100",
|
||||
)}
|
||||
>
|
||||
{reply && (
|
||||
<ReplyQuote authorLabel={reply.authorLabel} preview={reply.preview} />
|
||||
)}
|
||||
<MessageContent message={message} />
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 flex items-center gap-1",
|
||||
isAgent ? "justify-end" : "justify-start"
|
||||
isAgent ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
<span className="text-[10px] text-white/60">{time}</span>
|
||||
{isAgent && <StatusIcon status={message.status} />}
|
||||
</div>
|
||||
</div>
|
||||
{reactions && reactions.length > 0 && onToggleReaction && (
|
||||
<MessageReactions
|
||||
reactions={reactions}
|
||||
currentUserId={currentUserId}
|
||||
onToggle={onToggleReaction}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,12 +4,22 @@ import { useState, useRef, useCallback, KeyboardEvent } from "react";
|
||||
import { Send, LayoutTemplate } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ReplyQuote } from "./reply-quote";
|
||||
|
||||
interface ReplyDraft {
|
||||
/** Internal UUID of the message being replied to — sent back through onSend. */
|
||||
id: string;
|
||||
authorLabel: string;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
interface MessageComposerProps {
|
||||
conversationId: string;
|
||||
sessionExpired: boolean;
|
||||
onSend: (text: string) => void;
|
||||
onSend: (text: string, replyToId?: string) => void;
|
||||
onOpenTemplates: () => void;
|
||||
replyTo?: ReplyDraft | null;
|
||||
onClearReply?: () => void;
|
||||
}
|
||||
|
||||
export function MessageComposer({
|
||||
@ -17,6 +27,8 @@ export function MessageComposer({
|
||||
sessionExpired,
|
||||
onSend,
|
||||
onOpenTemplates,
|
||||
replyTo,
|
||||
onClearReply,
|
||||
}: MessageComposerProps) {
|
||||
const [text, setText] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
@ -36,7 +48,7 @@ export function MessageComposer({
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
onSend(trimmed);
|
||||
onSend(trimmed, replyTo?.id);
|
||||
setText("");
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
@ -44,7 +56,7 @@ export function MessageComposer({
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [text, sending, sessionExpired, onSend]);
|
||||
}, [text, sending, sessionExpired, onSend, replyTo?.id]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
@ -66,6 +78,15 @@ export function MessageComposer({
|
||||
|
||||
return (
|
||||
<div className="border-t border-slate-800 bg-slate-900 p-3">
|
||||
{replyTo && (
|
||||
<div className="mb-2">
|
||||
<ReplyQuote
|
||||
authorLabel={replyTo.authorLabel}
|
||||
preview={replyTo.preview}
|
||||
onDismiss={onClearReply}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{sessionExpired && (
|
||||
<div className="mb-2 flex items-center justify-between rounded-lg bg-amber-500/10 px-3 py-2">
|
||||
<p className="text-xs text-amber-400">
|
||||
|
||||
75
src/components/inbox/message-reactions.tsx
Normal file
75
src/components/inbox/message-reactions.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MessageReaction } from "@/types";
|
||||
|
||||
interface MessageReactionsProps {
|
||||
reactions: MessageReaction[];
|
||||
currentUserId: string | undefined;
|
||||
/** Toggle the agent's reaction. If the agent already has this emoji →
|
||||
* caller should send empty to remove; otherwise swap/add. */
|
||||
onToggle: (emoji: string) => void;
|
||||
}
|
||||
|
||||
interface ReactionGroup {
|
||||
emoji: string;
|
||||
count: number;
|
||||
byCurrentUser: boolean;
|
||||
}
|
||||
|
||||
function groupReactions(
|
||||
reactions: MessageReaction[],
|
||||
currentUserId: string | undefined,
|
||||
): ReactionGroup[] {
|
||||
const map = new Map<string, ReactionGroup>();
|
||||
for (const r of reactions) {
|
||||
const existing = map.get(r.emoji);
|
||||
const isMine =
|
||||
r.actor_type === "agent" &&
|
||||
!!currentUserId &&
|
||||
r.actor_id === currentUserId;
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
existing.byCurrentUser = existing.byCurrentUser || isMine;
|
||||
} else {
|
||||
map.set(r.emoji, { emoji: r.emoji, count: 1, byCurrentUser: isMine });
|
||||
}
|
||||
}
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
export function MessageReactions({
|
||||
reactions,
|
||||
currentUserId,
|
||||
onToggle,
|
||||
}: MessageReactionsProps) {
|
||||
const groups = useMemo(
|
||||
() => groupReactions(reactions, currentUserId),
|
||||
[reactions, currentUserId],
|
||||
);
|
||||
|
||||
if (groups.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{groups.map((g) => (
|
||||
<button
|
||||
key={g.emoji}
|
||||
type="button"
|
||||
onClick={() => onToggle(g.emoji)}
|
||||
aria-pressed={g.byCurrentUser}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[11px] leading-none transition-colors",
|
||||
g.byCurrentUser
|
||||
? "border-violet-500/60 bg-violet-500/15 text-violet-200 hover:bg-violet-500/25"
|
||||
: "border-slate-700 bg-slate-800/80 text-slate-200 hover:bg-slate-700",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm leading-none">{g.emoji}</span>
|
||||
{g.count > 1 && <span>{g.count}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -7,6 +7,7 @@ import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
Conversation,
|
||||
Message,
|
||||
MessageReaction,
|
||||
Contact,
|
||||
ConversationStatus,
|
||||
MessageTemplate,
|
||||
@ -31,10 +32,18 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { MessageBubble } from "./message-bubble";
|
||||
import { MessageActions } from "./message-actions";
|
||||
import { MessageComposer } from "./message-composer";
|
||||
import { TemplatePicker } from "./template-picker";
|
||||
import { buildReplyPreview } from "./reply-quote";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ReplyDraft {
|
||||
id: string;
|
||||
authorLabel: string;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
function renderTemplateBody(body: string, params: string[]): string {
|
||||
return body.replace(/\{\{(\d+)\}\}/g, (_, raw) => {
|
||||
const idx = Number(raw) - 1;
|
||||
@ -109,6 +118,8 @@ export function MessageThread({
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [templateModalOpen, setTemplateModalOpen] = useState(false);
|
||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||
const [reactions, setReactions] = useState<MessageReaction[]>([]);
|
||||
const [replyTo, setReplyTo] = useState<ReplyDraft | null>(null);
|
||||
|
||||
// Profiles are bounded by RLS to rows the current user is allowed to
|
||||
// see — today that's just the current user, but the dropdown keeps the
|
||||
@ -210,6 +221,89 @@ export function MessageThread({
|
||||
};
|
||||
}, [conversationId]);
|
||||
|
||||
// Reactions: fetch + realtime per conversation. Subscribing here (not at
|
||||
// the page level) keeps the channel scoped to the visible conversation,
|
||||
// matching the message fetch effect above and avoiding cross-conversation
|
||||
// chatter on a busy inbox.
|
||||
useEffect(() => {
|
||||
if (!conversationId) {
|
||||
setReactions([]);
|
||||
return;
|
||||
}
|
||||
const supabase = createClient();
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
const { data, error } = await supabase
|
||||
.from("message_reactions")
|
||||
.select("*")
|
||||
.eq("conversation_id", conversationId);
|
||||
if (cancelled) return;
|
||||
if (error) {
|
||||
console.error("Failed to fetch reactions:", error);
|
||||
return;
|
||||
}
|
||||
setReactions((data as MessageReaction[]) ?? []);
|
||||
})();
|
||||
|
||||
const channel = supabase
|
||||
.channel(`reactions:${conversationId}`)
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{
|
||||
event: "INSERT",
|
||||
schema: "public",
|
||||
table: "message_reactions",
|
||||
filter: `conversation_id=eq.${conversationId}`,
|
||||
},
|
||||
(payload) => {
|
||||
const row = payload.new as MessageReaction;
|
||||
setReactions((prev) =>
|
||||
prev.some((r) => r.id === row.id) ? prev : [...prev, row],
|
||||
);
|
||||
},
|
||||
)
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{
|
||||
event: "UPDATE",
|
||||
schema: "public",
|
||||
table: "message_reactions",
|
||||
filter: `conversation_id=eq.${conversationId}`,
|
||||
},
|
||||
(payload) => {
|
||||
const row = payload.new as MessageReaction;
|
||||
setReactions((prev) => prev.map((r) => (r.id === row.id ? row : r)));
|
||||
},
|
||||
)
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{
|
||||
event: "DELETE",
|
||||
schema: "public",
|
||||
table: "message_reactions",
|
||||
filter: `conversation_id=eq.${conversationId}`,
|
||||
},
|
||||
(payload) => {
|
||||
const old = payload.old as Partial<MessageReaction>;
|
||||
if (!old?.id) return;
|
||||
setReactions((prev) => prev.filter((r) => r.id !== old.id));
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [conversationId]);
|
||||
|
||||
// Clear any in-progress reply draft when the active conversation changes —
|
||||
// a quote pulled from conversation A shouldn't bleed into conversation B.
|
||||
useEffect(() => {
|
||||
setReplyTo(null);
|
||||
}, [conversationId]);
|
||||
|
||||
// Reset the server-side unread_count to 0 whenever an unread count
|
||||
// surfaces on the active conversation — covers both (a) opening a
|
||||
// conversation that had unread messages and (b) new messages arriving
|
||||
@ -240,7 +334,7 @@ export function MessageThread({
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (text: string) => {
|
||||
async (text: string, replyToId?: string) => {
|
||||
if (!conversation) return;
|
||||
|
||||
const tempId = `temp-${Date.now()}`;
|
||||
@ -254,8 +348,10 @@ export function MessageThread({
|
||||
content_text: text,
|
||||
status: "sending",
|
||||
created_at: new Date().toISOString(),
|
||||
reply_to_message_id: replyToId,
|
||||
};
|
||||
onNewMessage(optimisticMsg);
|
||||
setReplyTo(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/whatsapp/send", {
|
||||
@ -265,6 +361,7 @@ export function MessageThread({
|
||||
conversation_id: conversation.id,
|
||||
message_type: "text",
|
||||
content_text: text,
|
||||
reply_to_message_id: replyToId,
|
||||
}),
|
||||
});
|
||||
|
||||
@ -365,6 +462,132 @@ export function MessageThread({
|
||||
[conversation, onNewMessage, onUpdateMessage],
|
||||
);
|
||||
|
||||
// Build a quick id → Message map so reply quotes can be rendered without
|
||||
// an extra fetch — the thread already holds the full conversation.
|
||||
const messagesById = useMemo(() => {
|
||||
const map = new Map<string, Message>();
|
||||
for (const m of messages) map.set(m.id, m);
|
||||
return map;
|
||||
}, [messages]);
|
||||
|
||||
// Bucket reactions by their target message_id for O(1) per-bubble lookup.
|
||||
const reactionsByMessageId = useMemo(() => {
|
||||
const map = new Map<string, MessageReaction[]>();
|
||||
for (const r of reactions) {
|
||||
const bucket = map.get(r.message_id);
|
||||
if (bucket) bucket.push(r);
|
||||
else map.set(r.message_id, [r]);
|
||||
}
|
||||
return map;
|
||||
}, [reactions]);
|
||||
|
||||
const contactDisplayName = contact?.name || contact?.phone || "Customer";
|
||||
|
||||
// Author label for a quoted message: "You" when we sent the parent,
|
||||
// contact name when the customer sent it.
|
||||
const authorLabelFor = useCallback(
|
||||
(m: Message): string => {
|
||||
const isAgentMsg =
|
||||
m.sender_type === "agent" || m.sender_type === "bot";
|
||||
return isAgentMsg ? "You" : contactDisplayName;
|
||||
},
|
||||
[contactDisplayName],
|
||||
);
|
||||
|
||||
const handleStartReply = useCallback(
|
||||
(msg: Message) => {
|
||||
setReplyTo({
|
||||
id: msg.id,
|
||||
authorLabel: authorLabelFor(msg),
|
||||
preview: buildReplyPreview(msg),
|
||||
});
|
||||
},
|
||||
[authorLabelFor],
|
||||
);
|
||||
|
||||
// Toggle (called from a reaction pill click). If the user already reacted
|
||||
// with `emoji` → send "" to remove. Otherwise send `emoji` to add/swap.
|
||||
const handleToggleReaction = useCallback(
|
||||
async (messageId: string, emoji: string) => {
|
||||
if (!user?.id) return;
|
||||
const current = reactions.find(
|
||||
(r) =>
|
||||
r.message_id === messageId &&
|
||||
r.actor_type === "agent" &&
|
||||
r.actor_id === user.id,
|
||||
);
|
||||
const nextEmoji = current?.emoji === emoji ? "" : emoji;
|
||||
await postReaction(messageId, nextEmoji);
|
||||
},
|
||||
// postReaction is defined below; React only needs the closure refs.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[reactions, user?.id],
|
||||
);
|
||||
|
||||
// Add or swap to a specific emoji (called from the emoji picker bar).
|
||||
const handleAddReaction = useCallback(
|
||||
async (messageId: string, emoji: string) => {
|
||||
if (!emoji) return;
|
||||
await postReaction(messageId, emoji);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const postReaction = useCallback(
|
||||
async (messageId: string, emoji: string) => {
|
||||
if (!user?.id || !conversation) return;
|
||||
|
||||
// Snapshot for rollback. Optimistically update local state so the
|
||||
// pill flips immediately.
|
||||
const prev = reactions;
|
||||
const own = prev.find(
|
||||
(r) =>
|
||||
r.message_id === messageId &&
|
||||
r.actor_type === "agent" &&
|
||||
r.actor_id === user.id,
|
||||
);
|
||||
|
||||
let next: MessageReaction[];
|
||||
if (emoji === "") {
|
||||
next = prev.filter((r) => r !== own);
|
||||
} else if (own) {
|
||||
next = prev.map((r) => (r === own ? { ...own, emoji } : r));
|
||||
} else {
|
||||
next = [
|
||||
...prev,
|
||||
{
|
||||
id: `temp-${Date.now()}`,
|
||||
message_id: messageId,
|
||||
conversation_id: conversation.id,
|
||||
actor_type: "agent",
|
||||
actor_id: user.id,
|
||||
emoji,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
}
|
||||
setReactions(next);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/whatsapp/react", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message_id: messageId, emoji }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
throw new Error(payload?.error || `HTTP ${res.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : "network error";
|
||||
toast.error(`Reaction failed: ${reason}`);
|
||||
setReactions(prev);
|
||||
}
|
||||
},
|
||||
[reactions, conversation, user?.id],
|
||||
);
|
||||
|
||||
const handleAssignChange = useCallback(
|
||||
async (agentId: string | null) => {
|
||||
if (!conversation) return;
|
||||
@ -560,9 +783,36 @@ export function MessageThread({
|
||||
</div>
|
||||
{/* Messages */}
|
||||
<div className="space-y-2">
|
||||
{group.messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
{group.messages.map((msg) => {
|
||||
const parent = msg.reply_to_message_id
|
||||
? messagesById.get(msg.reply_to_message_id)
|
||||
: null;
|
||||
const reply = parent
|
||||
? {
|
||||
authorLabel: authorLabelFor(parent),
|
||||
preview: buildReplyPreview(parent),
|
||||
}
|
||||
: null;
|
||||
const msgReactions = reactionsByMessageId.get(msg.id);
|
||||
return (
|
||||
<MessageActions
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
onReply={() => handleStartReply(msg)}
|
||||
onReact={(emoji) => handleAddReaction(msg.id, emoji)}
|
||||
>
|
||||
<MessageBubble
|
||||
message={msg}
|
||||
reply={reply}
|
||||
reactions={msgReactions}
|
||||
currentUserId={user?.id}
|
||||
onToggleReaction={(emoji) =>
|
||||
handleToggleReaction(msg.id, emoji)
|
||||
}
|
||||
/>
|
||||
</MessageActions>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -576,6 +826,8 @@ export function MessageThread({
|
||||
sessionExpired={sessionInfo.expired}
|
||||
onSend={handleSend}
|
||||
onOpenTemplates={handleOpenTemplates}
|
||||
replyTo={replyTo}
|
||||
onClearReply={() => setReplyTo(null)}
|
||||
/>
|
||||
|
||||
<TemplatePicker
|
||||
|
||||
73
src/components/inbox/reply-quote.tsx
Normal file
73
src/components/inbox/reply-quote.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Message } from "@/types";
|
||||
|
||||
interface ReplyQuoteProps {
|
||||
/** Sender label of the quoted message: "You" for our own messages,
|
||||
* contact name for customer-sent messages. Caller resolves this — the
|
||||
* quote component doesn't see the parent Message. */
|
||||
authorLabel: string;
|
||||
/** Compact text preview. Falls back to a placeholder for media types. */
|
||||
preview: string;
|
||||
/** Present → renders the composer-chip variant with an X button. Absent →
|
||||
* renders the embedded-in-bubble variant. */
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export function ReplyQuote({
|
||||
authorLabel,
|
||||
preview,
|
||||
onDismiss,
|
||||
}: ReplyQuoteProps) {
|
||||
const isChip = !!onDismiss;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-2 border-l-2 border-violet-400 px-2 py-1",
|
||||
isChip
|
||||
? "rounded-md bg-slate-800/80"
|
||||
: "mb-1.5 rounded-md bg-black/20",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[11px] font-medium text-violet-300">
|
||||
{authorLabel}
|
||||
</div>
|
||||
<div className="truncate text-xs text-slate-200/80">{preview}</div>
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
aria-label="Cancel reply"
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-slate-400 hover:bg-slate-700 hover:text-white"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Build the one-line preview text shown inside a reply quote. */
|
||||
export function buildReplyPreview(message: Message): string {
|
||||
if (message.content_text) return message.content_text;
|
||||
switch (message.content_type) {
|
||||
case "image":
|
||||
return "[Image]";
|
||||
case "video":
|
||||
return "[Video]";
|
||||
case "audio":
|
||||
return "[Audio]";
|
||||
case "document":
|
||||
return "[Document]";
|
||||
case "location":
|
||||
return "[Location]";
|
||||
case "template":
|
||||
return "[Template]";
|
||||
default:
|
||||
return "[Message]";
|
||||
}
|
||||
}
|
||||
@ -121,6 +121,10 @@ export const RATE_LIMITS = {
|
||||
* broadcast is one call; this caps the rate at which a single user
|
||||
* can launch campaigns, not the messages inside one. */
|
||||
broadcast: { limit: 5, windowMs: 60_000 },
|
||||
/** Reaction add/swap/remove. More permissive than send — users
|
||||
* fidget with reactions and a single "swap" is actually two calls
|
||||
* (remove + add) under the hood. */
|
||||
react: { limit: 120, windowMs: 60_000 },
|
||||
} as const;
|
||||
|
||||
/** Test-only helper. Clears the in-memory state so unit tests don't
|
||||
|
||||
@ -74,6 +74,9 @@ export interface SendTextMessageArgs {
|
||||
accessToken: string
|
||||
to: string
|
||||
text: string
|
||||
/** Meta's message_id of the message being replied to. Adds a `context` field
|
||||
* so WhatsApp renders the new message as a reply with a quote preview. */
|
||||
contextMessageId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@ -83,21 +86,25 @@ export interface SendTextMessageArgs {
|
||||
export async function sendTextMessage(
|
||||
args: SendTextMessageArgs
|
||||
): Promise<MetaSendResult> {
|
||||
const { phoneNumberId, accessToken, to, text } = args
|
||||
const { phoneNumberId, accessToken, to, text, contextMessageId } = args
|
||||
const url = `${META_API_BASE}/${phoneNumberId}/messages`
|
||||
const body: Record<string, unknown> = {
|
||||
messaging_product: 'whatsapp',
|
||||
recipient_type: 'individual',
|
||||
to,
|
||||
type: 'text',
|
||||
text: { body: text },
|
||||
}
|
||||
if (contextMessageId) {
|
||||
body.context = { message_id: contextMessageId }
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messaging_product: 'whatsapp',
|
||||
recipient_type: 'individual',
|
||||
to,
|
||||
type: 'text',
|
||||
text: { body: text },
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!response.ok) {
|
||||
await throwMetaError(response, `Meta API error: ${response.status}`)
|
||||
@ -113,6 +120,8 @@ export interface SendTemplateMessageArgs {
|
||||
templateName: string
|
||||
language?: string
|
||||
params?: string[]
|
||||
/** Meta's message_id of the message being replied to. */
|
||||
contextMessageId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@ -129,6 +138,7 @@ export async function sendTemplateMessage(
|
||||
templateName,
|
||||
language = 'en_US',
|
||||
params,
|
||||
contextMessageId,
|
||||
} = args
|
||||
const url = `${META_API_BASE}/${phoneNumberId}/messages`
|
||||
|
||||
@ -146,6 +156,55 @@ export async function sendTemplateMessage(
|
||||
]
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
messaging_product: 'whatsapp',
|
||||
recipient_type: 'individual',
|
||||
to,
|
||||
type: 'template',
|
||||
template,
|
||||
}
|
||||
if (contextMessageId) {
|
||||
body.context = { message_id: contextMessageId }
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!response.ok) {
|
||||
await throwMetaError(response, `Meta API error: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
return { messageId: data.messages[0].id }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Reactions
|
||||
// ============================================================
|
||||
|
||||
export interface SendReactionMessageArgs {
|
||||
phoneNumberId: string
|
||||
accessToken: string
|
||||
to: string
|
||||
/** Meta's message_id of the message being reacted to. */
|
||||
targetMessageId: string
|
||||
/** Single emoji, or empty string to remove an existing reaction. */
|
||||
emoji: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reaction (or removal) to a previously-exchanged message.
|
||||
* Empty `emoji` removes the reaction per Meta's spec.
|
||||
*/
|
||||
export async function sendReactionMessage(
|
||||
args: SendReactionMessageArgs
|
||||
): Promise<MetaSendResult> {
|
||||
const { phoneNumberId, accessToken, to, targetMessageId, emoji } = args
|
||||
const url = `${META_API_BASE}/${phoneNumberId}/messages`
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@ -156,8 +215,8 @@ export async function sendTemplateMessage(
|
||||
messaging_product: 'whatsapp',
|
||||
recipient_type: 'individual',
|
||||
to,
|
||||
type: 'template',
|
||||
template,
|
||||
type: 'reaction',
|
||||
reaction: { message_id: targetMessageId, emoji },
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
|
||||
@ -90,6 +90,19 @@ export interface Message {
|
||||
message_id?: string;
|
||||
status: MessageStatus;
|
||||
created_at: string;
|
||||
reply_to_message_id?: string;
|
||||
}
|
||||
|
||||
export type ReactionActor = 'customer' | 'agent';
|
||||
|
||||
export interface MessageReaction {
|
||||
id: string;
|
||||
message_id: string;
|
||||
conversation_id: string;
|
||||
actor_type: ReactionActor;
|
||||
actor_id?: string;
|
||||
emoji: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface WhatsAppConfig {
|
||||
|
||||
114
supabase/migrations/009_message_actions.sql
Normal file
114
supabase/migrations/009_message_actions.sql
Normal file
@ -0,0 +1,114 @@
|
||||
-- ============================================================
|
||||
-- Chat actions: reply linkage + reactions
|
||||
--
|
||||
-- Adds two things the chat UI now needs:
|
||||
--
|
||||
-- 1. `messages.reply_to_message_id` — a self-FK so a message can
|
||||
-- point at the message it replies to. We use the internal UUID
|
||||
-- (not Meta's message_id text), because Meta IDs aren't unique
|
||||
-- across phone numbers and can't be FK-constrained. The webhook
|
||||
-- resolves `context.id` from Meta into our internal UUID before
|
||||
-- writing. ON DELETE SET NULL — a deleted parent must not nuke
|
||||
-- its replies (which today never happens, but the constraint
|
||||
-- should match intent).
|
||||
--
|
||||
-- 2. `message_reactions` table — one row per (message, actor).
|
||||
-- Reactions arrive concurrently from agents (UI) and customers
|
||||
-- (webhook). A row-level uniqueness constraint enforces "one
|
||||
-- reaction per actor per message" without read-modify-write
|
||||
-- games on a JSONB column.
|
||||
--
|
||||
-- `conversation_id` is denormalised purely so Supabase Realtime
|
||||
-- can filter on it with a plain `eq`. Realtime can't join.
|
||||
--
|
||||
-- Idempotent — safe to run multiple times.
|
||||
-- ============================================================
|
||||
|
||||
-- ============================================================
|
||||
-- 1. Reply linkage on messages
|
||||
-- ============================================================
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN IF NOT EXISTS reply_to_message_id UUID
|
||||
REFERENCES messages(id) ON DELETE SET NULL;
|
||||
|
||||
-- Partial index — most messages aren't replies, so skip nulls.
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_reply_to
|
||||
ON messages(reply_to_message_id)
|
||||
WHERE reply_to_message_id IS NOT NULL;
|
||||
|
||||
-- ============================================================
|
||||
-- 2. message_reactions
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS message_reactions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
actor_type TEXT NOT NULL CHECK (actor_type IN ('customer', 'agent')),
|
||||
actor_id UUID,
|
||||
emoji TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (message_id, actor_type, actor_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_message_reactions_conversation
|
||||
ON message_reactions(conversation_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_message_reactions_message
|
||||
ON message_reactions(message_id);
|
||||
|
||||
ALTER TABLE message_reactions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "Users see reactions on their conversations" ON message_reactions;
|
||||
CREATE POLICY "Users see reactions on their conversations" ON message_reactions FOR SELECT
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM conversations c
|
||||
WHERE c.id = message_reactions.conversation_id
|
||||
AND c.user_id = auth.uid()
|
||||
));
|
||||
|
||||
DROP POLICY IF EXISTS "Users insert reactions on their conversations" ON message_reactions;
|
||||
CREATE POLICY "Users insert reactions on their conversations" ON message_reactions FOR INSERT
|
||||
WITH CHECK (EXISTS (
|
||||
SELECT 1 FROM conversations c
|
||||
WHERE c.id = message_reactions.conversation_id
|
||||
AND c.user_id = auth.uid()
|
||||
));
|
||||
|
||||
-- Agents may remove their own reactions. Customer reactions are managed
|
||||
-- by the webhook (service-role bypass), not the UI.
|
||||
DROP POLICY IF EXISTS "Users delete their own agent reactions" ON message_reactions;
|
||||
CREATE POLICY "Users delete their own agent reactions" ON message_reactions FOR DELETE
|
||||
USING (
|
||||
actor_type = 'agent'
|
||||
AND actor_id = auth.uid()
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM conversations c
|
||||
WHERE c.id = message_reactions.conversation_id
|
||||
AND c.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Agents may swap their own reaction emoji (UPDATE path is also used by
|
||||
-- the upsert in /api/whatsapp/react).
|
||||
DROP POLICY IF EXISTS "Users update their own agent reactions" ON message_reactions;
|
||||
CREATE POLICY "Users update their own agent reactions" ON message_reactions FOR UPDATE
|
||||
USING (
|
||||
actor_type = 'agent'
|
||||
AND actor_id = auth.uid()
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM conversations c
|
||||
WHERE c.id = message_reactions.conversation_id
|
||||
AND c.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Realtime — let the thread subscribe filtered by conversation_id.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_publication_tables
|
||||
WHERE pubname = 'supabase_realtime' AND tablename = 'message_reactions'
|
||||
) THEN
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE message_reactions;
|
||||
END IF;
|
||||
END $$;
|
||||
Loading…
Reference in New Issue
Block a user