# Smart COD Order Confirmation System: Build Plan for Codex

## 0. Context

**Problem** (from `requirements.txt`, in Arabic): cash-on-delivery (COD) stores lose money on returns caused by fake orders and slow
confirmation. We are building a smart order management and confirmation system that runs 24/7:

1. Confirms orders with customers automatically over the channel they prefer.
2. Filters fake orders and spam with a **risk score from 0 to 100** before anything ships.
3. Understands addresses and phones in Egypt, KSA, the Gulf and internationally, **routes each order to a carrier** automatically
   (Bosta, Aramex or DHL), and generates a **4x6 thermal label (AWB) with a barcode and a PDF**.
4. Provides a **dashboard** with orders grouped by status and a live KPI screen (profit and money saved).
5. Manages **inventory**: stock is deducted on confirmation, with low-stock alerts.
6. Uses **AI on voice notes**: speech-to-text, then intent detection (confirm, cancel or change address), and escalates angry
   customers to a human.

**Reference images (Codex must open and study them before Phase 5 and Phase 8):**
| File | Shows |
|---|---|
| `images/n8n.jpg` | Target n8n architecture, "Master COD Enterprise System (E2E)", about 78 nodes in 5 layers |
| `images/dashboard.jpg` | KPI page: 5 KPI cards with sparklines, conversion funnel, live activity feed, infrastructure status |
| `images/dashboard2.jpg` | Interactive Kanban shipping board (columns by status, order cards) |
| `images/dashboard3.jpg` | Order card detail plus the "change status" dropdown |
| `images/dashboard4.jpg` | 4x6 AWB modal with barcode and a "print thermal label" button |
| `images/bot.jpg` | Telegram message formats (shipping update, reminder, daily digest) |

**Locked decisions**
- **One store per deployment.** A new client gets a new server or stack. No multi-tenancy.
- **PostgreSQL is the system of record.** No Airtable. Where the screenshots say "Airtable CRM", use Postgres through the internal API.
- **Customer channels in v1: Telegram bot and email (Gmail/SMTP).** The notification layer must be pluggable so WhatsApp
  (Evolution API or Cloud API) and SMS can be added later without changing callers.
- **Carriers use mock adapters** (Bosta, Aramex, DHL) behind a `CarrierAdapter` interface. Real APIs come later.
- **AI uses OpenAI** (Whisper speech-to-text and a chat model) through n8n AI nodes, as in `n8n.jpg`.
- **Deployment:** Docker Compose on an Ubuntu VPS, with Caddy providing automatic HTTPS.

---

## 1. Architecture

```
 Store checkout / "Simulate order" ──POST──▶ n8n WF-01 Order Ingestion (public webhook, X-Api-Key)
                                                   │  HTTP Request nodes (Bearer INTERNAL_API_TOKEN)
                                                   ▼
 Customer ◀── Telegram / Email ── n8n ◀──▶ Next.js app  ──Prisma──▶ PostgreSQL (db: cod)
   │  (Telegram Trigger, voice, buttons)      │  dashboard UI, /api/internal/*, /c/[token], /awb/[id]
   │                                          ├──▶ Gotenberg (HTML → 4x6 PDF labels)
   └── email link ─▶ /c/[token] (Next.js) ────┘──▶ n8n WF-06 (dashboard events → notifications)
 Mock carrier status ──▶ n8n WF-02 ──▶ internal API
 Crons (Africa/Cairo): WF-04 daily digest 23:59, WF-05 recovery every 2h
```

**Who owns what**
- **Next.js (`apps/web`)** owns the database schema (Prisma), all persistence, **all status transitions** (a single
  `transitionOrder()` domain function), atomic inventory operations, carrier adapters, AWB label page and PDF, public
  confirmation pages, dashboard UI, and the internal API for n8n.
- **n8n** handles orchestration: the ingestion pipeline, the pure-logic Code nodes (sanitizing, phone parsing, geo parsing, risk
  scoring, carrier routing, message templates), messaging (Telegram and email), AI (speech-to-text and the intent agent), crons,
  and carrier webhooks. **n8n never writes to Postgres directly.** It calls `/api/internal/*` through HTTP Request nodes. This
  matches the HTTP nodes in `n8n.jpg` and keeps business rules in one tested place.
- **Dashboard actions with side effects** (status change, resend confirmation): Next.js updates the database, then POSTs an event
  to the n8n WF-06 webhook, and n8n sends the customer or admin messages.

## 2. Repository layout (monorepo, pnpm workspaces)

```
/
├─ AGENTS.md                 # Codex rules (created in Phase 0 from §3)
├─ PLAN.md                   # this file; update the Progress Log at the bottom
├─ requirements.txt, images/ # business references
├─ docker-compose.yml, Caddyfile, .env.example, .gitignore
├─ infra/postgres/init.sql   # creates DBs + users: n8n, cod, cod_test
├─ shared/data/              # JSON shared by web + n8n: eg-governorates.json, gulf-countries.json, phone-rules.json, statuses.json
├─ apps/web/                 # Next.js App Router + TS
│   ├─ Dockerfile, prisma/schema.prisma, prisma/seed.ts
│   ├─ public/geo/egypt-governorates.geojson, public/fonts/
│   └─ src/
│       ├─ app/(auth)/login, app/(dashboard)/{page,board,map,inventory,ai-logs,orders/[id],settings}
│       ├─ app/api/internal/**, app/api/health, app/c/[token], app/awb/[orderId]
│       ├─ server/domain/{orders,transitions,inventory,risk-context,events,reports,settings}.ts
│       ├─ server/carriers/{types,bosta.mock,aramex.mock,dhl.mock,index}.ts
│       ├─ server/labels/{render,pdf}.ts, server/n8n.ts (emit events, health)
│       └─ lib/{db,auth,i18n,format,internal-auth}.ts, messages/{ar,en}.json
├─ n8n/
│   ├─ package.json          # esbuild + vitest
│   ├─ code/*.ts             # Code-node logic as pure functions (unit tested)
│   ├─ code/__tests__/*.test.ts
│   ├─ src/*.workflow.json   # workflow templates; Code nodes contain "jsCode": "@@code:<name>"
│   ├─ dist/                 # generated importable JSON (gitignored)
│   └─ credentials.template.json
└─ scripts/ n8n-build.mjs, n8n-validate.mjs, n8n-import.sh, smoke-test.sh, simulate-order.sh, backup.sh, restore.sh
```

## 3. Global rules for Codex (copy into `AGENTS.md` in Phase 0)

1. Work **one phase at a time**, in order. At the end of each phase, run its **Verify** steps and do not start the next phase
   until every step passes. Commit with `phase-N: <title>` and tick the Progress Log.
2. Never commit secrets. Every variable goes in `.env` (gitignored) and is listed with a comment in `.env.example`.
3. Stack: Node 22 LTS, pnpm, the latest stable Next.js (App Router, TypeScript `strict`), Tailwind CSS v4, shadcn/ui,
   TanStack Query, Recharts, Prisma, PostgreSQL 16, Auth.js (NextAuth v5), zod, Vitest, Playwright. **Pin exact
   versions** in package.json and pin the n8n Docker tag (current stable, never `latest`). Check versions on the server with
   `npm view <pkg> version` and `docker pull`.
4. UI is **Arabic by default, right-to-left** (`<html dir="rtl" lang="ar">`), with an English toggle (left-to-right). Dark theme by default, light theme
   optional. Every user-facing string lives in `messages/ar.json` and `messages/en.json`.
5. Money uses Prisma `Decimal(12,2)`, displayed as `150 ج.م`. One currency per store (setting, default `EGP`).
6. Store all times in UTC. Crons, digests and quiet hours use `Africa/Cairo` (the `TZ` env var).
7. ID formats: order `ORD-<Date.now()>` (13 digits, retry on unique conflict); AWB `WB-<8 digits>`; draft label shows
   `DRAFT`.
8. Every n8n workflow must pass `pnpm n8n:validate`. Code-node logic is written only in `n8n/code/*.ts` and never edited
   inside the JSON.
9. Every internal API input is validated with zod, and invalid input returns `422 {error, details}`.
10. When a step is ambiguous, pick the simplest option consistent with this plan and write it under "Decisions made" in the
    Progress Log.

---

## 4. Domain model

### 4.1 Order statuses
| Enum | Arabic label | Kanban column | Notes |
|---|---|---|---|
| `NEW` | جديد | 1 | Just ingested, before risk routing finishes |
| `PENDING_CONFIRMATION` | بانتظار التأكيد | 2 | Confirmation sent, waiting for the customer |
| `CONFIRMED` | مؤكد (تجهيز) | 3 | Stock deducted, AWB created, warehouse alerted |
| `SHIPPED` | تم الشحن | 4 | |
| `NEEDS_REVIEW` | مراجعة بشرية / AI | 5 | Medium risk, AI unsure, angry customer, or out of stock |
| `CANCELLED` | ملغي | 6 (shared) | |
| `BLOCKED` | محظور (احتيال) | 6 (shared) | High risk or blacklist |
| `DELIVERED` | تم التسليم | filter only | Counts toward profit |
| `RETURNED` | مرتجع | filter only | Counts toward return rate |

**Allowed transitions** (enforced only in `server/domain/transitions.ts`; anything else returns `409`):
`NEW → PENDING_CONFIRMATION | NEEDS_REVIEW | BLOCKED | CANCELLED` ·
`PENDING_CONFIRMATION → CONFIRMED | CANCELLED | NEEDS_REVIEW` ·
`NEEDS_REVIEW → PENDING_CONFIRMATION | CONFIRMED | CANCELLED | BLOCKED` ·
`CONFIRMED → SHIPPED | CANCELLED` · `SHIPPED → DELIVERED | RETURNED` · `BLOCKED → NEEDS_REVIEW` (manual unblock).

**Side effects inside the same database transaction as the status change:**
| Transition | Inventory | Other |
|---|---|---|
| Ingestion (create) | `reserved += qty` (conditional atomic update; on shortage → `NEEDS_REVIEW`, reason `OUT_OF_STOCK`) | |
| → `CONFIRMED` | `onHand -= qty`, `reserved -= qty` (`DEDUCT`) | `carrierAdapter.createShipment()` sets `awbNumber` and `trackingUrl`, then the label PDF is generated asynchronously |
| → `CANCELLED`/`BLOCKED` from a reserved state | `reserved -= qty` (`RELEASE`) | |
| `CONFIRMED → CANCELLED` | `onHand += qty` (`RESTOCK_CANCEL`) | `carrierAdapter.cancelShipment()` |
| → `RETURNED` | `onHand += qty` (`RESTOCK_RETURN`) | |

After every inventory change, if `available = onHand − reserved ≤ lowStockThreshold` and `lowStockAlertedAt` is null,
set `lowStockAlertedAt` and return the product in `lowStock[]`. Reset `lowStockAlertedAt` when stock rises back above the threshold.
Every transition writes an `OrderEvent`.

### 4.2 Prisma models (main fields)
- **User**: id, email (unique), name, passwordHash, role `ADMIN|AGENT`, createdAt.
- **Customer**: id, phoneE164 (unique), name, email, country, telecomOperator, telegramChatId (unique, nullable),
  telegramUsername, preferredChannel `AUTO|TELEGRAM|EMAIL`, pendingAction `NONE|AWAIT_ADDRESS`, pendingOrderId,
  counters (ordersTotal, delivered, cancelled, returned), createdAt.
- **Order**: id (`ORD-…`), externalId (unique, nullable), source, customerId, status, riskScore Int, riskLevel
  `LOW|MEDIUM|HIGH`, riskReasons Json (`[{code, points, labelAr, labelEn}]`), addressRaw, addressLine, city, governorate,
  country, zone `METRO|REGIONAL|GULF|INTL`, carrier `BOSTA|ARAMEX|DHL`, carrierService, awbNumber (unique), labelPdfPath,
  trackingUrl, subtotal, shippingFee, discount, codAmount, currency, confirmationToken (unique) + tokenExpiresAt,
  contactAttempts, lastContactAt, retentionOffered Bool, cancelReason, notes, confirmedAt, shippedAt, deliveredAt, createdAt,
  updatedAt. Indexes on (status, createdAt), (customerId), (governorate).
- **OrderItem**: orderId, sku, name, qty, unitPrice, unitCost.
- **Product**: sku (unique), nameAr, nameEn, price, costPrice, onHand, reserved, lowStockThreshold (default 5),
  lowStockAlertedAt, active.
- **InventoryMovement**: sku, orderId?, type `RESERVE|RELEASE|DEDUCT|RESTOCK|RESTOCK_CANCEL|RESTOCK_RETURN|ADJUST`, qty,
  actor, createdAt.
- **BlacklistEntry**: type `PHONE|ADDRESS_KEYWORD|NAME|EMAIL`, value, reason, createdBy, createdAt.
- **ConversationLog**: orderId?, customerId?, channel `TELEGRAM|EMAIL|SYSTEM`, direction `IN|OUT`, messageType
  `TEXT|VOICE|BUTTON|TEMPLATE`, content, transcript, intent, sentiment, confidence, escalated Bool, aiModel, externalRef
  (Telegram message/file id), raw Json, createdAt.
- **OrderEvent** (audit trail and live activity feed): orderId?, type (e.g. `ORDER_CREATED`, `STATUS_CHANGED`, `RISK_BLOCKED`,
  `MESSAGE_SENT`, `LOW_STOCK`, `AI_ESCALATION`, `SHIPPING_UPDATE`), fromStatus, toStatus, actor
  `system|n8n|ai|customer|user:<id>`, message, meta Json, createdAt.
- **ShipmentEvent**: orderId, carrier, awbNumber, status, location, occurredAt, raw.
- **Setting**: key (unique) → value Json. Defaults are seeded (see §4.5).

### 4.3 Risk scoring (n8n `code/risk-score.ts`, a pure function; context comes from the internal API)
| Signal | Points |
|---|---|
| Phone on blacklist | +100 (always HIGH) |
| Phone invalid or unparseable for its country | +40 |
| Address contains a blacklist keyword | +40 |
| Same phone ordered in the last 30 minutes (duplicate) | +30 |
| Past cancelled + returned orders ≥ 2 (or exactly 1) | +25 (+10) |
| Repeated or sequential digits (e.g. `01099999999`, `0123456789`) | +25 |
| Fake-looking name (test, asdf, fewer than 3 letters, digits, keyboard mashing) | +20 |
| Address shorter than 15 characters, or no governorate or city detected | +15 |
| First-time customer and COD above `highValueThreshold` | +10 |
| Any SKU with quantity above 5 | +10 |
| At least 1 delivered order in the past | −15 |
| Customer's Telegram already linked | −5 |

Clamp the total to 0–100. **Levels:** ≥ `blockThreshold` (70) is **HIGH**: status `BLOCKED` and an admin fraud alert.
From `reviewThreshold` (40) to 69 is **MEDIUM**: status `NEEDS_REVIEW` and an admin alert. Below 40 is **LOW**: status
`PENDING_CONFIRMATION` and the customer confirmation is sent. Store the breakdown in `riskReasons`.
Reference case from `dashboard2.jpg`: `ORD-FRAUD-9009`, phone `201099999999`, "Blocked Address", ends up `BLOCKED`.

### 4.4 Phone, geo and carrier routing (n8n `code/*.ts`, data in `shared/data/`)
- **Normalize:** convert Arabic-Indic digits (`٠-٩`, `۰-۹`) to ASCII; strip spaces, dashes and brackets; turn a leading `00` into `+`.
- **Egypt:** `01[0125]\d{8}` (or `+201…`). Operator by prefix: 010 Vodafone, 011 e& (Etisalat), 012 Orange, 015 WE.
- **Gulf** (operator detection is best effort): KSA `+9665\d{8}`, UAE `+9715\d{8}`, Kuwait `+965[569]\d{7}`, Qatar
  `+974[3567]\d{7}`, Bahrain `+973[36]\d{7}`, Oman `+968[79]\d{7}`. Anything else goes through the generic E.164 check.
  Store numbers in E.164 and display them without `+` (as in the screenshots).
- **Governorates:** all 27 Egyptian governorates with Arabic and English names plus aliases, including common misspellings (`القاهره`) and
  major districts or cities that map to a governorate (مدينة نصر/Nasr City/المعادي → Cairo; 6 أكتوبر/الشيخ زايد/الهرم → Giza;
  المنصورة/Mansoura → Dakahlia; …). Country comes from the explicit field, then the address text, then the phone country code.
- **Zones and carriers** (configurable in Settings; defaults follow `n8n.jpg`):
  | Zone | Rule | Carrier / service | Manifest |
  |---|---|---|---|
  | `METRO` | Cairo, Giza, Alexandria | **Bosta**, `BOSTA EXPRESS METRO` | Bosta packaging manifest |
  | `REGIONAL` | Other Egyptian governorates | **Aramex**, `ARAMEX REGIONAL` | Aramex packaging manifest |
  | `GULF` | SA, AE, KW, QA, BH, OM | **DHL**, `DHL GULF EXPRESS` | DHL export manifest |
  | `INTL` | Everything else | **DHL**, `DHL INTERNATIONAL` | DHL customs declaration |

### 4.5 Default settings (seeded, editable in Settings)
`storeName`, `currency: EGP`, `riskThresholds {block: 70, review: 40}`, `highValueThreshold: 3000`,
`shippingCosts {BOSTA: {forward: 60, return: 60}, ARAMEX: {80, 80}, DHL: {250, 250}}`, `packagingCost: 10`,
`reminders {firstAfterH: 2, secondAfterH: 6, thirdAfterH: 24, maxAttempts: 3, reviewAfterH: 48, quietHours: "23:00-09:00"}`,
`retention {enabled: true, discountPercent: 10, onlyForReason: ["price"]}`, `lowStockDefault: 5`,
`zoneRules` (the table above), `faq` (a short store FAQ text the AI may use), `channels {telegram: true, email: true}`.

### 4.6 KPI definitions (for the date range the user picks; the default is today, with 7d and 30d options)
- **إجمالي الطلبات (total orders):** count of orders.
- **نسبة التأكيد (confirmation rate):** (CONFIRMED + SHIPPED + DELIVERED + RETURNED) ÷ total.
- **بانتظار التأكيد (awaiting confirmation):** NEW + PENDING_CONFIRMATION.
- **إجمالي COD (total COD):** Σ codAmount of confirmed-or-later orders.
- **الاحتيال / قائمة الحظر (fraud / blacklist):** count of BLOCKED orders, shown with their prevented COD value ("(300 ج.م وفورات)").
- **الأرباح المباشرة (live profit):** Σ over DELIVERED orders of (codAmount − Σ item cost − forward shipping cost), plus a *projected* figure
  for CONFIRMED and SHIPPED orders.
- **الوفورات (money saved):** Σ over BLOCKED orders, and CANCELLED orders that were never shipped, of (forward + return shipping cost
  for their carrier + packagingCost).
- **Funnel bars:** Confirmed %, Pending %, and Cancelled/Fraud % (as in `dashboard.jpg`). **Sparklines:** a daily series for the last 7 days.

---

## 5. Contracts

### 5.1 Public order webhook (WF-01)
`POST https://${N8N_DOMAIN}/webhook/orders` with header `X-Api-Key: ${ORDER_WEBHOOK_KEY}`
```json
{
  "externalId": "store-1001", "source": "website",
  "customer": { "name": "أحمد محمد", "phone": "01012345678", "email": "a@x.com" },
  "shipping": { "address": "12 شارع مصطفى النحاس، مدينة نصر", "city": "مدينة نصر", "governorate": "القاهرة", "country": "EG" },
  "items": [{ "sku": "SKU-PROD-001", "qty": 1, "unitPrice": 150 }],
  "shippingFee": 0, "currency": "EGP", "notes": ""
}
```
**Responses:** `200 {orderId, status, riskScore, riskLevel, carrier, confirmationUrl, telegramLink}` (a Respond to Webhook node after the
order is created) · `401` bad key · `422` validation errors. The store's thank-you page should display `telegramLink`.
The call is idempotent on `externalId`: a repeat returns the existing order.

### 5.2 Internal API (Next.js; header `Authorization: Bearer ${INTERNAL_API_TOKEN}`; token compared in constant time)
| Method | Path | Purpose |
|---|---|---|
| POST | `/api/internal/risk-context` | `{phoneE164, addressNormalized, name, email}` → `{duplicates30m, blacklist:{phone, addressKeyword, name}, history:{total, delivered, cancelled, returned}, telegramLinked}` |
| GET | `/api/internal/products/stock?skus=A,B` | Stock check (onHand, reserved, available) |
| POST | `/api/internal/orders` | Create order + customer upsert + reservation in **one transaction**; body = sanitized order + computed risk, zone, carrier and initial status → `{order, reservation:{ok, shortages[]}, lowStock[], confirmationUrl, telegramLink}` |
| GET | `/api/internal/orders/:id` | Order with items and customer |
| POST | `/api/internal/orders/:id/transition` | `{to, reason?, actor, meta?}` → `{order, lowStock[]}`; returns `409` on an invalid transition |
| POST | `/api/internal/orders/:id/address` | `{addressLine, city, governorate, country, zone, carrier, carrierService}` (already parsed by n8n) |
| POST | `/api/internal/orders/:id/discount` | `{percent}` → recalculated codAmount; sets `retentionOffered` |
| POST | `/api/internal/orders/:id/contact-attempt` | Increments `contactAttempts`, sets `lastContactAt` |
| GET | `/api/internal/orders/stale` | Recovery list: pending orders with their age and attempts; CONFIRMED orders older than 24h that haven't shipped |
| GET | `/api/internal/reports/daily?date=YYYY-MM-DD` | Digest numbers (totals, confirmation %, revenue, blocked, saved, low stock) |
| POST | `/api/internal/customers/link-telegram` | `{token, chatId, username}` → `{customer, order}` |
| GET | `/api/internal/customers/by-telegram/:chatId` | Customer + latest open order + pendingAction |
| POST | `/api/internal/customers/:id/pending-action` | `{action, orderId}` |
| POST | `/api/internal/conversations` | Log an inbound or outbound message |
| POST | `/api/internal/events` | Add an OrderEvent |
| POST | `/api/internal/shipments/:awb/tracking` | `{status: IN_TRANSIT|OUT_FOR_DELIVERY|DELIVERED|RETURNED, location, occurredAt}` → ShipmentEvent + transition |
| GET | `/api/internal/settings` | All settings (n8n reads thresholds, zone rules, templates, FAQ) |
| GET | `/api/health` (public) | `{db: "ok"}` |

### 5.3 Events sent by Next.js to n8n (WF-06)
`POST http://n8n:5678/webhook/dashboard-events` with header `X-Internal-Token`. Body is `{event, orderId, actor, data}`, where `event` is
`ORDER_STATUS_CHANGED | RESEND_CONFIRMATION | ADDRESS_UPDATED | LOW_STOCK`.

---

## 6. n8n workflows (to match `images/n8n.jpg`)

Conventions: stable workflow IDs, so re-importing updates a workflow instead of duplicating it. Name them `COD · WF-0X <Name>`. Use descriptive node
names like the screenshot. Every internal HTTP node uses credential `cred_internal_api`, URL
`={{$env.INTERNAL_API_URL}}/api/internal/...`, 3 retries, and a 10 s timeout. Set WF-99 as the error workflow on every workflow. Add one sticky note
per layer explaining it.

| WF | Trigger | Node chain (display names) |
|---|---|---|
| **WF-00 Send Notification** (sub-workflow) | Execute Workflow Trigger `{audience: customer\|admin, orderId?, channel: auto\|telegram\|email, template, vars, buttons?}` | Resolve Recipient & Channel (auto = Telegram if a chatId exists, otherwise email) → Render Template AR/EN (`code/templates.ts`) → Channel Switch → [Telegram Send (with inline keyboard) \| Send Email SMTP (HTML, RTL)] → Log Outbound Message. Placeholder **disabled** branches for WhatsApp and SMS. |
| **WF-01 Order Ingestion** (Layer 1) | Webhook `POST /orders` (header auth) | Sanitize Phone & Input Data → Validate Phone Number E.164 Structure → Parse Governorate & City Geo-Location → Enrich Address Details → Detect Telecom Operator → Fetch Risk Context (dup 30m, blacklist, history) → Dynamic Risk Scoring Engine (0-100) → Multi-Carrier Destination Router → Switch [Assign Carrier A (Bosta Express) → Format Bosta Manifest \| Carrier B (Aramex Regional) → Format Aramex Manifest \| Carrier C (DHL Gulf/Intl) → Format DHL Customs Declaration] → Check Product Stock Availability → Create Order & Reserve Inventory (internal API) → Respond to Webhook → Risk & Fraud Switch → [HIGH: Send High-Risk Fraud Alert (admin) \| MEDIUM: Send Review Alert (admin) \| LOW: Send Customer Confirmation (email with confirm link + Telegram deep link; Telegram buttons if already linked) → Log Contact Attempt] → Evaluate Low-Stock List → Low-Stock Switch → Push Low-Stock Restock Alert (admin). |
| **WF-02 Shipping Transit** (Layer 2) | Webhook `POST /shipping-update` (header auth) | Parse Shipping Transit Payload → Update Tracking (internal API) → Push Live Shipping Update to Customer (format from `bot.jpg`: `🚚 تحديث الشحنة (WB-…): ✅ تم شحن طلبك …`) → Log Event. |
| **WF-03 Telegram Inbound** (Layer 3 entry) | **Telegram Trigger** (message, callback_query). This must be the only Telegram webhook for the bot. | Sanitize Incoming Payload → Media Router [voice/audio: Get File → Download → **OpenAI Whisper Transcription** (language `ar`) \| text \| callback] → Normalize to `InboundMessage {channel, chatId, text, transcript, callbackData, messageType, raw}` → Execute WF-03b. `/start <token>` → Link Telegram (internal API) → send the order confirmation with buttons. |
| **WF-03b Customer Message Brain** (channel-agnostic, so WhatsApp can reuse it later) | Execute Workflow Trigger + test Webhook `POST /test-inbound` (only when `ENABLE_TEST_HOOKS=true`) | Fetch Customer & Open Order → Log Conversation Entry → Pending-Action Switch (`AWAIT_ADDRESS` → Parse Address → Update Address → confirm new address) → Callback Switch (**Process Button Action Direct**: `act:confirm\|cancel\|addr:<orderId>`, `ret:accept\|decline:<orderId>`; check that the chat owns the order) → else **AI Agent** (OpenAI chat model `$env.OPENAI_MODEL` + Structured Output Parser) → Merge Order & AI Intent Context → **Angry Customer Sentiment Filter** → **Intent Decision Final Router**: confirm → Transition CONFIRMED → Send Receipt + Alert Warehouse Packing Slip (admin) · cancel → (reason=price & retention enabled & not offered yet → **Sales Retention Negotiator** → Dispatch Discount Offer with buttons) else Transition CANCELLED → Send Cancellation Notice · change_address → set `AWAIT_ADDRESS` (or apply `new_address` if already given) · question → reply using order data + settings FAQ only · angry/complaint → Flag NEEDS_REVIEW → **Escalate Frustrated Customer to CS** (admin, with transcript) + calm holding reply · unclear/confidence < 0.6 → Flag NEEDS_REVIEW → Escalate Ambiguous Message. |
| **WF-04 Daily Closing Digest** (Layer 4) | Schedule: 23:59 Africa/Cairo | Fetch Daily Report (internal API) → Format Executive Digest (`📊 DAILY CLOSING DIGEST (YYYY-MM-DD): Total Orders: N, Confirmed: X (P%), Total Revenue: R EGP` + blocked, saved, low stock) → Send to admin Telegram + owner email (HTML). |
| **WF-05 Scheduled Recovery** (Layer 5) | Schedule: every 2h | Query Stale Orders → Evaluate Order Age & Recovery Action (respect quiet hours) → Recovery Action Router → [reminder N (`تذكير: طلبك رقم ORD-… لا يزال بانتظار تأكيدك!…`) + contact attempt \| attempts exhausted & age > reviewAfterH → NEEDS_REVIEW + admin alert \| confirmed but unshipped > 24h → warehouse alert]. |
| **WF-06 Dashboard Events** | Webhook `POST /dashboard-events` (internal token) | Event Switch → notify customer or admin through WF-00 (status changed to CONFIRMED/SHIPPED/CANCELLED, resend confirmation, address updated, low stock). |
| **WF-99 Error Handler** | Error Trigger | Format error → admin Telegram alert → log event. |

**AI structured output schema (WF-03b):**
`{intent: "confirm"|"cancel"|"change_address"|"question"|"complaint"|"unclear", new_address: string|null, cancel_reason: "price"|"changed_mind"|"duplicate"|"delivery_time"|"other"|null, sentiment: "positive"|"neutral"|"negative"|"angry", confidence: 0..1, reply_ar: string}`.
The system prompt says: the customer writes in Egyptian, Gulf or Modern Standard Arabic, or English; answer politely in the customer's dialect; never invent prices, policies or
dates beyond the order data and FAQ given; output only JSON.

**Telegram limitation (by design):** a bot cannot start a chat with a phone number. Customers link themselves through
`https://t.me/${TELEGRAM_BOT_USERNAME}?start=<confirmationToken>`, which is included in the confirmation email and the webhook response.
Until they link, email is the channel. Admin alerts go to `TELEGRAM_ADMIN_CHAT_ID` (a group).

**Email confirmation:** the email buttons open `https://${APP_DOMAIN}/c/<token>`. That page shows the order summary with
**POST** buttons for Confirm, Cancel and Change address, plus a Telegram link. **Never act on a GET request**, because email link scanners would auto-confirm
orders. Tokens expire after 72 hours.

---

## 7. Phases

> Each phase lists **Tasks**, **Deliverables** and **Verify** steps. Do not move on until Verify passes.

### Phase 0: Repository bootstrap and Codex rules
**Tasks**
- `git init`; `.gitignore` (node_modules, .env, n8n/dist, data volumes); pnpm workspace (`apps/web`, `n8n`).
- `AGENTS.md` with the §3 rules, a pointer to `PLAN.md`, and the commands for the dev, test and deploy loops.
- `.env.example` listing every variable (see Phase 1), each with a comment.
- `shared/data/`: `eg-governorates.json` (27 governorates with ar, en, aliases[], zone), `gulf-countries.json`,
  `phone-rules.json`, `statuses.json` (enum → ar/en label, color, Kanban column).

**Verify:** `pnpm install` succeeds; the JSON files parse; `git log` shows `phase-0`.

### Phase 1: Server and Docker infrastructure
**Tasks**
- Server prerequisites (document them in `README.md`): Ubuntu 22.04 or 24.04, at least 2 vCPU and 4 GB RAM, Docker Engine + compose plugin, ufw
  allowing 22, 80 and 443, DNS A records for `APP_DOMAIN` and `N8N_DOMAIN`.
- `docker-compose.yml` services (all with healthchecks and `restart: unless-stopped`; Postgres is **not** published to the host):
  - `postgres` (postgres:16-alpine, volume `pgdata`, `infra/postgres/init.sql` creates DBs and users `n8n`, `cod`, `cod_test`).
  - `n8n` (pinned n8nio/n8n tag). Env: `DB_TYPE=postgresdb`, `DB_POSTGRESDB_*`, `N8N_ENCRYPTION_KEY`,
    `N8N_HOST=${N8N_DOMAIN}`, `N8N_PROTOCOL=https`, `WEBHOOK_URL=https://${N8N_DOMAIN}/`, `GENERIC_TIMEZONE=Africa/Cairo`,
    `TZ`, `N8N_BLOCK_ENV_ACCESS_IN_NODE=false`, `N8N_RUNNERS_ENABLED=true`, `EXECUTIONS_DATA_PRUNE=true`,
    `EXECUTIONS_DATA_MAX_AGE=336`, plus the workflow env vars (`INTERNAL_API_URL=http://web:3000`, `INTERNAL_API_TOKEN`,
    `APP_PUBLIC_URL`, `TELEGRAM_ADMIN_CHAT_ID`, `TELEGRAM_BOT_USERNAME`, `OPENAI_MODEL`, `OPENAI_STT_MODEL`,
    `ENABLE_TEST_HOOKS`). Volume `n8n_data`; mount `./n8n/dist:/workflows:ro`.
  - `web` (built from `apps/web/Dockerfile`: Next.js `output: "standalone"`; the entrypoint runs `prisma migrate deploy`). Env:
    `DATABASE_URL`, `AUTH_SECRET`, `AUTH_URL`, `INTERNAL_API_TOKEN`, `N8N_INTERNAL_URL=http://n8n:5678`, `N8N_API_KEY`,
    `ORDER_WEBHOOK_KEY`, `GOTENBERG_URL=http://gotenberg:3000`, `LABELS_DIR=/data/labels`, `LABEL_SIGNING_SECRET`,
    `TELEGRAM_BOT_TOKEN` (for the health check only). Volume `labels`.
  - `gotenberg` (gotenberg/gotenberg:8; internal only).
  - `caddy` (caddy:2, ports 80 and 443). `Caddyfile`: `{$APP_DOMAIN}` → `web:3000`; `{$N8N_DOMAIN}` → `n8n:5678`, with an optional
    IP allow-list on the editor while `/webhook/*` stays public.
  - Write a commented-out `whatsapp` profile (evolution-api + redis) for the future.
- `.env` variables: `APP_DOMAIN, N8N_DOMAIN, ACME_EMAIL, POSTGRES_PASSWORD, COD_DB_PASSWORD, N8N_DB_PASSWORD,
  N8N_ENCRYPTION_KEY, N8N_API_KEY, AUTH_SECRET, ADMIN_EMAIL, ADMIN_PASSWORD, INTERNAL_API_TOKEN, ORDER_WEBHOOK_KEY,
  LABEL_SIGNING_SECRET, TELEGRAM_BOT_TOKEN, TELEGRAM_BOT_USERNAME, TELEGRAM_ADMIN_CHAT_ID, SMTP_HOST, SMTP_PORT, SMTP_USER,
  SMTP_PASS, SMTP_FROM, OWNER_EMAIL, OPENAI_API_KEY, OPENAI_MODEL, OPENAI_STT_MODEL, STORE_NAME, TZ=Africa/Cairo,
  ENABLE_TEST_HOOKS=false, SEED_DEMO=false`. Add a `scripts/gen-secrets.sh` that fills the random secrets.
- A placeholder Next.js app with `/api/health`, so the stack can start.

**Verify:** `docker compose up -d --build` → `docker compose ps` shows everything healthy;
`curl -I https://$APP_DOMAIN/api/health` returns 200 with a valid certificate; `https://$N8N_DOMAIN` shows the n8n owner setup page (create the owner,
then create an API key and put it in `.env` as `N8N_API_KEY`).

### Phase 2: Database schema, seed and domain core
**Tasks**
- `prisma/schema.prisma` with every model and enum from §4.2; first migration.
- `seed.ts`: an admin user from env (bcrypt); settings defaults (§4.5); products `SKU-PROD-001` (150 EGP), `SKU-PROD-002` (300),
  `SKU-PROD-003` (450, stock 4 so the low-stock alert fires in tests); blacklist (`+201099999999`, keywords such as `عنوان وهمي`,
  `test address`). If `SEED_DEMO=true`, add about 30 demo orders spread across every status, governorate and carrier.
- `server/domain/`: `transitions.ts` (the matrix + `transitionOrder()` with the side effects from §4.1, one DB transaction),
  `inventory.ts` (atomic reserve/release/deduct/restock with a conditional `UPDATE … WHERE onHand - reserved >= qty`),
  `orders.ts` (create with ID retry, idempotent on externalId, token generation), `risk-context.ts`, `events.ts`,
  `reports.ts` (the KPI queries from §4.6), `settings.ts` (typed getters with zod, cached for 30 s).

**Verify:** `pnpm --filter web test` passes Vitest suites against `cod_test`: every allowed and forbidden transition; concurrent
reservations never oversell (run 20 parallel reservations for 10 units); cancelling after confirmation restocks; the low-stock flag fires once and
resets; KPI math matches a fixed fixture (8 orders, giving 50% / 13% / 38% as in `dashboard.jpg`).

### Phase 3: Internal API, carrier adapters, AWB labels, public confirmation page
**Tasks**
- Every route in §5.2 (zod-validated, Bearer token middleware, JSON errors).
- `server/carriers/`: a `CarrierAdapter` interface `{code, displayName, createShipment(order) → {awbNumber, trackingUrl},
  cancelShipment(awb), trackingUrl(awb)}` with mock Bosta, Aramex and DHL implementations (AWB `WB-<8 digits>`), selected by
  `order.carrier`. Also add `POST /api/dev/mock-carrier/:awb/advance`, dashboard only, which moves a mock shipment to the next state by
  calling the n8n WF-02 webhook, for demos.
- **Labels:** the `/awb/[orderId]` page is exactly **4in × 6in** (`@page { size: 4in 6in; margin: 0 }`), black on white, with the Arabic font
  embedded from `public/fonts` (Cairo or Noto Naskh Arabic). Content: carrier and service header ("BOSTA EXPRESS 4x6 AWB"), a large Code128
  barcode made with `bwip-js` (SVG) with the human-readable AWB, order ID, recipient name, phone, full address, governorate, city and zone, a **large COD
  amount**, items (SKU × qty), date, and the sender store name. Access requires a session **or** an HMAC-signed short-lived `?sig=`.
  `server/labels/pdf.ts` calls Gotenberg (`/forms/chromium/convert/url` with the signed internal URL), saves
  `LABELS_DIR/<awb>.pdf`, and `/api/labels/[awb]` serves it to logged-in users. The PDF is generated after the CONFIRMED transition.
- **Public `/c/[token]` page** (Arabic, mobile first, store branding): order summary, POST server actions for confirm, cancel and change
  address (with a form), a Telegram link, and an expired-token state. Each action calls `transitionOrder` and then emits a WF-06 event.
- `server/n8n.ts`: `emitDashboardEvent()` (errors are logged and never block the UI) and `getN8nHealth()` (public API: active workflow
  count and total node count).

**Verify:** a curl script hits every internal route (401 without the token, 422 on bad input, 200 on a good one); after a confirm
transition, `LABELS_DIR/WB-xxxxxxxx.pdf` exists and opens as a 4x6 page with a scannable barcode (check with `zbarimg` if available);
`/c/<token>` confirms through POST only (a GET never changes state).

### Phase 4: n8n toolchain, shared logic, credentials and base workflows
**Tasks**
- `n8n/code/*.ts` as pure, exported functions: `sanitize.ts`, `phone.ts` (normalize, validate, detect operator), `geo.ts`
  (governorate, city, country, zone from `shared/data`), `risk-score.ts` (§4.3), `carrier-route.ts` (§4.4, reads `zoneRules`
  from settings), `manifest.ts` (Bosta, Aramex and DHL manifest objects), `templates.ts` (every AR/EN message: confirmation,
  receipt, cancellation, reminder 1/2/3, retention offer, address request and confirmation, shipping updates, fraud, review and
  low-stock alerts, warehouse packing slip, daily digest, holding reply), `recovery.ts` (decides the recovery action from age, attempts and quiet hours),
  `digest.ts`.
- Vitest tests for every module, covering at least: EG, SA, AE, KW, QA, BH and OM phones, Arabic digits, invalid numbers; the 27 governorates plus aliases; zone and carrier routing
  (Cairo → Bosta, Mansoura → Aramex, Riyadh → DHL Gulf, London → DHL Intl); risk examples (the fraud reference case gives HIGH; a clean returning
  customer gives LOW); quiet-hours logic.
- `scripts/n8n-build.mjs`: bundle each `code/<name>.ts` with **esbuild** (IIFE, no runtime `require`, shared JSON inlined).
  Replace `"jsCode": "@@code:<name>"` in `n8n/src/*.workflow.json` with the bundle plus a wrapper
  (`return __mod.run($input.all(), {now: new Date().toISOString()})`), and write the result to `n8n/dist/`.
- `scripts/n8n-validate.mjs`: fail on invalid JSON, duplicate node names, connections pointing to missing nodes, leftover `@@code:`
  markers, credential IDs missing from the template, duplicate webhook paths, or anything that looks like a hard-coded secret.
- `n8n/credentials.template.json` with **fixed IDs**: `cred_internal_api` (Header Auth: Bearer), `cred_telegram`,
  `cred_smtp`, `cred_openai`, `cred_webhook_key` (Header Auth: `X-Api-Key`), `cred_internal_token` (for WF-06). Values are
  `${ENV}` placeholders.
- `scripts/n8n-import.sh`: build and validate; render the credentials with `envsubst` into a temporary file inside the container; run
  `n8n import:credentials`; delete the temporary file; run `n8n import:workflow --separate --input=/workflows`; activate or publish every workflow through the n8n public API
  (`POST /api/v1/workflows/{id}/activate`, using `N8N_API_KEY`). Check the CLI flags against `n8n --help` for the pinned version.
  If credential import fails, print manual UI steps instead.
- Build **WF-00 Send Notification** and **WF-99 Error Handler**.

**Verify:** `pnpm --filter n8n test` and `pnpm n8n:validate` pass; `scripts/n8n-import.sh` finishes, and the workflows appear active
in the n8n UI with their credentials attached; running WF-00 manually with `audience=admin, template=test` delivers a Telegram message to the
admin group and an email to `OWNER_EMAIL`.

### Phase 5: WF-01 Order Ingestion pipeline (Layer 1)
**Tasks:** build WF-01 exactly as in §6, with the layout and naming of `images/n8n.jpg`. Add `scripts/simulate-order.sh <scenario>`
with the scenarios `cairo`, `mansoura`, `riyadh`, `london`, `fraud`, `duplicate`, `out_of_stock` and `review`.

**Verify** (`scripts/smoke-test.sh ingestion`):
- `cairo` → 200, `PENDING_CONFIRMATION`, carrier BOSTA/METRO, stock reserved, confirmation email received (with the Telegram deep link).
- `mansoura` → ARAMEX/REGIONAL; `riyadh` → DHL/GULF; `london` → DHL/INTL (with a customs declaration built).
- `fraud` (`201099999999`, blacklist address) → `BLOCKED`, risk ≥ 70, admin fraud alert on Telegram, no reservation.
- `duplicate` (same phone twice within 30 minutes) → the second order's risk goes up by 30; `review` → `NEEDS_REVIEW` + an admin alert.
- `out_of_stock` → `NEEDS_REVIEW` with reason OUT_OF_STOCK; ordering the low-stock SKU triggers exactly one restock alert.
- Re-sending the same `externalId` returns the same orderId; a bad `X-Api-Key` returns 401; a missing phone returns 422.

### Phase 6: WF-03 and WF-03b customer inbound and AI brain (Layer 3)
**Tasks:** build both workflows as in §6. Add a `conversation state` for address changes. Log every inbound and outbound message,
including transcript, intent, sentiment and confidence.

**Verify:**
- With a real Telegram account: open the deep link, which links the chat and shows the order with ✅ / ❌ / 📍 buttons. Pressing ✅ gives `CONFIRMED`, stock
  deducted, an AWB and PDF created, a receipt message, and a warehouse alert in the admin group.
- Send the voice note "أيوه أكد الطلب" → it is transcribed and the intent is confirm. The text "مش عايزه غالي أوي" → a retention offer; "موافق" moves to
  CONFIRMED with the discount applied; declining moves to CANCELLED with stock released.
- An angry message ("انتو نصابين!!") → `NEEDS_REVIEW` + an escalation with the transcript; a gibberish message → NEEDS_REVIEW (unclear).
- Pressing 📍 and then sending a new address → the address is re-parsed, the carrier re-routed if the zone changed, and a confirmation is sent.
- A forged callback for someone else's order is rejected.
- With `ENABLE_TEST_HOOKS=true`, `smoke-test.sh inbound` runs the same cases through `/webhook/test-inbound` with canned transcripts.

### Phase 7: WF-02 Shipping, WF-04 Digest, WF-05 Recovery, WF-06 Dashboard Events
**Tasks:** build the four workflows as in §6.

**Verify:**
- Mock carrier advance on a CONFIRMED order → SHIPPED, with a customer message in the `bot.jpg` format; the next advance → DELIVERED (profit KPI
  updates); RETURNED restocks the items.
- Run WF-04 manually → the digest arrives on admin Telegram and by email, and its numbers match `/api/internal/reports/daily`.
- Backdate a pending order by 3 hours (SQL on `cod_test` or the demo data) and run WF-05 → reminder 1 is sent and attempts become 1. Nothing is sent during quiet
  hours. After the maximum attempts and 48 hours → NEEDS_REVIEW + an admin alert.
- WF-06: changing a status from the dashboard (Phase 9) sends the right customer message.

### Phase 8: Dashboard shell
**Tasks**
- Auth.js credentials login (`/login`, Arabic), a middleware that protects every dashboard route, and roles (AGENT cannot open Settings or Users).
- **Design tokens** (from the screenshots): background `#070B14` and `#0B1220`, card `#0F172A`, border `#1E293B`, radius 16px,
  primary cyan `#22D3EE` and blue `#3B82F6`, success `#22C55E`, pending `#F59E0B`, danger `#EF4444`, AI review `#A855F7`. Fonts are
  Cairo (UI) and JetBrains Mono (IDs, AWB) through `next/font`. Include a light theme.
- **Layout (right-to-left):** a right sidebar (logo shield, "Cyber-Ops COD SaaS" with an ENTERPRISE badge and the subtitle "منصة إدارة وتأكيد الطلبات
  الذكية"; the main menu: لوحة المؤشرات (KPI dashboard), لوحة الشحن التفاعلية (interactive shipping board), الخريطة والمحافظات (map and governorates), مركز المخزون (inventory center), سجلات الذكاء الاصطناعي (AI logs), الإعدادات (settings);
  the **infrastructure status card** "حالة البنية التحتية" with n8n Engine `NODES <n>`, PostgreSQL `CONNECTED`, Telegram and SMTP status,
  all checked on the server and cached for 60 s).
- **Header:** Cmd/Ctrl+K search by order ID, phone or name (a command palette); a DB status pill "متصل بالـ CRM" (connected to the CRM); an EN/AR language toggle; a
  theme toggle; the **"محاكاة طلب جديد"** (simulate new order) button, which uses a server action to POST a random realistic order (from the sample pools, with about 10% of them
  fraud-like) to the WF-01 webhook and shows a toast with the result; a settings icon.
- A TanStack Query provider; polling every 5 s for live widgets; optimistic updates for status changes.

**Verify:** login and logout work; an AGENT is blocked from `/settings`; the language toggle switches between RTL and LTR with every string translated;
the simulate button creates an order that appears in search; the infrastructure card shows the real node count.

### Phase 9: Dashboard pages
**Tasks**
- **`/` KPI page** (`dashboard.jpg`): 5 KPI cards with sparklines, plus a profit and money-saved card; a range selector; the
  conversion funnel bars; a revenue, profit and savings chart over 30 days; a **live system activity feed**
  "نشاط النظام المباشر" (latest 20 OrderEvents, green dot, order ID, event text).
- **`/board` Kanban** (`dashboard2.jpg`, `dashboard3.jpg`): 6 columns (NEW, PENDING, CONFIRMED, SHIPPED, NEEDS_REVIEW,
  CANCELLED+BLOCKED) with count badges. Each card shows a carrier badge, the order ID (links to its detail page), phone, address with a pin icon (or the
  block reason for blocked orders), SKUs, a green COD amount, and a risk chip colored by level. Buttons: **بوليصة** (label) opens the AWB modal; **تيليجرام**
  resends the confirmation (disabled with a tooltip when Telegram isn't linked, with an email fallback icon). A **تغيير الحالة...** (change status) select offers only
  the allowed transitions. Drag and drop between columns uses dnd-kit with the same rules, and a 409 rolls back with a toast. Also: filters (carrier, date,
  risk level), a refresh button, and a "mock advance" button on SHIPPED cards.
- **AWB modal** (`dashboard4.jpg`): a white card showing "<CARRIER> 4x6 AWB", ORDER ID, PHONE, AWB, CARRIER, the barcode, and a
  **طباعة البوليصة الحرارية** (print thermal label) button that opens the PDF (or generates it if missing).
- **`/orders/[id]`**: a status timeline (OrderEvents), the risk breakdown table, items, customer history, the conversation thread
  (with voice transcripts), shipment events, edits to address and notes, and a blacklist-this-phone action.
- **`/map`**: an SVG choropleth of the Egyptian governorates using `d3-geo` (GeoJSON from geoBoundaries EGY ADM1, CC-BY, simplified
  with mapshaper and committed to `public/geo/`). Hovering shows a tooltip. A metric toggle switches between orders, confirmation %, cancel/fraud % and COD.
  Add a Gulf and international summary cards and a sortable table per governorate.
- **`/inventory`**: a products table (SKU, name, on hand, reserved, available, threshold, status badge); create and edit products;
  a restock or adjust dialog (which writes an InventoryMovement); movement history; low-stock items highlighted.
- **`/ai-logs`**: a conversation logs table (time, order, channel, direction, type, content or transcript, intent, sentiment,
  confidence, escalated), with filters for intent, sentiment and escalated, and a click-through to the order.
- **`/settings`**: risk thresholds, shipping costs, zone rules, reminders and quiet hours, retention offer, FAQ, and channel
  test buttons (send a test Telegram message and a test email); blacklist management; user management (ADMIN only).

**Verify:** `pnpm --filter web build` has no type errors; Playwright tests (`apps/web/e2e`) cover login → the KPI numbers match
the seeded fixture → dragging a card from PENDING to CONFIRMED updates the KPI, creates an AWB and triggers the WF-06 message → the AWB modal opens the PDF → an invalid
drag (e.g. BLOCKED → SHIPPED) is rejected → the inventory restock updates the available count → the map renders 27 governorates; the layout works at 1440px
and 390px widths.

### Phase 10: End-to-end tests, hardening, backups and go-live
**Tasks**
- A full `scripts/smoke-test.sh all` covering the ingestion scenarios, inbound AI cases, shipping, recovery and digest.
- **Hardening:** security headers in Caddy; n8n editor IP allow-list (optional); change every default password; `ENABLE_TEST_HOOKS=false`
  in production; rotate `INTERNAL_API_TOKEN` and `ORDER_WEBHOOK_KEY`; a per-IP rate limit on the order webhook (a Next.js
  middleware counter or a Caddy rate-limit plugin); n8n execution pruning; confirm Postgres is not exposed.
- **Backups:** `scripts/backup.sh` (a nightly `pg_dump` of the `cod` and `n8n` databases plus a tar of `labels` and `n8n_data`, keeping 14 days,
  with an optional upload to S3-compatible storage); install it in the host crontab; `scripts/restore.sh` and a documented restore drill.
- **`README.md` runbook:** first deployment step by step (DNS → `.env` → `gen-secrets` → `compose up` → n8n owner and API key →
  `n8n-import.sh` → `prisma db seed` → set the Telegram admin group ID → smoke test); how to update (`git pull && docker compose up -d --build && scripts/n8n-import.sh`);
  how to connect a store (the webhook contract in §5.1, with sample snippets for WooCommerce and Shopify webhooks and for a custom checkout);
  troubleshooting (Telegram webhook conflicts, SMTP/Gmail app password, OpenAI quota, Gotenberg fonts).
- **Future work** (document it, don't build it): WhatsApp through Evolution API or Cloud API (a new branch in WF-00 plus a trigger that feeds WF-03b), SMS
  provider, real Bosta, Aramex and DHL adapters, multi-currency, multi-tenancy.

**Verify:** `smoke-test.sh all` is green on the production server; a backup is created and restored into a scratch DB successfully;
the go-live checklist in the README is fully ticked.

---

## 8. Progress Log (Codex updates this after each phase)
- [x] Phase 0: Repository bootstrap
- [x] Phase 1: Infrastructure
- [x] Phase 2: Database and domain core
- [x] Phase 3: Internal API, carriers, labels, confirmation page
- [x] Phase 4: n8n toolchain and base workflows
- [x] Phase 5: WF-01 Ingestion
- [x] Phase 6: WF-03 / WF-03b AI brain
- [x] Phase 7: WF-02 / 04 / 05 / 06
- [x] Phase 8: Dashboard shell
- [ ] Phase 9: Dashboard pages
- [ ] Phase 10: End-to-end tests, hardening, go-live

**Decisions made:**
- 2026-09-19, Phase 0: Pinned pnpm 12.4.2 (the stable version reported by `npm view`) and declared Node 22.x in both project guidance and package metadata. The host currently has Node 24, so `.nvmrc` keeps local and deployment work aligned with the locked Node 22 LTS requirement.
- 2026-09-19, Phase 1: Pinned n8n 2.39.8, Next.js 16.3.5, React 19.3.0, and the other exact package versions reported by the registries. Prisma's `latest` tag was an 8.0.0 release candidate, so the stable 7.10.0 release was selected instead of a prerelease.
- 2026-09-19, Phase 1: Ports 80 and 443 were already owned by the host web server. The Compose acceptance test therefore used an isolated project and loopback ports 18080/18443; all five services became healthy, the three databases and two scoped roles were created, Caddy TLS validated with its local CA, and n8n exposed the owner-setup screen. Public ACME issuance and saving the owner-created `N8N_API_KEY` remain deployment-time steps because no production domains or owner credentials were supplied.
- 2026-09-19, Phase 2: Added the internal `Order.inventoryReserved` flag. A `NEEDS_REVIEW` order can mean either medium risk with stock reserved or an out-of-stock order with nothing reserved; the flag makes later confirmation, cancellation, and unblock transitions apply inventory exactly once.
- 2026-09-19, Phase 2: Inventory reservation uses sorted `FOR NO KEY UPDATE` row locks plus the required conditional update at `READ COMMITTED`. This remains oversell-safe while avoiding PostgreSQL foreign-key deadlocks that occur when order items acquire key-share locks before a stronger product-row lock.
- 2026-09-19, Phase 2: Prisma 7 requires a PostgreSQL driver adapter, so the app uses the exactly matched `@prisma/adapter-pg` 7.10.0. The production entrypoint invokes the installed Prisma binary directly so the non-root runtime never attempts to relink dependencies.
- 2026-09-19, Phase 3: Label rendering and downloads use five-minute HMAC-signed URLs until Auth.js is introduced in Phase 8; this keeps both the Gotenberg render URL and direct PDF endpoint private without moving authentication ahead of its planned phase.
- 2026-09-19, Phase 3: Settings defaults live in `shared/data/default-settings.json`, which is parsed by the application and read by the standalone Prisma seed. This prevents the production seed from depending on application source modules that are intentionally absent from the minimal runtime image.
- 2026-09-19, Phase 4: The pinned n8n image is extended only with `envsubst`, copied from a pinned Alpine build stage, because the hardened upstream image has no package manager. This keeps credential rendering inside the container without changing the n8n runtime version.
- 2026-09-19, Phase 4: Code-node bundles use an esbuild IIFE with a virtual entry that assigns the exported `run` function to the wrapper scope. This avoids CommonJS export helpers that n8n's task runner does not expose reliably; the generated bundle was executed in the pinned container.
- 2026-09-19, Phase 4: Notification delivery acceptance used a Telegram Bot API-compatible local endpoint and Mailpit, targeted at the configured admin chat and `OWNER_EMAIL`. Live external delivery remains a deployment check because production Telegram and SMTP credentials were not supplied.
- 2026-09-19, Phase 5: WF-01 validates `X-Api-Key` in an explicit first workflow branch instead of n8n's built-in Header Auth mode. The pinned n8n version returns 403 for a bad Header Auth credential, while the public webhook contract requires a precise 401 response; the key still comes only from `ORDER_WEBHOOK_KEY`.
- 2026-09-19, Phase 5: The webhook response is emitted immediately after the internal API atomically creates the order and attempts its reservation. Risk notifications, the contact-attempt audit, and the one-shot low-stock alert continue on sibling branches so channel latency does not delay the storefront response.
- 2026-09-19, Phase 6: `n8n-import.sh` activates workflows in retry passes so referenced sub-workflows are published before their callers. This preserves stable workflow IDs while allowing a clean import regardless of filename sort order.
- 2026-09-19, Phase 6: WF-03 is the only Telegram Trigger and contains the real bot, voice-download, and Arabic Whisper path; WF-03b contains the real OpenAI Chat Model, AI Agent, and Structured Output Parser path. Acceptance used the secured `ENABLE_TEST_HOOKS` webhook with canned structured analyses plus a Telegram-compatible local gateway because no production Telegram account or OpenAI key was supplied; live provider verification remains a deployment check.
- 2026-09-19, Phase 6: A price-objection retention offer applies the configured discount when the offer is dispatched and marks `retentionOffered`, then the customer acceptance confirms that already-discounted order. This uses the existing atomic discount endpoint and prevents repeated offers without adding persistence outside the planned schema.
- 2026-09-19, Phase 7: WF-04 and WF-05 include authenticated, feature-flagged test webhooks alongside their Cairo schedules. These make digest and recovery acceptance deterministic without weakening production behavior; requests return 404 unless `ENABLE_TEST_HOOKS=true` and the order webhook key matches.
- 2026-09-19, Phase 7: Dashboard-event payloads are mapped by a pure, unit-tested module before calling WF-00. Provider selection therefore stays in the notification gateway and dashboard callers remain independent of Telegram and SMTP details.
- 2026-09-19, Phase 8: Auth.js 5 credentials sessions use a twelve-hour signed JWT and duplicate authorization at both the route proxy and ADMIN-only server pages. A live temporary AGENT acceptance account was blocked from both Settings and Users, then removed.
- 2026-09-19, Phase 8: The dashboard reads n8n through a dedicated API key restricted to `workflow:list`; the infrastructure card reports all imported and pre-existing workflows (243 nodes) without write access. Only WF-99, WF-00, and WF-01 were published to support safe order simulation while Telegram, SMTP, and OpenAI remain visibly unconfigured.
- 2026-09-19, Phase 8: Phase 9 modules intentionally render translated readiness placeholders. Browser-level acceptance verified Arabic RTL, English LTR, the real simulator button, and search discovery of the newly created order at the public production URL.
