feat: queue donor WhatsApp confirmations
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user