chore: initial project import
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled

This commit is contained in:
Wira Basalamah
2026-04-21 09:29:29 +07:00
commit adde003fba
222 changed files with 37657 additions and 0 deletions

160
app/api/health/route.ts Normal file
View File

@ -0,0 +1,160 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
type ComponentHealth = {
status: "ok" | "degraded" | "down";
message: string;
meta?: unknown;
};
function normalizePositiveNumber(value: string | undefined, fallback: number) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return fallback;
}
return parsed;
}
function maybeExposeDetails(req: NextRequest) {
const expected = process.env.HEALTHCHECK_TOKEN?.trim();
if (!expected) {
return false;
}
const fromHeader = req.headers.get("authorization")?.trim() || req.headers.get("x-health-token")?.trim();
const fromQuery = new URL(req.url).searchParams.get("token")?.trim();
const token = fromHeader || fromQuery;
if (!token) {
return false;
}
return token === expected || token === `Bearer ${expected}`;
}
function isUp(components: ComponentHealth[]) {
return components.every((item) => item.status === "ok");
}
export async function GET(req: NextRequest) {
const checks: Record<string, ComponentHealth> = {};
try {
await prisma.$queryRaw`SELECT 1`;
checks.database = { status: "ok", message: "connected" };
} catch (error) {
checks.database = {
status: "down",
message: error instanceof Error ? error.message : "Database query failed"
};
}
let retries: ComponentHealth = { status: "ok", message: "campaign retry worker state unavailable" };
let webhook: ComponentHealth = { status: "ok", message: "webhook events healthy" };
if (checks.database.status === "ok") {
const failureThreshold = normalizePositiveNumber(process.env.WEBHOOK_FAILURE_RATE_THRESHOLD_PER_HOUR, 10);
const staleThresholdMinutes = normalizePositiveNumber(process.env.RETRY_WORKER_STALE_MINUTES, 30);
const [retryState, webhookFailureCount, disconnectedChannels] = await Promise.all([
prisma.backgroundJobState.findUnique({
where: { jobName: "campaign-retry-worker" },
select: {
lockedUntil: true,
lastRunCompletedAt: true,
lastRunStatus: true,
lastError: true,
consecutiveFailures: true
}
}),
prisma.webhookEvent.count({
where: {
processStatus: "failed",
createdAt: {
gte: new Date(Date.now() - 60 * 60 * 1000)
}
}
}),
prisma.channel.count({ where: { status: "DISCONNECTED" } })
]);
if (!retryState) {
retries = {
status: "degraded",
message: "retry worker state not initialized"
};
} else {
const staleSince = new Date(Date.now() - staleThresholdMinutes * 60 * 1000);
const isStaleLastRun = retryState.lastRunCompletedAt && retryState.lastRunCompletedAt < staleSince;
const shouldBeDown = retryState.lastRunStatus === "failed" && (retryState.consecutiveFailures ?? 0) >= 3;
if (shouldBeDown) {
retries = {
status: "down",
message: "retry worker in repeated failure state",
meta: {
status: retryState.lastRunStatus,
consecutiveFailures: retryState.consecutiveFailures
}
};
} else if (isStaleLastRun) {
retries = {
status: "degraded",
message: "retry worker hasn't completed a run recently",
meta: {
lastRunCompletedAt: retryState.lastRunCompletedAt?.toISOString() ?? null,
staleMinutes: staleThresholdMinutes
}
};
} else {
retries = {
status: "ok",
message: `retry worker status: ${retryState.lastRunStatus ?? "unknown"}`,
meta: {
consecutiveFailures: retryState.consecutiveFailures ?? 0
}
};
}
}
if (webhookFailureCount > failureThreshold) {
webhook = {
status: "degraded",
message: `high webhook failure volume: ${webhookFailureCount} in 60m`,
meta: { count: webhookFailureCount, threshold: failureThreshold }
};
} else if (disconnectedChannels > 0) {
webhook = {
status: "degraded",
message: `disconnected channels: ${disconnectedChannels}`,
meta: { disconnectedChannels }
};
}
} else {
retries = {
status: "down",
message: "skipped due to database not available"
};
webhook = {
status: "down",
message: "skipped due to database not available"
};
}
checks.retries = retries;
checks.webhook = webhook;
const components = Object.entries(checks);
const overall: "ok" | "degraded" | "down" = isUp([checks.database, checks.retries, checks.webhook]) ? "ok" : checks.database.status === "down" ? "down" : "degraded";
const exposeDetails = maybeExposeDetails(req);
const payload = {
ok: overall !== "down",
status: overall,
components: exposeDetails
? checks
: Object.fromEntries(components.map(([name, item]) => [name, { status: item.status, message: item.message }])),
timestamp: new Date().toISOString()
};
return NextResponse.json(payload, { status: overall === "down" ? 503 : 200 });
}

View File

@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from "next/server";
import { getRequestAuditContext } from "@/lib/audit";
import { getCampaignRetryState, runCampaignRetryBatch } from "@/lib/campaign-dispatch-service";
import { consumeRateLimit, getRateLimitHeaders } from "@/lib/rate-limit";
type JobPayload = {
tenantId?: string;
campaignId?: string;
recipientBatchSize?: number;
maxCampaigns?: number;
};
function isAuthorized(req: NextRequest) {
const expected = process.env.CAMPAIGN_RETRY_JOB_TOKEN?.trim();
if (!expected) {
return process.env.NODE_ENV !== "production";
}
const tokenFromHeader = req.headers.get("authorization")?.trim() || req.headers.get("x-cron-token")?.trim();
const tokenFromQuery = new URL(req.url).searchParams.get("token")?.trim();
const token = tokenFromHeader || tokenFromQuery;
if (!token) {
return false;
}
return token === expected || token === `Bearer ${expected}`;
}
function resolveNumber(raw: string | undefined, fallback: number) {
const value = Number(raw?.trim());
if (!Number.isInteger(value) || value <= 0) {
return fallback;
}
return value;
}
export async function GET(req: NextRequest) {
const { ipAddress: requestIpAddress } = await getRequestAuditContext();
const retryRate = consumeRateLimit(requestIpAddress || "unknown", {
scope: "campaign_retry_job_get",
limit: resolveNumber(process.env.CAMPAIGN_RETRY_JOB_RATE_LIMIT_GET, 60),
windowMs: resolveNumber(process.env.CAMPAIGN_RETRY_JOB_RATE_LIMIT_WINDOW_MS, 60 * 1000)
});
if (!retryRate.allowed) {
return NextResponse.json(
{ ok: false, error: "Too many requests. Please retry later." },
{
status: 429,
headers: getRateLimitHeaders(retryRate)
}
);
}
if (!isAuthorized(req)) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const state = await getCampaignRetryState();
const now = new Date();
const lockedUntil = state?.lockedUntil ? new Date(state.lockedUntil) : null;
const health = {
isLocked: Boolean(lockedUntil && lockedUntil > now),
isStaleLock: Boolean(lockedUntil && lockedUntil <= now),
lastRunStartedAt: state?.lastRunStartedAt ?? null,
lastRunCompletedAt: state?.lastRunCompletedAt ?? null,
lastRunStatus: state?.lastRunStatus ?? null
};
return NextResponse.json({ ok: true, state, health });
}
export async function POST(req: NextRequest) {
const { ipAddress: requestIpAddress } = await getRequestAuditContext();
const retryRate = consumeRateLimit(requestIpAddress || "unknown", {
scope: "campaign_retry_job_post",
limit: resolveNumber(process.env.CAMPAIGN_RETRY_JOB_RATE_LIMIT_POST, 20),
windowMs: resolveNumber(process.env.CAMPAIGN_RETRY_JOB_RATE_LIMIT_WINDOW_MS, 60 * 1000)
});
if (!retryRate.allowed) {
return NextResponse.json(
{ ok: false, error: "Too many requests. Please retry later." },
{
status: 429,
headers: getRateLimitHeaders(retryRate)
}
);
}
if (!isAuthorized(req)) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
let payload: unknown = {};
try {
payload = (await req.json()) as unknown;
} catch {
payload = {};
}
const safePayload = payload as JobPayload;
const tenantId = safePayload?.tenantId?.trim?.() || undefined;
const campaignId = safePayload?.campaignId?.trim?.() || undefined;
const recipientBatchSize = Number.isInteger(safePayload?.recipientBatchSize)
? safePayload?.recipientBatchSize
: undefined;
const maxCampaigns = Number.isInteger(safePayload?.maxCampaigns)
? safePayload?.maxCampaigns
: undefined;
const { ipAddress, userAgent } = await getRequestAuditContext();
try {
const result = await runCampaignRetryBatch({
campaignId,
tenantId,
actorIpAddress: ipAddress,
actorUserAgent: userAgent,
actorUserId: null,
recipientBatchSize,
maxCampaigns
});
return NextResponse.json({ ok: true, ...result });
} catch (error) {
const message = error instanceof Error ? error.message : "Campaign retry job failed";
return NextResponse.json({ ok: false, error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,805 @@
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
});
}