mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Fixes 8 real issues found during a thorough review of the broadcast
feature (PRs #21-#29).
Security
* Webhook POSTs had zero spoof protection. Anyone could forge a
status update and drift broadcast counts arbitrarily. Added
HMAC-SHA256 signature verification via the X-Hub-Signature-256
header (Meta's standard). New helper src/lib/whatsapp/webhook-
signature.ts with timing-safe comparison. Rollout fails open
with a warning when META_APP_SECRET is unset so existing
deploys don't break at rollout; set the env var to enforce.
Correctness
* CSV audience was completely broken — synthesized "csv-N" strings
as contact_id, which failed the UUID cast on insert. Every CSV
broadcast silently created zero recipients. Replaced with a
real find-or-create against the contacts table (batched lookup
by phone, single INSERT for missing rows, de-duplicates CSV
inputs). CSV contacts now end up as real contacts.rows with
real UUIDs flowing through the rest of the pipeline.
* Recipient insert errors were logged with console.error and the
loop marched on — broadcasts then ran with incomplete recipient
sets, webhook updates couldn't find rows, and aggregate counts
drifted. Now: on batch insert failure, flip the broadcast to
failed + failed_count=total + throw so handleSend's toast fires.
* Webhook status guard ordered "failed" above "replied", which
meant a webhook replay delivering "failed" for an already-
replied recipient would upgrade replied → failed. Split the
ladder: forward-only ordering across pending → sent →
delivered → read → replied; failed is a terminal side branch
that can only be reached from pending or sent (never from any
success state). Net effect: a message that was read can no
longer regress to failed on a replay.
* Template sync only pulled page 1 (ignored paging.next). Users
with >100 templates silently missed some, and broadcasts with
those names failed with Meta's "template not found". Now
follows paging.next with a 20-page safety cap, reports
truncated=true in the response if hit.
* Template sync reported success:true even when individual
upserts failed. Now accumulates per-template errors and
surfaces them via a separate toast.error in the UI — green
toast no longer hides silent drift.
Performance
* Migration 005: replace the broadcast_recipients aggregate
trigger installed in migration 003. Old version ran
COUNT(*) FILTER on every row change — O(n²) across a send
loop (10k recipients × 10k index scans). New version
increments / decrements by ±1 per status transition: O(1)
per change. Kept the old recompute_broadcast_counts() as a
callable safety-net in case of drift.
* List page poll kept running while the tab was hidden. Added
visibilitychange handler — polling pauses when hidden,
resumes (and does an immediate fetch) on refocus.
UX
* Step 3 (Personalize) let users skip past unmapped placeholders
→ broadcasts then sent to Meta with empty-string params.
Next is now disabled until every {{N}} has a value; an amber
warning lists the missing placeholders.
Documented accepted risks in the PR body.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
620 lines
18 KiB
TypeScript
620 lines
18 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { createClient } from '@supabase/supabase-js'
|
|
import { decrypt } from '@/lib/whatsapp/encryption'
|
|
import { getMediaUrl, downloadMedia } from '@/lib/whatsapp/meta-api'
|
|
import { normalizePhone, phonesMatch } from '@/lib/whatsapp/phone-utils'
|
|
import { verifyMetaWebhookSignature } from '@/lib/whatsapp/webhook-signature'
|
|
|
|
// Lazy-initialized to avoid build-time crash when env vars are missing
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
let _adminClient: any = null
|
|
function supabaseAdmin() {
|
|
if (!_adminClient) {
|
|
_adminClient = createClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
|
)
|
|
}
|
|
return _adminClient
|
|
}
|
|
|
|
interface WhatsAppMessage {
|
|
id: string
|
|
from: string
|
|
timestamp: string
|
|
type: string
|
|
text?: { body: string }
|
|
image?: { id: string; mime_type: string; caption?: string }
|
|
video?: { id: string; mime_type: string; caption?: string }
|
|
document?: { id: string; mime_type: string; filename?: string; caption?: string }
|
|
audio?: { id: string; mime_type: string }
|
|
sticker?: { id: string; mime_type: string }
|
|
location?: { latitude: number; longitude: number; name?: string; address?: string }
|
|
reaction?: { message_id: string; emoji: string }
|
|
}
|
|
|
|
interface WhatsAppWebhookEntry {
|
|
id: string
|
|
changes: Array<{
|
|
value: {
|
|
messaging_product: string
|
|
metadata: {
|
|
display_phone_number: string
|
|
phone_number_id: string
|
|
}
|
|
contacts?: Array<{
|
|
profile: { name: string }
|
|
wa_id: string
|
|
}>
|
|
messages?: WhatsAppMessage[]
|
|
statuses?: Array<{
|
|
id: string
|
|
status: string
|
|
timestamp: string
|
|
recipient_id: string
|
|
}>
|
|
}
|
|
field: string
|
|
}>
|
|
}
|
|
|
|
// GET - Webhook verification
|
|
export async function GET(request: Request) {
|
|
try {
|
|
const { searchParams } = new URL(request.url)
|
|
const mode = searchParams.get('hub.mode')
|
|
const challenge = searchParams.get('hub.challenge')
|
|
const verifyToken = searchParams.get('hub.verify_token')
|
|
|
|
if (mode !== 'subscribe' || !challenge || !verifyToken) {
|
|
return NextResponse.json(
|
|
{ error: 'Missing verification parameters' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
// Fetch all whatsapp configs to check verify tokens
|
|
const { data: configs, error: configError } = await supabaseAdmin()
|
|
.from('whatsapp_config')
|
|
.select('id, verify_token')
|
|
|
|
if (configError || !configs) {
|
|
console.error('Error fetching configs for verification:', configError)
|
|
return NextResponse.json(
|
|
{ error: 'Verification failed' },
|
|
{ status: 403 }
|
|
)
|
|
}
|
|
|
|
// Check if any config's verify_token matches
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const matched = configs.some((config: any) => {
|
|
if (!config.verify_token) return false
|
|
try {
|
|
const decrypted = decrypt(config.verify_token)
|
|
return decrypted === verifyToken
|
|
} catch {
|
|
return false
|
|
}
|
|
})
|
|
|
|
if (matched) {
|
|
// Return challenge as plain text
|
|
return new Response(challenge, {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'text/plain' },
|
|
})
|
|
}
|
|
|
|
return NextResponse.json(
|
|
{ error: 'Verification token mismatch' },
|
|
{ status: 403 }
|
|
)
|
|
} catch (error) {
|
|
console.error('Error in webhook GET verification:', error)
|
|
return NextResponse.json(
|
|
{ error: 'Internal server error' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
}
|
|
|
|
// POST - Receive messages
|
|
export async function POST(request: Request) {
|
|
// Read raw body first so we can HMAC-verify the exact bytes Meta
|
|
// signed. request.json() would re-encode and break the signature.
|
|
const rawBody = await request.text()
|
|
const signature = request.headers.get('x-hub-signature-256')
|
|
|
|
if (!verifyMetaWebhookSignature(rawBody, signature)) {
|
|
// 401 (not 200) — we want Meta's delivery dashboard to show failures
|
|
// loudly if a misconfiguration causes signatures to stop matching,
|
|
// rather than silently eating events.
|
|
console.warn('[webhook] rejected request with invalid signature')
|
|
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
|
|
}
|
|
|
|
let body: { entry?: WhatsAppWebhookEntry[] }
|
|
try {
|
|
body = JSON.parse(rawBody)
|
|
} catch {
|
|
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
|
}
|
|
|
|
// Process asynchronously so we can ack Meta within their timeout.
|
|
processWebhook(body).catch((error) => {
|
|
console.error('Error processing webhook:', error)
|
|
})
|
|
|
|
return NextResponse.json({ status: 'received' }, { status: 200 })
|
|
}
|
|
|
|
async function processWebhook(body: { entry?: WhatsAppWebhookEntry[] }) {
|
|
if (!body.entry) return
|
|
|
|
for (const entry of body.entry) {
|
|
for (const change of entry.changes) {
|
|
const value = change.value
|
|
|
|
// Handle status updates
|
|
if (value.statuses) {
|
|
for (const status of value.statuses) {
|
|
await handleStatusUpdate(status)
|
|
}
|
|
}
|
|
|
|
// Handle incoming messages
|
|
if (!value.messages || !value.contacts) continue
|
|
|
|
const phoneNumberId = value.metadata.phone_number_id
|
|
|
|
// Find user's config by phone_number_id
|
|
const { data: config, error: configError } = await supabaseAdmin()
|
|
.from('whatsapp_config')
|
|
.select('*')
|
|
.eq('phone_number_id', phoneNumberId)
|
|
.single()
|
|
|
|
if (configError || !config) {
|
|
console.error('No config found for phone_number_id:', phoneNumberId)
|
|
continue
|
|
}
|
|
|
|
const decryptedAccessToken = decrypt(config.access_token)
|
|
|
|
for (let i = 0; i < value.messages.length; i++) {
|
|
const message = value.messages[i]
|
|
const contact = value.contacts[i] || value.contacts[0]
|
|
|
|
await processMessage(
|
|
message,
|
|
contact,
|
|
config.user_id,
|
|
decryptedAccessToken
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The happy-path status ladder — pending → sent → delivered → read →
|
|
// replied. Webhook replays must never regress a recipient back down
|
|
// this ladder.
|
|
//
|
|
// `failed` is NOT on this ladder. It's a terminal side branch that is
|
|
// only valid from the early states (pending / sent) — once Meta has
|
|
// delivered or the user has read or replied, a later "failed" status
|
|
// event is a bug in Meta's pipeline or a spoof attempt and must be
|
|
// ignored.
|
|
const RECIPIENT_STATUS_LADDER = [
|
|
'pending',
|
|
'sent',
|
|
'delivered',
|
|
'read',
|
|
'replied',
|
|
] as const
|
|
|
|
function ladderLevel(s: string): number {
|
|
const idx = (RECIPIENT_STATUS_LADDER as readonly string[]).indexOf(s)
|
|
return idx < 0 ? -1 : idx
|
|
}
|
|
|
|
/**
|
|
* Can a recipient transition from `current` to `incoming`?
|
|
* - Along the ladder, only forward moves are allowed.
|
|
* - `failed` is accepted only from `pending` or `sent`; it's refused
|
|
* once the recipient has reached any of the success states.
|
|
*/
|
|
function isValidStatusTransition(current: string, incoming: string): boolean {
|
|
if (incoming === 'failed') {
|
|
return current === 'pending' || current === 'sent'
|
|
}
|
|
if (current === 'failed') {
|
|
return false // failed is terminal
|
|
}
|
|
const ci = ladderLevel(current)
|
|
const ii = ladderLevel(incoming)
|
|
if (ii < 0) return false // unknown incoming status
|
|
if (ci < 0) return true // unknown current — accept anything on the ladder
|
|
return ii > ci
|
|
}
|
|
|
|
async function handleStatusUpdate(status: {
|
|
id: string
|
|
status: string
|
|
timestamp: string
|
|
recipient_id: string
|
|
}) {
|
|
// 1) Mirror onto messages (legacy behavior) — Meta's status values
|
|
// already match the CHECK constraint on messages.status.
|
|
const { error: msgErr } = await supabaseAdmin()
|
|
.from('messages')
|
|
.update({ status: status.status })
|
|
.eq('message_id', status.id)
|
|
|
|
if (msgErr) {
|
|
console.error('Error updating message status:', msgErr)
|
|
}
|
|
|
|
// 2) Mirror onto broadcast_recipients via whatsapp_message_id
|
|
// (added in migration 003). The aggregate trigger on
|
|
// broadcast_recipients re-derives the parent broadcast's
|
|
// sent/delivered/read/failed counts automatically.
|
|
const tsIso = new Date(parseInt(status.timestamp) * 1000).toISOString()
|
|
|
|
const { data: recipient, error: recFetchErr } = await supabaseAdmin()
|
|
.from('broadcast_recipients')
|
|
.select('id, status')
|
|
.eq('whatsapp_message_id', status.id)
|
|
.maybeSingle()
|
|
|
|
if (recFetchErr) {
|
|
console.error('Error fetching broadcast recipient:', recFetchErr)
|
|
return
|
|
}
|
|
if (!recipient) return // message wasn't part of a broadcast — fine
|
|
|
|
// Guard transitions — forward-only on the success ladder, and
|
|
// `failed` only from pre-delivered states.
|
|
if (!isValidStatusTransition(recipient.status, status.status)) return
|
|
|
|
const update: Record<string, unknown> = { status: status.status }
|
|
if (status.status === 'sent' && !('sent_at' in update)) update.sent_at = tsIso
|
|
if (status.status === 'delivered') update.delivered_at = tsIso
|
|
if (status.status === 'read') update.read_at = tsIso
|
|
|
|
const { error: recUpdateErr } = await supabaseAdmin()
|
|
.from('broadcast_recipients')
|
|
.update(update)
|
|
.eq('id', recipient.id)
|
|
|
|
if (recUpdateErr) {
|
|
console.error('Error updating broadcast recipient status:', recUpdateErr)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* If an inbound message's sender is on a still-unreplied
|
|
* broadcast_recipients row, flip it to `replied` so the reply count
|
|
* advances on the parent broadcast.
|
|
*
|
|
* Runs on a best-effort basis — failures here must not break the
|
|
* main inbound-message flow, so errors are swallowed with a log.
|
|
*/
|
|
async function flagBroadcastReplyIfAny(userId: string, contactId: string) {
|
|
try {
|
|
// Most recent outbound broadcast that hasn't been replied to yet.
|
|
const { data: recs, error } = await supabaseAdmin()
|
|
.from('broadcast_recipients')
|
|
.select('id, status, broadcast_id, broadcasts!inner(user_id)')
|
|
.eq('contact_id', contactId)
|
|
.eq('broadcasts.user_id', userId)
|
|
.in('status', ['sent', 'delivered', 'read'])
|
|
.order('created_at', { ascending: false })
|
|
.limit(1)
|
|
|
|
if (error || !recs || recs.length === 0) return
|
|
|
|
const row = recs[0]
|
|
const { error: updErr } = await supabaseAdmin()
|
|
.from('broadcast_recipients')
|
|
.update({ status: 'replied', replied_at: new Date().toISOString() })
|
|
.eq('id', row.id)
|
|
|
|
if (updErr) {
|
|
console.error('Error marking broadcast recipient replied:', updErr)
|
|
}
|
|
} catch (err) {
|
|
console.error('flagBroadcastReplyIfAny failed:', err)
|
|
}
|
|
}
|
|
|
|
async function processMessage(
|
|
message: WhatsAppMessage,
|
|
contact: { profile: { name: string }; wa_id: string },
|
|
userId: string,
|
|
accessToken: string
|
|
) {
|
|
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 contactRecord = await findOrCreateContact(
|
|
userId,
|
|
senderPhone,
|
|
contactName
|
|
)
|
|
if (!contactRecord) return
|
|
|
|
// Find or create conversation
|
|
const conversation = await findOrCreateConversation(
|
|
userId,
|
|
contactRecord.id
|
|
)
|
|
if (!conversation) return
|
|
|
|
// 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,
|
|
// media_url, template_name, message_id, status, created_at
|
|
// `mediaType` is intentionally unused — the schema has no media_type
|
|
// column; the MIME type is only used to construct the proxy URL during
|
|
// parseMessageContent. Silence the unused-var warning:
|
|
void mediaType
|
|
|
|
// The messages.content_type CHECK constraint only allows:
|
|
// text, image, document, audio, video, location, template
|
|
// Map incoming WhatsApp types that aren't in that list to the closest
|
|
// allowed value so the INSERT doesn't fail with a constraint error.
|
|
const ALLOWED_CONTENT_TYPES = new Set([
|
|
'text', 'image', 'document', 'audio', 'video', 'location', 'template',
|
|
])
|
|
const contentType = ALLOWED_CONTENT_TYPES.has(message.type)
|
|
? message.type
|
|
: message.type === 'sticker'
|
|
? 'image' // stickers are images
|
|
: 'text' // reaction, unknown → text fallback
|
|
|
|
const { error: msgError } = await supabaseAdmin().from('messages').insert({
|
|
conversation_id: conversation.id,
|
|
sender_type: 'customer',
|
|
content_type: contentType,
|
|
content_text: contentText,
|
|
media_url: mediaUrl,
|
|
message_id: message.id,
|
|
status: 'delivered',
|
|
created_at: new Date(parseInt(message.timestamp) * 1000).toISOString(),
|
|
})
|
|
|
|
if (msgError) {
|
|
console.error('Error inserting message:', msgError)
|
|
return
|
|
}
|
|
|
|
// Update conversation
|
|
const { error: convError } = await supabaseAdmin()
|
|
.from('conversations')
|
|
.update({
|
|
last_message_text: contentText || `[${message.type}]`,
|
|
last_message_at: new Date().toISOString(),
|
|
unread_count: (conversation.unread_count || 0) + 1,
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
.eq('id', conversation.id)
|
|
|
|
if (convError) {
|
|
console.error('Error updating conversation:', convError)
|
|
}
|
|
|
|
// If this contact was a recent broadcast recipient, flag the reply
|
|
// so the broadcast's `replied_count` advances (via the aggregate
|
|
// trigger installed in migration 003).
|
|
await flagBroadcastReplyIfAny(userId, contactRecord.id)
|
|
}
|
|
|
|
async function parseMessageContent(
|
|
message: WhatsAppMessage,
|
|
accessToken: string
|
|
): Promise<{
|
|
contentText: string | null
|
|
mediaUrl: string | null
|
|
mediaType: string | null
|
|
}> {
|
|
// getMediaUrl signature is (mediaId, accessToken) — earlier code had
|
|
// the args swapped, so every verification hit an invalid Meta URL and
|
|
// fell through to the catch block, leaving mediaUrl as null. That's
|
|
// why images showed up as empty bubbles in the inbox.
|
|
const verifyAndBuildUrl = async (
|
|
mediaId: string
|
|
): Promise<string | null> => {
|
|
try {
|
|
await getMediaUrl({ mediaId, accessToken })
|
|
return `/api/whatsapp/media/${mediaId}`
|
|
} catch (error) {
|
|
console.error(
|
|
`Failed to verify media ${mediaId} with Meta:`,
|
|
error instanceof Error ? error.message : error
|
|
)
|
|
return null
|
|
}
|
|
}
|
|
|
|
switch (message.type) {
|
|
case 'text':
|
|
return {
|
|
contentText: message.text?.body || null,
|
|
mediaUrl: null,
|
|
mediaType: null,
|
|
}
|
|
|
|
case 'image':
|
|
if (message.image?.id) {
|
|
return {
|
|
contentText: message.image.caption || null,
|
|
mediaUrl: await verifyAndBuildUrl(message.image.id),
|
|
mediaType: message.image.mime_type,
|
|
}
|
|
}
|
|
return { contentText: null, mediaUrl: null, mediaType: null }
|
|
|
|
case 'video':
|
|
if (message.video?.id) {
|
|
return {
|
|
contentText: message.video.caption || null,
|
|
mediaUrl: await verifyAndBuildUrl(message.video.id),
|
|
mediaType: message.video.mime_type,
|
|
}
|
|
}
|
|
return { contentText: null, mediaUrl: null, mediaType: null }
|
|
|
|
case 'document':
|
|
if (message.document?.id) {
|
|
return {
|
|
contentText:
|
|
message.document.caption || message.document.filename || null,
|
|
mediaUrl: await verifyAndBuildUrl(message.document.id),
|
|
mediaType: message.document.mime_type,
|
|
}
|
|
}
|
|
return { contentText: null, mediaUrl: null, mediaType: null }
|
|
|
|
case 'audio':
|
|
if (message.audio?.id) {
|
|
return {
|
|
contentText: null,
|
|
mediaUrl: await verifyAndBuildUrl(message.audio.id),
|
|
mediaType: message.audio.mime_type,
|
|
}
|
|
}
|
|
return { contentText: null, mediaUrl: null, mediaType: null }
|
|
|
|
case 'sticker':
|
|
// Stickers are images under the hood. Treat them as such so the
|
|
// MessageBubble renders the <img>. The caller maps the DB
|
|
// content_type to 'image' for the CHECK constraint.
|
|
if (message.sticker?.id) {
|
|
return {
|
|
contentText: null,
|
|
mediaUrl: await verifyAndBuildUrl(message.sticker.id),
|
|
mediaType: message.sticker.mime_type,
|
|
}
|
|
}
|
|
return { contentText: null, mediaUrl: null, mediaType: null }
|
|
|
|
case 'location':
|
|
if (message.location) {
|
|
const loc = message.location
|
|
const locationText = [loc.name, loc.address, `${loc.latitude},${loc.longitude}`]
|
|
.filter(Boolean)
|
|
.join(' - ')
|
|
return {
|
|
contentText: locationText,
|
|
mediaUrl: null,
|
|
mediaType: null,
|
|
}
|
|
}
|
|
return { contentText: null, mediaUrl: null, mediaType: null }
|
|
|
|
case 'reaction':
|
|
return {
|
|
contentText: message.reaction?.emoji || null,
|
|
mediaUrl: null,
|
|
mediaType: null,
|
|
}
|
|
|
|
default:
|
|
return {
|
|
contentText: `[Unsupported message type: ${message.type}]`,
|
|
mediaUrl: null,
|
|
mediaType: null,
|
|
}
|
|
}
|
|
}
|
|
|
|
async function findOrCreateContact(
|
|
userId: string,
|
|
phone: string,
|
|
name: string
|
|
) {
|
|
// Look up existing contacts for this user
|
|
const { data: contacts, error: contactsError } = await supabaseAdmin()
|
|
.from('contacts')
|
|
.select('*')
|
|
.eq('user_id', userId)
|
|
|
|
if (contactsError) {
|
|
console.error('Error fetching contacts:', contactsError)
|
|
return null
|
|
}
|
|
|
|
// Use phonesMatch for flexible matching
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const existingContact = contacts?.find((c: any) => phonesMatch(c.phone, phone))
|
|
|
|
if (existingContact) {
|
|
// Update name if it changed
|
|
if (name && name !== existingContact.name) {
|
|
await supabaseAdmin()
|
|
.from('contacts')
|
|
.update({ name, updated_at: new Date().toISOString() })
|
|
.eq('id', existingContact.id)
|
|
}
|
|
return existingContact
|
|
}
|
|
|
|
// Create new contact
|
|
const { data: newContact, error: createError } = await supabaseAdmin()
|
|
.from('contacts')
|
|
.insert({
|
|
user_id: userId,
|
|
phone,
|
|
name: name || phone,
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (createError) {
|
|
console.error('Error creating contact:', createError)
|
|
return null
|
|
}
|
|
|
|
return newContact
|
|
}
|
|
|
|
async function findOrCreateConversation(userId: string, contactId: string) {
|
|
// Look for existing conversation
|
|
const { data: existing, error: findError } = await supabaseAdmin()
|
|
.from('conversations')
|
|
.select('*')
|
|
.eq('user_id', userId)
|
|
.eq('contact_id', contactId)
|
|
.single()
|
|
|
|
if (!findError && existing) {
|
|
return existing
|
|
}
|
|
|
|
// Create new conversation
|
|
const { data: newConv, error: createError } = await supabaseAdmin()
|
|
.from('conversations')
|
|
.insert({
|
|
user_id: userId,
|
|
contact_id: contactId,
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (createError) {
|
|
console.error('Error creating conversation:', createError)
|
|
return null
|
|
}
|
|
|
|
return newConv
|
|
}
|