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