From 03376b3ea170f1e035cf012eab3677d79e57ec2b Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Wed, 27 May 2026 21:33:29 +0330 Subject: [PATCH] feat(docker): multi-stage Dockerfiles with npmmirror registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docker/admin-api/Dockerfile | 29 + docker/admin-web/Dockerfile | 35 + docker/api/Dockerfile | 39 + docker/daemon-registry-mirror.example.json | 6 + docker/finder/Dockerfile | 37 + docker/web/Dockerfile | 36 + docker/website/Dockerfile | 41 + docs/CURRENT_STATE_FOR_PLANNING.md | 230 ++++ docs/DEMO.md | 95 ++ docs/DOCKER.md | 150 +++ docs/MEEZI_BRANCH_CREDENTIALS_PLAN.md | 852 ++++++++++++++ docs/MEEZI_FEATURE_ROADMAP_PLAN.md | 393 +++++++ docs/MEEZI_NEXT_SPRINT_PLAN.md | 1212 ++++++++++++++++++++ docs/MEEZI_PRINTER_PLAN.md | 935 +++++++++++++++ docs/MEEZI_QR_MENU_PLAN.md | 1032 +++++++++++++++++ docs/MEEZI_REMAINING_BACKLOG.md | 112 ++ docs/SECURITY.md | 177 +++ scripts/check-ports.ps1 | 50 + scripts/docker-up-full.ps1 | 39 + scripts/run-local-dev.ps1 | 19 + 20 files changed, 5519 insertions(+) create mode 100644 docker/admin-api/Dockerfile create mode 100644 docker/admin-web/Dockerfile create mode 100644 docker/api/Dockerfile create mode 100644 docker/daemon-registry-mirror.example.json create mode 100644 docker/finder/Dockerfile create mode 100644 docker/web/Dockerfile create mode 100644 docker/website/Dockerfile create mode 100644 docs/CURRENT_STATE_FOR_PLANNING.md create mode 100644 docs/DEMO.md create mode 100644 docs/DOCKER.md create mode 100644 docs/MEEZI_BRANCH_CREDENTIALS_PLAN.md create mode 100644 docs/MEEZI_FEATURE_ROADMAP_PLAN.md create mode 100644 docs/MEEZI_NEXT_SPRINT_PLAN.md create mode 100644 docs/MEEZI_PRINTER_PLAN.md create mode 100644 docs/MEEZI_QR_MENU_PLAN.md create mode 100644 docs/MEEZI_REMAINING_BACKLOG.md create mode 100644 docs/SECURITY.md create mode 100644 scripts/check-ports.ps1 create mode 100644 scripts/docker-up-full.ps1 create mode 100644 scripts/run-local-dev.ps1 diff --git a/docker/admin-api/Dockerfile b/docker/admin-api/Dockerfile new file mode 100644 index 0000000..8cbf8e0 --- /dev/null +++ b/docker/admin-api/Dockerfile @@ -0,0 +1,29 @@ +ARG DOTNET_SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:10.0 +ARG DOTNET_ASPNET_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0 + +FROM ${DOTNET_SDK_IMAGE} AS build +WORKDIR /src + +COPY global.json Directory.Build.props Directory.Packages.props nuget.config ./ + +COPY src/Meezi.Shared/Meezi.Shared.csproj src/Meezi.Shared/ +COPY src/Meezi.Core/Meezi.Core.csproj src/Meezi.Core/ +COPY src/Meezi.Infrastructure/Meezi.Infrastructure.csproj src/Meezi.Infrastructure/ +COPY src/Meezi.Admin.API/Meezi.Admin.API.csproj src/Meezi.Admin.API/ + +ENV NUGET_CERT_REVOCATION_MODE=offline +RUN --mount=type=cache,target=/root/.nuget/packages \ + dotnet restore src/Meezi.Admin.API/Meezi.Admin.API.csproj --disable-parallel + +COPY src/ src/ +RUN dotnet publish src/Meezi.Admin.API/Meezi.Admin.API.csproj -c Release -o /app/publish /p:UseAppHost=false + +FROM ${DOTNET_ASPNET_IMAGE} AS runtime +WORKDIR /app + +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 + +COPY --from=build /app/publish . + +ENTRYPOINT ["dotnet", "Meezi.Admin.API.dll"] diff --git a/docker/admin-web/Dockerfile b/docker/admin-web/Dockerfile new file mode 100644 index 0000000..4ba6d00 --- /dev/null +++ b/docker/admin-web/Dockerfile @@ -0,0 +1,35 @@ +FROM node:20-alpine AS builder +WORKDIR /app + +COPY web/admin/ . + +ARG NEXT_PUBLIC_ADMIN_API_URL=http://localhost:5081 +ENV NEXT_PUBLIC_ADMIN_API_URL=$NEXT_PUBLIC_ADMIN_API_URL + +RUN NEXT_VER=$(node -e "process.stdout.write(require('./node_modules/next/package.json').version)") \ + && rm -rf node_modules/@next/swc-win32-* node_modules/@next/swc-darwin-* 2>/dev/null; \ + ls node_modules/@next/swc-linux-x64-musl 2>/dev/null \ + || npm install --no-save --ignore-scripts "@next/swc-linux-x64-musl@${NEXT_VER}" + +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/docker/api/Dockerfile b/docker/api/Dockerfile new file mode 100644 index 0000000..2da1cf9 --- /dev/null +++ b/docker/api/Dockerfile @@ -0,0 +1,39 @@ +# Base images: override via docker-compose build args or .env (see docs/DOCKER.md). +# Default = Microsoft Container Registry (official). Docker Hub dotnet/* often returns +# "insufficient_scope" unless logged in, or is unreachable in some regions. +ARG DOTNET_SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:10.0 +ARG DOTNET_ASPNET_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0 + +FROM ${DOTNET_SDK_IMAGE} AS build +WORKDIR /src + +COPY global.json Directory.Build.props Directory.Packages.props nuget.config ./ + +COPY src/Meezi.Shared/Meezi.Shared.csproj src/Meezi.Shared/ +COPY src/Meezi.Core/Meezi.Core.csproj src/Meezi.Core/ +COPY src/Meezi.Infrastructure/Meezi.Infrastructure.csproj src/Meezi.Infrastructure/ +COPY src/Meezi.API/Meezi.API.csproj src/Meezi.API/ + +# NuGet over TLS often fails in Docker when VPN/filter/antivirus breaks HTTPS (bad record mac). +# Retry via nuget.config; offline revocation helps restricted networks. Re-run build if restore fails. +ENV NUGET_CERT_REVOCATION_MODE=offline +RUN --mount=type=cache,target=/root/.nuget/packages \ + dotnet restore src/Meezi.API/Meezi.API.csproj --disable-parallel + +COPY src/ src/ +COPY data/menu-image-manifest.json data/ +COPY data/demo-credentials.json data/ +RUN dotnet publish src/Meezi.API/Meezi.API.csproj -c Release -o /app/publish /p:UseAppHost=false + +FROM ${DOTNET_ASPNET_IMAGE} AS runtime +WORKDIR /app + +# No apt-get here — avoids Ubuntu mirror downloads during build (Iran/VPN issues). +# Healthcheck uses a TCP probe (see docker-compose.yml). + +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 + +COPY --from=build /app/publish . + +ENTRYPOINT ["dotnet", "Meezi.API.dll"] diff --git a/docker/daemon-registry-mirror.example.json b/docker/daemon-registry-mirror.example.json new file mode 100644 index 0000000..895e767 --- /dev/null +++ b/docker/daemon-registry-mirror.example.json @@ -0,0 +1,6 @@ +{ + "registry-mirrors": [ + "https://docker.iranrepo.ir", + "https://registry.docker.ir" + ] +} diff --git a/docker/finder/Dockerfile b/docker/finder/Dockerfile new file mode 100644 index 0000000..7ebd594 --- /dev/null +++ b/docker/finder/Dockerfile @@ -0,0 +1,37 @@ +FROM node:20-alpine AS deps +WORKDIR /app +COPY web/finder/package*.json ./ +RUN npm config set registry https://registry.npmmirror.com \ + && npm install --legacy-peer-deps --ignore-scripts + +FROM node:20-alpine AS builder +WORKDIR /app + +ARG NEXT_PUBLIC_API_URL=http://localhost:5080 +ARG NEXT_PUBLIC_SITE_URL=https://find.meezi.ir +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL +ENV NEXT_TELEMETRY_DISABLED=1 + +COPY --from=deps /app/node_modules ./node_modules +COPY web/finder/ . +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/docker/web/Dockerfile b/docker/web/Dockerfile new file mode 100644 index 0000000..29fcbbd --- /dev/null +++ b/docker/web/Dockerfile @@ -0,0 +1,36 @@ +FROM node:20-alpine AS deps +WORKDIR /app +COPY web/dashboard/package*.json ./ +RUN npm config set registry https://registry.npmmirror.com \ + && npm install --legacy-peer-deps --ignore-scripts + +FROM node:20-alpine AS builder +WORKDIR /app + +ARG NEXT_PUBLIC_API_URL=http://localhost:5080 +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +ENV NEXT_TELEMETRY_DISABLED=1 + +COPY --from=deps /app/node_modules ./node_modules +COPY web/dashboard/ . +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/docker/website/Dockerfile b/docker/website/Dockerfile new file mode 100644 index 0000000..c285329 --- /dev/null +++ b/docker/website/Dockerfile @@ -0,0 +1,41 @@ +FROM meezi-node:20-alpine AS builder +WORKDIR /app + +COPY web/website/ . + +ARG MEEZI_API_URL=http://api:8080 +ENV MEEZI_API_URL=$MEEZI_API_URL + +ARG NEXT_PUBLIC_SITE_URL=http://localhost:3010 +ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL + +RUN NEXT_VER=$(node -e "process.stdout.write(require('./node_modules/next/package.json').version)") \ + && rm -rf node_modules/@next/swc-win32-* node_modules/@next/swc-darwin-* 2>/dev/null; \ + ls node_modules/@next/swc-linux-x64-musl 2>/dev/null \ + || npm install --no-save --ignore-scripts "@next/swc-linux-x64-musl@${NEXT_VER}" + +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +FROM meezi-node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +# Blog MDX content is read at runtime by process.cwd() +COPY --from=builder /app/src/content ./src/content + +USER nextjs +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/docs/CURRENT_STATE_FOR_PLANNING.md b/docs/CURRENT_STATE_FOR_PLANNING.md new file mode 100644 index 0000000..67cd5f7 --- /dev/null +++ b/docs/CURRENT_STATE_FOR_PLANNING.md @@ -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` / `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.* diff --git a/docs/DEMO.md b/docs/DEMO.md new file mode 100644 index 0000000..1723cc2 --- /dev/null +++ b/docs/DEMO.md @@ -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` diff --git a/docs/DOCKER.md b/docs/DOCKER.md new file mode 100644 index 0000000..5205dff --- /dev/null +++ b/docs/DOCKER.md @@ -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 +``` diff --git a/docs/MEEZI_BRANCH_CREDENTIALS_PLAN.md b/docs/MEEZI_BRANCH_CREDENTIALS_PLAN.md new file mode 100644 index 0000000..900a3da --- /dev/null +++ b/docs/MEEZI_BRANCH_CREDENTIALS_PLAN.md @@ -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()` 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 / 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) + +──────────────────────────────────────────────────────────────── +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 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 + +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 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.* diff --git a/docs/MEEZI_FEATURE_ROADMAP_PLAN.md b/docs/MEEZI_FEATURE_ROADMAP_PLAN.md new file mode 100644 index 0000000..53499e7 --- /dev/null +++ b/docs/MEEZI_FEATURE_ROADMAP_PLAN.md @@ -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`, 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.* diff --git a/docs/MEEZI_NEXT_SPRINT_PLAN.md b/docs/MEEZI_NEXT_SPRINT_PLAN.md new file mode 100644 index 0000000..c7bed32 --- /dev/null +++ b/docs/MEEZI_NEXT_SPRINT_PLAN.md @@ -0,0 +1,1212 @@ +# Meezi — Sprint Plan: POS Operations Polish + Hardening +> **Copy-paste this entire file into Cursor as your implementation guide.** +> Stack: ASP.NET Core 10 · Next.js 14 · PostgreSQL 16 · Redis · SignalR +> Last updated: 2026-05-21 + +--- + +## Overview + +Five self-contained PRs, ordered by dependency. Do them in sequence. + +| PR | Title | Effort | +|----|-------|--------| +| PR-1 | Void Order Line | ~2h | +| PR-2 | Transfer Table | ~2h | +| PR-3 | SignalR Table Board Realtime | ~3h | +| PR-4 | Receipt Print Preview | ~2h | +| PR-5 | Integration Test Coverage | ~2h | +| PR-6 | Fix NU1903 Package Warnings | ~1h | + +--- + +## PR-1 — Void Order Line + +### Goal +Allow staff to void (cancel) an individual line item on an open order. Only `Manager` role can void. + +### Files to create / edit + +``` +src/Meezi.Core/Entities/OrderItem.cs ← add IsVoided, VoidedAt, VoidedByUserId +src/Meezi.Core/Entities/Order.cs ← update TotalAmount computed property +src/Meezi.Infrastructure/Data/Migrations/ ← new migration: VoidOrderLine +src/Meezi.API/Services/OrderService.cs ← add VoidOrderItemAsync +src/Meezi.API/Controllers/OrdersController.cs ← add PATCH endpoint +src/Meezi.API/DTOs/OrderItemDto.cs ← add IsVoided +web/dashboard/src/components/pos/pos-screen.tsx ← void button per line +web/dashboard/src/lib/api/orders.ts ← voidOrderItem() +messages/fa.json, en.json, ar.json ← void strings +``` + +--- + +### Step 1 — Domain: `OrderItem.cs` + +Add to the existing `OrderItem` entity: + +```csharp +// src/Meezi.Core/Entities/OrderItem.cs +public bool IsVoided { get; set; } = false; +public DateTime? VoidedAt { get; set; } +public Guid? VoidedByUserId { get; set; } +``` + +--- + +### Step 2 — Domain: `Order.cs` + +Update `TotalAmount` (or wherever line totals are summed) to exclude voided items: + +```csharp +// In Order.cs — wherever TotalAmount is computed +public decimal TotalAmount => Items + .Where(i => !i.IsVoided) + .Sum(i => i.UnitPrice * i.Quantity); +``` + +If `TotalAmount` is a stored column rather than computed, update `OrderService` instead (see Step 4). + +--- + +### Step 3 — EF Migration + +```bash +cd src/Meezi.Infrastructure +dotnet ef migrations add VoidOrderLine \ + --startup-project ../../src/Meezi.API \ + --output-dir Data/Migrations +``` + +Verify the generated migration adds: +- `is_voided boolean not null default false` +- `voided_at timestamp with time zone null` +- `voided_by_user_id uuid null` + +--- + +### Step 4 — Service: `OrderService.cs` + +Add this method: + +```csharp +// src/Meezi.API/Services/OrderService.cs + +public async Task> VoidOrderItemAsync( + Guid orderId, Guid itemId, Guid voidedByUserId, CancellationToken ct = default) +{ + var order = await _db.Orders + .Include(o => o.Items) + .FirstOrDefaultAsync(o => o.Id == orderId && o.CafeId == _tenant.CafeId, ct); + + if (order is null) + return ApiResponse.Fail("ORDER_NOT_FOUND"); + + if (order.Status == OrderStatus.Closed) + return ApiResponse.Fail("ORDER_ALREADY_CLOSED"); + + var item = order.Items.FirstOrDefault(i => i.Id == itemId); + if (item is null) + return ApiResponse.Fail("ITEM_NOT_FOUND"); + + if (item.IsVoided) + return ApiResponse.Fail("ITEM_ALREADY_VOIDED"); + + item.IsVoided = true; + item.VoidedAt = DateTime.UtcNow; + item.VoidedByUserId = voidedByUserId; + + // Recompute stored total if not computed property + order.TotalAmount = order.Items + .Where(i => !i.IsVoided) + .Sum(i => i.UnitPrice * i.Quantity); + + await _db.SaveChangesAsync(ct); + return ApiResponse.Ok(_mapper.Map(order)); +} +``` + +--- + +### Step 5 — Controller: `OrdersController.cs` + +Add endpoint (inside the existing controller class): + +```csharp +// src/Meezi.API/Controllers/OrdersController.cs + +/// Void a single line item. Requires Manager role. +[HttpPatch("{orderId}/items/{itemId}/void")] +[Authorize(Roles = "Manager,Owner")] +public async Task VoidOrderItem( + Guid cafeId, Guid orderId, Guid itemId, CancellationToken ct) +{ + var userId = User.GetUserId(); // existing helper + var result = await _orderService.VoidOrderItemAsync(orderId, itemId, userId, ct); + return result.IsSuccess ? Ok(result) : BadRequest(result); +} +``` + +--- + +### Step 6 — DTO: `OrderItemDto.cs` + +```csharp +public bool IsVoided { get; set; } +public DateTime? VoidedAt { get; set; } +``` + +Add to AutoMapper profile if explicit mappings exist. + +--- + +### Step 7 — Dashboard: API helper + +```typescript +// web/dashboard/src/lib/api/orders.ts + +export async function voidOrderItem( + cafeId: string, + orderId: string, + itemId: string +): Promise { + await apiClient.patch( + `/cafes/${cafeId}/orders/${orderId}/items/${itemId}/void` + ); +} +``` + +--- + +### Step 8 — Dashboard: UI in `pos-screen.tsx` + +Inside the line-item render loop, add a void button visible only to managers: + +```tsx +// web/dashboard/src/components/pos/pos-screen.tsx + +{isManager && !item.isVoided && ( + +)} + +{item.isVoided && ( + + {t("pos.voided")} + +)} +``` + +Handler: + +```tsx +const handleVoidItem = async (itemId: string) => { + if (!confirm(t("pos.confirmVoid"))) return; + await voidOrderItem(cafeId, activeOrderId, itemId); + await reloadOrder(); // existing reload helper +}; +``` + +--- + +### Step 9 — i18n strings + +```json +// messages/fa.json +"pos": { + "void": "ابطال", + "voided": "ابطال شده", + "confirmVoid": "آیا مطمئن هستید که می‌خواهید این آیتم را ابطال کنید؟" +} + +// messages/en.json +"pos": { + "void": "Void", + "voided": "Voided", + "confirmVoid": "Are you sure you want to void this item?" +} + +// messages/ar.json +"pos": { + "void": "إلغاء", + "voided": "ملغى", + "confirmVoid": "هل أنت متأكد أنك تريد إلغاء هذا الصنف؟" +} +``` + +--- + +### PR-1 Test Plan + +**Unit/integration (add to `OrderSessionTests.cs`):** +``` +✓ Void item reduces order total +✓ Void already-voided item returns ITEM_ALREADY_VOIDED +✓ Void item on closed order returns ORDER_ALREADY_CLOSED +✓ Non-manager role returns 403 +``` + +**Manual:** +1. Open table, add 3 items. +2. As Manager, click Void on item 2 — confirm dialog appears. +3. Confirm — item shows strikethrough, total updates immediately. +4. As Cashier (non-manager), verify void button is hidden. +5. Pay remaining items — order closes normally. + +--- + +--- + +## PR-2 — Transfer Table + +### Goal +Move an open order from one table to another. Clears source table, assigns order to target table. Blocked if target table is already occupied. + +### Files to create / edit + +``` +src/Meezi.API/Services/OrderService.cs ← TransferTableAsync +src/Meezi.API/Services/TableService.cs ← helper status check +src/Meezi.API/Controllers/OrdersController.cs ← POST endpoint +src/Meezi.API/DTOs/TransferTableRequest.cs ← new DTO +web/dashboard/src/components/pos/pos-table-board.tsx ← transfer UI +web/dashboard/src/lib/api/orders.ts ← transferTable() +messages/fa.json, en.json, ar.json ← transfer strings +``` + +--- + +### Step 1 — Request DTO + +```csharp +// src/Meezi.API/DTOs/TransferTableRequest.cs +public record TransferTableRequest(Guid TargetTableId); +``` + +--- + +### Step 2 — Service: `OrderService.cs` + +```csharp +public async Task> TransferTableAsync( + Guid orderId, Guid targetTableId, CancellationToken ct = default) +{ + var order = await _db.Orders + .Include(o => o.Items) + .FirstOrDefaultAsync(o => o.Id == orderId && o.CafeId == _tenant.CafeId, ct); + + if (order is null) + return ApiResponse.Fail("ORDER_NOT_FOUND"); + + if (order.Status == OrderStatus.Closed) + return ApiResponse.Fail("ORDER_ALREADY_CLOSED"); + + // Check target table exists and belongs to this cafe + var targetTable = await _db.Tables + .FirstOrDefaultAsync(t => t.Id == targetTableId && t.CafeId == _tenant.CafeId, ct); + + if (targetTable is null) + return ApiResponse.Fail("TABLE_NOT_FOUND"); + + // Check target not occupied + var targetOccupied = await _db.Orders.AnyAsync( + o => o.TableId == targetTableId && + o.CafeId == _tenant.CafeId && + o.Status == OrderStatus.Open && + o.Id != orderId, ct); + + if (targetOccupied) + return ApiResponse.Fail("TABLE_OCCUPIED"); + + if (targetTable.IsCleaning) + return ApiResponse.Fail("TABLE_CLEANING"); + + // Free source table (nothing to update on Table entity — occupancy is inferred from open orders) + var sourceTableId = order.TableId; + + order.TableId = targetTableId; + await _db.SaveChangesAsync(ct); + + return ApiResponse.Ok(_mapper.Map(order)); +} +``` + +--- + +### Step 3 — Controller: `OrdersController.cs` + +```csharp +/// Transfer an open order to another table. +[HttpPost("{orderId}/transfer")] +[Authorize(Roles = "Manager,Owner,Waiter")] +public async Task TransferTable( + Guid cafeId, + Guid orderId, + [FromBody] TransferTableRequest request, + CancellationToken ct) +{ + var result = await _orderService.TransferTableAsync(orderId, request.TargetTableId, ct); + return result.IsSuccess ? Ok(result) : BadRequest(result); +} +``` + +--- + +### Step 4 — Dashboard: API helper + +```typescript +// web/dashboard/src/lib/api/orders.ts + +export async function transferTable( + cafeId: string, + orderId: string, + targetTableId: string +): Promise { + await apiClient.post( + `/cafes/${cafeId}/orders/${orderId}/transfer`, + { targetTableId } + ); +} +``` + +--- + +### Step 5 — Dashboard: Transfer UI in `pos-table-board.tsx` + +Add a "Transfer" button in the order action bar (next to Pay). On click, show a table-picker modal listing free tables only: + +```tsx +// In pos-table-board.tsx — inside the active order action row + + + +{showTransferPicker && ( + !t.isOccupied && !t.isCleaning && t.id !== currentTableId)} + onSelect={async (tableId) => { + await transferTable(cafeId, activeOrderId, tableId); + setShowTransferPicker(false); + router.push(`/pos?tableId=${tableId}&orderId=${activeOrderId}`); + }} + onClose={() => setShowTransferPicker(false)} + /> +)} +``` + +`TablePickerModal` is a simple grid of table buttons — reuse the existing table card style from the board. + +--- + +### Step 6 — i18n strings + +```json +// messages/fa.json +"pos": { + "transferTable": "انتقال میز", + "selectTargetTable": "میز مقصد را انتخاب کنید", + "transferSuccess": "سفارش با موفقیت منتقل شد" +} + +// messages/en.json +"pos": { + "transferTable": "Transfer Table", + "selectTargetTable": "Select destination table", + "transferSuccess": "Order transferred successfully" +} + +// messages/ar.json +"pos": { + "transferTable": "نقل الطاولة", + "selectTargetTable": "اختر الطاولة المستهدفة", + "transferSuccess": "تم نقل الطلب بنجاح" +} +``` + +--- + +### PR-2 Test Plan + +**Integration tests:** +``` +✓ Transfer moves order to free target table +✓ Transfer to occupied table returns TABLE_OCCUPIED +✓ Transfer to cleaning table returns TABLE_CLEANING +✓ Transfer closed order returns ORDER_ALREADY_CLOSED +✓ Transfer to table in different cafe returns TABLE_NOT_FOUND +``` + +**Manual:** +1. Open two tables (A occupied, B free). +2. On table A's order, click Transfer Table. +3. Pick table B — verify board shows B occupied, A free. +4. Verify URL updates to `?tableId=B&orderId=...`. +5. Try transferring to occupied table — verify blocked with error toast. + +--- + +--- + +## PR-3 — SignalR Table Board Realtime + +### Goal +When any order/payment/cleaning event fires, all connected dashboard clients see the board update without refresh. + +### Files to create / edit + +``` +src/Meezi.API/Hubs/TableBoardHub.cs ← new SignalR hub +src/Meezi.API/Services/BoardNotifier.cs ← new service, sends hub events +src/Meezi.API/Extensions/ServiceCollectionExtensions.cs ← register hub + notifier +src/Meezi.API/Program.cs ← map hub endpoint +src/Meezi.API/Services/OrderService.cs ← call notifier after mutations +src/Meezi.API/Services/TableService.cs ← call notifier after cleaning toggle +web/dashboard/src/lib/hooks/useTableBoard.ts ← new hook, SignalR subscription +web/dashboard/src/components/pos/pos-table-board.tsx ← use hook +``` + +--- + +### Step 1 — Hub: `TableBoardHub.cs` + +```csharp +// src/Meezi.API/Hubs/TableBoardHub.cs +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace Meezi.API.Hubs; + +[Authorize] +public class TableBoardHub : Hub +{ + // Clients join a cafe-specific group on connect + public async Task JoinCafe(string cafeId) + { + await Groups.AddToGroupAsync(Context.ConnectionId, $"cafe:{cafeId}"); + } + + public async Task LeaveCafe(string cafeId) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"cafe:{cafeId}"); + } +} +``` + +--- + +### Step 2 — Notifier: `BoardNotifier.cs` + +```csharp +// src/Meezi.API/Services/BoardNotifier.cs +using Microsoft.AspNetCore.SignalR; +using Meezi.API.Hubs; + +namespace Meezi.API.Services; + +public interface IBoardNotifier +{ + Task TableUpdatedAsync(Guid cafeId, Guid tableId); + Task OrderUpdatedAsync(Guid cafeId, Guid orderId, Guid? tableId); +} + +public class BoardNotifier : IBoardNotifier +{ + private readonly IHubContext _hub; + + public BoardNotifier(IHubContext hub) + { + _hub = hub; + } + + public async Task TableUpdatedAsync(Guid cafeId, Guid tableId) + { + await _hub.Clients + .Group($"cafe:{cafeId}") + .SendAsync("TableUpdated", new { tableId }); + } + + public async Task OrderUpdatedAsync(Guid cafeId, Guid orderId, Guid? tableId) + { + await _hub.Clients + .Group($"cafe:{cafeId}") + .SendAsync("OrderUpdated", new { orderId, tableId }); + } +} +``` + +--- + +### Step 3 — Register in DI and map endpoint + +In `ServiceCollectionExtensions.cs`, add: +```csharp +services.AddScoped(); +services.AddSignalR(); // already added if KDS uses it — ensure not duplicated +``` + +In `Program.cs`, after `app.MapControllers()`: +```csharp +app.MapHub("/hubs/table-board") + .RequireAuthorization(); +``` + +--- + +### Step 4 — Hook into services + +In `OrderService.cs`, inject `IBoardNotifier` via constructor. After every mutation that changes table state, call the notifier: + +```csharp +// After order closed (payment completes): +await _boardNotifier.OrderUpdatedAsync(order.CafeId, order.Id, order.TableId); + +// After void item: +await _boardNotifier.OrderUpdatedAsync(order.CafeId, order.Id, order.TableId); + +// After transfer: +await _boardNotifier.TableUpdatedAsync(order.CafeId, sourceTableId); +await _boardNotifier.OrderUpdatedAsync(order.CafeId, order.Id, targetTableId); +``` + +In `TableService.cs`, after cleaning toggle: +```csharp +await _boardNotifier.TableUpdatedAsync(table.CafeId, table.Id); +``` + +--- + +### Step 5 — Dashboard hook: `useTableBoard.ts` + +```typescript +// web/dashboard/src/lib/hooks/useTableBoard.ts +import { useEffect, useCallback } from "react"; +import * as signalR from "@microsoft/signalr"; + +let connection: signalR.HubConnection | null = null; + +export function useTableBoardRealtime( + cafeId: string, + onTableUpdated: (tableId: string) => void, + onOrderUpdated: (orderId: string, tableId: string | null) => void +) { + const connect = useCallback(async () => { + if (connection) return; + + connection = new signalR.HubConnectionBuilder() + .withUrl("/hubs/table-board", { + accessTokenFactory: () => getAccessToken(), // existing auth helper + }) + .withAutomaticReconnect() + .build(); + + connection.on("TableUpdated", ({ tableId }: { tableId: string }) => { + onTableUpdated(tableId); + }); + + connection.on("OrderUpdated", ({ orderId, tableId }: { orderId: string; tableId: string | null }) => { + onOrderUpdated(orderId, tableId); + }); + + await connection.start(); + await connection.invoke("JoinCafe", cafeId); + }, [cafeId, onTableUpdated, onOrderUpdated]); + + useEffect(() => { + connect(); + return () => { + connection?.stop(); + connection = null; + }; + }, [connect]); +} +``` + +Install SignalR client if not already present: +```bash +cd web/dashboard +npm install @microsoft/signalr +``` + +--- + +### Step 6 — Wire into `pos-table-board.tsx` + +```tsx +// web/dashboard/src/components/pos/pos-table-board.tsx + +useTableBoardRealtime( + cafeId, + (_tableId) => { + // Refresh the full board (or targeted table) + reloadBoard(); + }, + (_orderId, _tableId) => { + reloadBoard(); + } +); +``` + +`reloadBoard` should call the existing `GET /tables` + `GET /orders/open` (or whatever the board currently uses). + +--- + +### PR-3 Test Plan + +**Manual (two browser tabs):** +1. Open board in Tab A, same cafe in Tab B. +2. In Tab B, pay an order on Table 3. +3. Tab A — verify Table 3 changes to green (free) without refresh. +4. In Tab B, mark Table 5 as cleaning. +5. Tab A — verify Table 5 shows cleaning state within ~1 second. +6. Reload Tab A — verify reconnect works (SignalR `withAutomaticReconnect`). + +**Iran/network note:** SignalR falls back to long-polling if WebSocket is blocked. No extra config needed — `HubConnectionBuilder` handles this automatically. + +--- + +--- + +## PR-4 — Receipt Print Preview + +### Goal +Thermal-style (80mm) print preview for a paid or open order. Uses `window.print()` — no hardware SDK required. + +### Files to create / edit + +``` +web/dashboard/src/components/pos/pos-receipt-modal.tsx ← new component +web/dashboard/src/components/pos/pos-receipt-print.css ← thermal print styles +web/dashboard/src/components/pos/pos-pay-panel.tsx ← trigger after payment +web/dashboard/src/components/pos/pos-table-board.tsx ← "Print" button on closed orders (optional) +web/dashboard/src/lib/api/orders.ts ← getOrder() if not already +messages/fa.json, en.json, ar.json ← receipt strings +``` + +--- + +### Step 1 — Print CSS: `pos-receipt-print.css` + +```css +/* web/dashboard/src/components/pos/pos-receipt-print.css */ + +@media print { + body * { visibility: hidden; } + #receipt-print-area, + #receipt-print-area * { visibility: visible; } + #receipt-print-area { + position: absolute; + inset: 0; + margin: 0; + padding: 0; + } +} + +#receipt-print-area { + width: 80mm; + font-family: 'Courier New', monospace; + font-size: 12px; + direction: rtl; + text-align: right; + padding: 4mm; +} + +.receipt-divider { + border-top: 1px dashed #000; + margin: 3mm 0; +} + +.receipt-row { + display: flex; + justify-content: space-between; +} + +.receipt-total { + font-weight: bold; + font-size: 14px; +} +``` + +--- + +### Step 2 — Receipt Component: `pos-receipt-modal.tsx` + +```tsx +// web/dashboard/src/components/pos/pos-receipt-modal.tsx +"use client"; + +import { useTranslations } from "next-intl"; +import "./pos-receipt-print.css"; +import type { OrderDto } from "@/lib/api/types"; + +interface Props { + order: OrderDto; + cafeName: string; + onClose: () => void; +} + +export function PosReceiptModal({ order, cafeName, onClose }: Props) { + const t = useTranslations("receipt"); + + const handlePrint = () => window.print(); + + const activeItems = order.items.filter((i) => !i.isVoided); + const formattedDate = new Intl.DateTimeFormat("fa-IR", { + dateStyle: "short", + timeStyle: "short", + }).format(new Date(order.createdAt)); + + return ( +
+
+ {/* Screen preview */} +
+
{cafeName}
+
{formattedDate}
+
+ {t("table")}: {order.tableName ?? "—"} | {t("order")}: #{order.orderNumber} +
+ {order.guestName && ( +
{t("guest")}: {order.guestName}
+ )} + +
+ + {activeItems.map((item) => ( +
+ {item.productName} × {item.quantity} + {formatCurrency(item.unitPrice * item.quantity)} +
+ ))} + +
+ +
+ {t("total")} + {formatCurrency(order.totalAmount)} +
+ + {order.payments?.map((p, i) => ( +
+ {t(`payment.${p.method.toLowerCase()}`)} + {formatCurrency(p.amount)} +
+ ))} + +
+
{t("thankYou")}
+
+ + {/* Actions */} +
+ + +
+
+
+ ); +} + +function formatCurrency(amount: number) { + return new Intl.NumberFormat("fa-IR").format(amount) + " تومان"; +} +``` + +--- + +### Step 3 — Trigger from `pos-pay-panel.tsx` + +After a successful payment response, show the receipt: + +```tsx +// After successful payment call in pos-pay-panel.tsx + +const [receiptOrder, setReceiptOrder] = useState(null); + +// On payment success: +setReceiptOrder(paidOrder); // paidOrder = the OrderDto returned by the payment API + +// In JSX: +{receiptOrder && ( + { + setReceiptOrder(null); + onPaymentComplete(); // existing callback + }} + /> +)} +``` + +--- + +### Step 4 — i18n strings + +```json +// messages/fa.json +"receipt": { + "table": "میز", + "order": "سفارش", + "guest": "مهمان", + "total": "مجموع", + "print": "چاپ", + "close": "بستن", + "thankYou": "ممنون از انتخاب شما", + "payment": { + "cash": "نقد", + "card": "کارت", + "credit": "اعتبار" + } +} + +// messages/en.json +"receipt": { + "table": "Table", + "order": "Order", + "guest": "Guest", + "total": "Total", + "print": "Print", + "close": "Close", + "thankYou": "Thank you for your visit", + "payment": { + "cash": "Cash", + "card": "Card", + "credit": "Credit" + } +} + +// messages/ar.json +"receipt": { + "table": "الطاولة", + "order": "الطلب", + "guest": "الضيف", + "total": "الإجمالي", + "print": "طباعة", + "close": "إغلاق", + "thankYou": "شكراً على زيارتكم", + "payment": { + "cash": "نقداً", + "card": "بطاقة", + "credit": "رصيد" + } +} +``` + +--- + +### PR-4 Test Plan + +**Manual:** +1. Open table, add items, pay (split cash + card). +2. Verify receipt modal auto-opens after payment. +3. Verify all items listed, voided items excluded. +4. Verify payment methods shown (Cash X, Card Y). +5. Click Print — browser print dialog opens; preview shows 80mm receipt layout. +6. Test in Persian (fa) — text is RTL, numbers in Persian numerals. +7. Close modal — board reflects freed table. + +--- + +--- + +## PR-5 — Integration Test Coverage + +### Goal +Fill the gaps in `OrderSessionTests.cs` and add auth + payment integration tests. + +### Files to create / edit + +``` +tests/Meezi.API.Tests/OrderSessionTests.cs ← extend existing +tests/Meezi.API.Tests/OrderVoidTransferTests.cs ← new +tests/Meezi.API.Tests/PaymentSplitTests.cs ← new +tests/Meezi.API.Tests/AuthTests.cs ← new +``` + +--- + +### `OrderVoidTransferTests.cs` — full file + +```csharp +// tests/Meezi.API.Tests/OrderVoidTransferTests.cs +using System.Net; +using System.Net.Http.Json; +using Meezi.API.DTOs; +using Xunit; + +namespace Meezi.API.Tests; + +public class OrderVoidTransferTests : IClassFixture +{ + private readonly HttpClient _client; + + public OrderVoidTransferTests(MeeziWebApplicationFactory factory) + { + _client = factory.CreateAuthenticatedClient(role: "Manager"); + } + + [Fact] + public async Task VoidItem_ReducesOrderTotal() + { + // Arrange: create order with 2 items + var (cafeId, orderId, items) = await CreateOrderWithItems(2); + var itemId = items[0].Id; + var originalTotal = items.Sum(i => i.UnitPrice * i.Quantity); + + // Act + var response = await _client.PatchAsJsonAsync( + $"/api/cafes/{cafeId}/orders/{orderId}/items/{itemId}/void", new { }); + + // Assert + response.EnsureSuccessStatusCode(); + var order = await GetOrder(cafeId, orderId); + Assert.Equal(originalTotal - items[0].UnitPrice, order.TotalAmount); + } + + [Fact] + public async Task VoidItem_AlreadyVoided_ReturnsBadRequest() + { + var (cafeId, orderId, items) = await CreateOrderWithItems(1); + var itemId = items[0].Id; + + await _client.PatchAsJsonAsync( + $"/api/cafes/{cafeId}/orders/{orderId}/items/{itemId}/void", new { }); + + var response = await _client.PatchAsJsonAsync( + $"/api/cafes/{cafeId}/orders/{orderId}/items/{itemId}/void", new { }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var error = await response.Content.ReadFromJsonAsync(); + Assert.Equal("ITEM_ALREADY_VOIDED", error?.Code); + } + + [Fact] + public async Task TransferTable_MovesOrderToFreeTable() + { + var (cafeId, orderId, sourceTableId, targetTableId) = await SetupTwoTables(); + + var response = await _client.PostAsJsonAsync( + $"/api/cafes/{cafeId}/orders/{orderId}/transfer", + new { targetTableId }); + + response.EnsureSuccessStatusCode(); + + // Source table should be free + Assert.False(await IsTableOccupied(cafeId, sourceTableId)); + // Target table should be occupied + Assert.True(await IsTableOccupied(cafeId, targetTableId)); + } + + [Fact] + public async Task TransferTable_ToOccupiedTable_ReturnsTableOccupied() + { + // Both tables occupied + var (cafeId, order1Id, table1Id, _) = await SetupTwoTables(); + var (_, order2Id, table2Id, _) = await SetupTwoTables(cafeId); + + var response = await _client.PostAsJsonAsync( + $"/api/cafes/{cafeId}/orders/{order1Id}/transfer", + new { targetTableId = table2Id }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var error = await response.Content.ReadFromJsonAsync(); + Assert.Equal("TABLE_OCCUPIED", error?.Code); + } + + // Helper methods omitted for brevity — follow MeeziWebApplicationFactory patterns +} +``` + +--- + +### `PaymentSplitTests.cs` — key cases + +```csharp +[Fact] +public async Task SplitPayment_CashAndCard_ClosesOrder() +{ + var (cafeId, orderId) = await CreateOrderWithTotal(1000m); + + var response = await _client.PostAsJsonAsync( + $"/api/cafes/{cafeId}/orders/{orderId}/payments", + new { + payments = new[] { + new { method = "Cash", amount = 600 }, + new { method = "Card", amount = 400 } + } + }); + + response.EnsureSuccessStatusCode(); + var order = await GetOrder(cafeId, orderId); + Assert.Equal("Closed", order.Status); + Assert.Equal(2, order.Payments.Length); +} + +[Fact] +public async Task Payment_FreesTableOnBoard() +{ + var (cafeId, orderId, tableId) = await CreateOrderOnTable(); + + await _client.PostAsJsonAsync( + $"/api/cafes/{cafeId}/orders/{orderId}/payments", + new { payments = new[] { new { method = "Cash", amount = 500 } } }); + + var tableStatus = await GetTableStatus(cafeId, tableId); + Assert.Equal("Free", tableStatus); +} +``` + +--- + +### `AuthTests.cs` — key cases + +```csharp +[Fact] +public async Task UnauthorizedRequest_Returns401() +{ + var anonClient = _factory.CreateClient(); // no auth + var response = await anonClient.GetAsync("/api/cafes/00000000/orders"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); +} + +[Fact] +public async Task WrongCafeId_Returns403OrEmpty() +{ + var response = await _client.GetAsync("/api/cafes/99999999/orders"); + // Either 403 or empty list — not another cafe's data + Assert.True( + response.StatusCode == HttpStatusCode.Forbidden || + (await response.Content.ReadFromJsonAsync>>())?.Data?.Count == 0 + ); +} +``` + +--- + +### Run tests + +```bash +dotnet test tests/Meezi.API.Tests/Meezi.API.Tests.csproj -c Release --logger "console;verbosity=normal" +``` + +Expected: all existing 13 + new ~12 tests pass. + +--- + +--- + +## PR-6 — Fix NU1903 Package Warnings + +### Goal +Eliminate the two vulnerability warnings that appear on every build. + +### Warning 1: AutoMapper 12.0.1 + +**Option A — Bump AutoMapper (preferred):** +```xml + + + + + +``` + +Then verify no breaking changes (AutoMapper 13 removed some static APIs): +```bash +dotnet build src/Meezi.API/Meezi.API.csproj -c Release 2>&1 | grep -i error +``` + +**Option B — Replace with Mapperly (zero-reflection, .NET 10 ideal):** + +Only do this if AutoMapper 13 has breaking changes. Mapperly generates mapping code at compile time. Migration guide: https://mapperly.riok.app/docs/getting-started/migration/automapper/ + +--- + +### Warning 2: System.Security.Cryptography.Xml 9.0.0 + +This is a transitive dependency. Pin it to the fixed version: +```xml + + +``` + +Then in the project that pulls it transitively (likely `Meezi.Infrastructure`): +```xml + + +``` + +(No version needed — central package management handles it.) + +--- + +### Verify + +```bash +dotnet build src/Meezi.API/Meezi.API.csproj -c Release 2>&1 | grep NU1903 +# Expected: no output +``` + +--- + +--- + +## Implementation Order & Branching + +``` +main + └── feature/void-order-line (PR-1, ~2h) + └── feature/transfer-table (PR-2, depends on PR-1 merge) + └── feature/signalr-board (PR-3, depends on PR-2) + └── feature/receipt (PR-4, independent of PR-3 but nice with it) + +feature/integration-tests (PR-5, can start any time in parallel) +feature/fix-nu1903 (PR-6, can start any time in parallel) +``` + +PRs 5 and 6 are independent — open them alongside PR-1 in parallel. + +--- + +## Non-Negotiables (from `.cursorrules`) + +- Every EF query: `CafeId == _tenant.CafeId` +- Responses: `ApiResponse` / `ApiError` with error codes +- No hardcoded UI strings — all in `messages/{fa,ar,en}.json` +- Dashboard CSS: `ms-*` / `me-*` only (RTL-safe) +- Iran Docker: if any PR adds a new NuGet or npm package, document it; Docker pulls may need VPN/mirror +- Tests: in-memory EF, `Testing:Enabled=true`, no live Redis/Hangfire in test mode + +--- + +## Quick Reference — Error Codes Added This Sprint + +| Code | Meaning | +|------|---------| +| `ITEM_NOT_FOUND` | Line item ID not on this order | +| `ITEM_ALREADY_VOIDED` | Tried to void an already-voided line | +| `TABLE_CLEANING` | Target table is in cleaning state | +| `ORDER_ALREADY_CLOSED` | Mutation attempted on a closed order | + +--- + +*End of plan — paste this file into Cursor and start with PR-1.* diff --git a/docs/MEEZI_PRINTER_PLAN.md b/docs/MEEZI_PRINTER_PLAN.md new file mode 100644 index 0000000..1d88474 --- /dev/null +++ b/docs/MEEZI_PRINTER_PLAN.md @@ -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 _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 PrintReceiptAsync(Guid orderId, CancellationToken ct); + Task PrintKitchenTicketAsync(Guid orderId, CancellationToken ct); + Task 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 _logger; + + public async Task 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 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 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(); +services.AddScoped(); +services.AddScoped(); + +──────────────────────────────────────────────────────────────── +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: + + +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: + + +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 { + 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 { + 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 { + 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(null); +const [availablePrinters, setAvailablePrinters] = useState([]); + +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 printReceipt(OrderModel order, BranchSettings settings); + Future printKitchenTicket(OrderModel order); + Future> discoverPrinters(); +} + +class NetworkPrintService implements PrintService { + @override + Future printReceipt(OrderModel order, BranchSettings settings) async { + final bytes = _buildReceiptBytes(order, settings); + return await _sendToNetworkPrinter( + settings.receiptPrinterIp!, + settings.receiptPrinterPort ?? 9100, + bytes + ); + } + + Future _sendToNetworkPrinter(String ip, int port, List 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 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> 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.* diff --git a/docs/MEEZI_QR_MENU_PLAN.md b/docs/MEEZI_QR_MENU_PLAN.md new file mode 100644 index 0000000..b43e4ed --- /dev/null +++ b/docs/MEEZI_QR_MENU_PLAN.md @@ -0,0 +1,1032 @@ +# Meezi — QR Guest Menu + Branch Visual Identity + Tax Inheritance +> Copy-paste into Cursor. Based on the bug at /q/demo_table_01 and the branching decisions. +> Three self-contained PRs. Start with PR-1 (the broken QR flow). + +--- + +## What's broken and what's new + +| Area | Status | Work | +|------|--------|------| +| `/q/[code]` → shows menu | ❌ Broken | Full fix in PR-1 | +| Guest can browse menu by category | ❌ Missing | PR-1 | +| Guest can place order from QR | ❌ Missing | PR-1 | +| Order appears in dashboard panel | ❌ Missing | PR-1 | +| Branch has own menu (from parent catalog) | ⚠️ Partial | PR-1 uses BranchMenuItemOverride | +| Parent defines child branch tax rates | ❌ Missing | PR-2 | +| Parent defines visual identity (colors, icon) | ❌ Missing | PR-3 | + +--- + +## PR-1 — Fix QR Guest Menu: Full Flow + +### The flow (end to end) + +``` +Guest scans QR on table + ↓ +/q/demo_table_01 (Next.js public page) + ↓ +GET /api/q/demo_table_01 + → resolves table → branch → cafe + → returns: { cafeId, branchId, tableId, tableName, branchName, cafeName } + ↓ +Guest sees: branch name, menu categories, items with prices + ↓ +Guest picks items → taps "سفارش" (Order) + ↓ +POST /api/public/{cafeId}/branches/{branchId}/orders + Body: { tableId, guestName?, guestPhone?, items: [{menuItemId, qty, note?}] } + ↓ +Order created → appears in dashboard POS board immediately (SignalR) + ↓ +Guest sees: "سفارش شما ثبت شد ✓" confirmation screen +``` + +--- + +### STEP 1 — Fix the public QR resolver endpoint + +**File: `src/Meezi.API/Controllers/QrController.cs`** + +The current endpoint likely returns minimal data. Expand it: + +```csharp +[AllowAnonymous] +[HttpGet("/api/q/{qrCode}")] +public async Task ResolveQr(string qrCode, CancellationToken ct) +{ + // Find table by QrCode field + var table = await _db.Tables + .Include(t => t.Branch) + .ThenInclude(b => b.Cafe) + .Include(t => t.Branch) + .ThenInclude(b => b.BranchSettings) + .FirstOrDefaultAsync(t => t.QrCode == qrCode && t.IsActive, ct); + + if (table is null) + return NotFound(ApiError.Create("TABLE_NOT_FOUND")); + + // Load branch visual identity (colors, icon) — PR-3 adds this + var identity = table.Branch.VisualIdentity; // may be null until PR-3 + + return Ok(new QrResolveDto + { + TableId = table.Id, + TableName = table.Name, + BranchId = table.BranchId, + BranchName = table.Branch.Name, + CafeId = table.Branch.CafeId, + CafeName = table.Branch.Cafe.Name, + // Visual identity — fallback to cafe defaults if branch hasn't set own + PrimaryColor = identity?.PrimaryColor ?? "#C47B2B", // warm coffee default + LogoUrl = identity?.LogoUrl ?? table.Branch.Cafe.LogoUrl, + WelcomeText = table.Branch.BranchSettings?.WelcomeText + ?? table.Branch.Cafe.WelcomeText + ?? "خوش آمدید", + }); +} +``` + +--- + +### STEP 2 — Public branch menu endpoint + +**File: `src/Meezi.API/Controllers/PublicController.cs`** + +Add (no auth — `[AllowAnonymous]` already on controller): + +```csharp +/// Returns the effective menu for a branch, visible to guests. +[HttpGet("/api/public/{cafeId}/branches/{branchId}/menu")] +public async Task GetBranchMenu( + Guid cafeId, Guid branchId, CancellationToken ct) +{ + // Validate branch belongs to cafe + var branch = await _db.Branches + .FirstOrDefaultAsync(b => b.Id == branchId && b.CafeId == cafeId && b.IsActive, ct); + + if (branch is null) + return NotFound(ApiError.Create("BRANCH_NOT_FOUND")); + + // Load all active menu items for this cafe + var items = await _db.MenuItems + .Include(m => m.Category) + .Where(m => m.CafeId == cafeId && m.IsActive) + .ToListAsync(ct); + + // Load branch overrides + var overrides = await _db.BranchMenuItemOverrides + .Where(o => o.BranchId == branchId && o.CafeId == cafeId) + .ToDictionaryAsync(o => o.MenuItemId, ct); + + // Resolve effective menu — exclude unavailable items + var resolved = items + .Where(item => !overrides.TryGetValue(item.Id, out var ov) || ov.IsAvailable) + .Select(item => + { + overrides.TryGetValue(item.Id, out var ov); + return new PublicMenuItemDto + { + Id = item.Id, + CategoryId = item.CategoryId, + CategoryName = item.Category.Name, + CategorySort = item.Category.SortOrder, + Name = item.Name, + Description = item.Description, + ImageUrl = item.ImageUrl, + EffectivePrice = ov?.PriceOverride ?? item.BasePrice, + Tags = item.Tags, + IsAvailable = true, + SortOrder = ov?.SortOrderOverride ?? item.SortOrder, + }; + }) + .OrderBy(i => i.CategorySort) + .ThenBy(i => i.SortOrder) + .GroupBy(i => new { i.CategoryId, i.CategoryName, i.CategorySort }) + .Select(g => new PublicMenuCategoryDto + { + Id = g.Key.CategoryId, + Name = g.Key.CategoryName, + Sort = g.Key.CategorySort, + Items = g.ToList(), + }) + .ToList(); + + return Ok(ApiResponse>.Ok(resolved)); +} +``` + +--- + +### STEP 3 — Public order placement endpoint + +```csharp +/// Guest places an order from QR menu. +[HttpPost("/api/public/{cafeId}/branches/{branchId}/orders")] +public async Task PlaceGuestOrder( + Guid cafeId, Guid branchId, + [FromBody] PlaceGuestOrderRequest request, + CancellationToken ct) +{ + // Validate table belongs to this branch + var table = await _db.Tables + .FirstOrDefaultAsync(t => t.Id == request.TableId + && t.BranchId == branchId + && t.CafeId == cafeId + && t.IsActive, ct); + + if (table is null) + return BadRequest(ApiError.Create("TABLE_NOT_FOUND")); + + if (table.IsCleaning) + return BadRequest(ApiError.Create("TABLE_CLEANING")); + + // Validate items exist and are available at this branch + var itemIds = request.Items.Select(i => i.MenuItemId).ToList(); + var menuItems = await _db.MenuItems + .Where(m => itemIds.Contains(m.Id) && m.CafeId == cafeId && m.IsActive) + .ToListAsync(ct); + + if (menuItems.Count != itemIds.Distinct().Count()) + return BadRequest(ApiError.Create("INVALID_MENU_ITEMS")); + + // Load price overrides + var overrides = await _db.BranchMenuItemOverrides + .Where(o => o.BranchId == branchId && itemIds.Contains(o.MenuItemId)) + .ToDictionaryAsync(o => o.MenuItemId, ct); + + // Find or create open order for this table (merge logic — same as POS) + var existingOrder = await _db.Orders + .Include(o => o.Items) + .FirstOrDefaultAsync(o => o.TableId == table.Id + && o.CafeId == cafeId + && o.Status == OrderStatus.Open, ct); + + var order = existingOrder ?? new Order + { + Id = Guid.NewGuid(), + CafeId = cafeId, + BranchId = branchId, + TableId = table.Id, + Status = OrderStatus.Open, + Source = OrderSource.GuestQr, // new enum value + GuestName = request.GuestName, + GuestPhone = request.GuestPhone, + CreatedAt = DateTime.UtcNow, + }; + + // Append items + foreach (var line in request.Items) + { + var menuItem = menuItems.First(m => m.Id == line.MenuItemId); + overrides.TryGetValue(line.MenuItemId, out var ov); + var price = ov?.PriceOverride ?? menuItem.BasePrice; + + order.Items.Add(new OrderItem + { + Id = Guid.NewGuid(), + MenuItemId = line.MenuItemId, + ProductName = menuItem.Name, + Quantity = line.Quantity, + UnitPrice = price, + Notes = line.Notes, + Source = ItemSource.GuestQr, + }); + } + + order.TotalAmount = order.Items + .Where(i => !i.IsVoided) + .Sum(i => i.UnitPrice * i.Quantity); + + if (existingOrder is null) + _db.Orders.Add(order); + + await _db.SaveChangesAsync(ct); + + // Notify dashboard via SignalR + await _boardNotifier.OrderUpdatedAsync(cafeId, order.Id, order.TableId); + + return Ok(ApiResponse.Ok(new GuestOrderConfirmDto + { + OrderId = order.Id, + OrderNumber = order.OrderNumber, + TotalAmount = order.TotalAmount, + ItemCount = request.Items.Sum(i => i.Quantity), + })); +} +``` + +**DTOs to add:** + +```csharp +// PlaceGuestOrderRequest.cs +public record PlaceGuestOrderRequest( + Guid TableId, + string? GuestName, + string? GuestPhone, + List Items +); + +public record GuestOrderLine(Guid MenuItemId, int Quantity, string? Notes); + +// GuestOrderConfirmDto.cs +public record GuestOrderConfirmDto( + Guid OrderId, int OrderNumber, decimal TotalAmount, int ItemCount +); + +// Add to Order entity: +public OrderSource Source { get; set; } = OrderSource.Pos; + +// OrderSource enum: +public enum OrderSource { Pos, GuestQr, Kiosk, SnappFood } + +// Add to OrderItem: +public ItemSource Source { get; set; } = ItemSource.Pos; +public enum ItemSource { Pos, GuestQr } +``` + +--- + +### STEP 4 — EF migration + +```bash +dotnet ef migrations add GuestQrOrderSource \ + --project src/Meezi.Infrastructure \ + --startup-project src/Meezi.API \ + --output-dir Data/Migrations +``` + +--- + +### STEP 5 — Fix `web/dashboard/src/app/q/[code]/page.tsx` + +Full rewrite of the guest-facing QR page. This is a public page — no auth, no dashboard layout. + +```tsx +"use client"; +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; + +type BranchInfo = { + tableId: string; tableName: string; + branchId: string; branchName: string; + cafeId: string; cafeName: string; + primaryColor: string; logoUrl?: string; welcomeText: string; +}; + +type MenuItem = { + id: string; name: string; description?: string; + imageUrl?: string; effectivePrice: number; tags?: string[]; +}; + +type Category = { id: string; name: string; items: MenuItem[] }; + +type CartItem = { item: MenuItem; qty: number; note?: string }; + +type Screen = "loading" | "error" | "menu" | "cart" | "confirm" | "success"; + +export default function QrMenuPage() { + const { code } = useParams<{ code: string }>(); + + const [screen, setScreen] = useState("loading"); + const [error, setError] = useState(""); + const [branch, setBranch] = useState(null); + const [categories, setCategories] = useState([]); + const [activeCategory, setActiveCategory] = useState(""); + const [cart, setCart] = useState([]); + const [guestName, setGuestName] = useState(""); + const [guestPhone, setGuestPhone] = useState(""); + const [orderId, setOrderId] = useState(""); + const [orderNumber, setOrderNumber] = useState(0); + + const apiBase = process.env.NEXT_PUBLIC_API_URL ?? ""; + + // Step 1: resolve QR code → branch info + useEffect(() => { + fetch(`${apiBase}/api/q/${code}`) + .then(r => r.ok ? r.json() : Promise.reject(r.status)) + .then(data => { + setBranch(data); + // Step 2: load menu + return fetch( + `${apiBase}/api/public/${data.cafeId}/branches/${data.branchId}/menu` + ); + }) + .then(r => r.ok ? r.json() : Promise.reject(r.status)) + .then(res => { + setCategories(res.data ?? []); + setActiveCategory(res.data?.[0]?.id ?? ""); + setScreen("menu"); + }) + .catch(() => { + setError("میز یافت نشد یا منو در دسترس نیست"); + setScreen("error"); + }); + }, [code]); + + const totalItems = cart.reduce((s, c) => s + c.qty, 0); + const totalPrice = cart.reduce((s, c) => s + c.item.effectivePrice * c.qty, 0); + + function addToCart(item: MenuItem) { + setCart(prev => { + const idx = prev.findIndex(c => c.item.id === item.id); + if (idx >= 0) { + const next = [...prev]; + next[idx] = { ...next[idx], qty: next[idx].qty + 1 }; + return next; + } + return [...prev, { item, qty: 1 }]; + }); + } + + function removeFromCart(itemId: string) { + setCart(prev => { + const idx = prev.findIndex(c => c.item.id === itemId); + if (idx < 0) return prev; + const next = [...prev]; + if (next[idx].qty > 1) { + next[idx] = { ...next[idx], qty: next[idx].qty - 1 }; + } else { + next.splice(idx, 1); + } + return next; + }); + } + + async function submitOrder() { + if (!branch || cart.length === 0) return; + setScreen("loading"); + try { + const res = await fetch( + `${apiBase}/api/public/${branch.cafeId}/branches/${branch.branchId}/orders`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + tableId: branch.tableId, + guestName: guestName || null, + guestPhone: guestPhone || null, + items: cart.map(c => ({ + menuItemId: c.item.id, + quantity: c.qty, + notes: c.note ?? null, + })), + }), + } + ); + if (!res.ok) throw new Error(); + const data = await res.json(); + setOrderId(data.data.orderId); + setOrderNumber(data.data.orderNumber); + setScreen("success"); + } catch { + setError("خطا در ثبت سفارش. دوباره امتحان کنید"); + setScreen("cart"); + } + } + + const primary = branch?.primaryColor ?? "#C47B2B"; + + // ── Screens ────────────────────────────────────────────────────────────── + + if (screen === "loading") return ( +
+
+ +

در حال بارگذاری...

+
+ ); + + if (screen === "error") return ( +
+

😕

+

{error}

+

لطفاً دوباره کد QR را اسکن کنید

+
+ ); + + if (screen === "success") return ( +
+
+

سفارش ثبت شد!

+

شماره سفارش: #{orderNumber}

+

کارکنان به زودی سفارش شما را آماده می‌کنند

+ +
+ ); + + if (screen === "cart") return ( +
+ {/* Header */} +
+ +

سبد خرید

+
+ + {/* Cart items */} + {cart.map(c => ( +
+
+

{c.item.name}

+

+ {c.item.effectivePrice.toLocaleString("fa-IR")} تومان +

+
+
+ + {c.qty} + +
+
+ ))} + + {/* Guest info */} +
+ setGuestName(e.target.value)} + placeholder="نام شما (اختیاری)" + style={{ width: "100%", padding: "10px 14px", borderRadius: 10, + border: "1px solid #ddd", fontSize: 15, marginBottom: 10, + boxSizing: "border-box", textAlign: "right" }} /> + setGuestPhone(e.target.value)} + placeholder="شماره موبایل (اختیاری)" + inputMode="tel" + style={{ width: "100%", padding: "10px 14px", borderRadius: 10, + border: "1px solid #ddd", fontSize: 15, + boxSizing: "border-box", textAlign: "right" }} /> +
+ + {/* Total + submit */} +
+
+ جمع کل: + + {totalPrice.toLocaleString("fa-IR")} تومان + +
+ +
+
+ ); + + // screen === "menu" + return ( +
+ + {/* Branch header */} +
+ {branch?.logoUrl && ( + {branch.cafeName} + )} +

{branch?.cafeName}

+

{branch?.branchName}

+

+ {branch?.welcomeText} — میز {branch?.tableName} +

+
+ + {/* Category tabs */} +
+ {categories.map(cat => ( + + ))} +
+ + {/* Menu items */} +
+ {categories + .filter(cat => cat.id === activeCategory) + .flatMap(cat => cat.items) + .map(item => { + const inCart = cart.find(c => c.item.id === item.id); + return ( +
+ {item.imageUrl && ( + {item.name} + )} +
+

+ {item.name} +

+ {item.description && ( +

+ {item.description} +

+ )} +
+ + {item.effectivePrice.toLocaleString("fa-IR")} تومان + + {inCart ? ( +
+ + {inCart.qty} + +
+ ) : ( + + )} +
+
+
+ ); + })} +
+ + {/* Floating cart bar */} + {totalItems > 0 && ( +
+ +
+ )} +
+ ); +} +``` + +--- + +### STEP 6 — Dashboard: show guest QR orders in POS board + +In `pos-table-board.tsx` — orders with `Source == GuestQr` should show a badge: + +```tsx +{order.source === "GuestQr" && ( + + QR سفارش مهمان + +)} +``` + +In `OrderDto`, add: `public string Source { get; set; }` (mapped from `OrderSource` enum). + +--- + +### STEP 7 — Tests + +Add to `tests/Meezi.API.Tests/QrMenuTests.cs`: + +``` +✓ ResolveQr_ValidCode_ReturnsBranchInfo +✓ ResolveQr_InvalidCode_ReturnsNotFound +✓ GetBranchMenu_ExcludesUnavailableItems +✓ GetBranchMenu_AppliesBranchPriceOverride +✓ PlaceGuestOrder_ValidItems_CreatesOrder +✓ PlaceGuestOrder_MergesWithExistingOpenOrder +✓ PlaceGuestOrder_CleaningTable_ReturnsTableCleaning +✓ PlaceGuestOrder_InvalidMenuItemForBranch_ReturnsInvalidMenuItems +``` + +--- + +## PR-2 — Parent Branch Tax Inheritance + +### The rule (finalized) + +``` +Cafe (owner) defines: + - DefaultTaxRate → applies to ALL branches unless overridden + - AllowBranchTaxOverride (bool) — if false, branches CANNOT change their own tax + +Branch can ONLY change tax if parent AllowBranchTaxOverride == true +Otherwise: branch tax is ALWAYS the cafe's DefaultTaxRate, read-only +``` + +--- + +### STEP 1 — Add to CafeSettings + +```csharp +// src/Meezi.Core/Entities/CafeSettings.cs — add: +public decimal DefaultTaxRate { get; set; } = 0m; +public bool AllowBranchTaxOverride { get; set; } = false; +``` + +--- + +### STEP 2 — Update EffectiveSettingsService + +```csharp +public async Task 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); + + // Tax: only use branch override if parent explicitly allows it + var allowTaxOverride = cafeSettings?.AllowBranchTaxOverride ?? false; + var effectiveTaxRate = (allowTaxOverride && branchSettings?.TaxRate != null) + ? branchSettings.TaxRate.Value + : (cafeSettings?.DefaultTaxRate ?? 0m); + + return new BranchEffectiveSettingsDto + { + TaxRate = effectiveTaxRate, + TaxRateIsOverridden = allowTaxOverride && branchSettings?.TaxRate != null, + TaxRateLocked = !allowTaxOverride, // UI shows lock icon if true + // ... other settings + }; +} +``` + +--- + +### STEP 3 — Guard in BranchSettings PATCH endpoint + +```csharp +// In BranchSettingsController.PatchAsync — before saving tax rate: +if (request.TaxRate.HasValue) +{ + var cafeSettings = await _db.CafeSettings + .FirstOrDefaultAsync(s => s.CafeId == _tenant.CafeId, ct); + + if (cafeSettings?.AllowBranchTaxOverride != true) + return BadRequest(ApiResponse.Fail("TAX_OVERRIDE_NOT_ALLOWED", + "تغییر نرخ مالیات شعبه توسط مالک غیرفعال شده است")); +} +``` + +--- + +### STEP 4 — Dashboard: Tax settings UI + +In cafe-level settings (owner only): +``` +مالیات پیش‌فرض کل کافه: [ 9 ] % +☐ اجازه تغییر نرخ مالیات به مدیران شعبه +``` + +In branch settings (manager view): +``` +نرخ مالیات: 9% 🔒 (تعریف شده توسط مالک) +``` +OR if `AllowBranchTaxOverride == true`: +``` +نرخ مالیات: [ 12 ] % ✏️ (قابل تغییر) +``` + +--- + +### Migration + +```bash +dotnet ef migrations add TaxInheritanceControl \ + --project src/Meezi.Infrastructure \ + --startup-project src/Meezi.API +``` + +--- + +## PR-3 — Visual Identity Per Branch + +### What the parent defines for children + +``` +Cafe (parent) defines the BRAND: + - CafeName, LogoUrl + - PrimaryColor (hex) ← all branches inherit this + - SecondaryColor (hex) + - FontFamily (string) ← optional: "Vazir", "IRANSans", "default" + - FaviconUrl + +Branch CAN override for its location: + - BranchLogoUrl ← e.g. branch-specific photo + - AccentColor ← slight variation + - WelcomeText ← "خوش آمدید به شعبه نیاوران" + - WifiPassword ← shown on QR menu and receipts + +Branch CANNOT override: + - CafeName (always the brand name) + - PrimaryColor (brand consistency) + - FontFamily (brand consistency) +``` + +--- + +### STEP 1 — CafeIdentity entity (new) + +```csharp +// src/Meezi.Core/Entities/CafeIdentity.cs +public class CafeIdentity +{ + public Guid Id { get; set; } + public Guid CafeId { get; set; } + public string PrimaryColor { get; set; } = "#C47B2B"; // warm coffee default + public string SecondaryColor { get; set; } = "#F5F0E8"; // light cream + public string? FontFamily { get; set; } + public string? LogoUrl { get; set; } + public string? FaviconUrl { get; set; } + public string? IconName { get; set; } // Tabler icon name e.g. "coffee" + public DateTime UpdatedAt { get; set; } +} +``` + +--- + +### STEP 2 — BranchIdentity entity (new) + +```csharp +// src/Meezi.Core/Entities/BranchIdentity.cs +public class BranchIdentity +{ + public Guid Id { get; set; } + public Guid BranchId { get; set; } + public Guid CafeId { get; set; } + // Branch-level overrides (nulls = use CafeIdentity value) + public string? LogoUrl { get; set; } // branch-specific photo + public string? AccentColor { get; set; } // slight color variation + public string? WelcomeText { get; set; } // greeting on QR menu + public string? WifiPassword { get; set; } + public string? Address { get; set; } // shown on QR menu footer +} +``` + +--- + +### STEP 3 — Resolve effective identity + +```csharp +// In EffectiveSettingsService or new IdentityService: + +public async Task GetEffectiveIdentityAsync( + Guid cafeId, Guid branchId, CancellationToken ct) +{ + var cafe = await _db.CafeIdentities + .FirstOrDefaultAsync(i => i.CafeId == cafeId, ct); + + var branch = await _db.BranchIdentities + .FirstOrDefaultAsync(i => i.BranchId == branchId, ct); + + return new BranchEffectiveIdentityDto + { + PrimaryColor = cafe?.PrimaryColor ?? "#C47B2B", // NEVER branch override + SecondaryColor = cafe?.SecondaryColor ?? "#F5F0E8", + FontFamily = cafe?.FontFamily, + LogoUrl = branch?.LogoUrl ?? cafe?.LogoUrl, // branch photo first + IconName = cafe?.IconName ?? "coffee", + WelcomeText = branch?.WelcomeText ?? "خوش آمدید", + WifiPassword = branch?.WifiPassword, + Address = branch?.Address, + }; +} +``` + +--- + +### STEP 4 — Endpoints + +``` +// Cafe identity (owner only): +GET /api/cafes/{cafeId}/identity +PUT /api/cafes/{cafeId}/identity + Body: { primaryColor, secondaryColor, fontFamily, iconName, logoUrl, faviconUrl } + +// Branch identity (owner or branch manager): +GET /api/cafes/{cafeId}/branches/{branchId}/identity +PUT /api/cafes/{cafeId}/branches/{branchId}/identity + Body: { logoUrl, welcomeText, wifiPassword, address } + // Note: primaryColor NOT in body — branch cannot change it + +// Public (used by QR menu): +GET /api/public/{cafeId}/branches/{branchId}/identity + → Returns BranchEffectiveIdentityDto +``` + +--- + +### STEP 5 — Dashboard: Visual Identity Settings Page + +**File: `web/dashboard/src/components/settings/identity-settings.tsx` (new)** + +Owner view (cafe-level): +``` +Brand Color: [■ #C47B2B] color picker +Secondary Color: [■ #F5F0E8] +Logo: [upload] +Icon: icon picker (grid of Tabler icon names: coffee, cup, utensils, store, ...) +Font: dropdown [پیش‌فرض | Vazir | IRANSans] +Preview: shows QR menu header with current settings +``` + +Branch manager view: +``` +رنگ برند: ■ #C47B2B 🔒 (تعریف شده توسط مالک — قابل تغییر نیست) +لوگو شعبه: [upload] ← branch-specific photo +متن خوش‌آمدگویی: [___________] +رمز WiFi: [___________] +آدرس: [___________] +``` + +--- + +### STEP 6 — Apply identity to QR menu + +Update the QR resolver endpoint (STEP 1 of PR-1) to also call `GetEffectiveIdentityAsync` +and include the color/logo in `QrResolveDto`. The guest menu page already uses +`branch.primaryColor` for all buttons and accents — it will automatically +reflect the brand color. + +--- + +### Migration + +```bash +dotnet ef migrations add BranchVisualIdentity \ + --project src/Meezi.Infrastructure \ + --startup-project src/Meezi.API +``` + +--- + +## Execution Order + +``` +PR-1 feature/qr-guest-menu ← fix the broken flow FIRST (2 day) +PR-2 feature/tax-inheritance ← simple, 4 hours +PR-3 feature/visual-identity ← after PR-1 (QR menu uses the colors), 1 day +``` + +## New Error Codes + +| Code | Meaning | +|------|---------| +| `INVALID_MENU_ITEMS` | Guest submitted item not in branch menu | +| `TAX_OVERRIDE_NOT_ALLOWED` | Branch tried to set tax but parent locked it | +| `BRANCH_IDENTITY_NOT_FOUND` | Identity record missing (auto-create on first save) | + +## New i18n Strings + +```json +// fa.json additions: +"qrMenu": { + "welcome": "خوش آمدید", + "tableLabel": "میز", + "addToCart": "افزودن", + "viewCart": "مشاهده سبد خرید", + "placeOrder": "ثبت سفارش", + "orderPlaced": "سفارش ثبت شد!", + "orderNumber": "شماره سفارش", + "guestName": "نام شما (اختیاری)", + "guestPhone": "شماره موبایل (اختیاری)", + "addMoreItems": "افزودن آیتم دیگر", + "tableNotFound": "میز یافت نشد", + "loadError": "خطا در بارگذاری", + "orderError": "خطا در ثبت سفارش. دوباره امتحان کنید", + "subtotal": "جمع کل", + "guestQrBadge": "سفارش QR مهمان" +}, +"identity": { + "brandColor": "رنگ برند", + "secondaryColor": "رنگ ثانویه", + "logo": "لوگو", + "icon": "آیکون", + "font": "فونت", + "branchLogo": "لوگو شعبه", + "welcomeText": "متن خوش‌آمدگویی", + "wifiPassword": "رمز WiFi", + "lockedByOwner": "تعریف شده توسط مالک — قابل تغییر نیست", + "preview": "پیش‌نمایش" +}, +"tax": { + "defaultTaxRate": "نرخ مالیات پیش‌فرض", + "allowBranchOverride": "اجازه تغییر نرخ مالیات به مدیران شعبه", + "lockedByOwner": "نرخ مالیات توسط مالک قفل شده است", + "overrideNotAllowed": "تغییر نرخ مالیات شعبه توسط مالک غیرفعال شده است" +} +``` + +--- + +*Start with PR-1 — the QR menu fix. The other two are independent and can run in parallel after PR-1 merges.* diff --git a/docs/MEEZI_REMAINING_BACKLOG.md b/docs/MEEZI_REMAINING_BACKLOG.md new file mode 100644 index 0000000..ec0f534 --- /dev/null +++ b/docs/MEEZI_REMAINING_BACKLOG.md @@ -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.* diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..66e0f7a --- /dev/null +++ b/docs/SECURITY.md @@ -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 diff --git a/scripts/check-ports.ps1 b/scripts/check-ports.ps1 new file mode 100644 index 0000000..3d22970 --- /dev/null +++ b/scripts/check-ports.ps1 @@ -0,0 +1,50 @@ +# Checks host ports from .env (or defaults) before docker compose up. +param( + [string]$EnvFile = ".env" +) + +$root = Split-Path -Parent $PSScriptRoot +Set-Location $root + +$defaults = @{ + WEB_PORT = 3101 + API_PORT = 5080 + POSTGRES_PORT = 5434 + REDIS_PORT = 6381 +} + +$ports = @{} +foreach ($key in $defaults.Keys) { $ports[$key] = $defaults[$key] } + +if (Test-Path $EnvFile) { + Get-Content $EnvFile | ForEach-Object { + if ($_ -match '^\s*([A-Z_]+)\s*=\s*(\d+)\s*$') { + $ports[$Matches[1]] = [int]$Matches[2] + } + } +} + +Write-Host "Meezi port check (from $EnvFile or defaults):" -ForegroundColor Cyan +$blocked = @() + +foreach ($name in @("WEB_PORT", "API_PORT", "POSTGRES_PORT", "REDIS_PORT")) { + $port = $ports[$name] + $listener = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($listener) { + Write-Host " [IN USE] $name = $port" -ForegroundColor Red + $blocked += $port + } + else { + Write-Host " [OK] $name = $port" -ForegroundColor Green + } +} + +if ($blocked.Count -gt 0) { + Write-Host "" + Write-Host "Some ports are busy. Edit .env (copy from .env.example) and pick free ports, then re-run." -ForegroundColor Yellow + exit 1 +} + +Write-Host "" +Write-Host "All ports available. Run: docker compose up -d --build" -ForegroundColor Green +exit 0 diff --git a/scripts/docker-up-full.ps1 b/scripts/docker-up-full.ps1 new file mode 100644 index 0000000..1162cd7 --- /dev/null +++ b/scripts/docker-up-full.ps1 @@ -0,0 +1,39 @@ +# Pull base images then start the full Meezi stack in Docker. +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent $PSScriptRoot +Push-Location $Root + +$images = @( + "mcr.microsoft.com/dotnet/sdk:10.0", + "mcr.microsoft.com/dotnet/aspnet:10.0", + "postgres:16-alpine", + "redis:7-alpine", + "node:20-alpine" +) + +Write-Host "Pulling base images (use VPN if pulls time out)..." -ForegroundColor Cyan +foreach ($img in $images) { + Write-Host " pull $img" + docker pull $img + if ($LASTEXITCODE -ne 0) { + Write-Host "Failed to pull $img. Enable VPN or configure a registry mirror (see docs/DOCKER.md)." -ForegroundColor Red + Pop-Location + exit 1 + } +} + +Write-Host "" +Write-Host "Building and starting all services..." -ForegroundColor Cyan +docker compose up -d --build +$code = $LASTEXITCODE +Pop-Location +if ($code -ne 0) { exit $code } + +Write-Host "" +Write-Host "Meezi is starting:" -ForegroundColor Green +Write-Host " Dashboard http://localhost:3101/fa/login" +Write-Host " API http://localhost:5080/swagger" +Write-Host " Health http://localhost:5080/health" +Write-Host "" +Write-Host " docker compose ps" +Write-Host " docker compose logs -f api" diff --git a/scripts/run-local-dev.ps1 b/scripts/run-local-dev.ps1 new file mode 100644 index 0000000..3038688 --- /dev/null +++ b/scripts/run-local-dev.ps1 @@ -0,0 +1,19 @@ +# Start Postgres + Redis in Docker, API + Dashboard on the host (no MCR image pull). +# Use when: docker build fails on mcr.microsoft.com / dotnet base images. + +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent $PSScriptRoot + +Write-Host "Starting Postgres + Redis..." -ForegroundColor Cyan +Push-Location $Root +docker compose up -d postgres redis +if ($LASTEXITCODE -ne 0) { Pop-Location; exit $LASTEXITCODE } +Pop-Location + +Write-Host "" +Write-Host "Infra ready. In two terminals run:" -ForegroundColor Green +Write-Host " API: cd src/Meezi.API; `$env:RUN_MIGRATIONS='true'; dotnet run" +Write-Host " Dashboard: cd web/dashboard; npm run dev" +Write-Host "" +Write-Host " Login: http://localhost:3101/fa/login (demo OTP: 09121234567)" +Write-Host " API: http://localhost:5080/swagger"