import crypto from "node:crypto"; import { NextRequest, NextResponse } from "next/server"; import { ConversationStatus, DeliveryStatus, MessageDirection, MessageType, OptInStatus } from "@prisma/client"; import { prisma } from "@/lib/prisma"; import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit"; import { recalculateCampaignTotals } from "@/lib/campaign-utils"; import { consumeRateLimit, getRateLimitHeaders } from "@/lib/rate-limit"; type JsonRecord = Record; type NormalizedEvent = { eventType: string; tenantId: string; channelId?: string; channelPhoneNumberId?: string; providerEventId?: string | null; payload: JsonRecord; rawDirection: "inbound" | "status" | "other"; inbound?: { from: string; body?: string; contactName?: string; messageId?: string | null; }; status?: { messageId?: string | null; deliveryStatus: "sent" | "delivered" | "read" | "failed" | string; failureReason?: string; }; }; type WebhookProcessStatus = "processed" | "failed" | "skipped"; function getString(value: unknown) { if (typeof value === "string") { return value.trim(); } return ""; } function normalizePhone(value: string) { return value.replace(/\D/g, ""); } function resolveNumber(raw: string | undefined, fallback: number) { const parsed = Number(raw?.trim()); if (!Number.isInteger(parsed) || parsed <= 0) { return fallback; } return parsed; } function getWebhookIp(req: NextRequest) { const forwarded = req.headers.get("x-forwarded-for"); return (forwarded ? forwarded.split(",")[0]?.trim() : null) || req.headers.get("x-real-ip") || "unknown"; } function buildWebhookEventHash(event: NormalizedEvent, resolvedChannelId: string) { const inboundBody = event.inbound?.body?.trim(); const statusDelivery = event.status?.deliveryStatus?.trim(); const peerPhone = event.inbound?.from || event.channelPhoneNumberId || event.providerEventId || null; const payload = { tenantId: event.tenantId, eventType: event.eventType, channelId: resolvedChannelId, providerEventId: event.providerEventId?.trim() || null, direction: event.rawDirection, messageId: event.status?.messageId || event.inbound?.messageId || null, peerPhone, bodyHash: inboundBody ? crypto.createHash("sha256").update(inboundBody).digest("hex") : null, deliveryStatus: statusDelivery || null, failureReason: event.status?.failureReason?.trim() || null }; return crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex"); } async function isDuplicateWebhookEvent(tenantId: string, channelId: string, eventHash: string) { const existing = await prisma.webhookEvent.findFirst({ where: { tenantId, channelId, eventHash, processStatus: { in: ["processed", "skipped"] } }, select: { id: true } }); return Boolean(existing); } async function writeWebhookEvent(params: { tenantId: string; channelId: string | null; event: NormalizedEvent; processStatus: WebhookProcessStatus; eventHash: string; failedReason?: string; }) { const now = new Date(); await prisma.webhookEvent.create({ data: { tenantId: params.tenantId, channelId: params.channelId ?? null, eventType: params.event.eventType, providerEventId: params.event.providerEventId, payloadJson: JSON.stringify(params.event.payload), processStatus: params.processStatus, failedReason: params.failedReason, eventHash: params.eventHash, processedAt: params.processStatus !== "failed" ? now : null } }); } function getStatusDelivery(status: string): DeliveryStatus { const normalized = status.toLowerCase(); if (normalized === "sent") { return DeliveryStatus.SENT; } if (normalized === "delivered") { return DeliveryStatus.DELIVERED; } if (normalized === "read") { return DeliveryStatus.READ; } if (normalized === "accepted" || normalized === "accepted_by_sms" || normalized === "pending") { return DeliveryStatus.SENT; } if (normalized === "undelivered") { return DeliveryStatus.FAILED; } if (normalized === "failed") { return DeliveryStatus.FAILED; } return DeliveryStatus.QUEUED; } function shouldAdvanceDeliveryStatus(currentStatus: DeliveryStatus, nextStatus: DeliveryStatus) { const score: Record = { [DeliveryStatus.QUEUED]: 1, [DeliveryStatus.SENT]: 2, [DeliveryStatus.DELIVERED]: 3, [DeliveryStatus.READ]: 4, [DeliveryStatus.FAILED]: 0 }; if (nextStatus === DeliveryStatus.READ) { return DeliveryStatus.READ; } if (nextStatus === DeliveryStatus.DELIVERED && currentStatus === DeliveryStatus.READ) { return DeliveryStatus.READ; } if (nextStatus === DeliveryStatus.FAILED && (currentStatus === DeliveryStatus.DELIVERED || currentStatus === DeliveryStatus.READ)) { return currentStatus; } if (nextStatus === DeliveryStatus.FAILED && (currentStatus === DeliveryStatus.FAILED || currentStatus === DeliveryStatus.SENT || currentStatus === DeliveryStatus.QUEUED)) { return nextStatus; } if (currentStatus === DeliveryStatus.FAILED && nextStatus !== DeliveryStatus.DELIVERED) { return currentStatus; } if (score[nextStatus] > score[currentStatus]) { return nextStatus; } return currentStatus; } function verifyMetaSignature(rawBody: string, signatureHeader: string | null) { const secret = process.env.WHATSAPP_WEBHOOK_SECRET?.trim(); if (!secret) { if (process.env.NODE_ENV === "production") { return false; } return true; } if (!signatureHeader) { return false; } const split = signatureHeader.split("="); if (split.length !== 2 || split[0] !== "sha256") { return false; } const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); const provided = split[1]; if (provided.length !== expected.length) { return false; } const expectedBytes = Buffer.from(expected, "hex"); const providedBytes = Buffer.from(provided, "hex"); return crypto.timingSafeEqual(expectedBytes, providedBytes); } function parseMetaPayload(payload: JsonRecord) { const tenantId = getString(payload.tenantId) || getString(payload.tenant_id); const out: NormalizedEvent[] = []; const entry = payload.entry; if (!Array.isArray(entry)) { return out; } for (const entryItem of entry) { const rawChanges = (entryItem as JsonRecord).changes; const changes = Array.isArray(rawChanges) ? (rawChanges as unknown[]) : []; for (const rawChange of changes) { const change = rawChange as JsonRecord; const value = change.value as JsonRecord | undefined; if (!value || typeof value !== "object") { continue; } const metadata = value.metadata as JsonRecord | undefined; const phoneNumberId = getString(metadata?.phone_number_id || metadata?.phoneNumberId); const messages = Array.isArray(value.messages) ? value.messages : []; const statuses = Array.isArray(value.statuses) ? value.statuses : []; for (const rawMessage of messages) { const message = rawMessage as JsonRecord; const messageId = getString(message.id); const from = normalizePhone(getString(message.from)); const text = (message.text as JsonRecord | undefined)?.body; const body = getString(text); if (!from || !tenantId) { continue; } const contacts = Array.isArray(value.contacts) ? value.contacts : []; const matchedContact = contacts.find((item) => getString((item as JsonRecord).wa_id) === from) as JsonRecord | undefined; const rawProfile = typeof matchedContact?.profile === "object" ? (matchedContact.profile as JsonRecord | null) : null; const contactName = getString(rawProfile?.name) || from; out.push({ eventType: "message.inbound", tenantId, channelPhoneNumberId: phoneNumberId || undefined, providerEventId: messageId || undefined, payload, rawDirection: "inbound", inbound: { from, body, contactName, messageId: messageId || null } }); } for (const rawStatus of statuses) { const statusValue = rawStatus as JsonRecord; const status = getString(statusValue.status); const messageId = getString(statusValue.id); const to = normalizePhone(getString(statusValue.recipient_id || statusValue.to)); if (!tenantId || !messageId) { continue; } out.push({ eventType: "message.status", tenantId, channelPhoneNumberId: phoneNumberId || to || undefined, providerEventId: messageId, payload, rawDirection: "status", status: { messageId, deliveryStatus: status, failureReason: getString((statusValue.errors as unknown as JsonRecord[])?.[0]?.title) } }); } } } return out; } function parseLegacyPayload(payload: JsonRecord) { const out: NormalizedEvent[] = []; const tenantId = getString(payload.tenantId || payload.tenant_id); const eventType = getString(payload.eventType || payload.type || payload.event_type || payload.event); if (!tenantId || !eventType) { return out; } if (eventType.includes("status")) { out.push({ eventType, tenantId, channelId: getString(payload.channelId || payload.channel_id) || undefined, channelPhoneNumberId: getString(payload.channelPhoneNumberId || payload.phoneNumberId || payload.phone_number_id) || undefined, providerEventId: getString(payload.providerEventId || payload.eventId || payload.id), payload, rawDirection: "status", status: { messageId: getString(payload.messageId || payload.message_id), deliveryStatus: getString(payload.status || "failed"), failureReason: getString(payload.failureReason || payload.failedReason) } }); return out; } if (eventType.includes("inbound") || eventType.includes("message")) { out.push({ eventType, tenantId, channelId: getString(payload.channelId || payload.channel_id) || undefined, channelPhoneNumberId: getString(payload.channelPhoneNumberId || payload.phoneNumberId || payload.phone_number_id) || undefined, providerEventId: getString(payload.providerMessageId || payload.messageId || payload.id), payload, rawDirection: "inbound", inbound: { from: normalizePhone(getString(payload.from || payload.phone || payload.recipient)), body: getString(payload.body || (payload.message as JsonRecord)?.body || payload.text), contactName: getString(payload.contactName || payload.name), messageId: getString(payload.providerMessageId || payload.messageId || payload.id) } }); } return out; } function mapEventDirection(event: NormalizedEvent) { if (event.rawDirection === "other") { return "other"; } if (event.rawDirection === "status" || event.rawDirection === "inbound") { return event.rawDirection; } return "other"; } async function resolveChannelId(tenantId: string, channelId?: string, phoneNumberId?: string) { if (channelId) { const channel = await prisma.channel.findFirst({ where: { id: channelId, tenantId } }); return channel?.id ?? null; } if (phoneNumberId) { const channel = await prisma.channel.findFirst({ where: { phoneNumberId, tenantId } }); if (channel) { return channel.id; } } return null; } export async function GET(req: NextRequest) { const rate = consumeRateLimit(getWebhookIp(req), { scope: "whatsapp_webhook_get", limit: resolveNumber(process.env.WHATSAPP_WEBHOOK_RATE_LIMIT_GET, 60), windowMs: resolveNumber(process.env.WHATSAPP_WEBHOOK_RATE_LIMIT_WINDOW_MS, 60 * 1000) }); if (!rate.allowed) { return NextResponse.json( { ok: false, error: "Too many webhook verification requests" }, { status: 429, headers: getRateLimitHeaders(rate) } ); } const verifyToken = process.env.WHATSAPP_WEBHOOK_VERIFY_TOKEN?.trim() || ""; const mode = req.nextUrl.searchParams.get("hub.mode"); const token = req.nextUrl.searchParams.get("hub.verify_token"); const challenge = req.nextUrl.searchParams.get("hub.challenge"); if (mode === "subscribe" && token === verifyToken && challenge) { return new NextResponse(challenge, { status: 200 }); } return NextResponse.json({ ok: false, error: "Invalid verification request" }, { status: 403 }); } export async function POST(req: NextRequest) { const rate = consumeRateLimit(getWebhookIp(req), { scope: "whatsapp_webhook_post", limit: resolveNumber(process.env.WHATSAPP_WEBHOOK_RATE_LIMIT_POST, 120), windowMs: resolveNumber(process.env.WHATSAPP_WEBHOOK_RATE_LIMIT_WINDOW_MS, 60 * 1000) }); if (!rate.allowed) { return NextResponse.json( { ok: false, error: "Too many webhook events" }, { status: 429, headers: getRateLimitHeaders(rate) } ); } const raw = await req.text(); if (!verifyMetaSignature(raw, req.headers.get("x-hub-signature-256"))) { return NextResponse.json({ ok: false, error: "Invalid webhook signature" }, { status: 401 }); } let payload: unknown; try { payload = JSON.parse(raw); } catch { return NextResponse.json({ ok: false, error: "Invalid JSON payload" }, { status: 400 }); } if (!payload || typeof payload !== "object") { return NextResponse.json({ ok: false, error: "Payload must be a JSON object" }, { status: 400 }); } const payloadObj = payload as JsonRecord; const parsedEvents = [...parseMetaPayload(payloadObj), ...parseLegacyPayload(payloadObj)]; if (parsedEvents.length === 0) { return NextResponse.json({ ok: true, processed: 0, skipped: 0 }); } let processed = 0; let failed = 0; let skipped = 0; for (const event of parsedEvents) { const direction = mapEventDirection(event); const now = new Date(); const resolvedChannelId = await resolveChannelId(event.tenantId, event.channelId, event.channelPhoneNumberId); if (!resolvedChannelId) { const eventHash = buildWebhookEventHash(event, event.channelPhoneNumberId || event.channelId || "unresolved"); await writeWebhookEvent({ tenantId: event.tenantId, channelId: null, event, eventHash, processStatus: "failed", failedReason: "Channel not found" }); failed += 1; continue; } const eventHash = buildWebhookEventHash(event, resolvedChannelId); if (await isDuplicateWebhookEvent(event.tenantId, resolvedChannelId, eventHash)) { await writeWebhookEvent({ tenantId: event.tenantId, channelId: resolvedChannelId, event, eventHash, processStatus: "skipped" }); skipped += 1; continue; } if (direction === "inbound") { const inbound = event.inbound; if (!inbound) { failed += 1; await writeWebhookEvent({ tenantId: event.tenantId, channelId: resolvedChannelId, event, eventHash, processStatus: "failed", failedReason: "Invalid inbound payload" }); continue; } const fromPhone = inbound.from; if (!fromPhone) { failed += 1; await writeWebhookEvent({ tenantId: event.tenantId, channelId: resolvedChannelId, event, eventHash, processStatus: "failed", failedReason: "Missing sender phone number" }); continue; } const contact = await prisma.contact.upsert({ where: { tenantId_phoneNumber: { tenantId: event.tenantId, phoneNumber: fromPhone } }, create: { tenantId: event.tenantId, channelId: resolvedChannelId, fullName: inbound.contactName || fromPhone, phoneNumber: fromPhone, optInStatus: OptInStatus.OPTED_IN }, update: { channelId: resolvedChannelId, fullName: inbound.contactName || fromPhone } }); let conversation = await prisma.conversation.findFirst({ where: { tenantId: event.tenantId, channelId: resolvedChannelId, contactId: contact.id }, orderBy: { lastMessageAt: "desc" } }); if (!conversation) { conversation = await prisma.conversation.create({ data: { tenantId: event.tenantId, channelId: resolvedChannelId, contactId: contact.id, subject: inbound.body?.slice(0, 80) ?? "WhatsApp inbound", firstMessageAt: now, lastMessageAt: now, lastInboundAt: now, status: ConversationStatus.OPEN } }); } const existingInbound = inbound.messageId ? await prisma.message.findUnique({ where: { providerMessageId: inbound.messageId } }) : null; if (!inbound.messageId || !existingInbound) { await prisma.message.create({ data: { tenantId: event.tenantId, conversationId: conversation.id, channelId: resolvedChannelId, contactId: contact.id, direction: MessageDirection.INBOUND, type: MessageType.TEXT, providerMessageId: inbound.messageId, contentText: inbound.body, sentAt: now, sentByUserId: null } }); } await prisma.conversation.update({ where: { id: conversation.id }, data: { lastMessageAt: now, lastInboundAt: now, status: ConversationStatus.OPEN } }); await prisma.contact.update({ where: { id: contact.id }, data: { lastInteractionAt: now } }); await prisma.conversationActivity.create({ data: { tenantId: event.tenantId, conversationId: conversation.id, actorUserId: null, activityType: "MESSAGE_RECEIVED", metadataJson: JSON.stringify({ provider: "webhook", messageId: inbound.messageId, body: inbound.body?.slice(0, 120) }) } }); await writeWebhookEvent({ tenantId: event.tenantId, channelId: resolvedChannelId, event, eventHash, processStatus: "processed" }); await prisma.channel.update({ where: { id: resolvedChannelId }, data: { webhookStatus: "healthy", lastSyncAt: now } }); processed += 1; continue; } if (direction === "status") { const { ipAddress, userAgent } = await getRequestAuditContext(); const messageId = event.status?.messageId; if (!messageId) { failed += 1; await writeWebhookEvent({ tenantId: event.tenantId, channelId: resolvedChannelId, event, eventHash, processStatus: "failed", failedReason: "Status event missing messageId" }); continue; } const targetMessage = await prisma.message.findFirst({ where: { tenantId: event.tenantId, providerMessageId: messageId }, include: { conversation: true } }); const campaignRecipient = await prisma.campaignRecipient.findFirst({ where: { tenantId: event.tenantId, providerMessageId: messageId } }); if (!targetMessage && !campaignRecipient) { failed += 1; await writeWebhookEvent({ tenantId: event.tenantId, channelId: resolvedChannelId, event, eventHash, processStatus: "failed", failedReason: "Message not found by providerMessageId" }); await writeAuditTrail({ tenantId: event.tenantId, actorUserId: null, entityType: "campaign_recipient", entityId: messageId, action: "campaign_delivery_sync_not_found", metadata: { providerMessageId: messageId, providerStatus: event.status?.deliveryStatus, eventType: event.eventType }, ipAddress, userAgent }).catch(() => null); continue; } const mapped = getStatusDelivery(event.status?.deliveryStatus || "queued"); const resolvedTargetStatus = mapped; const targetMessageStatus = targetMessage ? shouldAdvanceDeliveryStatus(targetMessage.deliveryStatus, resolvedTargetStatus) : resolvedTargetStatus; const campaignRecipientStatus = campaignRecipient ? shouldAdvanceDeliveryStatus(campaignRecipient.sendStatus, resolvedTargetStatus) : resolvedTargetStatus; const nowDelivery = campaignRecipientStatus === DeliveryStatus.DELIVERED || campaignRecipientStatus === DeliveryStatus.READ ? now : targetMessageStatus === DeliveryStatus.DELIVERED || targetMessageStatus === DeliveryStatus.READ ? now : undefined; const nowRead = campaignRecipientStatus === DeliveryStatus.READ || targetMessageStatus === DeliveryStatus.READ ? now : undefined; const txOps = []; const updateData = { deliveryStatus: targetMessageStatus, failedReason: campaignRecipientStatus === DeliveryStatus.FAILED ? event.status?.failureReason : null, deliveredAt: nowDelivery, readAt: nowRead }; if (targetMessage) { txOps.push( prisma.message.update({ where: { id: targetMessage.id }, data: { ...updateData, sentAt: targetMessageStatus === DeliveryStatus.SENT && !targetMessage.sentAt ? now : undefined } }) ); } if (targetMessage) { txOps.push( prisma.conversationActivity.create({ data: { tenantId: event.tenantId, conversationId: targetMessage.conversationId, actorUserId: null, activityType: "DELIVERY_UPDATE", metadataJson: JSON.stringify({ providerStatus: mapped, providerEventId: event.providerEventId, messageId: targetMessage.id }) } }) ); } if (campaignRecipient) { txOps.push( prisma.campaignRecipient.update({ where: { id: campaignRecipient.id }, data: { sendStatus: campaignRecipientStatus, failureReason: campaignRecipientStatus === DeliveryStatus.FAILED ? event.status?.failureReason ?? campaignRecipient?.failureReason ?? null : campaignRecipient?.failureReason ?? null, deliveredAt: nowDelivery, readAt: nowRead, sentAt: campaignRecipientStatus === DeliveryStatus.SENT && !campaignRecipient.sentAt ? now : campaignRecipient.sentAt, nextRetryAt: null } }) ); } txOps.push( writeWebhookEvent({ tenantId: event.tenantId, channelId: resolvedChannelId, event, eventHash, processStatus: "processed" }) ); await Promise.all(txOps); if (campaignRecipient) { await recalculateCampaignTotals(campaignRecipient.campaignId); } await writeAuditTrail({ tenantId: event.tenantId, actorUserId: null, entityType: campaignRecipient ? "campaign_recipient" : "message", entityId: campaignRecipient?.id || targetMessage?.id || messageId, action: "message_delivery_status_synced", metadata: { providerStatus: resolvedTargetStatus, appliedStatus: campaignRecipient ? campaignRecipientStatus : targetMessageStatus, providerMessageId: messageId }, ipAddress, userAgent }).catch(() => null); processed += 1; } } return NextResponse.json({ ok: true, processed, failed, skipped }); }