43 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
soroush.asadi 149a4d88cd feat(dashboard): configurable notification sound, desktop popups & tab unread badge
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
Per-device notification preferences (localStorage) drive three new alert
channels in the dashboard shell, all fed by the existing SignalR
NotificationReceived events:

- Sound: 6 selectable procedural Web Audio chimes + volume, no asset files.
- Desktop/Windows popups via the Notification API, fired only when the tab
  is backgrounded (in-app toast covers the focused case).
- Unread count on the browser tab: (N) title prefix + numbered favicon badge.

useOrderAlerts is now the single orchestrator (sound + toast + desktop),
each gated by prefs; topbar feed enableToasts disabled to avoid double toasts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:08:02 +03:30
soroush.asadi aebfa825cd feat: custom roles with per-permission matrix for café owners
- Owner can define named custom roles (e.g. Barista, Supervisor) with
  color, description, and a fine-grained permission set (21 permissions
  across 7 categories: admin, menu, staff, customer, reports, ops, kitchen)
- Employee assigned a custom role gets its permissions embedded in the
  JWT at login (customPerms claim) and parsed by TenantMiddleware —
  overrides the static EmployeeRole matrix for all API permission checks
- New endpoints: GET/POST/PATCH/DELETE /api/cafes/{id}/custom-roles and
  PUT /api/cafes/{id}/employees/{id}/custom-role for assignment
- Dashboard Settings → Team & Staff → Custom Roles panel with grouped
  checkbox matrix, group-level toggles, color preset picker, CRUD forms,
  and employee-count display; translations in fa/en/ar
- EF migration adds CustomRoles table + nullable CustomRoleId FK on Employees
- POS slip now shows per-item notes on both thermal print and bill preview

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 03:12:43 +03:30
181 changed files with 18577 additions and 3772 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_API_URL=http://171.22.25.73:5080
NEXT_PUBLIC_ADMIN_API_URL=http://171.22.25.73:5081 NEXT_PUBLIC_ADMIN_API_URL=http://171.22.25.73:5081
NEXT_PUBLIC_SITE_URL=http://171.22.25.73:3010 # Public site origin — MUST be the real domain in prod (used for canonical URLs,
NEXT_PUBLIC_KOJA_URL=http://171.22.25.73:3103 # 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 APP_QR_BASE_URL=http://171.22.25.73:3101
BILLING_DASHBOARD_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_MERCHANT_ID=
ZARINPAL_SANDBOX=false 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 ──────────────────────────────────────────────────────────── # ── SMS: Kavenegar ────────────────────────────────────────────────────────────
# Empty = OTP is logged to API console (fine for dev, not for production) # Empty = OTP is logged to API console (fine for dev, not for production)
KAVENEGAR_API_KEY=4C30786935496261332B41685870444E47657A5367453369374F6E2F43334672576B526F5A4B4B795665493D 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 dockerfile: docker/website/Dockerfile
args: args:
MEEZI_API_URL: http://api:8080 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 container_name: meezi-website
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
@@ -178,7 +178,7 @@ services:
PORT: "3000" PORT: "3000"
HOSTNAME: 0.0.0.0 HOSTNAME: 0.0.0.0
MEEZI_API_URL: http://api:8080 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: ports:
- "${WEBSITE_PORT:-3010}:3000" - "${WEBSITE_PORT:-3010}:3000"
+8 -4
View File
@@ -94,6 +94,10 @@ services:
Snappfood__WebhookSecret: "${SNAPPFOOD_WEBHOOK_SECRET:-meezi-dev-snappfood-secret}" Snappfood__WebhookSecret: "${SNAPPFOOD_WEBHOOK_SECRET:-meezi-dev-snappfood-secret}"
ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}" ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}" 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__SystemAdminPhone: "${SEED_ADMIN_PHONE:-}"
Seed__SystemAdminUsername: "${SEED_ADMIN_USERNAME:-admin}" Seed__SystemAdminUsername: "${SEED_ADMIN_USERNAME:-admin}"
Seed__SystemAdminPassword: "${SEED_ADMIN_PASSWORD:-}" Seed__SystemAdminPassword: "${SEED_ADMIN_PASSWORD:-}"
@@ -139,7 +143,7 @@ services:
NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine} NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/} NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
MEEZI_API_URL: http://api:8080 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 container_name: meezi-website
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
@@ -149,7 +153,7 @@ services:
PORT: "3000" PORT: "3000"
HOSTNAME: 0.0.0.0 HOSTNAME: 0.0.0.0
MEEZI_API_URL: http://api:8080 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: ports:
- "${WEBSITE_PORT:-3010}:3000" - "${WEBSITE_PORT:-3010}:3000"
@@ -163,7 +167,7 @@ services:
NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine} NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/} NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080} 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 container_name: meezi-koja
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
@@ -173,7 +177,7 @@ services:
PORT: "3000" PORT: "3000"
HOSTNAME: 0.0.0.0 HOSTNAME: 0.0.0.0
NEXT_PUBLIC_API_URL: "${NEXT_PUBLIC_API_URL:-http://localhost:5080}" 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: ports:
- "${KOJA_PORT:-3103}:3000" - "${KOJA_PORT:-3103}:3000"
+1 -1
View File
@@ -23,7 +23,7 @@ FROM ${NODE_IMAGE} AS builder
WORKDIR /app WORKDIR /app
ARG MEEZI_API_URL=http://api:8080 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 MEEZI_API_URL=$MEEZI_API_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_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 Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Audit; using Meezi.API.Models.Audit;
using Meezi.Core.Authorization; using Meezi.Core.Authorization;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data; using Meezi.Infrastructure.Data;
using Meezi.Shared; using Meezi.Shared;
@@ -42,7 +43,7 @@ public class AuditController : CafeApiControllerBase
[FromQuery] int pageSize = 50) [FromQuery] int pageSize = 50)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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 (page < 1) page = 1;
if (pageSize < 1) pageSize = 50; if (pageSize < 1) pageSize = 50;
@@ -67,25 +68,50 @@ public class AuditController : CafeApiControllerBase
var total = await query.CountAsync(ct); var total = await query.CountAsync(ct);
var items = await query var rows = await query
.OrderByDescending(x => x.CreatedAt) .OrderByDescending(x => x.CreatedAt)
.Skip((page - 1) * pageSize) .Skip((page - 1) * pageSize)
.Take(pageSize) .Take(pageSize)
.Select(x => new AuditLogDto( .Select(x => new
x.Id, {
x.Category, x.Id, x.Category, x.Action, x.EntityType, x.EntityId, x.BranchId,
x.Action, x.ActorId, x.ActorName, x.ActorRole, x.Summary, x.DetailsJson, x.CreatedAt
x.EntityType, })
x.EntityId,
x.BranchId,
x.ActorId,
x.ActorName,
x.ActorRole,
x.Summary,
x.DetailsJson,
x.CreatedAt))
.ToListAsync(ct); .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))); 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 Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Billing; using Meezi.API.Models.Billing;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Shared; using Meezi.Shared;
namespace Meezi.API.Controllers; namespace Meezi.API.Controllers;
[ApiController] [ApiController]
public class BillingController : ControllerBase public class BillingController : CafeApiControllerBase
{ {
private readonly IBillingService _billing; private readonly IBillingService _billing;
private readonly IValidator<SubscribeRequest> _subscribeValidator; private readonly IValidator<SubscribeRequest> _subscribeValidator;
@@ -27,13 +28,9 @@ public class BillingController : ControllerBase
ITenantContext tenant, ITenantContext tenant,
CancellationToken ct) CancellationToken ct)
{ {
if (EnsurePermission(tenant, Permission.ManageBilling) is { } permDenied) return permDenied;
if (string.IsNullOrEmpty(tenant.CafeId)) if (string.IsNullOrEmpty(tenant.CafeId))
return Unauthorized(); 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); var validation = await _subscribeValidator.ValidateAsync(request, ct);
if (!validation.IsValid) if (!validation.IsValid)
@@ -108,11 +105,9 @@ public class BillingController : ControllerBase
[HttpDelete("api/billing/queued/{paymentId}")] [HttpDelete("api/billing/queued/{paymentId}")]
public async Task<IActionResult> CancelQueued(string paymentId, ITenantContext tenant, CancellationToken ct) 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)) if (string.IsNullOrEmpty(tenant.CafeId))
return Unauthorized(); 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); var (ok, code, message) = await _billing.CancelQueuedAsync(tenant.CafeId, paymentId, ct);
if (!ok) if (!ok)
@@ -1,8 +1,8 @@
using FluentValidation; using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Menu; using Meezi.API.Models.Menu;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Shared; using Meezi.Shared;
@@ -43,7 +43,6 @@ public class BranchMenuController : CafeApiControllerBase
} }
[HttpPut("{menuItemId}/override")] [HttpPut("{menuItemId}/override")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> UpsertOverride( public async Task<IActionResult> UpsertOverride(
string cafeId, string cafeId,
string branchId, string branchId,
@@ -53,8 +52,7 @@ public class BranchMenuController : CafeApiControllerBase
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (!BranchMenuService.CanManageOverrides(tenant.Role)) if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
return Forbid();
var validation = await _upsertValidator.ValidateAsync(request, cancellationToken); var validation = await _upsertValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -84,7 +82,6 @@ public class BranchMenuController : CafeApiControllerBase
} }
[HttpDelete("{menuItemId}/override")] [HttpDelete("{menuItemId}/override")]
[Authorize(Roles = "Owner")]
public async Task<IActionResult> DeleteOverride( public async Task<IActionResult> DeleteOverride(
string cafeId, string cafeId,
string branchId, string branchId,
@@ -93,6 +90,7 @@ public class BranchMenuController : CafeApiControllerBase
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
var deleted = await _branchMenu.DeleteOverrideAsync( var deleted = await _branchMenu.DeleteOverrideAsync(
cafeId, branchId, menuItemId, cancellationToken); cafeId, branchId, menuItemId, cancellationToken);
@@ -1,7 +1,7 @@
using FluentValidation; using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Printing; using Meezi.API.Models.Printing;
using Meezi.Core.Authorization;
using Meezi.Core.Entities; using Meezi.Core.Entities;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data; using Meezi.Infrastructure.Data;
@@ -11,7 +11,6 @@ using Microsoft.EntityFrameworkCore;
namespace Meezi.API.Controllers; namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/branches/{branchId}/print-settings")] [Route("api/cafes/{cafeId}/branches/{branchId}/print-settings")]
[Authorize(Roles = "Manager,Owner")]
public class BranchPrintSettingsController : CafeApiControllerBase public class BranchPrintSettingsController : CafeApiControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
@@ -54,6 +53,7 @@ public class BranchPrintSettingsController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
var validation = await _validator.ValidateAsync(request, ct); var validation = await _validator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -91,6 +91,14 @@ public class BranchPrintSettingsController : CafeApiControllerBase
: request.PosDeviceIp.Trim(); : request.PosDeviceIp.Trim();
if (request.PosDevicePort.HasValue) if (request.PosDevicePort.HasValue)
branch.PosDevicePort = request.PosDevicePort.Value; 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; branch.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
@@ -110,5 +118,7 @@ public class BranchPrintSettingsController : CafeApiControllerBase
b.ReceiptFooter, b.ReceiptFooter,
b.WifiPassword, b.WifiPassword,
b.PosDeviceIp, b.PosDeviceIp,
b.PosDevicePort); b.PosDevicePort,
b.ReceiptPrintDeviceId,
b.KitchenPrintDeviceId);
} }
@@ -1,8 +1,8 @@
using FluentValidation; using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Tables; using Meezi.API.Models.Tables;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Shared; using Meezi.Shared;
@@ -68,7 +68,6 @@ public class BranchTablesController : CafeApiControllerBase
} }
[HttpPost] [HttpPost]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> CreateTable( public async Task<IActionResult> CreateTable(
string cafeId, string cafeId,
string branchId, string branchId,
@@ -77,6 +76,7 @@ public class BranchTablesController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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)) if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
return Forbid(); return Forbid();
@@ -88,7 +88,6 @@ public class BranchTablesController : CafeApiControllerBase
} }
[HttpPatch("{id}")] [HttpPatch("{id}")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> PatchTable( public async Task<IActionResult> PatchTable(
string cafeId, string cafeId,
string branchId, string branchId,
@@ -98,6 +97,7 @@ public class BranchTablesController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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)) if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
return Forbid(); return Forbid();
@@ -109,7 +109,6 @@ public class BranchTablesController : CafeApiControllerBase
} }
[HttpDelete("{id}")] [HttpDelete("{id}")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> DeleteTable( public async Task<IActionResult> DeleteTable(
string cafeId, string cafeId,
string branchId, string branchId,
@@ -118,6 +117,7 @@ public class BranchTablesController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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)) if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
return Forbid(); return Forbid();
@@ -135,6 +135,7 @@ public class BranchTablesController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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)) if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
return Forbid(); return Forbid();
@@ -180,7 +181,6 @@ public class BranchTablesController : CafeApiControllerBase
} }
[HttpPost("sections")] [HttpPost("sections")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> CreateSection( public async Task<IActionResult> CreateSection(
string cafeId, string cafeId,
string branchId, string branchId,
@@ -189,6 +189,7 @@ public class BranchTablesController : CafeApiControllerBase
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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)) if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
return Forbid(); return Forbid();
@@ -200,7 +201,6 @@ public class BranchTablesController : CafeApiControllerBase
} }
[HttpPatch("sections/{sectionId}")] [HttpPatch("sections/{sectionId}")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> PatchSection( public async Task<IActionResult> PatchSection(
string cafeId, string cafeId,
string branchId, string branchId,
@@ -210,6 +210,7 @@ public class BranchTablesController : CafeApiControllerBase
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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)) if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
return Forbid(); return Forbid();
@@ -221,7 +222,6 @@ public class BranchTablesController : CafeApiControllerBase
} }
[HttpDelete("sections/{sectionId}")] [HttpDelete("sections/{sectionId}")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> DeleteSection( public async Task<IActionResult> DeleteSection(
string cafeId, string cafeId,
string branchId, string branchId,
@@ -230,6 +230,7 @@ public class BranchTablesController : CafeApiControllerBase
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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)) if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
return Forbid(); return Forbid();
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Branches; using Meezi.API.Models.Branches;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Constants; using Meezi.Core.Constants;
using Meezi.Core.Entities; using Meezi.Core.Entities;
using Meezi.Core.Enums; using Meezi.Core.Enums;
@@ -96,7 +97,7 @@ public class BranchesController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var validation = await _createValidator.ValidateAsync(request, ct);
if (!validation.IsValid) if (!validation.IsValid)
@@ -169,7 +170,7 @@ public class BranchesController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var validation = await _patchValidator.ValidateAsync(request, ct);
if (!validation.IsValid) if (!validation.IsValid)
@@ -222,7 +223,7 @@ public class BranchesController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var (ok, code, message) = await _lifecycle.ScheduleDeletionAsync(cafeId, branchId, ct);
if (!ok) if (!ok)
@@ -257,7 +258,7 @@ public class BranchesController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var (ok, code, message) = await _lifecycle.RestoreAsync(cafeId, branchId, ct);
if (!ok) if (!ok)
@@ -44,9 +44,14 @@ public abstract class CafeApiControllerBase : ControllerBase
return EnsureManager(tenant); return EnsureManager(tenant);
} }
/// <summary>Gate by an explicit capability from the role→permission matrix.</summary> /// <summary>Gate by an explicit capability from the role→permission matrix.
/// When the employee has a custom role its permission set is used instead.</summary>
protected IActionResult? EnsurePermission(ITenantContext tenant, Permission permission) protected IActionResult? EnsurePermission(ITenantContext tenant, Permission permission)
{ {
if (tenant.CustomPermissions is { } custom)
return custom.Contains(permission)
? null
: Forbidden("FORBIDDEN", "You do not have permission to perform this action.");
if (tenant.Role is { } role && RolePermissions.Has(role, permission)) if (tenant.Role is { } role && RolePermissions.Has(role, permission))
return null; return null;
return Forbidden("FORBIDDEN", "You do not have permission to perform this action."); return Forbidden("FORBIDDEN", "You do not have permission to perform this action.");
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Discover; using Meezi.API.Models.Discover;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data; using Meezi.Infrastructure.Data;
@@ -45,6 +46,7 @@ public class CafeDiscoverProfileController : CafeApiControllerBase
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) if (EnsureCafeAccess(cafeId, tenant) is { } denied)
return denied; return denied;
if (EnsurePermission(tenant, Permission.ManageDiscoverProfile) is { } permDenied) return permDenied;
var planTier = tenant.PlanTier ?? PlanTier.Free; var planTier = tenant.PlanTier ?? PlanTier.Free;
if (!await catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, "discover_profile", ct)) if (!await catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, "discover_profile", ct))
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Public; using Meezi.API.Models.Public;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Discover; using Meezi.Core.Discover;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data; using Meezi.Infrastructure.Data;
@@ -71,6 +72,7 @@ public class CafePublicProfileController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
if (cafe is null) if (cafe is null)
@@ -121,6 +123,7 @@ public class CafePublicProfileController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageDiscoverProfile) is { } permDenied) return permDenied;
if (photo is null || photo.Length == 0) if (photo is null || photo.Length == 0)
return BadRequest(Fail("NO_FILE", "No photo provided.")); return BadRequest(Fail("NO_FILE", "No photo provided."));
@@ -155,6 +158,7 @@ public class CafePublicProfileController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageDiscoverProfile) is { } permDenied) return permDenied;
if (string.IsNullOrWhiteSpace(url)) if (string.IsNullOrWhiteSpace(url))
return BadRequest(Fail("NO_URL", "Provide ?url= of the photo to remove.")); return BadRequest(Fail("NO_URL", "Provide ?url= of the photo to remove."));
@@ -2,6 +2,7 @@ using FluentValidation;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Public; using Meezi.API.Models.Public;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Services.Platform; using Meezi.Infrastructure.Services.Platform;
@@ -48,6 +49,7 @@ public class CafeReviewsController : CafeApiControllerBase
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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+). // Replying to reviews is a paid feature (Starter+).
var tier = tenant.PlanTier ?? PlanTier.Free; var tier = tenant.PlanTier ?? PlanTier.Free;
@@ -76,6 +78,7 @@ public class CafeReviewsController : CafeApiControllerBase
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var data = await _reviews.SetHiddenAsync(cafeId, reviewId, request.IsHidden, ct);
if (data is null) return NotFoundError(); if (data is null) return NotFoundError();
return Ok(new ApiResponse<CafeReviewDto>(true, data)); return Ok(new ApiResponse<CafeReviewDto>(true, data));
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Cafes; using Meezi.API.Models.Cafes;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Core.Utilities; using Meezi.Core.Utilities;
@@ -47,6 +48,7 @@ public class CafeSettingsController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageCafeSettings) is { } permDenied) return permDenied;
var validation = await _validator.ValidateAsync(request, ct); var validation = await _validator.ValidateAsync(request, ct);
if (!validation.IsValid) if (!validation.IsValid)
@@ -2,6 +2,7 @@ using FluentValidation;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Crm; using Meezi.API.Models.Crm;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Shared; using Meezi.Shared;
@@ -62,6 +63,7 @@ public class CouponsController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.CreateCoupon) is { } permDenied) return permDenied;
var validation = await _createValidator.ValidateAsync(request, cancellationToken); var validation = await _createValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -82,6 +84,7 @@ public class CouponsController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var data = await _couponService.UpdateAsync(cafeId, id, request, cancellationToken);
if (data is null) return NotFoundError(); if (data is null) return NotFoundError();
return Ok(new ApiResponse<CouponDto>(true, data)); return Ok(new ApiResponse<CouponDto>(true, data));
@@ -95,6 +98,7 @@ public class CouponsController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var deleted = await _couponService.DeleteAsync(cafeId, id, cancellationToken);
if (!deleted) return NotFoundError(); if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id })); return Ok(new ApiResponse<object>(true, new { id }));
@@ -0,0 +1,225 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.CustomRoles;
using Meezi.Core.Authorization;
using Meezi.Core.Entities;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/custom-roles")]
public class CustomRolesController : CafeApiControllerBase
{
private readonly AppDbContext _db;
public CustomRolesController(AppDbContext db)
{
_db = db;
}
[HttpGet]
public async Task<IActionResult> List(string cafeId, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageRoles) is { } forbidden) return forbidden;
var roles = await _db.CustomRoles
.AsNoTracking()
.Where(r => r.CafeId == cafeId)
.OrderBy(r => r.Name)
.Select(r => new
{
r.Id,
r.Name,
r.Description,
r.Color,
r.PermissionsJson,
EmployeeCount = _db.Employees.Count(e => e.CafeId == cafeId && e.CustomRoleId == r.Id && e.DeletedAt == null),
r.CreatedAt,
})
.ToListAsync(ct);
var dtos = roles.Select(r => new CustomRoleDto(
r.Id,
r.Name,
r.Description,
r.Color,
CustomRolePermissions.Parse(r.PermissionsJson).Select(p => p.ToString()).OrderBy(p => p).ToList(),
r.EmployeeCount,
r.CreatedAt)).ToList();
return Ok(new ApiResponse<IReadOnlyList<CustomRoleDto>>(true, dtos));
}
[HttpGet("{id}")]
public async Task<IActionResult> Get(string cafeId, string id, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageRoles) is { } forbidden) return forbidden;
var r = await _db.CustomRoles.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id && x.CafeId == cafeId, ct);
if (r is null) return NotFoundError("Custom role not found.");
var employeeCount = await _db.Employees
.CountAsync(e => e.CafeId == cafeId && e.CustomRoleId == id && e.DeletedAt == null, ct);
return Ok(new ApiResponse<CustomRoleDto>(true, new CustomRoleDto(
r.Id, r.Name, r.Description, r.Color,
CustomRolePermissions.Parse(r.PermissionsJson).Select(p => p.ToString()).OrderBy(p => p).ToList(),
employeeCount, r.CreatedAt)));
}
[HttpPost]
public async Task<IActionResult> Create(
string cafeId,
[FromBody] CreateCustomRoleRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageRoles) is { } forbidden) return forbidden;
var name = request.Name?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(name))
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Name is required.", "Name")));
var permissions = ParseAndValidatePermissions(request.Permissions);
var role = new CustomRole
{
CafeId = cafeId,
Name = name,
Description = request.Description?.Trim(),
Color = NormalizeColor(request.Color),
PermissionsJson = CustomRolePermissions.Serialize(permissions),
};
_db.CustomRoles.Add(role);
await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { cafeId, id = role.Id },
new ApiResponse<CustomRoleDto>(true, ToDto(role, 0)));
}
[HttpPatch("{id}")]
public async Task<IActionResult> Update(
string cafeId,
string id,
[FromBody] UpdateCustomRoleRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageRoles) is { } forbidden) return forbidden;
var role = await _db.CustomRoles
.FirstOrDefaultAsync(r => r.Id == id && r.CafeId == cafeId, ct);
if (role is null) return NotFoundError("Custom role not found.");
if (request.Name is not null)
{
var name = request.Name.Trim();
if (string.IsNullOrWhiteSpace(name))
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Name cannot be empty.", "Name")));
role.Name = name;
}
if (request.Description is not null)
role.Description = request.Description.Trim().Length > 0 ? request.Description.Trim() : null;
if (request.Color is not null)
role.Color = NormalizeColor(request.Color);
if (request.Permissions is not null)
role.PermissionsJson = CustomRolePermissions.Serialize(ParseAndValidatePermissions(request.Permissions));
await _db.SaveChangesAsync(ct);
var employeeCount = await _db.Employees
.CountAsync(e => e.CafeId == cafeId && e.CustomRoleId == id && e.DeletedAt == null, ct);
return Ok(new ApiResponse<CustomRoleDto>(true, ToDto(role, employeeCount)));
}
[HttpDelete("{id}")]
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.ManageRoles) is { } forbidden) return forbidden;
var role = await _db.CustomRoles
.FirstOrDefaultAsync(r => r.Id == id && r.CafeId == cafeId, ct);
if (role is null) return NotFoundError("Custom role not found.");
// Unassign employees before deletion so they fall back to their base role permissions.
await _db.Employees
.Where(e => e.CafeId == cafeId && e.CustomRoleId == id)
.ExecuteUpdateAsync(s => s.SetProperty(e => e.CustomRoleId, (string?)null), ct);
role.DeletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return Ok(new ApiResponse<object>(true, null));
}
// ── Employee custom-role assignment ───────────────────────────────────────
[HttpPut("/api/cafes/{cafeId}/employees/{employeeId}/custom-role")]
public async Task<IActionResult> AssignToEmployee(
string cafeId,
string employeeId,
[FromBody] AssignCustomRoleRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
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);
if (employee is null) return NotFoundError("Employee not found.");
if (request.CustomRoleId is not null)
{
var roleExists = await _db.CustomRoles
.AnyAsync(r => r.Id == request.CustomRoleId && r.CafeId == cafeId && r.DeletedAt == null, ct);
if (!roleExists)
return NotFoundError("Custom role not found.");
}
employee.CustomRoleId = request.CustomRoleId;
await _db.SaveChangesAsync(ct);
return Ok(new ApiResponse<object>(true, null));
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static CustomRoleDto ToDto(CustomRole r, int employeeCount) => new(
r.Id, r.Name, r.Description, r.Color,
CustomRolePermissions.Parse(r.PermissionsJson).Select(p => p.ToString()).OrderBy(p => p).ToList(),
employeeCount, r.CreatedAt);
private static IEnumerable<Permission> ParseAndValidatePermissions(IReadOnlyList<string>? names)
{
if (names is null) return [];
return names
.Where(n => Enum.TryParse<Permission>(n, ignoreCase: true, out _))
.Select(n => Enum.Parse<Permission>(n, ignoreCase: true))
.Distinct();
}
private static string? NormalizeColor(string? color)
{
if (string.IsNullOrWhiteSpace(color)) return null;
var c = color.Trim();
return c.StartsWith('#') ? c : null;
}
}
@@ -2,6 +2,7 @@ using FluentValidation;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Crm; using Meezi.API.Models.Crm;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Shared; using Meezi.Shared;
@@ -57,6 +58,7 @@ public class CustomersController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.CreateCustomer) is { } permDenied) return permDenied;
var validation = await _createValidator.ValidateAsync(request, cancellationToken); var validation = await _createValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -77,6 +79,7 @@ public class CustomersController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.EditCustomer) is { } permDenied) return permDenied;
var validation = await _updateValidator.ValidateAsync(request, cancellationToken); var validation = await _updateValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -99,6 +102,7 @@ public class CustomersController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var deleted = await _customerService.DeleteAsync(cafeId, id, cancellationToken);
if (!deleted) return NotFoundError(); if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id })); return Ok(new ApiResponse<object>(true, new { id }));
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.API.Services.Delivery; using Meezi.API.Services.Delivery;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Shared; using Meezi.Shared;
@@ -21,6 +22,7 @@ public class DeliveryReportsController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
var utcTo = to ?? DateTime.UtcNow; var utcTo = to ?? DateTime.UtcNow;
var utcFrom = from ?? utcTo.AddDays(-30); var utcFrom = from ?? utcTo.AddDays(-30);
@@ -2,7 +2,7 @@ using FluentValidation;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Expenses; using Meezi.API.Models.Expenses;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Enums; using Meezi.Core.Authorization;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Shared; using Meezi.Shared;
@@ -30,14 +30,11 @@ public class ExpensesController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.CreateExpense) is { } permDenied) return permDenied;
if (string.IsNullOrEmpty(tenant.UserId)) if (string.IsNullOrEmpty(tenant.UserId))
return StatusCode(StatusCodes.Status401Unauthorized, return StatusCode(StatusCodes.Status401Unauthorized,
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing."))); 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); var validation = await _createValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -57,6 +54,7 @@ public class ExpensesController : CafeApiControllerBase
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ViewExpenses) is { } permDenied) return permDenied;
if (string.IsNullOrWhiteSpace(branchId)) if (string.IsNullOrWhiteSpace(branchId))
return BadRequest(new ApiResponse<object>(false, null, return BadRequest(new ApiResponse<object>(false, null,
@@ -85,10 +83,7 @@ public class ExpensesController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.DeleteExpense) is { } permDenied) return permDenied;
if (!CanDeleteExpense(tenant.Role))
return StatusCode(StatusCodes.Status403Forbidden,
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "Only managers can delete expenses.")));
var result = await _expenses.DeleteExpenseAsync(cafeId, id, ct); var result = await _expenses.DeleteExpenseAsync(cafeId, id, ct);
if (!result.Success) if (!result.Success)
@@ -104,12 +99,6 @@ public class ExpensesController : CafeApiControllerBase
return Ok(new ApiResponse<object>(true, null)); 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) private IActionResult ExpenseResult(ExpenseServiceResult<ExpenseDto> result, int successStatus = StatusCodes.Status200OK)
{ {
if (result.Success) if (result.Success)
+13 -7
View File
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Hr; using Meezi.API.Models.Hr;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Entities; using Meezi.Core.Entities;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
@@ -43,6 +44,7 @@ public class HrController : CafeApiControllerBase
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var data = await _hr.GetEmployeesAsync(cafeId, branchId, ct);
return Ok(new ApiResponse<IReadOnlyList<EmployeeSummaryDto>>(true, data)); return Ok(new ApiResponse<IReadOnlyList<EmployeeSummaryDto>>(true, data));
} }
@@ -57,7 +59,7 @@ public class HrController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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) => IActionResult Invalid(string message, string field) =>
BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", message, field))); BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", message, field)));
@@ -183,6 +185,7 @@ public class HrController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var data = await _hr.GetAttendanceAsync(cafeId, employeeId, from, to, ct);
return Ok(new ApiResponse<IReadOnlyList<AttendanceDto>>(true, data)); 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) public async Task<IActionResult> GetShifts(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var data = await _hr.GetShiftsAsync(cafeId, employeeId, ct);
return Ok(new ApiResponse<IReadOnlyList<ShiftDto>>(true, data)); return Ok(new ApiResponse<IReadOnlyList<ShiftDto>>(true, data));
} }
@@ -204,7 +208,7 @@ public class HrController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var data = await _hr.UpsertShiftsAsync(cafeId, employeeId, request, ct);
return Ok(new ApiResponse<IReadOnlyList<ShiftDto>>(true, data)); return Ok(new ApiResponse<IReadOnlyList<ShiftDto>>(true, data));
} }
@@ -217,6 +221,7 @@ public class HrController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var data = await _hr.GetLeaveRequestsAsync(cafeId, status, ct);
return Ok(new ApiResponse<IReadOnlyList<LeaveRequestDto>>(true, data)); return Ok(new ApiResponse<IReadOnlyList<LeaveRequestDto>>(true, data));
} }
@@ -248,7 +253,7 @@ public class HrController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var validation = await _reviewValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -265,6 +270,7 @@ public class HrController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var data = await _hr.GetSalariesAsync(cafeId, monthYear, ct);
return Ok(new ApiResponse<IReadOnlyList<EmployeeSalaryDto>>(true, data)); return Ok(new ApiResponse<IReadOnlyList<EmployeeSalaryDto>>(true, data));
} }
@@ -277,7 +283,7 @@ public class HrController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var validation = await _salaryValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); 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) public async Task<IActionResult> MarkPaid(string cafeId, string salaryId, ITenantContext tenant, CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var data = await _hr.MarkSalaryPaidAsync(cafeId, salaryId, ct);
if (data is null) return NotFoundError(); if (data is null) return NotFoundError();
return Ok(new ApiResponse<EmployeeSalaryDto>(true, data)); return Ok(new ApiResponse<EmployeeSalaryDto>(true, data));
@@ -306,7 +312,7 @@ public class HrController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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(); var username = request.Username.Trim().ToLowerInvariant();
@@ -344,7 +350,7 @@ public class HrController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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 var employee = await _db.Employees
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct); .FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Shared; using Meezi.Shared;
@@ -36,6 +37,7 @@ public class InventoryController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.CreateInventory) is { } permDenied) return permDenied;
if (string.IsNullOrWhiteSpace(request.Name)) if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Name is required."))); return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Name is required.")));
@@ -56,6 +58,7 @@ public class InventoryController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var updated = await _inventory.UpdateAsync(cafeId, ingredientId, request, ct);
if (updated is null) return NotFoundError(); if (updated is null) return NotFoundError();
return Ok(new ApiResponse<object>(true, updated)); return Ok(new ApiResponse<object>(true, updated));
@@ -69,6 +72,7 @@ public class InventoryController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var deleted = await _inventory.DeleteAsync(cafeId, ingredientId, ct);
if (!deleted) return NotFoundError(); if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id = ingredientId })); return Ok(new ApiResponse<object>(true, new { id = ingredientId }));
@@ -83,6 +87,7 @@ public class InventoryController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.EditInventory) is { } permDenied) return permDenied;
try try
{ {
var updated = await _inventory.AdjustAsync(cafeId, ingredientId, request, tenant.UserId, ct); var updated = await _inventory.AdjustAsync(cafeId, ingredientId, request, tenant.UserId, ct);
@@ -146,6 +151,7 @@ public class InventoryController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var recipe = await _inventory.SetRecipeAsync(cafeId, menuItemId, request, ct);
if (recipe is null) return NotFoundError("Menu item not found."); if (recipe is null) return NotFoundError("Menu item not found.");
return Ok(new ApiResponse<object>(true, recipe)); return Ok(new ApiResponse<object>(true, recipe));
@@ -2,6 +2,7 @@ using FluentValidation;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Kitchen; using Meezi.API.Models.Kitchen;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Shared; using Meezi.Shared;
@@ -40,6 +41,7 @@ public class KitchenStationsController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageKitchenStations) is { } permDenied) return permDenied;
var validation = await _createValidator.ValidateAsync(request, ct); var validation = await _createValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -59,6 +61,7 @@ public class KitchenStationsController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageKitchenStations) is { } permDenied) return permDenied;
var validation = await _updateValidator.ValidateAsync(request, ct); var validation = await _updateValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); 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) public async Task<IActionResult> Delete(string cafeId, string id, ITenantContext tenant, CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var ok = await _stations.DeleteAsync(cafeId, id, ct);
if (!ok) return NotFoundError(); if (!ok) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id })); return Ok(new ApiResponse<object>(true, new { id }));
+32 -6
View File
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data; using Meezi.Infrastructure.Data;
@@ -20,13 +21,21 @@ public class MediaController : CafeApiControllerBase
[RequestSizeLimit(5 * 1024 * 1024)] [RequestSizeLimit(5 * 1024 * 1024)]
public Task<IActionResult> UploadMenuImage( public Task<IActionResult> UploadMenuImage(
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken) 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")] [HttpPost("menu-video")]
[RequestSizeLimit(25 * 1024 * 1024)] [RequestSizeLimit(25 * 1024 * 1024)]
public Task<IActionResult> UploadMenuVideo( public Task<IActionResult> UploadMenuVideo(
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken) 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")] [HttpPost("menu-model3d")]
[RequestSizeLimit(8 * 1024 * 1024)] [RequestSizeLimit(8 * 1024 * 1024)]
@@ -38,6 +47,7 @@ public class MediaController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
var planTier = tenant.PlanTier ?? PlanTier.Free; var planTier = tenant.PlanTier ?? PlanTier.Free;
if (!await catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, "menu_3d", cancellationToken)) if (!await catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, "menu_3d", cancellationToken))
{ {
@@ -63,25 +73,41 @@ public class MediaController : CafeApiControllerBase
[RequestSizeLimit(5 * 1024 * 1024)] [RequestSizeLimit(5 * 1024 * 1024)]
public Task<IActionResult> UploadTableImage( public Task<IActionResult> UploadTableImage(
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken) 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")] [HttpPost("table-video")]
[RequestSizeLimit(25 * 1024 * 1024)] [RequestSizeLimit(25 * 1024 * 1024)]
public Task<IActionResult> UploadTableVideo( public Task<IActionResult> UploadTableVideo(
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken) 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")] [HttpPost("cafe-logo")]
[RequestSizeLimit(5 * 1024 * 1024)] [RequestSizeLimit(5 * 1024 * 1024)]
public Task<IActionResult> UploadCafeLogo( public Task<IActionResult> UploadCafeLogo(
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken) 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")] [HttpPost("cafe-cover")]
[RequestSizeLimit(5 * 1024 * 1024)] [RequestSizeLimit(5 * 1024 * 1024)]
public Task<IActionResult> UploadCafeCover( public Task<IActionResult> UploadCafeCover(
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken) 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 /// <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> /// 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 Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Menu; using Meezi.API.Models.Menu;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Constants; using Meezi.Core.Constants;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
@@ -59,6 +60,7 @@ public class MenuController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.CreateMenuItem) is { } permDenied) return permDenied;
var validation = await _createCategoryValidator.ValidateAsync(request, cancellationToken); var validation = await _createCategoryValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -86,6 +88,7 @@ public class MenuController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var data = await _menuService.UpdateCategoryAsync(cafeId, id, request, cancellationToken);
if (data is null) return NotFoundError(); if (data is null) return NotFoundError();
return Ok(new ApiResponse<MenuCategoryDto>(true, data)); 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) public async Task<IActionResult> DeleteCategory(string cafeId, string id, ITenantContext tenant, CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var deleted = await _menuService.DeleteCategoryAsync(cafeId, id, cancellationToken);
if (!deleted) return NotFoundError(); if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id })); return Ok(new ApiResponse<object>(true, new { id }));
@@ -120,6 +124,7 @@ public class MenuController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.CreateMenuItem) is { } permDenied) return permDenied;
var validation = await _createItemValidator.ValidateAsync(request, cancellationToken); var validation = await _createItemValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -148,6 +153,7 @@ public class MenuController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var data = await _menuService.UpdateItemAsync(cafeId, id, request, cancellationToken);
if (data is null) return NotFoundError(); if (data is null) return NotFoundError();
return Ok(new ApiResponse<MenuItemDto>(true, data)); return Ok(new ApiResponse<MenuItemDto>(true, data));
@@ -162,6 +168,7 @@ public class MenuController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var data = await _menuService.SetAvailabilityAsync(cafeId, id, request.IsAvailable, cancellationToken);
if (data is null) return NotFoundError(); if (data is null) return NotFoundError();
return Ok(new ApiResponse<MenuItemDto>(true, data)); 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) public async Task<IActionResult> DeleteItem(string cafeId, string id, ITenantContext tenant, CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var deleted = await _menuService.DeleteItemAsync(cafeId, id, cancellationToken);
if (!deleted) return NotFoundError(); if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id })); return Ok(new ApiResponse<object>(true, new { id }));
@@ -193,6 +201,7 @@ public class MenuController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
var tier = tenant.PlanTier ?? PlanTier.Free; var tier = tenant.PlanTier ?? PlanTier.Free;
var (data, code, message) = await _menuAi3d.GenerateFromItemImageAsync(cafeId, id, tier, cancellationToken); var (data, code, message) = await _menuAi3d.GenerateFromItemImageAsync(cafeId, id, tier, cancellationToken);
if (code is not null) if (code is not null)
+11 -5
View File
@@ -1,5 +1,4 @@
using FluentValidation; using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Orders; using Meezi.API.Models.Orders;
using Meezi.API.Services; using Meezi.API.Services;
@@ -120,6 +119,7 @@ public class OrdersController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ProcessOrders) is { } permDenied) return permDenied;
var validation = await _createValidator.ValidateAsync(request, cancellationToken); var validation = await _createValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -139,6 +139,7 @@ public class OrdersController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.EditOrder) is { } permDenied) return permDenied;
var validation = await _appendValidator.ValidateAsync(request, cancellationToken); var validation = await _appendValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -150,7 +151,6 @@ public class OrdersController : CafeApiControllerBase
} }
[HttpPatch("{id}/items/{itemId}/void")] [HttpPatch("{id}/items/{itemId}/void")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> VoidOrderItem( public async Task<IActionResult> VoidOrderItem(
string cafeId, string cafeId,
string id, string id,
@@ -159,6 +159,7 @@ public class OrdersController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.VoidOrder) is { } permDenied) return permDenied;
if (string.IsNullOrEmpty(tenant.UserId)) if (string.IsNullOrEmpty(tenant.UserId))
return StatusCode(StatusCodes.Status403Forbidden, return StatusCode(StatusCodes.Status403Forbidden,
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "User context required."))); new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "User context required.")));
@@ -181,7 +182,6 @@ public class OrdersController : CafeApiControllerBase
} }
[HttpPost("{id}/transfer")] [HttpPost("{id}/transfer")]
[Authorize(Roles = "Manager,Owner,Waiter")]
public async Task<IActionResult> TransferTable( public async Task<IActionResult> TransferTable(
string cafeId, string cafeId,
string id, string id,
@@ -190,6 +190,7 @@ public class OrdersController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var result = await _orderService.TransferTableAsync(cafeId, id, request.TargetTableId, cancellationToken);
if (!result.Success) if (!result.Success)
@@ -207,6 +208,7 @@ public class OrdersController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.EditOrder) is { } permDenied) return permDenied;
var validation = await _sessionValidator.ValidateAsync(request, cancellationToken); var validation = await _sessionValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -226,6 +228,7 @@ public class OrdersController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.UpdateOrderStatus) is { } permDenied) return permDenied;
var validation = await _statusValidator.ValidateAsync(request, cancellationToken); var validation = await _statusValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -243,7 +246,7 @@ public class OrdersController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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( var result = await _orderService.CancelOrderAsync(
cafeId, id, request.Reason, tenant.UserId, cancellationToken); cafeId, id, request.Reason, tenant.UserId, cancellationToken);
@@ -279,6 +282,7 @@ public class OrdersController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.HandlePayments) is { } permDenied) return permDenied;
var validation = await _paymentsValidator.ValidateAsync(request, cancellationToken); var validation = await _paymentsValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -319,7 +323,7 @@ public class OrdersController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var validation = await _correctionValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); 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))), false, null, new ApiError(code, "Order is already cancelled.", field))),
"ORDER_HAS_PAYMENTS" => Conflict(new ApiResponse<object>( "ORDER_HAS_PAYMENTS" => Conflict(new ApiResponse<object>(
false, null, new ApiError(code, "Refund the recorded payments before cancelling this order.", field))), 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>( "ITEM_NOT_FOUND" => NotFound(new ApiResponse<object>(
false, null, new ApiError(code, "Line item not found.", field))), false, null, new ApiError(code, "Line item not found.", field))),
"ITEM_ALREADY_VOIDED" => BadRequest(new ApiResponse<object>( "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 FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Printing; using Meezi.API.Models.Printing;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Shared; using Meezi.Shared;
namespace Meezi.API.Controllers; namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/branches/{branchId}/pos-device")] [Route("api/cafes/{cafeId}/branches/{branchId}/pos-device")]
[Authorize(Roles = "Cashier,Manager,Owner")]
public class PosDeviceController : CafeApiControllerBase public class PosDeviceController : CafeApiControllerBase
{ {
private readonly IPosDeviceService _posDevice; private readonly IPosDeviceService _posDevice;
@@ -30,6 +29,7 @@ public class PosDeviceController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.HandlePayments) is { } permDenied) return permDenied;
var validation = await _validator.ValidateAsync(request, ct); var validation = await _validator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); 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 Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Printing; using Meezi.API.Models.Printing;
using Meezi.API.Services.Printing; using Meezi.API.Services.Printing;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Shared; using Meezi.Shared;
@@ -32,16 +32,18 @@ public class PrintController : CafeApiControllerBase
string cafeId, string cafeId,
string orderId, string orderId,
ITenantContext tenant, ITenantContext tenant,
CancellationToken ct) CancellationToken ct,
[FromQuery] string? stationId)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); return ToActionResult(result);
} }
[HttpPost("test")] [HttpPost("test")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> TestPrint( public async Task<IActionResult> TestPrint(
string cafeId, string cafeId,
[FromBody] TestPrintRequest request, [FromBody] TestPrintRequest request,
@@ -49,6 +51,7 @@ public class PrintController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var result = await _printer.TestPrintAsync(request.PrinterIp, request.Port, ct);
return ToActionResult(result); return ToActionResult(result);
@@ -62,7 +65,8 @@ public class PrintController : CafeApiControllerBase
var status = result.ErrorCode switch 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, "ORDER_NOT_FOUND" => StatusCodes.Status404NotFound,
_ => StatusCodes.Status502BadGateway _ => StatusCodes.Status502BadGateway
}; };
@@ -75,6 +79,7 @@ public class PrintController : CafeApiControllerBase
{ {
"PRINTER_NOT_CONFIGURED" => "Receipt printer IP is not configured for this branch.", "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.", "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.", "PRINTER_CONNECTION_FAILED" => "Could not connect to the printer.",
"ORDER_NOT_FOUND" => "Order not found.", "ORDER_NOT_FOUND" => "Order not found.",
_ => "Print failed." _ => "Print failed."
+25 -11
View File
@@ -1,9 +1,12 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Public; using Meezi.API.Models.Public;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Meezi.Shared; using Meezi.Shared;
namespace Meezi.API.Controllers; namespace Meezi.API.Controllers;
@@ -13,19 +16,19 @@ namespace Meezi.API.Controllers;
/// ///
/// POST /api/public/push/register — anonymous device registration /// POST /api/public/push/register — anonymous device registration
/// POST /api/public/push/unregister — anonymous device removal /// POST /api/public/push/unregister — anonymous device removal
/// POST /api/push/broadcast — authorized topic broadcast (marketing / /// POST /api/push/broadcast — café marketing push (own topic only)
/// saved-café alerts)
/// </summary> /// </summary>
[ApiController] public class PushController : CafeApiControllerBase
public class PushController : ControllerBase
{ {
private readonly IPushDeviceService _devices; private readonly IPushDeviceService _devices;
private readonly IPushSender _sender; private readonly IPushSender _sender;
private readonly AppDbContext _db;
public PushController(IPushDeviceService devices, IPushSender sender) public PushController(IPushDeviceService devices, IPushSender sender, AppDbContext db)
{ {
_devices = devices; _devices = devices;
_sender = sender; _sender = sender;
_db = db;
} }
[HttpPost("api/public/push/register")] [HttpPost("api/public/push/register")]
@@ -53,15 +56,26 @@ public class PushController : ControllerBase
} }
[HttpPost("api/push/broadcast")] [HttpPost("api/push/broadcast")]
[Authorize]
public async Task<IActionResult> Broadcast( public async Task<IActionResult> Broadcast(
[FromBody] BroadcastPushRequest request, CancellationToken ct) [FromBody] BroadcastPushRequest request,
ITenantContext tenant,
CancellationToken ct)
{ {
if (string.IsNullOrWhiteSpace(request.Topic)) if (EnsurePermission(tenant, Permission.SendSms) is { } forbidden) return forbidden;
return BadRequest(new ApiResponse<object>(false, null, if (string.IsNullOrEmpty(tenant.CafeId))
new ApiError("INVALID_TOPIC", "Topic is required."))); 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 })); return Ok(new ApiResponse<object>(true, new { sent = true }));
} }
} }
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Queue; using Meezi.API.Models.Queue;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Shared; using Meezi.Shared;
@@ -37,6 +38,7 @@ public class QueueController : CafeApiControllerBase
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var (ticket, error) = await _queue.IssueNextAsync(cafeId, tenant.UserId, request, ct);
if (error == "BRANCH_NOT_FOUND") if (error == "BRANCH_NOT_FOUND")
return NotFound(new ApiResponse<object>(false, null, new ApiError(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) CancellationToken ct = default)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var (ticket, error) = await _queue.UpdateStatusAsync(cafeId, ticketId, request.Status, ct);
if (error == "NOT_FOUND") if (error == "NOT_FOUND")
return NotFound(new ApiResponse<object>(false, null, new ApiError(error, "Ticket 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) CancellationToken ct = default)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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 board = await _queue.GetTodayBoardAsync(cafeId, branchId, ct);
var next = board.Tickets.FirstOrDefault(t => t.Status == QueueTicketStatus.Waiting); var next = board.Tickets.FirstOrDefault(t => t.Status == QueueTicketStatus.Waiting);
if (next is null) if (next is null)
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Reports; using Meezi.API.Models.Reports;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.API.Utils; using Meezi.API.Utils;
using Meezi.Core.Authorization;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Services.Platform; using Meezi.Infrastructure.Services.Platform;
@@ -38,6 +39,7 @@ public class ReportsController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
if (string.IsNullOrWhiteSpace(branchId)) if (string.IsNullOrWhiteSpace(branchId))
return BadRequest(new ApiResponse<object>(false, null, return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", "branchId is required.", "branchId"))); new ApiError("VALIDATION_ERROR", "branchId is required.", "branchId")));
@@ -65,6 +67,7 @@ public class ReportsController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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)) if (!TryParseReportDate(from, out var startDate) || !TryParseReportDate(to, out var endDate))
return BadRequest(new ApiResponse<object>(false, null, return BadRequest(new ApiResponse<object>(false, null,
@@ -99,6 +102,7 @@ public class ReportsController : CafeApiControllerBase
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
var maxDays = await MaxHistoryDaysAsync(tenant, ct); var maxDays = await MaxHistoryDaysAsync(tenant, ct);
if (days > maxDays && maxDays != int.MaxValue) if (days > maxDays && maxDays != int.MaxValue)
@@ -120,6 +124,7 @@ public class ReportsController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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 _)) if (date is not null && !JalaliCalendarHelper.TryParseJalaliDate(date, out _, out _, out _))
return BadRequest(new ApiResponse<object>(false, null, return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", "Invalid Jalali date. Use yyyy-MM-dd."))); new ApiError("VALIDATION_ERROR", "Invalid Jalali date. Use yyyy-MM-dd.")));
@@ -136,6 +141,7 @@ public class ReportsController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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 _)) if (month is not null && !JalaliCalendarHelper.TryParseJalaliMonth(month, out _, out _))
return BadRequest(new ApiResponse<object>(false, null, return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", "Invalid Jalali month. Use yyyy-MM."))); new ApiError("VALIDATION_ERROR", "Invalid Jalali month. Use yyyy-MM.")));
@@ -152,6 +158,7 @@ public class ReportsController : CafeApiControllerBase
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var data = await _reports.GetTrendAsync(cafeId, days, ct);
return Ok(new ApiResponse<IReadOnlyList<TrendDayDto>>(true, data)); return Ok(new ApiResponse<IReadOnlyList<TrendDayDto>>(true, data));
} }
@@ -165,6 +172,7 @@ public class ReportsController : CafeApiControllerBase
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ExportReports) is { } permDenied) return permDenied;
if (!string.Equals(format, "excel", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(format, "excel", StringComparison.OrdinalIgnoreCase))
return BadRequest(new ApiResponse<object>(false, null, return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", "Only excel format is supported."))); new ApiError("VALIDATION_ERROR", "Only excel format is supported.")));
@@ -2,6 +2,7 @@ using FluentValidation;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Public; using Meezi.API.Models.Public;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Shared; using Meezi.Shared;
@@ -30,6 +31,7 @@ public class ReservationsController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.CreateReservation) is { } permDenied) return permDenied;
var validation = await _createValidator.ValidateAsync(request, ct); var validation = await _createValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -62,6 +64,7 @@ public class ReservationsController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var data = await _reservations.UpdateStatusAsync(cafeId, id, request.Status, ct);
if (data is null) return NotFoundError(); if (data is null) return NotFoundError();
return Ok(new ApiResponse<ReservationDto>(true, data)); return Ok(new ApiResponse<ReservationDto>(true, data));
@@ -75,6 +78,7 @@ public class ReservationsController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var deleted = await _reservations.DeleteAsync(cafeId, id, ct);
if (!deleted) return NotFoundError(); if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id })); return Ok(new ApiResponse<object>(true, new { id }));
@@ -2,6 +2,7 @@ using FluentValidation;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Shifts; using Meezi.API.Models.Shifts;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Shared; using Meezi.Shared;
@@ -33,6 +34,7 @@ public class ShiftsController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.OperateRegister) is { } permDenied) return permDenied;
if (string.IsNullOrEmpty(tenant.UserId)) if (string.IsNullOrEmpty(tenant.UserId))
return StatusCode(StatusCodes.Status401Unauthorized, return StatusCode(StatusCodes.Status401Unauthorized,
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing."))); new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing.")));
@@ -54,6 +56,7 @@ public class ShiftsController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.OperateRegister) is { } permDenied) return permDenied;
if (string.IsNullOrEmpty(tenant.UserId)) if (string.IsNullOrEmpty(tenant.UserId))
return StatusCode(StatusCodes.Status401Unauthorized, return StatusCode(StatusCodes.Status401Unauthorized,
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing."))); 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 Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Crm; using Meezi.API.Models.Crm;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Shared; using Meezi.Shared;
@@ -43,7 +44,7 @@ public class SmsController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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( var (success, data, code, message) = await _smsMarketingService.UpdateSettingsAsync(
cafeId, request, cancellationToken); cafeId, request, cancellationToken);
@@ -85,6 +86,7 @@ public class SmsController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.SendSms) is { } permDenied) return permDenied;
var validation = await _campaignValidator.ValidateAsync(request, cancellationToken); var validation = await _campaignValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -1,9 +1,9 @@
using FluentValidation; using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Orders; using Meezi.API.Models.Orders;
using Meezi.API.Models.Tables; using Meezi.API.Models.Tables;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Shared; using Meezi.Shared;
@@ -65,6 +65,7 @@ public class TablesController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
var validation = await _createValidator.ValidateAsync(request, ct); var validation = await _createValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -82,6 +83,7 @@ public class TablesController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
var validation = await _patchValidator.ValidateAsync(request, ct); var validation = await _patchValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -104,7 +106,6 @@ public class TablesController : CafeApiControllerBase
} }
[HttpDelete("{id}")] [HttpDelete("{id}")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> DeleteTable( public async Task<IActionResult> DeleteTable(
string cafeId, string cafeId,
string id, string id,
@@ -112,6 +113,7 @@ public class TablesController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var result = await _tableService.DeleteTableAsync(cafeId, id, ct);
if (!result.Success) if (!result.Success)
@@ -135,6 +137,7 @@ public class TablesController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
var validation = await _cleaningValidator.ValidateAsync(request, ct); var validation = await _cleaningValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); if (!validation.IsValid) return BadRequest(ValidationError(validation));
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Shared; using Meezi.Shared;
@@ -19,6 +20,7 @@ public class TarazController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageFinancials) is { } permDenied) return permDenied;
var targetDate = date ?? DateTime.UtcNow.Date; var targetDate = date ?? DateTime.UtcNow.Date;
var result = await _taraz.SubmitDailyInvoicesAsync(cafeId, targetDate, ct); var result = await _taraz.SubmitDailyInvoicesAsync(cafeId, targetDate, ct);
+4 -3
View File
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Taxes; using Meezi.API.Models.Taxes;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Shared; using Meezi.Shared;
@@ -29,7 +30,7 @@ public class TaxesController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var data = await _taxService.CreateAsync(cafeId, request, cancellationToken);
return Ok(new ApiResponse<TaxDto>(true, data)); return Ok(new ApiResponse<TaxDto>(true, data));
} }
@@ -43,7 +44,7 @@ public class TaxesController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var data = await _taxService.UpdateAsync(cafeId, id, request, cancellationToken);
if (data is null) return NotFoundError(); if (data is null) return NotFoundError();
return Ok(new ApiResponse<TaxDto>(true, data)); return Ok(new ApiResponse<TaxDto>(true, data));
@@ -57,7 +58,7 @@ public class TaxesController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); var deleted = await _taxService.DeleteAsync(cafeId, id, cancellationToken);
if (!deleted) return NotFoundError(); if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id })); return Ok(new ApiResponse<object>(true, new { id }));
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.Core.Authorization;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.API.Services; using Meezi.API.Services;
@@ -52,6 +53,7 @@ public class TerminalsController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageCafeSettings) is { } permDenied) return permDenied;
await _terminals.RevokeAsync(cafeId, terminalId, ct); await _terminals.RevokeAsync(cafeId, terminalId, ct);
return Ok(new ApiResponse<object>(true, new { revoked = true })); return Ok(new ApiResponse<object>(true, new { revoked = true }));
} }
@@ -13,6 +13,7 @@ using Meezi.API.Services;
using Meezi.API.Services.Delivery; using Meezi.API.Services.Delivery;
using Meezi.Infrastructure.Services.Platform; using Meezi.Infrastructure.Services.Platform;
using Meezi.API.Services.Printing; using Meezi.API.Services.Printing;
using Meezi.API.Services.Payments;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Infrastructure; using Meezi.Infrastructure;
using Serilog; using Serilog;
@@ -94,6 +95,14 @@ public static class ServiceCollectionExtensions
services.AddScoped<IDemoSeedService, DemoSeedService>(); services.AddScoped<IDemoSeedService, DemoSeedService>();
services.AddScoped<ReceiptBuilder>(); services.AddScoped<ReceiptBuilder>();
services.AddScoped<IPrinterService, NetworkPrinterService>(); 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.AddHttpClient(nameof(PosDeviceService));
services.AddScoped<IPosDeviceService, PosDeviceService>(); services.AddScoped<IPosDeviceService, PosDeviceService>();
services.AddScoped<SubscriptionRenewalReminderJob>(); services.AddScoped<SubscriptionRenewalReminderJob>();
@@ -224,6 +233,7 @@ public static class ServiceCollectionExtensions
app.MapControllers(); app.MapControllers();
app.MapHub<KdsHub>("/hubs/kds"); app.MapHub<KdsHub>("/hubs/kds");
app.MapHub<GuestOrderHub>("/hubs/guest-order"); app.MapHub<GuestOrderHub>("/hubs/guest-order");
app.MapHub<PrintAgentHub>("/hubs/print-agent");
app.MapGet("/health", () => Results.Ok(new { status = "healthy" })); app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));
if (!app.Configuration.GetValue<bool>("Testing:Enabled")) 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();
}
}
@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Meezi.Core.Authorization;
using Meezi.Core.Constants; using Meezi.Core.Constants;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
@@ -116,6 +117,16 @@ public class TenantMiddleware
else else
_logger.LogWarning("Ignoring invalid or inactive branchId claim for cafe {CafeId}", cafeId); _logger.LogWarning("Ignoring invalid or inactive branchId claim for cafe {CafeId}", cafeId);
} }
var customPermsClaim = context.User.FindFirst(MeeziClaimTypes.CustomPermissions)?.Value;
if (!string.IsNullOrEmpty(customPermsClaim))
{
var set = new HashSet<Permission>();
foreach (var name in customPermsClaim.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (Enum.TryParse<Permission>(name, ignoreCase: true, out var p))
set.Add(p);
scopedMerchant.CustomPermissions = set;
}
} }
if (branchContext is BranchContext scopedBranch) if (branchContext is BranchContext scopedBranch)
@@ -0,0 +1,24 @@
namespace Meezi.API.Models.CustomRoles;
public record CustomRoleDto(
string Id,
string Name,
string? Description,
string? Color,
IReadOnlyList<string> Permissions,
int EmployeeCount,
DateTime CreatedAt);
public record CreateCustomRoleRequest(
string Name,
string? Description = null,
string? Color = null,
IReadOnlyList<string>? Permissions = null);
public record UpdateCustomRoleRequest(
string? Name = null,
string? Description = null,
string? Color = null,
IReadOnlyList<string>? Permissions = null);
public record AssignCustomRoleRequest(string? CustomRoleId);
@@ -7,18 +7,21 @@ public record KitchenStationDto(
string? PrinterIp, string? PrinterIp,
int PrinterPort, int PrinterPort,
int SortOrder, int SortOrder,
int CategoryCount); int CategoryCount,
string? PrintDeviceId);
public record CreateKitchenStationRequest( public record CreateKitchenStationRequest(
string Name, string Name,
string? BranchId, string? BranchId,
string? PrinterIp, string? PrinterIp,
int PrinterPort = 9100, int PrinterPort = 9100,
int SortOrder = 0); int SortOrder = 0,
string? PrintDeviceId = null);
public record UpdateKitchenStationRequest( public record UpdateKitchenStationRequest(
string? Name, string? Name,
string? BranchId, string? BranchId,
string? PrinterIp, string? PrinterIp,
int? PrinterPort, int? PrinterPort,
int? SortOrder); int? SortOrder,
string? PrintDeviceId);
+6 -3
View File
@@ -55,7 +55,8 @@ public record MenuItemDto(
string? ImageUrl, string? ImageUrl,
string? VideoUrl, string? VideoUrl,
string? Model3dUrl, string? Model3dUrl,
bool IsAvailable); bool IsAvailable,
string? KitchenStationId);
public record CreateMenuItemRequest( public record CreateMenuItemRequest(
string CategoryId, string CategoryId,
@@ -68,7 +69,8 @@ public record CreateMenuItemRequest(
string? ImageUrl = null, string? ImageUrl = null,
string? VideoUrl = null, string? VideoUrl = null,
string? Model3dUrl = null, string? Model3dUrl = null,
bool IsAvailable = true); bool IsAvailable = true,
string? KitchenStationId = null);
public record UpdateMenuItemRequest( public record UpdateMenuItemRequest(
string? CategoryId, string? CategoryId,
@@ -81,6 +83,7 @@ public record UpdateMenuItemRequest(
string? ImageUrl, string? ImageUrl,
string? VideoUrl, string? VideoUrl,
string? Model3dUrl, string? Model3dUrl,
bool? IsAvailable); bool? IsAvailable,
string? KitchenStationId);
public record UpdateMenuItemAvailabilityRequest(bool IsAvailable); public record UpdateMenuItemAvailabilityRequest(bool IsAvailable);
+5 -1
View File
@@ -10,7 +10,11 @@ public record OrderItemDto(
decimal UnitPrice, decimal UnitPrice,
string? Notes, string? Notes,
bool IsVoided = false, 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); 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? ReceiptFooter,
string? WifiPassword, string? WifiPassword,
string? PosDeviceIp, string? PosDeviceIp,
int? PosDevicePort); int? PosDevicePort,
string? ReceiptPrintDeviceId,
string? KitchenPrintDeviceId);
public record PatchBranchPrintSettingsRequest( public record PatchBranchPrintSettingsRequest(
string? ReceiptPrinterIp, string? ReceiptPrinterIp,
@@ -25,7 +27,9 @@ public record PatchBranchPrintSettingsRequest(
string? ReceiptFooter, string? ReceiptFooter,
string? WifiPassword, string? WifiPassword,
string? PosDeviceIp, string? PosDeviceIp,
int? PosDevicePort); int? PosDevicePort,
string? ReceiptPrintDeviceId,
string? KitchenPrintDeviceId);
public record PosPaymentRequest(string OrderId, decimal Amount); public record PosPaymentRequest(string OrderId, decimal Amount);
+13 -3
View File
@@ -558,7 +558,18 @@ public class AuthService : IAuthService
{ {
var resolution = await ResolveBranchAsync(employee, cafe, requestedBranchId, cancellationToken); var resolution = await ResolveBranchAsync(employee, cafe, requestedBranchId, cancellationToken);
var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe, resolution.EffectiveRole, resolution.ActiveBranchId); // Load custom role permissions when the employee has a custom role assigned.
IReadOnlySet<Permission>? customPerms = null;
if (!string.IsNullOrEmpty(employee.CustomRoleId))
{
var cr = await _db.CustomRoles
.AsNoTracking()
.FirstOrDefaultAsync(r => r.Id == employee.CustomRoleId && r.CafeId == cafe.Id && r.DeletedAt == null, cancellationToken);
if (cr != null)
customPerms = CustomRolePermissions.Parse(cr.PermissionsJson);
}
var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe, resolution.EffectiveRole, resolution.ActiveBranchId, customPerms);
// On refresh, reuse the caller's refresh token (and slide its TTL below) instead // On refresh, reuse the caller's refresh token (and slide its TTL below) instead
// of minting a new one. A café often runs POS + KDS + queue display at once; if // of minting a new one. A café often runs POS + KDS + queue display at once; if
// refresh rotated the token, the first refresh would revoke it and every other // refresh rotated the token, the first refresh would revoke it and every other
@@ -580,8 +591,7 @@ public class AuthService : IAuthService
TimeSpan.FromDays(refreshDays), TimeSpan.FromDays(refreshDays),
cancellationToken); cancellationToken);
var permissions = Meezi.Core.Authorization.RolePermissions var permissions = (customPerms as IEnumerable<Permission> ?? RolePermissions.For(resolution.EffectiveRole))
.For(resolution.EffectiveRole)
.Select(p => p.ToString()) .Select(p => p.ToString())
.OrderBy(p => p) .OrderBy(p => p)
.ToList(); .ToList();
+93 -1
View File
@@ -40,6 +40,21 @@ public interface IBillingService
string cafeId, string cafeId,
string paymentId, string paymentId,
CancellationToken cancellationToken = default); 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 public class BillingService : IBillingService
@@ -217,6 +232,16 @@ public class BillingService : IBillingService
payment.RefId = verify.RefId; 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 cafe = payment.Cafe;
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
@@ -244,8 +269,75 @@ public class BillingService : IBillingService
await _db.SaveChangesAsync(cancellationToken); await _db.SaveChangesAsync(cancellationToken);
await TrySendConfirmationSmsAsync(cafe, payment, queued, 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 /// <summary>End of the cafe's current paid coverage: the later of its active plan expiry
+9 -1
View File
@@ -1,3 +1,4 @@
using Meezi.Core.Authorization;
using Meezi.Core.Entities; using Meezi.Core.Entities;
using Meezi.Core.Enums; using Meezi.Core.Enums;
@@ -11,8 +12,15 @@ public interface IJwtTokenService
/// Issue a token scoped to an active branch. The <paramref name="effectiveRole"/> /// Issue a token scoped to an active branch. The <paramref name="effectiveRole"/>
/// is the role the employee holds in <paramref name="activeBranchId"/> (or their /// is the role the employee holds in <paramref name="activeBranchId"/> (or their
/// café-wide role when <paramref name="activeBranchId"/> is null). /// café-wide role when <paramref name="activeBranchId"/> is null).
/// When <paramref name="customPermissions"/> is non-null the token embeds those
/// permissions as a claim that overrides the role matrix on the server side.
/// </summary> /// </summary>
string CreateAccessToken(Employee employee, Cafe cafe, EmployeeRole effectiveRole, string? activeBranchId); string CreateAccessToken(
Employee employee,
Cafe cafe,
EmployeeRole effectiveRole,
string? activeBranchId,
IEnumerable<Permission>? customPermissions = null);
string CreateConsumerAccessToken(ConsumerAccount account, string language = "fa"); string CreateConsumerAccessToken(ConsumerAccount account, string language = "fa");
string CreateRefreshToken(); string CreateRefreshToken();
+14 -1
View File
@@ -1,6 +1,7 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Text; using System.Text;
using Meezi.Core.Authorization;
using Meezi.Core.Constants; using Meezi.Core.Constants;
using Meezi.Core.Entities; using Meezi.Core.Entities;
using Meezi.Core.Enums; using Meezi.Core.Enums;
@@ -21,7 +22,12 @@ public class JwtTokenService : IJwtTokenService
public string CreateAccessToken(Employee employee, Cafe cafe) => public string CreateAccessToken(Employee employee, Cafe cafe) =>
CreateAccessToken(employee, cafe, employee.Role, employee.BranchId); CreateAccessToken(employee, cafe, employee.Role, employee.BranchId);
public string CreateAccessToken(Employee employee, Cafe cafe, EmployeeRole effectiveRole, string? activeBranchId) public string CreateAccessToken(
Employee employee,
Cafe cafe,
EmployeeRole effectiveRole,
string? activeBranchId,
IEnumerable<Permission>? customPermissions = null)
{ {
var key = _configuration["Jwt:Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured."); var key = _configuration["Jwt:Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured.");
var issuer = _configuration["Jwt:Issuer"] ?? "meezi"; var issuer = _configuration["Jwt:Issuer"] ?? "meezi";
@@ -41,6 +47,13 @@ public class JwtTokenService : IJwtTokenService
if (!string.IsNullOrEmpty(activeBranchId)) if (!string.IsNullOrEmpty(activeBranchId))
claims.Add(new Claim(MeeziClaimTypes.BranchId, activeBranchId)); claims.Add(new Claim(MeeziClaimTypes.BranchId, activeBranchId));
if (customPermissions != null)
{
var encoded = string.Join(",", customPermissions.Select(p => p.ToString()));
if (!string.IsNullOrEmpty(encoded))
claims.Add(new Claim(MeeziClaimTypes.CustomPermissions, encoded));
}
var credentials = new SigningCredentials( var credentials = new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)), new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
SecurityAlgorithms.HmacSha256); SecurityAlgorithms.HmacSha256);
@@ -33,12 +33,13 @@ public class KitchenStationService : IKitchenStationService
s.PrinterIp, s.PrinterIp,
s.PrinterPort, s.PrinterPort,
s.SortOrder, s.SortOrder,
s.PrintDeviceId,
CategoryCount = s.Categories.Count(c => c.DeletedAt == null) CategoryCount = s.Categories.Count(c => c.DeletedAt == null)
}) })
.ToListAsync(ct); .ToListAsync(ct);
return stations.Select(s => new KitchenStationDto( 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( public async Task<KitchenStationDto?> CreateAsync(
@@ -60,6 +61,7 @@ public class KitchenStationService : IKitchenStationService
Name = request.Name.Trim(), Name = request.Name.Trim(),
PrinterIp = string.IsNullOrWhiteSpace(request.PrinterIp) ? null : request.PrinterIp.Trim(), PrinterIp = string.IsNullOrWhiteSpace(request.PrinterIp) ? null : request.PrinterIp.Trim(),
PrinterPort = request.PrinterPort > 0 ? request.PrinterPort : 9100, PrinterPort = request.PrinterPort > 0 ? request.PrinterPort : 9100,
PrintDeviceId = string.IsNullOrWhiteSpace(request.PrintDeviceId) ? null : request.PrintDeviceId,
SortOrder = request.SortOrder SortOrder = request.SortOrder
}; };
@@ -95,6 +97,8 @@ public class KitchenStationService : IKitchenStationService
entity.PrinterIp = string.IsNullOrWhiteSpace(request.PrinterIp) ? null : request.PrinterIp.Trim(); entity.PrinterIp = string.IsNullOrWhiteSpace(request.PrinterIp) ? null : request.PrinterIp.Trim();
if (request.PrinterPort.HasValue) if (request.PrinterPort.HasValue)
entity.PrinterPort = request.PrinterPort.Value > 0 ? request.PrinterPort.Value : 9100; 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) if (request.SortOrder.HasValue)
entity.SortOrder = request.SortOrder.Value; entity.SortOrder = request.SortOrder.Value;
@@ -114,6 +118,12 @@ public class KitchenStationService : IKitchenStationService
foreach (var cat in categories) foreach (var cat in categories)
cat.KitchenStationId = null; 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; entity.DeletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return true; return true;
@@ -131,12 +141,13 @@ public class KitchenStationService : IKitchenStationService
x.PrinterIp, x.PrinterIp,
x.PrinterPort, x.PrinterPort,
x.SortOrder, x.SortOrder,
x.PrintDeviceId,
CategoryCount = x.Categories.Count(c => c.DeletedAt == null) CategoryCount = x.Categories.Count(c => c.DeletedAt == null)
}) })
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(ct);
return s is null return s is null
? 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, ImageUrl = imageUrl,
VideoUrl = request.VideoUrl, VideoUrl = request.VideoUrl,
Model3dUrl = NormalizeOptionalText(request.Model3dUrl), Model3dUrl = NormalizeOptionalText(request.Model3dUrl),
IsAvailable = request.IsAvailable IsAvailable = request.IsAvailable,
KitchenStationId = string.IsNullOrWhiteSpace(request.KitchenStationId) ? null : request.KitchenStationId,
}; };
_db.MenuItems.Add(entity); _db.MenuItems.Add(entity);
@@ -178,6 +179,8 @@ public class MenuService : IMenuService
if (request.Model3dUrl is not null) if (request.Model3dUrl is not null)
entity.Model3dUrl = string.IsNullOrWhiteSpace(request.Model3dUrl) ? null : request.Model3dUrl.Trim(); entity.Model3dUrl = string.IsNullOrWhiteSpace(request.Model3dUrl) ? null : request.Model3dUrl.Trim();
if (request.IsAvailable.HasValue) entity.IsAvailable = request.IsAvailable.Value; 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); await _db.SaveChangesAsync(cancellationToken);
return ToItemDto(entity); return ToItemDto(entity);
@@ -236,5 +239,6 @@ public class MenuService : IMenuService
MenuItemImageDefaults.ResolveDisplayImageUrl(i), MenuItemImageDefaults.ResolveDisplayImageUrl(i),
i.VideoUrl, i.VideoUrl,
i.Model3dUrl, 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 var orders = await _db.Orders
.Include(o => o.Items) .Include(o => o.Items)
.ThenInclude(i => i.MenuItem) .ThenInclude(i => i.MenuItem)
.ThenInclude(m => m.Category)
.ThenInclude(c => c.KitchenStation)
.Include(o => o.Table) .Include(o => o.Table)
.Where(o => o.CafeId == cafeId && LiveStatuses.Contains(o.Status)) .Where(o => o.CafeId == cafeId && LiveStatuses.Contains(o.Status))
.OrderBy(o => o.CreatedAt) .OrderBy(o => o.CreatedAt)
@@ -993,9 +995,18 @@ public class OrderService : IOrderService
if (order.Status == OrderStatus.Cancelled) if (order.Status == OrderStatus.Cancelled)
return new OrderServiceResult<OrderDto>(false, null, "ORDER_ALREADY_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"); 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 // 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. // here would silently strip the recorded money. Block and surface the reason.
if (order.Payments.Any(p => p.DeletedAt == null)) if (order.Payments.Any(p => p.DeletedAt == null))
@@ -1037,6 +1048,12 @@ public class OrderService : IOrderService
if (order is null) if (order is null)
return new OrderServiceResult<IReadOnlyList<PaymentDto>>(false, null, "ORDER_NOT_FOUND"); 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); var branchId = await ResolveOrderBranchIdAsync(order, cafeId, cancellationToken);
if (string.IsNullOrEmpty(branchId)) if (string.IsNullOrEmpty(branchId))
return new OrderServiceResult<IReadOnlyList<PaymentDto>>(false, null, "NO_OPEN_SHIFT", "branchId"); return new OrderServiceResult<IReadOnlyList<PaymentDto>>(false, null, "NO_OPEN_SHIFT", "branchId");
@@ -1123,7 +1140,8 @@ public class OrderService : IOrderService
if (paidTotal >= order.Total) 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 _loyalty.ApplyEarnOnOrderPaidAsync(cafeId, order.CustomerId, paidTotal, cancellationToken);
await _deliverySync.SyncInternalStatusAsync(cafeId, orderId, OrderStatus.Delivered, cancellationToken); await _deliverySync.SyncInternalStatusAsync(cafeId, orderId, OrderStatus.Delivered, cancellationToken);
} }
@@ -1345,6 +1363,8 @@ public class OrderService : IOrderService
i.UnitPrice, i.UnitPrice,
i.Notes, i.Notes,
i.IsVoided, i.IsVoided,
i.VoidedAt)).ToList(), i.VoidedAt,
i.MenuItem?.Category?.KitchenStationId,
i.MenuItem?.Category?.KitchenStation?.Name)).ToList(),
o.Source); 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.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using Meezi.API.Models.Printing; using Meezi.API.Models.Printing;
using Meezi.API.Services.Printing;
using Meezi.Infrastructure.Data; using Meezi.Infrastructure.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -29,15 +30,18 @@ public class PosDeviceService : IPosDeviceService
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
private readonly IPrintAgentRegistry _agents;
private readonly ILogger<PosDeviceService> _logger; private readonly ILogger<PosDeviceService> _logger;
public PosDeviceService( public PosDeviceService(
AppDbContext db, AppDbContext db,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IPrintAgentRegistry agents,
ILogger<PosDeviceService> logger) ILogger<PosDeviceService> logger)
{ {
_db = db; _db = db;
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
_agents = agents;
_logger = logger; _logger = logger;
} }
@@ -71,14 +75,31 @@ public class PosDeviceService : IPosDeviceService
if (order is null) if (order is null)
return PosDeviceResult.Fail("ORDER_NOT_FOUND"); 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 var payload = new
{ {
amount = (long)Math.Round(request.Amount, 0, MidpointRounding.AwayFromZero), amount,
orderId = request.OrderId, orderId = request.OrderId,
branchId, branchId,
}; };
var url = $"http://{branch.PosDeviceIp!.Trim()}:{port}/pay"; var url = $"http://{ip}:{port}/pay";
try try
{ {
@@ -117,4 +138,31 @@ public class PosDeviceService : IPosDeviceService
return PosDeviceResult.Fail("POS_DEVICE_CONNECTION_FAILED", ex.Message); 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( Task<PrintResult> PrintKitchenTicketAsync(
string cafeId, string cafeId,
string orderId, string orderId,
string? stationId = null,
CancellationToken ct = default); CancellationToken ct = default);
Task<PrintResult> TestPrintAsync(string printerIp, int port, 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 public class NetworkPrinterService : IPrinterService
@@ -28,17 +30,20 @@ public class NetworkPrinterService : IPrinterService
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly IOrderService _orders; private readonly IOrderService _orders;
private readonly ReceiptBuilder _receiptBuilder; private readonly ReceiptBuilder _receiptBuilder;
private readonly IPrintAgentRegistry _agents;
private readonly ILogger<NetworkPrinterService> _logger; private readonly ILogger<NetworkPrinterService> _logger;
public NetworkPrinterService( public NetworkPrinterService(
AppDbContext db, AppDbContext db,
IOrderService orders, IOrderService orders,
ReceiptBuilder receiptBuilder, ReceiptBuilder receiptBuilder,
IPrintAgentRegistry agents,
ILogger<NetworkPrinterService> logger) ILogger<NetworkPrinterService> logger)
{ {
_db = db; _db = db;
_orders = orders; _orders = orders;
_receiptBuilder = receiptBuilder; _receiptBuilder = receiptBuilder;
_agents = agents;
_logger = logger; _logger = logger;
} }
@@ -48,13 +53,16 @@ public class NetworkPrinterService : IPrinterService
if (ctx is null) if (ctx is null)
return PrintResult.Fail("ORDER_NOT_FOUND"); 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"); return PrintResult.Fail("PRINTER_NOT_CONFIGURED");
var bytes = _receiptBuilder.BuildReceipt(ctx.Value.printCtx); var bytes = _receiptBuilder.BuildReceipt(ctx.Value.printCtx);
return await SendToPrinterAsync( return await DispatchAsync(
ctx.Value.branch.ReceiptPrinterIp!, cafeId,
ctx.Value.branch.ReceiptPrinterPort ?? 9100, branch.ReceiptPrintDeviceId,
branch.ReceiptPrinterIp,
branch.ReceiptPrinterPort ?? 9100,
bytes, bytes,
ct); ct);
} }
@@ -62,6 +70,7 @@ public class NetworkPrinterService : IPrinterService
public async Task<PrintResult> PrintKitchenTicketAsync( public async Task<PrintResult> PrintKitchenTicketAsync(
string cafeId, string cafeId,
string orderId, string orderId,
string? stationId = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var ctx = await BuildContextAsync(cafeId, orderId, ct); var ctx = await BuildContextAsync(cafeId, orderId, ct);
@@ -74,15 +83,16 @@ public class NetworkPrinterService : IPrinterService
return PrintResult.Ok(); return PrintResult.Ok();
var menuItemIds = activeItems.Select(i => i.MenuItemId).Distinct().ToList(); 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() from m in _db.MenuItems.AsNoTracking()
join c in _db.MenuCategories.AsNoTracking() on m.CategoryId equals c.Id join c in _db.MenuCategories.AsNoTracking() on m.CategoryId equals c.Id
where menuItemIds.Contains(m.Id) && m.CafeId == cafeId 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); ).ToListAsync(ct);
var stationIds = categoryStations var stationIds = itemStations
.Select(x => x.KitchenStationId) .Select(x => x.StationId)
.Where(id => !string.IsNullOrEmpty(id)) .Where(id => !string.IsNullOrEmpty(id))
.Distinct() .Distinct()
.ToList(); .ToList();
@@ -97,11 +107,19 @@ public class NetworkPrinterService : IPrinterService
var groups = activeItems var groups = activeItems
.GroupBy(item => .GroupBy(item =>
{ {
var cat = categoryStations.FirstOrDefault(c => c.Id == item.MenuItemId); var map = itemStations.FirstOrDefault(c => c.Id == item.MenuItemId);
return cat?.KitchenStationId; return map?.StationId;
}) })
.ToList(); .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; PrintResult? lastFail = null;
var anyPrinted = false; var anyPrinted = false;
@@ -111,18 +129,21 @@ public class NetworkPrinterService : IPrinterService
? null ? null
: stations.FirstOrDefault(s => s.Id == group.Key); : stations.FirstOrDefault(s => s.Id == group.Key);
string? deviceId;
string? ip; string? ip;
int port; int port;
string? stationLabel = null; 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; ip = station.PrinterIp;
port = station.PrinterPort; port = station.PrinterPort;
stationLabel = station.Name; 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; ip = ctx.Value.branch.KitchenPrinterIp;
port = ctx.Value.branch.KitchenPrinterPort ?? 9100; port = ctx.Value.branch.KitchenPrinterPort ?? 9100;
} }
@@ -136,7 +157,7 @@ public class NetworkPrinterService : IPrinterService
var bytes = _receiptBuilder.BuildKitchenTicket( var bytes = _receiptBuilder.BuildKitchenTicket(
ctx.Value.printCtx with { StationName = stationLabel }, ctx.Value.printCtx with { StationName = stationLabel },
itemsOnly); itemsOnly);
var result = await SendToPrinterAsync(ip!, port, bytes, ct); var result = await DispatchAsync(cafeId, deviceId, ip, port, bytes, ct);
if (result.Success) if (result.Success)
anyPrinted = true; anyPrinted = true;
else else
@@ -155,6 +176,66 @@ public class NetworkPrinterService : IPrinterService
return await SendToPrinterAsync(printerIp.Trim(), port, bytes, ct); 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( private async Task<(Branch branch, ReceiptPrintContext printCtx)?> BuildContextAsync(
string cafeId, string cafeId,
string orderId, string orderId,
@@ -243,7 +324,7 @@ public static class PrinterBackgroundJobs
try try
{ {
var printer = scope.ServiceProvider.GetRequiredService<IPrinterService>(); 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) if (!result.Success)
logger.LogWarning("Kitchen print failed for {OrderId}: {Code}", orderId, result.ErrorCode); 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": "", "MerchantId": "",
"Sandbox": true "Sandbox": true
}, },
"FlatPay": {
"BaseUrl": "https://pay.flatrender.ir",
"ReturnUrl": "https://meezi.ir/payment/return",
"ApiKey": "",
"Secret": ""
},
"Billing": { "Billing": {
"DashboardBaseUrl": "http://localhost:3101" "DashboardBaseUrl": "http://localhost:3101"
}, },
@@ -32,6 +32,23 @@ public class AdminCafesController : AdminApiControllerBase
return Ok(new ApiResponse<object>(true, new { cafeId })); 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")] [HttpPut("{cafeId}/features")]
public async Task<IActionResult> SetFeature( public async Task<IActionResult> SetFeature(
string cafeId, string cafeId,
+4
View File
@@ -50,4 +50,8 @@ public record AdminCafePatchRequest(
bool? IsVerified, bool? IsVerified,
IReadOnlyList<string>? DiscoverBadges = null); 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); 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<bool> UpdateFeatureAsync(string featureKey, UpdateFeatureRequest request, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AdminCafeListItemDto>> ListCafesAsync(CancellationToken cancellationToken = default); Task<IReadOnlyList<AdminCafeListItemDto>> ListCafesAsync(CancellationToken cancellationToken = default);
Task<bool> PatchCafeAsync(string cafeId, AdminCafePatchRequest request, 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 Success, string? Key, string? ErrorCode)> GenerateRecoveryKeyAsync(string cafeId, CancellationToken cancellationToken = default);
Task<bool> RevokeRecoveryKeyAsync(string cafeId, CancellationToken cancellationToken = default); Task<bool> RevokeRecoveryKeyAsync(string cafeId, CancellationToken cancellationToken = default);
Task<bool> SetCafeFeatureOverrideAsync(string cafeId, CafeFeatureOverrideRequest request, CancellationToken cancellationToken = default); Task<bool> SetCafeFeatureOverrideAsync(string cafeId, CafeFeatureOverrideRequest request, CancellationToken cancellationToken = default);
@@ -207,6 +208,44 @@ public class AdminPlatformService : IAdminPlatformService
return true; 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( public async Task<bool> SetCafeFeatureOverrideAsync(
string cafeId, string cafeId,
CafeFeatureOverrideRequest request, CafeFeatureOverrideRequest request,
@@ -0,0 +1,32 @@
using System.Text.Json;
namespace Meezi.Core.Authorization;
/// <summary>Helpers for serialising/deserialising a custom role's permission set.</summary>
public static class CustomRolePermissions
{
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
/// <summary>Parse the stored JSON array of permission names into a set.</summary>
public static IReadOnlySet<Permission> Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new HashSet<Permission>();
try
{
var names = JsonSerializer.Deserialize<string[]>(json, JsonOpts) ?? [];
var set = new HashSet<Permission>();
foreach (var name in names)
if (Enum.TryParse<Permission>(name, ignoreCase: true, out var p))
set.Add(p);
return set;
}
catch
{
return new HashSet<Permission>();
}
}
/// <summary>Serialise a permission set to JSON for storage.</summary>
public static string Serialize(IEnumerable<Permission> permissions) =>
JsonSerializer.Serialize(permissions.Select(p => p.ToString()).ToArray(), JsonOpts);
}
+116 -18
View File
@@ -5,37 +5,135 @@ namespace Meezi.Core.Authorization;
/// truth for authorization — controllers check a <see cref="Permission"/> rather /// truth for authorization — controllers check a <see cref="Permission"/> rather
/// than hard-coding role names, so the role→capability mapping lives in exactly /// than hard-coding role names, so the role→capability mapping lives in exactly
/// one place (<see cref="RolePermissions"/>). /// 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> /// </summary>
public enum Permission public enum Permission
{ {
// Café-level administration (Owner only) // ── Café administration (owner tier) ──────────────────────────────────────
ViewCafeSettings,
ManageCafeSettings, ManageCafeSettings,
ManageDiscoverProfile,
ViewBilling,
ManageBilling, ManageBilling,
ManageBranches, ViewBranches,
CreateBranch,
// Management (Owner + Manager) EditBranch,
ManageStaff, DeleteBranch,
ManageMenu, ManageRoles,
ManageInventory, ViewPrintSettings,
ManageExpenses,
ManageTaxes,
ManageCoupons,
ManageReservations,
ManageTables,
ViewReports,
ReviewLeave,
ManageSalaries,
ManagePrintSettings, 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, ProcessOrders,
EditOrder,
VoidOrder,
RefundOrder,
ApplyDiscount,
CompOrder,
HandlePayments, HandlePayments,
UpdateOrderStatus,
// ── Register / cash ──────────────────────────────────────────────────────
OperateRegister, OperateRegister,
OpenCashDrawer,
// ── Queue ─────────────────────────────────────────────────────────────────
ViewQueue,
ManageQueue, ManageQueue,
// Kitchen // ── Kitchen ───────────────────────────────────────────────────────────────
ViewKitchen, ViewKitchen,
ManageKitchenStations,
// Delivery // ── Delivery ──────────────────────────────────────────────────────────────
ViewDelivery,
HandleDelivery, 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 Meezi.Core.Enums;
using static Meezi.Core.Authorization.Permission;
namespace Meezi.Core.Authorization; namespace Meezi.Core.Authorization;
/// <summary> /// <summary>
/// The authoritative role→capability matrix. Change what a role can do here and /// The authoritative role→capability matrix. Change what a base role can do here
/// every controller that calls <c>EnsurePermission</c> updates automatically. /// and every controller that calls <c>EnsurePermission</c> updates automatically.
/// Owners customise further with custom roles (which override this matrix entirely).
/// </summary> /// </summary>
public static class RolePermissions 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 = private static readonly IReadOnlyDictionary<EmployeeRole, HashSet<Permission>> Matrix =
new Dictionary<EmployeeRole, HashSet<Permission>> new Dictionary<EmployeeRole, HashSet<Permission>>
{ {
[EmployeeRole.Owner] = AllPermissions(), [EmployeeRole.Owner] = AllPermissions(),
[EmployeeRole.Manager] = new() // Manager runs the café day to day: everything except the owner-only
{ // governance (billing, branches, café identity, role definitions).
Permission.ManageStaff, [EmployeeRole.Manager] = AllExcept(OwnerOnly),
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,
},
[EmployeeRole.Cashier] = new() [EmployeeRole.Cashier] = new()
{ {
Permission.ProcessOrders, ViewOrders, ProcessOrders, EditOrder, HandlePayments, UpdateOrderStatus,
Permission.HandlePayments, OperateRegister, OpenCashDrawer,
Permission.OperateRegister, ViewQueue, ManageQueue,
Permission.ManageQueue, ViewTables,
Permission.ManageReservations, ViewReservations, CreateReservation, EditReservation,
ViewMenu,
ViewCustomers, CreateCustomer,
ViewCoupons,
}, },
[EmployeeRole.Waiter] = new() [EmployeeRole.Waiter] = new()
{ {
Permission.ProcessOrders, ViewOrders, ProcessOrders, EditOrder, UpdateOrderStatus,
Permission.ManageReservations, ViewTables,
Permission.ManageQueue, ViewMenu,
ViewReservations, CreateReservation, EditReservation,
ViewQueue, ManageQueue,
}, },
[EmployeeRole.Chef] = new() [EmployeeRole.Chef] = new()
{ {
Permission.ViewKitchen, ViewKitchen, UpdateOrderStatus, ViewOrders, ViewMenu,
}, },
[EmployeeRole.Delivery] = new() [EmployeeRole.Delivery] = new()
{ {
Permission.HandleDelivery, ViewDelivery, HandleDelivery, ViewOrders,
}, },
}; };
@@ -73,4 +75,7 @@ public static class RolePermissions
private static HashSet<Permission> AllPermissions() => private static HashSet<Permission> AllPermissions() =>
new(Enum.GetValues<Permission>()); new(Enum.GetValues<Permission>());
private static HashSet<Permission> AllExcept(HashSet<Permission> excluded) =>
new(Enum.GetValues<Permission>().Where(p => !excluded.Contains(p)));
} }
+3
View File
@@ -9,6 +9,9 @@ public static class MeeziClaimTypes
public const string BranchId = "branchId"; public const string BranchId = "branchId";
public const string Actor = "actor"; public const string Actor = "actor";
public const string Phone = "phone"; public const string Phone = "phone";
/// <summary>Comma-separated list of <see cref="Meezi.Core.Authorization.Permission"/> names
/// embedded when the employee has a custom role. Presence overrides the role-based matrix.</summary>
public const string CustomPermissions = "customPerms";
} }
public static class MeeziActorKinds public static class MeeziActorKinds
+5
View File
@@ -18,6 +18,11 @@ public class Branch : TenantEntity
public int? ReceiptPrinterPort { get; set; } public int? ReceiptPrinterPort { get; set; }
public string? KitchenPrinterIp { get; set; } public string? KitchenPrinterIp { get; set; }
public int? KitchenPrinterPort { 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 int PaperWidthMm { get; set; } = 80;
public bool AutoCutEnabled { get; set; } = true; public bool AutoCutEnabled { get; set; } = true;
public string? ReceiptHeader { get; set; } public string? ReceiptHeader { get; set; }
+21
View File
@@ -0,0 +1,21 @@
namespace Meezi.Core.Entities;
/// <summary>
/// A café-defined role template that overrides the standard <see cref="Meezi.Core.Enums.EmployeeRole"/>
/// permission set. The owner creates named roles (e.g. "Barista", "Floor Supervisor") and assigns
/// specific <see cref="Meezi.Core.Authorization.Permission"/> values to each one.
/// When an employee has a <see cref="CustomRoleId"/>, their effective permissions come from here
/// instead of the static <see cref="Meezi.Core.Authorization.RolePermissions"/> matrix.
/// </summary>
public class CustomRole : TenantEntity
{
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
/// <summary>Optional hex color (e.g. "#F59E0B") for badge display in the UI.</summary>
public string? Color { get; set; }
/// <summary>JSON array of <see cref="Meezi.Core.Authorization.Permission"/> enum names.</summary>
public string PermissionsJson { get; set; } = "[]";
public Cafe Cafe { get; set; } = null!;
public ICollection<Employee> Employees { get; set; } = [];
}
+8
View File
@@ -26,6 +26,14 @@ public class Employee : TenantEntity
public ICollection<EmployeeSchedule> Schedules { get; set; } = []; public ICollection<EmployeeSchedule> Schedules { get; set; } = [];
public ICollection<LeaveRequest> LeaveRequests { get; set; } = []; public ICollection<LeaveRequest> LeaveRequests { get; set; } = [];
/// <summary>
/// Optional custom role defined by the café owner. When set, this role's permission set
/// overrides the standard <see cref="RolePermissions"/> matrix for this employee.
/// The base <see cref="Role"/> enum still controls café-wide vs. branch-scoped behaviour.
/// </summary>
public string? CustomRoleId { get; set; }
public CustomRole? CustomRole { get; set; }
/// <summary>Per-branch role assignments (multi-branch staff). Owners are café-wide and may have none.</summary> /// <summary>Per-branch role assignments (multi-branch staff). Owners are café-wide and may have none.</summary>
public ICollection<EmployeeBranchRole> BranchRoles { get; set; } = []; public ICollection<EmployeeBranchRole> BranchRoles { get; set; } = [];
} }
@@ -7,9 +7,15 @@ public class KitchenStation : TenantEntity
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string? PrinterIp { get; set; } public string? PrinterIp { get; set; }
public int PrinterPort { get; set; } = 9100; 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 int SortOrder { get; set; }
public Cafe Cafe { get; set; } = null!; public Cafe Cafe { get; set; } = null!;
public Branch? Branch { get; set; } public Branch? Branch { get; set; }
public ICollection<MenuCategory> Categories { 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> /// <summary>GLB/GLTF 3D model for QR menu interactive view (poster = ImageUrl).</summary>
public string? Model3dUrl { get; set; } public string? Model3dUrl { get; set; }
public bool IsAvailable { get; set; } = true; 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 Cafe Cafe { get; set; } = null!;
public MenuCategory Category { get; set; } = null!; public MenuCategory Category { get; set; } = null!;
public KitchenStation? KitchenStation { get; set; }
public ICollection<OrderItem> OrderItems { get; set; } = []; public ICollection<OrderItem> OrderItems { get; set; } = [];
public ICollection<BranchMenuItemOverride> BranchOverrides { get; set; } = []; public ICollection<BranchMenuItemOverride> BranchOverrides { get; set; } = [];
public ICollection<MenuItemIngredient> RecipeIngredients { 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, ZarinPal = 0,
Tara = 1, 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 public static class PaymentProviderIds
@@ -1,3 +1,4 @@
using Meezi.Core.Authorization;
using Meezi.Core.Enums; using Meezi.Core.Enums;
namespace Meezi.Core.Interfaces; namespace Meezi.Core.Interfaces;
@@ -14,4 +15,10 @@ public interface ITenantContext
bool IsSystemAdmin { get; } bool IsSystemAdmin { get; }
bool IsAuthenticated { get; } bool IsAuthenticated { get; }
bool IsCafeOwner => Role == EmployeeRole.Owner; bool IsCafeOwner => Role == EmployeeRole.Owner;
/// <summary>
/// When non-null the employee has a custom role. These permissions override the static
/// <see cref="RolePermissions"/> matrix; the base <see cref="Role"/> still governs
/// café-wide vs. branch-scoped behaviour.
/// </summary>
IReadOnlySet<Permission>? CustomPermissions { get; }
} }
@@ -32,6 +32,7 @@ public class AppDbContext : DbContext
public DbSet<Table> Tables => Set<Table>(); public DbSet<Table> Tables => Set<Table>();
public DbSet<TableSection> TableSections => Set<TableSection>(); public DbSet<TableSection> TableSections => Set<TableSection>();
public DbSet<Employee> Employees => Set<Employee>(); public DbSet<Employee> Employees => Set<Employee>();
public DbSet<CustomRole> CustomRoles => Set<CustomRole>();
public DbSet<EmployeeBranchRole> EmployeeBranchRoles => Set<EmployeeBranchRole>(); public DbSet<EmployeeBranchRole> EmployeeBranchRoles => Set<EmployeeBranchRole>();
public DbSet<MenuCategory> MenuCategories => Set<MenuCategory>(); public DbSet<MenuCategory> MenuCategories => Set<MenuCategory>();
public DbSet<MenuItem> MenuItems => Set<MenuItem>(); public DbSet<MenuItem> MenuItems => Set<MenuItem>();
@@ -53,6 +54,8 @@ public class AppDbContext : DbContext
public DbSet<CafeReviewPhoto> CafeReviewPhotos => Set<CafeReviewPhoto>(); public DbSet<CafeReviewPhoto> CafeReviewPhotos => Set<CafeReviewPhoto>();
public DbSet<ConsumerAccount> ConsumerAccounts => Set<ConsumerAccount>(); public DbSet<ConsumerAccount> ConsumerAccounts => Set<ConsumerAccount>();
public DbSet<KitchenStation> KitchenStations => Set<KitchenStation>(); 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<SubscriptionPayment> SubscriptionPayments => Set<SubscriptionPayment>();
public DbSet<Ingredient> Ingredients => Set<Ingredient>(); public DbSet<Ingredient> Ingredients => Set<Ingredient>();
public DbSet<MenuItemIngredient> MenuItemIngredients => Set<MenuItemIngredient>(); public DbSet<MenuItemIngredient> MenuItemIngredients => Set<MenuItemIngredient>();
@@ -195,6 +198,17 @@ public class AppDbContext : DbContext
e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId)); e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
}); });
modelBuilder.Entity<CustomRole>(e =>
{
e.HasKey(x => x.Id);
e.Property(x => x.Name).HasMaxLength(100).IsRequired();
e.Property(x => x.Description).HasMaxLength(500);
e.Property(x => x.Color).HasMaxLength(20);
e.Property(x => x.PermissionsJson).HasMaxLength(2000).IsRequired();
e.HasOne(x => x.Cafe).WithMany().HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade);
e.HasQueryFilter(x => x.DeletedAt == null);
});
modelBuilder.Entity<Employee>(e => modelBuilder.Entity<Employee>(e =>
{ {
e.HasKey(x => x.Id); e.HasKey(x => x.Id);
@@ -204,6 +218,7 @@ public class AppDbContext : DbContext
e.HasIndex(x => x.BranchId); e.HasIndex(x => x.BranchId);
e.HasOne(x => x.Cafe).WithMany(c => c.Employees).HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade); e.HasOne(x => x.Cafe).WithMany(c => c.Employees).HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Branch).WithMany(b => b.Staff).HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.SetNull); e.HasOne(x => x.Branch).WithMany(b => b.Staff).HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(x => x.CustomRole).WithMany(r => r.Employees).HasForeignKey(x => x.CustomRoleId).OnDelete(DeleteBehavior.SetNull);
e.HasQueryFilter(x => x.DeletedAt == null); e.HasQueryFilter(x => x.DeletedAt == null);
}); });
@@ -258,6 +273,7 @@ public class AppDbContext : DbContext
e.Property(x => x.Price).HasPrecision(18, 2); 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.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.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); e.HasQueryFilter(x => x.DeletedAt == null);
}); });
@@ -445,6 +461,32 @@ public class AppDbContext : DbContext
e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId)); 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 => modelBuilder.Entity<SubscriptionPayment>(e =>
{ {
e.HasKey(x => x.Id); e.HasKey(x => x.Id);
@@ -0,0 +1,82 @@
using Meezi.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260620100000_AddCustomRoles")]
public partial class AddCustomRoles : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CustomRoles",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
CafeId = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
Color = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
PermissionsJson = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: false, defaultValue: "[]"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_CustomRoles", x => x.Id);
table.ForeignKey(
name: "FK_CustomRoles_Cafes_CafeId",
column: x => x.CafeId,
principalTable: "Cafes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_CustomRoles_CafeId",
table: "CustomRoles",
column: "CafeId");
migrationBuilder.AddColumn<string>(
name: "CustomRoleId",
table: "Employees",
type: "text",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Employees_CustomRoleId",
table: "Employees",
column: "CustomRoleId");
migrationBuilder.AddForeignKey(
name: "FK_Employees_CustomRoles_CustomRoleId",
table: "Employees",
column: "CustomRoleId",
principalTable: "CustomRoles",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Employees_CustomRoles_CustomRoleId",
table: "Employees");
migrationBuilder.DropIndex(
name: "IX_Employees_CustomRoleId",
table: "Employees");
migrationBuilder.DropColumn(
name: "CustomRoleId",
table: "Employees");
migrationBuilder.DropTable(name: "CustomRoles");
}
}
}
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") b.Property<bool>("IsActive")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<string>("KitchenPrintDeviceId")
.HasColumnType("text");
b.Property<string>("KitchenPrinterIp") b.Property<string>("KitchenPrinterIp")
.HasMaxLength(45) .HasMaxLength(45)
.HasColumnType("character varying(45)"); .HasColumnType("character varying(45)");
@@ -192,6 +195,9 @@ namespace Meezi.Infrastructure.Data.Migrations
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("character varying(500)"); .HasColumnType("character varying(500)");
b.Property<string>("ReceiptPrintDeviceId")
.HasColumnType("text");
b.Property<string>("ReceiptPrinterIp") b.Property<string>("ReceiptPrinterIp")
.HasMaxLength(45) .HasMaxLength(45)
.HasColumnType("character varying(45)"); .HasColumnType("character varying(45)");
@@ -707,6 +713,46 @@ namespace Meezi.Infrastructure.Data.Migrations
b.ToTable("Coupons"); 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 => modelBuilder.Entity("Meezi.Core.Entities.Customer", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@@ -946,6 +992,9 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<string>("CustomRoleId")
.HasColumnType("text");
b.Property<DateTime?>("DeletedAt") b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -976,6 +1025,8 @@ namespace Meezi.Infrastructure.Data.Migrations
b.HasIndex("BranchId"); b.HasIndex("BranchId");
b.HasIndex("CustomRoleId");
b.HasIndex("CafeId", "Phone") b.HasIndex("CafeId", "Phone")
.IsUnique() .IsUnique()
.HasFilter("\"DeletedAt\" IS NULL"); .HasFilter("\"DeletedAt\" IS NULL");
@@ -1268,6 +1319,9 @@ namespace Meezi.Infrastructure.Data.Migrations
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("character varying(100)"); .HasColumnType("character varying(100)");
b.Property<string>("PrintDeviceId")
.HasColumnType("text");
b.Property<string>("PrinterIp") b.Property<string>("PrinterIp")
.HasMaxLength(45) .HasMaxLength(45)
.HasColumnType("character varying(45)"); .HasColumnType("character varying(45)");
@@ -1471,6 +1525,9 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Property<bool>("IsAvailable") b.Property<bool>("IsAvailable")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<string>("KitchenStationId")
.HasColumnType("text");
b.Property<string>("Model3dUrl") b.Property<string>("Model3dUrl")
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("character varying(500)"); .HasColumnType("character varying(500)");
@@ -1498,6 +1555,8 @@ namespace Meezi.Infrastructure.Data.Migrations
b.HasIndex("CategoryId"); b.HasIndex("CategoryId");
b.HasIndex("KitchenStationId");
b.ToTable("MenuItems"); b.ToTable("MenuItems");
}); });
@@ -1885,6 +1944,104 @@ namespace Meezi.Infrastructure.Data.Migrations
b.ToTable("PlatformSettings"); 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 => modelBuilder.Entity("Meezi.Core.Entities.PushDevice", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@@ -2779,6 +2936,17 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Navigation("Cafe"); 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 => modelBuilder.Entity("Meezi.Core.Entities.Customer", b =>
{ {
b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") b.HasOne("Meezi.Core.Entities.Cafe", "Cafe")
@@ -2825,9 +2993,16 @@ namespace Meezi.Infrastructure.Data.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("Meezi.Core.Entities.CustomRole", "CustomRole")
.WithMany("Employees")
.HasForeignKey("CustomRoleId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Branch"); b.Navigation("Branch");
b.Navigation("Cafe"); b.Navigation("Cafe");
b.Navigation("CustomRole");
}); });
modelBuilder.Entity("Meezi.Core.Entities.EmployeeBranchRole", b => modelBuilder.Entity("Meezi.Core.Entities.EmployeeBranchRole", b =>
@@ -2968,9 +3143,16 @@ namespace Meezi.Infrastructure.Data.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("Meezi.Core.Entities.KitchenStation", "KitchenStation")
.WithMany("MenuItems")
.HasForeignKey("KitchenStationId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Cafe"); b.Navigation("Cafe");
b.Navigation("Category"); b.Navigation("Category");
b.Navigation("KitchenStation");
}); });
modelBuilder.Entity("Meezi.Core.Entities.MenuItemIngredient", b => modelBuilder.Entity("Meezi.Core.Entities.MenuItemIngredient", b =>
@@ -3075,6 +3257,35 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Navigation("Order"); 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 => modelBuilder.Entity("Meezi.Core.Entities.QueueTicket", b =>
{ {
b.HasOne("Meezi.Core.Entities.Branch", "Branch") b.HasOne("Meezi.Core.Entities.Branch", "Branch")
@@ -3338,6 +3549,11 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Navigation("Orders"); b.Navigation("Orders");
}); });
modelBuilder.Entity("Meezi.Core.Entities.CustomRole", b =>
{
b.Navigation("Employees");
});
modelBuilder.Entity("Meezi.Core.Entities.Customer", b => modelBuilder.Entity("Meezi.Core.Entities.Customer", b =>
{ {
b.Navigation("Orders"); b.Navigation("Orders");
@@ -3368,6 +3584,8 @@ namespace Meezi.Infrastructure.Data.Migrations
modelBuilder.Entity("Meezi.Core.Entities.KitchenStation", b => modelBuilder.Entity("Meezi.Core.Entities.KitchenStation", b =>
{ {
b.Navigation("Categories"); b.Navigation("Categories");
b.Navigation("MenuItems");
}); });
modelBuilder.Entity("Meezi.Core.Entities.MenuCategory", b => modelBuilder.Entity("Meezi.Core.Entities.MenuCategory", b =>
@@ -3391,6 +3609,11 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Navigation("Payments"); b.Navigation("Payments");
}); });
modelBuilder.Entity("Meezi.Core.Entities.PrintAgent", b =>
{
b.Navigation("Devices");
});
modelBuilder.Entity("Meezi.Core.Entities.Shift", b => modelBuilder.Entity("Meezi.Core.Entities.Shift", b =>
{ {
b.Navigation("Transactions"); b.Navigation("Transactions");
@@ -1,3 +1,4 @@
using Meezi.Core.Authorization;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
@@ -13,4 +14,5 @@ public class TenantContext : ITenantContext
public string? BranchId { get; set; } public string? BranchId { get; set; }
public bool IsSystemAdmin { get; set; } public bool IsSystemAdmin { get; set; }
public bool IsAuthenticated => IsSystemAdmin || !string.IsNullOrEmpty(CafeId); public bool IsAuthenticated => IsSystemAdmin || !string.IsNullOrEmpty(CafeId);
public IReadOnlySet<Permission>? CustomPermissions { get; set; }
} }
+9
View File
@@ -1159,6 +1159,15 @@
"save": "حفظ", "save": "حفظ",
"saved": "تم الحفظ", "saved": "تم الحفظ",
"loading": "جاري التحميل..." "loading": "جاري التحميل..."
},
"grant": {
"title": "منح اشتراك مجاني",
"plan": "الباقة",
"months": "عدد الأشهر",
"submit": "منح",
"granted": "تم منح الاشتراك",
"failed": "تعذّر منح الاشتراك",
"currentExpiry": "انتهاء الصلاحية الحالي"
} }
}, },
"integrations": { "integrations": {
+9
View File
@@ -1152,6 +1152,15 @@
"save": "Save", "save": "Save",
"saved": "Saved", "saved": "Saved",
"loading": "Loading..." "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": { "integrations": {

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