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:
@@ -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 1–3 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.*
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
@@ -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.*
|
||||
@@ -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 2–4 weeks of focused PRs.
|
||||
- Split work into **small PRs**: `api` | `dashboard` | `mobile` | `infra` | `docs`.
|
||||
- Mark items **Built (thin)** vs **Greenfield** — don’t 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 | 1–2 weeks |
|
||||
| **1** | Public discover | Consumer-facing کافهیاب for Tehran/Karaj | 2–3 weeks |
|
||||
| **2** | Growth & community | Loyalty, reviews 2.0, badges | 3–4 weeks |
|
||||
| **3** | Operations | Queue polish, printers, shifts, terminals | 3–4 weeks |
|
||||
| **4** | Integrations | Maps, delivery parity, hardware onboarding | 3–4 weeks |
|
||||
| **5** | Platform & Enterprise | Admin split, API keys, audit, export | 4–6 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 (3–5 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** | 2–3 stable tests green in GitHub Actions |
|
||||
|
||||
### Q-3 — Load tests (public QR + OTP)
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **Effort** | M (2–3 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.5–2 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** | M–L (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** | S–M (3–5 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** | M–L (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 (2–3 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** | M–L (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-1–Q-4 | ~2 weeks |
|
||||
| Growth & community | G-1–G-6 | ~6–8 weeks |
|
||||
| Operations | O-1–O-4 | ~5–6 weeks |
|
||||
| Integrations | I-1–I-4 | ~5–6 weeks |
|
||||
| Platform | P-1–P-4 | ~6–8 weeks |
|
||||
|
||||
**Calendar (1 dev):** ~20–24 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
@@ -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
@@ -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.*
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user