41 Commits

Author SHA1 Message Date
soroush.asadi 352c3b41cb feat(admin): grant a free subscription to any café from the admin panel
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m43s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 33s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m14s
CI/CD / CI · Admin Web (tsc) (push) Successful in 40s
CI/CD / CI · Website (tsc) (push) Successful in 47s
CI/CD / CI · Koja (tsc) (push) Successful in 54s
CI/CD / Deploy · all services (push) Successful in 5m13s
Adds POST /api/admin/cafes/{cafeId}/grant-subscription (admin-auth): sets the
café's plan and adds N months of coverage, appended to any time it already has so
a grant never shortens existing paid time. Records the gift as a SubscriptionPayment
(provider Manual, amount 0, Completed) for billing history/audit. New
PaymentProvider.Manual = 4 (int append, no migration). Admin-web café cards get a
"grant free subscription" panel (plan select + months + apply), showing the current
expiry; fa/en/ar strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:41:33 +03:30
soroush.asadi 4cc1c3a423 feat(payment): FlatRender Pay (ZarinPal broker) checkout + webhook
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m3s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 37s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m10s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 53s
CI/CD / Deploy · all services (push) Successful in 1m41s
Adds a signed broker integration for online plan purchases:
- FlatPayService: POST /v1/pay/request with X-Api-Key + X-Signature =
  hex(HMAC-SHA256(secret, raw JSON bytes)); the exact serialized bytes are both
  signed and sent. VerifyWebhook does a fixed-time compare of the digest, plus an
  in-memory first-seen idempotency set.
- POST /api/payment/request (auth, ManageBilling): parses a "Tier:Months" product
  (e.g. "Pro:12"), prices it via the plan catalog, creates a Pending SubscriptionPayment
  (provider=FlatPay) as the order, and returns the broker payment URL. The order id is
  the client_ref / metadata.payment_id.
- POST /api/payment/webhook (anonymous; HMAC is the auth — 401 on bad signature):
  on status=Paid + first-seen id, grants the order via the shared plan-activation
  path (extracted ActivatePaymentAsync, reused by all providers). Always 200 after a
  valid signature so the broker won't retry an accepted job.
- Config FlatPay__{ApiKey,Secret,BaseUrl,ReturnUrl} (env-supplied; secrets stay out
  of git), compose + .env.example wiring. PaymentProvider.FlatPay appended (int, no
  migration).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 04:20:02 +03:30
soroush.asadi b0896dc777 feat(pos): bridge the card terminal through the print agent + LAN auto-detect
CI/CD / CI · API (dotnet build + test) (push) Successful in 40s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 3m29s
The card-terminal integration only ever worked when the API could reach the
terminal's IP directly — impossible for the cloud deployment, where the terminal
sits on the café LAN (the same wall the Print Agent already climbs for printers).
And the terminal IP had to be typed by hand. Both fixed by reusing the agent.

Cloud→LAN relay:
- PrintAgentRegistry.SendPaymentAsync sends a PaymentRequest to the café's online
  agent and awaits its ack (PaymentResult on the hub); 95s window for the customer.
- PosDeviceService now prefers an online agent (branch-matched, else any café
  agent) to relay POST /pay over the LAN, and falls back to the direct HTTP call
  only when no agent is connected (on-prem). Agent errors map back to POS_DEVICE_*.
- Agent (Program.cs + PosTerminal.cs) handles PaymentRequest → POSTs the amount to
  the terminal's local http://ip:port/pay and reports approval/decline/timeout.

Auto-detect:
- Registry.ScanAsync + hub ReportScan; POST /print-agents/scan asks online agents
  to scan their /24 for given ports and merges the hosts found.
- Agent NetworkScanner scans the LAN (:9100 printers, :8088 terminals) with a
  short per-host TCP probe.
- Dashboard: a "تشخیص خودکار" (auto-detect) button on the POS-device, receipt and
  kitchen IP fields scans via the agent and fills the IP:port from a found host.

Backend + agent build clean; dashboard tsc clean. NOTE: the agent app is not in
CI — it must be rebuilt and redeployed on the café PC to gain these handlers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:01:21 +03:30
soroush.asadi f368765419 fix(pos): charge the server amount, and don't book unconfirmed card payments
CI/CD / CI · API (dotnet build + test) (push) Successful in 43s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m11s
CI/CD / CI · Admin Web (tsc) (push) Successful in 39s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 2m49s
Two go-live money-correctness bugs in the POS pay flow (deferred TODO #1/#2):

#2 — pay against the server's amount, not a client recompute. The pay sheet
took `orderAmountDue(payTarget) || total`, so any time the server figure was
absent/zero it silently fell back to the POS's own 9% tax recompute. The backend
records whatever amount the client posts (it only uses its own order.Total to
decide closure), so a client/server mismatch books the wrong cash-drawer amount.
Now a real (server) order always charges orderAmountDue(serverOrder); only a
genuinely-local offline order — which has no server figure — uses the client
total.

#1 — don't record a card payment that wasn't confirmed. A connected terminal
that declines already throws POS_DEVICE_* and records nothing. But when no
terminal is wired up the request is "skipped" and the card was booked as paid
with zero proof it cleared. Now, when the card leg isn't machine-confirmed, the
cashier must confirm "card approved on the terminal?" before it's recorded;
cancel records nothing.

Also raise the shared AlertDialog to z-[80] so a confirmation renders above the
POS pay sheet (z-[60]) and its busy overlay (z-[70]); still below toasts.

tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:13:21 +03:30
soroush.asadi 197f6f2d38 feat(print): dashboard UI for print servers + auto-discovered printer pickers
CI/CD / CI · API (dotnet build + test) (push) Successful in 42s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 3m44s
Phase 4 (final). Settings → Printers now has a "Print servers" section: add a
print server (issues a one-time pairing code with steps), see each agent's online
status, its auto-discovered printers, test any of them, and revoke. Receipt,
kitchen and per-station printers can now be picked from a dropdown of discovered
devices instead of typing an IP (manual IP stays as fallback). Wires the device
mappings through the branch print-settings + kitchen-station DTOs/services and adds
the device-test endpoint. fa/en/ar strings added.

Completes the cloud↔LAN print-agent feature (entities/hub → routing → agent → UI).
Remaining polish: agent system-tray + run-at-login + installer, optional LAN scan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:28:27 +03:30
soroush.asadi 7d5af0c81b feat(print): Windows print agent — the cloud↔LAN bridge (Phase 3)
CI/CD / CI · API (dotnet build + test) (push) Successful in 39s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 33s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Successful in 39s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 29s
A standalone net10.0-windows console app (agent/Meezi.PrintAgent) installed on the
café cash PC. It pairs with a one-time code (POST /print-agent/claim), stores the
token in %APPDATA%, holds a SignalR connection to /hubs/print-agent (retries
forever, re-reports on reconnect), discovers installed printers via WMI (USB +
network, classified), and prints jobs it receives by writing raw ESC/POS bytes —
winspool RAW passthrough for installed printers, raw TCP for ip:port devices —
acking each job back. Not in the API solution or CI (own net10.0-windows build);
see agent/README.md for build/publish/pair. Builds clean; startup + pairing flow
smoke-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:16:28 +03:30
soroush.asadi 9e47a4e60c feat(print): route print jobs through a local agent, fall back to TCP
CI/CD / CI · API (dotnet build + test) (push) Successful in 47s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 1m40s
Phase 2. NetworkPrinterService now builds the ESC/POS bytes (as before) and
dispatches them via a new router: if the receipt/kitchen/station printer is mapped
to a PrintDevice whose agent is online, the bytes are sent to that agent over the
hub and we await its ack; otherwise it falls back to a direct TCP connection (raw
IP), so existing on-prem/reachable printers keep working unchanged. Adds nullable
mapping columns Branch.ReceiptPrintDeviceId / KitchenPrintDeviceId and
KitchenStation.PrintDeviceId (additive migration), plus TestPrintDeviceAsync for
testing an agent printer. The cloud can now reach LAN/USB printers it never could.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:07:16 +03:30
soroush.asadi cb57c61a11 feat(print): cloud↔local print-agent foundation (hub, pairing, registry)
CI/CD / CI · API (dotnet build + test) (push) Successful in 43s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Has been cancelled
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>
2026-06-25 12:02:25 +03:30
soroush.asadi 67450393fc fix(pos): cashier can't delete/reduce an item already sent to the kitchen
CI/CD / CI · API (dotnet build + test) (push) Successful in 42s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 34s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m7s
CI/CD / CI · Admin Web (tsc) (push) Successful in 41s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m54s
On the POS, once a line is fired to the kitchen its sent quantity is the locked
portion: a user without the VoidOrder permission (the default cashier) can no
longer remove that line or decrease it below what was sent — otherwise they could
send food and then erase it from the order (charge less / pocket cash). The unsent
portion of a line stays freely editable, and adding more is always allowed. The
delete button is replaced by a lock icon on sent lines, and the minus button is
disabled at the sent floor. Gated by VoidOrder, so owners/managers with the
permission are unaffected. Mirrors the server-side order-cancel lock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 11:40:13 +03:30
soroush.asadi ae5c750d34 fix(notifications): don't lose live alerts until a page refresh
CI/CD / CI · API (dotnet build + test) (push) Successful in 44s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m50s
The SignalR connection used the default auto-reconnect, which gives up after
~30s and, even when it did reconnect, never re-ran JoinCafe — so the client
dropped out of the café group and silently stopped receiving notifications until
a manual refresh. Now it retries forever (capped backoff), re-joins the group on
reconnect (and catches up via invalidate), and re-establishes the connection when
the network returns or the tab is refocused. As a safety net, the unread/bell and
tab-badge polls now run in background tabs too (refetchIntervalInBackground).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 11:28:47 +03:30
soroush.asadi f985deb233 fix(offline): stop the sync queue badge getting stuck above zero
Two bugs made "N در صف" persist even when online:
- The badge counted poisoned ops (failed after 5 retries, never removed), so it
  never returned to 0. Now the badge counts only retryable (active) ops; poisoned
  ops are tracked separately as failedCount and surfaced as a red "N failed —
  clear" chip the user can tap to discard them.
- The manual-retry click drained the LEGACY order_queue, not the real outbox the
  app actually uses — so clicking did nothing for stuck items. It now drains the
  outbox (drainOutbox), invalidates queries on success, and recounts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 11:28:47 +03:30
soroush.asadi 27ca80fd54 fix(orders): block cancelling an order once the kitchen has started it
CI/CD / CI · API (dotnet build + test) (push) Successful in 52s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 39s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 1m40s
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>
2026-06-25 10:41:30 +03:30
soroush.asadi b162335b48 feat(notifications): proactively ask the browser for popup permission
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m1s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m11s
CI/CD / CI · Admin Web (tsc) (push) Successful in 39s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 2m52s
Previously desktop popups had to be enabled from Settings. Now a dismissible
prompt card appears for signed-in users on a supporting browser that haven't
decided yet ("Turn on notifications?" + Enable/Later). Tapping Enable triggers
the browser permission request (user gesture, as browsers require), turns on
desktop popups, and immediately fires one (force) so the user sees it works.
Shown once per device (remembered in localStorage); mounted on both the
dashboard and POS/queue layouts. fa/en/ar added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:32:12 +03:30
soroush.asadi 27b3ac60c7 feat(menu): per-item print station (cold bar / kitchen / barista)
CI/CD / CI · API (dotnet build + test) (push) Successful in 41s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 3m28s
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>
2026-06-25 10:08:07 +03:30
soroush.asadi aede5bfd97 refactor(hr): move Custom Roles from Settings into the HR section
CI/CD / CI · API (dotnet build + test) (push) Successful in 42s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 39s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 2m47s
Custom roles is staff governance, so it belongs with the team — added a "Roles &
permissions" tab to the HR screen (owner-only) rendering the existing
CustomRolesPanel, and removed the Settings → Team → Custom Roles leaf/group.
fa/en/ar label added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 09:38:55 +03:30
soroush.asadi eaf911e12c fix(pos): alert on waiter calls / guest orders on the POS & queue display
CI/CD / CI · API (dotnet build + test) (push) Successful in 46s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 28s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m10s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 48s
CI/CD / CI · Koja (tsc) (push) Successful in 51s
CI/CD / Deploy · all services (push) Successful in 3m3s
The call-waiter flow was fully wired (guest QR button → public endpoint →
NotifyCallWaiterAsync persists + broadcasts NotificationReceived), but the alert
hook (useOrderAlerts: sound + toast + desktop popup) and the bell live only in
the (dashboard) layout. During service staff are on the POS (fullscreen) layout,
which mounted neither — so a waiter call produced nothing where staff actually
stand. Mount useOrderAlerts in the (fullscreen) layout so POS / queue-display
get the chime + toast for waiter calls and new guest orders. (KDS is a dashboard
route, already covered.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 09:27:54 +03:30
soroush.asadi 166f2b2586 fix(seo): self-canonical + unique description on 6 pages that deduped to home
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m4s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 52s
CI/CD / Deploy · all services (push) Successful in 2m1s
contact / careers / status / privacy / terms / docs set no alternates, so they
inherited the layout's canonical (= the locale homepage) — Google treats them as
duplicates of the home page and drops them. Each now sets a self-referencing
canonical + full fa/en/x-default hreflang (new shared lib/seo.ts pageAlternates)
and a unique meta description (added *Desc keys, fa/en) + per-page OpenGraph.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:32:08 +03:30
soroush.asadi 8ea98bdc09 fix(seo): website/koja base URL defaulted to localhost → de-indexed in GSC
CI/CD / CI · API (dotnet build + test) (push) Successful in 5m47s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 34s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m10s
CI/CD / CI · Admin Web (tsc) (push) Successful in 40s
CI/CD / CI · Website (tsc) (push) Successful in 47s
CI/CD / CI · Koja (tsc) (push) Successful in 51s
CI/CD / Deploy · all services (push) Successful in 3m53s
Production serves robots.txt Host/Sitemap, sitemap <loc>, and every page's
canonical + og:url as http://localhost:3010 — so Google rejects all URLs
("URL not allowed") and indexes nothing. Cause: NEXT_PUBLIC_SITE_URL is baked in
at BUILD time and was unset in prod, so it fell back to the localhost defaults in
the compose files + website Dockerfile.

Changes the defaults to the real domains (website → https://meezi.ir, koja →
https://koja.meezi.ir) in docker-compose.yml, docker-compose.full.yml, the
website Dockerfile ARG, and .env.example.

Build-time var → the website image MUST be rebuilt + redeployed (CI does this on
push), then purge the WCDN cache and resubmit the sitemap in Search Console.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 18:58:38 +03:30
soroush.asadi 72abf05a5f fix(dashboard): review fixes — error toasts, dedupe socket, POS guards
CI/CD / CI · API (dotnet build + test) (push) Successful in 42s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m11s
CI/CD / CI · Admin Web (tsc) (push) Successful in 39s
CI/CD / CI · Website (tsc) (push) Successful in 47s
CI/CD / CI · Koja (tsc) (push) Successful in 52s
CI/CD / Deploy · all services (push) Successful in 3m20s
- Global MutationCache.onError safety net so mutations without their own onError
  no longer fail silently (skips ones that handle errors → no double toast).
- Notifications feed no longer opens its own SignalR connection; it reuses the
  one in useOrderAlerts (was double sockets + double cache churn per session).
- "Send test notification" now works on the settings page (force flag bypasses
  the tab-visible guard) instead of silently doing nothing.
- POS: re-entry guard on payment confirm (no duplicate payment on double-tap);
  notes on already-sent lines are read-only (a note-only edit was silently lost);
  ORDER_ALREADY_CLOSED surfaced with a clear Persian message.
- Reservation Confirm/Cancel/Complete buttons disabled while pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:54:02 +03:30
soroush.asadi 63e3cb6962 fix(security,pos): close payment/push/PII gaps from app review
CI/CD / CI · API (dotnet build + test) (push) Successful in 59s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 33s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m10s
CI/CD / CI · Admin Web (tsc) (push) Successful in 40s
CI/CD / CI · Website (tsc) (push) Successful in 48s
CI/CD / CI · Koja (tsc) (push) Successful in 52s
CI/CD / Deploy · all services (push) Successful in 2m13s
- 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>
2026-06-22 15:40:20 +03:30
soroush.asadi c360fbb068 feat(orders): recent orders view with receipt / kitchen / bar reprint
CI/CD / CI · API (dotnet build + test) (push) Successful in 41s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m10s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 3m34s
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>
2026-06-21 23:15:34 +03:30
soroush.asadi 1264606410 fix(pos): show the post-payment receipt sheet (was rendered in the wrong view)
CI/CD / CI · API (dotnet build + test) (push) Has been cancelled
CI/CD / CI · Admin API (dotnet build) (push) Has been cancelled
CI/CD / CI · Dashboard (tsc) (push) Has been cancelled
CI/CD / CI · Admin Web (tsc) (push) Has been cancelled
CI/CD / CI · Website (tsc) (push) Has been cancelled
CI/CD / CI · Koja (tsc) (push) Has been cancelled
CI/CD / Deploy · all services (push) Has been cancelled
The payment-success sheet with the "چاپ فاکتور" button lives in the order-view
return, but confirmPay called backToBoard() which switched to the board view —
so the sheet never rendered and the cashier couldn't print after paying.

Now payment clears the cart + closes the pay sheet but STAYS on the order view,
so the success sheet shows; returning to the board happens when the cashier taps
"سفارش جدید" or the backdrop. Offline/local orders still go straight to the board.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 22:17:14 +03:30
soroush.asadi cad5ba6ea3 fix(pos): make the per-item note obvious with an explicit button
CI/CD / CI · API (dotnet build + test) (push) Successful in 46s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 47s
CI/CD / CI · Koja (tsc) (push) Successful in 51s
CI/CD / Deploy · all services (push) Successful in 3m11s
The note control was an unlabeled icon next to the trash on each cart line, so
cashiers couldn't tell where to add a note. Replaced it with a clear
"افزودن یادداشت" (add note) text button under each item; clicking it reveals the
note input, which collapses again on blur when left empty. Existing notes still
show the editable field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 22:10:29 +03:30
soroush.asadi 5596e8dbc5 chore(pos): fully remove the classic POS
CI/CD / CI · API (dotnet build + test) (push) Successful in 49s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m11s
CI/CD / CI · Admin Web (tsc) (push) Successful in 41s
CI/CD / CI · Website (tsc) (push) Successful in 47s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 2m52s
POS v2 has been the default at /pos for a while; this deletes the old classic
POS entirely:
- removed the /pos-classic route and all classic-only components
  (pos-screen, pos-pay-panel, pos-table-board, pos-queue-bar, pos-receipt-modal,
  pos-slip-modal, pos-receipt-print.css)
- relocated the two modules POS v2 still shared into the pos2 tree
  (lib/pos/submit-order → lib/pos2, components/pos/pos-customer-picker → pos2),
  so the components/pos and lib/pos folders are gone
- dropped the now-dead "نسخه کلاسیک" (classic version) button + RotateCcw import
  from the POS v2 header, and updated stale comments

POS v2 (/pos) is unchanged and fully self-contained. Typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:42:32 +03:30
soroush.asadi 46f962eb75 fix(pos): keep the receipt printable after paying an order
CI/CD / CI · API (dotnet build + test) (push) Successful in 56s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m10s
CI/CD / CI · Admin Web (tsc) (push) Successful in 39s
CI/CD / CI · Website (tsc) (push) Successful in 47s
CI/CD / CI · Koja (tsc) (push) Successful in 1m1s
CI/CD / Deploy · all services (push) Successful in 3m0s
Bug: confirming payment ran backToBoard() → clearSession(), wiping the cart's
activeOrderId, so the order vanished from the POS and the "print receipt" button
(which keys off activeOrderId) went dead — the only escape was a 4s toast.

Fix: capture the just-paid order id in local state (survives the session clear)
and show a persistent payment-success sheet with a "چاپ فاکتور" button so the
cashier can print/reprint the customer receipt, then "سفارش جدید" to continue.
Shared printReceiptById() backs both the in-order button and the success sheet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:14:01 +03:30
soroush.asadi 6184c83fa7 feat(pos): print customer receipt from the POS page
CI/CD / CI · API (dotnet build + test) (push) Successful in 44s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 32s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m12s
CI/CD / CI · Admin Web (tsc) (push) Successful in 39s
CI/CD / CI · Website (tsc) (push) Successful in 48s
CI/CD / CI · Koja (tsc) (push) Successful in 51s
CI/CD / Deploy · all services (push) Successful in 3m0s
POS v2 auto-printed the receipt on full payment but had no manual button. Adds a
"چاپ فاکتور" (print receipt) action in the order panel that prints/reprints the
active saved order's customer receipt, plus a "print receipt" action on the
payment-success toast. Replaces the dead disabled "hold" placeholder button.
Backend print endpoint unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:18:20 +03:30
soroush.asadi 0c2ded4070 feat(pos): set a per-item note on each cart line in POS v2
CI/CD / CI · API (dotnet build + test) (push) Successful in 43s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 32s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m13s
CI/CD / CI · Admin Web (tsc) (push) Successful in 40s
CI/CD / CI · Website (tsc) (push) Successful in 48s
CI/CD / CI · Koja (tsc) (push) Successful in 52s
CI/CD / Deploy · all services (push) Successful in 3m9s
The cart store, order payload (create + add-items + offline), KDS ticket and
receipt already supported per-item notes — but POS v2 had no way to enter one.
Adds a note button on each cart line that toggles an inline input (e.g. "no
sugar"); the note shows highlighted when set and rides along to the kitchen/bar
ticket. No backend change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:05:00 +03:30
soroush.asadi 2a24798a59 feat(audit): show actor full name + role in logs, click to view details
CI/CD / CI · API (dotnet build + test) (push) Successful in 44s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m11s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 3m39s
Logs showed the raw User ID (ActorName was almost never stored) and an English
role enum. Now:

- AuditController resolves each entry's actor to the employee's CURRENT full name
  and localized role at read time (joins Employees with IgnoreQueryFilters, so it
  also names soft-deleted staff and fixes all historical rows — no migration).
- The audit table renders "Full name (Role)" with the role localized (fa/en/ar);
  the name is a button that opens an employee-details dialog.
- New EmployeeDetailsDialog: fetches the employee and shows name, role, phone,
  base salary, and an "Open in HR" link; handles removed staff gracefully.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 11:24:06 +03:30
soroush.asadi 6d71770f2e fix(auth): redirect already-signed-in users away from the register page
CI/CD / CI · API (dotnet build + test) (push) Successful in 44s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m10s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 51s
CI/CD / Deploy · all services (push) Successful in 2m55s
Mirrors the login guard: visiting /register while authenticated redirects to the
dashboard home (/) instead of showing the form. Gated on _hasHydrated; shows a
brief redirecting state. Reuses the existing auth.redirecting string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 11:11:10 +03:30
soroush.asadi fd1f985597 fix(auth): redirect already-signed-in users away from the login page
CI/CD / CI · API (dotnet build + test) (push) Successful in 40s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m10s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 47s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m57s
Visiting /login while authenticated now redirects to the app (/pos) instead of
showing the login form again. Guarded on _hasHydrated so the not-yet-rehydrated
(null) session isn't misread, and renders a brief "redirecting" state instead of
flashing the form. fa/en/ar strings added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 10:39:27 +03:30
soroush.asadi d261c13175 docs(website): note KDS station tabs in the kitchen-display guide
CI/CD / CI · API (dotnet build + test) (push) Successful in 42s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 33s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m11s
CI/CD / CI · Admin Web (tsc) (push) Successful in 39s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 3m44s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 10:29:15 +03:30
soroush.asadi 958addf734 feat(kds): filter the kitchen display by station (kitchen / bar)
CI/CD / CI · API (dotnet build + test) (push) Successful in 44s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Has been cancelled
CI/CD / CI · Admin Web (tsc) (push) Has been cancelled
CI/CD / CI · Website (tsc) (push) Has been cancelled
CI/CD / CI · Koja (tsc) (push) Has been cancelled
CI/CD / Deploy · all services (push) Has been cancelled
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>
2026-06-21 10:27:40 +03:30
soroush.asadi 8703e9cf87 docs(website): knowledge-base guide for printing (receipt, kitchen, bar)
CI/CD / CI · API (dotnet build + test) (push) Successful in 42s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m12s
CI/CD / CI · Admin Web (tsc) (push) Successful in 39s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 3m0s
Documents the new separate kitchen/bar print stations plus the customer
receipt (factor): how to set printer IPs, create Kitchen/Bar stations, route
menu categories to a station, and what auto-prints at send-to-kitchen vs
payment. fa/en; auto-listed on /docs and in the sitemap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 09:58:22 +03:30
soroush.asadi fb6a20eaa1 feat(print): separate kitchen & bar printers via print stations UI
CI/CD / CI · API (dotnet build + test) (push) Successful in 47s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 28s
CI/CD / CI · Dashboard (tsc) (push) Has been cancelled
CI/CD / CI · Admin Web (tsc) (push) Has been cancelled
CI/CD / CI · Website (tsc) (push) Has been cancelled
CI/CD / CI · Koja (tsc) (push) Has been cancelled
CI/CD / Deploy · all services (push) Has been cancelled
The print engine already routed items to per-station printers (MenuCategory →
KitchenStation.PrinterIp, falling back to the branch kitchen printer) and prints
the customer receipt to the receipt printer — but there was no UI to set it up.
This exposes it:

- Settings → "Kitchen & bar printers": create/edit/delete print stations, each
  with its own printer IP/port, with a per-station test print (gated by
  ManageKitchenStations).
- Menu category editor: a "Print station" dropdown to route each category to a
  station (food → Kitchen, drinks → Bar); no station = branch kitchen printer.

Result: kitchen and bar tickets print on separate printers, while the customer
factor/receipt keeps printing on the receipt printer. fa/en/ar strings added.
No backend/migration changes — purely wiring the existing capability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 09:56:14 +03:30
soroush.asadi 97bd63015f docs(website): knowledge-base guides for notifications & roles + sitemap docs pages
CI/CD / CI · API (dotnet build + test) (push) Successful in 45s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 28s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 48s
CI/CD / Deploy · all services (push) Successful in 1m51s
The recent dashboard features shipped without knowledge-base coverage. Adds two
fa/en guides at meezi.ir/docs:
- "Notifications & sound" — bell/unread count, configurable sound (chime + volume
  + preview), desktop/Windows popups, browser-tab counter, and click-to-navigate
  to the related page.
- "Roles & permissions" — base roles, defining custom roles via the permission
  matrix (CRUD + sensitive actions), assigning them, and how page/action access
  is enforced.

Also fixes a standing SEO gap: the sitemap listed only /docs, never the
per-feature /docs/{slug} pages — now all guide pages (fa+en) are included so the
whole knowledge base is crawlable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 07:19:23 +03:30
soroush.asadi 3dfcb1585b feat(notifications): click a notification to jump to its related page
CI/CD / CI · API (dotnet build + test) (push) Successful in 42s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 28s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m45s
Every notification surface now deep-links to where the staff member needs to act:
- bell dropdown: clicking an actionable notification navigates and closes the
  dropdown (platform broadcasts still expand inline to show their text)
- notifications page: rows navigate to the right page
- in-app toast: gains a "View" action button
- desktop/Windows popup: clicking it focuses the tab and navigates

Routing is now permission-aware via a single resolver (notification-routes.ts):
a new-order alert sends a kitchen user to /kds, a cashier to /pos, and a floor
user to /tables — never to a page their role can't open; a waiter call → /tables.
This also fixes the old bug where table_call_waiter (which carries a referenceId)
wrongly routed to /kds. Toast/desktop clicks navigate client-side through a small
event bridge mounted in the dashboard shell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 06:08:18 +03:30
soroush.asadi 2cff5051ac feat(rbac): gate pages and action buttons in the UI by permission
CI/CD / CI · API (dotnet build + test) (push) Successful in 39s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 28s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m45s
Nav already hides pages a role can't view (NAV_REQUIRED_PERMISSION). This wraps
the sensitive/CRUD action controls in <Can permission> so users only see what
they can do (server still enforces):

- POS/orders: void → VoidOrder, cancel → VoidOrder, transfer → EditOrder,
  pay/split → HandlePayments
- menu/inventory/coupons/customers/reservations/expenses/taxes/branches:
  add/edit/delete buttons → the matching Create/Edit/Delete permission
- reports CSV export → ExportReports; SMS send → SendSms, settings → ManageSmsSettings
- home dashboard: revenue/orders KPI queries gated on ViewReports so non-report
  roles don't 403 on the landing page

(Refund/discount/comp/cash-drawer have no UI control yet — no buttons to gate.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:58:56 +03:30
soroush.asadi 53d90fa357 feat(rbac): full permission catalog in the custom-role matrix UI (fa/en/ar)
CI/CD / CI · API (dotnet build + test) (push) Successful in 42s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m7s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 3m24s
Mirrors the expanded backend catalog on the client: the Permission type and the
custom-role permission matrix now expose all ~80 capabilities grouped into 16
sections (admin, branches, menu, inventory, taxes, staff, tables, orders,
register, queue/kitchen, delivery, customers, coupons, marketing, reports,
expenses), each with fa/en/ar labels. Nav visibility now maps each page to its
View permission; taxes & branches become permission-driven (managers can view),
leaving billing as the sole hard owner-only nav gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:49:11 +03:30
soroush.asadi 7a5ea75b50 feat(rbac): enforce permissions on every café write endpoint
CI/CD / CI · API (dotnet build + test) (push) Successful in 40s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Has been cancelled
CI/CD / Deploy · all services (push) Has been cancelled
Closes the gap where the custom-role matrix was defined but unenforced — most
write endpoints only checked café membership, so the API would accept writes a
role's UI hid. Adds EnsurePermission(...) to all mutating/sensitive endpoints
across 32 controllers, mapped to the granular catalog:

- menu/inventory/coupons/customers/expenses/reservations/taxes/branches → CRUD perms
- tables/queue/kitchen-stations/print-settings → manage perms
- orders → ProcessOrders / EditOrder / VoidOrder / UpdateOrderStatus / HandlePayments,
  payment corrections → ManageFinancials
- HR → CreateStaff / ManageSchedules / ReviewLeave / View+ManageSalaries /
  ManageStaffCredentials (self-service clock-in/leave preserved)
- reports → ViewReports, export → ExportReports, audit → ViewAuditLog
- billing → ManageBilling, sms → SendSms/ManageSmsSettings, reviews → ManageReviews,
  discover/public profile → ManageDiscoverProfile, café settings → ManageCafeSettings,
  custom roles → ManageRoles

Removes legacy [Authorize(Roles=...)] attributes that would have overridden the
permission model (orders, branch-menu, pos-device, print). Manual discount/comp
have no backend endpoint yet (discounts come from coupons) — gated on the POS UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:43:07 +03:30
soroush.asadi 236013f53c feat(rbac): full-CRUD permission catalog + per-role matrix
CI/CD / CI · API (dotnet build + test) (push) Successful in 55s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 1m33s
Expands the authorization catalog from 21 coarse page-level permissions to a
granular set: View/Create/Edit/Delete per record module, plus distinct
permissions for sensitive actions (VoidOrder, RefundOrder, ApplyDiscount,
CompOrder, OpenCashDrawer, ExportReports) and the previously-uncovered pages
(customers/CRM, SMS, reviews, financials, audit log, attendance, schedules).

RolePermissions now derives Manager as "everything except owner-only governance"
and gives Cashier/Waiter/Chef/Delivery sensible day-to-day defaults; owners
refine further via custom roles. Effective permissions already flow to the
client through AuthService, so no token-shape change is needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:27:02 +03:30
soroush.asadi 170a9aa7ac feat(dashboard): Notifications & sound settings panel (fa/en/ar)
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m5s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 39s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 3m46s
New Settings → "Notifications & sound" leaf to make the alert channels
changeable: toggle sound (+ picker with live preview + volume slider),
enable desktop notifications (permission flow + test button), toggle the
tab unread badge and in-app toasts. Strings added for fa/en/ar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:08:39 +03:30
166 changed files with 17202 additions and 3898 deletions
+12 -2
View File
@@ -23,8 +23,10 @@ JWT_KEY=change-me-64-char-random-string-use-openssl-rand-hex-32-output
NEXT_PUBLIC_API_URL=http://171.22.25.73:5080
NEXT_PUBLIC_ADMIN_API_URL=http://171.22.25.73:5081
NEXT_PUBLIC_SITE_URL=http://171.22.25.73:3010
NEXT_PUBLIC_KOJA_URL=http://171.22.25.73:3103
# Public site origin — MUST be the real domain in prod (used for canonical URLs,
# sitemap, robots, OG tags). A wrong value here de-indexes the whole site in GSC.
NEXT_PUBLIC_SITE_URL=https://meezi.ir
NEXT_PUBLIC_KOJA_URL=https://koja.meezi.ir
APP_QR_BASE_URL=http://171.22.25.73:3101
BILLING_DASHBOARD_URL=http://171.22.25.73:3101
@@ -81,6 +83,14 @@ SEED_ADMIN_PASSWORD=change-me-strong-admin-password
ZARINPAL_MERCHANT_ID=
ZARINPAL_SANDBOX=false
# ── Payment: FlatRender Pay (ZarinPal broker) ─────────────────────────────────
# Broker keys from the FlatRender dashboard. Webhook is registered at the broker as
# https://api.meezi.ir/api/payment/webhook. Keep the live secret OUT of git.
FLATPAY_API_KEY=
FLATPAY_SECRET=
FLATPAY_BASE_URL=https://pay.flatrender.ir
FLATPAY_RETURN_URL=https://meezi.ir/payment/return
# ── SMS: Kavenegar ────────────────────────────────────────────────────────────
# Empty = OTP is logged to API console (fine for dev, not for production)
KAVENEGAR_API_KEY=4C30786935496261332B41685870444E47657A5367453369374F6E2F43334672576B526F5A4B4B795665493D
+40
View File
@@ -0,0 +1,40 @@
using System.Text.Json;
namespace Meezi.PrintAgent;
/// <summary>Persisted agent identity — written to %APPDATA%\MeeziPrintAgent\config.json.</summary>
public class AgentConfig
{
/// <summary>Origin of the Meezi API, e.g. https://app.meezi.ir.</summary>
public string? ApiBaseUrl { get; set; }
public string? Token { get; set; }
public string? CafeId { get; set; }
public string? AgentId { get; set; }
public string? Name { get; set; }
private static string Dir =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "MeeziPrintAgent");
private static string FilePath => Path.Combine(Dir, "config.json");
public bool IsPaired => !string.IsNullOrWhiteSpace(Token) && !string.IsNullOrWhiteSpace(ApiBaseUrl);
public static AgentConfig Load()
{
try
{
if (File.Exists(FilePath))
return JsonSerializer.Deserialize<AgentConfig>(File.ReadAllText(FilePath)) ?? new AgentConfig();
}
catch
{
// corrupt/unreadable config → start fresh
}
return new AgentConfig();
}
public void Save()
{
Directory.CreateDirectory(Dir);
File.WriteAllText(FilePath, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }));
}
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<!-- Windows-only: uses winspool (raw printing) + WMI (printer discovery).
Overrides the repo-wide net10.0 / central package management on purpose so
this app stays independent of the API build (CI never compiles it). -->
<TargetFramework>net10.0-windows</TargetFramework>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Meezi.PrintAgent</RootNamespace>
<AssemblyName>MeeziPrintAgent</AssemblyName>
<Version>0.1.0</Version>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0" />
<PackageReference Include="System.Management" Version="10.0.0" />
</ItemGroup>
</Project>
+106
View File
@@ -0,0 +1,106 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
namespace Meezi.PrintAgent;
/// <summary>A host on the café LAN answering on a probed port. Property names match
/// the cloud's <c>DiscoveredDevice</c> record so SignalR maps them across.</summary>
public record ScannedDevice(string Ip, int Port, string Kind);
/// <summary>
/// Scans the agent PC's local /24 subnet(s) for hosts answering on the given TCP
/// ports — used to auto-find network printers (:9100) and card terminals (:8088)
/// so the café owner doesn't have to type IP addresses.
/// </summary>
public static class NetworkScanner
{
private const int MaxConcurrency = 128;
private const int ConnectTimeoutMs = 300;
public static async Task<List<ScannedDevice>> ScanAsync(string portsCsv, CancellationToken ct)
{
var ports = portsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(p => int.TryParse(p, out var n) ? n : 0)
.Where(n => n is > 0 and <= 65535)
.Distinct()
.ToList();
if (ports.Count == 0) ports = [9100, 8088];
var results = new ConcurrentBag<ScannedDevice>();
using var gate = new SemaphoreSlim(MaxConcurrency);
var tasks = new List<Task>();
foreach (var prefix in LocalSubnets())
{
for (var host = 1; host <= 254; host++)
{
var ip = $"{prefix}.{host}";
foreach (var port in ports)
{
await gate.WaitAsync(ct);
tasks.Add(Task.Run(async () =>
{
try
{
if (await CanConnectAsync(ip, port))
results.Add(new ScannedDevice(ip, port, Classify(port)));
}
finally { gate.Release(); }
}, ct));
}
}
}
await Task.WhenAll(tasks);
return results
.DistinctBy(d => $"{d.Ip}:{d.Port}")
.OrderBy(d => d.Ip)
.ThenBy(d => d.Port)
.ToList();
}
/// <summary>Distinct /24 prefixes of this PC's up, non-loopback IPv4 interfaces.</summary>
private static IEnumerable<string> LocalSubnets()
{
var seen = new HashSet<string>();
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
{
if (ni.OperationalStatus != OperationalStatus.Up) continue;
foreach (var ua in ni.GetIPProperties().UnicastAddresses)
{
if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue;
if (IPAddress.IsLoopback(ua.Address)) continue;
var b = ua.Address.GetAddressBytes();
var prefix = $"{b[0]}.{b[1]}.{b[2]}";
if (seen.Add(prefix)) yield return prefix;
}
}
}
private static async Task<bool> CanConnectAsync(string ip, int port)
{
try
{
using var client = new TcpClient();
var connect = client.ConnectAsync(ip, port);
var done = await Task.WhenAny(connect, Task.Delay(ConnectTimeoutMs));
if (done != connect) return false;
await connect; // observe exceptions
return client.Connected;
}
catch
{
return false;
}
}
private static string Classify(int port) => port switch
{
9100 => "network-printer",
8088 => "pos-terminal",
_ => "other",
};
}
+43
View File
@@ -0,0 +1,43 @@
using System.Net.Http.Json;
namespace Meezi.PrintAgent;
/// <summary>Redeems a one-time pairing code for a long-lived agent token.</summary>
public static class Pairing
{
private record ClaimReq(string code, string? name, string? machineName);
private record ApiEnvelope<T>(bool success, T? data);
private record ClaimData(string agentId, string token, string cafeId, string agentName);
public static async Task<AgentConfig?> ClaimAsync(string apiBaseUrl, string code, string name)
{
using var http = new HttpClient { BaseAddress = new Uri(apiBaseUrl), Timeout = TimeSpan.FromSeconds(20) };
HttpResponseMessage resp;
try
{
resp = await http.PostAsJsonAsync("/api/print-agent/claim",
new ClaimReq(code, name, Environment.MachineName));
}
catch (Exception ex)
{
Console.WriteLine($" network error: {ex.Message}");
return null;
}
if (!resp.IsSuccessStatusCode)
return null;
var env = await resp.Content.ReadFromJsonAsync<ApiEnvelope<ClaimData>>();
if (env?.success != true || env.data is null)
return null;
return new AgentConfig
{
ApiBaseUrl = apiBaseUrl.TrimEnd('/'),
Token = env.data.token,
CafeId = env.data.cafeId,
AgentId = env.data.agentId,
Name = env.data.agentName,
};
}
}
+35
View File
@@ -0,0 +1,35 @@
using System.Net.Http.Json;
namespace Meezi.PrintAgent;
/// <summary>
/// Relays a card-terminal payment on the café LAN. The cloud can't reach the
/// terminal's private IP, so it hands the agent the amount and the terminal's
/// ip:port; the agent POSTs to the terminal's local HTTP <c>/pay</c> endpoint and
/// reports back whether it was approved.
/// </summary>
public static class PosTerminal
{
// A card payment blocks on the customer inserting/approving — allow plenty.
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(90) };
public static async Task<(bool Ok, string? Error)> SendPaymentAsync(
string ip, int port, long amount, string orderId, CancellationToken ct)
{
var url = $"http://{ip}:{port}/pay";
try
{
using var resp = await Http.PostAsJsonAsync(url, new { amount, orderId }, ct);
if (resp.IsSuccessStatusCode) return (true, null);
return (false, $"POS_DEVICE_REJECTED:HTTP {(int)resp.StatusCode}");
}
catch (TaskCanceledException)
{
return (false, "POS_DEVICE_TIMEOUT");
}
catch (Exception ex)
{
return (false, $"POS_DEVICE_CONNECTION_FAILED:{ex.Message}");
}
}
}
@@ -0,0 +1,45 @@
using System.Management;
using System.Runtime.Versioning;
namespace Meezi.PrintAgent;
/// <summary>One printer the agent can reach. SystemName is what it prints to (the
/// Windows printer name, or "ip:port" for a raw network device).</summary>
public record DiscoveredPrinter(string SystemName, string DisplayName, string Kind);
[SupportedOSPlatform("windows")]
public static class PrinterDiscovery
{
/// <summary>Every printer installed on this PC (USB and network-with-driver alike).</summary>
public static List<DiscoveredPrinter> Discover()
{
var list = new List<DiscoveredPrinter>();
try
{
using var searcher = new ManagementObjectSearcher(
"SELECT Name, PortName, Network FROM Win32_Printer");
foreach (var o in searcher.Get())
{
using var p = (ManagementObject)o;
var name = p["Name"]?.ToString();
if (string.IsNullOrWhiteSpace(name)) continue;
var port = p["PortName"]?.ToString() ?? "";
var network = p["Network"] as bool? ?? false;
list.Add(new DiscoveredPrinter(name!, name!, ClassifyKind(port, network)));
}
}
catch
{
// WMI unavailable — report nothing rather than crash.
}
return list;
}
private static string ClassifyKind(string port, bool network)
{
var up = port.ToUpperInvariant();
if (up.StartsWith("USB") || up.StartsWith("DOT4")) return "usb";
if (network || up.StartsWith("IP_") || up.StartsWith("WSD") || up.Contains(':')) return "network";
return "other";
}
}
+164
View File
@@ -0,0 +1,164 @@
using Meezi.PrintAgent;
using Microsoft.AspNetCore.SignalR.Client;
Console.OutputEncoding = System.Text.Encoding.UTF8;
Console.WriteLine("=== Meezi Print Agent (پرینت‌سرور میزی) ===");
var config = AgentConfig.Load();
var wantsPair = args.Length > 0 && args[0].Equals("pair", StringComparison.OrdinalIgnoreCase);
if (!config.IsPaired || wantsPair)
{
var paired = await PairInteractiveAsync(config);
if (paired is null)
{
Console.WriteLine("Pairing cancelled or failed.");
return 1;
}
paired.Save();
config = paired;
Console.WriteLine($"✓ Paired as '{config.Name}'. Configuration saved.");
}
await RunAsync(config);
return 0;
static async Task<AgentConfig?> PairInteractiveAsync(AgentConfig existing)
{
var defaultUrl = existing.ApiBaseUrl ?? "https://app.meezi.ir";
Console.Write($"Meezi API URL [{defaultUrl}]: ");
var url = Console.ReadLine();
if (string.IsNullOrWhiteSpace(url)) url = defaultUrl;
Console.Write("Pairing code (from Dashboard → Settings → Printers): ");
var code = Console.ReadLine()?.Trim();
if (string.IsNullOrWhiteSpace(code)) return null;
Console.Write($"Name for this PC [{Environment.MachineName}]: ");
var name = Console.ReadLine();
if (string.IsNullOrWhiteSpace(name)) name = Environment.MachineName;
Console.WriteLine("Pairing…");
var cfg = await Pairing.ClaimAsync(url!, code!, name!);
if (cfg is null) Console.WriteLine(" Invalid/expired code, or the URL is wrong.");
return cfg;
}
static async Task RunAsync(AgentConfig config)
{
var hubUrl = $"{config.ApiBaseUrl!.TrimEnd('/')}/hubs/print-agent" +
$"?access_token={Uri.EscapeDataString(config.Token!)}";
var connection = new HubConnectionBuilder()
.WithUrl(hubUrl)
.WithAutomaticReconnect(new ForeverRetry())
.Build();
connection.On<string, string, string>("PrintJob", async (jobId, printerSystemName, base64) =>
{
var ok = false;
string? err = null;
try
{
var data = Convert.FromBase64String(base64);
await RawPrinter.PrintAsync(printerSystemName, data, CancellationToken.None);
ok = true;
Console.WriteLine($"[print] {data.Length} bytes → {printerSystemName} ✓");
}
catch (Exception ex)
{
err = ex.Message;
Console.WriteLine($"[print] {printerSystemName} ✗ {ex.Message}");
}
try { await connection.InvokeAsync("JobResult", jobId, ok, err); } catch { /* ack best-effort */ }
});
// Cloud → agent: relay a card-terminal payment to the terminal on the LAN.
connection.On<string, string, int, long, string>("PaymentRequest", async (requestId, ip, port, amount, orderId) =>
{
var (ok, err) = await PosTerminal.SendPaymentAsync(ip, port, amount, orderId, CancellationToken.None);
Console.WriteLine(ok
? $"[pay] {amount} → {ip}:{port} ✓"
: $"[pay] {ip}:{port} ✗ {err}");
try { await connection.InvokeAsync("PaymentResult", requestId, ok, err); } catch { /* ack best-effort */ }
});
// Cloud → agent: scan the LAN for hosts on the given ports (printers :9100, terminals :8088).
connection.On<string, string>("ScanNetwork", async (requestId, ports) =>
{
List<ScannedDevice> found;
try { found = await NetworkScanner.ScanAsync(ports, CancellationToken.None); }
catch (Exception ex) { Console.WriteLine($"[scan] failed: {ex.Message}"); found = []; }
Console.WriteLine($"[scan] ports={ports} → {found.Count} host(s): " +
string.Join(", ", found.Select(d => $"{d.Ip}:{d.Port}")));
try { await connection.InvokeAsync("ReportScan", requestId, found); } catch { /* best-effort */ }
});
connection.Reconnected += async _ =>
{
Console.WriteLine("[hub] reconnected");
await SafeReportAsync(connection);
};
connection.Closed += _ =>
{
Console.WriteLine("[hub] connection closed");
return Task.CompletedTask;
};
await ConnectWithRetryAsync(connection);
Console.WriteLine("[hub] connected");
await SafeReportAsync(connection);
// Heartbeat + re-report every 2 minutes (printers added/removed get picked up).
_ = Task.Run(async () =>
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(2));
while (await timer.WaitForNextTickAsync())
{
try
{
await connection.InvokeAsync("Heartbeat");
await SafeReportAsync(connection);
}
catch { /* will recover on reconnect */ }
}
});
Console.WriteLine("Agent running. Leave this window open. Press Ctrl+C to quit.");
await Task.Delay(Timeout.Infinite);
}
static async Task SafeReportAsync(HubConnection connection)
{
try
{
var printers = PrinterDiscovery.Discover();
await connection.InvokeAsync("ReportPrinters", printers);
Console.WriteLine($"[printers] reported {printers.Count}: " +
string.Join(", ", printers.Select(p => $"{p.DisplayName} ({p.Kind})")));
}
catch (Exception ex)
{
Console.WriteLine($"[printers] report failed: {ex.Message}");
}
}
static async Task ConnectWithRetryAsync(HubConnection connection)
{
while (true)
{
try { await connection.StartAsync(); return; }
catch (Exception ex)
{
Console.WriteLine($"[hub] connect failed: {ex.Message}; retrying in 5s");
await Task.Delay(5000);
}
}
}
/// <summary>Retry reconnecting forever with capped exponential backoff.</summary>
sealed class ForeverRetry : IRetryPolicy
{
public TimeSpan? NextRetryDelay(RetryContext ctx) =>
TimeSpan.FromSeconds(Math.Min(30, Math.Pow(2, Math.Min(ctx.PreviousRetryCount, 5))));
}
+95
View File
@@ -0,0 +1,95 @@
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace Meezi.PrintAgent;
/// <summary>Writes raw ESC/POS bytes to a printer — by Windows name (winspool RAW
/// passthrough) or to an "ip:port" endpoint (raw TCP).</summary>
[SupportedOSPlatform("windows")]
public static class RawPrinter
{
public static async Task PrintAsync(string systemName, byte[] data, CancellationToken ct)
{
if (TryParseEndpoint(systemName, out var ip, out var port))
{
await PrintTcpAsync(ip, port, data, ct);
return;
}
if (!SendBytesToPrinter(systemName, data))
throw new Exception($"winspool write failed (last error {Marshal.GetLastWin32Error()})");
}
private static bool TryParseEndpoint(string s, out string ip, out int port)
{
ip = "";
port = 9100;
var idx = s.LastIndexOf(':');
if (idx <= 0) return false;
var host = s[..idx];
if (!host.Contains('.')) return false; // not an IPv4-ish host → treat as printer name
if (int.TryParse(s[(idx + 1)..], out var p)) port = p;
ip = host;
return true;
}
private static async Task PrintTcpAsync(string ip, int port, byte[] data, CancellationToken ct)
{
using var client = new TcpClient();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(8));
await client.ConnectAsync(ip, port, cts.Token);
await using var stream = client.GetStream();
await stream.WriteAsync(data, cts.Token);
await stream.FlushAsync(cts.Token);
}
// ── winspool raw printing ────────────────────────────────────────────────
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct DOCINFOW
{
[MarshalAs(UnmanagedType.LPWStr)] public string pDocName;
[MarshalAs(UnmanagedType.LPWStr)] public string? pOutputFile;
[MarshalAs(UnmanagedType.LPWStr)] public string pDataType;
}
[DllImport("winspool.drv", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool OpenPrinter(string src, out IntPtr hPrinter, IntPtr pd);
[DllImport("winspool.drv", SetLastError = true)]
private static extern bool ClosePrinter(IntPtr hPrinter);
[DllImport("winspool.drv", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool StartDocPrinter(IntPtr hPrinter, int level, ref DOCINFOW di);
[DllImport("winspool.drv", SetLastError = true)]
private static extern bool EndDocPrinter(IntPtr hPrinter);
[DllImport("winspool.drv", SetLastError = true)]
private static extern bool StartPagePrinter(IntPtr hPrinter);
[DllImport("winspool.drv", SetLastError = true)]
private static extern bool EndPagePrinter(IntPtr hPrinter);
[DllImport("winspool.drv", SetLastError = true)]
private static extern bool WritePrinter(IntPtr hPrinter, IntPtr pBytes, int dwCount, out int dwWritten);
private static bool SendBytesToPrinter(string printerName, byte[] bytes)
{
if (!OpenPrinter(printerName, out var hPrinter, IntPtr.Zero)) return false;
try
{
var di = new DOCINFOW { pDocName = "Meezi Receipt", pDataType = "RAW" };
if (!StartDocPrinter(hPrinter, 1, ref di)) return false;
try
{
if (!StartPagePrinter(hPrinter)) return false;
var ptr = Marshal.AllocHGlobal(bytes.Length);
try
{
Marshal.Copy(bytes, 0, ptr, bytes.Length);
if (!WritePrinter(hPrinter, ptr, bytes.Length, out _)) return false;
}
finally { Marshal.FreeHGlobal(ptr); }
EndPagePrinter(hPrinter);
}
finally { EndDocPrinter(hPrinter); }
}
finally { ClosePrinter(hPrinter); }
return true;
}
}
+43
View File
@@ -0,0 +1,43 @@
# Meezi Print Agent (پرینت‌سرور میزی)
A tiny Windows background app that lets the **cloud-hosted** Meezi reach printers on
the café's **local network** (USB or Wi-Fi/Ethernet). The cloud can't open a
connection to a `192.168.x.x` or USB printer directly — this agent runs on the cash
PC (which *is* on that network), connects **outward** to Meezi over SignalR, reports
the printers it can see, and prints the jobs the cloud sends it.
```
Cloud API ──SignalR(out)──► Print Agent (cash PC) ──► USB / LAN printers
```
## How it works
1. In the dashboard: **Settings → Printers → Add print server** → you get a pairing code.
2. Run the agent on the cash PC, enter the code once. It saves a token to
`%APPDATA%\MeeziPrintAgent\config.json` and connects.
3. It reports every printer installed on that PC. Back in the dashboard you map
*receipt / kitchen / bar* to a printer from the dropdown — no IP typing.
4. When Meezi prints, the bytes (ESC/POS) are relayed to the agent, which writes them
raw to the chosen printer (`winspool` for installed printers, raw TCP for
`ip:port` devices).
## Build & run (dev)
Requires the .NET 10 SDK on Windows.
```sh
# restore via the Nexus mirror (nuget.org is blocked on this network)
dotnet restore agent/Meezi.PrintAgent/Meezi.PrintAgent.csproj -s https://mirror.soroushasadi.com/repository/nuget-group/
dotnet run --project agent/Meezi.PrintAgent # first run prompts to pair
dotnet run --project agent/Meezi.PrintAgent -- pair # re-pair later
```
## Publish a single .exe for cafés
```sh
dotnet publish agent/Meezi.PrintAgent -c Release -r win-x64 \
-p:PublishSingleFile=true --self-contained true -o dist/agent
# → dist/agent/MeeziPrintAgent.exe
```
## Notes / roadmap
- **Not part of the API solution or CI** — it targets `net10.0-windows` and builds on its own.
- Console MVP today. Next: system-tray UI, run-at-login (Task Scheduler / service), auto-update, and an optional LAN scan for raw `ip:9100` printers that aren't installed in Windows.
- The token is bearer-equivalent — keep `config.json` on a trusted machine. Revoke from the dashboard if a PC is lost.
+2 -2
View File
@@ -168,7 +168,7 @@ services:
dockerfile: docker/website/Dockerfile
args:
MEEZI_API_URL: http://api:8080
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3010}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://meezi.ir}
container_name: meezi-website
restart: unless-stopped
depends_on:
@@ -178,7 +178,7 @@ services:
PORT: "3000"
HOSTNAME: 0.0.0.0
MEEZI_API_URL: http://api:8080
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3010}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://meezi.ir}
ports:
- "${WEBSITE_PORT:-3010}:3000"
+8 -4
View File
@@ -94,6 +94,10 @@ services:
Snappfood__WebhookSecret: "${SNAPPFOOD_WEBHOOK_SECRET:-meezi-dev-snappfood-secret}"
ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}"
FlatPay__ApiKey: "${FLATPAY_API_KEY:-}"
FlatPay__Secret: "${FLATPAY_SECRET:-}"
FlatPay__BaseUrl: "${FLATPAY_BASE_URL:-https://pay.flatrender.ir}"
FlatPay__ReturnUrl: "${FLATPAY_RETURN_URL:-https://meezi.ir/payment/return}"
Seed__SystemAdminPhone: "${SEED_ADMIN_PHONE:-}"
Seed__SystemAdminUsername: "${SEED_ADMIN_USERNAME:-admin}"
Seed__SystemAdminPassword: "${SEED_ADMIN_PASSWORD:-}"
@@ -139,7 +143,7 @@ services:
NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
MEEZI_API_URL: http://api:8080
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3010}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://meezi.ir}
container_name: meezi-website
restart: unless-stopped
depends_on:
@@ -149,7 +153,7 @@ services:
PORT: "3000"
HOSTNAME: 0.0.0.0
MEEZI_API_URL: http://api:8080
NEXT_PUBLIC_SITE_URL: "${NEXT_PUBLIC_SITE_URL:-http://localhost:3010}"
NEXT_PUBLIC_SITE_URL: "${NEXT_PUBLIC_SITE_URL:-https://meezi.ir}"
ports:
- "${WEBSITE_PORT:-3010}:3000"
@@ -163,7 +167,7 @@ services:
NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_KOJA_URL:-http://localhost:3103}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_KOJA_URL:-https://koja.meezi.ir}
container_name: meezi-koja
restart: unless-stopped
depends_on:
@@ -173,7 +177,7 @@ services:
PORT: "3000"
HOSTNAME: 0.0.0.0
NEXT_PUBLIC_API_URL: "${NEXT_PUBLIC_API_URL:-http://localhost:5080}"
NEXT_PUBLIC_SITE_URL: "${NEXT_PUBLIC_KOJA_URL:-http://localhost:3103}"
NEXT_PUBLIC_SITE_URL: "${NEXT_PUBLIC_KOJA_URL:-https://koja.meezi.ir}"
ports:
- "${KOJA_PORT:-3103}:3000"
+1 -1
View File
@@ -23,7 +23,7 @@ FROM ${NODE_IMAGE} AS builder
WORKDIR /app
ARG MEEZI_API_URL=http://api:8080
ARG NEXT_PUBLIC_SITE_URL=http://localhost:3010
ARG NEXT_PUBLIC_SITE_URL=https://meezi.ir
ENV MEEZI_API_URL=$MEEZI_API_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
+41 -15
View File
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Audit;
using Meezi.Core.Authorization;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Meezi.Shared;
@@ -42,7 +43,7 @@ public class AuditController : CafeApiControllerBase
[FromQuery] int pageSize = 50)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ViewReports) is { } forbidden) return forbidden;
if (EnsurePermission(tenant, Permission.ViewAuditLog) is { } forbidden) return forbidden;
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 50;
@@ -67,25 +68,50 @@ public class AuditController : CafeApiControllerBase
var total = await query.CountAsync(ct);
var items = await query
var rows = await query
.OrderByDescending(x => x.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new AuditLogDto(
x.Id,
x.Category,
x.Action,
x.EntityType,
x.EntityId,
x.BranchId,
x.ActorId,
x.ActorName,
x.ActorRole,
x.Summary,
x.DetailsJson,
x.CreatedAt))
.Select(x => new
{
x.Id, x.Category, x.Action, x.EntityType, x.EntityId, x.BranchId,
x.ActorId, x.ActorName, x.ActorRole, x.Summary, x.DetailsJson, x.CreatedAt
})
.ToListAsync(ct);
// Resolve the actor's CURRENT full name + role from the employee record.
// This fixes historical rows (where ActorName was never stored) and keeps
// names current. IgnoreQueryFilters so we still name soft-deleted staff.
var actorIds = rows
.Where(r => !string.IsNullOrEmpty(r.ActorId))
.Select(r => r.ActorId!)
.Distinct()
.ToList();
var employees = actorIds.Count == 0
? new Dictionary<string, (string Name, EmployeeRole Role)>()
: (await _db.Employees
.IgnoreQueryFilters()
.AsNoTracking()
.Where(e => e.CafeId == cafeId && actorIds.Contains(e.Id))
.Select(e => new { e.Id, e.Name, e.Role })
.ToListAsync(ct))
.ToDictionary(e => e.Id, e => (e.Name, e.Role));
var items = rows.Select(r =>
{
string? name = r.ActorName;
string? role = r.ActorRole;
if (!string.IsNullOrEmpty(r.ActorId) && employees.TryGetValue(r.ActorId, out var emp))
{
name = emp.Name; // prefer the live employee name
role ??= emp.Role.ToString();
}
return new AuditLogDto(
r.Id, r.Category, r.Action, r.EntityType, r.EntityId, r.BranchId,
r.ActorId, name, role, r.Summary, r.DetailsJson, r.CreatedAt);
}).ToList();
return Ok(new PagedApiResponse<AuditLogDto>(true, items, new PagedMeta(total, page, pageSize)));
}
}
@@ -3,13 +3,14 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Billing;
using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[ApiController]
public class BillingController : ControllerBase
public class BillingController : CafeApiControllerBase
{
private readonly IBillingService _billing;
private readonly IValidator<SubscribeRequest> _subscribeValidator;
@@ -27,13 +28,9 @@ public class BillingController : ControllerBase
ITenantContext tenant,
CancellationToken ct)
{
if (EnsurePermission(tenant, Permission.ManageBilling) is { } permDenied) return permDenied;
if (string.IsNullOrEmpty(tenant.CafeId))
return Unauthorized();
if (tenant.Role != Core.Enums.EmployeeRole.Owner)
{
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("OWNER_REQUIRED", "Only the cafe owner can manage subscription billing.")));
}
var validation = await _subscribeValidator.ValidateAsync(request, ct);
if (!validation.IsValid)
@@ -108,11 +105,9 @@ public class BillingController : ControllerBase
[HttpDelete("api/billing/queued/{paymentId}")]
public async Task<IActionResult> CancelQueued(string paymentId, ITenantContext tenant, CancellationToken ct)
{
if (EnsurePermission(tenant, Permission.ManageBilling) is { } permDenied) return permDenied;
if (string.IsNullOrEmpty(tenant.CafeId))
return Unauthorized();
if (tenant.Role != Core.Enums.EmployeeRole.Owner)
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("OWNER_REQUIRED", "Only the cafe owner can manage subscription billing.")));
var (ok, code, message) = await _billing.CancelQueuedAsync(tenant.CafeId, paymentId, ct);
if (!ok)
@@ -1,8 +1,8 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Menu;
using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Shared;
@@ -43,7 +43,6 @@ public class BranchMenuController : CafeApiControllerBase
}
[HttpPut("{menuItemId}/override")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> UpsertOverride(
string cafeId,
string branchId,
@@ -53,8 +52,7 @@ public class BranchMenuController : CafeApiControllerBase
CancellationToken cancellationToken = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (!BranchMenuService.CanManageOverrides(tenant.Role))
return Forbid();
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
var validation = await _upsertValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -84,7 +82,6 @@ public class BranchMenuController : CafeApiControllerBase
}
[HttpDelete("{menuItemId}/override")]
[Authorize(Roles = "Owner")]
public async Task<IActionResult> DeleteOverride(
string cafeId,
string branchId,
@@ -93,6 +90,7 @@ public class BranchMenuController : CafeApiControllerBase
CancellationToken cancellationToken = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
var deleted = await _branchMenu.DeleteOverrideAsync(
cafeId, branchId, menuItemId, cancellationToken);
@@ -1,7 +1,7 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Printing;
using Meezi.Core.Authorization;
using Meezi.Core.Entities;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
@@ -11,7 +11,6 @@ using Microsoft.EntityFrameworkCore;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/branches/{branchId}/print-settings")]
[Authorize(Roles = "Manager,Owner")]
public class BranchPrintSettingsController : CafeApiControllerBase
{
private readonly AppDbContext _db;
@@ -54,6 +53,7 @@ public class BranchPrintSettingsController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
var validation = await _validator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -91,6 +91,14 @@ public class BranchPrintSettingsController : CafeApiControllerBase
: request.PosDeviceIp.Trim();
if (request.PosDevicePort.HasValue)
branch.PosDevicePort = request.PosDevicePort.Value;
if (request.ReceiptPrintDeviceId is not null)
branch.ReceiptPrintDeviceId = string.IsNullOrWhiteSpace(request.ReceiptPrintDeviceId)
? null
: request.ReceiptPrintDeviceId;
if (request.KitchenPrintDeviceId is not null)
branch.KitchenPrintDeviceId = string.IsNullOrWhiteSpace(request.KitchenPrintDeviceId)
? null
: request.KitchenPrintDeviceId;
branch.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
@@ -110,5 +118,7 @@ public class BranchPrintSettingsController : CafeApiControllerBase
b.ReceiptFooter,
b.WifiPassword,
b.PosDeviceIp,
b.PosDevicePort);
b.PosDevicePort,
b.ReceiptPrintDeviceId,
b.KitchenPrintDeviceId);
}
@@ -1,8 +1,8 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Tables;
using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Shared;
@@ -68,7 +68,6 @@ public class BranchTablesController : CafeApiControllerBase
}
[HttpPost]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> CreateTable(
string cafeId,
string branchId,
@@ -77,6 +76,7 @@ public class BranchTablesController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
return Forbid();
@@ -88,7 +88,6 @@ public class BranchTablesController : CafeApiControllerBase
}
[HttpPatch("{id}")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> PatchTable(
string cafeId,
string branchId,
@@ -98,6 +97,7 @@ public class BranchTablesController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
return Forbid();
@@ -109,7 +109,6 @@ public class BranchTablesController : CafeApiControllerBase
}
[HttpDelete("{id}")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> DeleteTable(
string cafeId,
string branchId,
@@ -118,6 +117,7 @@ public class BranchTablesController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
return Forbid();
@@ -135,6 +135,7 @@ public class BranchTablesController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
return Forbid();
@@ -180,7 +181,6 @@ public class BranchTablesController : CafeApiControllerBase
}
[HttpPost("sections")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> CreateSection(
string cafeId,
string branchId,
@@ -189,6 +189,7 @@ public class BranchTablesController : CafeApiControllerBase
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
return Forbid();
@@ -200,7 +201,6 @@ public class BranchTablesController : CafeApiControllerBase
}
[HttpPatch("sections/{sectionId}")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> PatchSection(
string cafeId,
string branchId,
@@ -210,6 +210,7 @@ public class BranchTablesController : CafeApiControllerBase
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
return Forbid();
@@ -221,7 +222,6 @@ public class BranchTablesController : CafeApiControllerBase
}
[HttpDelete("sections/{sectionId}")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> DeleteSection(
string cafeId,
string branchId,
@@ -230,6 +230,7 @@ public class BranchTablesController : CafeApiControllerBase
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
return Forbid();
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Branches;
using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Constants;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
@@ -96,7 +97,7 @@ public class BranchesController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
if (EnsurePermission(tenant, Permission.CreateBranch) is { } permDenied) return permDenied;
var validation = await _createValidator.ValidateAsync(request, ct);
if (!validation.IsValid)
@@ -169,7 +170,7 @@ public class BranchesController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
if (EnsurePermission(tenant, Permission.EditBranch) is { } permDenied) return permDenied;
var validation = await _patchValidator.ValidateAsync(request, ct);
if (!validation.IsValid)
@@ -222,7 +223,7 @@ public class BranchesController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
if (EnsurePermission(tenant, Permission.DeleteBranch) is { } permDenied) return permDenied;
var (ok, code, message) = await _lifecycle.ScheduleDeletionAsync(cafeId, branchId, ct);
if (!ok)
@@ -257,7 +258,7 @@ public class BranchesController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
if (EnsurePermission(tenant, Permission.EditBranch) is { } permDenied) return permDenied;
var (ok, code, message) = await _lifecycle.RestoreAsync(cafeId, branchId, ct);
if (!ok)
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Discover;
using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
@@ -45,6 +46,7 @@ public class CafeDiscoverProfileController : CafeApiControllerBase
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied)
return denied;
if (EnsurePermission(tenant, Permission.ManageDiscoverProfile) is { } permDenied) return permDenied;
var planTier = tenant.PlanTier ?? PlanTier.Free;
if (!await catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, "discover_profile", ct))
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Public;
using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Discover;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
@@ -71,6 +72,7 @@ public class CafePublicProfileController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageDiscoverProfile) is { } permDenied) return permDenied;
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
if (cafe is null)
@@ -121,6 +123,7 @@ public class CafePublicProfileController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageDiscoverProfile) is { } permDenied) return permDenied;
if (photo is null || photo.Length == 0)
return BadRequest(Fail("NO_FILE", "No photo provided."));
@@ -155,6 +158,7 @@ public class CafePublicProfileController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageDiscoverProfile) is { } permDenied) return permDenied;
if (string.IsNullOrWhiteSpace(url))
return BadRequest(Fail("NO_URL", "Provide ?url= of the photo to remove."));
@@ -2,6 +2,7 @@ using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Public;
using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Services.Platform;
@@ -48,6 +49,7 @@ public class CafeReviewsController : CafeApiControllerBase
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageReviews) is { } permDenied) return permDenied;
// Replying to reviews is a paid feature (Starter+).
var tier = tenant.PlanTier ?? PlanTier.Free;
@@ -76,6 +78,7 @@ public class CafeReviewsController : CafeApiControllerBase
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageReviews) is { } permDenied) return permDenied;
var data = await _reviews.SetHiddenAsync(cafeId, reviewId, request.IsHidden, ct);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<CafeReviewDto>(true, data));
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Cafes;
using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Core.Utilities;
@@ -47,6 +48,7 @@ public class CafeSettingsController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageCafeSettings) is { } permDenied) return permDenied;
var validation = await _validator.ValidateAsync(request, ct);
if (!validation.IsValid)
@@ -2,6 +2,7 @@ using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Crm;
using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces;
using Meezi.Shared;
@@ -62,6 +63,7 @@ public class CouponsController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.CreateCoupon) is { } permDenied) return permDenied;
var validation = await _createValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -82,6 +84,7 @@ public class CouponsController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.EditCoupon) is { } permDenied) return permDenied;
var data = await _couponService.UpdateAsync(cafeId, id, request, cancellationToken);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<CouponDto>(true, data));
@@ -95,6 +98,7 @@ public class CouponsController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.DeleteCoupon) is { } permDenied) return permDenied;
var deleted = await _couponService.DeleteAsync(cafeId, id, cancellationToken);
if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id }));
@@ -23,7 +23,7 @@ public class CustomRolesController : CafeApiControllerBase
public async Task<IActionResult> List(string cafeId, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } forbidden) return forbidden;
if (EnsurePermission(tenant, Permission.ManageRoles) is { } forbidden) return forbidden;
var roles = await _db.CustomRoles
.AsNoTracking()
@@ -57,7 +57,7 @@ public class CustomRolesController : CafeApiControllerBase
public async Task<IActionResult> Get(string cafeId, string id, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } forbidden) return forbidden;
if (EnsurePermission(tenant, Permission.ManageRoles) is { } forbidden) return forbidden;
var r = await _db.CustomRoles.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id && x.CafeId == cafeId, ct);
@@ -80,7 +80,7 @@ public class CustomRolesController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } forbidden) return forbidden;
if (EnsurePermission(tenant, Permission.ManageRoles) is { } forbidden) return forbidden;
var name = request.Name?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(name))
@@ -113,7 +113,7 @@ public class CustomRolesController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } forbidden) return forbidden;
if (EnsurePermission(tenant, Permission.ManageRoles) is { } forbidden) return forbidden;
var role = await _db.CustomRoles
.FirstOrDefaultAsync(r => r.Id == id && r.CafeId == cafeId, ct);
@@ -152,7 +152,7 @@ public class CustomRolesController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } forbidden) return forbidden;
if (EnsurePermission(tenant, Permission.ManageRoles) is { } forbidden) return forbidden;
var role = await _db.CustomRoles
.FirstOrDefaultAsync(r => r.Id == id && r.CafeId == cafeId, ct);
@@ -180,7 +180,7 @@ public class CustomRolesController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } forbidden) return forbidden;
if (EnsurePermission(tenant, Permission.ManageRoles) is { } forbidden) return forbidden;
var employee = await _db.Employees
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
@@ -2,6 +2,7 @@ using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Crm;
using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces;
using Meezi.Shared;
@@ -57,6 +58,7 @@ public class CustomersController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.CreateCustomer) is { } permDenied) return permDenied;
var validation = await _createValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -77,6 +79,7 @@ public class CustomersController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.EditCustomer) is { } permDenied) return permDenied;
var validation = await _updateValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -99,6 +102,7 @@ public class CustomersController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.DeleteCustomer) is { } permDenied) return permDenied;
var deleted = await _customerService.DeleteAsync(cafeId, id, cancellationToken);
if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id }));
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Services.Delivery;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces;
using Meezi.Shared;
@@ -21,6 +22,7 @@ public class DeliveryReportsController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
var utcTo = to ?? DateTime.UtcNow;
var utcFrom = from ?? utcTo.AddDays(-30);
@@ -2,7 +2,7 @@ using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Expenses;
using Meezi.API.Services;
using Meezi.Core.Enums;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces;
using Meezi.Shared;
@@ -30,14 +30,11 @@ public class ExpensesController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.CreateExpense) is { } permDenied) return permDenied;
if (string.IsNullOrEmpty(tenant.UserId))
return StatusCode(StatusCodes.Status401Unauthorized,
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing.")));
if (!CanLogExpense(tenant.Role))
return StatusCode(StatusCodes.Status403Forbidden,
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "You cannot log expenses.")));
var validation = await _createValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -57,6 +54,7 @@ public class ExpensesController : CafeApiControllerBase
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ViewExpenses) is { } permDenied) return permDenied;
if (string.IsNullOrWhiteSpace(branchId))
return BadRequest(new ApiResponse<object>(false, null,
@@ -85,10 +83,7 @@ public class ExpensesController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (!CanDeleteExpense(tenant.Role))
return StatusCode(StatusCodes.Status403Forbidden,
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "Only managers can delete expenses.")));
if (EnsurePermission(tenant, Permission.DeleteExpense) is { } permDenied) return permDenied;
var result = await _expenses.DeleteExpenseAsync(cafeId, id, ct);
if (!result.Success)
@@ -104,12 +99,6 @@ public class ExpensesController : CafeApiControllerBase
return Ok(new ApiResponse<object>(true, null));
}
private static bool CanLogExpense(EmployeeRole? role) =>
role is EmployeeRole.Owner or EmployeeRole.Manager or EmployeeRole.Cashier;
private static bool CanDeleteExpense(EmployeeRole? role) =>
role is EmployeeRole.Owner or EmployeeRole.Manager;
private IActionResult ExpenseResult(ExpenseServiceResult<ExpenseDto> result, int successStatus = StatusCodes.Status200OK)
{
if (result.Success)
+13 -7
View File
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Hr;
using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
@@ -43,6 +44,7 @@ public class HrController : CafeApiControllerBase
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ViewStaff) is { } forbidden) return forbidden;
var data = await _hr.GetEmployeesAsync(cafeId, branchId, ct);
return Ok(new ApiResponse<IReadOnlyList<EmployeeSummaryDto>>(true, data));
}
@@ -57,7 +59,7 @@ public class HrController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureManager(tenant) is { } forbidden) return forbidden;
if (EnsurePermission(tenant, Permission.CreateStaff) is { } forbidden) return forbidden;
IActionResult Invalid(string message, string field) =>
BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", message, field)));
@@ -183,6 +185,7 @@ public class HrController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ViewAttendance) is { } forbidden) return forbidden;
var data = await _hr.GetAttendanceAsync(cafeId, employeeId, from, to, ct);
return Ok(new ApiResponse<IReadOnlyList<AttendanceDto>>(true, data));
}
@@ -191,6 +194,7 @@ public class HrController : CafeApiControllerBase
public async Task<IActionResult> GetShifts(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ViewSchedules) is { } forbidden) return forbidden;
var data = await _hr.GetShiftsAsync(cafeId, employeeId, ct);
return Ok(new ApiResponse<IReadOnlyList<ShiftDto>>(true, data));
}
@@ -204,7 +208,7 @@ public class HrController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureManager(tenant) is { } forbidden) return forbidden;
if (EnsurePermission(tenant, Permission.ManageSchedules) is { } forbidden) return forbidden;
var data = await _hr.UpsertShiftsAsync(cafeId, employeeId, request, ct);
return Ok(new ApiResponse<IReadOnlyList<ShiftDto>>(true, data));
}
@@ -217,6 +221,7 @@ public class HrController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ReviewLeave) is { } forbidden) return forbidden;
var data = await _hr.GetLeaveRequestsAsync(cafeId, status, ct);
return Ok(new ApiResponse<IReadOnlyList<LeaveRequestDto>>(true, data));
}
@@ -248,7 +253,7 @@ public class HrController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureManager(tenant) is { } forbidden) return forbidden;
if (EnsurePermission(tenant, Permission.ReviewLeave) is { } forbidden) return forbidden;
var validation = await _reviewValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -265,6 +270,7 @@ public class HrController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ViewSalaries) is { } forbidden) return forbidden;
var data = await _hr.GetSalariesAsync(cafeId, monthYear, ct);
return Ok(new ApiResponse<IReadOnlyList<EmployeeSalaryDto>>(true, data));
}
@@ -277,7 +283,7 @@ public class HrController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureManager(tenant) is { } forbidden) return forbidden;
if (EnsurePermission(tenant, Permission.ManageSalaries) is { } forbidden) return forbidden;
var validation = await _salaryValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -290,7 +296,7 @@ public class HrController : CafeApiControllerBase
public async Task<IActionResult> MarkPaid(string cafeId, string salaryId, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureManager(tenant) is { } forbidden) return forbidden;
if (EnsurePermission(tenant, Permission.ManageSalaries) is { } forbidden) return forbidden;
var data = await _hr.MarkSalaryPaidAsync(cafeId, salaryId, ct);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<EmployeeSalaryDto>(true, data));
@@ -306,7 +312,7 @@ public class HrController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureManager(tenant) is { } forbidden) return forbidden;
if (EnsurePermission(tenant, Permission.ManageStaffCredentials) is { } forbidden) return forbidden;
var username = request.Username.Trim().ToLowerInvariant();
@@ -344,7 +350,7 @@ public class HrController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureManager(tenant) is { } forbidden) return forbidden;
if (EnsurePermission(tenant, Permission.ManageStaffCredentials) is { } forbidden) return forbidden;
var employee = await _db.Employees
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces;
using Meezi.Shared;
@@ -36,6 +37,7 @@ public class InventoryController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.CreateInventory) is { } permDenied) return permDenied;
if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Name is required.")));
@@ -56,6 +58,7 @@ public class InventoryController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.EditInventory) is { } permDenied) return permDenied;
var updated = await _inventory.UpdateAsync(cafeId, ingredientId, request, ct);
if (updated is null) return NotFoundError();
return Ok(new ApiResponse<object>(true, updated));
@@ -69,6 +72,7 @@ public class InventoryController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.DeleteInventory) is { } permDenied) return permDenied;
var deleted = await _inventory.DeleteAsync(cafeId, ingredientId, ct);
if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id = ingredientId }));
@@ -83,6 +87,7 @@ public class InventoryController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.EditInventory) is { } permDenied) return permDenied;
try
{
var updated = await _inventory.AdjustAsync(cafeId, ingredientId, request, tenant.UserId, ct);
@@ -146,6 +151,7 @@ public class InventoryController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.EditInventory) is { } permDenied) return permDenied;
var recipe = await _inventory.SetRecipeAsync(cafeId, menuItemId, request, ct);
if (recipe is null) return NotFoundError("Menu item not found.");
return Ok(new ApiResponse<object>(true, recipe));
@@ -2,6 +2,7 @@ using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Kitchen;
using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces;
using Meezi.Shared;
@@ -40,6 +41,7 @@ public class KitchenStationsController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageKitchenStations) is { } permDenied) return permDenied;
var validation = await _createValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -59,6 +61,7 @@ public class KitchenStationsController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageKitchenStations) is { } permDenied) return permDenied;
var validation = await _updateValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -71,6 +74,7 @@ public class KitchenStationsController : CafeApiControllerBase
public async Task<IActionResult> Delete(string cafeId, string id, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageKitchenStations) is { } permDenied) return permDenied;
var ok = await _stations.DeleteAsync(cafeId, id, ct);
if (!ok) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id }));
+32 -6
View File
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
@@ -20,13 +21,21 @@ public class MediaController : CafeApiControllerBase
[RequestSizeLimit(5 * 1024 * 1024)]
public Task<IActionResult> UploadMenuImage(
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
=> Upload(cafeId, file, tenant, _media.SaveMenuImageAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return Task.FromResult(denied);
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return Task.FromResult(permDenied);
return Upload(cafeId, file, tenant, _media.SaveMenuImageAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
}
[HttpPost("menu-video")]
[RequestSizeLimit(25 * 1024 * 1024)]
public Task<IActionResult> UploadMenuVideo(
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
=> Upload(cafeId, file, tenant, _media.SaveMenuVideoAsync, "INVALID_FILE", "Use MP4/WebM/MOV up to 25MB.", cancellationToken);
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return Task.FromResult(denied);
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return Task.FromResult(permDenied);
return Upload(cafeId, file, tenant, _media.SaveMenuVideoAsync, "INVALID_FILE", "Use MP4/WebM/MOV up to 25MB.", cancellationToken);
}
[HttpPost("menu-model3d")]
[RequestSizeLimit(8 * 1024 * 1024)]
@@ -38,6 +47,7 @@ public class MediaController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
var planTier = tenant.PlanTier ?? PlanTier.Free;
if (!await catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, "menu_3d", cancellationToken))
{
@@ -63,25 +73,41 @@ public class MediaController : CafeApiControllerBase
[RequestSizeLimit(5 * 1024 * 1024)]
public Task<IActionResult> UploadTableImage(
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
=> Upload(cafeId, file, tenant, _media.SaveTableImageAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return Task.FromResult(denied);
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return Task.FromResult(permDenied);
return Upload(cafeId, file, tenant, _media.SaveTableImageAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
}
[HttpPost("table-video")]
[RequestSizeLimit(25 * 1024 * 1024)]
public Task<IActionResult> UploadTableVideo(
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
=> Upload(cafeId, file, tenant, _media.SaveTableVideoAsync, "INVALID_FILE", "Use MP4/WebM/MOV up to 25MB.", cancellationToken);
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return Task.FromResult(denied);
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return Task.FromResult(permDenied);
return Upload(cafeId, file, tenant, _media.SaveTableVideoAsync, "INVALID_FILE", "Use MP4/WebM/MOV up to 25MB.", cancellationToken);
}
[HttpPost("cafe-logo")]
[RequestSizeLimit(5 * 1024 * 1024)]
public Task<IActionResult> UploadCafeLogo(
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
=> Upload(cafeId, file, tenant, _media.SaveCafeLogoAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return Task.FromResult(denied);
if (EnsurePermission(tenant, Permission.ManageCafeSettings) is { } permDenied) return Task.FromResult(permDenied);
return Upload(cafeId, file, tenant, _media.SaveCafeLogoAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
}
[HttpPost("cafe-cover")]
[RequestSizeLimit(5 * 1024 * 1024)]
public Task<IActionResult> UploadCafeCover(
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
=> Upload(cafeId, file, tenant, _media.SaveCafeCoverAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return Task.FromResult(denied);
if (EnsurePermission(tenant, Permission.ManageCafeSettings) is { } permDenied) return Task.FromResult(permDenied);
return Upload(cafeId, file, tenant, _media.SaveCafeCoverAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
}
/// <summary>Media library for this café — previously uploaded files so the UI can
/// reuse one instead of re-uploading. Deduplication means each distinct file appears once.</summary>
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Menu;
using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Constants;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
@@ -59,6 +60,7 @@ public class MenuController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.CreateMenuItem) is { } permDenied) return permDenied;
var validation = await _createCategoryValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -86,6 +88,7 @@ public class MenuController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
var data = await _menuService.UpdateCategoryAsync(cafeId, id, request, cancellationToken);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<MenuCategoryDto>(true, data));
@@ -95,6 +98,7 @@ public class MenuController : CafeApiControllerBase
public async Task<IActionResult> DeleteCategory(string cafeId, string id, ITenantContext tenant, CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.DeleteMenuItem) is { } permDenied) return permDenied;
var deleted = await _menuService.DeleteCategoryAsync(cafeId, id, cancellationToken);
if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id }));
@@ -120,6 +124,7 @@ public class MenuController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.CreateMenuItem) is { } permDenied) return permDenied;
var validation = await _createItemValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -148,6 +153,7 @@ public class MenuController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
var data = await _menuService.UpdateItemAsync(cafeId, id, request, cancellationToken);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<MenuItemDto>(true, data));
@@ -162,6 +168,7 @@ public class MenuController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
var data = await _menuService.SetAvailabilityAsync(cafeId, id, request.IsAvailable, cancellationToken);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<MenuItemDto>(true, data));
@@ -171,6 +178,7 @@ public class MenuController : CafeApiControllerBase
public async Task<IActionResult> DeleteItem(string cafeId, string id, ITenantContext tenant, CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.DeleteMenuItem) is { } permDenied) return permDenied;
var deleted = await _menuService.DeleteItemAsync(cafeId, id, cancellationToken);
if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id }));
@@ -193,6 +201,7 @@ public class MenuController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
var tier = tenant.PlanTier ?? PlanTier.Free;
var (data, code, message) = await _menuAi3d.GenerateFromItemImageAsync(cafeId, id, tier, cancellationToken);
if (code is not null)
+11 -5
View File
@@ -1,5 +1,4 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Orders;
using Meezi.API.Services;
@@ -120,6 +119,7 @@ public class OrdersController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ProcessOrders) is { } permDenied) return permDenied;
var validation = await _createValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -139,6 +139,7 @@ public class OrdersController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.EditOrder) is { } permDenied) return permDenied;
var validation = await _appendValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -150,7 +151,6 @@ public class OrdersController : CafeApiControllerBase
}
[HttpPatch("{id}/items/{itemId}/void")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> VoidOrderItem(
string cafeId,
string id,
@@ -159,6 +159,7 @@ public class OrdersController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.VoidOrder) is { } permDenied) return permDenied;
if (string.IsNullOrEmpty(tenant.UserId))
return StatusCode(StatusCodes.Status403Forbidden,
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "User context required.")));
@@ -181,7 +182,6 @@ public class OrdersController : CafeApiControllerBase
}
[HttpPost("{id}/transfer")]
[Authorize(Roles = "Manager,Owner,Waiter")]
public async Task<IActionResult> TransferTable(
string cafeId,
string id,
@@ -190,6 +190,7 @@ public class OrdersController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.EditOrder) is { } permDenied) return permDenied;
var result = await _orderService.TransferTableAsync(cafeId, id, request.TargetTableId, cancellationToken);
if (!result.Success)
@@ -207,6 +208,7 @@ public class OrdersController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.EditOrder) is { } permDenied) return permDenied;
var validation = await _sessionValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -226,6 +228,7 @@ public class OrdersController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.UpdateOrderStatus) is { } permDenied) return permDenied;
var validation = await _statusValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -243,7 +246,7 @@ public class OrdersController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ProcessOrders) is { } forbidden) return forbidden;
if (EnsurePermission(tenant, Permission.VoidOrder) is { } forbidden) return forbidden;
var result = await _orderService.CancelOrderAsync(
cafeId, id, request.Reason, tenant.UserId, cancellationToken);
@@ -279,6 +282,7 @@ public class OrdersController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.HandlePayments) is { } permDenied) return permDenied;
var validation = await _paymentsValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -319,7 +323,7 @@ public class OrdersController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureManager(tenant) is { } forbidden) return forbidden;
if (EnsurePermission(tenant, Permission.ManageFinancials) is { } forbidden) return forbidden;
var validation = await _correctionValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -372,6 +376,8 @@ public class OrdersController : CafeApiControllerBase
false, null, new ApiError(code, "Order is already cancelled.", field))),
"ORDER_HAS_PAYMENTS" => Conflict(new ApiResponse<object>(
false, null, new ApiError(code, "Refund the recorded payments before cancelling this order.", field))),
"ORDER_IN_PREPARATION" => Conflict(new ApiResponse<object>(
false, null, new ApiError(code, "This order has already been sent to the kitchen and cannot be cancelled.", field))),
"ITEM_NOT_FOUND" => NotFound(new ApiResponse<object>(
false, null, new ApiError(code, "Line item not found.", field))),
"ITEM_ALREADY_VOIDED" => BadRequest(new ApiResponse<object>(
@@ -0,0 +1,129 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Payments;
using Meezi.API.Services;
using Meezi.API.Services.Payments;
using Meezi.Core.Authorization;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
/// <summary>FlatRender Pay (ZarinPal broker) checkout + webhook.</summary>
[ApiController]
public class PaymentController : CafeApiControllerBase
{
private readonly IBillingService _billing;
private readonly IFlatPayService _flatPay;
private readonly ILogger<PaymentController> _logger;
public PaymentController(IBillingService billing, IFlatPayService flatPay, ILogger<PaymentController> logger)
{
_billing = billing;
_flatPay = flatPay;
_logger = logger;
}
/// <summary>Start a FlatPay checkout for a plan bundle; returns the URL to redirect the buyer to.</summary>
[Authorize]
[HttpPost("api/payment/request")]
public async Task<IActionResult> CreatePayment(
[FromBody] PaymentRequestDto request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsurePermission(tenant, Permission.ManageBilling) is { } permDenied) return permDenied;
if (string.IsNullOrEmpty(tenant.CafeId)) return Unauthorized();
if (request?.ProductId is null || !TryParseProduct(request.ProductId, out var tier, out var months))
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("INVALID_PRODUCT", "productId must be a \"Tier:Months\" bundle, e.g. \"Pro:12\".")));
var (paymentId, amountToman, code, message) =
await _billing.CreateFlatPayOrderAsync(tenant.CafeId, tier, months, ct);
if (paymentId is null)
return BadRequest(new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Failed.")));
var description = $"میزی — اشتراک {tier} ({months} ماه)";
var url = await _flatPay.RequestAsync(
tenant.CafeId, request.ProductId, (long)amountToman, description, paymentId, ct);
if (string.IsNullOrEmpty(url))
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("PAYMENT_FAILED", "Could not start the payment.")));
return Ok(new ApiResponse<PaymentRequestResponse>(true, new PaymentRequestResponse(url, paymentId)));
}
/// <summary>Broker → us. Security is the HMAC signature (no user auth). Always 200 after a valid
/// signature so the broker doesn't retry a job we've accepted.</summary>
[AllowAnonymous]
[HttpPost("api/payment/webhook")]
public async Task<IActionResult> Webhook(CancellationToken ct)
{
using var ms = new MemoryStream();
await Request.Body.CopyToAsync(ms, ct);
var raw = ms.ToArray();
var signature = Request.Headers["X-FlatPay-Signature"].ToString();
if (!_flatPay.VerifyWebhook(raw, signature))
return Unauthorized();
try
{
using var doc = JsonDocument.Parse(raw);
var root = doc.RootElement;
var status = GetString(root, "status");
var brokerId = GetString(root, "id") ?? GetString(root, "payment_id");
if (string.Equals(status, "Paid", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrEmpty(brokerId)
&& _flatPay.TryMarkProcessed(brokerId))
{
var meta = root.TryGetProperty("metadata", out var m) && m.ValueKind == JsonValueKind.Object
? m
: default;
var paymentId = GetString(meta, "payment_id");
if (!string.IsNullOrEmpty(paymentId))
await _billing.CompleteFlatPayAsync(paymentId, brokerId, ct);
else
_logger.LogWarning("FlatPay webhook Paid but missing metadata.payment_id (broker id {Id})", brokerId);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "FlatPay webhook processing error");
}
return Ok();
}
/// <summary>Parse a "Tier:Months" product id, e.g. "Pro:12" → (PlanTier.Pro, 12).</summary>
private static bool TryParseProduct(string productId, out PlanTier tier, out int months)
{
tier = default;
months = 0;
var parts = productId.Split(':', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2) return false;
return Enum.TryParse(parts[0], ignoreCase: true, out tier)
&& tier != PlanTier.Free
&& int.TryParse(parts[1], out months)
&& months > 0;
}
private static string? GetString(JsonElement el, string name)
{
if (el.ValueKind != JsonValueKind.Object || !el.TryGetProperty(name, out var v))
return null;
return v.ValueKind switch
{
JsonValueKind.String => v.GetString(),
JsonValueKind.Number => v.ToString(),
_ => null,
};
}
}
@@ -1,15 +1,14 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Printing;
using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/branches/{branchId}/pos-device")]
[Authorize(Roles = "Cashier,Manager,Owner")]
public class PosDeviceController : CafeApiControllerBase
{
private readonly IPosDeviceService _posDevice;
@@ -30,6 +29,7 @@ public class PosDeviceController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.HandlePayments) is { } permDenied) return permDenied;
var validation = await _validator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -0,0 +1,63 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Hubs;
using Meezi.API.Models.Printing;
using Meezi.Infrastructure.Data;
using Meezi.Shared;
namespace Meezi.API.Controllers;
/// <summary>
/// Anonymous endpoint the print-agent installer calls to redeem a pairing code for
/// a long-lived token. The token is returned exactly once; only its hash is stored.
/// </summary>
[ApiController]
[AllowAnonymous]
[Route("api/print-agent")]
public class PrintAgentPairingController : ControllerBase
{
private readonly AppDbContext _db;
public PrintAgentPairingController(AppDbContext db) => _db = db;
[HttpPost("claim")]
public async Task<IActionResult> Claim([FromBody] ClaimAgentRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Code))
return BadRequest(new ApiResponse<object>(false, null, new ApiError("CODE_REQUIRED", "Pairing code is required.")));
var now = DateTime.UtcNow;
var agent = await _db.PrintAgents
.IgnoreQueryFilters()
.FirstOrDefaultAsync(a =>
a.PairingCode == request.Code &&
a.TokenHash == null &&
!a.Revoked &&
a.DeletedAt == null &&
a.PairingCodeExpiresAt > now, ct);
if (agent is null)
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("INVALID_OR_EXPIRED_CODE", "This pairing code is invalid or has expired.")));
var token = NewToken();
agent.TokenHash = PrintAgentHub.HashToken(token);
agent.PairingCode = null;
agent.PairingCodeExpiresAt = null;
if (!string.IsNullOrWhiteSpace(request.Name)) agent.Name = request.Name!.Trim();
else if (!string.IsNullOrWhiteSpace(request.MachineName)) agent.Name = request.MachineName!.Trim();
agent.LastSeenAt = now;
await _db.SaveChangesAsync(ct);
return Ok(new ApiResponse<ClaimAgentResponse>(
true, new ClaimAgentResponse(agent.Id, token, agent.CafeId, agent.Name)));
}
private static string NewToken()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('=');
}
}
@@ -0,0 +1,164 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Hubs;
using Meezi.API.Models.Printing;
using Meezi.API.Services.Printing;
using Meezi.Core.Authorization;
using Meezi.Core.Entities;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Meezi.Shared;
namespace Meezi.API.Controllers;
/// <summary>Manage the local print agents paired to a café (cloud → LAN bridge).</summary>
[Route("api/cafes/{cafeId}/print-agents")]
public class PrintAgentsController : CafeApiControllerBase
{
private readonly AppDbContext _db;
private readonly IPrintAgentRegistry _registry;
private readonly IPrinterService _printer;
public PrintAgentsController(AppDbContext db, IPrintAgentRegistry registry, IPrinterService printer)
{
_db = db;
_registry = registry;
_printer = printer;
}
[HttpGet]
public async Task<IActionResult> List(string cafeId, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ViewPrintSettings) is { } permDenied) return permDenied;
var agents = await _db.PrintAgents
.Where(a => a.CafeId == cafeId)
.Include(a => a.Devices)
.OrderBy(a => a.CreatedAt)
.ToListAsync(ct);
var dtos = agents.Select(a => new PrintAgentDto(
a.Id,
a.Name,
a.BranchId,
_registry.IsOnline(a.Id),
a.TokenHash is not null,
a.LastSeenAt,
a.CreatedAt,
a.Devices
.OrderBy(d => d.DisplayName)
.Select(d => new PrintAgentDeviceDto(d.Id, d.SystemName, d.DisplayName, d.Kind, d.LastSeenAt))
.ToList()
)).ToList();
return Ok(new ApiResponse<IReadOnlyList<PrintAgentDto>>(true, dtos));
}
/// <summary>Create a pending agent and a short one-time code the installer enters to pair.</summary>
[HttpPost("pairing-code")]
public async Task<IActionResult> CreatePairingCode(
string cafeId,
[FromBody] CreatePairingCodeRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
var code = await GenerateUniqueCodeAsync(ct);
var agent = new PrintAgent
{
CafeId = cafeId,
BranchId = string.IsNullOrWhiteSpace(request.BranchId) ? null : request.BranchId,
Name = string.IsNullOrWhiteSpace(request.Name) ? "پرینت‌سرور" : request.Name!.Trim(),
PairingCode = code,
PairingCodeExpiresAt = DateTime.UtcNow.AddMinutes(15),
};
_db.PrintAgents.Add(agent);
await _db.SaveChangesAsync(ct);
return Ok(new ApiResponse<PairingCodeResponse>(
true, new PairingCodeResponse(agent.Id, code, agent.PairingCodeExpiresAt!.Value)));
}
/// <summary>Unpair/revoke an agent — it can no longer connect or print.</summary>
[HttpDelete("{id}")]
public async Task<IActionResult> Revoke(string cafeId, string id, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
var agent = await _db.PrintAgents.FirstOrDefaultAsync(a => a.Id == id && a.CafeId == cafeId, ct);
if (agent is null)
return NotFound(new ApiResponse<object>(false, null, new ApiError("AGENT_NOT_FOUND", "Print agent not found.")));
agent.Revoked = true;
agent.TokenHash = null;
agent.PairingCode = null;
agent.DeletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return Ok(new ApiResponse<object>(true, null));
}
/// <summary>Send a test page to a discovered printer through its agent.</summary>
[HttpPost("devices/{deviceId}/test")]
public async Task<IActionResult> TestDevice(string cafeId, string deviceId, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
var result = await _printer.TestPrintDeviceAsync(cafeId, deviceId, ct);
return result.Success
? Ok(new ApiResponse<object>(true, null))
: BadRequest(new ApiResponse<object>(false, null,
new ApiError(result.ErrorCode ?? "PRINT_FAILED", result.ErrorDetail ?? "Test print failed.")));
}
/// <summary>Ask the café's online agents to scan their LAN for devices (network
/// printers on :9100, card terminals on :8088) so the owner can pick instead of
/// typing an IP. Merges results across agents.</summary>
[HttpPost("scan")]
public async Task<IActionResult> Scan(
string cafeId,
[FromBody] ScanRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
var online = _registry.OnlineAgentIdsForCafe(cafeId);
if (online.Count == 0)
return BadRequest(new ApiResponse<object>(
false, null, new ApiError("AGENT_OFFLINE", "No print agent is online to scan the network.")));
var ports = string.IsNullOrWhiteSpace(request.Ports) ? "9100,8088" : request.Ports!.Trim();
var merged = new Dictionary<string, ScannedDeviceDto>();
foreach (var agentId in online)
{
foreach (var d in await _registry.ScanAsync(agentId, ports, ct))
merged[$"{d.Ip}:{d.Port}"] = new ScannedDeviceDto(d.Ip, d.Port, d.Kind);
}
var dtos = merged.Values.OrderBy(d => d.Ip).ThenBy(d => d.Port).ToList();
return Ok(new ApiResponse<IReadOnlyList<ScannedDeviceDto>>(true, dtos));
}
private async Task<string> GenerateUniqueCodeAsync(CancellationToken ct)
{
for (var attempt = 0; attempt < 8; attempt++)
{
var code = RandomNumberGenerator.GetInt32(100_000, 1_000_000).ToString();
var now = DateTime.UtcNow;
var clash = await _db.PrintAgents.IgnoreQueryFilters().AnyAsync(
a => a.PairingCode == code && a.PairingCodeExpiresAt > now && a.TokenHash == null, ct);
if (!clash) return code;
}
// Extremely unlikely; fall back to a longer code.
return RandomNumberGenerator.GetInt32(100_000, 1_000_000).ToString() +
RandomNumberGenerator.GetInt32(10, 100).ToString();
}
}
+10 -5
View File
@@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Printing;
using Meezi.API.Services.Printing;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces;
using Meezi.Shared;
@@ -32,16 +32,18 @@ public class PrintController : CafeApiControllerBase
string cafeId,
string orderId,
ITenantContext tenant,
CancellationToken ct)
CancellationToken ct,
[FromQuery] string? stationId)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var result = await _printer.PrintKitchenTicketAsync(cafeId, orderId, ct);
// stationId omitted → print every station (kitchen + bar …); provided →
// reprint only that one station's items.
var result = await _printer.PrintKitchenTicketAsync(cafeId, orderId, stationId, ct);
return ToActionResult(result);
}
[HttpPost("test")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> TestPrint(
string cafeId,
[FromBody] TestPrintRequest request,
@@ -49,6 +51,7 @@ public class PrintController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
var result = await _printer.TestPrintAsync(request.PrinterIp, request.Port, ct);
return ToActionResult(result);
@@ -62,7 +65,8 @@ public class PrintController : CafeApiControllerBase
var status = result.ErrorCode switch
{
"PRINTER_NOT_CONFIGURED" or "KITCHEN_PRINTER_NOT_CONFIGURED" => StatusCodes.Status400BadRequest,
"PRINTER_NOT_CONFIGURED" or "KITCHEN_PRINTER_NOT_CONFIGURED" or "NO_STATION_ITEMS"
=> StatusCodes.Status400BadRequest,
"ORDER_NOT_FOUND" => StatusCodes.Status404NotFound,
_ => StatusCodes.Status502BadGateway
};
@@ -75,6 +79,7 @@ public class PrintController : CafeApiControllerBase
{
"PRINTER_NOT_CONFIGURED" => "Receipt printer IP is not configured for this branch.",
"KITCHEN_PRINTER_NOT_CONFIGURED" => "Kitchen printer IP is not configured for this branch.",
"NO_STATION_ITEMS" => "This order has no items for the selected station.",
"PRINTER_CONNECTION_FAILED" => "Could not connect to the printer.",
"ORDER_NOT_FOUND" => "Order not found.",
_ => "Print failed."
+25 -11
View File
@@ -1,9 +1,12 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Public;
using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Meezi.Shared;
namespace Meezi.API.Controllers;
@@ -13,19 +16,19 @@ namespace Meezi.API.Controllers;
///
/// POST /api/public/push/register — anonymous device registration
/// POST /api/public/push/unregister — anonymous device removal
/// POST /api/push/broadcast — authorized topic broadcast (marketing /
/// saved-café alerts)
/// POST /api/push/broadcast — café marketing push (own topic only)
/// </summary>
[ApiController]
public class PushController : ControllerBase
public class PushController : CafeApiControllerBase
{
private readonly IPushDeviceService _devices;
private readonly IPushSender _sender;
private readonly AppDbContext _db;
public PushController(IPushDeviceService devices, IPushSender sender)
public PushController(IPushDeviceService devices, IPushSender sender, AppDbContext db)
{
_devices = devices;
_sender = sender;
_db = db;
}
[HttpPost("api/public/push/register")]
@@ -53,15 +56,26 @@ public class PushController : ControllerBase
}
[HttpPost("api/push/broadcast")]
[Authorize]
public async Task<IActionResult> Broadcast(
[FromBody] BroadcastPushRequest request, CancellationToken ct)
[FromBody] BroadcastPushRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Topic))
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("INVALID_TOPIC", "Topic is required.")));
if (EnsurePermission(tenant, Permission.SendSms) is { } forbidden) return forbidden;
if (string.IsNullOrEmpty(tenant.CafeId))
return StatusCode(StatusCodes.Status403Forbidden,
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "Café context is required.")));
await _sender.SendToTopicAsync(request.Topic, request.Title, request.Body, request.DeepLink, ct);
// A café may only push to its OWN topic (cafe-{slug}). The client-supplied
// topic is intentionally ignored to prevent cross-café / city-wide pushes.
var slug = await _db.Cafes.AsNoTracking()
.Where(c => c.Id == tenant.CafeId)
.Select(c => c.Slug)
.FirstOrDefaultAsync(ct);
if (string.IsNullOrWhiteSpace(slug))
return NotFoundError("Café not found.");
await _sender.SendToTopicAsync($"cafe-{slug}", request.Title, request.Body, request.DeepLink, ct);
return Ok(new ApiResponse<object>(true, new { sent = true }));
}
}
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Queue;
using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Shared;
@@ -37,6 +38,7 @@ public class QueueController : CafeApiControllerBase
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageQueue) is { } permDenied) return permDenied;
var (ticket, error) = await _queue.IssueNextAsync(cafeId, tenant.UserId, request, ct);
if (error == "BRANCH_NOT_FOUND")
return NotFound(new ApiResponse<object>(false, null, new ApiError(error, "Branch not found.")));
@@ -54,6 +56,7 @@ public class QueueController : CafeApiControllerBase
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageQueue) is { } permDenied) return permDenied;
var (ticket, error) = await _queue.UpdateStatusAsync(cafeId, ticketId, request.Status, ct);
if (error == "NOT_FOUND")
return NotFound(new ApiResponse<object>(false, null, new ApiError(error, "Ticket not found.")));
@@ -71,6 +74,7 @@ public class QueueController : CafeApiControllerBase
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageQueue) is { } permDenied) return permDenied;
var board = await _queue.GetTodayBoardAsync(cafeId, branchId, ct);
var next = board.Tickets.FirstOrDefault(t => t.Status == QueueTicketStatus.Waiting);
if (next is null)
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Reports;
using Meezi.API.Services;
using Meezi.API.Utils;
using Meezi.Core.Authorization;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Services.Platform;
@@ -38,6 +39,7 @@ public class ReportsController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
if (string.IsNullOrWhiteSpace(branchId))
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", "branchId is required.", "branchId")));
@@ -65,6 +67,7 @@ public class ReportsController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
if (!TryParseReportDate(from, out var startDate) || !TryParseReportDate(to, out var endDate))
return BadRequest(new ApiResponse<object>(false, null,
@@ -99,6 +102,7 @@ public class ReportsController : CafeApiControllerBase
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
var maxDays = await MaxHistoryDaysAsync(tenant, ct);
if (days > maxDays && maxDays != int.MaxValue)
@@ -120,6 +124,7 @@ public class ReportsController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
if (date is not null && !JalaliCalendarHelper.TryParseJalaliDate(date, out _, out _, out _))
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", "Invalid Jalali date. Use yyyy-MM-dd.")));
@@ -136,6 +141,7 @@ public class ReportsController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
if (month is not null && !JalaliCalendarHelper.TryParseJalaliMonth(month, out _, out _))
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", "Invalid Jalali month. Use yyyy-MM.")));
@@ -152,6 +158,7 @@ public class ReportsController : CafeApiControllerBase
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
var data = await _reports.GetTrendAsync(cafeId, days, ct);
return Ok(new ApiResponse<IReadOnlyList<TrendDayDto>>(true, data));
}
@@ -165,6 +172,7 @@ public class ReportsController : CafeApiControllerBase
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ExportReports) is { } permDenied) return permDenied;
if (!string.Equals(format, "excel", StringComparison.OrdinalIgnoreCase))
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", "Only excel format is supported.")));
@@ -2,6 +2,7 @@ using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Public;
using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Shared;
@@ -30,6 +31,7 @@ public class ReservationsController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.CreateReservation) is { } permDenied) return permDenied;
var validation = await _createValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -62,6 +64,7 @@ public class ReservationsController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.EditReservation) is { } permDenied) return permDenied;
var data = await _reservations.UpdateStatusAsync(cafeId, id, request.Status, ct);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<ReservationDto>(true, data));
@@ -75,6 +78,7 @@ public class ReservationsController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.DeleteReservation) is { } permDenied) return permDenied;
var deleted = await _reservations.DeleteAsync(cafeId, id, ct);
if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id }));
@@ -2,6 +2,7 @@ using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Shifts;
using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces;
using Meezi.Shared;
@@ -33,6 +34,7 @@ public class ShiftsController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.OperateRegister) is { } permDenied) return permDenied;
if (string.IsNullOrEmpty(tenant.UserId))
return StatusCode(StatusCodes.Status401Unauthorized,
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing.")));
@@ -54,6 +56,7 @@ public class ShiftsController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.OperateRegister) is { } permDenied) return permDenied;
if (string.IsNullOrEmpty(tenant.UserId))
return StatusCode(StatusCodes.Status401Unauthorized,
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing.")));
+3 -1
View File
@@ -2,6 +2,7 @@ using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Crm;
using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces;
using Meezi.Shared;
@@ -43,7 +44,7 @@ public class SmsController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureManager(tenant) is { } forbidden) return forbidden;
if (EnsurePermission(tenant, Permission.ManageSmsSettings) is { } permDenied) return permDenied;
var (success, data, code, message) = await _smsMarketingService.UpdateSettingsAsync(
cafeId, request, cancellationToken);
@@ -85,6 +86,7 @@ public class SmsController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.SendSms) is { } permDenied) return permDenied;
var validation = await _campaignValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -1,9 +1,9 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Orders;
using Meezi.API.Models.Tables;
using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces;
using Meezi.Shared;
@@ -65,6 +65,7 @@ public class TablesController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
var validation = await _createValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -82,6 +83,7 @@ public class TablesController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
var validation = await _patchValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -104,7 +106,6 @@ public class TablesController : CafeApiControllerBase
}
[HttpDelete("{id}")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> DeleteTable(
string cafeId,
string id,
@@ -112,6 +113,7 @@ public class TablesController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
var result = await _tableService.DeleteTableAsync(cafeId, id, ct);
if (!result.Success)
@@ -135,6 +137,7 @@ public class TablesController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
var validation = await _cleaningValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces;
using Meezi.Shared;
@@ -19,6 +20,7 @@ public class TarazController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageFinancials) is { } permDenied) return permDenied;
var targetDate = date ?? DateTime.UtcNow.Date;
var result = await _taraz.SubmitDailyInvoicesAsync(cafeId, targetDate, ct);
+4 -3
View File
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Taxes;
using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces;
using Meezi.Shared;
@@ -29,7 +30,7 @@ public class TaxesController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
if (EnsurePermission(tenant, Permission.CreateTax) is { } permDenied) return permDenied;
var data = await _taxService.CreateAsync(cafeId, request, cancellationToken);
return Ok(new ApiResponse<TaxDto>(true, data));
}
@@ -43,7 +44,7 @@ public class TaxesController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
if (EnsurePermission(tenant, Permission.EditTax) is { } permDenied) return permDenied;
var data = await _taxService.UpdateAsync(cafeId, id, request, cancellationToken);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<TaxDto>(true, data));
@@ -57,7 +58,7 @@ public class TaxesController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
if (EnsurePermission(tenant, Permission.DeleteTax) is { } permDenied) return permDenied;
var deleted = await _taxService.DeleteAsync(cafeId, id, cancellationToken);
if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id }));
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.Core.Authorization;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.API.Services;
@@ -52,6 +53,7 @@ public class TerminalsController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageCafeSettings) is { } permDenied) return permDenied;
await _terminals.RevokeAsync(cafeId, terminalId, ct);
return Ok(new ApiResponse<object>(true, new { revoked = true }));
}
@@ -13,6 +13,7 @@ using Meezi.API.Services;
using Meezi.API.Services.Delivery;
using Meezi.Infrastructure.Services.Platform;
using Meezi.API.Services.Printing;
using Meezi.API.Services.Payments;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure;
using Serilog;
@@ -94,6 +95,14 @@ public static class ServiceCollectionExtensions
services.AddScoped<IDemoSeedService, DemoSeedService>();
services.AddScoped<ReceiptBuilder>();
services.AddScoped<IPrinterService, NetworkPrinterService>();
services.AddSingleton<IPrintAgentRegistry, PrintAgentRegistry>();
services.Configure<FlatPayOptions>(configuration.GetSection(FlatPayOptions.SectionName));
services.AddHttpClient<IFlatPayService, FlatPayService>((sp, c) =>
{
var baseUrl = configuration["FlatPay:BaseUrl"];
c.BaseAddress = new Uri(string.IsNullOrWhiteSpace(baseUrl) ? "https://pay.flatrender.ir" : baseUrl);
c.Timeout = TimeSpan.FromSeconds(30);
});
services.AddHttpClient(nameof(PosDeviceService));
services.AddScoped<IPosDeviceService, PosDeviceService>();
services.AddScoped<SubscriptionRenewalReminderJob>();
@@ -224,6 +233,7 @@ public static class ServiceCollectionExtensions
app.MapControllers();
app.MapHub<KdsHub>("/hubs/kds");
app.MapHub<GuestOrderHub>("/hubs/guest-order");
app.MapHub<PrintAgentHub>("/hubs/print-agent");
app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));
if (!app.Configuration.GetValue<bool>("Testing:Enabled"))
+119
View File
@@ -0,0 +1,119 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Services.Printing;
using Meezi.Core.Entities;
using Meezi.Infrastructure.Data;
namespace Meezi.API.Hubs;
/// <summary>
/// Local print agents connect here (outbound from the café PC), authenticated by
/// their token in the <c>access_token</c> query param — agents are not users, so
/// the hub self-authenticates rather than relying on the user JWT pipeline.
/// They report the printers they can see and receive print jobs to relay locally.
/// </summary>
[AllowAnonymous]
public class PrintAgentHub : Hub
{
private readonly AppDbContext _db;
private readonly IPrintAgentRegistry _registry;
private readonly ILogger<PrintAgentHub> _logger;
public PrintAgentHub(AppDbContext db, IPrintAgentRegistry registry, ILogger<PrintAgentHub> logger)
{
_db = db;
_registry = registry;
_logger = logger;
}
/// <summary>SHA-256 (hex) of an agent token — what we persist and compare against.</summary>
public static string HashToken(string token) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
public override async Task OnConnectedAsync()
{
var token = Context.GetHttpContext()?.Request.Query["access_token"].ToString();
if (string.IsNullOrEmpty(token)) { Context.Abort(); return; }
var hash = HashToken(token);
var agent = await _db.PrintAgents
.IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.TokenHash == hash && !a.Revoked && a.DeletedAt == null);
if (agent is null) { Context.Abort(); return; }
_registry.Register(Context.ConnectionId, agent.Id, agent.CafeId);
agent.LastSeenAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
_registry.Unregister(Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
}
public record ReportedPrinter(string SystemName, string DisplayName, string? Kind);
/// <summary>Agent → cloud: the current set of printers it can reach. Upserts devices.</summary>
public async Task ReportPrinters(IReadOnlyList<ReportedPrinter> printers)
{
if (_registry.Resolve(Context.ConnectionId) is not { } ctx) return;
var existing = await _db.PrintDevices.IgnoreQueryFilters()
.Where(d => d.AgentId == ctx.AgentId)
.ToListAsync();
var now = DateTime.UtcNow;
foreach (var p in printers ?? [])
{
if (string.IsNullOrWhiteSpace(p.SystemName)) continue;
var match = existing.FirstOrDefault(d => d.SystemName == p.SystemName);
if (match is null)
{
_db.PrintDevices.Add(new PrintDevice
{
CafeId = ctx.CafeId,
AgentId = ctx.AgentId,
SystemName = p.SystemName,
DisplayName = string.IsNullOrWhiteSpace(p.DisplayName) ? p.SystemName : p.DisplayName,
Kind = string.IsNullOrWhiteSpace(p.Kind) ? "other" : p.Kind!,
LastSeenAt = now,
});
}
else
{
match.DisplayName = string.IsNullOrWhiteSpace(p.DisplayName) ? match.DisplayName : p.DisplayName;
if (!string.IsNullOrWhiteSpace(p.Kind)) match.Kind = p.Kind!;
match.LastSeenAt = now;
match.DeletedAt = null; // a printer that came back is no longer "gone"
}
}
await _db.SaveChangesAsync();
}
/// <summary>Agent → cloud: acknowledgement of a dispatched print job.</summary>
public void JobResult(string jobId, bool success, string? error) =>
_registry.CompleteJob(jobId, success, error);
/// <summary>Agent → cloud: result of a relayed card-terminal payment.</summary>
public void PaymentResult(string requestId, bool success, string? error) =>
_registry.CompleteJob(requestId, success, error);
/// <summary>Agent → cloud: hosts found by a LAN scan (network printers, card terminals).</summary>
public void ReportScan(string requestId, IReadOnlyList<DiscoveredDevice> devices) =>
_registry.CompleteScan(requestId, devices ?? []);
/// <summary>Agent → cloud: keep-alive so the dashboard can show an accurate "last seen".</summary>
public async Task Heartbeat()
{
if (_registry.Resolve(Context.ConnectionId) is not { } ctx) return;
var agent = await _db.PrintAgents.IgnoreQueryFilters().FirstOrDefaultAsync(a => a.Id == ctx.AgentId);
if (agent is null) return;
agent.LastSeenAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
}
}
@@ -7,18 +7,21 @@ public record KitchenStationDto(
string? PrinterIp,
int PrinterPort,
int SortOrder,
int CategoryCount);
int CategoryCount,
string? PrintDeviceId);
public record CreateKitchenStationRequest(
string Name,
string? BranchId,
string? PrinterIp,
int PrinterPort = 9100,
int SortOrder = 0);
int SortOrder = 0,
string? PrintDeviceId = null);
public record UpdateKitchenStationRequest(
string? Name,
string? BranchId,
string? PrinterIp,
int? PrinterPort,
int? SortOrder);
int? SortOrder,
string? PrintDeviceId);
+6 -3
View File
@@ -55,7 +55,8 @@ public record MenuItemDto(
string? ImageUrl,
string? VideoUrl,
string? Model3dUrl,
bool IsAvailable);
bool IsAvailable,
string? KitchenStationId);
public record CreateMenuItemRequest(
string CategoryId,
@@ -68,7 +69,8 @@ public record CreateMenuItemRequest(
string? ImageUrl = null,
string? VideoUrl = null,
string? Model3dUrl = null,
bool IsAvailable = true);
bool IsAvailable = true,
string? KitchenStationId = null);
public record UpdateMenuItemRequest(
string? CategoryId,
@@ -81,6 +83,7 @@ public record UpdateMenuItemRequest(
string? ImageUrl,
string? VideoUrl,
string? Model3dUrl,
bool? IsAvailable);
bool? IsAvailable,
string? KitchenStationId);
public record UpdateMenuItemAvailabilityRequest(bool IsAvailable);
+5 -1
View File
@@ -10,7 +10,11 @@ public record OrderItemDto(
decimal UnitPrice,
string? Notes,
bool IsVoided = false,
DateTime? VoidedAt = null);
DateTime? VoidedAt = null,
// Prep station the item routes to (Kitchen/Bar). Populated on the live/KDS
// path only; null elsewhere (= the branch kitchen / no station).
string? StationId = null,
string? StationName = null);
public record TransferTableRequest(string TargetTableId);
@@ -0,0 +1,6 @@
namespace Meezi.API.Models.Payments;
/// <summary>Body for POST /api/payment/request. ProductId is a "Tier:Months" bundle, e.g. "Pro:12".</summary>
public record PaymentRequestDto(string ProductId);
public record PaymentRequestResponse(string Url, string PaymentId);
@@ -0,0 +1,31 @@
namespace Meezi.API.Models.Printing;
public record PrintAgentDeviceDto(
string Id,
string SystemName,
string DisplayName,
string Kind,
DateTime LastSeenAt);
public record PrintAgentDto(
string Id,
string Name,
string? BranchId,
bool Online,
bool Paired,
DateTime? LastSeenAt,
DateTime CreatedAt,
IReadOnlyList<PrintAgentDeviceDto> Devices);
public record CreatePairingCodeRequest(string? Name, string? BranchId);
public record PairingCodeResponse(string AgentId, string Code, DateTime ExpiresAt);
public record ClaimAgentRequest(string Code, string? Name, string? MachineName);
public record ClaimAgentResponse(string AgentId, string Token, string CafeId, string AgentName);
/// <summary>Ask online agents to scan the LAN for the given comma-separated TCP ports.</summary>
public record ScanRequest(string? Ports);
public record ScannedDeviceDto(string Ip, int Port, string Kind);
+6 -2
View File
@@ -12,7 +12,9 @@ public record BranchPrintSettingsDto(
string? ReceiptFooter,
string? WifiPassword,
string? PosDeviceIp,
int? PosDevicePort);
int? PosDevicePort,
string? ReceiptPrintDeviceId,
string? KitchenPrintDeviceId);
public record PatchBranchPrintSettingsRequest(
string? ReceiptPrinterIp,
@@ -25,7 +27,9 @@ public record PatchBranchPrintSettingsRequest(
string? ReceiptFooter,
string? WifiPassword,
string? PosDeviceIp,
int? PosDevicePort);
int? PosDevicePort,
string? ReceiptPrintDeviceId,
string? KitchenPrintDeviceId);
public record PosPaymentRequest(string OrderId, decimal Amount);
+93 -1
View File
@@ -40,6 +40,21 @@ public interface IBillingService
string cafeId,
string paymentId,
CancellationToken cancellationToken = default);
/// <summary>Price a plan+months bundle and create a Pending FlatPay SubscriptionPayment
/// (the "order"); the returned id is passed to the broker as client_ref / metadata.payment_id.</summary>
Task<(string? PaymentId, decimal AmountToman, string? ErrorCode, string? Message)> CreateFlatPayOrderAsync(
string cafeId,
PlanTier tier,
int months,
CancellationToken cancellationToken = default);
/// <summary>Grant a FlatPay order after the broker reports it Paid: activate the plan using
/// the same coverage/queueing logic as the other providers. Idempotent.</summary>
Task<bool> CompleteFlatPayAsync(
string paymentId,
string? refId,
CancellationToken cancellationToken = default);
}
public class BillingService : IBillingService
@@ -217,6 +232,16 @@ public class BillingService : IBillingService
payment.RefId = verify.RefId;
await ActivatePaymentAsync(payment, cancellationToken);
return new BillingVerifyResult(true, successUrl);
}
/// <summary>Apply a paid SubscriptionPayment: book it after the current coverage (queued) or
/// activate it now, update the cafe plan, persist, and send the confirmation SMS. Shared by all
/// providers (gateway callbacks and the FlatPay webhook).</summary>
private async Task ActivatePaymentAsync(SubscriptionPayment payment, CancellationToken cancellationToken)
{
var cafe = payment.Cafe;
var now = DateTime.UtcNow;
@@ -244,8 +269,75 @@ public class BillingService : IBillingService
await _db.SaveChangesAsync(cancellationToken);
await TrySendConfirmationSmsAsync(cafe, payment, queued, cancellationToken);
}
return new BillingVerifyResult(true, successUrl);
public async Task<(string? PaymentId, decimal AmountToman, string? ErrorCode, string? Message)> CreateFlatPayOrderAsync(
string cafeId,
PlanTier tier,
int months,
CancellationToken cancellationToken = default)
{
if (months is < 1 or > 36)
return (null, 0m, "INVALID_MONTHS", "Months must be between 1 and 36.");
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
if (cafe is null)
return (null, 0m, "NOT_FOUND", "Cafe not found.");
if (!await _platformCatalog.IsBillableOnlineAsync(tier, cancellationToken))
return (null, 0m, "NOT_BILLABLE", "This plan requires contacting sales.");
var monthly = await _platformCatalog.GetMonthlyPriceTomanAsync(tier, cancellationToken);
if (monthly <= 0)
return (null, 0m, "NOT_BILLABLE", "This plan has no online price.");
var amountToman = monthly * months;
var payment = new SubscriptionPayment
{
CafeId = cafeId,
PlanTier = tier,
Months = months,
AmountToman = amountToman,
AmountRials = PlanPricing.ToRials(amountToman),
Provider = PaymentProvider.FlatPay,
Status = SubscriptionPaymentStatus.Pending,
};
_db.SubscriptionPayments.Add(payment);
await _db.SaveChangesAsync(cancellationToken);
return (payment.Id, amountToman, null, null);
}
public async Task<bool> CompleteFlatPayAsync(
string paymentId,
string? refId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(paymentId))
return false;
var payment = await _db.SubscriptionPayments
.Include(p => p.Cafe)
.FirstOrDefaultAsync(
p => p.Id == paymentId && p.Provider == PaymentProvider.FlatPay,
cancellationToken);
if (payment is null)
{
_logger.LogWarning("FlatPay grant: no pending order {PaymentId}", paymentId);
return false;
}
// Already granted (webhook redelivery / double-process) → idempotent no-op.
if (payment.Status is SubscriptionPaymentStatus.Completed or SubscriptionPaymentStatus.Scheduled)
return true;
payment.RefId = refId;
await ActivatePaymentAsync(payment, cancellationToken);
_logger.LogInformation("FlatPay grant applied: payment {PaymentId} → {Tier} x{Months}m",
payment.Id, payment.PlanTier, payment.Months);
return true;
}
/// <summary>End of the cafe's current paid coverage: the later of its active plan expiry
@@ -33,12 +33,13 @@ public class KitchenStationService : IKitchenStationService
s.PrinterIp,
s.PrinterPort,
s.SortOrder,
s.PrintDeviceId,
CategoryCount = s.Categories.Count(c => c.DeletedAt == null)
})
.ToListAsync(ct);
return stations.Select(s => new KitchenStationDto(
s.Id, s.BranchId, s.Name, s.PrinterIp, s.PrinterPort, s.SortOrder, s.CategoryCount)).ToList();
s.Id, s.BranchId, s.Name, s.PrinterIp, s.PrinterPort, s.SortOrder, s.CategoryCount, s.PrintDeviceId)).ToList();
}
public async Task<KitchenStationDto?> CreateAsync(
@@ -60,6 +61,7 @@ public class KitchenStationService : IKitchenStationService
Name = request.Name.Trim(),
PrinterIp = string.IsNullOrWhiteSpace(request.PrinterIp) ? null : request.PrinterIp.Trim(),
PrinterPort = request.PrinterPort > 0 ? request.PrinterPort : 9100,
PrintDeviceId = string.IsNullOrWhiteSpace(request.PrintDeviceId) ? null : request.PrintDeviceId,
SortOrder = request.SortOrder
};
@@ -95,6 +97,8 @@ public class KitchenStationService : IKitchenStationService
entity.PrinterIp = string.IsNullOrWhiteSpace(request.PrinterIp) ? null : request.PrinterIp.Trim();
if (request.PrinterPort.HasValue)
entity.PrinterPort = request.PrinterPort.Value > 0 ? request.PrinterPort.Value : 9100;
if (request.PrintDeviceId is not null)
entity.PrintDeviceId = string.IsNullOrWhiteSpace(request.PrintDeviceId) ? null : request.PrintDeviceId;
if (request.SortOrder.HasValue)
entity.SortOrder = request.SortOrder.Value;
@@ -114,6 +118,12 @@ public class KitchenStationService : IKitchenStationService
foreach (var cat in categories)
cat.KitchenStationId = null;
var items = await _db.MenuItems
.Where(i => i.KitchenStationId == id && i.CafeId == cafeId)
.ToListAsync(ct);
foreach (var item in items)
item.KitchenStationId = null;
entity.DeletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return true;
@@ -131,12 +141,13 @@ public class KitchenStationService : IKitchenStationService
x.PrinterIp,
x.PrinterPort,
x.SortOrder,
x.PrintDeviceId,
CategoryCount = x.Categories.Count(c => c.DeletedAt == null)
})
.FirstOrDefaultAsync(ct);
return s is null
? null
: new KitchenStationDto(s.Id, s.BranchId, s.Name, s.PrinterIp, s.PrinterPort, s.SortOrder, s.CategoryCount);
: new KitchenStationDto(s.Id, s.BranchId, s.Name, s.PrinterIp, s.PrinterPort, s.SortOrder, s.CategoryCount, s.PrintDeviceId);
}
}
+6 -2
View File
@@ -139,7 +139,8 @@ public class MenuService : IMenuService
ImageUrl = imageUrl,
VideoUrl = request.VideoUrl,
Model3dUrl = NormalizeOptionalText(request.Model3dUrl),
IsAvailable = request.IsAvailable
IsAvailable = request.IsAvailable,
KitchenStationId = string.IsNullOrWhiteSpace(request.KitchenStationId) ? null : request.KitchenStationId,
};
_db.MenuItems.Add(entity);
@@ -178,6 +179,8 @@ public class MenuService : IMenuService
if (request.Model3dUrl is not null)
entity.Model3dUrl = string.IsNullOrWhiteSpace(request.Model3dUrl) ? null : request.Model3dUrl.Trim();
if (request.IsAvailable.HasValue) entity.IsAvailable = request.IsAvailable.Value;
if (request.KitchenStationId is not null)
entity.KitchenStationId = string.IsNullOrWhiteSpace(request.KitchenStationId) ? null : request.KitchenStationId;
await _db.SaveChangesAsync(cancellationToken);
return ToItemDto(entity);
@@ -236,5 +239,6 @@ public class MenuService : IMenuService
MenuItemImageDefaults.ResolveDisplayImageUrl(i),
i.VideoUrl,
i.Model3dUrl,
i.IsAvailable);
i.IsAvailable,
i.KitchenStationId);
}
+23 -3
View File
@@ -172,6 +172,8 @@ public class OrderService : IOrderService
var orders = await _db.Orders
.Include(o => o.Items)
.ThenInclude(i => i.MenuItem)
.ThenInclude(m => m.Category)
.ThenInclude(c => c.KitchenStation)
.Include(o => o.Table)
.Where(o => o.CafeId == cafeId && LiveStatuses.Contains(o.Status))
.OrderBy(o => o.CreatedAt)
@@ -993,9 +995,18 @@ public class OrderService : IOrderService
if (order.Status == OrderStatus.Cancelled)
return new OrderServiceResult<OrderDto>(false, null, "ORDER_ALREADY_CANCELLED");
if (!OpenForPaymentStatuses.Contains(order.Status))
if (order.Status == OrderStatus.Delivered)
return new OrderServiceResult<OrderDto>(false, null, "ORDER_NOT_OPEN");
// Integrity / anti-fraud: once the kitchen has acted on the order
// (Confirmed / Preparing / Ready) the food has been produced, so the order
// can no longer be cancelled/deleted — otherwise a cashier could fire an
// order, take cash without recording a payment, then erase it. Only a
// not-yet-started (Pending) order may be cancelled; a started one must be
// completed (and refunded via the audited refund flow if needed).
if (order.Status != OrderStatus.Pending)
return new OrderServiceResult<OrderDto>(false, null, "ORDER_IN_PREPARATION");
// A paid order must be refunded through the payment flow first — cancelling it
// here would silently strip the recorded money. Block and surface the reason.
if (order.Payments.Any(p => p.DeletedAt == null))
@@ -1037,6 +1048,12 @@ public class OrderService : IOrderService
if (order is null)
return new OrderServiceResult<IReadOnlyList<PaymentDto>>(false, null, "ORDER_NOT_FOUND");
// Never take payment on an already-closed order — a double-tap on Pay, or
// paying a closed order reopened from the board, would otherwise record
// duplicate payments, re-earn loyalty, reprint, and overstate the drawer.
if (order.Status is OrderStatus.Delivered or OrderStatus.Cancelled)
return new OrderServiceResult<IReadOnlyList<PaymentDto>>(false, null, "ORDER_ALREADY_CLOSED");
var branchId = await ResolveOrderBranchIdAsync(order, cafeId, cancellationToken);
if (string.IsNullOrEmpty(branchId))
return new OrderServiceResult<IReadOnlyList<PaymentDto>>(false, null, "NO_OPEN_SHIFT", "branchId");
@@ -1123,7 +1140,8 @@ public class OrderService : IOrderService
if (paidTotal >= order.Total)
{
PrinterBackgroundJobs.QueueReceiptPrint(_scopeFactory, cafeId, orderId);
// Receipt is printed explicitly from the POS success sheet (single
// print path) — no auto-print here, to avoid a duplicate receipt.
await _loyalty.ApplyEarnOnOrderPaidAsync(cafeId, order.CustomerId, paidTotal, cancellationToken);
await _deliverySync.SyncInternalStatusAsync(cafeId, orderId, OrderStatus.Delivered, cancellationToken);
}
@@ -1345,6 +1363,8 @@ public class OrderService : IOrderService
i.UnitPrice,
i.Notes,
i.IsVoided,
i.VoidedAt)).ToList(),
i.VoidedAt,
i.MenuItem?.Category?.KitchenStationId,
i.MenuItem?.Category?.KitchenStation?.Name)).ToList(),
o.Source);
}
@@ -0,0 +1,151 @@
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;
namespace Meezi.API.Services.Payments;
public sealed class FlatPayOptions
{
public const string SectionName = "FlatPay";
public string ApiKey { get; set; } = "";
public string Secret { get; set; } = "";
public string BaseUrl { get; set; } = "https://pay.flatrender.ir";
public string ReturnUrl { get; set; } = "https://meezi.ir/payment/return";
}
/// <summary>
/// Client for the FlatRender Pay broker (a ZarinPal front). Requests are authenticated
/// with <c>X-Api-Key</c> + <c>X-Signature</c> = hex(HMAC-SHA256(secret, raw JSON bytes));
/// webhooks are verified the same way. The signature is computed over the EXACT bytes
/// that are sent/received, so we serialize once and reuse the buffer.
/// </summary>
public interface IFlatPayService
{
/// <summary>Create a payment at the broker and return its hosted payment URL (null on failure).
/// <paramref name="clientRef"/> is echoed back and also embedded in metadata.payment_id.</summary>
Task<string?> RequestAsync(
string userId, string productId, long amountToman, string description, string clientRef,
CancellationToken ct = default);
/// <summary>Fixed-time compare hex(HMAC(secret, rawBytes)) against the webhook signature header.</summary>
bool VerifyWebhook(byte[] rawBytes, string? signature);
/// <summary>Idempotency: true only the first time a given broker payment id is seen.</summary>
bool TryMarkProcessed(string id);
}
public sealed class FlatPayService : IFlatPayService
{
private readonly HttpClient _http;
private readonly FlatPayOptions _opts;
private readonly ILogger<FlatPayService> _logger;
// Webhooks can be redelivered; remember the broker ids we've already granted.
private readonly ConcurrentDictionary<string, byte> _seen = new();
public FlatPayService(HttpClient http, IOptions<FlatPayOptions> opts, ILogger<FlatPayService> logger)
{
_http = http;
_opts = opts.Value;
_logger = logger;
}
public async Task<string?> RequestAsync(
string userId, string productId, long amountToman, string description, string clientRef,
CancellationToken ct = default)
{
var body = new PayRequestBody(
amountToman,
"IRT",
description,
clientRef,
_opts.ReturnUrl,
new PayMetadata(userId, productId, clientRef));
// Serialize once: these exact bytes are both signed and sent.
var bytes = JsonSerializer.SerializeToUtf8Bytes(body);
using var req = new HttpRequestMessage(HttpMethod.Post, "/v1/pay/request");
req.Content = new ByteArrayContent(bytes);
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
req.Headers.TryAddWithoutValidation("X-Api-Key", _opts.ApiKey);
req.Headers.TryAddWithoutValidation("X-Signature", Sign(bytes));
try
{
using var resp = await _http.SendAsync(req, ct);
var respBody = await resp.Content.ReadAsStringAsync(ct);
if (!resp.IsSuccessStatusCode)
{
_logger.LogError("FlatPay /v1/pay/request failed {Status}: {Body}", (int)resp.StatusCode, respBody);
return null;
}
using var doc = JsonDocument.Parse(respBody);
var url = ExtractPaymentUrl(doc.RootElement);
if (string.IsNullOrEmpty(url))
_logger.LogError("FlatPay request returned no payment_url: {Body}", respBody);
return url;
}
catch (Exception ex)
{
_logger.LogError(ex, "FlatPay request error");
return null;
}
}
public bool VerifyWebhook(byte[] rawBytes, string? signature)
{
if (string.IsNullOrWhiteSpace(signature)) return false;
var expected = Sign(rawBytes);
var provided = signature.Trim().ToLowerInvariant();
// Compare the ascii hex digests in fixed time.
var a = Encoding.ASCII.GetBytes(expected);
var b = Encoding.ASCII.GetBytes(provided);
return a.Length == b.Length && CryptographicOperations.FixedTimeEquals(a, b);
}
public bool TryMarkProcessed(string id) =>
!string.IsNullOrEmpty(id) && _seen.TryAdd(id, 0);
private string Sign(byte[] body)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_opts.Secret));
return Convert.ToHexString(hmac.ComputeHash(body)).ToLowerInvariant();
}
private static string? ExtractPaymentUrl(JsonElement root)
{
if (TryGetString(root, "payment_url") is { } direct) return direct;
// Some broker responses nest the result under "data".
if (root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object)
return TryGetString(data, "payment_url");
return null;
}
private static string? TryGetString(JsonElement el, string name) =>
el.ValueKind == JsonValueKind.Object
&& el.TryGetProperty(name, out var v)
&& v.ValueKind == JsonValueKind.String
? v.GetString()
: null;
private sealed record PayRequestBody(
[property: JsonPropertyName("amount")] long Amount,
[property: JsonPropertyName("currency")] string Currency,
[property: JsonPropertyName("description")] string Description,
[property: JsonPropertyName("client_ref")] string ClientRef,
[property: JsonPropertyName("return_url")] string ReturnUrl,
[property: JsonPropertyName("metadata")] PayMetadata Metadata);
private sealed record PayMetadata(
[property: JsonPropertyName("user_id")] string UserId,
[property: JsonPropertyName("product_id")] string ProductId,
[property: JsonPropertyName("payment_id")] string PaymentId);
}
+50 -2
View File
@@ -1,6 +1,7 @@
using System.Net.Http.Json;
using System.Text.Json;
using Meezi.API.Models.Printing;
using Meezi.API.Services.Printing;
using Meezi.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
@@ -29,15 +30,18 @@ public class PosDeviceService : IPosDeviceService
private readonly AppDbContext _db;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IPrintAgentRegistry _agents;
private readonly ILogger<PosDeviceService> _logger;
public PosDeviceService(
AppDbContext db,
IHttpClientFactory httpClientFactory,
IPrintAgentRegistry agents,
ILogger<PosDeviceService> logger)
{
_db = db;
_httpClientFactory = httpClientFactory;
_agents = agents;
_logger = logger;
}
@@ -71,14 +75,31 @@ public class PosDeviceService : IPosDeviceService
if (order is null)
return PosDeviceResult.Fail("ORDER_NOT_FOUND");
var amount = (long)Math.Round(request.Amount, 0, MidpointRounding.AwayFromZero);
var ip = branch.PosDeviceIp!.Trim();
// Prefer relaying through a local print agent on the café LAN — the cloud
// can't reach the terminal's private IP directly (same reason the agent
// exists for printers). Fall back to a direct call only on-prem / when no
// agent is connected.
var agentId = await ResolveOnlineAgentAsync(cafeId, branchId, ct);
if (agentId is not null)
{
var outcome = await _agents.SendPaymentAsync(agentId, ip, port, amount, request.OrderId, ct);
if (outcome.Success)
return PosDeviceResult.Ok();
_logger.LogWarning("Agent-relayed POS payment failed ({Agent}): {Error}", agentId, outcome.Error);
return PosDeviceResult.Fail(MapAgentError(outcome.Error), outcome.Error);
}
var payload = new
{
amount = (long)Math.Round(request.Amount, 0, MidpointRounding.AwayFromZero),
amount,
orderId = request.OrderId,
branchId,
};
var url = $"http://{branch.PosDeviceIp!.Trim()}:{port}/pay";
var url = $"http://{ip}:{port}/pay";
try
{
@@ -117,4 +138,31 @@ public class PosDeviceService : IPosDeviceService
return PosDeviceResult.Fail("POS_DEVICE_CONNECTION_FAILED", ex.Message);
}
}
/// <summary>The online agent best placed to reach this branch's terminal — one
/// bound to the branch if present, else any online agent of the café.</summary>
private async Task<string?> ResolveOnlineAgentAsync(string cafeId, string branchId, CancellationToken ct)
{
var online = _agents.OnlineAgentIdsForCafe(cafeId);
if (online.Count == 0) return null;
var agents = await _db.PrintAgents
.AsNoTracking()
.Where(a => a.CafeId == cafeId && !a.Revoked && a.DeletedAt == null)
.ToListAsync(ct);
return agents.FirstOrDefault(a => a.BranchId == branchId && online.Contains(a.Id))?.Id
?? agents.FirstOrDefault(a => online.Contains(a.Id))?.Id;
}
/// <summary>Normalize an agent-relay error string back to a POS_DEVICE_* code.</summary>
private static string MapAgentError(string? error) => error switch
{
null or "" => "POS_DEVICE_FAILED",
var e when e.Contains("TIMEOUT", StringComparison.OrdinalIgnoreCase) => "POS_DEVICE_TIMEOUT",
var e when e.StartsWith("POS_DEVICE_", StringComparison.Ordinal) => e.Split(':')[0],
var e when e.Contains("REJECT", StringComparison.OrdinalIgnoreCase) => "POS_DEVICE_REJECTED",
var e when e.Contains("OFFLINE", StringComparison.OrdinalIgnoreCase) => "POS_DEVICE_CONNECTION_FAILED",
_ => "POS_DEVICE_CONNECTION_FAILED",
};
}
@@ -19,8 +19,10 @@ public interface IPrinterService
Task<PrintResult> PrintKitchenTicketAsync(
string cafeId,
string orderId,
string? stationId = null,
CancellationToken ct = default);
Task<PrintResult> TestPrintAsync(string printerIp, int port, CancellationToken ct = default);
Task<PrintResult> TestPrintDeviceAsync(string cafeId, string deviceId, CancellationToken ct = default);
}
public class NetworkPrinterService : IPrinterService
@@ -28,17 +30,20 @@ public class NetworkPrinterService : IPrinterService
private readonly AppDbContext _db;
private readonly IOrderService _orders;
private readonly ReceiptBuilder _receiptBuilder;
private readonly IPrintAgentRegistry _agents;
private readonly ILogger<NetworkPrinterService> _logger;
public NetworkPrinterService(
AppDbContext db,
IOrderService orders,
ReceiptBuilder receiptBuilder,
IPrintAgentRegistry agents,
ILogger<NetworkPrinterService> logger)
{
_db = db;
_orders = orders;
_receiptBuilder = receiptBuilder;
_agents = agents;
_logger = logger;
}
@@ -48,13 +53,16 @@ public class NetworkPrinterService : IPrinterService
if (ctx is null)
return PrintResult.Fail("ORDER_NOT_FOUND");
if (string.IsNullOrWhiteSpace(ctx.Value.branch.ReceiptPrinterIp))
var branch = ctx.Value.branch;
if (string.IsNullOrWhiteSpace(branch.ReceiptPrintDeviceId) && string.IsNullOrWhiteSpace(branch.ReceiptPrinterIp))
return PrintResult.Fail("PRINTER_NOT_CONFIGURED");
var bytes = _receiptBuilder.BuildReceipt(ctx.Value.printCtx);
return await SendToPrinterAsync(
ctx.Value.branch.ReceiptPrinterIp!,
ctx.Value.branch.ReceiptPrinterPort ?? 9100,
return await DispatchAsync(
cafeId,
branch.ReceiptPrintDeviceId,
branch.ReceiptPrinterIp,
branch.ReceiptPrinterPort ?? 9100,
bytes,
ct);
}
@@ -62,6 +70,7 @@ public class NetworkPrinterService : IPrinterService
public async Task<PrintResult> PrintKitchenTicketAsync(
string cafeId,
string orderId,
string? stationId = null,
CancellationToken ct = default)
{
var ctx = await BuildContextAsync(cafeId, orderId, ct);
@@ -74,15 +83,16 @@ public class NetworkPrinterService : IPrinterService
return PrintResult.Ok();
var menuItemIds = activeItems.Select(i => i.MenuItemId).Distinct().ToList();
var categoryStations = await (
// Per-item station overrides the category's station; fall back to category.
var itemStations = await (
from m in _db.MenuItems.AsNoTracking()
join c in _db.MenuCategories.AsNoTracking() on m.CategoryId equals c.Id
where menuItemIds.Contains(m.Id) && m.CafeId == cafeId
select new { m.Id, c.KitchenStationId }
select new { m.Id, StationId = m.KitchenStationId ?? c.KitchenStationId }
).ToListAsync(ct);
var stationIds = categoryStations
.Select(x => x.KitchenStationId)
var stationIds = itemStations
.Select(x => x.StationId)
.Where(id => !string.IsNullOrEmpty(id))
.Distinct()
.ToList();
@@ -97,11 +107,19 @@ public class NetworkPrinterService : IPrinterService
var groups = activeItems
.GroupBy(item =>
{
var cat = categoryStations.FirstOrDefault(c => c.Id == item.MenuItemId);
return cat?.KitchenStationId;
var map = itemStations.FirstOrDefault(c => c.Id == item.MenuItemId);
return map?.StationId;
})
.ToList();
// Optionally reprint a single station only (e.g. just the bar ticket).
if (!string.IsNullOrEmpty(stationId))
{
groups = groups.Where(g => g.Key == stationId).ToList();
if (groups.Count == 0)
return PrintResult.Fail("NO_STATION_ITEMS");
}
PrintResult? lastFail = null;
var anyPrinted = false;
@@ -111,18 +129,21 @@ public class NetworkPrinterService : IPrinterService
? null
: stations.FirstOrDefault(s => s.Id == group.Key);
string? deviceId;
string? ip;
int port;
string? stationLabel = null;
if (station is not null && !string.IsNullOrWhiteSpace(station.PrinterIp))
if (station is not null && (!string.IsNullOrWhiteSpace(station.PrintDeviceId) || !string.IsNullOrWhiteSpace(station.PrinterIp)))
{
deviceId = station.PrintDeviceId;
ip = station.PrinterIp;
port = station.PrinterPort;
stationLabel = station.Name;
}
else if (!string.IsNullOrWhiteSpace(ctx.Value.branch.KitchenPrinterIp))
else if (!string.IsNullOrWhiteSpace(ctx.Value.branch.KitchenPrintDeviceId) || !string.IsNullOrWhiteSpace(ctx.Value.branch.KitchenPrinterIp))
{
deviceId = ctx.Value.branch.KitchenPrintDeviceId;
ip = ctx.Value.branch.KitchenPrinterIp;
port = ctx.Value.branch.KitchenPrinterPort ?? 9100;
}
@@ -136,7 +157,7 @@ public class NetworkPrinterService : IPrinterService
var bytes = _receiptBuilder.BuildKitchenTicket(
ctx.Value.printCtx with { StationName = stationLabel },
itemsOnly);
var result = await SendToPrinterAsync(ip!, port, bytes, ct);
var result = await DispatchAsync(cafeId, deviceId, ip, port, bytes, ct);
if (result.Success)
anyPrinted = true;
else
@@ -155,6 +176,66 @@ public class NetworkPrinterService : IPrinterService
return await SendToPrinterAsync(printerIp.Trim(), port, bytes, ct);
}
public async Task<PrintResult> TestPrintDeviceAsync(string cafeId, string deviceId, CancellationToken ct = default)
{
var device = await _db.PrintDevices.AsNoTracking()
.FirstOrDefaultAsync(d => d.Id == deviceId && d.CafeId == cafeId, ct);
if (device is null)
return PrintResult.Fail("DEVICE_NOT_FOUND");
if (!_agents.IsOnline(device.AgentId))
return PrintResult.Fail("AGENT_OFFLINE");
var bytes = _receiptBuilder.BuildTestPage();
var outcome = await _agents.SendJobAsync(device.AgentId, new PrintJobRequest(device.SystemName, bytes), ct);
return outcome.Success ? PrintResult.Ok() : PrintResult.Fail("AGENT_PRINT_FAILED", outcome.Error);
}
/// <summary>
/// Send bytes to a printer, preferring a local print agent when one is mapped and
/// online (the only way to reach a LAN/USB printer from the cloud); otherwise fall
/// back to a direct TCP connection (on-prem deployments / reachable printers).
/// </summary>
private async Task<PrintResult> DispatchAsync(
string cafeId,
string? deviceId,
string? ip,
int port,
byte[] bytes,
CancellationToken ct)
{
if (!string.IsNullOrWhiteSpace(deviceId))
{
var device = await _db.PrintDevices.AsNoTracking()
.FirstOrDefaultAsync(d => d.Id == deviceId && d.CafeId == cafeId, ct);
if (device is not null && _agents.IsOnline(device.AgentId))
{
var outcome = await _agents.SendJobAsync(
device.AgentId, new PrintJobRequest(device.SystemName, bytes), ct);
if (outcome.Success)
{
_logger.LogInformation("Printed {Bytes} bytes via agent {Agent} → {Printer}",
bytes.Length, device.AgentId, device.SystemName);
return PrintResult.Ok();
}
_logger.LogWarning("Agent print failed ({Printer}): {Error}", device.SystemName, outcome.Error);
// Only surface the failure if there's no IP to fall back to.
if (string.IsNullOrWhiteSpace(ip))
return PrintResult.Fail("AGENT_PRINT_FAILED", outcome.Error);
}
else if (string.IsNullOrWhiteSpace(ip))
{
return PrintResult.Fail("AGENT_OFFLINE");
}
// Agent offline/missing but an IP is configured → fall through to TCP.
}
if (!string.IsNullOrWhiteSpace(ip))
return await SendToPrinterAsync(ip!.Trim(), port, bytes, ct);
return PrintResult.Fail("PRINTER_NOT_CONFIGURED");
}
private async Task<(Branch branch, ReceiptPrintContext printCtx)?> BuildContextAsync(
string cafeId,
string orderId,
@@ -243,7 +324,7 @@ public static class PrinterBackgroundJobs
try
{
var printer = scope.ServiceProvider.GetRequiredService<IPrinterService>();
var result = await printer.PrintKitchenTicketAsync(cafeId, orderId, CancellationToken.None);
var result = await printer.PrintKitchenTicketAsync(cafeId, orderId, null, CancellationToken.None);
if (!result.Success)
logger.LogWarning("Kitchen print failed for {OrderId}: {Code}", orderId, result.ErrorCode);
}
@@ -0,0 +1,177 @@
using System.Collections.Concurrent;
using Microsoft.AspNetCore.SignalR;
using Meezi.API.Hubs;
namespace Meezi.API.Services.Printing;
public record PrintJobRequest(string PrinterSystemName, byte[] Payload);
public record PrintJobOutcome(bool Success, string? Error);
/// <summary>A host the agent found on the café LAN responding on a probed port
/// (a network printer on :9100, a card terminal on :8088, …).</summary>
public record DiscoveredDevice(string Ip, int Port, string Kind);
/// <summary>
/// Tracks which print agents are currently connected (by SignalR connection) and
/// dispatches print jobs to them, awaiting the agent's acknowledgement. In-memory:
/// a dropped process simply means agents reconnect and re-register.
/// </summary>
public interface IPrintAgentRegistry
{
void Register(string connectionId, string agentId, string cafeId);
void Unregister(string connectionId);
(string AgentId, string CafeId)? Resolve(string connectionId);
bool IsOnline(string agentId);
IReadOnlySet<string> OnlineAgentIds();
/// <summary>Online agents belonging to a café — used to pick a LAN bridge for a
/// card-terminal payment or a network scan.</summary>
IReadOnlySet<string> OnlineAgentIdsForCafe(string cafeId);
Task<PrintJobOutcome> SendJobAsync(string agentId, PrintJobRequest job, CancellationToken ct = default);
void CompleteJob(string jobId, bool success, string? error);
/// <summary>Relay a card-terminal payment through the agent on the café LAN; it
/// POSTs the amount to the terminal at ip:port and acks the approval result.</summary>
Task<PrintJobOutcome> SendPaymentAsync(
string agentId, string ip, int port, long amount, string orderId, CancellationToken ct = default);
/// <summary>Ask the agent to scan its LAN for hosts answering on the given ports.</summary>
Task<IReadOnlyList<DiscoveredDevice>> ScanAsync(
string agentId, string ports, CancellationToken ct = default);
void CompleteScan(string requestId, IReadOnlyList<DiscoveredDevice> devices);
}
public class PrintAgentRegistry : IPrintAgentRegistry
{
private readonly IHubContext<PrintAgentHub> _hub;
private readonly ConcurrentDictionary<string, (string AgentId, string CafeId)> _byConnection = new();
private readonly ConcurrentDictionary<string, string> _agentConnection = new(); // agentId -> connectionId
private readonly ConcurrentDictionary<string, TaskCompletionSource<PrintJobOutcome>> _pending = new();
private readonly ConcurrentDictionary<string, TaskCompletionSource<IReadOnlyList<DiscoveredDevice>>> _pendingScans = new();
public PrintAgentRegistry(IHubContext<PrintAgentHub> hub) => _hub = hub;
public void Register(string connectionId, string agentId, string cafeId)
{
_byConnection[connectionId] = (agentId, cafeId);
_agentConnection[agentId] = connectionId;
}
public void Unregister(string connectionId)
{
if (!_byConnection.TryRemove(connectionId, out var info)) return;
// Only drop the agent→connection mapping if it still points at this socket
// (a fast reconnect may already have replaced it with a newer one).
if (_agentConnection.TryGetValue(info.AgentId, out var current) && current == connectionId)
_agentConnection.TryRemove(info.AgentId, out _);
}
public (string AgentId, string CafeId)? Resolve(string connectionId) =>
_byConnection.TryGetValue(connectionId, out var info) ? info : null;
public bool IsOnline(string agentId) => _agentConnection.ContainsKey(agentId);
public IReadOnlySet<string> OnlineAgentIds() => _agentConnection.Keys.ToHashSet();
public IReadOnlySet<string> OnlineAgentIdsForCafe(string cafeId) =>
_byConnection.Values
.Where(v => v.CafeId == cafeId)
.Select(v => v.AgentId)
.ToHashSet();
public async Task<PrintJobOutcome> SendJobAsync(string agentId, PrintJobRequest job, CancellationToken ct = default)
{
if (!_agentConnection.TryGetValue(agentId, out var connectionId))
return new PrintJobOutcome(false, "AGENT_OFFLINE");
var jobId = Guid.NewGuid().ToString("N");
var tcs = new TaskCompletionSource<PrintJobOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending[jobId] = tcs;
try
{
await _hub.Clients.Client(connectionId).SendAsync(
"PrintJob", jobId, job.PrinterSystemName, Convert.ToBase64String(job.Payload), ct);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(20));
using var reg = timeout.Token.Register(() => tcs.TrySetResult(new PrintJobOutcome(false, "TIMEOUT")));
return await tcs.Task;
}
catch (Exception ex)
{
return new PrintJobOutcome(false, ex.Message);
}
finally
{
_pending.TryRemove(jobId, out _);
}
}
public void CompleteJob(string jobId, bool success, string? error)
{
if (_pending.TryGetValue(jobId, out var tcs))
tcs.TrySetResult(new PrintJobOutcome(success, error));
}
public async Task<PrintJobOutcome> SendPaymentAsync(
string agentId, string ip, int port, long amount, string orderId, CancellationToken ct = default)
{
if (!_agentConnection.TryGetValue(agentId, out var connectionId))
return new PrintJobOutcome(false, "AGENT_OFFLINE");
var requestId = Guid.NewGuid().ToString("N");
var tcs = new TaskCompletionSource<PrintJobOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending[requestId] = tcs;
try
{
await _hub.Clients.Client(connectionId).SendAsync(
"PaymentRequest", requestId, ip, port, amount, orderId, ct);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
// Card payment waits on the customer at the terminal — give it the same
// headroom the direct path uses.
timeout.CancelAfter(TimeSpan.FromSeconds(95));
using var reg = timeout.Token.Register(() => tcs.TrySetResult(new PrintJobOutcome(false, "POS_DEVICE_TIMEOUT")));
return await tcs.Task;
}
catch (Exception ex)
{
return new PrintJobOutcome(false, ex.Message);
}
finally
{
_pending.TryRemove(requestId, out _);
}
}
public async Task<IReadOnlyList<DiscoveredDevice>> ScanAsync(
string agentId, string ports, CancellationToken ct = default)
{
if (!_agentConnection.TryGetValue(agentId, out var connectionId))
return [];
var requestId = Guid.NewGuid().ToString("N");
var tcs = new TaskCompletionSource<IReadOnlyList<DiscoveredDevice>>(TaskCreationOptions.RunContinuationsAsynchronously);
_pendingScans[requestId] = tcs;
try
{
await _hub.Clients.Client(connectionId).SendAsync("ScanNetwork", requestId, ports, ct);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(30));
using var reg = timeout.Token.Register(() => tcs.TrySetResult([]));
return await tcs.Task;
}
catch
{
return [];
}
finally
{
_pendingScans.TryRemove(requestId, out _);
}
}
public void CompleteScan(string requestId, IReadOnlyList<DiscoveredDevice> devices)
{
if (_pendingScans.TryGetValue(requestId, out var tcs))
tcs.TrySetResult(devices);
}
}
+6
View File
@@ -44,6 +44,12 @@
"MerchantId": "",
"Sandbox": true
},
"FlatPay": {
"BaseUrl": "https://pay.flatrender.ir",
"ReturnUrl": "https://meezi.ir/payment/return",
"ApiKey": "",
"Secret": ""
},
"Billing": {
"DashboardBaseUrl": "http://localhost:3101"
},
@@ -32,6 +32,23 @@ public class AdminCafesController : AdminApiControllerBase
return Ok(new ApiResponse<object>(true, new { cafeId }));
}
/// <summary>Gift a café a free subscription (set plan + add N months of coverage).</summary>
[HttpPost("{cafeId}/grant-subscription")]
public async Task<IActionResult> GrantSubscription(
string cafeId,
[FromBody] AdminGrantSubscriptionRequest request,
CancellationToken cancellationToken)
{
if (request.Months is < 1 or > 120)
return BadRequest(new ApiResponse<object>(false, null, new ApiError("INVALID_MONTHS", "Months must be 1120.")));
var ok = await _platform.GrantSubscriptionAsync(cafeId, request.PlanTier, request.Months, cancellationToken);
if (!ok)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found or invalid plan.")));
return Ok(new ApiResponse<object>(true, new { cafeId }));
}
[HttpPut("{cafeId}/features")]
public async Task<IActionResult> SetFeature(
string cafeId,
+4
View File
@@ -50,4 +50,8 @@ public record AdminCafePatchRequest(
bool? IsVerified,
IReadOnlyList<string>? DiscoverBadges = null);
/// <summary>Admin gifts a café a free subscription: set the plan and add <see cref="Months"/>
/// of coverage (appended to any time it already has).</summary>
public record AdminGrantSubscriptionRequest(PlanTier PlanTier, int Months);
public record CafeFeatureOverrideRequest(string FeatureKey, bool IsEnabled);
@@ -24,6 +24,7 @@ public interface IAdminPlatformService
Task<bool> UpdateFeatureAsync(string featureKey, UpdateFeatureRequest request, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AdminCafeListItemDto>> ListCafesAsync(CancellationToken cancellationToken = default);
Task<bool> PatchCafeAsync(string cafeId, AdminCafePatchRequest request, CancellationToken cancellationToken = default);
Task<bool> GrantSubscriptionAsync(string cafeId, PlanTier tier, int months, CancellationToken cancellationToken = default);
Task<(bool Success, string? Key, string? ErrorCode)> GenerateRecoveryKeyAsync(string cafeId, CancellationToken cancellationToken = default);
Task<bool> RevokeRecoveryKeyAsync(string cafeId, CancellationToken cancellationToken = default);
Task<bool> SetCafeFeatureOverrideAsync(string cafeId, CafeFeatureOverrideRequest request, CancellationToken cancellationToken = default);
@@ -207,6 +208,44 @@ public class AdminPlatformService : IAdminPlatformService
return true;
}
public async Task<bool> GrantSubscriptionAsync(
string cafeId, PlanTier tier, int months, CancellationToken cancellationToken = default)
{
if (tier == PlanTier.Free || months is < 1 or > 120)
return false;
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
if (cafe is null) return false;
var now = DateTime.UtcNow;
// Append to existing paid coverage so a grant never shortens time the café already has.
var coverageEnd = cafe.PlanTier != PlanTier.Free && cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value > now
? cafe.PlanExpiresAt.Value
: now;
var newExpiry = coverageEnd.AddMonths(months);
cafe.PlanTier = tier;
cafe.PlanExpiresAt = newExpiry;
// Record the gift for billing history / audit (free → amount 0, provider Manual).
_db.SubscriptionPayments.Add(new SubscriptionPayment
{
CafeId = cafeId,
PlanTier = tier,
Months = months,
AmountToman = 0m,
AmountRials = 0,
Provider = PaymentProvider.Manual,
Status = SubscriptionPaymentStatus.Completed,
EffectiveFrom = now,
EffectiveTo = newExpiry,
RefId = "admin-grant",
});
await _db.SaveChangesAsync(cancellationToken);
return true;
}
public async Task<bool> SetCafeFeatureOverrideAsync(
string cafeId,
CafeFeatureOverrideRequest request,
+116 -18
View File
@@ -5,37 +5,135 @@ namespace Meezi.Core.Authorization;
/// truth for authorization — controllers check a <see cref="Permission"/> rather
/// than hard-coding role names, so the role→capability mapping lives in exactly
/// one place (<see cref="RolePermissions"/>).
///
/// Granularity is "full CRUD per module + distinct sensitive actions": each page
/// has a View capability, record modules split Create/Edit/Delete, and high-risk
/// operations (void, refund, discount, comp, cash drawer, export) are their own
/// permissions so an owner can grant day-to-day work without the dangerous bits.
///
/// Names are persisted (custom roles store them by name in JSON, and they ride in
/// the JWT). Renaming or removing a value is a breaking change — add, don't rename.
/// </summary>
public enum Permission
{
// Café-level administration (Owner only)
// ── Café administration (owner tier) ──────────────────────────────────────
ViewCafeSettings,
ManageCafeSettings,
ManageDiscoverProfile,
ViewBilling,
ManageBilling,
ManageBranches,
// Management (Owner + Manager)
ManageStaff,
ManageMenu,
ManageInventory,
ManageExpenses,
ManageTaxes,
ManageCoupons,
ManageReservations,
ManageTables,
ViewReports,
ReviewLeave,
ManageSalaries,
ViewBranches,
CreateBranch,
EditBranch,
DeleteBranch,
ManageRoles,
ViewPrintSettings,
ManagePrintSettings,
// Front-of-house operations
// ── Taxes ─────────────────────────────────────────────────────────────────
ViewTaxes,
CreateTax,
EditTax,
DeleteTax,
// ── Staff & HR ──────────────────────────────────────────────────────────────
ViewStaff,
CreateStaff,
EditStaff,
DeleteStaff,
/// <summary>Assign per-branch roles / org structure (distinct from editing a record).</summary>
ManageStaff,
ManageStaffCredentials,
ViewAttendance,
ManageAttendance,
ViewSchedules,
ManageSchedules,
ViewLeave,
ReviewLeave,
ViewSalaries,
ManageSalaries,
// ── Menu ──────────────────────────────────────────────────────────────────
ViewMenu,
CreateMenuItem,
EditMenuItem,
DeleteMenuItem,
// ── Inventory ───────────────────────────────────────────────────────────────
ViewInventory,
CreateInventory,
EditInventory,
DeleteInventory,
// ── Tables ──────────────────────────────────────────────────────────────────
ViewTables,
ManageTables,
// ── Reservations ──────────────────────────────────────────────────────────
ViewReservations,
CreateReservation,
EditReservation,
DeleteReservation,
// ── Orders & POS ──────────────────────────────────────────────────────────
ViewOrders,
ProcessOrders,
EditOrder,
VoidOrder,
RefundOrder,
ApplyDiscount,
CompOrder,
HandlePayments,
UpdateOrderStatus,
// ── Register / cash ──────────────────────────────────────────────────────
OperateRegister,
OpenCashDrawer,
// ── Queue ─────────────────────────────────────────────────────────────────
ViewQueue,
ManageQueue,
// Kitchen
// ── Kitchen ───────────────────────────────────────────────────────────────
ViewKitchen,
ManageKitchenStations,
// Delivery
// ── Delivery ──────────────────────────────────────────────────────────────
ViewDelivery,
HandleDelivery,
AssignDelivery,
// ── Customers / CRM ───────────────────────────────────────────────────────
ViewCustomers,
CreateCustomer,
EditCustomer,
DeleteCustomer,
// ── Coupons ───────────────────────────────────────────────────────────────
ViewCoupons,
CreateCoupon,
EditCoupon,
DeleteCoupon,
// ── SMS / marketing ──────────────────────────────────────────────────────
ViewSms,
SendSms,
ManageSmsSettings,
// ── Reviews ───────────────────────────────────────────────────────────────
ViewReviews,
ManageReviews,
// ── Reports & finance ─────────────────────────────────────────────────────
ViewReports,
ExportReports,
ViewAuditLog,
ViewFinancials,
ManageFinancials,
// ── Expenses ──────────────────────────────────────────────────────────────
ViewExpenses,
CreateExpense,
EditExpense,
DeleteExpense,
}
+38 -33
View File
@@ -1,64 +1,66 @@
using Meezi.Core.Enums;
using static Meezi.Core.Authorization.Permission;
namespace Meezi.Core.Authorization;
/// <summary>
/// The authoritative role→capability matrix. Change what a role can do here and
/// every controller that calls <c>EnsurePermission</c> updates automatically.
/// The authoritative role→capability matrix. Change what a base role can do here
/// and every controller that calls <c>EnsurePermission</c> updates automatically.
/// Owners customise further with custom roles (which override this matrix entirely).
/// </summary>
public static class RolePermissions
{
/// <summary>Capabilities reserved to the Owner — the rest is the Manager baseline.</summary>
private static readonly HashSet<Permission> OwnerOnly = new()
{
ManageCafeSettings,
ManageDiscoverProfile,
ViewBilling,
ManageBilling,
CreateBranch,
EditBranch,
DeleteBranch,
ManageRoles,
};
private static readonly IReadOnlyDictionary<EmployeeRole, HashSet<Permission>> Matrix =
new Dictionary<EmployeeRole, HashSet<Permission>>
{
[EmployeeRole.Owner] = AllPermissions(),
[EmployeeRole.Manager] = new()
{
Permission.ManageStaff,
Permission.ManageMenu,
Permission.ManageInventory,
Permission.ManageExpenses,
Permission.ManageTaxes,
Permission.ManageCoupons,
Permission.ManageReservations,
Permission.ManageTables,
Permission.ViewReports,
Permission.ReviewLeave,
Permission.ManageSalaries,
Permission.ManagePrintSettings,
Permission.ProcessOrders,
Permission.HandlePayments,
Permission.OperateRegister,
Permission.ManageQueue,
Permission.ViewKitchen,
Permission.HandleDelivery,
},
// Manager runs the café day to day: everything except the owner-only
// governance (billing, branches, café identity, role definitions).
[EmployeeRole.Manager] = AllExcept(OwnerOnly),
[EmployeeRole.Cashier] = new()
{
Permission.ProcessOrders,
Permission.HandlePayments,
Permission.OperateRegister,
Permission.ManageQueue,
Permission.ManageReservations,
ViewOrders, ProcessOrders, EditOrder, HandlePayments, UpdateOrderStatus,
OperateRegister, OpenCashDrawer,
ViewQueue, ManageQueue,
ViewTables,
ViewReservations, CreateReservation, EditReservation,
ViewMenu,
ViewCustomers, CreateCustomer,
ViewCoupons,
},
[EmployeeRole.Waiter] = new()
{
Permission.ProcessOrders,
Permission.ManageReservations,
Permission.ManageQueue,
ViewOrders, ProcessOrders, EditOrder, UpdateOrderStatus,
ViewTables,
ViewMenu,
ViewReservations, CreateReservation, EditReservation,
ViewQueue, ManageQueue,
},
[EmployeeRole.Chef] = new()
{
Permission.ViewKitchen,
ViewKitchen, UpdateOrderStatus, ViewOrders, ViewMenu,
},
[EmployeeRole.Delivery] = new()
{
Permission.HandleDelivery,
ViewDelivery, HandleDelivery, ViewOrders,
},
};
@@ -73,4 +75,7 @@ public static class RolePermissions
private static HashSet<Permission> AllPermissions() =>
new(Enum.GetValues<Permission>());
private static HashSet<Permission> AllExcept(HashSet<Permission> excluded) =>
new(Enum.GetValues<Permission>().Where(p => !excluded.Contains(p)));
}
+5
View File
@@ -18,6 +18,11 @@ public class Branch : TenantEntity
public int? ReceiptPrinterPort { get; set; }
public string? KitchenPrinterIp { get; set; }
public int? KitchenPrinterPort { get; set; }
/// <summary>Optional <see cref="PrintDevice"/> to route through a local print agent
/// (preferred over the raw IP when its agent is online). Cloud-hosted cafés use this.</summary>
public string? ReceiptPrintDeviceId { get; set; }
public string? KitchenPrintDeviceId { get; set; }
public int PaperWidthMm { get; set; } = 80;
public bool AutoCutEnabled { get; set; } = true;
public string? ReceiptHeader { get; set; }
@@ -7,9 +7,15 @@ public class KitchenStation : TenantEntity
public string Name { get; set; } = string.Empty;
public string? PrinterIp { get; set; }
public int PrinterPort { get; set; } = 9100;
/// <summary>Optional <see cref="PrintDevice"/> routed through a local print agent
/// (preferred over <see cref="PrinterIp"/> when its agent is online).</summary>
public string? PrintDeviceId { get; set; }
public int SortOrder { get; set; }
public Cafe Cafe { get; set; } = null!;
public Branch? Branch { get; set; }
public ICollection<MenuCategory> Categories { get; set; } = [];
public ICollection<MenuItem> MenuItems { get; set; } = [];
}
+4
View File
@@ -15,9 +15,13 @@ public class MenuItem : TenantEntity
/// <summary>GLB/GLTF 3D model for QR menu interactive view (poster = ImageUrl).</summary>
public string? Model3dUrl { get; set; }
public bool IsAvailable { get; set; } = true;
/// <summary>Optional per-item print station (cold bar, kitchen, barista …).
/// Overrides the item's category station when set.</summary>
public string? KitchenStationId { get; set; }
public Cafe Cafe { get; set; } = null!;
public MenuCategory Category { get; set; } = null!;
public KitchenStation? KitchenStation { get; set; }
public ICollection<OrderItem> OrderItems { get; set; } = [];
public ICollection<BranchMenuItemOverride> BranchOverrides { get; set; } = [];
public ICollection<MenuItemIngredient> RecipeIngredients { get; set; } = [];
+29
View File
@@ -0,0 +1,29 @@
namespace Meezi.Core.Entities;
/// <summary>
/// A local print bridge installed on a café PC. It connects outbound to the cloud
/// over SignalR (authenticated by its token), reports the printers it can see, and
/// relays print jobs to them — so the cloud can reach LAN/USB printers it could
/// never connect to directly.
/// </summary>
public class PrintAgent : TenantEntity
{
public string? BranchId { get; set; }
public string Name { get; set; } = string.Empty;
/// <summary>Short one-time code shown in the dashboard; the agent exchanges it for a token.</summary>
public string? PairingCode { get; set; }
public DateTime? PairingCodeExpiresAt { get; set; }
/// <summary>SHA-256 (hex) of the long-lived agent token. Null until the agent is paired.</summary>
public string? TokenHash { get; set; }
/// <summary>Last time the agent connected or sent a heartbeat (UTC).</summary>
public DateTime? LastSeenAt { get; set; }
public bool Revoked { get; set; }
public Cafe Cafe { get; set; } = null!;
public Branch? Branch { get; set; }
public ICollection<PrintDevice> Devices { get; set; } = [];
}
+18
View File
@@ -0,0 +1,18 @@
namespace Meezi.Core.Entities;
/// <summary>A printer discovered and reported by a <see cref="PrintAgent"/>.</summary>
public class PrintDevice : TenantEntity
{
public string AgentId { get; set; } = string.Empty;
/// <summary>Stable identifier the agent uses to print (OS printer name, or "ip:port").</summary>
public string SystemName { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
/// <summary>"usb" | "network" | "other".</summary>
public string Kind { get; set; } = "other";
public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
public PrintAgent Agent { get; set; } = null!;
}
+5 -1
View File
@@ -4,7 +4,11 @@ public enum PaymentProvider
{
ZarinPal = 0,
Tara = 1,
SnappPay = 2
SnappPay = 2,
// Appended (stored as int) so existing rows keep their meaning — no migration needed.
FlatPay = 3,
/// <summary>A free subscription granted by a platform admin (no money changed hands).</summary>
Manual = 4
}
public static class PaymentProviderIds
@@ -54,6 +54,8 @@ public class AppDbContext : DbContext
public DbSet<CafeReviewPhoto> CafeReviewPhotos => Set<CafeReviewPhoto>();
public DbSet<ConsumerAccount> ConsumerAccounts => Set<ConsumerAccount>();
public DbSet<KitchenStation> KitchenStations => Set<KitchenStation>();
public DbSet<PrintAgent> PrintAgents => Set<PrintAgent>();
public DbSet<PrintDevice> PrintDevices => Set<PrintDevice>();
public DbSet<SubscriptionPayment> SubscriptionPayments => Set<SubscriptionPayment>();
public DbSet<Ingredient> Ingredients => Set<Ingredient>();
public DbSet<MenuItemIngredient> MenuItemIngredients => Set<MenuItemIngredient>();
@@ -271,6 +273,7 @@ public class AppDbContext : DbContext
e.Property(x => x.Price).HasPrecision(18, 2);
e.HasOne(x => x.Cafe).WithMany(c => c.MenuItems).HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Category).WithMany(c => c.MenuItems).HasForeignKey(x => x.CategoryId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.KitchenStation).WithMany(s => s.MenuItems).HasForeignKey(x => x.KitchenStationId).OnDelete(DeleteBehavior.SetNull);
e.HasQueryFilter(x => x.DeletedAt == null);
});
@@ -458,6 +461,32 @@ public class AppDbContext : DbContext
e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
});
modelBuilder.Entity<PrintAgent>(e =>
{
e.HasKey(x => x.Id);
e.Property(x => x.Name).HasMaxLength(120).IsRequired();
e.Property(x => x.PairingCode).HasMaxLength(16);
e.Property(x => x.TokenHash).HasMaxLength(128);
e.HasIndex(x => x.TokenHash);
e.HasIndex(x => x.PairingCode);
e.HasIndex(x => x.CafeId);
e.HasOne(x => x.Cafe).WithMany().HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.SetNull);
e.HasMany(x => x.Devices).WithOne(d => d.Agent).HasForeignKey(d => d.AgentId).OnDelete(DeleteBehavior.Cascade);
// Café-wide agents (BranchId null) stay visible inside any branch scope.
e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId || x.BranchId == null));
});
modelBuilder.Entity<PrintDevice>(e =>
{
e.HasKey(x => x.Id);
e.Property(x => x.SystemName).HasMaxLength(256).IsRequired();
e.Property(x => x.DisplayName).HasMaxLength(256).IsRequired();
e.Property(x => x.Kind).HasMaxLength(20);
e.HasIndex(x => new { x.AgentId, x.SystemName }).IsUnique();
e.HasQueryFilter(x => x.DeletedAt == null);
});
modelBuilder.Entity<SubscriptionPayment>(e =>
{
e.HasKey(x => x.Id);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddMenuItemKitchenStation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "KitchenStationId",
table: "MenuItems",
type: "text",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_MenuItems_KitchenStationId",
table: "MenuItems",
column: "KitchenStationId");
migrationBuilder.AddForeignKey(
name: "FK_MenuItems_KitchenStations_KitchenStationId",
table: "MenuItems",
column: "KitchenStationId",
principalTable: "KitchenStations",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_MenuItems_KitchenStations_KitchenStationId",
table: "MenuItems");
migrationBuilder.DropIndex(
name: "IX_MenuItems_KitchenStationId",
table: "MenuItems");
migrationBuilder.DropColumn(
name: "KitchenStationId",
table: "MenuItems");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,109 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddPrintAgents : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PrintAgents",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
BranchId = table.Column<string>(type: "text", nullable: true),
Name = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
PairingCode = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: true),
PairingCodeExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
TokenHash = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
LastSeenAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Revoked = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CafeId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PrintAgents", x => x.Id);
table.ForeignKey(
name: "FK_PrintAgents_Branches_BranchId",
column: x => x.BranchId,
principalTable: "Branches",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_PrintAgents_Cafes_CafeId",
column: x => x.CafeId,
principalTable: "Cafes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PrintDevices",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
AgentId = table.Column<string>(type: "text", nullable: false),
SystemName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
DisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Kind = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
LastSeenAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CafeId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PrintDevices", x => x.Id);
table.ForeignKey(
name: "FK_PrintDevices_PrintAgents_AgentId",
column: x => x.AgentId,
principalTable: "PrintAgents",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_PrintAgents_BranchId",
table: "PrintAgents",
column: "BranchId");
migrationBuilder.CreateIndex(
name: "IX_PrintAgents_CafeId",
table: "PrintAgents",
column: "CafeId");
migrationBuilder.CreateIndex(
name: "IX_PrintAgents_PairingCode",
table: "PrintAgents",
column: "PairingCode");
migrationBuilder.CreateIndex(
name: "IX_PrintAgents_TokenHash",
table: "PrintAgents",
column: "TokenHash");
migrationBuilder.CreateIndex(
name: "IX_PrintDevices_AgentId_SystemName",
table: "PrintDevices",
columns: new[] { "AgentId", "SystemName" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PrintDevices");
migrationBuilder.DropTable(
name: "PrintAgents");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddPrintDeviceRouting : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PrintDeviceId",
table: "KitchenStations",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "KitchenPrintDeviceId",
table: "Branches",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ReceiptPrintDeviceId",
table: "Branches",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PrintDeviceId",
table: "KitchenStations");
migrationBuilder.DropColumn(
name: "KitchenPrintDeviceId",
table: "Branches");
migrationBuilder.DropColumn(
name: "ReceiptPrintDeviceId",
table: "Branches");
}
}
}
@@ -155,6 +155,9 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("KitchenPrintDeviceId")
.HasColumnType("text");
b.Property<string>("KitchenPrinterIp")
.HasMaxLength(45)
.HasColumnType("character varying(45)");
@@ -192,6 +195,9 @@ namespace Meezi.Infrastructure.Data.Migrations
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ReceiptPrintDeviceId")
.HasColumnType("text");
b.Property<string>("ReceiptPrinterIp")
.HasMaxLength(45)
.HasColumnType("character varying(45)");
@@ -707,6 +713,46 @@ namespace Meezi.Infrastructure.Data.Migrations
b.ToTable("Coupons");
});
modelBuilder.Entity("Meezi.Core.Entities.CustomRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("CafeId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Color")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("PermissionsJson")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.HasKey("Id");
b.HasIndex("CafeId");
b.ToTable("CustomRoles");
});
modelBuilder.Entity("Meezi.Core.Entities.Customer", b =>
{
b.Property<string>("Id")
@@ -928,46 +974,6 @@ namespace Meezi.Infrastructure.Data.Migrations
b.ToTable("DemoRequests");
});
modelBuilder.Entity("Meezi.Core.Entities.CustomRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("CafeId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Color")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("PermissionsJson")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.HasKey("Id");
b.HasIndex("CafeId");
b.ToTable("CustomRoles");
});
modelBuilder.Entity("Meezi.Core.Entities.Employee", b =>
{
b.Property<string>("Id")
@@ -1313,6 +1319,9 @@ namespace Meezi.Infrastructure.Data.Migrations
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("PrintDeviceId")
.HasColumnType("text");
b.Property<string>("PrinterIp")
.HasMaxLength(45)
.HasColumnType("character varying(45)");
@@ -1516,6 +1525,9 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Property<bool>("IsAvailable")
.HasColumnType("boolean");
b.Property<string>("KitchenStationId")
.HasColumnType("text");
b.Property<string>("Model3dUrl")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
@@ -1543,6 +1555,8 @@ namespace Meezi.Infrastructure.Data.Migrations
b.HasIndex("CategoryId");
b.HasIndex("KitchenStationId");
b.ToTable("MenuItems");
});
@@ -1930,6 +1944,104 @@ namespace Meezi.Infrastructure.Data.Migrations
b.ToTable("PlatformSettings");
});
modelBuilder.Entity("Meezi.Core.Entities.PrintAgent", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("BranchId")
.HasColumnType("text");
b.Property<string>("CafeId")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("LastSeenAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("PairingCode")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTime?>("PairingCodeExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("Revoked")
.HasColumnType("boolean");
b.Property<string>("TokenHash")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("BranchId");
b.HasIndex("CafeId");
b.HasIndex("PairingCode");
b.HasIndex("TokenHash");
b.ToTable("PrintAgents");
});
modelBuilder.Entity("Meezi.Core.Entities.PrintDevice", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("AgentId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CafeId")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("SystemName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("AgentId", "SystemName")
.IsUnique();
b.ToTable("PrintDevices");
});
modelBuilder.Entity("Meezi.Core.Entities.PushDevice", b =>
{
b.Property<string>("Id")
@@ -2824,6 +2936,17 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Navigation("Cafe");
});
modelBuilder.Entity("Meezi.Core.Entities.CustomRole", b =>
{
b.HasOne("Meezi.Core.Entities.Cafe", "Cafe")
.WithMany()
.HasForeignKey("CafeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Cafe");
});
modelBuilder.Entity("Meezi.Core.Entities.Customer", b =>
{
b.HasOne("Meezi.Core.Entities.Cafe", "Cafe")
@@ -2857,17 +2980,6 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Navigation("Cafe");
});
modelBuilder.Entity("Meezi.Core.Entities.CustomRole", b =>
{
b.HasOne("Meezi.Core.Entities.Cafe", "Cafe")
.WithMany()
.HasForeignKey("CafeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Cafe");
});
modelBuilder.Entity("Meezi.Core.Entities.Employee", b =>
{
b.HasOne("Meezi.Core.Entities.Branch", "Branch")
@@ -3031,9 +3143,16 @@ namespace Meezi.Infrastructure.Data.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Meezi.Core.Entities.KitchenStation", "KitchenStation")
.WithMany("MenuItems")
.HasForeignKey("KitchenStationId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Cafe");
b.Navigation("Category");
b.Navigation("KitchenStation");
});
modelBuilder.Entity("Meezi.Core.Entities.MenuItemIngredient", b =>
@@ -3138,6 +3257,35 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Navigation("Order");
});
modelBuilder.Entity("Meezi.Core.Entities.PrintAgent", b =>
{
b.HasOne("Meezi.Core.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Meezi.Core.Entities.Cafe", "Cafe")
.WithMany()
.HasForeignKey("CafeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Cafe");
});
modelBuilder.Entity("Meezi.Core.Entities.PrintDevice", b =>
{
b.HasOne("Meezi.Core.Entities.PrintAgent", "Agent")
.WithMany("Devices")
.HasForeignKey("AgentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Agent");
});
modelBuilder.Entity("Meezi.Core.Entities.QueueTicket", b =>
{
b.HasOne("Meezi.Core.Entities.Branch", "Branch")
@@ -3401,16 +3549,16 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Navigation("Orders");
});
modelBuilder.Entity("Meezi.Core.Entities.Customer", b =>
{
b.Navigation("Orders");
});
modelBuilder.Entity("Meezi.Core.Entities.CustomRole", b =>
{
b.Navigation("Employees");
});
modelBuilder.Entity("Meezi.Core.Entities.Customer", b =>
{
b.Navigation("Orders");
});
modelBuilder.Entity("Meezi.Core.Entities.Employee", b =>
{
b.Navigation("Attendances");
@@ -3436,6 +3584,8 @@ namespace Meezi.Infrastructure.Data.Migrations
modelBuilder.Entity("Meezi.Core.Entities.KitchenStation", b =>
{
b.Navigation("Categories");
b.Navigation("MenuItems");
});
modelBuilder.Entity("Meezi.Core.Entities.MenuCategory", b =>
@@ -3459,6 +3609,11 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Navigation("Payments");
});
modelBuilder.Entity("Meezi.Core.Entities.PrintAgent", b =>
{
b.Navigation("Devices");
});
modelBuilder.Entity("Meezi.Core.Entities.Shift", b =>
{
b.Navigation("Transactions");
+9
View File
@@ -1159,6 +1159,15 @@
"save": "حفظ",
"saved": "تم الحفظ",
"loading": "جاري التحميل..."
},
"grant": {
"title": "منح اشتراك مجاني",
"plan": "الباقة",
"months": "عدد الأشهر",
"submit": "منح",
"granted": "تم منح الاشتراك",
"failed": "تعذّر منح الاشتراك",
"currentExpiry": "انتهاء الصلاحية الحالي"
}
},
"integrations": {
+9
View File
@@ -1152,6 +1152,15 @@
"save": "Save",
"saved": "Saved",
"loading": "Loading..."
},
"grant": {
"title": "Grant free subscription",
"plan": "Plan",
"months": "Months",
"submit": "Grant",
"granted": "Subscription granted",
"failed": "Could not grant subscription",
"currentExpiry": "Current expiry"
}
},
"integrations": {
+9
View File
@@ -1152,6 +1152,15 @@
"save": "ذخیره",
"saved": "ذخیره شد",
"loading": "در حال بارگذاری..."
},
"grant": {
"title": "افزودن اشتراک رایگان",
"plan": "پلن",
"months": "تعداد ماه",
"submit": "اعطا",
"granted": "اشتراک اعطا شد",
"failed": "اعطای اشتراک ناموفق بود",
"currentExpiry": "انقضای فعلی"
}
},
"integrations": {
@@ -493,6 +493,7 @@ export function AdminCafesScreen() {
</Button>
</div>
</div>
<GrantSubscriptionPanel cafe={c} />
<RecoveryKeyPanel cafe={c} />
{profileCafeId === c.id ? (
<CafeDiscoverProfilePanel cafeId={c.id} mode="admin" compact />
@@ -504,6 +505,70 @@ export function AdminCafesScreen() {
);
}
/** Gift a café a free subscription: pick a plan + number of months and apply.
* Months are appended to any coverage the café already has. */
function GrantSubscriptionPanel({ cafe }: { cafe: AdminCafe }) {
const t = useTranslations("admin.cafes.grant");
const qc = useQueryClient();
const [tier, setTier] = useState("Pro");
const [months, setMonths] = useState(1);
const TIERS = ["Starter", "Pro", "Business", "Enterprise"];
const grant = useMutation({
mutationFn: () =>
adminPost(`/api/admin/cafes/${cafe.id}/grant-subscription`, { planTier: tier, months }),
onSuccess: () => {
notify.success(t("granted"));
void qc.invalidateQueries({ queryKey: ["admin", "cafes"] });
},
onError: () => notify.error(t("failed")),
});
return (
<div className="space-y-2 rounded-lg border border-border/70 bg-muted/20 p-3 text-sm">
<p className="font-medium">{t("title")}</p>
<div className="flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">{t("plan")}</span>
<select
value={tier}
onChange={(e) => setTier(e.target.value)}
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
>
{TIERS.map((x) => (
<option key={x} value={x}>
{x}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">{t("months")}</span>
<Input
type="number"
min={1}
max={120}
value={months}
onChange={(e) =>
setMonths(Math.max(1, Math.min(120, Number(e.target.value) || 1)))
}
className="h-9 w-24"
/>
</label>
<Button size="sm" disabled={grant.isPending} onClick={() => grant.mutate()}>
{t("submit")}
</Button>
</div>
{cafe.planExpiresAt ? (
<p className="text-xs text-muted-foreground">
{t("currentExpiry")}: {new Date(cafe.planExpiresAt).toLocaleDateString("fa-IR")}
</p>
) : null}
</div>
);
}
/**
* Generate / revoke a café's permanent recovery key. The raw key is returned
* once on generate shown here for copy, never retrievable again.
+210 -28
View File
@@ -1,6 +1,7 @@
{
"common": {
"save": "حفظ",
"close": "إغلاق",
"cancel": "إلغاء",
"confirm": "تأكيد",
"delete": "حذف",
@@ -39,6 +40,7 @@
"auth": {
"title": "تسجيل الدخول إلى ميزي",
"subtitle": "سيتم إرسال رمز التحقق إلى هاتفك",
"redirecting": "مسجّل الدخول بالفعل — يتم التحويل…",
"phone": "رقم الجوال",
"phonePlaceholder": "٠٩١٢١٢٣٤٥٦٧",
"sendOtp": "إرسال الرمز",
@@ -116,6 +118,7 @@
"menu": "القائمة",
"crm": "العملاء",
"coupons": "القسائم",
"orders": "الطلبات",
"inventory": "المخزون",
"hr": "الموارد البشرية",
"reports": "التقارير",
@@ -329,7 +332,49 @@
"configurePrinters": "فتح إعدادات الطابعة",
"posDeviceSection": "جهاز نقطة البيع (بطاقة)",
"posDeviceHint": "عند الدفع بالبطاقة، يُرسل المبلغ عبر HTTP (POST /pay) إلى الجهاز على الشبكة المحلية.",
"posDeviceIp": "عنوان IP لجهاز نقطة البيع"
"posDeviceIp": "عنوان IP لجهاز نقطة البيع",
"detect": "كشف تلقائي",
"detecting": "جارٍ فحص الشبكة…",
"detectNone": "لم يُعثر على أجهزة في الشبكة",
"detectOffline": "يجب أن يكون خادم الطباعة متصلاً للكشف التلقائي",
"detectHint": "يفحص خادم الطباعة شبكتك المحلية للعثور على الجهاز.",
"testSent": "تم إرسال الاختبار إلى الطابعة.",
"sent": "تم الإرسال إلى الطابعة.",
"noStationItems": "لا توجد أصناف لهذه المحطة في هذا الطلب.",
"printFailed": "فشلت الطباعة.",
"stations": {
"title": "محطات طباعة المطبخ والبار",
"subtitle": "امنح كل قسم تحضير طابعته الخاصة ووجّه فئات القائمة إليها.",
"help": "أنشئ محطة (مثل المطبخ أو البار) بطابعتها الخاصة، ثم من «القائمة» اختر محطة الطباعة لكل فئة — الطعام ← المطبخ، المشروبات ← البار. أصناف الفئات بدون محطة تُطبع على طابعة مطبخ الفرع. أما فاتورة العميل فتُطبع دائمًا على طابعة الفواتير.",
"add": "إضافة محطة",
"name": "اسم المحطة",
"namePlaceholder": "مثل المطبخ، البار",
"printerIp": "IP الطابعة",
"noPrinter": "بدون طابعة — تُستخدم طابعة المطبخ",
"categoryCount": "{count} فئات",
"test": "اختبار",
"empty": "لا توجد محطات بعد. أضف «المطبخ» و«البار» لطباعة أصنافهما بشكل منفصل.",
"deleteConfirm": "حذف المحطة «{name}»؟ ستعود فئاتها إلى طابعة المطبخ.",
"saveError": "تعذّر حفظ المحطة."
},
"agents": {
"title": "خوادم الطباعة (اكتشاف تلقائي)",
"hint": "ثبّت وكيل طباعة Meezi على جهاز الكاشير لاكتشاف طابعات USB والشبكة تلقائياً والطباعة عبره.",
"add": "إضافة خادم طباعة",
"pairingTitle": "أدخل هذا الرمز في وكيل الطباعة على جهاز الكاشير:",
"pairingSteps": "ثبّت وشغّل وكيل طباعة Meezi على الجهاز المتصل بالطابعات ثم أدخل هذا الرمز. صالح لمدة 15 دقيقة.",
"empty": "لا يوجد خادم طباعة متصل بعد.",
"online": "متصل",
"offline": "غير متصل",
"noDevices": "جارٍ اكتشاف الطابعات…",
"test": "اختبار",
"receiptVia": "طابعة الإيصال (عبر الخادم)",
"kitchenVia": "طابعة المطبخ (عبر الخادم)",
"viaServer": "الطابعة (عبر الخادم)",
"useIpInstead": "— استخدام IP يدوي —",
"revokeConfirm": "إزالة خادم الطباعة «{name}»؟ لن يتمكن من الطباعة بعد ذلك.",
"codeError": "تعذّر إنشاء الرمز."
}
},
"receipt": {
"table": "الطاولة",
@@ -416,7 +461,8 @@
"payroll": "الرواتب",
"access": "صلاحيات الفروع",
"credentials": "بيانات الدخول",
"team": "الموظفون"
"team": "الموظفون",
"roles": "الأدوار والصلاحيات"
},
"myAttendance": "حضوري",
"clockIn": "تسجيل دخول",
@@ -445,6 +491,9 @@
"addEmployee": "إضافة موظف",
"noEmployees": "لا يوجد موظفون بعد.",
"employeeCreated": "تمت إضافة الموظف",
"employeeDetails": "تفاصيل الموظف",
"employeeNotFound": "هذا المستخدم لم يعد نشطًا.",
"openInHr": "فتح في الموارد البشرية",
"save": "حفظ",
"cancel": "إلغاء",
"fields": {
@@ -631,6 +680,7 @@
"colSummary": "الوصف",
"details": "التفاصيل",
"systemActor": "النظام",
"unknownActor": "مستخدم غير معروف",
"prevPage": "السابق",
"nextPage": "التالي"
}
@@ -700,6 +750,8 @@
"loading": "جاري التحميل...",
"live": "مباشر",
"polling": "تحديث دوري",
"allStations": "الكل",
"defaultStation": "المطبخ",
"advance": "المرحلة التالية",
"status": {
"Pending": "قيد الانتظار",
@@ -873,6 +925,8 @@
"newItem": "صنف جديد",
"newCategory": "فئة جديدة",
"editCategoryTitle": "تعديل الفئة",
"printStation": "محطة الطباعة",
"printStationNone": "طابعة المطبخ (افتراضي)",
"close": "إغلاق",
"saving": "جاري الحفظ…",
"model3d": "نموذج ثلاثي الأبعاد",
@@ -886,7 +940,8 @@
"deleteItemSuccess": "تم حذف الصنف",
"deleteCategoryConfirmTitle": "حذف الفئة",
"deleteCategoryConfirmDesc": "هل أنت متأكد من حذف الفئة «{name}»؟",
"deleteCategorySuccess": "تم حذف الفئة"
"deleteCategorySuccess": "تم حذف الفئة",
"printStationInherit": "نفس الفئة"
},
"branchMenu": {
"title": "قائمة الفرع",
@@ -1060,6 +1115,23 @@
}
}
},
"recentOrders": {
"title": "الطلبات الأخيرة",
"subtitle": "تصفّح الطلبات المغلقة وأعد طباعة فاتورة العميل وتذاكر المطبخ/البار.",
"date": "التاريخ",
"branch": "الفرع",
"allBranches": "كل الفروع",
"empty": "لا توجد طلبات لهذا اليوم.",
"loadFailed": "تعذّر تحميل الطلبات.",
"retry": "إعادة المحاولة",
"prevPage": "السابق",
"nextPage": "التالي",
"table": "الطاولة",
"statusPaid": "مدفوع",
"statusCancelled": "ملغى",
"receipt": "الفاتورة",
"kitchen": "تذكرة المطبخ"
},
"notifications": {
"title": "الإشعارات",
"pageTitle": "الإشعارات",
@@ -1200,13 +1272,51 @@
"shop": "المقهى والمتجر",
"shopGeneral": "الملف والتكاملات",
"shopAppearance": "المظهر والألوان",
"shopNotifications": "الإشعارات والصوت",
"printer": "الطابعة",
"printerSettings": "إعدادات الطابعة",
"printerStations": "طابعات المطبخ والبار",
"printTest": "صفحة اختبار الطباعة",
"shopDiscover": "اكتشاف و AI",
"team": "الفريق والموظفون",
"customRoles": "الأدوار المخصصة"
},
"notifPrefs": {
"soundSection": "الصوت",
"soundEnabled": "تشغيل صوت للإشعارات الجديدة",
"soundEnabledHint": "يصدر صوتًا عند وصول طلب جديد أو نداء نادل أو تنبيه.",
"soundChoice": "صوت الإشعار",
"preview": "معاينة",
"volume": "مستوى الصوت",
"soundClassic": "كلاسيكي",
"soundDing": "رنين",
"soundBell": "جرس",
"soundChime": "أجراس",
"soundMarimba": "ماريمبا",
"soundAlert": "تنبيه",
"desktopSection": "إشعارات سطح المكتب",
"desktopHint": "إظهار نافذة منبثقة على ويندوز/سطح المكتب حتى عندما تكون لوحة التحكم في تبويب آخر أو مصغّرة.",
"enableDesktop": "تفعيل إشعارات سطح المكتب",
"desktopEnabled": "نوافذ سطح المكتب",
"desktopEnabledHint": "تظهر فقط عندما لا يكون هذا التبويب نشطًا.",
"desktopGranted": "تم تفعيل إشعارات سطح المكتب",
"desktopDenied": "تم رفض الإذن من المتصفح",
"desktopBlocked": "الإشعارات محظورة لهذا الموقع. اسمح بها من إعدادات الموقع في المتصفح ثم أعد التحميل.",
"desktopUnsupported": "هذا المتصفح لا يدعم إشعارات سطح المكتب.",
"desktopFocusNote": "تظهر النافذة التجريبية فقط إذا انتقلت إلى نافذة أخرى أولًا.",
"sendTest": "إرسال إشعار تجريبي",
"testTitle": "ميزي",
"testBody": "هذا إشعار تجريبي.",
"testToast": "تم إرسال الإشعار التجريبي",
"inAppSection": "داخل التطبيق",
"tabBadge": "عدد غير المقروء على تبويب المتصفح",
"tabBadgeHint": "يعرض عدد الإشعارات غير المقروءة في عنوان التبويب والأيقونة المفضلة.",
"toast": "تنبيه داخل التطبيق",
"toastHint": "إظهار شريط صغير داخل لوحة التحكم للإشعارات الجديدة.",
"promptTitle": "تفعيل الإشعارات؟",
"promptBody": "احصل على نافذة منبثقة وصوت للطلبات الجديدة ونداءات النادل — حتى عندما يكون هذا التبويب في الخلفية.",
"later": "لاحقًا"
},
"customRoles": {
"title": "الأدوار المخصصة",
"subtitle": "حدّد أدواراً بصلاحيات مخصصة لموظفيك",
@@ -1222,34 +1332,106 @@
"saveError": "فشل حفظ الدور",
"deleteConfirm": "حذف الدور «{name}»؟ سيعود الموظفون إلى صلاحيات دورهم الأساسي.",
"groupAdmin": "إدارة المقهى",
"groupMenu": "القائمة والمخزون",
"groupStaff": "الموظفون",
"groupCustomer": "العملاء والطاولات",
"groupBranches": "الفروع",
"groupMenu": "القائمة",
"groupInventory": "المخزون",
"groupTaxes": "الضرائب",
"groupStaff": "الموظفون والموارد البشرية",
"groupTables": "الطاولات والحجوزات",
"groupOrders": "الطلبات ونقطة البيع",
"groupRegister": "الصندوق والنقد",
"groupQueueKitchen": "الانتظار والمطبخ",
"groupDelivery": "التوصيل",
"groupCustomers": "العملاء",
"groupCoupons": "الكوبونات",
"groupMarketing": "التسويق والتقييمات",
"groupReports": "التقارير والمالية",
"groupOps": "عمليات الصندوق",
"groupKitchen": "المطبخ والتوصيل",
"groupExpenses": "المصروفات",
"perm": {
"ManageCafeSettings": "إعدادات المقهى",
"ManageBilling": "الاشتراك والفواتير",
"ManageBranches": "إدارة الفروع",
"ManageMenu": "إدارة القائمة",
"ManageInventory": "المخزون",
"ManageTaxes": "الضرائب",
"ManagePrintSettings": "إعدادات الطباعة",
"ManageStaff": "إدارة الموظفين",
"ManageSalaries": "الرواتب",
"ReviewLeave": "طلبات الإجازة",
"ManageReservations": "الحجوزات",
"ManageTables": "الطاولات",
"ManageCoupons": "الكوبونات",
"ViewReports": "التقارير",
"ManageExpenses": "المصروفات",
"ProcessOrders": "معالجة الطلبات",
"HandlePayments": "المدفوعات",
"OperateRegister": "الصندوق",
"ManageQueue": "قائمة الانتظار",
"ViewCafeSettings": "عرض إعدادات المقهى",
"ManageCafeSettings": "تعديل إعدادات المقهى",
"ManageDiscoverProfile": "الملف العام و«كوجا»",
"ViewBilling": "عرض الفواتير",
"ManageBilling": "إدارة الاشتراك والفواتير",
"ManageRoles": "إدارة الأدوار",
"ViewPrintSettings": "عرض إعدادات الطباعة",
"ManagePrintSettings": "تعديل إعدادات الطباعة",
"ViewBranches": "عرض الفروع",
"CreateBranch": "إنشاء فرع",
"EditBranch": "تعديل فرع",
"DeleteBranch": "حذف فرع",
"ViewMenu": "عرض القائمة",
"CreateMenuItem": "إضافة أصناف",
"EditMenuItem": "تعديل الأصناف",
"DeleteMenuItem": "حذف الأصناف",
"ViewInventory": "عرض المخزون",
"CreateInventory": "إضافة للمخزون",
"EditInventory": "تعديل المخزون والكميات",
"DeleteInventory": "حذف من المخزون",
"ViewTaxes": "عرض الضرائب",
"CreateTax": "إنشاء ضريبة",
"EditTax": "تعديل ضريبة",
"DeleteTax": "حذف ضريبة",
"ViewStaff": "عرض الموظفين",
"CreateStaff": "إضافة موظف",
"EditStaff": "تعديل موظف",
"DeleteStaff": "حذف موظف",
"ManageStaff": "تعيين أدوار الفروع",
"ManageStaffCredentials": "إدارة بيانات الدخول",
"ViewAttendance": "عرض الحضور",
"ManageAttendance": "إدارة الحضور",
"ViewSchedules": "عرض المناوبات",
"ManageSchedules": "إدارة المناوبات",
"ViewLeave": "عرض طلبات الإجازة",
"ReviewLeave": "اعتماد الإجازات",
"ViewSalaries": "عرض الرواتب",
"ManageSalaries": "إدارة الرواتب",
"ViewTables": "عرض الطاولات",
"ManageTables": "إدارة الطاولات والأقسام",
"ViewReservations": "عرض الحجوزات",
"CreateReservation": "إنشاء حجز",
"EditReservation": "تعديل حجز",
"DeleteReservation": "حذف حجز",
"ViewOrders": "عرض الطلبات",
"ProcessOrders": "تسجيل الطلبات",
"EditOrder": "تعديل الطلبات",
"VoidOrder": "إبطال / إلغاء الطلبات",
"RefundOrder": "استرداد الطلبات",
"ApplyDiscount": "تطبيق الخصومات",
"CompOrder": "طلب مجاني (ضيافة)",
"HandlePayments": "استلام المدفوعات",
"UpdateOrderStatus": "تحديث حالة الطلب",
"OperateRegister": "فتح / إغلاق الصندوق",
"OpenCashDrawer": "فتح درج النقود (بدون بيع)",
"ViewQueue": "عرض قائمة الانتظار",
"ManageQueue": "إدارة قائمة الانتظار",
"ViewKitchen": "شاشة المطبخ",
"HandleDelivery": "التوصيل"
"ManageKitchenStations": "إدارة محطات المطبخ",
"ViewDelivery": "عرض التوصيل",
"HandleDelivery": "إدارة التوصيل",
"AssignDelivery": "تعيين السائق",
"ViewCustomers": "عرض العملاء",
"CreateCustomer": "إضافة عميل",
"EditCustomer": "تعديل عميل",
"DeleteCustomer": "حذف عميل",
"ViewCoupons": "عرض الكوبونات",
"CreateCoupon": "إنشاء كوبون",
"EditCoupon": "تعديل كوبون",
"DeleteCoupon": "حذف كوبون",
"ViewSms": "عرض الرسائل",
"SendSms": "إرسال حملات الرسائل",
"ManageSmsSettings": "إعدادات الرسائل",
"ViewReviews": "عرض التقييمات",
"ManageReviews": "الرد على التقييمات وإدارتها",
"ViewReports": "عرض التقارير",
"ExportReports": "تصدير التقارير",
"ViewAuditLog": "عرض سجل العمليات",
"ViewFinancials": "عرض المالية (الأرباح والخسائر)",
"ManageFinancials": "تصحيح سندات الدفع",
"ViewExpenses": "عرض المصروفات",
"CreateExpense": "إضافة مصروف",
"EditExpense": "تعديل مصروف",
"DeleteExpense": "حذف مصروف"
}
},
"appearance": {
+210 -28
View File
@@ -1,6 +1,7 @@
{
"common": {
"save": "Save",
"close": "Close",
"cancel": "Cancel",
"confirm": "Confirm",
"delete": "Delete",
@@ -39,6 +40,7 @@
"auth": {
"title": "Sign in to Meezi",
"subtitle": "We will send a verification code to your phone",
"redirecting": "Already signed in — redirecting…",
"phone": "Mobile number",
"phonePlaceholder": "09121234567",
"sendOtp": "Send code",
@@ -127,6 +129,7 @@
"crm": "CRM",
"coupons": "Coupons",
"menu": "Menu",
"orders": "Orders",
"inventory": "Inventory",
"hr": "HR",
"reports": "Reports",
@@ -348,7 +351,49 @@
"configurePrinters": "Open printer settings",
"posDeviceSection": "Card POS terminal",
"posDeviceHint": "On card payment, the amount is sent via HTTP (POST /pay) to the device on your LAN.",
"posDeviceIp": "POS device IP address"
"posDeviceIp": "POS device IP address",
"detect": "Auto-detect",
"detecting": "Scanning the network…",
"detectNone": "No devices found on the network",
"detectOffline": "A print server must be online to auto-detect",
"detectHint": "The print server scans your LAN to find the device.",
"testSent": "Test sent to the printer.",
"sent": "Sent to the printer.",
"noStationItems": "This order has no items for that station.",
"printFailed": "Print failed.",
"stations": {
"title": "Kitchen & bar print stations",
"subtitle": "Give each prep area its own printer and route menu categories to it.",
"help": "Create a station (e.g. Kitchen, Bar) with its own printer, then in Menu set each categorys print station — food → Kitchen, drinks → Bar. Items in a category with no station fall back to the branch kitchen printer. The customer receipt always prints to the receipt printer.",
"add": "Add station",
"name": "Station name",
"namePlaceholder": "e.g. Kitchen, Bar",
"printerIp": "Printer IP",
"noPrinter": "No printer — uses the kitchen printer",
"categoryCount": "{count} categories",
"test": "Test",
"empty": "No stations yet. Add Kitchen and Bar to print their items separately.",
"deleteConfirm": "Delete station “{name}”? Its categories will fall back to the kitchen printer.",
"saveError": "Failed to save the station."
},
"agents": {
"title": "Print servers (auto-discovery)",
"hint": "Install the Meezi print agent on the cash PC to auto-detect its USB & network printers and print through it.",
"add": "Add print server",
"pairingTitle": "Enter this code in the print agent on the cash PC:",
"pairingSteps": "Install and run the Meezi Print Agent on the PC connected to the printers, then enter this code. It is valid for 15 minutes.",
"empty": "No print server connected yet.",
"online": "Online",
"offline": "Offline",
"noDevices": "Discovering printers…",
"test": "Test",
"receiptVia": "Receipt printer (via server)",
"kitchenVia": "Kitchen printer (via server)",
"viaServer": "Printer (via server)",
"useIpInstead": "— Use manual IP —",
"revokeConfirm": "Remove print server “{name}”? It will no longer be able to print.",
"codeError": "Could not create code."
}
},
"receipt": {
"table": "Table",
@@ -435,7 +480,8 @@
"payroll": "Payroll",
"access": "Branch access",
"credentials": "Login credentials",
"team": "Team"
"team": "Team",
"roles": "Roles & permissions"
},
"myAttendance": "My attendance",
"clockIn": "Clock in",
@@ -464,6 +510,9 @@
"addEmployee": "Add employee",
"noEmployees": "No employees yet.",
"employeeCreated": "Employee added",
"employeeDetails": "Employee details",
"employeeNotFound": "This user is no longer active.",
"openInHr": "Open in HR",
"save": "Save",
"cancel": "Cancel",
"fields": {
@@ -650,6 +699,7 @@
"colSummary": "Summary",
"details": "Details",
"systemActor": "System",
"unknownActor": "Unknown user",
"prevPage": "Previous",
"nextPage": "Next"
}
@@ -734,6 +784,8 @@
"loading": "Loading...",
"live": "Live",
"polling": "Polling",
"allStations": "All",
"defaultStation": "Kitchen",
"advance": "Next step",
"status": {
"Pending": "Pending",
@@ -907,6 +959,8 @@
"newItem": "New item",
"newCategory": "New category",
"editCategoryTitle": "Edit category",
"printStation": "Print station",
"printStationNone": "Kitchen printer (default)",
"close": "Close",
"saving": "Saving…",
"model3d": "3D model",
@@ -920,7 +974,8 @@
"deleteItemSuccess": "Item deleted",
"deleteCategoryConfirmTitle": "Delete category",
"deleteCategoryConfirmDesc": "Are you sure you want to delete the “{name}” category?",
"deleteCategorySuccess": "Category deleted"
"deleteCategorySuccess": "Category deleted",
"printStationInherit": "Same as category"
},
"branchMenu": {
"title": "Branch Menu",
@@ -1120,6 +1175,23 @@
}
}
},
"recentOrders": {
"title": "Recent orders",
"subtitle": "Browse closed orders and reprint the customer receipt and the kitchen / bar tickets.",
"date": "Date",
"branch": "Branch",
"allBranches": "All branches",
"empty": "No orders for this day.",
"loadFailed": "Could not load orders.",
"retry": "Retry",
"prevPage": "Previous",
"nextPage": "Next",
"table": "Table",
"statusPaid": "Paid",
"statusCancelled": "Cancelled",
"receipt": "Receipt",
"kitchen": "Kitchen ticket"
},
"notifications": {
"title": "Notifications",
"pageTitle": "Notifications",
@@ -1272,13 +1344,51 @@
"shop": "Shop & café",
"shopGeneral": "Profile & integrations",
"shopAppearance": "Appearance & colors",
"shopNotifications": "Notifications & sound",
"printer": "Printer",
"printerSettings": "Printer settings",
"printerStations": "Kitchen & bar printers",
"printTest": "Print test page",
"shopDiscover": "Discover & AI",
"team": "Team & Staff",
"customRoles": "Custom Roles"
},
"notifPrefs": {
"soundSection": "Sound",
"soundEnabled": "Play a sound for new notifications",
"soundEnabledHint": "Chimes when a new order, waiter call, or alert arrives.",
"soundChoice": "Notification sound",
"preview": "Preview",
"volume": "Volume",
"soundClassic": "Classic",
"soundDing": "Ding",
"soundBell": "Bell",
"soundChime": "Chime",
"soundMarimba": "Marimba",
"soundAlert": "Alert",
"desktopSection": "Desktop notifications",
"desktopHint": "Show a Windows/desktop popup even when the dashboard is in another tab or minimized.",
"enableDesktop": "Enable desktop notifications",
"desktopEnabled": "Desktop popups",
"desktopEnabledHint": "Pop up only when this tab is not focused.",
"desktopGranted": "Desktop notifications enabled",
"desktopDenied": "Permission denied by the browser",
"desktopBlocked": "Notifications are blocked for this site. Allow them in your browser's site settings, then reload.",
"desktopUnsupported": "This browser does not support desktop notifications.",
"desktopFocusNote": "A test popup only appears if you switch to another window first.",
"sendTest": "Send a test notification",
"testTitle": "Meezi",
"testBody": "This is a test notification.",
"testToast": "Test sent",
"inAppSection": "In-app",
"tabBadge": "Unread count on the browser tab",
"tabBadgeHint": "Shows the number of unread notifications in the tab title and favicon.",
"toast": "In-app toast",
"toastHint": "Show a small banner inside the dashboard for new notifications.",
"promptTitle": "Turn on notifications?",
"promptBody": "Get a popup + sound for new orders and waiter calls — even when this tab is in the background.",
"later": "Later"
},
"customRoles": {
"title": "Custom Roles",
"subtitle": "Define roles with tailored permissions for your staff",
@@ -1294,34 +1404,106 @@
"saveError": "Failed to save role",
"deleteConfirm": "Delete role '{name}'? Employees will revert to their base role permissions.",
"groupAdmin": "Café Administration",
"groupMenu": "Menu & Inventory",
"groupStaff": "Staff",
"groupCustomer": "Customer & Tables",
"groupBranches": "Branches",
"groupMenu": "Menu",
"groupInventory": "Inventory",
"groupTaxes": "Taxes",
"groupStaff": "Staff & HR",
"groupTables": "Tables & Reservations",
"groupOrders": "Orders & POS",
"groupRegister": "Register & Cash",
"groupQueueKitchen": "Queue & Kitchen",
"groupDelivery": "Delivery",
"groupCustomers": "Customers",
"groupCoupons": "Coupons",
"groupMarketing": "Marketing & Reviews",
"groupReports": "Reports & Finance",
"groupOps": "Register Operations",
"groupKitchen": "Kitchen & Delivery",
"groupExpenses": "Expenses",
"perm": {
"ManageCafeSettings": "Café settings",
"ManageBilling": "Billing & subscription",
"ManageBranches": "Manage branches",
"ManageMenu": "Menu management",
"ManageInventory": "Inventory",
"ManageTaxes": "Taxes",
"ManagePrintSettings": "Print settings",
"ManageStaff": "Staff management",
"ManageSalaries": "Salaries",
"ReviewLeave": "Leave requests",
"ManageReservations": "Reservations",
"ManageTables": "Tables",
"ManageCoupons": "Coupons",
"ViewReports": "Reports",
"ManageExpenses": "Expenses",
"ProcessOrders": "Process orders",
"HandlePayments": "Handle payments",
"OperateRegister": "Register",
"ManageQueue": "Queue",
"ViewCafeSettings": "View café settings",
"ManageCafeSettings": "Edit café settings",
"ManageDiscoverProfile": "Discover & public profile",
"ViewBilling": "View billing",
"ManageBilling": "Manage billing & subscription",
"ManageRoles": "Manage roles",
"ViewPrintSettings": "View print settings",
"ManagePrintSettings": "Edit print settings",
"ViewBranches": "View branches",
"CreateBranch": "Create branch",
"EditBranch": "Edit branch",
"DeleteBranch": "Delete branch",
"ViewMenu": "View menu",
"CreateMenuItem": "Add menu items",
"EditMenuItem": "Edit menu items",
"DeleteMenuItem": "Delete menu items",
"ViewInventory": "View inventory",
"CreateInventory": "Add inventory",
"EditInventory": "Edit inventory & stock",
"DeleteInventory": "Delete inventory",
"ViewTaxes": "View taxes",
"CreateTax": "Create tax",
"EditTax": "Edit tax",
"DeleteTax": "Delete tax",
"ViewStaff": "View staff",
"CreateStaff": "Add staff",
"EditStaff": "Edit staff",
"DeleteStaff": "Remove staff",
"ManageStaff": "Assign branch roles",
"ManageStaffCredentials": "Manage login credentials",
"ViewAttendance": "View attendance",
"ManageAttendance": "Manage attendance",
"ViewSchedules": "View schedules",
"ManageSchedules": "Manage schedules",
"ViewLeave": "View leave requests",
"ReviewLeave": "Approve leave requests",
"ViewSalaries": "View salaries",
"ManageSalaries": "Manage salaries",
"ViewTables": "View tables",
"ManageTables": "Manage tables & sections",
"ViewReservations": "View reservations",
"CreateReservation": "Create reservation",
"EditReservation": "Edit reservation",
"DeleteReservation": "Delete reservation",
"ViewOrders": "View orders",
"ProcessOrders": "Take orders",
"EditOrder": "Edit orders",
"VoidOrder": "Void / cancel orders",
"RefundOrder": "Refund orders",
"ApplyDiscount": "Apply discounts",
"CompOrder": "Comp (free) orders",
"HandlePayments": "Take payments",
"UpdateOrderStatus": "Update order status",
"OperateRegister": "Open / close register",
"OpenCashDrawer": "Open cash drawer (no-sale)",
"ViewQueue": "View queue",
"ManageQueue": "Manage queue",
"ViewKitchen": "Kitchen display",
"HandleDelivery": "Delivery"
"ManageKitchenStations": "Manage kitchen stations",
"ViewDelivery": "View delivery",
"HandleDelivery": "Handle delivery",
"AssignDelivery": "Assign delivery",
"ViewCustomers": "View customers",
"CreateCustomer": "Add customers",
"EditCustomer": "Edit customers",
"DeleteCustomer": "Delete customers",
"ViewCoupons": "View coupons",
"CreateCoupon": "Create coupon",
"EditCoupon": "Edit coupon",
"DeleteCoupon": "Delete coupon",
"ViewSms": "View SMS",
"SendSms": "Send SMS campaigns",
"ManageSmsSettings": "SMS settings",
"ViewReviews": "View reviews",
"ManageReviews": "Reply & moderate reviews",
"ViewReports": "View reports",
"ExportReports": "Export reports",
"ViewAuditLog": "View audit log",
"ViewFinancials": "View financials (P&L)",
"ManageFinancials": "Payment corrections",
"ViewExpenses": "View expenses",
"CreateExpense": "Add expense",
"EditExpense": "Edit expense",
"DeleteExpense": "Delete expense"
}
},
"appearance": {
+209 -27
View File
@@ -1,6 +1,7 @@
{
"common": {
"save": "ذخیره",
"close": "بستن",
"cancel": "انصراف",
"confirm": "تأیید",
"delete": "حذف",
@@ -39,6 +40,7 @@
"auth": {
"title": "ورود به میزی",
"subtitle": "کد تأیید به موبایل شما ارسال می‌شود",
"redirecting": "قبلاً وارد شده‌اید — در حال انتقال…",
"phone": "شماره موبایل",
"phonePlaceholder": "۰۹۱۲۱۲۳۴۵۶۷",
"sendOtp": "ارسال کد",
@@ -127,6 +129,7 @@
"crm": "مشتریان",
"coupons": "کوپن‌ها",
"menu": "منو",
"orders": "سفارش‌ها",
"inventory": "انبار",
"hr": "منابع انسانی",
"reports": "گزارش‌ها",
@@ -348,7 +351,49 @@
"configurePrinters": "رفتن به تنظیمات پرینتر",
"posDeviceSection": "دستگاه پوز (کارتخوان)",
"posDeviceHint": "هنگام پرداخت کارتی، مبلغ به آدرس HTTP دستگاه ارسال می‌شود (POST /pay).",
"posDeviceIp": "آدرس IP دستگاه پوز"
"posDeviceIp": "آدرس IP دستگاه پوز",
"detect": "تشخیص خودکار",
"detecting": "در حال جستجوی شبکه…",
"detectNone": "دستگاهی در شبکه پیدا نشد",
"detectOffline": "برای تشخیص خودکار باید پرینت‌سرور روشن و متصل باشد",
"detectHint": "پرینت‌سرور شبکه محلی را برای یافتن دستگاه اسکن می‌کند.",
"testSent": "تست به پرینتر ارسال شد.",
"sent": "به پرینتر ارسال شد.",
"noStationItems": "این سفارش آیتمی برای این ایستگاه ندارد.",
"printFailed": "چاپ ناموفق بود.",
"stations": {
"title": "ایستگاه‌های چاپ آشپزخانه و بار",
"subtitle": "برای هر بخش آماده‌سازی یک پرینتر جدا بگذارید و دسته‌های منو را به آن وصل کنید.",
"help": "یک ایستگاه (مثلاً آشپزخانه یا بار) با پرینتر مخصوص خودش بسازید، سپس در «منو» برای هر دسته ایستگاه چاپ را انتخاب کنید — غذا ← آشپزخانه، نوشیدنی ← بار. آیتم‌های دسته‌هایی که ایستگاه ندارند روی پرینتر آشپزخانهٔ شعبه چاپ می‌شوند. فاکتور مشتری همیشه روی پرینتر فاکتور چاپ می‌شود.",
"add": "افزودن ایستگاه",
"name": "نام ایستگاه",
"namePlaceholder": "مثلاً آشپزخانه، بار",
"printerIp": "آی‌پی پرینتر",
"noPrinter": "بدون پرینتر — از پرینتر آشپزخانه استفاده می‌شود",
"categoryCount": "{count} دسته",
"test": "تست",
"empty": "هنوز ایستگاهی ندارید. «آشپزخانه» و «بار» را اضافه کنید تا آیتم‌هایشان جدا چاپ شود.",
"deleteConfirm": "ایستگاه «{name}» حذف شود؟ دسته‌های آن به پرینتر آشپزخانه برمی‌گردند.",
"saveError": "ذخیرهٔ ایستگاه ناموفق بود."
},
"agents": {
"title": "پرینت‌سرورها (شناسایی خودکار پرینتر)",
"hint": "روی کامپیوتر صندوق، برنامهٔ «پرینت‌سرور میزی» را نصب کنید تا پرینترهای USB و شبکه به‌صورت خودکار شناسایی شوند و چاپ از طریق آن انجام شود.",
"add": "افزودن پرینت‌سرور",
"pairingTitle": "این کد را در برنامهٔ پرینت‌سرور روی کامپیوتر صندوق وارد کنید:",
"pairingSteps": "برنامهٔ «پرینت‌سرور میزی» را روی همان کامپیوتری که به پرینترها وصل است نصب و اجرا کنید، سپس این کد را وارد کنید. کد تا ۱۵ دقیقه معتبر است.",
"empty": "هنوز پرینت‌سروری متصل نشده است.",
"online": "آنلاین",
"offline": "آفلاین",
"noDevices": "در حال یافتن پرینترها…",
"test": "تست",
"receiptVia": "پرینتر رسید (از پرینت‌سرور)",
"kitchenVia": "پرینتر آشپزخانه (از پرینت‌سرور)",
"viaServer": "پرینتر (از پرینت‌سرور)",
"useIpInstead": "— استفاده از IP دستی —",
"revokeConfirm": "حذف پرینت‌سرور «{name}»؟ پس از آن دیگر نمی‌تواند چاپ کند.",
"codeError": "ایجاد کد ناموفق بود."
}
},
"receipt": {
"table": "میز",
@@ -435,7 +480,8 @@
"payroll": "حقوق",
"access": "دسترسی شعب",
"credentials": "رمز ورود",
"team": "کارکنان"
"team": "کارکنان",
"roles": "نقش‌ها و دسترسی‌ها"
},
"myAttendance": "حضور من",
"clockIn": "ورود",
@@ -464,6 +510,9 @@
"addEmployee": "افزودن کارمند",
"noEmployees": "هنوز کارمندی ثبت نشده است.",
"employeeCreated": "کارمند اضافه شد",
"employeeDetails": "مشخصات کارمند",
"employeeNotFound": "این کاربر دیگر فعال نیست.",
"openInHr": "مشاهده در منابع انسانی",
"save": "ذخیره",
"cancel": "انصراف",
"fields": {
@@ -650,6 +699,7 @@
"colSummary": "شرح",
"details": "جزئیات",
"systemActor": "سیستم",
"unknownActor": "کاربر نامشخص",
"prevPage": "قبلی",
"nextPage": "بعدی"
}
@@ -734,6 +784,8 @@
"loading": "در حال بارگذاری...",
"live": "زنده",
"polling": "به‌روزرسانی دوره‌ای",
"allStations": "همه",
"defaultStation": "آشپزخانه",
"advance": "مرحله بعد",
"status": {
"Pending": "در انتظار",
@@ -907,6 +959,8 @@
"newItem": "آیتم جدید",
"newCategory": "دسته جدید",
"editCategoryTitle": "ویرایش دسته",
"printStation": "ایستگاه چاپ",
"printStationNone": "پرینتر آشپزخانه (پیش‌فرض)",
"close": "بستن",
"saving": "در حال ذخیره…",
"model3d": "مدل سه‌بعدی",
@@ -920,7 +974,8 @@
"deleteItemSuccess": "آیتم حذف شد",
"deleteCategoryConfirmTitle": "حذف دسته‌بندی",
"deleteCategoryConfirmDesc": "آیا از حذف دسته «{name}» مطمئن هستید؟",
"deleteCategorySuccess": "دسته حذف شد"
"deleteCategorySuccess": "دسته حذف شد",
"printStationInherit": "مثل دستهٔ منو"
},
"branchMenu": {
"title": "منوی شعبه",
@@ -1121,6 +1176,23 @@
}
}
},
"recentOrders": {
"title": "سفارش‌های اخیر",
"subtitle": "سفارش‌های بسته‌شده را ببینید و فاکتور مشتری و فیش آشپزخانه/بار را دوباره چاپ کنید.",
"date": "تاریخ",
"branch": "شعبه",
"allBranches": "همه شعب",
"empty": "سفارشی برای این روز نیست.",
"loadFailed": "بارگذاری سفارش‌ها ناموفق بود.",
"retry": "تلاش مجدد",
"prevPage": "قبلی",
"nextPage": "بعدی",
"table": "میز",
"statusPaid": "پرداخت‌شده",
"statusCancelled": "لغوشده",
"receipt": "فاکتور",
"kitchen": "فیش آشپزخانه"
},
"notifications": {
"title": "اعلان‌ها",
"pageTitle": "اعلان‌ها",
@@ -1273,13 +1345,51 @@
"shop": "کافه و فروشگاه",
"shopGeneral": "پروفایل و اتصال‌ها",
"shopAppearance": "ظاهر و رنگ‌بندی",
"shopNotifications": "اعلان‌ها و صدا",
"printer": "پرینتر",
"printerSettings": "تنظیمات پرینتر",
"printerStations": "پرینتر آشپزخانه و بار",
"printTest": "صفحه تست چاپ",
"shopDiscover": "کشف و AI",
"team": "تیم و کارمندان",
"customRoles": "نقش‌های سفارشی"
},
"notifPrefs": {
"soundSection": "صدا",
"soundEnabled": "پخش صدا برای اعلان‌های جدید",
"soundEnabledHint": "هنگام رسیدن سفارش جدید، درخواست میز یا هشدار، صدا پخش می‌شود.",
"soundChoice": "صدای اعلان",
"preview": "پیش‌نمایش",
"volume": "بلندی صدا",
"soundClassic": "کلاسیک",
"soundDing": "دینگ",
"soundBell": "زنگ",
"soundChime": "ناقوس",
"soundMarimba": "ماریمبا",
"soundAlert": "هشدار",
"desktopSection": "اعلان‌های دسکتاپ",
"desktopHint": "نمایش پاپ‌آپ ویندوز/دسکتاپ حتی وقتی داشبورد در تب دیگری باز است یا کوچک شده.",
"enableDesktop": "فعال‌سازی اعلان‌های دسکتاپ",
"desktopEnabled": "پاپ‌آپ دسکتاپ",
"desktopEnabledHint": "فقط وقتی این تب فعال نیست نمایش داده می‌شود.",
"desktopGranted": "اعلان‌های دسکتاپ فعال شد",
"desktopDenied": "دسترسی توسط مرورگر رد شد",
"desktopBlocked": "اعلان‌ها برای این سایت مسدود شده‌اند. از تنظیمات سایت در مرورگر اجازه دهید و سپس صفحه را دوباره بارگذاری کنید.",
"desktopUnsupported": "این مرورگر از اعلان‌های دسکتاپ پشتیبانی نمی‌کند.",
"desktopFocusNote": "پاپ‌آپ آزمایشی فقط زمانی نمایش داده می‌شود که ابتدا به پنجره دیگری بروید.",
"sendTest": "ارسال اعلان آزمایشی",
"testTitle": "میزی",
"testBody": "این یک اعلان آزمایشی است.",
"testToast": "اعلان آزمایشی ارسال شد",
"inAppSection": "درون‌برنامه",
"tabBadge": "شمارش خوانده‌نشده روی تب مرورگر",
"tabBadgeHint": "تعداد اعلان‌های خوانده‌نشده را در عنوان تب و فاویکون نشان می‌دهد.",
"toast": "نوتیف درون‌برنامه",
"toastHint": "نمایش یک بنر کوچک داخل داشبورد برای اعلان‌های جدید.",
"promptTitle": "اعلان‌ها روشن شود؟",
"promptBody": "برای سفارش‌های جدید و درخواست میز، پاپ‌آپ و صدا دریافت کنید — حتی وقتی این تب در پس‌زمینه است.",
"later": "بعداً"
},
"customRoles": {
"title": "نقش‌های سفارشی",
"subtitle": "نقش‌هایی با دسترسی دلخواه برای کارمندان تعریف کنید",
@@ -1295,34 +1405,106 @@
"saveError": "ذخیره نقش ناموفق بود",
"deleteConfirm": "نقش «{name}» حذف شود؟ این کارمندان به دسترسی پیش‌فرض نقش اصلی خود بازمی‌گردند.",
"groupAdmin": "مدیریت کافه",
"groupMenu": "منو و انبار",
"groupStaff": "پرسنل",
"groupCustomer": "مشتری و میز",
"groupBranches": "شعب",
"groupMenu": "منو",
"groupInventory": "انبار و موجودی",
"groupTaxes": "مالیات",
"groupStaff": "پرسنل و منابع انسانی",
"groupTables": "میز و رزرو",
"groupOrders": "سفارش و فروش",
"groupRegister": "صندوق و وجه نقد",
"groupQueueKitchen": "صف و آشپزخانه",
"groupDelivery": "تحویل و پیک",
"groupCustomers": "مشتریان",
"groupCoupons": "کوپن‌ها",
"groupMarketing": "بازاریابی و نظرات",
"groupReports": "گزارش و مالی",
"groupOps": "عملیات صندوق",
"groupKitchen": "آشپزخانه و تحویل",
"groupExpenses": "هزینه‌ها",
"perm": {
"ManageCafeSettings": "تنظیمات کافه",
"ManageBilling": "اشتراک و پرداخت",
"ManageBranches": "مدیریت شعب",
"ManageMenu": "مدیریت منو",
"ManageInventory": "انبار و موجودی",
"ManageTaxes": الیات",
"ManagePrintSettings": "تنظیمات چاپ",
"ManageStaff": "مدیریت کارمندان",
"ManageSalaries": "حقوق و دستمزد",
"ReviewLeave": "بررسی مرخصی",
"ManageReservations": "رزروها",
"ManageTables": "میزها",
"ManageCoupons": "کوپن‌ها",
"ViewReports": "گزارش‌ها",
"ManageExpenses": "هزینه‌ها",
"ViewCafeSettings": "مشاهده تنظیمات کافه",
"ManageCafeSettings": "ویرایش تنظیمات کافه",
"ManageDiscoverProfile": "پروفایل عمومی و کوجا",
"ViewBilling": "مشاهده صورتحساب",
"ManageBilling": "مدیریت اشتراک و پرداخت",
"ManageRoles": دیریت نقش‌ها",
"ViewPrintSettings": "مشاهده تنظیمات چاپ",
"ManagePrintSettings": "ویرایش تنظیمات چاپ",
"ViewBranches": "مشاهده شعب",
"CreateBranch": "ایجاد شعبه",
"EditBranch": "ویرایش شعبه",
"DeleteBranch": "حذف شعبه",
"ViewMenu": "مشاهده منو",
"CreateMenuItem": "افزودن آیتم منو",
"EditMenuItem": "ویرایش آیتم منو",
"DeleteMenuItem": "حذف آیتم منو",
"ViewInventory": "مشاهده انبار",
"CreateInventory": "افزودن به انبار",
"EditInventory": "ویرایش انبار و موجودی",
"DeleteInventory": "حذف از انبار",
"ViewTaxes": "مشاهده مالیات",
"CreateTax": "ایجاد مالیات",
"EditTax": "ویرایش مالیات",
"DeleteTax": "حذف مالیات",
"ViewStaff": "مشاهده کارمندان",
"CreateStaff": "افزودن کارمند",
"EditStaff": "ویرایش کارمند",
"DeleteStaff": "حذف کارمند",
"ManageStaff": "تخصیص نقش شعبه",
"ManageStaffCredentials": "مدیریت اطلاعات ورود",
"ViewAttendance": "مشاهده حضور و غیاب",
"ManageAttendance": "مدیریت حضور و غیاب",
"ViewSchedules": "مشاهده شیفت‌ها",
"ManageSchedules": "مدیریت شیفت‌ها",
"ViewLeave": "مشاهده درخواست مرخصی",
"ReviewLeave": "تأیید مرخصی",
"ViewSalaries": "مشاهده حقوق",
"ManageSalaries": "مدیریت حقوق و دستمزد",
"ViewTables": "مشاهده میزها",
"ManageTables": "مدیریت میز و بخش‌ها",
"ViewReservations": "مشاهده رزروها",
"CreateReservation": "ایجاد رزرو",
"EditReservation": "ویرایش رزرو",
"DeleteReservation": "حذف رزرو",
"ViewOrders": "مشاهده سفارش‌ها",
"ProcessOrders": "ثبت سفارش",
"HandlePayments": "پردازش پرداخت",
"OperateRegister": "صندوق",
"ManageQueue": "صف انتظار",
"EditOrder": "ویرایش سفارش",
"VoidOrder": "ابطال / لغو سفارش",
"RefundOrder": "استرداد وجه سفارش",
"ApplyDiscount": "اعمال تخفیف",
"CompOrder": "سفارش رایگان (مهمان)",
"HandlePayments": "دریافت پرداخت",
"UpdateOrderStatus": "تغییر وضعیت سفارش",
"OperateRegister": "باز / بستن صندوق",
"OpenCashDrawer": "باز کردن کشوی پول (بدون فروش)",
"ViewQueue": "مشاهده صف",
"ManageQueue": "مدیریت صف",
"ViewKitchen": "نمایش آشپزخانه",
"HandleDelivery": "تحویل و پیک"
"ManageKitchenStations": "مدیریت ایستگاه‌های آشپزخانه",
"ViewDelivery": "مشاهده تحویل",
"HandleDelivery": "مدیریت تحویل",
"AssignDelivery": "تخصیص پیک",
"ViewCustomers": "مشاهده مشتریان",
"CreateCustomer": "افزودن مشتری",
"EditCustomer": "ویرایش مشتری",
"DeleteCustomer": "حذف مشتری",
"ViewCoupons": "مشاهده کوپن‌ها",
"CreateCoupon": "ایجاد کوپن",
"EditCoupon": "ویرایش کوپن",
"DeleteCoupon": "حذف کوپن",
"ViewSms": "مشاهده پیامک",
"SendSms": "ارسال کمپین پیامکی",
"ManageSmsSettings": "تنظیمات پیامک",
"ViewReviews": "مشاهده نظرات",
"ManageReviews": "پاسخ و مدیریت نظرات",
"ViewReports": "مشاهده گزارش‌ها",
"ExportReports": "خروجی گرفتن از گزارش",
"ViewAuditLog": "مشاهده گزارش رویدادها",
"ViewFinancials": "مشاهده مالی (سود و زیان)",
"ManageFinancials": "اصلاح سند پرداخت",
"ViewExpenses": "مشاهده هزینه‌ها",
"CreateExpense": "افزودن هزینه",
"EditExpense": "ویرایش هزینه",
"DeleteExpense": "حذف هزینه"
}
},
"appearance": {
@@ -11,6 +11,8 @@ import { useAuthStore } from "@/lib/stores/auth.store";
import { useOfflineSync } from "@/lib/offline/use-offline-sync";
import { useOrderAlerts } from "@/lib/realtime/use-order-alerts";
import { useTabBadge } from "@/lib/notifications/use-tab-badge";
import { useNotificationNavBridge } from "@/lib/notifications/notification-routes";
import { NotificationPermissionPrompt } from "@/components/notifications/notification-permission-prompt";
export default function DashboardLayout({
children,
@@ -24,6 +26,7 @@ export default function DashboardLayout({
useOfflineSync(); // register online/offline listeners + load queue count
useOrderAlerts(); // global sound + toast + desktop popup for café notifications
useTabBadge(); // unread count on the browser tab title + favicon
useNotificationNavBridge(); // toast/desktop notification clicks → navigate
useEffect(() => {
// Wait for Zustand to finish reading localStorage before deciding to redirect.
@@ -63,6 +66,7 @@ export default function DashboardLayout({
</>
)}
</div>
<NotificationPermissionPrompt />
</CafeThemeProvider>
);
}
@@ -0,0 +1,7 @@
import { RecentOrdersScreen } from "@/components/orders/recent-orders-screen";
/** Recent orders browse closed orders and reprint the customer receipt and the
* kitchen / bar tickets per order. */
export default function OrdersPage() {
return <RecentOrdersScreen />;
}
@@ -6,6 +6,8 @@ import { Loader2 } from "lucide-react";
import { useRouter } from "@/i18n/routing";
import { useAuthStore } from "@/lib/stores/auth.store";
import { RouteGuard } from "@/components/auth/route-guard";
import { useOrderAlerts } from "@/lib/realtime/use-order-alerts";
import { NotificationPermissionPrompt } from "@/components/notifications/notification-permission-prompt";
/** Full-viewport routes (POS, queue TV display) — auth only, no dashboard chrome. */
export default function FullscreenLayout({ children }: { children: React.ReactNode }) {
@@ -15,6 +17,10 @@ export default function FullscreenLayout({ children }: { children: React.ReactNo
const hasHydrated = useAuthStore((s) => s._hasHydrated);
const dir = locale === "en" ? "ltr" : "rtl";
// Surface café notifications (waiter calls, new guest orders) on the POS /
// queue-display too — during service staff are here, not on the dashboard.
useOrderAlerts();
useEffect(() => {
// Only redirect AFTER the persisted auth has rehydrated from localStorage —
// otherwise a page refresh sees the empty initial state and bounces an
@@ -36,6 +42,7 @@ export default function FullscreenLayout({ children }: { children: React.ReactNo
return (
<div className="min-h-svh" dir={dir}>
<RouteGuard>{children}</RouteGuard>
<NotificationPermissionPrompt />
</div>
);
}
@@ -1,50 +0,0 @@
"use client";
import { useLocale } from "next-intl";
import { Sidebar } from "@/components/layout/sidebar";
import { Topbar } from "@/components/layout/topbar";
import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider";
/**
* Classic POS route layout wraps the terminal in the standard dashboard
* chrome (collapsible sidebar + topbar) but keeps the main content area
* overflow-hidden so PosScreen can manage its own internal scrolling.
*/
export default function PosClassicLayout({
children,
}: {
children: React.ReactNode;
}) {
const locale = useLocale();
const isRtl = locale !== "en";
const mainColumn = (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<Topbar />
<main className="min-h-0 flex-1 overflow-hidden bg-background p-3 md:p-4">
{children}
</main>
</div>
);
return (
<CafeThemeProvider>
<div
className="flex h-screen min-h-0 overflow-hidden bg-background"
dir={isRtl ? "rtl" : "ltr"}
>
{isRtl ? (
<>
<Sidebar side="right" />
{mainColumn}
</>
) : (
<>
<Sidebar side="left" />
{mainColumn}
</>
)}
</div>
</CafeThemeProvider>
);
}
@@ -1,12 +0,0 @@
import { Suspense } from "react";
import { PosScreen } from "@/components/pos/pos-screen";
/** Classic POS terminal chrome (sidebar + topbar) is provided by layout.tsx.
* Kept as a fallback while POS v2 (at /pos) is piloted. */
export default function PosClassicPage() {
return (
<Suspense fallback={null}>
<PosScreen />
</Suspense>
);
}
@@ -6,7 +6,6 @@ import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider";
* POS v2 layout the redesigned terminal is full-screen (its own topbar +
* order ticket), so no dashboard sidebar/topbar chrome here. Café theming
* still applies. Auth guarding comes from the parent (fullscreen) layout.
* The classic POS keeps its chrome under /pos-classic.
*/
export default function PosLayout({ children }: { children: React.ReactNode }) {
return <CafeThemeProvider>{children}</CafeThemeProvider>;
@@ -1,8 +1,7 @@
import { Pos2Screen } from "@/components/pos2/pos2-screen";
/** Default POS terminal redesigned v2, wired to live data (menu, tables,
* orders, payments) via the shared cart store + offline submit pipeline.
* The classic POS remains available at /[locale]/pos-classic. */
/** POS terminal wired to live data (menu, tables, orders, payments) via the
* shared cart store + offline submit pipeline. */
export default function PosPage() {
return <Pos2Screen />;
}
+18 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { useRouter, Link } from "@/i18n/routing";
import { apiPost, ApiClientError } from "@/lib/api/client";
@@ -18,6 +18,15 @@ export default function LoginPage() {
const t = useTranslations("auth");
const router = useRouter();
const setAuth = useAuthStore((s) => s.setAuth);
const user = useAuthStore((s) => s.user);
const hasHydrated = useAuthStore((s) => s._hasHydrated);
// Already signed in? Don't show the login form again — send them to the app.
// Gate on _hasHydrated so we don't act on a not-yet-rehydrated (null) session.
const alreadyAuthed = hasHydrated && !!user?.accessToken;
useEffect(() => {
if (alreadyAuthed) router.replace("/pos");
}, [alreadyAuthed, router]);
const [tab, setTab] = useState<LoginTab>("otp");
@@ -140,6 +149,14 @@ export default function LoginPage() {
setCode("");
};
if (alreadyAuthed) {
return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
<p className="text-sm text-muted-foreground">{t("redirecting")}</p>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
<Card className="w-full max-w-md">

Some files were not shown because too many files have changed in this diff Show More