806 lines
24 KiB
TypeScript
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
|
|
});
|
|
}
|