First phase of auto-discovered printing for cloud-hosted cafés whose printers are
on the local network (the cloud can't reach a LAN/USB printer directly). Adds:
- PrintAgent + PrintDevice entities (+ additive migration) — a per-café local
bridge and the printers it reports.
- PrintAgentHub (/hubs/print-agent): agents connect outbound, authenticated by a
token in access_token (not the user JWT); ReportPrinters upserts devices,
PrintJob is pushed to the agent, JobResult/Heartbeat come back.
- PrintAgentRegistry (singleton): tracks connected agents and dispatches a job to
one, awaiting its ack with a timeout.
- Pairing: POST /cafes/{id}/print-agents/pairing-code (ManagePrintSettings) issues
a short one-time code; anonymous POST /print-agent/claim redeems it for a
long-lived token (only its SHA-256 hash is stored). List + revoke endpoints,
online status from the registry.
Inert until Phase 2 routes jobs through it and the agent app (Phase 3) connects.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Anti-fraud / integrity: a cashier could fire an order to the kitchen, take cash
without recording a payment, then cancel (soft-delete) the unpaid order to erase
it. CancelOrderAsync now only allows cancelling a still-Pending order; once the
kitchen has acted on it (Confirmed/Preparing/Ready) it returns ORDER_IN_PREPARATION
and a started order can no longer be removed — it must be completed (and refunded
through the audited refund flow if needed). Delivered → ORDER_NOT_OPEN; paid →
ORDER_HAS_PAYMENTS (unchanged). Orders are never hard-deleted and every cancel is
already audited with the actor. Applies to all roles, independent of permissions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each menu item can now pick its own print station, overriding the category's —
so a category can fan out to different printers (e.g. a drink → cold bar, a
food → kitchen). Adds MenuItem.KitchenStationId (+ migration, FK SetNull), wires
create/update/DTO, and updates kitchen-ticket routing to group by the item's
station ?? the category's station ?? the branch kitchen printer. Deleting a
station now also clears item assignments. Menu item editor gains a "Print
station" dropdown (default = "same as category"). fa/en/ar added.
Backend built clean via the Nexus mirror; migration applies on deploy (MigrateAsync).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Payments: reject RecordPaymentsAsync when the order is already Delivered/
Cancelled (ORDER_ALREADY_CLOSED) — prevents duplicate payments, double loyalty
earn, and overstated cash drawer from a double-tap or paying a reopened order.
- Push broadcast: POST /api/push/broadcast was [Authorize]-only (any user → any
topic, platform-wide). Now requires SendSms + café context and is forced to the
caller's own topic (cafe-{slug}); arbitrary/cross-café topics rejected.
- HR reads: GetEmployees/GetAttendance/GetShifts now require ViewStaff/
ViewAttendance/ViewSchedules (were café-access-only, leaking roster PII the UI
already hid). Expenses list now requires ViewExpenses.
- Receipt: removed the auto-print on full payment so the POS success sheet is the
single print path (no more double receipt).
Local build blocked by NU1301 (NuGet network unreachable); CI builds via mirror.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a "سفارشها" (Orders) nav page listing closed orders by day (date +
branch filter, paged), each with reprint actions:
- چاپ فاکتور → customer receipt
- فیش آشپزخانه → kitchen ticket (all stations)
- one button per print station (e.g. Bar) → reprints only that station's items
Backend: the kitchen print endpoint gains an optional ?stationId= to reprint a
single station; PrintKitchenTicketAsync filters its station groups accordingly
(NO_STATION_ITEMS when that station has nothing on the order). Nav gated by
ViewOrders (visible to branch staff too). fa/en/ar strings added.
Note: local backend build couldn't run (NU1301 — NuGet restore network timeout);
dashboard typecheck is clean and the C# changes are minimal — CI builds via the
Nexus mirror.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Complements the separate kitchen/bar printers with an on-screen split. The live
order DTO now carries each item's prep station (MenuItem → Category →
KitchenStation), and the KDS shows station tabs (All / Kitchen / Bar / …) that
appear only once ≥2 stations are in play. Selecting a station shows just the
tickets — and just the items within each ticket — for that station, so bar staff
see drinks and kitchen staff see food. Single-station cafés see the board
unchanged. fa/en/ar strings added.
Note: order status is still per-order (one advance button); the split is for
viewing/printing, not per-item status.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Owner can define named custom roles (e.g. Barista, Supervisor) with
color, description, and a fine-grained permission set (21 permissions
across 7 categories: admin, menu, staff, customer, reports, ops, kitchen)
- Employee assigned a custom role gets its permissions embedded in the
JWT at login (customPerms claim) and parsed by TenantMiddleware —
overrides the static EmployeeRole matrix for all API permission checks
- New endpoints: GET/POST/PATCH/DELETE /api/cafes/{id}/custom-roles and
PUT /api/cafes/{id}/employees/{id}/custom-role for assignment
- Dashboard Settings → Team & Staff → Custom Roles panel with grouped
checkbox matrix, group-level toggles, color preset picker, CRUD forms,
and employee-count display; translations in fa/en/ar
- EF migration adds CustomRoles table + nullable CustomRoleId FK on Employees
- POS slip now shows per-item notes on both thermal print and bill preview
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DemoSeedService / DemoMenuSeeder:
Add IgnoreQueryFilters() to every seeder lookup (Taxes, MenuCategories,
MenuItems). Soft-deleted rows still hold their PKs; without this a second
seed run after user-deletion throws a PK collision on the Tax or category
that was soft-deleted but is still in the index.
sitemap.ts:
Guard new Date(post.date) against empty / missing frontmatter date fields.
new Date("") = Invalid Date → broken <lastmod> in sitemap XML.
Fall back to the build-time date when the post date is absent or invalid.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Platform admins can generate a permanent recovery key per café (admin
panel → Cafés). The café Owner uses it to sign in when OTP access is lost;
once authenticated, all server-side data syncs as normal (data is per-café
on the server, the device only caches it).
Backend:
- Cafe.RecoveryKeyHash (SHA-256, unique index) + RecoveryKeyCreatedAt; migration
- RecoveryKeyGenerator util: MZ-XXXXX-XXXXX-XXXXX-XXXXX, ~190-bit entropy,
stored as SHA-256 (API-token pattern — raw key shown once, never retrievable)
- Admin: POST/DELETE /api/admin/cafes/{id}/recovery-key (key returned once);
café list now reports HasRecoveryKey + RecoveryKeyCreatedAt
- Login: POST /api/auth/login-key → exact-hash lookup → resolves café Owner →
issues normal JWT; rate-limited (auth-otp), suspended/no-owner guarded, logged
Admin UI: per-café generate / regenerate / revoke with one-time reveal + copy.
Dashboard login: discreet "ورود با کلید بازیابی" link → key field. fa/en/ar.
86 tests pass; all tsc clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The platform no longer sells SMS. Each café saves its OWN Kavenegar API
key + sender line (new Cafes columns + migration) and campaigns are sent
and billed through that account.
Backend:
- GET/PUT /sms/settings (Manager/Owner; key echoed masked, verified
against the provider before saving)
- campaign + balance use the café's credentials; SMS_NOT_CONFIGURED
error when missing; plan-tier SMS gating removed everywhere
(PlanLimitChecker, SmsMarketingService, billing status)
- platform Kavenegar config stays ONLY for login OTPs (env/DB)
- design-time DbContext factory so `dotnet ef migrations add` works
without booting the host
Dashboard:
- SMS screen: provider-settings card, not-configured callout, campaign
form disabled until configured; quota bar removed (usage stays as info)
- subscription screen + plan comparison no longer show SMS limits
Admin panel:
- Kavenegar/SMS section removed from integrations (request field now
optional; stored OTP config untouched)
- SMS limit field removed from the plan editor
- nav label "درگاه و پیامک" → "درگاه پرداخت و AI"
fa/en/ar translations. 86 tests pass; all tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Backend:
- POST /orders/{id}/payments/corrections (Manager/Owner): void wrong
payments (marked Refunded, never deleted) and/or record replacements
atomically; mandatory reason; requires an open register shift; full
before/after written to the immutable audit trail.
- GET /orders/closed?date= — closed orders of one Iran-calendar day,
paged, the browsing surface for corrections.
- CalculateExpectedCash now subtracts cash refunds so corrections keep
the drawer expectation honest.
Dashboard (reports screen now has three tabs):
- عملکرد و سود: existing KPIs/charts + new day-by-day breakdown table
(orders, revenue, expenses, net profit per Jalali day).
- اصلاح سند: closed-orders browser with payment chips + correction
dialog (void checkboxes, replacement rows, live balance, reason).
- گزارش عملیات: filterable audit-log viewer (category, Jalali range,
branch) with expandable structured details.
fa/en/ar translations included. 86 backend tests pass; dashboard tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The last two limits that still read hardcoded PlanLimits now come from the
admin-editable catalog, so editing them in the admin panel takes effect:
- ReportPlanGate is now limit-driven (takes int maxDays, not a tier); ReportsController
resolves MaxReportHistoryDays from catalog.GetLimitsAsync. LimitMessage is generic
(reflects the actual days). EnsureReportDateAllowed is now async.
- MenuAi3dGenerationService.ResolveLimitAsync reads MaxMenuAi3dPerMonth from the catalog.
Every plan limit + feature gate is now DB-driven and admin-editable. 86 tests pass.
Guest orders from the QR/digital menu already notified via SignalR, but only
screens that were open (KDS/POS/tables) reacted — and silently (a data refresh,
no alert). So staff on any other screen never knew a menu order arrived.
- Add a global useOrderAlerts() mounted in the dashboard shell: connects to
/hubs/kds, joins the café group, and on a new GUEST order plays a chime + shows
a toast (localized fa/en/ar) + nudges order/KDS/POS lists to refresh — on every
screen.
- Filter to guest QR-menu orders only (not staff POS orders): LiveOrderDto now
carries Source, set in MapLiveOrder (+ the delivery/snappfood mappers).
86 API tests pass; dashboard tsc + build clean.
Guest QR menu shows a "ساختهشده با میزی" watermark under the menu unless the café's
plan has the `watermark_removed` feature (Starter+).
- PublicMenuDto gains ShowWatermark; PublicService computes it from
IsFeatureEnabledForCafeAsync("watermark_removed") for both slug and branch menus.
- Guest menu renders the watermark footer when showWatermark.
- NoOpPlatformCatalogService test double (all features on) for the PublicService
ctor; QrMenuTests updated.
86 tests pass; dashboard tsc clean.
Make more plan rules read the admin-editable catalog instead of hardcoded values:
- Review reply gated by the `review_reply` feature (Starter+) — 403 if not in plan.
- Custom menu styling gated by `custom_menu_styling` (Starter+): only blocks an
actual theme change, so a normal settings save re-sending the current theme is fine.
- Menu categories/items limits now read catalog.GetLimitsAsync (Free categories
editable; message no longer hardcodes a number).
- Terminals limit reads the catalog (enforcement in TerminalRegistryService +
the displayed max in TerminalsController).
Remaining (small): menu watermark (Free shows it, `watermark_removed` removes it —
needs the public-menu render), report-history (static ReportPlanGate) and AI-3D
routing — these already enforce the correct matrix values, just not yet editable.
86 tests pass; build clean.
PlanLimitChecker already enforces orders/customers/branches/SMS from the
admin-editable catalog (GetLimitsAsync). Add the tables cap the same way
(POST /api/cafes/{cafeId}/tables → MaxTables), so Free's 6-table limit is both
enforced and admin-editable. Terminals/categories/report-history already enforce
the correct matrix values via PlanLimits defaults; routing them through the
catalog for editability + the watermark/styling/review-reply feature gates are
the remaining S3 items.
86 tests pass.
Uploads previously wrote every file to disk with a fresh GUID name, so the
same image uploaded twice produced two identical files. Now:
- New MediaAsset table records each stored upload (SHA-256 hash, size, type,
url, kind, scope) + migration. Indexed on (CafeId, ContentHash).
- MediaStorageService computes the content hash on upload; if an identical file
already exists for that café it returns the existing URL instead of writing a
duplicate (covers images, videos, 3D models). Dedup lookup/record run via a
scoped DbContext (the service is a singleton) and never block an upload on
failure.
- GET /api/cafes/{cafeId}/media lists the café's library (newest first, optional
?kind=) so the UI can let users pick an existing file instead of re-uploading.
86 API tests pass.
Before, buying a plan immediately switched the tier and stacked the duration.
Now a purchase made while the café still has paid coverage is QUEUED to start
when the current coverage ends, and the owner can cancel a queued one.
Model:
- SubscriptionPayment gains EffectiveFrom/EffectiveTo; status gains Scheduled
(paid, queued) and Cancelled. EF migration AddSubscriptionScheduling (nullable).
BillingService:
- On payment completion, compute coverage end (latest of active expiry + furthest
queued period). If it is in the future → Scheduled (queued, café tier/expiry
untouched); else activate immediately as before. Periods chain correctly.
- GetStatusAsync lazily promotes any due queued period to active, and returns the
queue (QueuedPlans).
- CancelQueuedAsync cancels a Scheduled period (owner-only) and re-packs the queue
so later periods slide earlier. Active prepaid plan is never cut short; no
automatic refund (manual, per product decision).
- Confirmation SMS distinguishes "activated until X" vs "queued, starts X".
API: BillingStatusDto.QueuedPlans + DELETE /api/billing/queued/{paymentId}.
Dashboard:
- Subscription screen shows a "Queued subscriptions" card (tier, window, cancel
with confirm).
- Checkout shows "you already have an active subscription — this will start on
{date}" when the café is still covered.
- i18n fa/en/ar.
81 API tests pass; dashboard typechecks.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Delete (every manageable entity that only had "add" now has delete):
- Ingredients (warehouse): new DELETE /inventory/ingredients/{id} (soft-delete via
the global DeletedAt filter — no FK trouble with recipes/movements) + NoOp stub +
trash button in the materials cards.
- Reservations: new DELETE /reservations/{id} (soft-delete) + per-card delete button.
- Coupons & Customers: backend DELETE already existed; wired delete buttons in the UI.
- Shared ConfirmDialog component used by all delete flows (RTL-aware).
- Audit result: tables/branches/taxes/kitchen-stations/expenses/menu/terminals already
had delete; HR has no "add" so no delete needed; shifts intentionally excluded
(financial open/close records, not add-style entities).
Koja visibility:
- New Cafe.ShowOnKoja flag, default TRUE (DB default true so existing cafés stay
listed). Discover query now filters IsVerified && !Deleted && ShowOnKoja.
- public-profile GET/PUT expose showOnKoja; dashboard public-profile panel has an
on-by-default toggle that persists immediately. Platform IsVerified gate unchanged.
- EF migration AddCafeShowOnKoja (defaultValue: true).
Also: added the missing errors.generic i18n key (fa/en/ar) so useApiError's fallback
resolves instead of rendering the literal "errors.generic". 81 API tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Login already issues a 7-day access token + 30-day refresh token, and the
dashboard persists the session and silently refreshes on 401 — so a session
should last well over a week. The real cause of "re-login every time / massive
OTP" was single-use refresh-token rotation: RefreshAsync revoked the presented
token and minted a new one, so when a café runs POS + KDS + queue display at
once (or two tabs), the first refresh won the race and every other concurrent
refresh hit the now-revoked token -> INVALID_TOKEN -> forced logout -> OTP.
Make refresh idempotent and race-safe:
- IssueTokensAsync takes an optional existingRefreshToken; on refresh we reuse
the presented token and re-store it (sliding the 30-day TTL) instead of
minting a new one. Login still mints a fresh token.
- RefreshAsync no longer revokes the presented token.
Net effect: concurrent refreshes all succeed; an active session slides forward
and effectively never forces re-auth. Access stays 7 days, refresh 30 days.
All 81 API tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add DELETE /api/cafes/{cafeId}/menu/items/{id} (DeleteItemAsync soft-delete,
mirroring the existing category delete) — item delete had no backend route.
- Dashboard menu admin: destructive "delete" action in the item and category
edit modals, behind a shared confirm dialog (AlertDialog). Deleting the
selected category falls back to "all items".
- Fix the availability ToggleSwitch in RTL: force dir="ltr" so the knob's
translate-x stays inside the track instead of escaping on the right
(same fix as the admin-panel toggles).
- i18n: deleteItem/deleteCategory confirm + success strings (fa/en/ar).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BuildDemoIngredients/BuildDemoTables built ids as
"{cafeId}_ing_{guid}"[..36]. For a real cafe (32-char hex id) the
first 36 chars are just "{cafeId}_ing" — the unique guid is cut off,
so all 15 ingredients (and all 10 tables) get the SAME id, causing a
primary-key collision on SaveChanges -> 500. cafe_demo_001 has a short
id so the guid survived, which is why the bug only hit real cafes.
The Id columns are text (no length limit), so the truncation served no
purpose. Removed [..36] from both so the full unique id is kept.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DemoMenuSeeder used hardcoded IDs like cat_demo_coffee for every café.
If the dev seeder (runs when ASPNETCORE_ENVIRONMENT=Development) already
inserted those IDs for cafe_demo_001, a production café clicking
"Add demo data" hit a primary-key constraint violation.
Fix: EnsureMenuAsync now accepts useScopedIds=true which prefixes every
category and item ID with cafeId (e.g. cafe_abc_cat_demo_coffee).
CategoryId FKs on items are remapped through the same function.
DemoSeedService (the API endpoint handler) always passes useScopedIds=true.
DevelopmentDataSeeder keeps useScopedIds=false (default) so the existing
cafe_demo_001 rows in dev databases are not touched.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds POST /api/cafes/{cafeId}/demo/seed (owner-only) that seeds:
- 9% default VAT tax
- 7 menu categories + 59+ items via DemoMenuSeeder
- 15 inventory ingredients (coffee shop staples)
- 10 tables across 3 floors on the first active branch
Frontend DemoDataBanner appears on menu, tables, and inventory
pages when the café is completely empty, so owners can populate
demo data in one click instead of entering everything manually.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sidebar:
- All groups start collapsed on first load (v4 storage key resets old state)
- Opening one group closes all others (accordion)
- Navigating to a section opens only that section's group
Koja slug:
- SlugHelper: Persian->Latin transliteration, slug validation
- Registration accepts optional custom slug; auto-derives from cafe name
- Slug can be updated from dashboard Settings -> Profile
- Settings PATCH validates uniqueness (SLUG_TAKEN) and format (INVALID_SLUG)
- koja.meezi.ir/{slug} now redirects to /fa/cafe/{slug} (short URL support)
Bug fix:
- SupportTicketService: cafeId/status filters applied before Select() projection
to fix EF "could not be translated" crash on the support tickets page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- VerifyRegisterAsync: create a Branch named after the café alongside
the Café and Owner, so new owners can use the dashboard immediately
without hitting the "select a branch" gate
- PlatformDataSeeder: EnsureDefaultBranchesAsync runs on every boot and
creates a default branch for any existing café that has none (covers
cafés registered before this fix)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Move the dev-mode OTP logging into KavenegarSmsService so consumer and
admin auth flows no longer duplicate the fallback log.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduce an OTP input box on login/register, surface user roles and a
cafe chooser, add a dashboard switch button in the POS screen, and
register OTP validators explicitly to survive Docker layer caching.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Full backend implementation:
- Multi-tenant cafe/restaurant management (menus, orders, tables, staff)
- POS order flow with ZarinPal and Snappfood payment integration
- OTP authentication via Kavenegar SMS
- QR digital menu with public discover/finder endpoints
- Customer loyalty, coupons, CRM
- PostgreSQL via EF Core, Redis for caching/sessions
- Background jobs, webhook handlers
- Full migration history
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>