mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Adds three WhatsApp-standard affordances to the inbox: per-message emoji reactions, reply-with-quote, and copy-text. Bubbles stay pure presenters — actions layer on by composition (MessageActions wraps MessageBubble). Hover toolbar on desktop, long-press (onContextMenu) on touch. Data model (migration 009): - messages.reply_to_message_id (self-FK, ON DELETE SET NULL) so replies survive a deleted parent - message_reactions table — one row per (message, actor) with a unique constraint, conversation_id denormalised so Supabase Realtime can filter without a join - RLS scoped via conversation ownership; service-role bypass for the webhook API: - /api/whatsapp/send accepts reply_to_message_id, resolves the parent's Meta message_id and forwards it as context.message_id - /api/whatsapp/react (new) sends Meta reaction payloads and mirrors the upsert/delete into message_reactions - Webhook stops storing inbound reactions as fake text messages and instead upserts/deletes message_reactions; inbound replies (context.id) resolve to internal UUID and populate reply_to_message_id UI: - MessageActions hover toolbar with inline 6-emoji Popover (static set; no emoji-picker library) - ReplyQuote shared between the in-bubble quote and the composer chip - MessageReactions pill row with click-to-toggle - Thread subscribes to message_reactions filtered by conversation_id; optimistic upsert with rollback on failure Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
179 lines
5.3 KiB
TypeScript
179 lines
5.3 KiB
TypeScript
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 },
|
|
);
|
|
}
|
|
}
|