feat: queue donor WhatsApp confirmations
This commit is contained in:
@@ -77,6 +77,45 @@ export interface WhatsappLogEntry {
|
|||||||
createdAt: string;
|
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 ───────────────────────────────────────────────────
|
// ─── Eligibility Database ───────────────────────────────────────────────────
|
||||||
export const eligibilityDb: EligibilityRecord[] = [
|
export const eligibilityDb: EligibilityRecord[] = [
|
||||||
{ nationalId: "1090512345", eligible: true },
|
{ nationalId: "1090512345", eligible: true },
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import requestsRouter from "./requests.js";
|
|||||||
import donorsRouter from "./donors.js";
|
import donorsRouter from "./donors.js";
|
||||||
import statsRouter from "./stats.js";
|
import statsRouter from "./stats.js";
|
||||||
import whatsappLogRouter from "./whatsappLog.js";
|
import whatsappLogRouter from "./whatsappLog.js";
|
||||||
|
import notificationsRouter from "./notifications.js";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
@@ -12,5 +13,6 @@ router.use(requestsRouter);
|
|||||||
router.use(donorsRouter);
|
router.use(donorsRouter);
|
||||||
router.use(statsRouter);
|
router.use(statsRouter);
|
||||||
router.use(whatsappLogRouter);
|
router.use(whatsappLogRouter);
|
||||||
|
router.use(notificationsRouter);
|
||||||
|
|
||||||
export default router;
|
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,
|
requests,
|
||||||
donors,
|
donors,
|
||||||
whatsappLog,
|
whatsappLog,
|
||||||
|
donorNotifications,
|
||||||
|
DONOR_THANK_YOU_MESSAGE,
|
||||||
|
normalizeSaudiPhone,
|
||||||
checkEligibility,
|
checkEligibility,
|
||||||
STATUS_STEP,
|
STATUS_STEP,
|
||||||
DonationRequest,
|
DonationRequest,
|
||||||
WhatsappLogEntry,
|
WhatsappLogEntry,
|
||||||
|
DonorNotification,
|
||||||
} from "../lib/mockDb.js";
|
} from "../lib/mockDb.js";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
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.currentStep = STATUS_STEP["donated"];
|
||||||
}
|
}
|
||||||
item.updatedAt = new Date().toISOString();
|
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);
|
res.json(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user