courtmitra / apps /api /src /webhooks /whatsapp.controller.ts
Hetansh Waghela
fix: Critical auth + ownership + container bugs from audit
76b46a3
Raw
History Blame
7.81 kB
/**
* WhatsApp webhook controller.
*
* GET /api/webhooks/whatsapp — hub.challenge verification (required by Meta).
* POST /api/webhooks/whatsapp — inbound message processing.
*
* Raw body note: Fastify does NOT attach req.rawBody by default. We read the
* body from req.body (already parsed JSON) — safe because the HMAC is over the
* raw bytes before JSON parse. For full signature verification in production,
* configure `addContentTypeParser` or use the rawBody plugin. In dev, when
* WHATSAPP_APP_SECRET is unset we skip verification entirely (logged warning).
*
* Day 2 is INBOUND ONLY — no messages are sent back (CLAUDE.md #2, #3).
*/
import {
Controller,
Get,
Headers,
HttpCode,
HttpException,
HttpStatus,
Inject,
Logger,
Post,
Query,
RawBodyRequest,
Req,
Res,
} from "@nestjs/common";
import type { FastifyReply, FastifyRequest } from "fastify";
import { eq } from "drizzle-orm";
import { type Database, webhookEvents } from "@courtmitra/db";
import type { NormalizedInboundMessage } from "@courtmitra/contracts";
import { verifyWebhookChallenge, verifyWhatsAppSignature } from "@courtmitra/adapters";
import { DB } from "../db/db.module.js";
import { ConversationsService } from "../conversations/conversations.service.js";
import { WorkflowService } from "../workflow/inngest.module.js";
import { Public } from "../auth/public.decorator.js";
// ---- WhatsApp Cloud API payload types (minimal) ---------------------------
interface WaTextMessage { body: string }
interface WaImage { id: string; mime_type?: string }
interface WaDocument { id: string; mime_type?: string; filename?: string }
interface WaAudio { id: string; mime_type?: string }
interface WaVideo { id: string; mime_type?: string }
interface WaMessage {
id: string;
from: string;
type: "text" | "image" | "document" | "audio" | "video" | "interactive" | string;
text?: WaTextMessage;
image?: WaImage;
document?: WaDocument;
audio?: WaAudio;
video?: WaVideo;
}
interface WaEntry {
id: string;
changes: Array<{
value: {
messaging_product: string;
metadata: { phone_number_id: string; display_phone_number: string };
contacts?: Array<{ wa_id: string; profile?: { name?: string } }>;
messages?: WaMessage[];
};
}>;
}
interface WaWebhookPayload {
object: string;
entry: WaEntry[];
}
function normalizeWhatsAppMessage(
msg: WaMessage,
entry: WaEntry,
): NormalizedInboundMessage | null {
const phoneNumberId = entry.changes[0]?.value.metadata.phone_number_id ?? "unknown";
const media: NormalizedInboundMessage["media"] = [];
if (msg.image) {
media.push({ kind: "photo", providerFileId: msg.image.id, mimeType: msg.image.mime_type ?? "image/jpeg", filename: null });
}
if (msg.document) {
media.push({
kind: "document",
providerFileId: msg.document.id,
mimeType: msg.document.mime_type ?? null,
filename: msg.document.filename ?? null,
});
}
if (msg.audio) {
media.push({ kind: "audio", providerFileId: msg.audio.id, mimeType: msg.audio.mime_type ?? "audio/ogg", filename: null });
}
if (msg.video) {
media.push({ kind: "video", providerFileId: msg.video.id, mimeType: msg.video.mime_type ?? "video/mp4", filename: null });
}
return {
channel: "whatsapp",
// Thread = the WA number pair (sender + our phone number ID)
externalThreadId: `${msg.from}@${phoneNumberId}`,
providerMessageId: msg.id,
fromSubject: msg.from,
text: msg.text?.body ?? null,
media,
rawPayloadRef: msg,
};
}
// ---------------------------------------------------------------------------
@Public()
@Controller("webhooks/whatsapp")
export class WhatsAppWebhookController {
private readonly logger = new Logger(WhatsAppWebhookController.name);
constructor(
@Inject(DB) private readonly db: Database,
@Inject(ConversationsService) private readonly conversations: ConversationsService,
@Inject(WorkflowService) private readonly workflow: WorkflowService,
) {}
/** Meta webhook verification challenge (GET). */
@Get()
async verify(
@Query() query: Record<string, string>,
@Res() res: FastifyReply,
) {
const challenge = verifyWebhookChallenge(query);
if (!challenge) {
return res.status(HttpStatus.FORBIDDEN).send("Verification failed");
}
return res.status(HttpStatus.OK).send(challenge);
}
/** Inbound message processing (POST). */
@Post()
@HttpCode(200)
async handleUpdate(
@Req() req: FastifyRequest & RawBodyRequest<FastifyRequest>,
@Headers("x-hub-signature-256") signatureHeader?: string,
) {
const appSecret = process.env["WHATSAPP_APP_SECRET"];
// Fix 13: in production, reject requests if the secret is unset.
if (!appSecret && process.env["NODE_ENV"] === "production") {
throw new HttpException("Forbidden: WHATSAPP_APP_SECRET not configured", HttpStatus.FORBIDDEN);
}
if (appSecret) {
// Verify HMAC signature.
// Note: Fastify parses the body before we see it here, so we use
// the re-serialized body for HMAC. In production, configure the
// rawBody plugin for exact byte-level verification.
const rawBody = req.rawBody ?? Buffer.from(JSON.stringify(req.body), "utf8");
const sig = signatureHeader ?? "";
if (!verifyWhatsAppSignature(rawBody, sig)) {
throw new HttpException("Forbidden: invalid signature", HttpStatus.FORBIDDEN);
}
} else {
// Dev bypass: skip signature verification when secret is unset (CLAUDE.md convention)
this.logger.warn("WHATSAPP_APP_SECRET not set — skipping signature verification (dev mode)");
}
const payload = req.body as WaWebhookPayload;
if (!payload || payload.object !== "whatsapp_business_account") {
return { ok: true, skipped: "not a whatsapp_business_account event" };
}
// Process all messages across entries (usually one entry in prod)
for (const entry of payload.entry ?? []) {
for (const change of entry.changes ?? []) {
const msgs = change.value.messages ?? [];
for (const msg of msgs) {
await this.processWhatsAppMessage(msg, entry, payload);
}
}
}
return { ok: true };
}
private async processWhatsAppMessage(
msg: WaMessage,
entry: WaEntry,
rawPayload: WaWebhookPayload,
) {
const providerEventId = `whatsapp:${msg.id}`;
// Dedupe
const [inserted] = await this.db
.insert(webhookEvents)
.values({
provider: "whatsapp",
providerEventId,
payload: rawPayload as unknown as Record<string, unknown>,
})
.onConflictDoNothing()
.returning();
if (!inserted) return; // already processed
const normalized = normalizeWhatsAppMessage(msg, entry);
if (!normalized) {
await this.db
.update(webhookEvents)
.set({ processedAt: new Date() })
.where(eq(webhookEvents.id, inserted.id));
return;
}
const result = await this.conversations.ingestInbound(normalized, {});
// Emit message.received unconditionally — photo-only messages must also trigger intake.
// If workflow emit fails, propagate so provider can retry and we avoid marking
// processedAt for an unprocessed event.
await this.workflow.send("message.received", {
missionId: result.missionId,
conversationId: result.conversationId,
messageId: result.messageId,
});
for (const evidenceItemId of result.evidenceItemIds) {
await this.workflow.send("evidence.uploaded", {
missionId: result.missionId,
evidenceItemId,
});
}
await this.db
.update(webhookEvents)
.set({ processedAt: new Date() })
.where(eq(webhookEvents.id, inserted.id));
}
}