chore: initial project import
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
This commit is contained in:
160
app/api/health/route.ts
Normal file
160
app/api/health/route.ts
Normal 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 });
|
||||
}
|
||||
133
app/api/jobs/campaign-retry/route.ts
Normal file
133
app/api/jobs/campaign-retry/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
805
app/api/webhooks/whatsapp/route.ts
Normal file
805
app/api/webhooks/whatsapp/route.ts
Normal 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
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user