wacrm/src/app/api/whatsapp/react/route.ts
Arnas Donauskas 339b1fe94f
feat(inbox): reactions, reply-to, and copy on chat messages
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>
2026-05-19 16:17:26 +03:00

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 },
);
}
}