Compare commits
4 Commits
cb47f9bd2b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a0b07262f8 | |||
| a69da41f98 | |||
| 94ccbf6fe4 | |||
| c089f41b68 |
+2
-2
@@ -12,12 +12,12 @@ FROM --platform=linux/amd64 node:24-bookworm-slim AS build
|
|||||||
|
|
||||||
ENV PNPM_HOME=/pnpm
|
ENV PNPM_HOME=/pnpm
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN corepack enable && corepack prepare pnpm@9 --activate
|
RUN corepack enable && corepack prepare pnpm@10.32.1 --activate
|
||||||
|
|
||||||
WORKDIR /repo
|
WORKDIR /repo
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --no-frozen-lockfile
|
||||||
RUN NODE_ENV=production pnpm --filter @workspace/api-server run build
|
RUN NODE_ENV=production pnpm --filter @workspace/api-server run build
|
||||||
|
|
||||||
# ---- Runtime stage ---------------------------------------------------------
|
# ---- Runtime stage ---------------------------------------------------------
|
||||||
|
|||||||
+2
-2
@@ -11,12 +11,12 @@ FROM --platform=linux/amd64 node:24-bookworm-slim AS build
|
|||||||
|
|
||||||
ENV PNPM_HOME=/pnpm
|
ENV PNPM_HOME=/pnpm
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN corepack enable && corepack prepare pnpm@9 --activate
|
RUN corepack enable && corepack prepare pnpm@10.32.1 --activate
|
||||||
|
|
||||||
WORKDIR /repo
|
WORKDIR /repo
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --no-frozen-lockfile
|
||||||
# PORT + BASE_PATH are required by vite.config.ts at config load time.
|
# PORT + BASE_PATH are required by vite.config.ts at config load time.
|
||||||
RUN PORT=8080 BASE_PATH=/ NODE_ENV=production pnpm --filter @workspace/ehsan-poc run build
|
RUN PORT=8080 BASE_PATH=/ NODE_ENV=production pnpm --filter @workspace/ehsan-poc run build
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
@@ -116,7 +120,7 @@ router.get("/requests/:id", (req: Request, res: Response): void => {
|
|||||||
|
|
||||||
// ─── Helper: find & update ────────────────────────────────────────────────────
|
// ─── Helper: find & update ────────────────────────────────────────────────────
|
||||||
function findAndUpdate(
|
function findAndUpdate(
|
||||||
id: string,
|
id: string | string[],
|
||||||
updater: (r: DonationRequest) => void,
|
updater: (r: DonationRequest) => void,
|
||||||
res: Response
|
res: Response
|
||||||
): void {
|
): void {
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+156
@@ -0,0 +1,156 @@
|
|||||||
|
# Hammam Dev — EHSAN "Closed Donation Loop" (POC)
|
||||||
|
|
||||||
|
> Onboarding brief for any developer or AI coding agent (e.g. OpenClaw) taking over
|
||||||
|
> this project. Read it fully before editing. The repo is hosted on a self-hosted
|
||||||
|
> **Gitea** server and runs on a **Mac Mini** via Docker.
|
||||||
|
>
|
||||||
|
> **تنويه:** مشروع **تجريبي** مستوحى من فكرة منصة «إحسان»، وليس منصة إحسان الرسمية
|
||||||
|
> ولا تابعاً لها.
|
||||||
|
|
||||||
|
## 1. What this product is
|
||||||
|
A bilingual (Arabic / English, full RTL + LTR) **proof-of-concept charity donation web
|
||||||
|
app** inspired by the Saudi "EHSAN" (إحسان) platform. Its core idea is a **Closed
|
||||||
|
Donation Loop**:
|
||||||
|
|
||||||
|
- Beneficiaries submit support requests (housing, food, electricity, water, health,
|
||||||
|
court-ordered debt, appliances like A/C & refrigerator, etc.).
|
||||||
|
- Each request/opportunity has a **funding target**. Donations from multiple donors
|
||||||
|
**accumulate and are clamped to the target** — a case can never be over-funded.
|
||||||
|
- A case only **enters the "closed loop" fulfillment pipeline once it is fully funded**.
|
||||||
|
This is the central business rule. Do not break it.
|
||||||
|
- After funding, the flow continues to confirmation / tracking, and a simulated
|
||||||
|
WhatsApp notification log records donor/beneficiary messaging.
|
||||||
|
|
||||||
|
This is a POC/demo: data is **in-memory mock data** (no real database, no real payments).
|
||||||
|
|
||||||
|
## 2. Tech stack
|
||||||
|
- Monorepo: **pnpm workspaces**, Node.js 24, TypeScript 5.9.
|
||||||
|
- Web app (`artifacts/ehsan-poc`): React + Vite + **wouter** (routing) +
|
||||||
|
**TanStack Query** (data) + **Tailwind CSS** + shadcn/ui components.
|
||||||
|
- API (`artifacts/api-server`): **Express 5**, all routes mounted under `/api`,
|
||||||
|
health at `/api/healthz`. Data lives in `src/lib/mockDb.ts` (in-memory; resets on restart).
|
||||||
|
- Shared libs under `lib/`: `api-spec` (OpenAPI source of truth), `api-client-react`
|
||||||
|
(generated React Query hooks), `api-zod` (Zod schemas), `db` (Drizzle schema, not
|
||||||
|
active at runtime since data is in-memory).
|
||||||
|
- All workspace libs export TypeScript source directly (`./src/index.ts`) — no lib
|
||||||
|
pre-build step; Vite/esbuild consume the source.
|
||||||
|
|
||||||
|
## 3. Repo map
|
||||||
|
- `artifacts/ehsan-poc/src/pages/` — screens: home, about, waqf, baraem, request,
|
||||||
|
opportunities, donate/:id, cart, login, admin, track/:id, thank-you/:id,
|
||||||
|
whatsapp-log, not-found.
|
||||||
|
- `artifacts/ehsan-poc/src/contexts/` — `LanguageContext` (ar/en + RTL), `CartContext`
|
||||||
|
(multi-case donation cart), `AuthContext` (mock admin login).
|
||||||
|
- `artifacts/ehsan-poc/src/components/` — `Riyal.tsx` (renders the NEW Saudi Riyal
|
||||||
|
symbol via an image mask), `layout/` (Header, AppLayout), `ui/` (shadcn components).
|
||||||
|
- `artifacts/ehsan-poc/src/App.tsx` — UI root: routing (wouter) + all context providers.
|
||||||
|
- `artifacts/api-server/src/routes/` — `health`, `requests`, `donors`, `stats`,
|
||||||
|
`whatsappLog`. Mounted in `routes/index.ts`.
|
||||||
|
- `artifacts/api-server/src/routes/requests.ts` — **the closed-loop logic and every
|
||||||
|
status transition** live here.
|
||||||
|
- `artifacts/api-server/src/lib/mockDb.ts` — types, in-memory seed data, the
|
||||||
|
`STATUS_STEP` map, and `checkEligibility`.
|
||||||
|
- Root deploy files: `Dockerfile.web`, `Dockerfile.api`, `docker/nginx.conf`,
|
||||||
|
`docker-compose.yml`, `deploy.sh`, `scripts/push-to-gitea.sh`, `DEPLOYMENT.md`.
|
||||||
|
|
||||||
|
## 4. How to run locally (development)
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm --filter @workspace/api-server run dev # API (port 5000 in dev)
|
||||||
|
pnpm --filter @workspace/ehsan-poc run dev # Web (needs PORT and BASE_PATH env)
|
||||||
|
pnpm run typecheck # full typecheck
|
||||||
|
pnpm run build # typecheck + build everything
|
||||||
|
```
|
||||||
|
Note: `vite.config.ts` REQUIRES `PORT` and `BASE_PATH` env vars even for `build`
|
||||||
|
(it throws otherwise), e.g.
|
||||||
|
`PORT=8080 BASE_PATH=/ pnpm --filter @workspace/ehsan-poc run build`.
|
||||||
|
|
||||||
|
> **Apple Silicon caveat:** a full `pnpm run build` typechecks all packages but the
|
||||||
|
> `vite build` step fails locally on darwin-arm64 with
|
||||||
|
> `Cannot find module @rollup/rollup-darwin-arm64`. That is **by design** — the
|
||||||
|
> workspace `overrides` in `pnpm-workspace.yaml` strip every non-`linux-x64-gnu`
|
||||||
|
> native binary so the Docker (`linux/amd64`) build stays clean. The real build runs
|
||||||
|
> inside Docker; locally the `typecheck` result is the meaningful gate.
|
||||||
|
|
||||||
|
## 5. How it runs in production (Mac Mini, Docker)
|
||||||
|
Two services in `docker-compose.yml`:
|
||||||
|
- `api` — Express, internal only, listens on `PORT=8080`, healthcheck `/api/healthz`.
|
||||||
|
- `web` — nginx that serves the built Vite SPA **and reverse-proxies `/api/` to
|
||||||
|
`api:8080`**. The browser always calls **same-origin `/api/...`**, so there is NO
|
||||||
|
frontend API URL to configure.
|
||||||
|
|
||||||
|
Deploy / redeploy on the Mac Mini:
|
||||||
|
```bash
|
||||||
|
./deploy.sh # git pull gitea main → docker compose down → build → up -d
|
||||||
|
```
|
||||||
|
App is then served on the Mac Mini at `http://localhost:8080` (override with `WEB_PORT`).
|
||||||
|
|
||||||
|
## 6. Deployment flow (how code travels)
|
||||||
|
```
|
||||||
|
Edit code → commit → push to Gitea (branch: main)
|
||||||
|
→ on Mac Mini run ./deploy.sh → Docker rebuilds & restarts
|
||||||
|
```
|
||||||
|
- Central repo is **Gitea** (no GitHub), branch `main`. The documented remote name is
|
||||||
|
`gitea`; on this Mac Mini checkout the remote may be named `origin` but still points
|
||||||
|
at the Gitea host — verify with `git remote -v`.
|
||||||
|
- `scripts/push-to-gitea.sh` pushes from a dev machine; `deploy.sh` redeploys on the Mac Mini.
|
||||||
|
- An AI agent working directly on the Mac Mini clone should use this loop:
|
||||||
|
edit → test → `pnpm run build` → `git commit` → `git push <gitea-remote> main` → `./deploy.sh`.
|
||||||
|
|
||||||
|
## 7. Status lifecycle (create → close)
|
||||||
|
The case `status` (`RequestStatus`) and its step number (`STATUS_STEP` in `mockDb.ts`):
|
||||||
|
|
||||||
|
```
|
||||||
|
new (1)
|
||||||
|
→ pending_review (2)
|
||||||
|
→ verified (3)
|
||||||
|
→ published (4)
|
||||||
|
→ donated (5) ← reached ONLY when fully funded
|
||||||
|
→ delivered (6)
|
||||||
|
→ receipt_confirmed (7)
|
||||||
|
→ thank_you_submitted (8)
|
||||||
|
→ whatsapp_sent (9)
|
||||||
|
→ closed (10)
|
||||||
|
|
||||||
|
rejected (side path, step 2)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Eligibility on creation:** `POST /requests` checks `nationalId` via
|
||||||
|
`checkEligibility` → eligible ⇒ `verified`, not eligible ⇒ `rejected`, unknown ⇒
|
||||||
|
`pending_review`.
|
||||||
|
- **API transition endpoints:** `verify`, `publish`, `donate`, `deliver`,
|
||||||
|
`confirm-receipt`, `thank-you`, `send-whatsapp`, `close`, `reject`
|
||||||
|
(`POST /requests/:id/<action>`).
|
||||||
|
- **`donate` is the heart of the closed loop:** it only accepts donations while the
|
||||||
|
case is `published`, clamps each donation to the remaining amount
|
||||||
|
(`applied = min(amount, requestedAmount − collectedAmount)`), and advances the case
|
||||||
|
to `donated` only once `collectedAmount >= requestedAmount`.
|
||||||
|
|
||||||
|
## 8. Hard rules / gotchas — do NOT break these
|
||||||
|
- **amd64 / glibc ONLY.** The pnpm workspace strips every native binary that is not
|
||||||
|
`linux-x64-gnu`. Docker build stages MUST use `node:24-bookworm-slim` (glibc, not
|
||||||
|
alpine) and `platform: linux/amd64` (runs under Rosetta on Apple Silicon). Do not
|
||||||
|
switch the build base to alpine or arm64 — rollup / tailwind-oxide / lightningcss
|
||||||
|
will fail to find native binaries.
|
||||||
|
- **Funding rule (Closed Donation Loop).** Donations accumulate and clamp to the
|
||||||
|
target; a case can never be over-funded and enters the fulfillment pipeline
|
||||||
|
(`donated` and beyond) only when fully funded. Preserve this.
|
||||||
|
- **Bilingual + RTL.** Every user-facing string must exist in both Arabic and English
|
||||||
|
via `LanguageContext`. Don't hardcode single-language text. Keep RTL layout working.
|
||||||
|
- **Same-origin API.** The browser calls `/api/...` on the same domain (nginx proxies
|
||||||
|
it to the `api` service). Never add a separate frontend API URL.
|
||||||
|
- **Currency.** Saudi Riyal uses the new official symbol rendered by the `<Riyal/>`
|
||||||
|
component (image mask), not the old "ر.س"/"SAR" text. Reuse `<Riyal/>`.
|
||||||
|
- **Routing base.** The app is mounted under a base path via `import.meta.env.BASE_URL`.
|
||||||
|
Use it for routes/links; never hardcode root-relative `/api` in a way that escapes
|
||||||
|
the base — call same-origin `/api/...` through the proxy.
|
||||||
|
- **Data is ephemeral.** mockDb is in-memory; restarting the `api` container resets it.
|
||||||
|
If you add persistence, add a database service to `docker-compose.yml` accordingly.
|
||||||
|
- **HMR quirk (dev):** if you see "useLanguage must be used within a LanguageProvider"
|
||||||
|
while the code is correct, it's stale Fast Refresh state — restart the web dev server.
|
||||||
|
- Always run `pnpm run build` (typecheck + build) before pushing to catch type errors.
|
||||||
|
|
||||||
|
## 9. Suggested first task for a new agent
|
||||||
|
Read `DEPLOYMENT.md`, `artifacts/ehsan-poc/src/App.tsx`, and
|
||||||
|
`artifacts/api-server/src/routes/index.ts` to confirm the routes and data model, then
|
||||||
|
summarize your understanding of the funding / closed-loop flow before making any change.
|
||||||
Reference in New Issue
Block a user