From a69da41f987be603c5574b1f74b1543df33cb95c Mon Sep 17 00:00:00 2001 From: Replit Agent Date: Sat, 6 Jun 2026 22:27:47 +0300 Subject: [PATCH] feat: queue donor WhatsApp confirmations --- artifacts/api-server/src/lib/mockDb.ts | 39 +++++++++++++++++++ artifacts/api-server/src/routes/index.ts | 2 + .../api-server/src/routes/notifications.ts | 36 +++++++++++++++++ artifacts/api-server/src/routes/requests.ts | 24 ++++++++++++ 4 files changed, 101 insertions(+) create mode 100644 artifacts/api-server/src/routes/notifications.ts diff --git a/artifacts/api-server/src/lib/mockDb.ts b/artifacts/api-server/src/lib/mockDb.ts index 80c7153..9844cb7 100644 --- a/artifacts/api-server/src/lib/mockDb.ts +++ b/artifacts/api-server/src/lib/mockDb.ts @@ -77,6 +77,45 @@ export interface WhatsappLogEntry { createdAt: string; } +// Outbound WhatsApp "thank the donor" notifications, fired right after a +// successful donation. They are queued here (in-memory) and picked up by an +// external poller (OpenClaw) that actually sends the WhatsApp message and then +// marks the row as sent. Resets on container restart like all mock data. +export interface DonorNotification { + id: string; + caseId: string; + donorName: string; + donorPhone: string; // normalized to international form, e.g. 9665XXXXXXXX + message: string; + status: WhatsappStatus; // pending | sent | failed + sentAt: string | null; + createdAt: string; +} + +// Fixed Arabic confirmation message sent to the donor after a successful +// donation. This is an outbound WhatsApp message (not in-app UI text), so it is +// intentionally a single fixed string and does not go through LanguageContext. +export const DONOR_THANK_YOU_MESSAGE = + "إحسانك يُثمر وعطاؤك يعين ..\n\nتمت عملية تبرعك عبر منصة إحسان بنجاح\n\n(والله يحب المحسنين)"; + +export const donorNotifications: DonorNotification[] = []; + +// Normalize a Saudi phone number to WhatsApp international form. +// 05XXXXXXXX -> 9665XXXXXXXX +// 5XXXXXXXX -> 9665XXXXXXXX +// 9665XXXXXXXX -> 9665XXXXXXXX (unchanged) +// +966 5XXXXXXXX -> 9665XXXXXXXX +// Returns digits only (no '+'). Falls back to the cleaned digits if the shape +// is unexpected, so we never throw inside the donation flow. +export function normalizeSaudiPhone(raw: string): string { + const digits = String(raw || "").replace(/\D/g, ""); + if (!digits) return ""; + if (digits.startsWith("966")) return digits; + if (digits.startsWith("0")) return "966" + digits.slice(1); + if (digits.startsWith("5") && digits.length === 9) return "966" + digits; + return digits; +} + // ─── Eligibility Database ─────────────────────────────────────────────────── export const eligibilityDb: EligibilityRecord[] = [ { nationalId: "1090512345", eligible: true }, diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 42f716f..8d5f64c 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -4,6 +4,7 @@ import requestsRouter from "./requests.js"; import donorsRouter from "./donors.js"; import statsRouter from "./stats.js"; import whatsappLogRouter from "./whatsappLog.js"; +import notificationsRouter from "./notifications.js"; const router: IRouter = Router(); @@ -12,5 +13,6 @@ router.use(requestsRouter); router.use(donorsRouter); router.use(statsRouter); router.use(whatsappLogRouter); +router.use(notificationsRouter); export default router; diff --git a/artifacts/api-server/src/routes/notifications.ts b/artifacts/api-server/src/routes/notifications.ts new file mode 100644 index 0000000..00a7621 --- /dev/null +++ b/artifacts/api-server/src/routes/notifications.ts @@ -0,0 +1,36 @@ +import { Router, Request, Response } from "express"; +import { donorNotifications } from "../lib/mockDb.js"; + +const router = Router(); + +// ─── GET /notifications ─────────────────────────────────────────────────────── +// Full queue (most recent first). Useful for debugging/inspection. +router.get("/notifications", (_req: Request, res: Response): void => { + res.json([...donorNotifications].reverse()); +}); + +// ─── GET /notifications/pending ─────────────────────────────────────────────── +// Rows the external poller (OpenClaw) still needs to send via WhatsApp. +router.get("/notifications/pending", (_req: Request, res: Response): void => { + res.json(donorNotifications.filter((n) => n.status === "pending")); +}); + +// ─── POST /notifications/:id/sent ───────────────────────────────────────────── +// Marks a queued notification as delivered (or failed). Called by the poller +// after it actually sends the WhatsApp message from the platform number. +router.post( + "/notifications/:id/sent", + (req: Request, res: Response): void => { + const item = donorNotifications.find((n) => n.id === req.params.id); + if (!item) { + res.status(404).json({ error: "Not found" }); + return; + } + const failed = req.body?.status === "failed"; + item.status = failed ? "failed" : "sent"; + item.sentAt = failed ? null : new Date().toISOString(); + res.json(item); + } +); + +export default router; diff --git a/artifacts/api-server/src/routes/requests.ts b/artifacts/api-server/src/routes/requests.ts index 69ebc70..bfdf0a3 100644 --- a/artifacts/api-server/src/routes/requests.ts +++ b/artifacts/api-server/src/routes/requests.ts @@ -3,10 +3,14 @@ import { requests, donors, whatsappLog, + donorNotifications, + DONOR_THANK_YOU_MESSAGE, + normalizeSaudiPhone, checkEligibility, STATUS_STEP, DonationRequest, WhatsappLogEntry, + DonorNotification, } from "../lib/mockDb.js"; import { v4 as uuidv4 } from "uuid"; @@ -213,6 +217,26 @@ router.post("/requests/:id/donate", (req: Request, res: Response): void => { item.currentStep = STATUS_STEP["donated"]; } item.updatedAt = new Date().toISOString(); + + // Queue a WhatsApp "thank the donor" confirmation for every successful + // donation. An external poller (OpenClaw) picks pending rows up and sends the + // message from the platform's WhatsApp number, then marks it sent. This does + // not touch the closed-loop funding logic above. + const normalizedPhone = normalizeSaudiPhone(donorPhone); + if (normalizedPhone) { + const notification: DonorNotification = { + id: uuidv4(), + caseId: item.caseId, + donorName, + donorPhone: normalizedPhone, + message: DONOR_THANK_YOU_MESSAGE, + status: "pending", + sentAt: null, + createdAt: new Date().toISOString(), + }; + donorNotifications.push(notification); + } + res.json(item); });