Files
Wira Basalamah adde003fba
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
chore: initial project import
2026-04-21 09:29:29 +07:00

806 lines
24 KiB
TypeScript

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<string, unknown>;
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, number> = {
[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
});
}