03376b3ea1
Rewrites dashboard and finder Dockerfiles to use a clean multi-stage build (deps → builder → runner) that installs npm packages inside Alpine Linux, avoiding the SWC musl binary issue when building from Windows host. Uses registry.npmmirror.com for reliable installs from restricted networks (Iran). - docker/api/Dockerfile: .NET 10 multi-stage build - docker/web/Dockerfile: Node 20-alpine multi-stage, npmmirror - docker/finder/Dockerfile: Node 20-alpine multi-stage, npmmirror - docker/website/Dockerfile: marketing website build - scripts/: PowerShell helper scripts for local dev Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
853 lines
39 KiB
Markdown
853 lines
39 KiB
Markdown
# Meezi — Branch Ownership Model + Branch Credentials
|
|
> **Copy-paste this entire file into Cursor.**
|
|
> Stack: ASP.NET Core 10, EF Core 10, Next.js 14, next-intl, PostgreSQL 16
|
|
> Prerequisite: Branch entity already exists (migration AddBranchEntity applied)
|
|
|
|
---
|
|
|
|
## Architecture Decisions (read before coding)
|
|
|
|
### Menu Ownership
|
|
- MenuItem and MenuCategory live at the **Cafe (owner) level** — global catalog
|
|
- Each branch gets a `BranchMenuItemOverride` row to disable or reprice an item
|
|
- Branch managers CANNOT create new menu items — only toggle availability and override price (Pro+ plan)
|
|
- No override row = item is active at master price (implicit inheritance)
|
|
|
|
### Table Ownership
|
|
- Tables are **100% per-branch** — BranchId is required, non-nullable
|
|
- Owner can manage any branch's tables; Branch Manager manages only their own
|
|
- Table sections (سالن, تراس, VIP) are also per-branch
|
|
|
|
### Staff / Credentials
|
|
- One user account per person (global to cafe)
|
|
- `UserBranchAssignment` links user → branch with a role
|
|
- JWT contains active `branchId` claim
|
|
- Multi-branch users get a branch-picker step after login
|
|
- POS terminals use a short `TerminalPin` for quick device unlock (secondary auth)
|
|
|
|
### Settings Inheritance
|
|
- `CafeSettings` = owner defaults
|
|
- `BranchSettings` = per-branch overrides (null = use cafe default)
|
|
- A `GetEffectiveSetting<T>()` helper resolves the right value
|
|
|
|
---
|
|
|
|
## PROMPT 1 — Menu Ownership: BranchMenuItemOverride
|
|
|
|
```
|
|
Context: Meezi POS, ASP.NET Core 10, EF Core 10.
|
|
MenuItem and MenuCategory already exist with CafeId (owner-level catalog).
|
|
Goal: Add per-branch availability and price override for menu items.
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 1 — New entity: BranchMenuItemOverride
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
File: src/Meezi.Core/Entities/BranchMenuItemOverride.cs
|
|
|
|
Properties:
|
|
- Id (Guid)
|
|
- CafeId (Guid) — tenant guard
|
|
- BranchId (Guid)
|
|
- MenuItemId (Guid)
|
|
- IsAvailable (bool, default true) — false = hidden at this branch
|
|
- PriceOverride (decimal?) — null = use MenuItem.BasePrice
|
|
- SortOrderOverride (int?) — null = use MenuItem.SortOrder
|
|
- UpdatedAt (DateTime)
|
|
- UpdatedByUserId (Guid)
|
|
|
|
Unique constraint: (BranchId, MenuItemId) — one override row per branch+item.
|
|
Navigation properties: Branch, MenuItem.
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 2 — EF migration
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
dotnet ef migrations add BranchMenuItemOverride \
|
|
--project src/Meezi.Infrastructure \
|
|
--startup-project src/Meezi.API \
|
|
--output-dir Data/Migrations
|
|
|
|
Verify migration creates:
|
|
- branch_menu_item_overrides table
|
|
- unique index on (branch_id, menu_item_id)
|
|
- fk to branches, menu_items, app_users
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 3 — Update MenuService to resolve effective menu per branch
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
File: src/Meezi.API/Services/MenuService.cs
|
|
|
|
Add method: GetBranchMenuAsync(Guid branchId, CancellationToken ct)
|
|
|
|
Logic:
|
|
1. Load all MenuItems where CafeId == _tenant.CafeId and IsActive == true
|
|
2. Load all BranchMenuItemOverride rows for this branchId
|
|
3. For each item, apply override:
|
|
- if override.IsAvailable == false → exclude from result
|
|
- if override.PriceOverride != null → use override price
|
|
- if override.SortOrderOverride != null → use override sort
|
|
4. Return as BranchMenuItemDto list
|
|
|
|
BranchMenuItemDto:
|
|
- All existing MenuItemDto fields
|
|
- EffectivePrice (decimal) — resolved price
|
|
- IsOverridden (bool) — true if any override row exists
|
|
- HasPriceOverride (bool)
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 4 — New endpoint for branch override management
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
File: src/Meezi.API/Controllers/MenuController.cs — add to existing controller:
|
|
|
|
GET /api/cafes/{cafeId}/branches/{branchId}/menu
|
|
→ Returns resolved branch menu (effective prices, filtered unavailable)
|
|
→ Used by POS and KDS screens
|
|
|
|
PUT /api/cafes/{cafeId}/branches/{branchId}/menu/{menuItemId}/override
|
|
→ Body: { isAvailable: bool, priceOverride: decimal? }
|
|
→ Creates or updates BranchMenuItemOverride row
|
|
→ Authorization: Owner always allowed; Manager allowed only if plan >= Pro
|
|
→ If Manager and plan == Free: return PLAN_LIMIT_REACHED
|
|
with message: "Price overrides require Pro plan"
|
|
|
|
DELETE /api/cafes/{cafeId}/branches/{branchId}/menu/{menuItemId}/override
|
|
→ Removes override row (resets to catalog defaults)
|
|
→ Owner only
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 5 — Plan gate in BranchMenuOverrideService
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
In UpsertOverrideAsync:
|
|
- If request.PriceOverride != null:
|
|
→ Check cafe plan tier
|
|
→ Free plan: return ApiResponse.Fail("PLAN_LIMIT_REACHED",
|
|
"قیمتگذاری شعبهای نیاز به پلن Pro دارد")
|
|
→ Pro+: allow
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 6 — Update POS screen to use branch menu endpoint
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
File: web/dashboard/src/lib/api/menu.ts
|
|
|
|
Update getBranchMenu() to call:
|
|
GET /api/cafes/{cafeId}/branches/{branchId}/menu
|
|
instead of the global menu endpoint.
|
|
|
|
The cart.store.ts should use effective price from BranchMenuItemDto.EffectivePrice,
|
|
not MenuItem.BasePrice.
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 7 — Dashboard: Branch Menu Management UI
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
File: web/dashboard/src/components/menu/branch-menu-overrides.tsx (new)
|
|
|
|
UI: A table showing all menu items for the selected branch:
|
|
Columns: نام | قیمت اصلی | قیمت شعبه | وضعیت | عملیات
|
|
|
|
- Toggle switch per item for IsAvailable
|
|
- Price input field (Pro plan only — show lock icon + upgrade CTA on Free)
|
|
- "بازنشانی" (Reset) button per item to delete override
|
|
- Visual indicator on rows with active overrides
|
|
|
|
This component is embedded in the existing /menu page under a "تنظیمات شعبه" tab,
|
|
visible only when a specific branch is selected (not in "all branches" view).
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 8 — i18n strings
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
Add to messages/fa.json, en.json, ar.json under "branchMenu" key:
|
|
|
|
fa.json:
|
|
"branchMenu": {
|
|
"title": "منوی شعبه",
|
|
"masterPrice": "قیمت اصلی",
|
|
"branchPrice": "قیمت شعبه",
|
|
"availability": "وضعیت",
|
|
"available": "فعال",
|
|
"unavailable": "غیرفعال",
|
|
"resetOverride": "بازنشانی",
|
|
"priceOverridePro": "قیمتگذاری اختصاصی برای پلن Pro",
|
|
"overrideActive": "تنظیمات شعبه فعال",
|
|
"confirmReset": "آیا میخواهید تنظیمات این آیتم را به حالت پیشفرض برگردانید؟"
|
|
}
|
|
|
|
en.json:
|
|
"branchMenu": {
|
|
"title": "Branch Menu",
|
|
"masterPrice": "Master Price",
|
|
"branchPrice": "Branch Price",
|
|
"availability": "Status",
|
|
"available": "Active",
|
|
"unavailable": "Hidden",
|
|
"resetOverride": "Reset",
|
|
"priceOverridePro": "Price overrides require Pro plan",
|
|
"overrideActive": "Branch override active",
|
|
"confirmReset": "Reset this item to catalog defaults?"
|
|
}
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
TESTS (add to tests/Meezi.API.Tests/BranchMenuTests.cs)
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
✓ GetBranchMenu_ExcludesUnavailableItems
|
|
✓ GetBranchMenu_AppliesPriceOverride_WhenSet
|
|
✓ GetBranchMenu_UsesMasterPrice_WhenNoOverride
|
|
✓ UpsertOverride_FreePlan_PriceOverride_ReturnsPlanLimitReached
|
|
✓ UpsertOverride_ProPlan_PriceOverride_Succeeds
|
|
✓ DeleteOverride_ResetsToMasterPrice
|
|
✓ Override_UniqueConstraint_UpsertNotDuplicate
|
|
|
|
Conventions:
|
|
- All EF queries: CafeId == _tenant.CafeId
|
|
- Response: ApiResponse<T> / ApiError with codes
|
|
- No hardcoded strings in dashboard — all in messages/*.json
|
|
- RTL: ms-*/me-* only in CSS
|
|
```
|
|
|
|
---
|
|
|
|
## PROMPT 2 — Table Ownership: Per-Branch Tables + Sections
|
|
|
|
```
|
|
Context: Meezi POS, ASP.NET Core 10, EF Core 10.
|
|
Table entity currently has CafeId. Branch entity exists.
|
|
Goal: Make tables fully branch-owned. Add table sections per branch.
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 1 — Add BranchId to Table (if not already added)
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
File: src/Meezi.Core/Entities/Table.cs
|
|
|
|
Add:
|
|
public Guid BranchId { get; set; }
|
|
public Guid? SectionId { get; set; } // nullable — section is optional
|
|
public virtual Branch Branch { get; set; } = null!;
|
|
public virtual TableSection? Section { get; set; }
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 2 — New entity: TableSection
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
File: src/Meezi.Core/Entities/TableSection.cs
|
|
|
|
Properties:
|
|
- Id (Guid)
|
|
- CafeId (Guid)
|
|
- BranchId (Guid)
|
|
- Name (string) — e.g. "سالن اصلی", "تراس", "VIP", "روفگاردن"
|
|
- SortOrder (int, default 0)
|
|
- IsActive (bool, default true)
|
|
Navigation: Branch, Tables (ICollection<Table>)
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 3 — EF migration
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
dotnet ef migrations add BranchTableOwnership \
|
|
--project src/Meezi.Infrastructure \
|
|
--startup-project src/Meezi.API \
|
|
--output-dir Data/Migrations
|
|
|
|
Migration must:
|
|
- Add branch_id (Guid, not null) to tables
|
|
→ For existing rows: set to the cafe's default/first branch
|
|
→ Script: UPDATE tables SET branch_id = (SELECT id FROM branches
|
|
WHERE cafe_id = tables.cafe_id LIMIT 1)
|
|
- Add section_id (Guid, nullable) to tables
|
|
- Create table_sections table
|
|
- Add FK tables.branch_id → branches.id
|
|
- Add FK tables.section_id → table_sections.id
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 4 — Update TablesController + TableService
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
All existing table endpoints already scoped by cafeId.
|
|
Add branchId scoping:
|
|
|
|
GET /api/cafes/{cafeId}/branches/{branchId}/tables
|
|
→ Returns tables for this branch only
|
|
→ Includes section name in TableDto
|
|
|
|
POST /api/cafes/{cafeId}/branches/{branchId}/tables
|
|
→ Creates table for this branch
|
|
|
|
PATCH /api/cafes/{cafeId}/branches/{branchId}/tables/{id}
|
|
→ Updates table (name, capacity, section, sort order)
|
|
|
|
DELETE /api/cafes/{cafeId}/branches/{branchId}/tables/{id}
|
|
→ Soft delete — only if no open order on table
|
|
→ Returns TABLE_HAS_OPEN_ORDER if blocked
|
|
|
|
Sections endpoints:
|
|
GET /api/cafes/{cafeId}/branches/{branchId}/tables/sections
|
|
POST /api/cafes/{cafeId}/branches/{branchId}/tables/sections
|
|
PATCH /api/cafes/{cafeId}/branches/{branchId}/tables/sections/{id}
|
|
DELETE /api/cafes/{cafeId}/branches/{branchId}/tables/sections/{id}
|
|
→ Cannot delete section with active tables → TABLE_SECTION_HAS_TABLES
|
|
|
|
Authorization:
|
|
Owner: full CRUD on any branch
|
|
Manager: CRUD on their assigned branch only
|
|
Waiter: read-only (GET only)
|
|
|
|
In TableService, all queries:
|
|
.Where(t => t.CafeId == _tenant.CafeId && t.BranchId == branchId)
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 5 — Update POS board to pass branchId
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
File: web/dashboard/src/components/pos/pos-table-board.tsx
|
|
|
|
Currently fetches tables for whole cafe.
|
|
Update to fetch: GET /branches/{activeBranchId}/tables
|
|
|
|
activeBranchId comes from:
|
|
1. JWT claim (decoded from token)
|
|
2. Or branch context stored in Zustand/session
|
|
|
|
Group tables by section in the board UI:
|
|
- Section headers: "سالن اصلی", "تراس", etc.
|
|
- Tables within each section as cards
|
|
- "بدون بخش" group for tables with no section
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 6 — Dashboard: Table Management UI
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
File: web/dashboard/src/components/tables/tables-screen.tsx
|
|
|
|
Add branch selector at top (only visible to Owner — managers see their branch only).
|
|
Add section management panel:
|
|
- List sections with edit/delete
|
|
- Drag to reorder sections
|
|
- Assign table to section via dropdown on table card
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 7 — i18n strings
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
Add under "tables" key:
|
|
fa.json:
|
|
"tables": {
|
|
"section": "بخش",
|
|
"sections": "بخشها",
|
|
"addSection": "افزودن بخش",
|
|
"noSection": "بدون بخش",
|
|
"sectionHasTables": "این بخش دارای میز است و قابل حذف نیست",
|
|
"tableHasOpenOrder": "این میز دارای سفارش باز است"
|
|
}
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
TESTS (add to tests/Meezi.API.Tests/BranchTableTests.cs)
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
✓ GetTables_ReturnsBranchTablesOnly
|
|
✓ GetTables_DoesNotReturnOtherBranchTables
|
|
✓ CreateTable_AssignsToBranch
|
|
✓ DeleteTable_WithOpenOrder_ReturnsTableHasOpenOrder
|
|
✓ DeleteSection_WithTables_ReturnsTableSectionHasTables
|
|
✓ ManagerCannotAccessOtherBranchTables
|
|
```
|
|
|
|
---
|
|
|
|
## PROMPT 3 — Branch Credentials: UserBranchAssignment + JWT
|
|
|
|
```
|
|
Context: Meezi POS, ASP.NET Core 10. AppUser, Branch entities exist.
|
|
Goal: Implement per-branch staff assignment, branch-scoped JWT, and
|
|
branch selector flow after login.
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 1 — New entity: UserBranchAssignment
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
File: src/Meezi.Core/Entities/UserBranchAssignment.cs
|
|
|
|
Properties:
|
|
- Id (Guid)
|
|
- CafeId (Guid)
|
|
- UserId (Guid)
|
|
- BranchId (Guid)
|
|
- Role (enum: Owner | Manager | Cashier | Waiter | KitchenStaff)
|
|
- IsActive (bool, default true)
|
|
- AssignedAt (DateTime)
|
|
- AssignedByUserId (Guid)
|
|
Navigation: User, Branch, AssignedBy
|
|
|
|
Unique constraint: (UserId, BranchId) — one role per user per branch.
|
|
|
|
Note: Owner role assignment is auto-created when a branch is created.
|
|
Owner can be assigned to all branches automatically.
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 2 — Update AppUser
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
File: src/Meezi.Core/Entities/AppUser.cs
|
|
|
|
Add navigation:
|
|
public virtual ICollection<UserBranchAssignment> BranchAssignments { get; set; }
|
|
|
|
Remove any direct Role property if it exists — role is now per-branch.
|
|
Keep a CafeRole (Owner/Admin) for cafe-level operations if needed.
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 3 — EF migration
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
dotnet ef migrations add UserBranchAssignment \
|
|
--project src/Meezi.Infrastructure \
|
|
--startup-project src/Meezi.API \
|
|
--output-dir Data/Migrations
|
|
|
|
Migration must:
|
|
- Create user_branch_assignments table
|
|
- Unique index on (user_id, branch_id)
|
|
- FK to app_users, branches
|
|
- Seed: for existing users, create assignment rows based on
|
|
their current role + the cafe's first/default branch
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 4 — Update JWT claims
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
File: src/Meezi.API/Services/AuthService.cs (or TokenService.cs)
|
|
|
|
Current token likely contains: sub, cafeId, role, exp
|
|
Update to contain:
|
|
|
|
{
|
|
"sub": "userId",
|
|
"cafeId": "xxx",
|
|
"branchId": "yyy", ← active branch for this session
|
|
"role": "Manager", ← role in THAT branch
|
|
"branchIds": ["yyy","zzz"], ← all branches this user can access (for picker UI)
|
|
"exp": ...
|
|
}
|
|
|
|
Method: GenerateBranchToken(AppUser user, Guid branchId)
|
|
1. Load UserBranchAssignment for this user + branchId
|
|
2. If not found or not active → throw UnauthorizedException
|
|
3. Load all active assignments for this user → branchIds claim
|
|
4. Build token with branchId + role from assignment
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 5 — New auth endpoints
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
File: src/Meezi.API/Controllers/AuthController.cs — add:
|
|
|
|
POST /api/auth/login
|
|
(existing — unchanged, but response now includes branchIds list)
|
|
|
|
Response:
|
|
{
|
|
"token": "...", ← if user has exactly ONE branch, this is branch-scoped
|
|
"requiresBranchSelect": true, ← if user has multiple branches
|
|
"branches": [ ← list for the picker UI
|
|
{ "id": "yyy", "name": "شعبه ولیعصر", "role": "Manager" },
|
|
{ "id": "zzz", "name": "شعبه کرج", "role": "Cashier" }
|
|
]
|
|
}
|
|
|
|
POST /api/auth/select-branch [Authorize] — requires valid login token
|
|
Body: { "branchId": "yyy" }
|
|
→ Validates user is assigned to this branch
|
|
→ Returns a new branch-scoped JWT
|
|
→ Old token is invalidated (or just let it expire — branch select issues new token)
|
|
|
|
Response:
|
|
{
|
|
"token": "...", ← branch-scoped token with branchId + role claims
|
|
"branchName": "شعبه ولیعصر",
|
|
"role": "Manager"
|
|
}
|
|
|
|
POST /api/auth/switch-branch [Authorize] — for already-logged-in users
|
|
Body: { "branchId": "zzz" }
|
|
→ Same as select-branch but can be called mid-session
|
|
→ Returns new token for the new branch
|
|
→ Used when owner wants to switch between branches without re-login
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 6 — Update ITenantService to resolve BranchId from JWT
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
File: src/Meezi.API/Services/TenantService.cs (or middleware)
|
|
|
|
Current: resolves CafeId from JWT claim "cafeId"
|
|
Update: also resolve BranchId from JWT claim "branchId"
|
|
|
|
Interface:
|
|
public interface ITenantContext
|
|
{
|
|
Guid CafeId { get; }
|
|
Guid? BranchId { get; } // nullable — owner-level calls may not have branchId
|
|
string Role { get; }
|
|
Guid UserId { get; }
|
|
}
|
|
|
|
In all service methods that need branch scope:
|
|
var branchId = _tenant.BranchId
|
|
?? throw new InvalidOperationException("Branch context required");
|
|
|
|
For owner-level endpoints (e.g. menu catalog management):
|
|
Only CafeId needed, BranchId can be null.
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 7 — Staff Management endpoints
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
File: src/Meezi.API/Controllers/HrController.cs — add branch assignment endpoints:
|
|
|
|
GET /api/cafes/{cafeId}/branches/{branchId}/staff
|
|
→ List all UserBranchAssignment for this branch (active only)
|
|
→ Includes user name, phone, role, assignedAt
|
|
|
|
POST /api/cafes/{cafeId}/branches/{branchId}/staff
|
|
Body: { userId, role }
|
|
→ Creates UserBranchAssignment
|
|
→ If user doesn't exist yet, caller should first POST /api/cafes/{cafeId}/users
|
|
→ Owner only
|
|
|
|
PATCH /api/cafes/{cafeId}/branches/{branchId}/staff/{userId}
|
|
Body: { role?, isActive? }
|
|
→ Updates assignment (change role or deactivate)
|
|
→ Owner only; Manager can deactivate but not change roles
|
|
|
|
DELETE /api/cafes/{cafeId}/branches/{branchId}/staff/{userId}
|
|
→ Soft delete (sets isActive = false)
|
|
→ Cannot remove the last active Owner from a branch
|
|
|
|
GET /api/cafes/{cafeId}/users
|
|
→ All users belonging to this cafe (unfiltered by branch)
|
|
→ Owner only — used to populate "add staff to branch" picker
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 8 — TerminalPin: POS device quick-access
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
For shared POS tablets where multiple cashiers use the same device.
|
|
Not a replacement for full auth — just a quick unlock per shift.
|
|
|
|
File: src/Meezi.Core/Entities/AppUser.cs — add:
|
|
public string? TerminalPin { get; set; } // 4-6 digit PIN, hashed
|
|
|
|
File: src/Meezi.API/Controllers/AuthController.cs — add:
|
|
|
|
POST /api/auth/pin-login
|
|
Body: { cafeId, branchId, pin }
|
|
[AllowAnonymous]
|
|
→ Finds user with matching PIN assigned to this branch
|
|
→ Returns a short-lived token (2h expiry, "Cashier" or actual role)
|
|
→ Rate limited: max 5 attempts per branchId per 15 minutes
|
|
→ Returns PIN_INVALID or BRANCH_NOT_FOUND on failure (never reveals which)
|
|
|
|
In AuthService:
|
|
VerifyPin(string inputPin, string storedHash) — use BCrypt or Argon2
|
|
|
|
PIN management endpoint:
|
|
PATCH /api/cafes/{cafeId}/users/{userId}/pin
|
|
Body: { pin: "1234" }
|
|
→ Owner or the user themselves
|
|
→ Validate: 4-6 digits, not trivially sequential (1234, 0000)
|
|
→ Hash and store
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 9 — Dashboard: Branch Selector UI (post-login)
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
File: web/dashboard/src/app/[locale]/select-branch/page.tsx (new)
|
|
|
|
Shown after login when requiresBranchSelect === true.
|
|
Shows a card grid of branches the user is assigned to.
|
|
Each card: branch name, address, user's role at that branch.
|
|
On click → POST /auth/select-branch → store new token → redirect to dashboard.
|
|
|
|
If user has only 1 branch → skip this page, auto-redirect to dashboard.
|
|
|
|
File: web/dashboard/src/lib/stores/auth.store.ts (or existing auth store)
|
|
|
|
Add:
|
|
activeBranchId: string | null
|
|
activeBranchName: string | null
|
|
availableBranches: { id, name, role }[]
|
|
switchBranch: (branchId: string) => Promise<void>
|
|
|
|
switchBranch:
|
|
→ POST /auth/switch-branch
|
|
→ Update token in storage
|
|
→ Update activeBranchId in store
|
|
→ Trigger full data reload (invalidate all React Query cache)
|
|
|
|
File: web/dashboard/src/components/layout/sidebar.tsx (or nav)
|
|
|
|
Add branch switcher dropdown in header:
|
|
- Shows current branch name
|
|
- Dropdown lists other assigned branches
|
|
- Click → switchBranch()
|
|
- Only visible if user has 2+ branches
|
|
- Owner always sees all branches
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 10 — i18n strings
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
messages/fa.json — add:
|
|
"auth": {
|
|
"selectBranch": "انتخاب شعبه",
|
|
"selectBranchPrompt": "لطفاً شعبه مورد نظر خود را انتخاب کنید",
|
|
"switchBranch": "تغییر شعبه",
|
|
"currentBranch": "شعبه فعال",
|
|
"pinLogin": "ورود با پین",
|
|
"enterPin": "پین خود را وارد کنید",
|
|
"pinInvalid": "پین نادرست است",
|
|
"pinTooManyAttempts": "تعداد تلاشهای مجاز تمام شد. لطفاً ۱۵ دقیقه صبر کنید"
|
|
},
|
|
"staff": {
|
|
"title": "کارکنان",
|
|
"addStaff": "افزودن کارمند",
|
|
"role": "نقش",
|
|
"assignedAt": "تاریخ تخصیص",
|
|
"deactivate": "غیرفعال کردن",
|
|
"roles": {
|
|
"Owner": "مالک",
|
|
"Manager": "مدیر",
|
|
"Cashier": "صندوقدار",
|
|
"Waiter": "گارسون",
|
|
"KitchenStaff": "آشپزخانه"
|
|
}
|
|
}
|
|
|
|
messages/en.json — add:
|
|
"auth": {
|
|
"selectBranch": "Select Branch",
|
|
"selectBranchPrompt": "Please select your branch to continue",
|
|
"switchBranch": "Switch Branch",
|
|
"currentBranch": "Active Branch",
|
|
"pinLogin": "PIN Login",
|
|
"enterPin": "Enter your PIN",
|
|
"pinInvalid": "Invalid PIN",
|
|
"pinTooManyAttempts": "Too many attempts. Please wait 15 minutes"
|
|
},
|
|
"staff": {
|
|
"title": "Staff",
|
|
"addStaff": "Add Staff",
|
|
"role": "Role",
|
|
"assignedAt": "Assigned",
|
|
"deactivate": "Deactivate",
|
|
"roles": {
|
|
"Owner": "Owner",
|
|
"Manager": "Manager",
|
|
"Cashier": "Cashier",
|
|
"Waiter": "Waiter",
|
|
"KitchenStaff": "Kitchen Staff"
|
|
}
|
|
}
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
TESTS (add to tests/Meezi.API.Tests/BranchCredentialsTests.cs)
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
✓ Login_SingleBranch_ReturnsBranchScopedToken
|
|
✓ Login_MultiBranch_ReturnsRequiresBranchSelect
|
|
✓ SelectBranch_ValidAssignment_ReturnsBranchToken
|
|
✓ SelectBranch_UnassignedBranch_ReturnsUnauthorized
|
|
✓ SwitchBranch_UpdatesTokenClaims
|
|
✓ PinLogin_ValidPin_ReturnsShortLivedToken
|
|
✓ PinLogin_InvalidPin_ReturnsPinInvalid
|
|
✓ PinLogin_FiveFailures_ReturnsRateLimited
|
|
✓ ManagerCannotSeeOtherBranchStaff
|
|
✓ CannotRemoveLastOwnerFromBranch
|
|
✓ GetTables_UsesJwtBranchId_ReturnsCorrectBranch
|
|
```
|
|
|
|
---
|
|
|
|
## PROMPT 4 — Branch Settings Inheritance
|
|
|
|
```
|
|
Context: Meezi POS, ASP.NET Core 10, EF Core 10.
|
|
CafeSettings entity exists with owner-level defaults.
|
|
Goal: Add BranchSettings that override cafe defaults using fallback resolution.
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 1 — New entity: BranchSettings
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
File: src/Meezi.Core/Entities/BranchSettings.cs
|
|
|
|
Properties (all nullable — null = use cafe default):
|
|
- Id (Guid)
|
|
- CafeId (Guid)
|
|
- BranchId (Guid, unique)
|
|
- ReceiptHeader (string?) — override cafe receipt header
|
|
- ReceiptFooter (string?) — e.g. branch address / phone
|
|
- TaxRate (decimal?) — local tax override
|
|
- ServiceCharge (decimal?) — optional service charge %
|
|
- OperatingHours (string?) — JSON: { mon: {open:"08:00", close:"23:00"}, ... }
|
|
- WifiPassword (string?) — shown on receipt optionally
|
|
- Currency (string?) — for future multi-currency, default "IRR"
|
|
- UpdatedAt (DateTime)
|
|
|
|
Unique constraint: one row per branch.
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 2 — EF migration
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
dotnet ef migrations add BranchSettings \
|
|
--project src/Meezi.Infrastructure \
|
|
--startup-project src/Meezi.API \
|
|
--output-dir Data/Migrations
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 3 — EffectiveSettingsService
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
File: src/Meezi.API/Services/EffectiveSettingsService.cs (new)
|
|
|
|
public class EffectiveSettingsService
|
|
{
|
|
// Returns branch value if set, otherwise cafe default
|
|
public async Task<BranchEffectiveSettingsDto> GetEffectiveSettingsAsync(
|
|
Guid cafeId, Guid branchId, CancellationToken ct)
|
|
{
|
|
var cafeSettings = await _db.CafeSettings
|
|
.FirstOrDefaultAsync(s => s.CafeId == cafeId, ct);
|
|
|
|
var branchSettings = await _db.BranchSettings
|
|
.FirstOrDefaultAsync(s => s.BranchId == branchId, ct);
|
|
|
|
// Resolve with fallback
|
|
return new BranchEffectiveSettingsDto
|
|
{
|
|
ReceiptHeader = branchSettings?.ReceiptHeader ?? cafeSettings?.ReceiptHeader,
|
|
ReceiptFooter = branchSettings?.ReceiptFooter ?? cafeSettings?.ReceiptFooter,
|
|
TaxRate = branchSettings?.TaxRate ?? cafeSettings?.TaxRate ?? 0m,
|
|
ServiceCharge = branchSettings?.ServiceCharge ?? cafeSettings?.ServiceCharge ?? 0m,
|
|
OperatingHours = branchSettings?.OperatingHours ?? cafeSettings?.OperatingHours,
|
|
// IsOverridden flags for UI (show which settings are branch-specific)
|
|
TaxRateIsOverridden = branchSettings?.TaxRate != null,
|
|
ReceiptIsOverridden = branchSettings?.ReceiptHeader != null
|
|
|| branchSettings?.ReceiptFooter != null,
|
|
};
|
|
}
|
|
}
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 4 — Settings endpoints
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
Add to existing CafeSettingsController or new BranchSettingsController:
|
|
|
|
GET /api/cafes/{cafeId}/branches/{branchId}/settings
|
|
→ Returns BranchEffectiveSettingsDto (resolved with fallback)
|
|
→ Shows which fields are overridden vs inherited
|
|
|
|
PATCH /api/cafes/{cafeId}/branches/{branchId}/settings
|
|
Body: partial BranchSettings fields
|
|
→ Upserts BranchSettings row
|
|
→ Only fields included in body are updated
|
|
→ Tax rate override: Owner only (or Manager on Pro+ plan)
|
|
|
|
DELETE /api/cafes/{cafeId}/branches/{branchId}/settings/{field}
|
|
→ Clears a specific override field (resets to cafe default)
|
|
→ e.g. DELETE .../settings/taxRate
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
STEP 5 — Use effective settings in receipt + order flow
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
In OrderService.CloseOrderAsync:
|
|
var settings = await _effectiveSettings.GetEffectiveSettingsAsync(cafeId, branchId, ct);
|
|
order.TaxAmount = order.SubTotal * settings.TaxRate;
|
|
order.ServiceCharge = order.SubTotal * settings.ServiceCharge;
|
|
|
|
In PosReceiptModal (dashboard):
|
|
Fetch GET /branches/{branchId}/settings on receipt open.
|
|
Show ReceiptHeader, ReceiptFooter, WifiPassword on receipt.
|
|
|
|
────────────────────────────────────────────────────────────────
|
|
TESTS
|
|
────────────────────────────────────────────────────────────────
|
|
|
|
✓ GetEffectiveSettings_UsesBranchOverride_WhenSet
|
|
✓ GetEffectiveSettings_FallsBackToCafe_WhenNoOverride
|
|
✓ GetEffectiveSettings_BothNull_ReturnsDefaults
|
|
✓ PatchSettings_OnlyUpdatesIncludedFields
|
|
✓ DeleteSettingField_ResetsToInheritedValue
|
|
✓ OrderClose_AppliesBranchTaxRate
|
|
```
|
|
|
|
---
|
|
|
|
## Execution Order
|
|
|
|
```
|
|
Branch 1: feature/branch-menu-ownership
|
|
→ PROMPT 1 (BranchMenuItemOverride)
|
|
→ Tests: BranchMenuTests.cs
|
|
|
|
Branch 2: feature/branch-table-sections (can parallel with branch 1)
|
|
→ PROMPT 2 (TableSection + BranchId on Table)
|
|
→ Tests: BranchTableTests.cs
|
|
|
|
Branch 3: feature/branch-credentials (depends on branch 1+2 merged)
|
|
→ PROMPT 3 (UserBranchAssignment + JWT + PIN)
|
|
→ Tests: BranchCredentialsTests.cs
|
|
|
|
Branch 4: feature/branch-settings (depends on branch 3 merged)
|
|
→ PROMPT 4 (BranchSettings + EffectiveSettingsService)
|
|
→ Tests: inline in SettingsTests.cs
|
|
```
|
|
|
|
---
|
|
|
|
## Permission Matrix (implement in all service methods)
|
|
|
|
| Action | Owner | Manager (own branch) | Cashier | Waiter |
|
|
|--------|-------|---------------------|---------|--------|
|
|
| Create/edit menu items | ✅ | ❌ | ❌ | ❌ |
|
|
| Toggle item availability at branch | ✅ | ✅ | ❌ | ❌ |
|
|
| Override item price at branch | ✅ | ✅ Pro+ only | ❌ | ❌ |
|
|
| Create/edit tables | ✅ | ✅ own branch | ❌ | ❌ |
|
|
| Create table sections | ✅ | ✅ own branch | ❌ | ❌ |
|
|
| Assign staff to branch | ✅ | ❌ | ❌ | ❌ |
|
|
| Deactivate staff at branch | ✅ | ✅ own branch | ❌ | ❌ |
|
|
| Edit branch settings | ✅ | ✅ own branch | ❌ | ❌ |
|
|
| Override tax rate | ✅ | ✅ Pro+ only | ❌ | ❌ |
|
|
| View other branch data | ✅ | ❌ | ❌ | ❌ |
|
|
| Switch active branch | ✅ all | ✅ if assigned | ❌ | ❌ |
|
|
| PIN login | — | ✅ | ✅ | ✅ |
|
|
|
|
---
|
|
|
|
## New Error Codes This Sprint
|
|
|
|
| Code | Meaning |
|
|
|------|---------|
|
|
| `BRANCH_NOT_FOUND` | BranchId not found or wrong cafe |
|
|
| `REQUIRES_BRANCH_SELECT` | Login succeeded but branch must be chosen |
|
|
| `BRANCH_UNASSIGNED` | User not assigned to requested branch |
|
|
| `PIN_INVALID` | PIN login failed |
|
|
| `PIN_RATE_LIMITED` | Too many PIN attempts |
|
|
| `TABLE_SECTION_HAS_TABLES` | Cannot delete section with active tables |
|
|
| `LAST_OWNER_PROTECTED` | Cannot remove last owner from branch |
|
|
| `PLAN_LIMIT_BRANCH_MENU` | Price override requires Pro plan |
|
|
|
|
---
|
|
|
|
*End of plan — start with PROMPT 1 (BranchMenuItemOverride), it has zero auth dependencies.*
|