feat(docker): multi-stage Dockerfiles with npmmirror registry

Rewrites dashboard and finder Dockerfiles to use a clean multi-stage
build (deps → builder → runner) that installs npm packages inside
Alpine Linux, avoiding the SWC musl binary issue when building from
Windows host. Uses registry.npmmirror.com for reliable installs from
restricted networks (Iran).

- docker/api/Dockerfile: .NET 10 multi-stage build
- docker/web/Dockerfile: Node 20-alpine multi-stage, npmmirror
- docker/finder/Dockerfile: Node 20-alpine multi-stage, npmmirror
- docker/website/Dockerfile: marketing website build
- scripts/: PowerShell helper scripts for local dev

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-27 21:33:29 +03:30
parent 45cd028d1c
commit 03376b3ea1
20 changed files with 5519 additions and 0 deletions
+230
View File
@@ -0,0 +1,230 @@
# Meezi — Current State & Handoff for Next-Step Planning
> **Purpose:** Give this file to Claude (or any planner) to design the next implementation batch.
> **Product:** Meezi (میزی) — Persian-first SaaS POS + community for Iranian cafés (Tehran/Karaj V1).
> **Full product context:** `.cursorrules`, `MEEZI_CURSOR_GUIDE.md` (read before planning).
**Last updated:** 2026-05-21
---
## 1. Stack snapshot (current)
| Layer | Technology | Location |
|-------|------------|----------|
| Backend | **ASP.NET Core 10**, C# | `src/Meezi.API` |
| Core / infra | EF Core **10**, Npgsql | `src/Meezi.Core`, `src/Meezi.Infrastructure` |
| Web dashboard | Next.js 14, TypeScript, next-intl | `web/dashboard` |
| Mobile | Flutter 3 | `mobile/meezi_app`, `mobile/meezi_pos` |
| DB / cache | PostgreSQL 16, Redis | `docker-compose.yml` |
| Jobs / realtime | Hangfire, SignalR KDS | API |
| SDK pin | `global.json` → 10.0.100 | repo root |
| Central packages | `Directory.Packages.props` | repo root |
| Target framework | `net10.0` via `Directory.Build.props` | all C# projects |
**Build / test status (local):**
- `dotnet build src/Meezi.API/Meezi.API.csproj -c Release`**OK**
- `dotnet test tests/Meezi.API.Tests/Meezi.API.Tests.csproj -c Release`**13/13 passed**
- CI (`.github/workflows/ci.yml`) — API on `10.0.x`, web `npm run build`, Flutter analyze (continue-on-error)
---
## 2. Recently completed (do not re-plan unless fixing gaps)
### 2.1 .NET 10 migration
- All backend projects on `net10.0`.
- Central package management; Microsoft + EF + Npgsql at **10.0.0**.
- FluentValidation: `FluentValidation` + `FluentValidation.DependencyInjectionExtensions` (no `FluentValidation.AspNetCore`).
- Docker API image: `dotnet/sdk:10.0`, `dotnet/aspnet:10.0`; Dockerfile copies `global.json`, `Directory.Build.props`, `Directory.Packages.props`.
- `Program.cs` refactored: `Program.BuildWebApplication(args, configureBeforeServices, configureAfterServices)` + `Main` for integration tests.
- Testing mode: `Testing:Enabled=true` → Hangfire memory storage, no Hangfire server/dashboard/recurring jobs; faster Redis connect options.
- Fix: `RefreshTokenStore` uses `value.ToString()` for JSON deserialize (.NET 10 overload ambiguity).
- Integration tests: `MeeziWebApplicationFactory` + `UseTestServer()`, in-memory EF, config **before** `AddMeeziServices`.
### 2.2 POS table-session workflow (backend + dashboard)
**Domain / DB**
- `Order.GuestPhone`, `Table.IsCleaning`, `TableBoardStatus.Cleaning`.
- Migration: `PosTableSessionFields` (`20260520165836_PosTableSessionFields`).
**API (`OrderService`, controllers)**
- Single open order per table (merge/upsert).
- `POST /api/cafes/{cafeId}/orders/{id}/items` — append lines.
- `PATCH /api/cafes/{cafeId}/orders/{id}/session` — guest name/phone, customer link.
- `GET .../orders/open?search=` — open orders search.
- `GET .../tables/{id}/active-order`.
- `PATCH .../tables/{id}/cleaning`.
- Guards: `TABLE_OCCUPIED`, cleaning blocks new orders.
- `OrderDto`: `GuestPhone`, `CustomerPhone`, `PaidAmount`, `Payments[]`.
**Dashboard POS (`web/dashboard`)**
- `pos-table-board.tsx` — table board (order + pay modes).
- `pos-screen.tsx` — URL session `?tableId=&orderId=`, hydrate/append, debounced session PATCH.
- `pos-pay-panel.tsx` — pay by table board, dropdown, search, split payments (Cash/Card/Credit).
- `pos-customer-picker.tsx` — CRM search + quick-create guest.
- `cart.store.ts``customerId`, `activeOrderId`, `hydrateFromOrder`, `getPendingLines`.
- i18n: `fa.json`, `en.json`, `ar.json` POS strings.
**Tests**
- `tests/Meezi.API.Tests/OrderSessionTests.cs` — merge per table, append, search, payment frees board, cleaning block (in-memory DB).
### 2.3 Docker / local dev (Iran constraints)
- `docker-compose.yml` — default `up` runs full stack (no `full` profile gate).
- `docker/api/Dockerfile` — Docker Hub images (not `mcr.microsoft.com`).
- `scripts/docker-up-full.ps1`, `scripts/run-local-dev.ps1`, `docs/DOCKER.md`, registry mirror example.
- **Known ops:** Docker Desktop/WSL 500/EOF; image pulls often need VPN/mirror in Iran. Documented workaround: postgres+redis in Docker, API via `dotnet run`, dashboard via `npm run dev`.
---
## 3. Key files (quick navigation)
```
src/Meezi.API/
Program.cs # BuildWebApplication + Main
Extensions/ServiceCollectionExtensions.cs # DI, Hangfire, Redis, Testing:Enabled
Services/OrderService.cs # Table session / append / search
Services/TableService.cs # Board, cleaning
Services/RefreshTokenStore.cs
Controllers/OrdersController.cs
Controllers/TablesController.cs
src/Meezi.Core/Entities/
Order.cs, Table.cs
src/Meezi.Infrastructure/Data/Migrations/
*PosTableSessionFields*
web/dashboard/src/components/pos/
pos-screen.tsx, pos-table-board.tsx, pos-pay-panel.tsx, pos-customer-picker.tsx
web/dashboard/src/lib/stores/cart.store.ts
tests/Meezi.API.Tests/
OrderSessionTests.cs
Integration/HealthIntegrationTests.cs
Integration/MeeziWebApplicationFactory.cs
docker/api/Dockerfile
docker-compose.yml
global.json, Directory.Build.props, Directory.Packages.props
```
---
## 4. Open issues & tech debt (good planning inputs)
| Item | Severity | Notes |
|------|----------|--------|
| NU1903 AutoMapper 12.0.1 | Medium | Transitive vulnerability warning on build |
| NU1903 System.Security.Cryptography.Xml 9.0.0 | Medium | Via Infrastructure transitive |
| Docker full stack in Iran | Ops | Pull timeouts; mirror/VPN documented |
| E2E tests | Low | No Playwright/API E2E for POS flow yet |
| Flutter / mobile | — | Not updated for table-session APIs |
| `MEEZI_PRD.md` | — | Referenced in rules but may be missing at repo root; use `.cursorrules` + guide |
| Hangfire in production | — | PostgreSQL storage; tests use memory only when `Testing:Enabled` |
---
## 5. Suggested themes for the *next* planning batch
Use these as prompts for Claude; pick 13 per sprint.
### A. Hardening & quality
- Bump or replace packages with NU1903 warnings.
- More integration tests: auth, append-items HTTP, payment split, cleaning API.
- E2E: dashboard POS happy path (table → items → pay → board free).
### B. POS / operations polish
- Receipt print preview / thermal bridge alignment with session order id.
- Table board realtime (SignalR invalidate on order/payment/cleaning).
- Edge cases: void line, transfer table, merge tables, staff permissions per action.
### C. CRM & customer on orders
- Enforce plan limits on CRM create from POS picker.
- Sync `GuestPhone` with SMS OTP / Kavenegar flows where applicable.
- Customer history on pay panel (last visit, points if in scope).
### D. Infrastructure & deploy
- Verify `docker compose` build on .NET 10 in CI (optional job).
- Arvan Cloud deploy checklist; env-specific `appsettings`.
- Redis required services: mock or Testcontainers for CI integration tests.
### E. Mobile (Flutter POS)
- Offline Drift sync for open table orders.
- Call new append/session/active-order endpoints from `meezi_pos`.
### F. Billing / plan limits
- Enforce `PLAN_LIMIT_REACHED` on daily orders, terminals, SMS from POS paths.
- Upgrade CTA in dashboard when limits hit.
---
## 6. API conventions (must keep in plans)
- Multi-tenant: every EF query filters `CafeId == _tenant.CafeId`.
- Responses: `ApiResponse<T>` / `ApiError` with codes e.g. `PLAN_LIMIT_REACHED`, `TABLE_OCCUPIED`.
- Protected routes: `/api/cafes/{cafeId}/...` — JWT `cafeId` must match.
- i18n: no hardcoded UI strings in dashboard; use `messages/{fa,ar,en}.json`.
- RTL: `ms-*` / `me-*` only in dashboard CSS.
---
## 7. How to run locally (for validators)
```powershell
# DB + Redis
docker compose up -d postgres redis
# API (.NET 10)
cd src/Meezi.API
dotnet run
# http://localhost:5080 (or per launchSettings)
# Dashboard
cd web/dashboard
npm run dev
# http://localhost:3101 (typical)
# Tests
dotnet test tests/Meezi.API.Tests/Meezi.API.Tests.csproj -c Release
```
**appsettings.json (API):** Postgres `localhost:5434`, Redis `localhost:6381` — matches `docker-compose` / `.env.example` defaults.
---
## 8. Planning instructions for Claude
When generating a plan from this file:
1. Read `.cursorrules` and `MEEZI_CURSOR_GUIDE.md` for non-negotiables.
2. Do **not** redo .NET 10 migration or POS table-session core unless fixing a listed gap.
3. Propose **small, reviewable PRs** (backend / web / mobile / infra separated).
4. Include **test plan** per feature (unit + manual steps).
5. Call out **Iran/Docker** constraints if the plan involves container builds or image pulls.
6. Respect plan tiers: Free / Pro / Business / Enterprise limits on orders, CRM, SMS, branches.
---
## 9. Out of scope for next batch (unless user asks)
- Rewriting Next.js or Flutter major versions.
- Full Sepidz parity / enterprise white-label.
- Tax (Taraz) production integration beyond stubs.
- Snappfood production webhook hardening (HMAC exists; expand when needed).
---
*End of handoff — attach this file plus the specific user goal when asking Claude to plan.*
+95
View File
@@ -0,0 +1,95 @@
# Meezi development demo
## Café
| Field | Value |
|-------|--------|
| Slug | `demo-cafe` |
| ID | `cafe_demo_001` |
| Branch | `branch_demo_main` |
## Staff (OTP login)
Phones and roles are listed in [`data/demo-credentials.json`](../data/demo-credentials.json).
| Role | Phone | Name |
|------|-------|------|
| Owner | `09121234567` | مدیر دمو |
| Manager | `09121111111` | مدیر شعبه |
| Cashier | `09122222222` | صندوقدار |
| Waiter | `09123333333` | گارسون |
| Waiter | `09124444444` | گارسون ۲ |
| Chef | `09125555555` | آشپز |
| Delivery | `09126666666` | پیک |
In **Development**, OTP is logged by the API (no SMS).
## System admin
| Field | Value |
|-------|--------|
| Login URL | `http://localhost:3101/fa/admin/login` |
| Phone | `09120000001` |
After OTP login you can manage **plans/prices**, **integrations** (ZarinPal, NextPay, Vandar tokens + Kavenegar SMS), **notifications** (broadcast to all cafes, list/delete), **app settings**, **feature flags**, **cafes** (suspend/activate), and **support tickets**.
| Admin page | Path |
|------------|------|
| Integrations | `/fa/admin/integrations` |
| Notifications | `/fa/admin/notifications` |
Apply migration `SystemAdminPlatform` if the database was created before this feature.
## Merchant support
Dashboard → **پشتیبانی** (`/fa/support`) — open tickets; platform admins reply from **Admin → Tickets**.
## Appearance (per café)
Each café can customize **dashboard + guest menu** colors in **تنظیمات → ظاهر و رنگ‌بندی**:
- **17 color palettes** (Meezi green, ocean, wine bar, espresso, …)
- **8 panel styles** (flat, modern, glass, minimal, bold, soft, elevated, outline)
- **6 menu layouts** (cards, compact, grid, list, magazine, classic)
- **Density** + **corner radius**
- **9 custom hex colors** (primary, secondary, accent, background, …)
Preset catalog: [`data/cafe-theme-presets.json`](../data/cafe-theme-presets.json)
## Category icons
Menu categories support **preset icons** (26 types) with **10 design styles**: flat, modern, real, minimal, outline, soft, bold, gradient, pastel, duotone — or pick from **100+ emojis** grouped by category theme.
- Pick style + food/drink preset under **مدیریت منو → دسته جدید → آیکون آماده**
- Catalog: [`data/category-icon-presets.json`](../data/category-icon-presets.json)
## Menu (Food-101)
- **48** demo items across 6 categories — see [`data/demo-menu-food101.json`](../data/demo-menu-food101.json)
- Images: Unsplash URLs in [`data/menu-image-manifest.json`](../data/menu-image-manifest.json) until you import real JPEGs
- Items without a Kaggle file still get a class-matched fallback image
### Reseed menu (existing DB)
Restart the API in Development — `DevelopmentDataSeeder` adds any **missing** catalog rows and refreshes images/translations.
### Import real Food-101 photos (optional)
1. Download [Food-101](https://www.kaggle.com/datasets/kmader/food41) (or `food-101` images folder).
2. Run:
```bash
dotnet run --project tools/MenuImageImporter -- --food101 "C:\path\to\food-101\images" --cafe cafe_demo_001
```
3. Update manifest `imageUrl` to `/uploads/cafe_demo_001/{itemId}.jpg` or restart API seeder after copying files.
### Regenerate manifest from catalog
```bash
dotnet run --project tools/SyncMenuManifest/SyncMenuManifest.csproj
```
## Tables
- Table **1** QR: `demo_table_01``/api/q/demo_table_01`
+150
View File
@@ -0,0 +1,150 @@
# Running Meezi fully in Docker
## Quick start
```powershell
cd F:\Projects\Meezi
copy .env.example .env # if not done yet
powershell -File scripts\docker-up-full.ps1
```
Or manually:
```powershell
docker compose up -d --build
```
| Service | URL |
|-----------|-----|
| Dashboard | http://localhost:3101/fa/login |
| API | http://localhost:5080/swagger |
| Health | http://localhost:5080/health |
Demo OTP phone: `09121234567`
## If API build fails pulling .NET images
Default bases are **MCR** (official):
- `mcr.microsoft.com/dotnet/sdk:10.0`
- `mcr.microsoft.com/dotnet/aspnet:10.0`
Override in `.env` if needed:
```env
DOTNET_SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:10.0
DOTNET_ASPNET_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0
```
### `insufficient_scope` / `pull access denied` on `docker.io/dotnet/sdk:10.0`
That usually means **Docker Hub auth**, not a missing .NET 10 tag. Do **not** rely on `dotnet/sdk:10.0` on Docker Hub unless pulls work on your machine.
1. **Clear bad Hub login** (fixes many `insufficient_scope` errors):
```powershell
docker logout
docker pull mcr.microsoft.com/dotnet/sdk:10.0
docker pull mcr.microsoft.com/dotnet/aspnet:10.0
```
2. **VPN** — then pre-pull MCR and build:
```powershell
docker pull mcr.microsoft.com/dotnet/sdk:10.0
docker pull mcr.microsoft.com/dotnet/aspnet:10.0
docker compose up -d --build
```
3. **Registry mirror** — Docker Desktop → Settings → Docker Engine, merge [`docker/daemon-registry-mirror.example.json`](../docker/daemon-registry-mirror.example.json), Apply & Restart.
4. **Pre-pull all images with VPN**, then build offline:
```powershell
docker pull mcr.microsoft.com/dotnet/sdk:10.0
docker pull mcr.microsoft.com/dotnet/aspnet:10.0
docker pull postgres:16-alpine
docker pull redis:7-alpine
docker pull node:20-alpine
docker compose up -d --build
```
## NuGet restore fails inside Docker (`SSL_ERROR_SSL`, `bad record mac`)
Example during `dotnet restore`:
```text
Failed to download package 'Microsoft.CodeAnalysis.CSharp.4.14.0' from 'https://api.nuget.org/...'
Decrypt failed with OpenSSL error - SSL_ERROR_SSL
error:0A000119:SSL routines::decryption failed or bad record mac
```
This is **not a Meezi code bug** — TLS to `api.nuget.org` is being corrupted or cut off inside the build container (unstable VPN, antivirus HTTPS scan, filtered ISP, Docker Desktop network glitch).
**Try in order:**
1. **Restart Docker Desktop** fully (Quit → start again). After `Ctrl+C` mid-build, the engine often returns `500 Internal Server Error` until restarted.
2. **VPN on** for the whole build (connect *before* `docker compose build`, keep it on until restore finishes).
3. **Pre-restore on the host** (uses Windows network, often more reliable), then rebuild:
```powershell
cd F:\Projects\Meezi
dotnet restore src\Meezi.API\Meezi.API.csproj
docker compose build api
docker compose up -d
```
4. **Disable HTTPS inspection** in antivirus / corporate proxy for Docker Desktop and `docker.exe`.
5. **Retry** — `docker compose build api --no-cache` once network is stable.
The API `Dockerfile` uses `nuget.config` retries, `NUGET_CERT_REVOCATION_MODE=offline`, and a NuGet cache mount to make repeat builds easier.
## Docker Desktop `500 Internal Server Error` on `docker compose up`
```text
unable to get image 'meezi-web': request returned 500 Internal Server Error
... dockerDesktopLinuxEngine ...
```
The Linux engine crashed or is stuck (common after aborting a long build).
```powershell
# 1) Quit Docker Desktop from the tray (not only close the window)
# 2) Optional — reset WSL backend (Docker Desktop → WSL2):
wsl --shutdown
# 3) Start Docker Desktop, wait until it says "Running"
docker version
docker compose build
docker compose up -d
```
If `docker version` hangs, Docker is still broken — reboot Windows or **Troubleshoot → Reset to factory defaults** in Docker Desktop (last resort).
## Why the API image does not run `apt-get`
Older Dockerfiles installed `curl` for healthchecks, which downloads Ubuntu packages during **build** (`archive.ubuntu.com`). That often fails in Iran without VPN.
The API image now uses a **TCP healthcheck** on port 8080 instead — no extra OS packages.
## Commands
```powershell
docker compose ps
docker compose logs -f api
docker compose down
docker compose down -v # also removes DB volume
```
## Infra-only fallback (host API)
Only if Docker cannot build .NET images at all:
```powershell
docker compose up -d postgres redis
cd src\Meezi.API
$env:RUN_MIGRATIONS="true"
dotnet run
```
+852
View File
@@ -0,0 +1,852 @@
# Meezi — Branch Ownership Model + Branch Credentials
> **Copy-paste this entire file into Cursor.**
> Stack: ASP.NET Core 10, EF Core 10, Next.js 14, next-intl, PostgreSQL 16
> Prerequisite: Branch entity already exists (migration AddBranchEntity applied)
---
## Architecture Decisions (read before coding)
### Menu Ownership
- MenuItem and MenuCategory live at the **Cafe (owner) level** — global catalog
- Each branch gets a `BranchMenuItemOverride` row to disable or reprice an item
- Branch managers CANNOT create new menu items — only toggle availability and override price (Pro+ plan)
- No override row = item is active at master price (implicit inheritance)
### Table Ownership
- Tables are **100% per-branch** — BranchId is required, non-nullable
- Owner can manage any branch's tables; Branch Manager manages only their own
- Table sections (سالن, تراس, VIP) are also per-branch
### Staff / Credentials
- One user account per person (global to cafe)
- `UserBranchAssignment` links user → branch with a role
- JWT contains active `branchId` claim
- Multi-branch users get a branch-picker step after login
- POS terminals use a short `TerminalPin` for quick device unlock (secondary auth)
### Settings Inheritance
- `CafeSettings` = owner defaults
- `BranchSettings` = per-branch overrides (null = use cafe default)
- A `GetEffectiveSetting<T>()` helper resolves the right value
---
## PROMPT 1 — Menu Ownership: BranchMenuItemOverride
```
Context: Meezi POS, ASP.NET Core 10, EF Core 10.
MenuItem and MenuCategory already exist with CafeId (owner-level catalog).
Goal: Add per-branch availability and price override for menu items.
────────────────────────────────────────────────────────────────
STEP 1 — New entity: BranchMenuItemOverride
────────────────────────────────────────────────────────────────
File: src/Meezi.Core/Entities/BranchMenuItemOverride.cs
Properties:
- Id (Guid)
- CafeId (Guid) — tenant guard
- BranchId (Guid)
- MenuItemId (Guid)
- IsAvailable (bool, default true) — false = hidden at this branch
- PriceOverride (decimal?) — null = use MenuItem.BasePrice
- SortOrderOverride (int?) — null = use MenuItem.SortOrder
- UpdatedAt (DateTime)
- UpdatedByUserId (Guid)
Unique constraint: (BranchId, MenuItemId) — one override row per branch+item.
Navigation properties: Branch, MenuItem.
────────────────────────────────────────────────────────────────
STEP 2 — EF migration
────────────────────────────────────────────────────────────────
dotnet ef migrations add BranchMenuItemOverride \
--project src/Meezi.Infrastructure \
--startup-project src/Meezi.API \
--output-dir Data/Migrations
Verify migration creates:
- branch_menu_item_overrides table
- unique index on (branch_id, menu_item_id)
- fk to branches, menu_items, app_users
────────────────────────────────────────────────────────────────
STEP 3 — Update MenuService to resolve effective menu per branch
────────────────────────────────────────────────────────────────
File: src/Meezi.API/Services/MenuService.cs
Add method: GetBranchMenuAsync(Guid branchId, CancellationToken ct)
Logic:
1. Load all MenuItems where CafeId == _tenant.CafeId and IsActive == true
2. Load all BranchMenuItemOverride rows for this branchId
3. For each item, apply override:
- if override.IsAvailable == false → exclude from result
- if override.PriceOverride != null → use override price
- if override.SortOrderOverride != null → use override sort
4. Return as BranchMenuItemDto list
BranchMenuItemDto:
- All existing MenuItemDto fields
- EffectivePrice (decimal) — resolved price
- IsOverridden (bool) — true if any override row exists
- HasPriceOverride (bool)
────────────────────────────────────────────────────────────────
STEP 4 — New endpoint for branch override management
────────────────────────────────────────────────────────────────
File: src/Meezi.API/Controllers/MenuController.cs — add to existing controller:
GET /api/cafes/{cafeId}/branches/{branchId}/menu
→ Returns resolved branch menu (effective prices, filtered unavailable)
→ Used by POS and KDS screens
PUT /api/cafes/{cafeId}/branches/{branchId}/menu/{menuItemId}/override
→ Body: { isAvailable: bool, priceOverride: decimal? }
→ Creates or updates BranchMenuItemOverride row
→ Authorization: Owner always allowed; Manager allowed only if plan >= Pro
→ If Manager and plan == Free: return PLAN_LIMIT_REACHED
with message: "Price overrides require Pro plan"
DELETE /api/cafes/{cafeId}/branches/{branchId}/menu/{menuItemId}/override
→ Removes override row (resets to catalog defaults)
→ Owner only
────────────────────────────────────────────────────────────────
STEP 5 — Plan gate in BranchMenuOverrideService
────────────────────────────────────────────────────────────────
In UpsertOverrideAsync:
- If request.PriceOverride != null:
→ Check cafe plan tier
→ Free plan: return ApiResponse.Fail("PLAN_LIMIT_REACHED",
"قیمت‌گذاری شعبه‌ای نیاز به پلن Pro دارد")
→ Pro+: allow
────────────────────────────────────────────────────────────────
STEP 6 — Update POS screen to use branch menu endpoint
────────────────────────────────────────────────────────────────
File: web/dashboard/src/lib/api/menu.ts
Update getBranchMenu() to call:
GET /api/cafes/{cafeId}/branches/{branchId}/menu
instead of the global menu endpoint.
The cart.store.ts should use effective price from BranchMenuItemDto.EffectivePrice,
not MenuItem.BasePrice.
────────────────────────────────────────────────────────────────
STEP 7 — Dashboard: Branch Menu Management UI
────────────────────────────────────────────────────────────────
File: web/dashboard/src/components/menu/branch-menu-overrides.tsx (new)
UI: A table showing all menu items for the selected branch:
Columns: نام | قیمت اصلی | قیمت شعبه | وضعیت | عملیات
- Toggle switch per item for IsAvailable
- Price input field (Pro plan only — show lock icon + upgrade CTA on Free)
- "بازنشانی" (Reset) button per item to delete override
- Visual indicator on rows with active overrides
This component is embedded in the existing /menu page under a "تنظیمات شعبه" tab,
visible only when a specific branch is selected (not in "all branches" view).
────────────────────────────────────────────────────────────────
STEP 8 — i18n strings
────────────────────────────────────────────────────────────────
Add to messages/fa.json, en.json, ar.json under "branchMenu" key:
fa.json:
"branchMenu": {
"title": "منوی شعبه",
"masterPrice": "قیمت اصلی",
"branchPrice": "قیمت شعبه",
"availability": "وضعیت",
"available": "فعال",
"unavailable": "غیرفعال",
"resetOverride": "بازنشانی",
"priceOverridePro": "قیمت‌گذاری اختصاصی برای پلن Pro",
"overrideActive": "تنظیمات شعبه فعال",
"confirmReset": "آیا می‌خواهید تنظیمات این آیتم را به حالت پیش‌فرض برگردانید؟"
}
en.json:
"branchMenu": {
"title": "Branch Menu",
"masterPrice": "Master Price",
"branchPrice": "Branch Price",
"availability": "Status",
"available": "Active",
"unavailable": "Hidden",
"resetOverride": "Reset",
"priceOverridePro": "Price overrides require Pro plan",
"overrideActive": "Branch override active",
"confirmReset": "Reset this item to catalog defaults?"
}
────────────────────────────────────────────────────────────────
TESTS (add to tests/Meezi.API.Tests/BranchMenuTests.cs)
────────────────────────────────────────────────────────────────
✓ GetBranchMenu_ExcludesUnavailableItems
✓ GetBranchMenu_AppliesPriceOverride_WhenSet
✓ GetBranchMenu_UsesMasterPrice_WhenNoOverride
✓ UpsertOverride_FreePlan_PriceOverride_ReturnsPlanLimitReached
✓ UpsertOverride_ProPlan_PriceOverride_Succeeds
✓ DeleteOverride_ResetsToMasterPrice
✓ Override_UniqueConstraint_UpsertNotDuplicate
Conventions:
- All EF queries: CafeId == _tenant.CafeId
- Response: ApiResponse<T> / ApiError with codes
- No hardcoded strings in dashboard — all in messages/*.json
- RTL: ms-*/me-* only in CSS
```
---
## PROMPT 2 — Table Ownership: Per-Branch Tables + Sections
```
Context: Meezi POS, ASP.NET Core 10, EF Core 10.
Table entity currently has CafeId. Branch entity exists.
Goal: Make tables fully branch-owned. Add table sections per branch.
────────────────────────────────────────────────────────────────
STEP 1 — Add BranchId to Table (if not already added)
────────────────────────────────────────────────────────────────
File: src/Meezi.Core/Entities/Table.cs
Add:
public Guid BranchId { get; set; }
public Guid? SectionId { get; set; } // nullable — section is optional
public virtual Branch Branch { get; set; } = null!;
public virtual TableSection? Section { get; set; }
────────────────────────────────────────────────────────────────
STEP 2 — New entity: TableSection
────────────────────────────────────────────────────────────────
File: src/Meezi.Core/Entities/TableSection.cs
Properties:
- Id (Guid)
- CafeId (Guid)
- BranchId (Guid)
- Name (string) — e.g. "سالن اصلی", "تراس", "VIP", "روف‌گاردن"
- SortOrder (int, default 0)
- IsActive (bool, default true)
Navigation: Branch, Tables (ICollection<Table>)
────────────────────────────────────────────────────────────────
STEP 3 — EF migration
────────────────────────────────────────────────────────────────
dotnet ef migrations add BranchTableOwnership \
--project src/Meezi.Infrastructure \
--startup-project src/Meezi.API \
--output-dir Data/Migrations
Migration must:
- Add branch_id (Guid, not null) to tables
→ For existing rows: set to the cafe's default/first branch
→ Script: UPDATE tables SET branch_id = (SELECT id FROM branches
WHERE cafe_id = tables.cafe_id LIMIT 1)
- Add section_id (Guid, nullable) to tables
- Create table_sections table
- Add FK tables.branch_id → branches.id
- Add FK tables.section_id → table_sections.id
────────────────────────────────────────────────────────────────
STEP 4 — Update TablesController + TableService
────────────────────────────────────────────────────────────────
All existing table endpoints already scoped by cafeId.
Add branchId scoping:
GET /api/cafes/{cafeId}/branches/{branchId}/tables
→ Returns tables for this branch only
→ Includes section name in TableDto
POST /api/cafes/{cafeId}/branches/{branchId}/tables
→ Creates table for this branch
PATCH /api/cafes/{cafeId}/branches/{branchId}/tables/{id}
→ Updates table (name, capacity, section, sort order)
DELETE /api/cafes/{cafeId}/branches/{branchId}/tables/{id}
→ Soft delete — only if no open order on table
→ Returns TABLE_HAS_OPEN_ORDER if blocked
Sections endpoints:
GET /api/cafes/{cafeId}/branches/{branchId}/tables/sections
POST /api/cafes/{cafeId}/branches/{branchId}/tables/sections
PATCH /api/cafes/{cafeId}/branches/{branchId}/tables/sections/{id}
DELETE /api/cafes/{cafeId}/branches/{branchId}/tables/sections/{id}
→ Cannot delete section with active tables → TABLE_SECTION_HAS_TABLES
Authorization:
Owner: full CRUD on any branch
Manager: CRUD on their assigned branch only
Waiter: read-only (GET only)
In TableService, all queries:
.Where(t => t.CafeId == _tenant.CafeId && t.BranchId == branchId)
────────────────────────────────────────────────────────────────
STEP 5 — Update POS board to pass branchId
────────────────────────────────────────────────────────────────
File: web/dashboard/src/components/pos/pos-table-board.tsx
Currently fetches tables for whole cafe.
Update to fetch: GET /branches/{activeBranchId}/tables
activeBranchId comes from:
1. JWT claim (decoded from token)
2. Or branch context stored in Zustand/session
Group tables by section in the board UI:
- Section headers: "سالن اصلی", "تراس", etc.
- Tables within each section as cards
- "بدون بخش" group for tables with no section
────────────────────────────────────────────────────────────────
STEP 6 — Dashboard: Table Management UI
────────────────────────────────────────────────────────────────
File: web/dashboard/src/components/tables/tables-screen.tsx
Add branch selector at top (only visible to Owner — managers see their branch only).
Add section management panel:
- List sections with edit/delete
- Drag to reorder sections
- Assign table to section via dropdown on table card
────────────────────────────────────────────────────────────────
STEP 7 — i18n strings
────────────────────────────────────────────────────────────────
Add under "tables" key:
fa.json:
"tables": {
"section": "بخش",
"sections": "بخش‌ها",
"addSection": "افزودن بخش",
"noSection": "بدون بخش",
"sectionHasTables": "این بخش دارای میز است و قابل حذف نیست",
"tableHasOpenOrder": "این میز دارای سفارش باز است"
}
────────────────────────────────────────────────────────────────
TESTS (add to tests/Meezi.API.Tests/BranchTableTests.cs)
────────────────────────────────────────────────────────────────
✓ GetTables_ReturnsBranchTablesOnly
✓ GetTables_DoesNotReturnOtherBranchTables
✓ CreateTable_AssignsToBranch
✓ DeleteTable_WithOpenOrder_ReturnsTableHasOpenOrder
✓ DeleteSection_WithTables_ReturnsTableSectionHasTables
✓ ManagerCannotAccessOtherBranchTables
```
---
## PROMPT 3 — Branch Credentials: UserBranchAssignment + JWT
```
Context: Meezi POS, ASP.NET Core 10. AppUser, Branch entities exist.
Goal: Implement per-branch staff assignment, branch-scoped JWT, and
branch selector flow after login.
────────────────────────────────────────────────────────────────
STEP 1 — New entity: UserBranchAssignment
────────────────────────────────────────────────────────────────
File: src/Meezi.Core/Entities/UserBranchAssignment.cs
Properties:
- Id (Guid)
- CafeId (Guid)
- UserId (Guid)
- BranchId (Guid)
- Role (enum: Owner | Manager | Cashier | Waiter | KitchenStaff)
- IsActive (bool, default true)
- AssignedAt (DateTime)
- AssignedByUserId (Guid)
Navigation: User, Branch, AssignedBy
Unique constraint: (UserId, BranchId) — one role per user per branch.
Note: Owner role assignment is auto-created when a branch is created.
Owner can be assigned to all branches automatically.
────────────────────────────────────────────────────────────────
STEP 2 — Update AppUser
────────────────────────────────────────────────────────────────
File: src/Meezi.Core/Entities/AppUser.cs
Add navigation:
public virtual ICollection<UserBranchAssignment> BranchAssignments { get; set; }
Remove any direct Role property if it exists — role is now per-branch.
Keep a CafeRole (Owner/Admin) for cafe-level operations if needed.
────────────────────────────────────────────────────────────────
STEP 3 — EF migration
────────────────────────────────────────────────────────────────
dotnet ef migrations add UserBranchAssignment \
--project src/Meezi.Infrastructure \
--startup-project src/Meezi.API \
--output-dir Data/Migrations
Migration must:
- Create user_branch_assignments table
- Unique index on (user_id, branch_id)
- FK to app_users, branches
- Seed: for existing users, create assignment rows based on
their current role + the cafe's first/default branch
────────────────────────────────────────────────────────────────
STEP 4 — Update JWT claims
────────────────────────────────────────────────────────────────
File: src/Meezi.API/Services/AuthService.cs (or TokenService.cs)
Current token likely contains: sub, cafeId, role, exp
Update to contain:
{
"sub": "userId",
"cafeId": "xxx",
"branchId": "yyy", ← active branch for this session
"role": "Manager", ← role in THAT branch
"branchIds": ["yyy","zzz"], ← all branches this user can access (for picker UI)
"exp": ...
}
Method: GenerateBranchToken(AppUser user, Guid branchId)
1. Load UserBranchAssignment for this user + branchId
2. If not found or not active → throw UnauthorizedException
3. Load all active assignments for this user → branchIds claim
4. Build token with branchId + role from assignment
────────────────────────────────────────────────────────────────
STEP 5 — New auth endpoints
────────────────────────────────────────────────────────────────
File: src/Meezi.API/Controllers/AuthController.cs — add:
POST /api/auth/login
(existing — unchanged, but response now includes branchIds list)
Response:
{
"token": "...", ← if user has exactly ONE branch, this is branch-scoped
"requiresBranchSelect": true, ← if user has multiple branches
"branches": [ ← list for the picker UI
{ "id": "yyy", "name": "شعبه ولیعصر", "role": "Manager" },
{ "id": "zzz", "name": "شعبه کرج", "role": "Cashier" }
]
}
POST /api/auth/select-branch [Authorize] — requires valid login token
Body: { "branchId": "yyy" }
→ Validates user is assigned to this branch
→ Returns a new branch-scoped JWT
→ Old token is invalidated (or just let it expire — branch select issues new token)
Response:
{
"token": "...", ← branch-scoped token with branchId + role claims
"branchName": "شعبه ولیعصر",
"role": "Manager"
}
POST /api/auth/switch-branch [Authorize] — for already-logged-in users
Body: { "branchId": "zzz" }
→ Same as select-branch but can be called mid-session
→ Returns new token for the new branch
→ Used when owner wants to switch between branches without re-login
────────────────────────────────────────────────────────────────
STEP 6 — Update ITenantService to resolve BranchId from JWT
────────────────────────────────────────────────────────────────
File: src/Meezi.API/Services/TenantService.cs (or middleware)
Current: resolves CafeId from JWT claim "cafeId"
Update: also resolve BranchId from JWT claim "branchId"
Interface:
public interface ITenantContext
{
Guid CafeId { get; }
Guid? BranchId { get; } // nullable — owner-level calls may not have branchId
string Role { get; }
Guid UserId { get; }
}
In all service methods that need branch scope:
var branchId = _tenant.BranchId
?? throw new InvalidOperationException("Branch context required");
For owner-level endpoints (e.g. menu catalog management):
Only CafeId needed, BranchId can be null.
────────────────────────────────────────────────────────────────
STEP 7 — Staff Management endpoints
────────────────────────────────────────────────────────────────
File: src/Meezi.API/Controllers/HrController.cs — add branch assignment endpoints:
GET /api/cafes/{cafeId}/branches/{branchId}/staff
→ List all UserBranchAssignment for this branch (active only)
→ Includes user name, phone, role, assignedAt
POST /api/cafes/{cafeId}/branches/{branchId}/staff
Body: { userId, role }
→ Creates UserBranchAssignment
→ If user doesn't exist yet, caller should first POST /api/cafes/{cafeId}/users
→ Owner only
PATCH /api/cafes/{cafeId}/branches/{branchId}/staff/{userId}
Body: { role?, isActive? }
→ Updates assignment (change role or deactivate)
→ Owner only; Manager can deactivate but not change roles
DELETE /api/cafes/{cafeId}/branches/{branchId}/staff/{userId}
→ Soft delete (sets isActive = false)
→ Cannot remove the last active Owner from a branch
GET /api/cafes/{cafeId}/users
→ All users belonging to this cafe (unfiltered by branch)
→ Owner only — used to populate "add staff to branch" picker
────────────────────────────────────────────────────────────────
STEP 8 — TerminalPin: POS device quick-access
────────────────────────────────────────────────────────────────
For shared POS tablets where multiple cashiers use the same device.
Not a replacement for full auth — just a quick unlock per shift.
File: src/Meezi.Core/Entities/AppUser.cs — add:
public string? TerminalPin { get; set; } // 4-6 digit PIN, hashed
File: src/Meezi.API/Controllers/AuthController.cs — add:
POST /api/auth/pin-login
Body: { cafeId, branchId, pin }
[AllowAnonymous]
→ Finds user with matching PIN assigned to this branch
→ Returns a short-lived token (2h expiry, "Cashier" or actual role)
→ Rate limited: max 5 attempts per branchId per 15 minutes
→ Returns PIN_INVALID or BRANCH_NOT_FOUND on failure (never reveals which)
In AuthService:
VerifyPin(string inputPin, string storedHash) — use BCrypt or Argon2
PIN management endpoint:
PATCH /api/cafes/{cafeId}/users/{userId}/pin
Body: { pin: "1234" }
→ Owner or the user themselves
→ Validate: 4-6 digits, not trivially sequential (1234, 0000)
→ Hash and store
────────────────────────────────────────────────────────────────
STEP 9 — Dashboard: Branch Selector UI (post-login)
────────────────────────────────────────────────────────────────
File: web/dashboard/src/app/[locale]/select-branch/page.tsx (new)
Shown after login when requiresBranchSelect === true.
Shows a card grid of branches the user is assigned to.
Each card: branch name, address, user's role at that branch.
On click → POST /auth/select-branch → store new token → redirect to dashboard.
If user has only 1 branch → skip this page, auto-redirect to dashboard.
File: web/dashboard/src/lib/stores/auth.store.ts (or existing auth store)
Add:
activeBranchId: string | null
activeBranchName: string | null
availableBranches: { id, name, role }[]
switchBranch: (branchId: string) => Promise<void>
switchBranch:
→ POST /auth/switch-branch
→ Update token in storage
→ Update activeBranchId in store
→ Trigger full data reload (invalidate all React Query cache)
File: web/dashboard/src/components/layout/sidebar.tsx (or nav)
Add branch switcher dropdown in header:
- Shows current branch name
- Dropdown lists other assigned branches
- Click → switchBranch()
- Only visible if user has 2+ branches
- Owner always sees all branches
────────────────────────────────────────────────────────────────
STEP 10 — i18n strings
────────────────────────────────────────────────────────────────
messages/fa.json — add:
"auth": {
"selectBranch": "انتخاب شعبه",
"selectBranchPrompt": "لطفاً شعبه مورد نظر خود را انتخاب کنید",
"switchBranch": "تغییر شعبه",
"currentBranch": "شعبه فعال",
"pinLogin": "ورود با پین",
"enterPin": "پین خود را وارد کنید",
"pinInvalid": "پین نادرست است",
"pinTooManyAttempts": "تعداد تلاش‌های مجاز تمام شد. لطفاً ۱۵ دقیقه صبر کنید"
},
"staff": {
"title": "کارکنان",
"addStaff": "افزودن کارمند",
"role": "نقش",
"assignedAt": "تاریخ تخصیص",
"deactivate": "غیرفعال کردن",
"roles": {
"Owner": "مالک",
"Manager": "مدیر",
"Cashier": "صندوقدار",
"Waiter": "گارسون",
"KitchenStaff": "آشپزخانه"
}
}
messages/en.json — add:
"auth": {
"selectBranch": "Select Branch",
"selectBranchPrompt": "Please select your branch to continue",
"switchBranch": "Switch Branch",
"currentBranch": "Active Branch",
"pinLogin": "PIN Login",
"enterPin": "Enter your PIN",
"pinInvalid": "Invalid PIN",
"pinTooManyAttempts": "Too many attempts. Please wait 15 minutes"
},
"staff": {
"title": "Staff",
"addStaff": "Add Staff",
"role": "Role",
"assignedAt": "Assigned",
"deactivate": "Deactivate",
"roles": {
"Owner": "Owner",
"Manager": "Manager",
"Cashier": "Cashier",
"Waiter": "Waiter",
"KitchenStaff": "Kitchen Staff"
}
}
────────────────────────────────────────────────────────────────
TESTS (add to tests/Meezi.API.Tests/BranchCredentialsTests.cs)
────────────────────────────────────────────────────────────────
✓ Login_SingleBranch_ReturnsBranchScopedToken
✓ Login_MultiBranch_ReturnsRequiresBranchSelect
✓ SelectBranch_ValidAssignment_ReturnsBranchToken
✓ SelectBranch_UnassignedBranch_ReturnsUnauthorized
✓ SwitchBranch_UpdatesTokenClaims
✓ PinLogin_ValidPin_ReturnsShortLivedToken
✓ PinLogin_InvalidPin_ReturnsPinInvalid
✓ PinLogin_FiveFailures_ReturnsRateLimited
✓ ManagerCannotSeeOtherBranchStaff
✓ CannotRemoveLastOwnerFromBranch
✓ GetTables_UsesJwtBranchId_ReturnsCorrectBranch
```
---
## PROMPT 4 — Branch Settings Inheritance
```
Context: Meezi POS, ASP.NET Core 10, EF Core 10.
CafeSettings entity exists with owner-level defaults.
Goal: Add BranchSettings that override cafe defaults using fallback resolution.
────────────────────────────────────────────────────────────────
STEP 1 — New entity: BranchSettings
────────────────────────────────────────────────────────────────
File: src/Meezi.Core/Entities/BranchSettings.cs
Properties (all nullable — null = use cafe default):
- Id (Guid)
- CafeId (Guid)
- BranchId (Guid, unique)
- ReceiptHeader (string?) — override cafe receipt header
- ReceiptFooter (string?) — e.g. branch address / phone
- TaxRate (decimal?) — local tax override
- ServiceCharge (decimal?) — optional service charge %
- OperatingHours (string?) — JSON: { mon: {open:"08:00", close:"23:00"}, ... }
- WifiPassword (string?) — shown on receipt optionally
- Currency (string?) — for future multi-currency, default "IRR"
- UpdatedAt (DateTime)
Unique constraint: one row per branch.
────────────────────────────────────────────────────────────────
STEP 2 — EF migration
────────────────────────────────────────────────────────────────
dotnet ef migrations add BranchSettings \
--project src/Meezi.Infrastructure \
--startup-project src/Meezi.API \
--output-dir Data/Migrations
────────────────────────────────────────────────────────────────
STEP 3 — EffectiveSettingsService
────────────────────────────────────────────────────────────────
File: src/Meezi.API/Services/EffectiveSettingsService.cs (new)
public class EffectiveSettingsService
{
// Returns branch value if set, otherwise cafe default
public async Task<BranchEffectiveSettingsDto> GetEffectiveSettingsAsync(
Guid cafeId, Guid branchId, CancellationToken ct)
{
var cafeSettings = await _db.CafeSettings
.FirstOrDefaultAsync(s => s.CafeId == cafeId, ct);
var branchSettings = await _db.BranchSettings
.FirstOrDefaultAsync(s => s.BranchId == branchId, ct);
// Resolve with fallback
return new BranchEffectiveSettingsDto
{
ReceiptHeader = branchSettings?.ReceiptHeader ?? cafeSettings?.ReceiptHeader,
ReceiptFooter = branchSettings?.ReceiptFooter ?? cafeSettings?.ReceiptFooter,
TaxRate = branchSettings?.TaxRate ?? cafeSettings?.TaxRate ?? 0m,
ServiceCharge = branchSettings?.ServiceCharge ?? cafeSettings?.ServiceCharge ?? 0m,
OperatingHours = branchSettings?.OperatingHours ?? cafeSettings?.OperatingHours,
// IsOverridden flags for UI (show which settings are branch-specific)
TaxRateIsOverridden = branchSettings?.TaxRate != null,
ReceiptIsOverridden = branchSettings?.ReceiptHeader != null
|| branchSettings?.ReceiptFooter != null,
};
}
}
────────────────────────────────────────────────────────────────
STEP 4 — Settings endpoints
────────────────────────────────────────────────────────────────
Add to existing CafeSettingsController or new BranchSettingsController:
GET /api/cafes/{cafeId}/branches/{branchId}/settings
→ Returns BranchEffectiveSettingsDto (resolved with fallback)
→ Shows which fields are overridden vs inherited
PATCH /api/cafes/{cafeId}/branches/{branchId}/settings
Body: partial BranchSettings fields
→ Upserts BranchSettings row
→ Only fields included in body are updated
→ Tax rate override: Owner only (or Manager on Pro+ plan)
DELETE /api/cafes/{cafeId}/branches/{branchId}/settings/{field}
→ Clears a specific override field (resets to cafe default)
→ e.g. DELETE .../settings/taxRate
────────────────────────────────────────────────────────────────
STEP 5 — Use effective settings in receipt + order flow
────────────────────────────────────────────────────────────────
In OrderService.CloseOrderAsync:
var settings = await _effectiveSettings.GetEffectiveSettingsAsync(cafeId, branchId, ct);
order.TaxAmount = order.SubTotal * settings.TaxRate;
order.ServiceCharge = order.SubTotal * settings.ServiceCharge;
In PosReceiptModal (dashboard):
Fetch GET /branches/{branchId}/settings on receipt open.
Show ReceiptHeader, ReceiptFooter, WifiPassword on receipt.
────────────────────────────────────────────────────────────────
TESTS
────────────────────────────────────────────────────────────────
✓ GetEffectiveSettings_UsesBranchOverride_WhenSet
✓ GetEffectiveSettings_FallsBackToCafe_WhenNoOverride
✓ GetEffectiveSettings_BothNull_ReturnsDefaults
✓ PatchSettings_OnlyUpdatesIncludedFields
✓ DeleteSettingField_ResetsToInheritedValue
✓ OrderClose_AppliesBranchTaxRate
```
---
## Execution Order
```
Branch 1: feature/branch-menu-ownership
→ PROMPT 1 (BranchMenuItemOverride)
→ Tests: BranchMenuTests.cs
Branch 2: feature/branch-table-sections (can parallel with branch 1)
→ PROMPT 2 (TableSection + BranchId on Table)
→ Tests: BranchTableTests.cs
Branch 3: feature/branch-credentials (depends on branch 1+2 merged)
→ PROMPT 3 (UserBranchAssignment + JWT + PIN)
→ Tests: BranchCredentialsTests.cs
Branch 4: feature/branch-settings (depends on branch 3 merged)
→ PROMPT 4 (BranchSettings + EffectiveSettingsService)
→ Tests: inline in SettingsTests.cs
```
---
## Permission Matrix (implement in all service methods)
| Action | Owner | Manager (own branch) | Cashier | Waiter |
|--------|-------|---------------------|---------|--------|
| Create/edit menu items | ✅ | ❌ | ❌ | ❌ |
| Toggle item availability at branch | ✅ | ✅ | ❌ | ❌ |
| Override item price at branch | ✅ | ✅ Pro+ only | ❌ | ❌ |
| Create/edit tables | ✅ | ✅ own branch | ❌ | ❌ |
| Create table sections | ✅ | ✅ own branch | ❌ | ❌ |
| Assign staff to branch | ✅ | ❌ | ❌ | ❌ |
| Deactivate staff at branch | ✅ | ✅ own branch | ❌ | ❌ |
| Edit branch settings | ✅ | ✅ own branch | ❌ | ❌ |
| Override tax rate | ✅ | ✅ Pro+ only | ❌ | ❌ |
| View other branch data | ✅ | ❌ | ❌ | ❌ |
| Switch active branch | ✅ all | ✅ if assigned | ❌ | ❌ |
| PIN login | — | ✅ | ✅ | ✅ |
---
## New Error Codes This Sprint
| Code | Meaning |
|------|---------|
| `BRANCH_NOT_FOUND` | BranchId not found or wrong cafe |
| `REQUIRES_BRANCH_SELECT` | Login succeeded but branch must be chosen |
| `BRANCH_UNASSIGNED` | User not assigned to requested branch |
| `PIN_INVALID` | PIN login failed |
| `PIN_RATE_LIMITED` | Too many PIN attempts |
| `TABLE_SECTION_HAS_TABLES` | Cannot delete section with active tables |
| `LAST_OWNER_PROTECTED` | Cannot remove last owner from branch |
| `PLAN_LIMIT_BRANCH_MENU` | Price override requires Pro plan |
---
*End of plan — start with PROMPT 1 (BranchMenuItemOverride), it has zero auth dependencies.*
+393
View File
@@ -0,0 +1,393 @@
# Meezi — Feature Roadmap Plan
> **Purpose:** Implementation plan for growth, operations, integrations, platform, and quality items.
> **Audience:** Product + engineering (solo/small team with Cursor).
> **Conventions:** `.cursorrules`, `ApiResponse<T>`, multi-tenant `CafeId`, `messages/{fa,ar,en}.json`, plan tiers Free / Pro / Business / Enterprise.
> **Last updated:** 2026-05-22
### Implementation status (started)
| Item | Status |
|------|--------|
| Q-1 `docs/SECURITY.md` | Done |
| Q-2 Playwright (`web/dashboard/e2e/`) | Done (API smoke + discover page) |
| Q-3 k6 `tests/load/public-abuse.js` | Done |
| G-1 API discover filters | Done |
| G-1 UI `/[locale]/discover` | Done (MVP) |
| Order `DisplayNumber` (digits-only) | Done |
| Table board SignalR (via KDS hub) | Done |
| O-4 Terminal Redis enforcement | Done (API + settings UI) |
| P-1 Admin split (`web/admin` + compose) | Partial (redirect from dashboard) |
| G-1 discover detail + Neshan embed | Done |
| G-6 Review owner reply (dashboard + public) | Done |
| G-4 Loyalty earn on pay | Done (1 pt / 10k ت) |
| O-1 Public queue ticket + plan gate + SMS | Done |
| O-3 Shift close UI | Done (`/shifts`) |
| I-2 Snappfood outbound on status/pay | Done |
---
## How to use this doc
- Work in **phases** (below); each phase is 24 weeks of focused PRs.
- Split work into **small PRs**: `api` | `dashboard` | `mobile` | `infra` | `docs`.
- Mark items **Built (thin)** vs **Greenfield** — dont rebuild what already exists.
- **Plan gates** are called out per feature; wire via `IPlatformCatalogService` + `PlanLimitMiddleware` / `IPlanLimitChecker`.
---
## Current baseline (relevant to this roadmap)
| Area | Already in repo |
|------|------------------|
| Discover profile | `CafeDiscoverProfile`, merchant/admin editor, `GET /api/public/discover`, taxonomy |
| Reviews | `CafeReview`, public create; **`OwnerReply` on entity** — UI/public display thin |
| Queue | `QueueController`, `queue-screen.tsx`, feature flag `queue` in seeder |
| Loyalty | `Customer.LoyaltyPoints` field — **no earn/redeem rules or UI** |
| Delivery | Inbound webhooks (Snappfood/Tap30/Digikala), `DeliveryStatusSyncService`**outbound Snappfood partial** |
| Terminals | `PlanLimits.MaxTerminals`, JWT/header patterns — **enforcement incomplete** |
| Security | Turnstile + Redis abuse limits — **`docs/SECURITY.md` missing** |
| Admin | `Meezi.Admin.API` exists; **admin UI still in `web/dashboard`** |
---
## Phase overview
```mermaid
flowchart LR
P0[Phase 0\nQuality + Ops]
P1[Phase 1\nDiscover public]
P2[Phase 2\nGrowth CRM]
P3[Phase 3\nOperations]
P4[Phase 4\nIntegrations]
P5[Phase 5\nPlatform Enterprise]
P0 --> P1
P1 --> P2
P2 --> P3
P3 --> P4
P1 --> P4
P4 --> P5
```
| Phase | Theme | Outcome | Duration |
|-------|--------|---------|----------|
| **0** | Quality & ops docs | Safe public surface, CI confidence | 12 weeks |
| **1** | Public discover | Consumer-facing کافه‌یاب for Tehran/Karaj | 23 weeks |
| **2** | Growth & community | Loyalty, reviews 2.0, badges | 34 weeks |
| **3** | Operations | Queue polish, printers, shifts, terminals | 34 weeks |
| **4** | Integrations | Maps, delivery parity, hardware onboarding | 34 weeks |
| **5** | Platform & Enterprise | Admin split, API keys, audit, export | 46 weeks |
---
## Phase 0 — Quality & operations (do first)
### Q-1 — `docs/SECURITY.md` (ops)
| | |
|--|--|
| **Effort** | S (1 day) |
| **Deliverable** | Turnstile setup, Redis limits, rate-limit table, Arvan WAF/CDN rules (OTP, `/api/public/*`, `/api/q/*`), `X-Forwarded-For`, incident checklist |
| **Acceptance** | On-call can enable CAPTCHA and edge rules without reading source |
### Q-2 — Playwright E2E (dashboard)
| | |
|--|--|
| **Effort** | M (35 days) |
| **Scope** | `web/dashboard/e2e/`: auth OTP mock or test phone, POS happy path (table → item → pay), QR public order smoke (optional second project) |
| **CI** | Job on PR; secrets for test DB/API |
| **Acceptance** | 23 stable tests green in GitHub Actions |
### Q-3 — Load tests (public QR + OTP)
| | |
|--|--|
| **Effort** | M (23 days) |
| **Tool** | k6 or NBomber script in `tests/load/` |
| **Scenarios** | `GET /api/q/{code}`, `GET /api/public/.../menu`, `POST` guest order, `POST /api/auth/send-otp` |
| **Acceptance** | Document p95 targets; verify `429` / `RATE_LIMITED` under abuse |
### Q-4 — Package / Docker hardening
| | |
|--|--|
| **Effort** | S | NU1903 bumps, `nuget.config` + Dockerfile (done), CI `docker compose build api` optional job |
**Phase 0 exit:** SECURITY doc published, 2+ E2E tests, load script runnable locally.
---
## Phase 1 — Growth: public discover homepage
### G-1 — Public discover web app (Tehran / Karaj)
| | |
|--|--|
| **Effort** | L (1.52 weeks) |
| **Plan** | Free to browse; Pro+ cafés appear when `discover_profile` filled & `IsVerified` |
| **Route** | `web/dashboard/src/app/[locale]/(public)/discover/` **or** separate `web/discover` (lighter SEO) — recommend **public routes inside dashboard** first to reuse API client + i18n |
| **API** | Extend `GET /api/public/discover` with filters: `city` (تهران/کرج), `themes[]`, `vibes[]`, `occasions[]`, `spaceFeatures[]`, `noise`, `priceTier`, `minRating`, `sort` (rating, distance later) |
| **Backend** | Filter in SQL/EF on deserialized `DiscoverProfileJson` (JSONB query on PostgreSQL) or materialized columns if perf needed |
| **UI** | Filter chips (taxonomy from `GET /api/public/discover-profile/taxonomy`), café cards (cover, rating, badges, price tier), detail page → menu link / map link |
| **i18n** | `discoverPublic.*` in fa/ar/en |
| **Acceptance** | User can filter “date + outdoor + کرج” and open café detail; RTL correct |
### G-2 — Neshan maps (discover + detail) — *can start in Phase 1 or 4*
See **I-1** below; for discover MVP, static map embed on detail is enough.
**Phase 1 exit:** Public discover listing + detail live at `/fa/discover` (or dedicated host).
---
## Phase 2 — Growth & community (depth)
### G-3 — Customer accounts (lightweight)
| | |
|--|--|
| **Effort** | L (2 weeks) |
| **Plan** | Pro+ for “registered guests”; optional SMS OTP customer auth |
| **Model** | `CustomerAccount` (phone PK per platform or per cafe), link to `Customer` on first order; JWT `role=customer` scoped to optional `cafeId` or global |
| **API** | `POST /api/auth/customer/send-otp`, `verify-otp`, `GET /api/customers/me/orders`, `GET /api/customers/me/reservations` |
| **Apps** | `meezi_app`: login, order history; discover web: “my orders” |
| **Acceptance** | Returning guest sees past orders after OTP; no merge with staff JWT |
### G-4 — Loyalty points (earn / redeem)
| | |
|--|--|
| **Effort** | ML (1.5 weeks) |
| **Built** | `Customer.LoyaltyPoints` |
| **Rules** | `LoyaltyRule` per cafe: earn % of paid order, min redeem, expiry; Business+ feature flag `loyalty` |
| **API** | Earn on order `Paid`; redeem as discount line on POS; `PATCH` adjust (manager) |
| **UI** | CRM customer row, POS pay panel preview, SMS on milestone (optional) |
| **Acceptance** | Closed order increases points; redeem reduces total with audit row |
### G-5 — Café badges (Enterprise)
| | |
|--|--|
| **Effort** | M (1 week) |
| **Plan** | Enterprise only; admin assigns badges |
| **Model** | `CafeBadge` (key, labelFa, icon, assignedAt) or JSON on `Cafe` |
| **API** | Admin CRUD; public discover DTO includes `badges[]` |
| **UI** | Admin café detail; discover cards show badge chips |
| **Acceptance** | Only Enterprise cafés display admin-assigned badges |
### G-6 — Review photos + owner responses (polish)
| | |
|--|--|
| **Effort** | M (1 week) |
| **Built** | `OwnerReply`, `OwnerRepliedAt` on `CafeReview` |
| **Photos** | `CafeReviewPhoto` (url, sort); upload via public or authenticated; max 3 photos, 5MB, MIME validate |
| **API** | `POST /api/public/cafes/{slug}/reviews` multipart; `PATCH /api/cafes/{cafeId}/reviews/{id}/reply` (owner) |
| **UI** | Dashboard reviews screen: reply editor; public discover detail: reviews + photos |
| **Moderation** | `IsHidden` flag; admin can hide (abuse) |
| **Acceptance** | Owner reply visible on public page; photos optional |
**Phase 2 exit:** Loyalty + review 2.0 + badges; customer OTP optional for pilot.
---
## Phase 3 — Operations
### O-1 — Queue / waitlist (polish, not greenfield)
| | |
|--|--|
| **Effort** | SM (35 days) |
| **Built** | `IQueueService`, `queue-screen.tsx` |
| **Gaps** | Plan gate `queue` (Business+); public “take number” QR/tablet; SMS when called (Kavenegar); TV display mode (fullscreen Next page); branch-scoped boards |
| **API** | `POST /api/public/{cafeId}/queue/tickets` (anonymous, rate limited) |
| **Acceptance** | Walk-in gets number; staff calls next; plan limit blocks Free tier |
### O-2 — Kitchen printer routing per station
| | |
|--|--|
| **Effort** | L (2 weeks) |
| **Model** | `KitchenStation` (name, printerAddress/bluetoothId), `MenuCategory.StationId`, order route split on submit |
| **API** | CRUD stations; KDS ticket includes `stationId` |
| **Dashboard** | Settings → stations; map categories |
| **Mobile POS** | `meezi_pos` Phase 2: `bluetooth_print` per station ticket |
| **Acceptance** | Drink items print to bar printer; food to kitchen |
### O-3 — Cash drawer / shift close reports
| | |
|--|--|
| **Effort** | ML (1.5 weeks) |
| **Model** | `CashShift` (openedAt, closedAt, openingFloat, countedCash, expectedCash, variance, userId) |
| **API** | Open/close shift; Z-report snapshot (orders, payments, voids, discounts) |
| **UI** | POS: “بستن شیفت”; PDF/printable summary; manager-only |
| **HR tie-in** | Optional link to `Employee` clock-out |
| **Acceptance** | Cannot close shift with open tables; report matches day orders |
### O-4 — Multi-terminal enforcement
| | |
|--|--|
| **Effort** | M (1 week) |
| **Built** | `PlanLimits.MaxTerminals`, `X-Meezi-Terminal-Id` mentioned for POS |
| **Implementation** | Redis set `terminals:{cafeId}` with TTL; register on staff login/refresh; reject 4th terminal on Free with `PLAN_LIMIT_REACHED` |
| **Dashboard** | Settings → active terminals list + revoke |
| **Acceptance** | Free cafe blocked on 2nd concurrent terminal session |
**Phase 3 exit:** Queue production-ready; shift close; terminals enforced; printer routing MVP.
---
## Phase 4 — Integrations
### I-1 — Neshan maps (discover + delivery radius)
| | |
|--|--|
| **Effort** | M (1 week) |
| **Config** | `Neshan:ApiKey` in platform settings / cafe settings |
| **Discover** | Geocode café address; embed map on detail; optional “near me” sort (browser geolocation + distance) |
| **Delivery radius** | `Cafe.DeliveryRadiusKm` + circle check for guest delivery orders (future) |
| **Acceptance** | Map loads on café page; cities filtered Tehran/Karaj |
### I-2 — Snappfood outbound status updates
| | |
|--|--|
| **Effort** | M (1 week) |
| **Built** | `DeliveryStatusSyncService`, `ISnappfoodClient` |
| **Work** | On order status → Ready/OutForDelivery/Delivered call Snappfood API; idempotent; Hangfire retry; log failures to `WebhookLog` |
| **Dashboard** | Delivery settings: vendor id, test webhook |
| **Acceptance** | Status change in KDS triggers outbound call when `SnappfoodOrderId` set |
### I-3 — Digikala / Tap30 delivery parity
| | |
|--|--|
| **Effort** | L (2 weeks) |
| **Built** | Normalizers + webhook ingress pattern |
| **Work** | Symmetric outbound sync; commission rules; admin integration toggles; menu mapping table `DeliveryMenuMapping` |
| **Acceptance** | Same lifecycle as Snappfood for each enabled platform |
### I-4 — Hardware bundle onboarding (tablet + printer)
| | |
|--|--|
| **Effort** | M (1 week product + 1 week ops) |
| **Not code-only** | SKU in admin; café `HardwareBundlePurchasedAt` |
| **App flow** | Wizard: download POS APK, pair printer (BLE), register terminal id, test print |
| **Docs** | PDF checklist Farsi; support ticket auto-tag `hardware` |
| **Acceptance** | New Pro signup can complete wizard end-to-end |
**Phase 4 exit:** Maps on discover; delivery platforms symmetric; hardware wizard documented.
---
## Phase 5 — Platform & Enterprise
### P-1 — Separate admin web + Compose services
| | |
|--|--|
| **Effort** | L (23 weeks) |
| **Work** | New `web/admin` Next app; move `src/app/[locale]/admin/**`; env `NEXT_PUBLIC_ADMIN_API_URL`; `docker-compose.admin.yml` (admin-api + admin-web); CORS split |
| **API** | Merchant `Meezi.API` strips `/api/admin/*` when migration complete |
| **Acceptance** | Admin users never hit merchant dashboard origin; two compose profiles documented |
### P-2 — API keys (Enterprise)
| | |
|--|--|
| **Effort** | M (1 week) |
| **Model** | `CafeApiKey` (hash, prefix, scopes, expiresAt, lastUsedAt) |
| **Auth** | `Authorization: Bearer mk_...` middleware path; scopes: `orders:read`, `menu:write`, etc. |
| **UI** | Dashboard settings (Enterprise): create/revoke keys |
| **Acceptance** | External script can `GET` orders with key; keys tenant-scoped |
### P-3 — Audit log for owners
| | |
|--|--|
| **Effort** | M (1 week) |
| **Model** | `AuditEvent` (cafeId, userId, action, entityType, entityId, diffJson, ip, at) |
| **Instrument** | Order void, refund, settings change, plan change, employee role change |
| **UI** | Settings → audit feed; filter by date/user |
| **Acceptance** | Owner sees who voided a line item |
### P-4 — Data export & GDPR-style tooling
| | |
|--|--|
| **Effort** | ML (1.5 weeks) |
| **Export** | Hangfire job: ZIP JSON (customers, orders, reviews) per café; signed download link 24h |
| **Delete** | Soft-delete existing; `POST /api/cafes/{id}/privacy/erase-customer` anonymize PII (phone hash, name redacted) |
| **Retention** | Document policy in privacy page |
| **Acceptance** | Owner can export month of CRM; erase one customer on request |
**Phase 5 exit:** Admin split deployed; Enterprise API keys + audit; export/erase available.
---
## Cross-cutting requirements (every phase)
| Rule | Action |
|------|--------|
| Multi-tenant | All EF queries filter `CafeId` |
| Plans | Feature flags in `PlatformPlanDefinitions` + `IsFeatureEnabledForCafeAsync` |
| Public abuse | Rate limit + optional Turnstile on new `POST` routes |
| i18n | No hardcoded UI strings |
| Tests | At least one integration test per new controller; E2E for critical UX |
---
## Suggested PR order (first 10 PRs)
1. `docs/SECURITY.md` (Q-1)
2. Playwright smoke POS (Q-2)
3. Public discover filters API + page (G-1)
4. Discover map embed Neshan (I-1 minimal)
5. Review reply UI + public display (G-6 partial)
6. Loyalty earn on pay (G-4)
7. Terminal enforcement (O-4)
8. Queue public ticket + plan gate (O-1)
9. Snappfood outbound hardening (I-2)
10. Shift close MVP (O-3)
---
## Effort summary
| Bucket | Items | Rough total |
|--------|-------|-------------|
| Quality | Q-1Q-4 | ~2 weeks |
| Growth & community | G-1G-6 | ~68 weeks |
| Operations | O-1O-4 | ~56 weeks |
| Integrations | I-1I-4 | ~56 weeks |
| Platform | P-1P-4 | ~68 weeks |
**Calendar (1 dev):** ~2024 weeks sequential. **Parallel (2 dev):** Phase 1 + 0 in parallel; Phase 3 + 4 overlap after Phase 2 API stable.
---
## Out of scope (unless product changes)
- Full Sepidz parity
- Native iOS/Android store apps separate from Flutter
- Real-time ML ranking for discover (start with filter + sort)
- Full Taraz production (see `CURRENT_STATE_FOR_PLANNING.md`)
---
## Planning prompts for Cursor
Copy into a session with this file attached:
1. *“Implement G-1 PR-1 only: extend `GET /api/public/discover` filters + tests.”*
2. *“Implement O-4 terminal registration with Redis and PlanLimits.”*
3. *“Scaffold `web/admin` and move admin routes from dashboard.”*
---
*End of roadmap — update phase exit criteria as items ship.*
File diff suppressed because it is too large Load Diff
+935
View File
@@ -0,0 +1,935 @@
# Meezi — Printer Support Plan
> Copy-paste this into Cursor.
> Stack: ASP.NET Core 10, Next.js 14, Flutter 3
> Existing packages: QuestPDF 2024.12.3, QRCoder 1.6.0
---
## Architecture Overview
```
Three print paths — build all three:
PATH 1: Network Printer (API → TCP → Printer)
Browser/Mobile → POST /api/print/receipt → API → TCP:9100 → Thermal printer
Use for: POS receipts, kitchen tickets, table bills
Requires: WiFi/Ethernet printer on same LAN as server
PATH 2: PDF via QuestPDF (API → PDF → Download/Print)
Browser → GET /api/print/report/{id}.pdf → stream PDF → browser print dialog
Use for: End-of-day reports, formal invoices, management summaries
Requires: Nothing extra — QuestPDF already installed
PATH 3: QZ Tray Bridge (Browser WebSocket → localhost:8181 → USB Printer)
Browser → WebSocket localhost:8181 → QZ Tray → USB thermal printer
Use for: Cafés with USB-only printers (cheaper hardware)
Requires: QZ Tray installed on café's Windows machine (one-time setup)
```
---
## PROMPT 1 — Network Thermal Printer: Backend ESC/POS Service
```
Context: Meezi POS, ASP.NET Core 10. BranchSettings entity exists.
Goal: Print thermal receipts by sending ESC/POS bytes over TCP to a
network printer. No extra library needed — raw TCP socket.
────────────────────────────────────────────────────────────────
STEP 1 — Add printer config to BranchSettings
────────────────────────────────────────────────────────────────
File: src/Meezi.Core/Entities/BranchSettings.cs — add fields:
// Network printer config (TCP/IP)
public string? ReceiptPrinterIp { get; set; } // e.g. "192.168.1.100"
public int? ReceiptPrinterPort { get; set; } // default 9100
public string? KitchenPrinterIp { get; set; } // separate kitchen printer
public int? KitchenPrinterPort { get; set; }
public int PaperWidthMm { get; set; } = 80; // 58 or 80
public bool AutoCutEnabled { get; set; } = true;
public string? ReceiptLogoBase64 { get; set; } // optional small logo
EF migration:
dotnet ef migrations add AddPrinterSettings \
--project src/Meezi.Infrastructure \
--startup-project src/Meezi.API
────────────────────────────────────────────────────────────────
STEP 2 — ESC/POS builder utility
────────────────────────────────────────────────────────────────
File: src/Meezi.API/Services/Printing/EscPosBuilder.cs (new)
Do NOT use any external ESC/POS library. Build the byte sequences directly.
ESC/POS is a simple byte protocol — only need these commands:
public class EscPosBuilder
{
private readonly List<byte> _buffer = new();
// ESC/POS constants
private static readonly byte[] ESC = { 0x1B };
private static readonly byte[] GS = { 0x1D };
public EscPosBuilder Initialize()
{
// ESC @ — initialize printer
_buffer.AddRange(new byte[] { 0x1B, 0x40 });
return this;
}
public EscPosBuilder SetEncoding()
{
// ESC t 37 — set code page to UTF-8 compatible
// For Persian: use code page that supports UTF-8
// Most modern Epson/Bixolon support ESC t with PC720 or UTF-8
_buffer.AddRange(new byte[] { 0x1B, 0x74, 0x25 });
return this;
}
public EscPosBuilder AlignCenter()
{
_buffer.AddRange(new byte[] { 0x1B, 0x61, 0x01 });
return this;
}
public EscPosBuilder AlignRight()
{
_buffer.AddRange(new byte[] { 0x1B, 0x61, 0x02 });
return this;
}
public EscPosBuilder AlignLeft()
{
_buffer.AddRange(new byte[] { 0x1B, 0x61, 0x00 });
return this;
}
public EscPosBuilder Bold(bool on)
{
_buffer.AddRange(new byte[] { 0x1B, 0x45, on ? (byte)1 : (byte)0 });
return this;
}
public EscPosBuilder DoubleHeight(bool on)
{
_buffer.AddRange(new byte[] { 0x1B, 0x21, on ? (byte)0x10 : (byte)0x00 });
return this;
}
public EscPosBuilder Text(string text)
{
// Encode as UTF-8 — modern thermal printers support it
_buffer.AddRange(System.Text.Encoding.UTF8.GetBytes(text));
return this;
}
public EscPosBuilder Line(string text = "")
{
return Text(text + "\n");
}
public EscPosBuilder Separator(int width = 48, char ch = '-')
{
return Line(new string(ch, width));
}
// Print two columns (right-aligned second column)
// e.g. "کالا × 2" and "25,000 تومان" on same line
public EscPosBuilder TwoColumns(string left, string right, int totalWidth = 48)
{
var padded = left.PadRight(totalWidth - right.Length - 1) + right;
return Line(padded);
}
public EscPosBuilder Feed(int lines = 3)
{
// GS V — feed and cut (lines before cut)
for (int i = 0; i < lines; i++)
_buffer.Add(0x0A);
return this;
}
public EscPosBuilder Cut()
{
// GS V 66 3 — partial cut with feed
_buffer.AddRange(new byte[] { 0x1D, 0x56, 0x42, 0x03 });
return this;
}
public byte[] Build() => _buffer.ToArray();
}
────────────────────────────────────────────────────────────────
STEP 3 — Receipt template builder
────────────────────────────────────────────────────────────────
File: src/Meezi.API/Services/Printing/ReceiptBuilder.cs (new)
Uses EscPosBuilder to compose a full receipt from an OrderDto + BranchSettings.
public class ReceiptBuilder
{
public byte[] BuildReceipt(Order order, BranchEffectiveSettingsDto settings,
string cafeName, string branchName)
{
var b = new EscPosBuilder();
int width = settings.PaperWidthMm == 58 ? 32 : 48;
b.Initialize()
.SetEncoding();
// Header
b.AlignCenter()
.Bold(true)
.DoubleHeight(true)
.Line(cafeName)
.DoubleHeight(false)
.Bold(false)
.Line(branchName);
if (!string.IsNullOrEmpty(settings.ReceiptHeader))
b.Line(settings.ReceiptHeader);
// Order info
var shamsiDate = ToShamsi(order.CreatedAt); // helper method
b.AlignRight()
.Line($"شماره سفارش: {order.OrderNumber}")
.Line($"تاریخ: {shamsiDate}")
.Line($"میز: {order.TableName ?? "—"}");
if (!string.IsNullOrEmpty(order.GuestName))
b.Line($"مهمان: {order.GuestName}");
b.Separator(width)
.AlignRight();
// Items
foreach (var item in order.Items.Where(i => !i.IsVoided))
{
var itemTotal = FormatCurrency(item.UnitPrice * item.Quantity);
var itemLine = $"{item.ProductName} × {item.Quantity}";
b.TwoColumns(itemLine, itemTotal, width);
}
b.Separator(width);
// Totals
if (order.TaxAmount > 0)
b.TwoColumns("مالیات", FormatCurrency(order.TaxAmount), width);
if (order.ServiceCharge > 0)
b.TwoColumns("سرویس", FormatCurrency(order.ServiceCharge), width);
b.Bold(true)
.TwoColumns("مجموع کل", FormatCurrency(order.TotalAmount), width)
.Bold(false);
// Payments
foreach (var payment in order.Payments ?? [])
{
var methodLabel = payment.Method switch {
"Cash" => "نقد",
"Card" => "کارت",
"Credit" => "اعتبار",
_ => payment.Method
};
b.TwoColumns(methodLabel, FormatCurrency(payment.Amount), width);
}
b.Separator(width);
// Footer
b.AlignCenter();
if (!string.IsNullOrEmpty(settings.WifiPassword))
b.Line($"WiFi: {settings.WifiPassword}");
if (!string.IsNullOrEmpty(settings.ReceiptFooter))
b.Line(settings.ReceiptFooter);
b.Line("ممنون از انتخاب شما");
b.Feed(3)
.Cut();
return b.Build();
}
private static string FormatCurrency(decimal amount)
=> $"{amount:N0} تومان";
private static string ToShamsi(DateTime dt)
{
// Use System.Globalization.PersianCalendar
var pc = new System.Globalization.PersianCalendar();
return $"{pc.GetYear(dt)}/{pc.GetMonth(dt):D2}/{pc.GetDayOfMonth(dt):D2} " +
$"{dt:HH:mm}";
}
}
────────────────────────────────────────────────────────────────
STEP 4 — Network printer sender
────────────────────────────────────────────────────────────────
File: src/Meezi.API/Services/Printing/NetworkPrinterService.cs (new)
public interface IPrinterService
{
Task<PrintResult> PrintReceiptAsync(Guid orderId, CancellationToken ct);
Task<PrintResult> PrintKitchenTicketAsync(Guid orderId, CancellationToken ct);
Task<PrintResult> TestPrintAsync(string printerIp, int port, CancellationToken ct);
}
public class NetworkPrinterService : IPrinterService
{
private readonly IOrderService _orders;
private readonly IEffectiveSettingsService _settings;
private readonly ReceiptBuilder _receiptBuilder;
private readonly ILogger<NetworkPrinterService> _logger;
public async Task<PrintResult> PrintReceiptAsync(Guid orderId, CancellationToken ct)
{
var order = await _orders.GetOrderAsync(orderId, ct);
var settings = await _settings.GetEffectiveSettingsAsync(
order.CafeId, order.BranchId, ct);
if (string.IsNullOrEmpty(settings.ReceiptPrinterIp))
return PrintResult.Fail("PRINTER_NOT_CONFIGURED");
var bytes = _receiptBuilder.BuildReceipt(order, settings,
order.CafeName, order.BranchName);
return await SendToPrinterAsync(
settings.ReceiptPrinterIp,
settings.ReceiptPrinterPort ?? 9100,
bytes,
ct);
}
public async Task<PrintResult> PrintKitchenTicketAsync(Guid orderId, CancellationToken ct)
{
var order = await _orders.GetOrderAsync(orderId, ct);
var settings = await _settings.GetEffectiveSettingsAsync(
order.CafeId, order.BranchId, ct);
if (string.IsNullOrEmpty(settings.KitchenPrinterIp))
return PrintResult.Fail("KITCHEN_PRINTER_NOT_CONFIGURED");
var bytes = BuildKitchenTicket(order, settings);
return await SendToPrinterAsync(
settings.KitchenPrinterIp,
settings.KitchenPrinterPort ?? 9100,
bytes, ct);
}
private static byte[] BuildKitchenTicket(Order order, BranchEffectiveSettingsDto settings)
{
var b = new EscPosBuilder();
int width = settings.PaperWidthMm == 58 ? 32 : 48;
var pc = new System.Globalization.PersianCalendar();
b.Initialize()
.SetEncoding()
.AlignCenter()
.Bold(true)
.DoubleHeight(true)
.Line("آشپزخانه")
.DoubleHeight(false)
.AlignRight()
.Line($"میز: {order.TableName ?? "—"} | #{order.OrderNumber}")
.Line($"{DateTime.Now:HH:mm}")
.Separator(width);
foreach (var item in order.Items.Where(i => !i.IsVoided))
{
b.Bold(true)
.Line($"× {item.Quantity} {item.ProductName}")
.Bold(false);
if (!string.IsNullOrEmpty(item.Notes))
b.Line($" ← {item.Notes}");
}
b.Feed(4).Cut();
return b.Build();
}
private async Task<PrintResult> SendToPrinterAsync(
string ip, int port, byte[] data, CancellationToken ct)
{
try
{
using var client = new System.Net.Sockets.TcpClient();
client.SendTimeout = 3000;
client.ReceiveTimeout = 3000;
await client.ConnectAsync(ip, port, ct);
await using var stream = client.GetStream();
await stream.WriteAsync(data, ct);
await stream.FlushAsync(ct);
_logger.LogInformation("Printed {Bytes} bytes to {Ip}:{Port}", data.Length, ip, port);
return PrintResult.Ok();
}
catch (Exception ex)
{
_logger.LogError(ex, "Print failed to {Ip}:{Port}", ip, port);
return PrintResult.Fail("PRINTER_CONNECTION_FAILED", ex.Message);
}
}
}
public record PrintResult(bool Success, string? ErrorCode, string? ErrorDetail)
{
public static PrintResult Ok() => new(true, null, null);
public static PrintResult Fail(string code, string? detail = null) => new(false, code, detail);
}
────────────────────────────────────────────────────────────────
STEP 5 — Print controller
────────────────────────────────────────────────────────────────
File: src/Meezi.API/Controllers/PrintController.cs (new)
Base: CafeApiControllerBase (inherits [Authorize])
POST /api/cafes/{cafeId}/print/receipt/{orderId}
→ Prints receipt to branch receipt printer
→ Returns 200 OK or ApiError with PRINTER_NOT_CONFIGURED / PRINTER_CONNECTION_FAILED
POST /api/cafes/{cafeId}/print/kitchen/{orderId}
→ Prints kitchen ticket to branch kitchen printer
POST /api/cafes/{cafeId}/print/test
Body: { printerIp, port }
→ Prints test page: "Meezi Test Print ✓" + date
→ Owner/Manager only — used from settings page to verify connection
────────────────────────────────────────────────────────────────
STEP 6 — Register services in DI
────────────────────────────────────────────────────────────────
File: src/Meezi.API/Extensions/ServiceCollectionExtensions.cs
services.AddScoped<EscPosBuilder>();
services.AddScoped<ReceiptBuilder>();
services.AddScoped<IPrinterService, NetworkPrinterService>();
────────────────────────────────────────────────────────────────
STEP 7 — Auto-print after payment
────────────────────────────────────────────────────────────────
In OrderService.ProcessPaymentAsync, after order is closed:
// Fire-and-forget print — don't block payment on print success
_ = Task.Run(async () => {
try {
await _printerService.PrintReceiptAsync(order.Id, CancellationToken.None);
} catch (Exception ex) {
_logger.LogWarning(ex, "Auto-print failed for order {OrderId}", order.Id);
}
});
// Also print kitchen ticket when new items are added (on append)
// In OrderService.AppendItemsAsync — after saving:
_ = Task.Run(async () => {
try {
await _printerService.PrintKitchenTicketAsync(order.Id, CancellationToken.None);
} catch (Exception ex) {
_logger.LogWarning(ex, "Kitchen print failed for order {OrderId}", order.Id);
}
});
────────────────────────────────────────────────────────────────
STEP 8 — Dashboard: Print button + printer settings
────────────────────────────────────────────────────────────────
In pos-pay-panel.tsx — after payment success, add manual print button:
<button onClick={() => printReceipt(orderId)}>{t("pos.printReceipt")}</button>
Where printReceipt calls:
POST /api/cafes/{cafeId}/print/receipt/{orderId}
Show toast on success/failure:
Success: t("print.success") — "رسید چاپ شد"
Failure PRINTER_NOT_CONFIGURED: t("print.notConfigured") — "پرینتر تنظیم نشده"
Failure PRINTER_CONNECTION_FAILED: t("print.connectionFailed") — "خطا در اتصال به پرینتر"
In settings page (web/dashboard/src/components/settings/):
Add "تنظیمات پرینتر" section:
- Receipt printer IP input
- Receipt printer port (default 9100)
- Kitchen printer IP input
- Paper width selector (58mm / 80mm)
- Auto-cut toggle
- WiFi password field (shown on receipt)
- "تست پرینت" button → POST /print/test
────────────────────────────────────────────────────────────────
TESTS (add to tests/Meezi.API.Tests/PrintingTests.cs)
────────────────────────────────────────────────────────────────
For unit tests, mock IPrinterService — don't test actual TCP.
Test ReceiptBuilder and EscPosBuilder directly (they're pure logic).
✓ ReceiptBuilder_ExcludesVoidedItems
✓ ReceiptBuilder_AppliesPersianCalendarDate
✓ ReceiptBuilder_ShowsTaxLine_WhenTaxNonZero
✓ ReceiptBuilder_80mm_Uses48CharWidth
✓ ReceiptBuilder_58mm_Uses32CharWidth
✓ KitchenTicket_IncludesItemNotes
✓ PrintController_NoPrinterConfigured_ReturnsPrinterNotConfigured
✓ PrintController_AfterPayment_AutoPrintFires (mock IPrinterService)
✓ EscPosBuilder_Cut_AppendsCorrectBytes
✓ EscPosBuilder_TwoColumns_PadsCorrectly
i18n strings:
fa.json under "print":
{
"printReceipt": "چاپ رسید",
"printKitchen": "ارسال به آشپزخانه",
"success": "رسید با موفقیت چاپ شد",
"notConfigured": "آدرس پرینتر تنظیم نشده است",
"connectionFailed": "خطا در اتصال به پرینتر",
"testPrint": "تست پرینت",
"printerSettings": "تنظیمات پرینتر",
"receiptPrinter": "پرینتر رسید",
"kitchenPrinter": "پرینتر آشپزخانه",
"paperWidth": "عرض کاغذ",
"autoCut": "برش خودکار"
}
```
---
## PROMPT 2 — PDF Formal Invoices via QuestPDF
```
Context: Meezi POS, ASP.NET Core 10. QuestPDF 2024.12.3 already installed.
Goal: Generate professional PDF invoices for orders and end-of-day reports.
These are for formal billing, accounting export, and management — not thermal.
────────────────────────────────────────────────────────────────
STEP 1 — Order Invoice PDF
────────────────────────────────────────────────────────────────
File: src/Meezi.API/Services/Printing/OrderInvoiceDocument.cs (new)
Use QuestPDF fluent API to build an A4 invoice.
Key sections:
Header:
- Café logo (if stored) or café name large
- Branch name, address, phone
- "فاکتور رسمی" title
- Invoice number (= order number), date in Shamsi
Customer section:
- Guest name / customer name if linked
- Table number, server name
Items table:
Columns: ردیف | نام آیتم | تعداد | قیمت واحد | جمع
- Voided items excluded
- Alternating row shading
Totals section:
- Subtotal
- Tax (if applicable)
- Service charge (if applicable)
- Total (bold, larger)
- Payment method(s)
Footer:
- Thank you message
- WiFi password if set
- QR code linking to digital receipt (use QRCoder)
QuestPDF document class:
public class OrderInvoiceDocument : IDocument
{
private readonly Order _order;
private readonly BranchEffectiveSettingsDto _settings;
private readonly string _cafeName;
public DocumentMetadata GetMetadata() => DocumentMetadata.Default with
{
Title = $"فاکتور #{_order.OrderNumber}",
Author = _cafeName
};
public void Compose(IDocumentContainer container)
{
container.Page(page => {
page.Size(PageSizes.A4);
page.MarginHorizontal(30);
page.MarginVertical(20);
page.ContentFromRightToLeft(); // RTL for Persian
page.Header().Element(ComposeHeader);
page.Content().Element(ComposeContent);
page.Footer().Element(ComposeFooter);
});
}
// Implement ComposeHeader, ComposeContent, ComposeFooter
// following QuestPDF fluent API patterns
}
────────────────────────────────────────────────────────────────
STEP 2 — Daily Report PDF
────────────────────────────────────────────────────────────────
File: src/Meezi.API/Services/Printing/DailyReportDocument.cs (new)
A4 PDF for end-of-day / end-of-shift report.
Sections:
Header: café name, branch, date, shift info
KPI summary: total revenue, orders, avg order, net income — as large stat boxes
Payment breakdown: Cash / Card / Credit as a simple table
Top 10 products: table with rank, name, qty, revenue
Expense list: category, amount, note
Shift reconciliation: opening cash, expected, actual, discrepancy
Staff signature line (for physical printing)
────────────────────────────────────────────────────────────────
STEP 3 — PDF endpoints
────────────────────────────────────────────────────────────────
File: src/Meezi.API/Controllers/PrintController.cs — add:
GET /api/cafes/{cafeId}/print/invoice/{orderId}.pdf
→ Generates and streams PDF
→ Content-Type: application/pdf
→ Content-Disposition: inline (opens in browser) or attachment (downloads)
→ Query param: ?disposition=inline|attachment
→ Authorization: any authenticated staff
GET /api/cafes/{cafeId}/reports/daily/{date}/pdf?branchId=
→ Generates DailyReport PDF for given date
→ Authorization: Manager+
Implementation:
var pdf = Document.Create(doc => new OrderInvoiceDocument(order, settings, cafeName).Compose(doc));
var bytes = pdf.GeneratePdf();
return File(bytes, "application/pdf", $"invoice-{order.OrderNumber}.pdf");
────────────────────────────────────────────────────────────────
STEP 4 — Dashboard PDF download buttons
────────────────────────────────────────────────────────────────
In pos-receipt-modal.tsx — add second button:
<button onClick={() => window.open(`/api/.../invoice/${orderId}.pdf?disposition=inline`)}>
{t("print.formalInvoice")}
</button>
In reports page — add "دانلود PDF" button per daily report row:
→ uses lib/api/download.ts downloadFile() helper
i18n additions:
fa.json:
"print.formalInvoice": "فاکتور رسمی (PDF)"
"print.downloadReport": "دانلود گزارش PDF"
```
---
## PROMPT 3 — QZ Tray Bridge (USB Printer Support)
```
Context: Meezi dashboard (Next.js 14). Some cafés have USB thermal printers.
Goal: Support USB printing via QZ Tray — a background service the café installs once.
The dashboard communicates with it via WebSocket on localhost:8181.
────────────────────────────────────────────────────────────────
STEP 1 — Install QZ Tray client library
────────────────────────────────────────────────────────────────
cd web/dashboard
npm install qz-tray
────────────────────────────────────────────────────────────────
STEP 2 — QZ Tray print service
────────────────────────────────────────────────────────────────
File: web/dashboard/src/lib/printing/qz-bridge.ts (new)
import qz from "qz-tray";
let connected = false;
export async function connectQZ(): Promise<boolean> {
if (connected) return true;
try {
await qz.websocket.connect();
connected = true;
return true;
} catch {
return false; // QZ Tray not running — fall back to API print
}
}
export async function disconnectQZ() {
if (connected) {
await qz.websocket.disconnect();
connected = false;
}
}
export async function printWithQZ(
printerName: string,
escPosHex: string[] // hex strings of ESC/POS bytes from API
): Promise<boolean> {
const ok = await connectQZ();
if (!ok) return false;
const config = qz.configs.create(printerName);
const data = escPosHex.map(hex => ({
type: "raw" as const,
format: "hex" as const,
data: hex
}));
await qz.print(config, data);
return true;
}
export async function listQZPrinters(): Promise<string[]> {
const ok = await connectQZ();
if (!ok) return [];
return await qz.printers.find();
}
────────────────────────────────────────────────────────────────
STEP 3 — Backend: add hex export endpoint
────────────────────────────────────────────────────────────────
File: src/Meezi.API/Controllers/PrintController.cs — add:
GET /api/cafes/{cafeId}/print/receipt/{orderId}/raw
→ Same as receipt builder, but instead of sending to TCP printer,
returns the ESC/POS bytes as hex string array in JSON
→ Used by QZ Tray bridge in browser
Response:
{
"printerName": "EPSON TM-T88VI", // from branch settings
"data": ["1b40", "1b74", ...] // ESC/POS bytes as hex
}
In BranchSettings, add:
public string? UsbPrinterName { get; set; } // Windows printer name for QZ
────────────────────────────────────────────────────────────────
STEP 4 — Smart print dispatcher in dashboard
────────────────────────────────────────────────────────────────
File: web/dashboard/src/lib/printing/print-dispatcher.ts (new)
Tries QZ Tray first, falls back to API network print.
export async function printReceipt(
cafeId: string,
orderId: string
): Promise<{ success: boolean; method: "qz" | "network" | "failed" }> {
// Try QZ Tray first (USB printer)
const qzOk = await connectQZ();
if (qzOk) {
try {
const res = await apiClient.get(
`/cafes/${cafeId}/print/receipt/${orderId}/raw`
);
const printed = await printWithQZ(res.printerName, res.data);
if (printed) return { success: true, method: "qz" };
} catch {
// fall through
}
}
// Fall back to network print via API
try {
await apiClient.post(`/cafes/${cafeId}/print/receipt/${orderId}`);
return { success: true, method: "network" };
} catch {
return { success: false, method: "failed" };
}
}
────────────────────────────────────────────────────────────────
STEP 5 — Printer settings page: detect QZ Tray
────────────────────────────────────────────────────────────────
In settings page printer section, add a QZ Tray detector:
const [qzAvailable, setQzAvailable] = useState<boolean | null>(null);
const [availablePrinters, setAvailablePrinters] = useState<string[]>([]);
useEffect(() => {
connectQZ().then(ok => {
setQzAvailable(ok);
if (ok) listQZPrinters().then(setAvailablePrinters);
});
}, []);
UI:
if qzAvailable === true:
→ Green badge: "QZ Tray متصل است ✓"
→ Dropdown: select USB printer from availablePrinters
→ Save selection to branchSettings.UsbPrinterName
if qzAvailable === false:
→ Yellow badge: "QZ Tray نصب نشده"
→ Link: "دانلود QZ Tray" → https://qz.io
→ Info: "برای استفاده از پرینترهای USB، QZ Tray را یک‌بار روی این کامپیوتر نصب کنید"
→ Alternative: "در صورت داشتن پرینتر شبکه، آدرس IP را وارد کنید"
if qzAvailable === null:
→ Spinner: "در حال بررسی..."
```
---
## PROMPT 4 — Flutter Bluetooth/Network Print (meezi_pos)
```
Context: Flutter 3, mobile/meezi_pos.
Goal: Print from the Flutter POS app to Bluetooth or network thermal printers.
────────────────────────────────────────────────────────────────
STEP 1 — Add packages to pubspec.yaml
────────────────────────────────────────────────────────────────
dependencies:
esc_pos_utils_plus: ^2.0.2 # ESC/POS command builder for Dart
flutter_bluetooth_printer: ^3.0.0 # Bluetooth printing (Android/iOS)
# For network printing, use dart:io TcpSocket directly (no extra package)
────────────────────────────────────────────────────────────────
STEP 2 — Print service abstraction
────────────────────────────────────────────────────────────────
File: mobile/meezi_pos/lib/services/print_service.dart
abstract class PrintService {
Future<bool> printReceipt(OrderModel order, BranchSettings settings);
Future<bool> printKitchenTicket(OrderModel order);
Future<List<PrinterDevice>> discoverPrinters();
}
class NetworkPrintService implements PrintService {
@override
Future<bool> printReceipt(OrderModel order, BranchSettings settings) async {
final bytes = _buildReceiptBytes(order, settings);
return await _sendToNetworkPrinter(
settings.receiptPrinterIp!,
settings.receiptPrinterPort ?? 9100,
bytes
);
}
Future<bool> _sendToNetworkPrinter(String ip, int port, List<int> bytes) async {
try {
final socket = await Socket.connect(ip, port,
timeout: const Duration(seconds: 3));
socket.add(bytes);
await socket.flush();
await socket.close();
return true;
} catch (e) {
debugPrint("Network print error: $e");
return false;
}
}
}
class BluetoothPrintService implements PrintService {
@override
Future<bool> printReceipt(OrderModel order, BranchSettings settings) async {
// Use flutter_bluetooth_printer
// Find paired printer → send ESC/POS bytes
final bytes = _buildReceiptBytes(order, settings);
return await FlutterBluetoothPrinter.printBytes(
address: settings.bluetoothPrinterAddress!,
data: Uint8List.fromList(bytes),
);
}
@override
Future<List<PrinterDevice>> discoverPrinters() async {
return await FlutterBluetoothPrinter.discover();
}
}
────────────────────────────────────────────────────────────────
STEP 3 — ESC/POS receipt builder in Dart
────────────────────────────────────────────────────────────────
File: mobile/meezi_pos/lib/services/receipt_builder.dart
Use esc_pos_utils_plus to build the receipt — same structure as
the C# ReceiptBuilder (header, items, totals, footer, cut).
PaperSize based on settings.paperWidthMm:
58mm → PaperSize.mm58
80mm → PaperSize.mm80
Persian text: most modern BT printers support UTF-8.
If printer doesn't support Persian, fall back to transliteration
or use image-mode printing (render receipt as image, print as bitmap).
────────────────────────────────────────────────────────────────
STEP 4 — Printer settings in Flutter POS
────────────────────────────────────────────────────────────────
File: mobile/meezi_pos/lib/screens/printer_settings_screen.dart
Tabs:
1. "بلوتوث" — scan + pair Bluetooth printers
2. "شبکه" — enter IP:port manually or auto-discover via mDNS
On pair/save → store in SharedPreferences or Drift local DB.
Add "تست پرینت" button — prints test page.
────────────────────────────────────────────────────────────────
STEP 5 — Auto-print after POS payment
────────────────────────────────────────────────────────────────
In order payment flow, after API confirms payment:
final printService = ref.read(printServiceProvider);
final printed = await printService.printReceipt(order, branchSettings);
if (!printed) {
showSnackBar(context, "خطا در اتصال به پرینتر");
}
```
---
## Summary: Which Approach for Which Scenario
| Scenario | Solution | Effort |
|----------|----------|--------|
| Café has WiFi/Ethernet printer | PROMPT 1 (API → TCP) | ⭐ Build first |
| Need formal A4 invoice PDF | PROMPT 2 (QuestPDF) | ⭐ Build second |
| Café has USB printer on Windows PC | PROMPT 3 (QZ Tray) | Build third |
| Mobile waiter tablet → Bluetooth print | PROMPT 4 (Flutter) | Flutter sprint |
## New Error Codes
| Code | Meaning |
|------|---------|
| `PRINTER_NOT_CONFIGURED` | No printer IP set in branch settings |
| `KITCHEN_PRINTER_NOT_CONFIGURED` | No kitchen printer IP set |
| `PRINTER_CONNECTION_FAILED` | TCP connection to printer failed |
| `PRINTER_TIMEOUT` | Printer connected but didn't respond |
## Recommended Printers for Iran (tested with ESC/POS + TCP/9100)
- **Epson TM-T82III-i** — built-in WiFi, widely available, best support
- **Bixolon SRP-350plusIII** — network version, very reliable
- **Sewoo LK-TE112NR** — cheaper, good for budget cafés
- **80mm paper** recommended over 58mm — more readable
---
*Start with PROMPT 1 — it has zero dependencies and works immediately with any network printer.*
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
# Meezi — Remaining work backlog
> **Last updated:** 2026-05-23
> Companion to [MEEZI_FEATURE_ROADMAP_PLAN.md](./MEEZI_FEATURE_ROADMAP_PLAN.md).
> **Shipped this session:** coffee advisor UI, queue TV display, loyalty redeem (POS).
---
## Recently shipped (not yet in roadmap table)
| Item | Notes |
|------|--------|
| Branch soft-delete (7-day restore) | API + branches UI + Hangfire purge |
| Admin OpenAI + Meshy keys | Integrations screen |
| Navbar café → settings, plan → subscription | Topbar |
| Toast fonts + modern Sonner styling | Dashboard + admin |
| Queue branch filter fix | List matches POS branch |
| Support ticket list fix | Query + optimistic update |
---
## Priority queue (next 12 items)
| # | ID | Effort | Area | What | Status |
|---|-----|--------|------|------|--------|
| 1 | G-7 | M | Growth | **Coffee advisor UI** on public discover detail | Done |
| 2 | O-1b | S | Ops | **Queue TV display** fullscreen (`/queue/display`) | Done |
| 3 | G-4b | M | CRM/POS | **Loyalty redeem** on pay (1 pt = 100 ت) | Done |
| 4 | G-3 | L | Growth | Customer OTP accounts + order history | Planned |
| 5 | G-5 | M | Platform | Enterprise café badges on discover | Planned |
| 6 | G-6b | M | Growth | Review photo upload (public multipart) | Planned |
| 7 | O-2 | L | Ops | Kitchen printer routing per station | Planned |
| 8 | I-1 | M | Integrations | Neshan embed API key + geocode (not search iframe only) | Planned |
| 9 | I-3 | L | Delivery | Tap30/Digikala outbound parity | Planned |
| 10 | P-1 | L | Platform | Finish **web/admin** split (remove dashboard `/admin` redirect) | Partial |
| 11 | P-2 | M | Enterprise | API keys (`mk_…`) scoped to café | Planned |
| 12 | P-3 | M | Enterprise | Owner audit log (void, settings, roles) | Planned |
---
## Phase 0 leftovers
| ID | Item | Effort |
|----|------|--------|
| Q-4 | CI `docker compose build` job | S |
---
## Phase 2 — Growth depth
| ID | Item | Effort |
|----|------|--------|
| G-3 | Customer accounts (OTP, `GET /me/orders`) | L |
| G-4b | Loyalty rules UI + redeem audit | M |
| G-5 | Café badges (Enterprise, admin assign) | M |
| G-6b | Review photos + moderation `IsHidden` | M |
---
## Phase 3 — Operations
| ID | Item | Effort |
|----|------|--------|
| O-1 | Public queue “take number” QR page | S |
| O-2 | Kitchen stations → printer routing | L |
| O-3b | Shift Z-report PDF / printable | M |
| O-4 | Done — terminal enforcement | — |
---
## Phase 4 — Integrations
| ID | Item | Effort |
|----|------|--------|
| I-1 | Neshan maps (API key, lat/lng, near-me sort) | M |
| I-3 | Tap30 + Digikala outbound sync | L |
| I-4 | Hardware onboarding wizard (docs + settings flag) | M |
---
## Phase 5 — Platform
| ID | Item | Effort |
|----|------|--------|
| P-1 | Admin app standalone compose profile | L |
| P-2 | Enterprise API keys | M |
| P-3 | Audit log for owners | M |
| P-4 | GDPR export + customer erase | M |
---
## Mobile (`meezi_app`)
| Item | Notes |
|------|--------|
| Offline sync polish | Drift queue hardening |
| POS thermal print | Bluetooth per station |
| Discover consumer app | After web discover stable |
---
## Suggested next PRs (after current batch)
1. Loyalty redeem + CRM points display
2. Review photos
3. Customer OTP pilot
4. Neshan API map on discover
5. P-1 admin split completion
---
*Update status column as items ship.*
+177
View File
@@ -0,0 +1,177 @@
# Meezi — Security & abuse protection (operations)
This document describes how to protect **public café-facing** endpoints (QR menu, discover, OTP, reviews) in development and production. Application code lives under `src/Meezi.API/Security/` and `appsettings.json``Security`.
---
## 1. Defense layers (use all in production)
| Layer | Where | Purpose |
|-------|--------|---------|
| **CDN / WAF** | Arvan Cloud (recommended) | Block volumetric DDoS, geo rules, bot scores |
| **ASP.NET rate limiting** | `SecurityExtensions` policies | Per-IP caps on reads and OTP |
| **Redis sliding windows** | `AbuseProtectionService` | Guest orders, public writes, OTP by IP |
| **Cloudflare Turnstile** | Optional CAPTCHA | Bots on order/review/reservation |
| **Suspended café** | `PublicCafeGuard` | Stop traffic to closed/suspended venues |
App-layer limits **do not replace** edge protection. Configure Arvan in front of `api.*` and `dashboard.*`.
---
## 2. Configuration (`appsettings` / env)
```json
"Security": {
"Enabled": true,
"RequireCaptchaOnPublicWrites": false,
"Turnstile": {
"SiteKey": "",
"SecretKey": ""
},
"RateLimits": {
"AuthOtpPerIpPerHour": 15,
"PublicReadsPerIpPerMinute": 120,
"PublicWritesPerIpPerMinute": 30
},
"GuestOrders": {
"PerIpPerCafePerHour": 25,
"PerCafePerHour": 200,
"PerIpGlobalPerHour": 60
}
}
```
| Env override | Example |
|--------------|---------|
| `Security__Turnstile__SiteKey` | Turnstile site key |
| `Security__Turnstile__SecretKey` | Turnstile secret |
| `Security__RequireCaptchaOnPublicWrites` | `true` in production |
| `Security__Enabled` | `false` only in isolated local debug |
Also: `Auth:MaxOtpAttemptsPerHour` (per **phone**, Redis key `otp:attempts:{phone}`).
---
## 3. Rate limit reference
### ASP.NET policies (`[EnableRateLimiting]`)
| Policy | Routes | Default limit |
|--------|--------|----------------|
| `public-read` | `GET /api/public/*`, `GET /api/q/*` | 120 / IP / minute |
| `auth-otp` | `POST /api/auth/send-otp` | 15 / IP / hour |
Response: HTTP **429**, body `{ success: false, error: { code: "RATE_LIMITED", ... } }`.
### Redis (`AbuseProtectionService`)
| Key pattern | Limit | Window |
|-------------|-------|--------|
| `abuse:otp-ip:{ip}` | 15 | 1 hour |
| `abuse:pub-write:{ip}` | 30 | 1 minute |
| `abuse:qr-ip:{cafeId}:{ip}` | 25 | 1 hour |
| `abuse:qr-cafe:{cafeId}` | 200 | 1 hour |
| `abuse:qr-ip-global:{ip}` | 60 | 1 hour |
Public write paths also run **Turnstile** when `RequireCaptchaOnPublicWrites` is true and `SecretKey` is set.
---
## 4. Cloudflare Turnstile
1. Create a widget at [Cloudflare Turnstile](https://dash.cloudflare.com/?to=/:account/turnstile).
2. Set **Site key**`Security:Turnstile:SiteKey`.
3. Set **Secret key**`Security:Turnstile:SecretKey`.
4. Set `RequireCaptchaOnPublicWrites: true`.
5. QR guest UI loads `GET /api/public/security-config` and shows the widget when `captchaRequired` is true.
**Client bodies** must include `captchaToken` on:
- `POST /api/public/{cafeId}/branches/{branchId}/orders`
- `POST /api/public/cafes/{slug}/orders`
- `POST /api/public/cafes/{slug}/reservations`
- `POST /api/public/cafes/{slug}/reviews`
---
## 5. Real client IP (reverse proxy)
Rate limits use `ClientIpResolver`:
1. `X-Forwarded-For` (first hop)
2. `X-Real-IP`
3. `CF-Connecting-IP`
4. Connection remote address
**Arvan / nginx** must forward:
```nginx
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
```
Without this, all traffic may appear as one IP (under- or over-limiting).
---
## 6. Arvan CDN / WAF (recommended rules)
Apply to the **API** origin (and dashboard if served separately).
| Rule | Path / condition | Action |
|------|------------------|--------|
| Rate limit | `POST /api/auth/send-otp` | e.g. 10 req/min per IP |
| Rate limit | `POST /api/public/*` | e.g. 30 req/min per IP |
| Rate limit | `GET /api/q/*`, `GET /api/public/*` | e.g. 300 req/min per IP |
| Challenge / CAPTCHA | High bot score on `POST` | Optional if Turnstile not enough |
| Block | Countries not in scope (optional) | Only if product is IR-only |
| Cache | `GET` public menu/discover | Short TTL only if responses are anonymous |
Do **not** cache authenticated `GET /api/cafes/{id}/*`.
---
## 7. Protected public endpoints checklist
| Endpoint | Auth | Limits |
|----------|------|--------|
| `GET /api/q/{code}` | None | public-read |
| `GET /api/public/discover` | None | public-read |
| `GET /api/public/security-config` | None | public-read |
| `POST /api/auth/send-otp` | None | auth-otp + Redis IP + phone |
| `POST .../orders` (guest) | None | public-write + guest order + CAPTCHA |
| `POST .../reviews` | None | public-write + CAPTCHA |
**Café suspended:** `CAFE_SUSPENDED` (403) on public writes.
---
## 8. Incident response (short)
1. **OTP flood** — Lower `Auth:MaxOtpAttemptsPerHour` and `AuthOtpPerIpPerHour`; enable Arvan rule on `send-otp`.
2. **QR order spam** — Lower `GuestOrders.*`; enable Turnstile; suspend café in admin if targeted.
3. **Scrape / discover** — Tighten `PublicReadsPerIpPerMinute`; WAF rate limit on `/api/public/discover`.
4. **False positives** — Temporarily `Security:Enabled: false` only on a **staging** slot, not production.
Never log: phone numbers, national IDs, payment tokens, Turnstile secrets.
---
## 9. Verification
```bash
# Load script (local)
k6 run tests/load/public-abuse.js
# E2E smoke
cd web/dashboard && npm run test:e2e
```
Expect `429` / `RATE_LIMITED` when exceeding limits (see `tests/load/README.md`).
---
## 10. Related docs
- `docs/DOCKER.md` — build and network issues
- `docs/MEEZI_FEATURE_ROADMAP_PLAN.md` — Phase 0 quality items