feat: queue donor WhatsApp confirmations

This commit is contained in:
Replit Agent
2026-06-06 22:27:47 +03:00
parent 94ccbf6fe4
commit a69da41f98
4 changed files with 101 additions and 0 deletions
+39
View File
@@ -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 },
+2
View File
@@ -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;
@@ -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;
@@ -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);
});