Compare commits

..

83 Commits

Author SHA1 Message Date
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
soroush.asadi 73a5e5183b fix(seed): IgnoreQueryFilters on all seeder queries + sitemap invalid date guard
CI/CD / CI · API (dotnet build + test) (push) Successful in 45s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
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 47s
CI/CD / CI · Koja (tsc) (push) Successful in 59s
CI/CD / Deploy · all services (push) Successful in 3m45s
DemoSeedService / DemoMenuSeeder:
  Add IgnoreQueryFilters() to every seeder lookup (Taxes, MenuCategories,
  MenuItems). Soft-deleted rows still hold their PKs; without this a second
  seed run after user-deletion throws a PK collision on the Tax or category
  that was soft-deleted but is still in the index.

sitemap.ts:
  Guard new Date(post.date) against empty / missing frontmatter date fields.
  new Date("") = Invalid Date → broken <lastmod> in sitemap XML.
  Fall back to the build-time date when the post date is absent or invalid.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 15:54:07 +03:30
soroush.asadi 1daa6d452c fix(admin): admin OTP login always failed — rate-limit key clobbered the OTP
CI/CD / CI · API (dotnet build + test) (push) Successful in 47s
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 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 1m36s
The admin send-otp used the SAME Redis key ("otp:admin:{phone}") for both the
OTP value and the per-hour attempts counter. After storing the code and SMSing
it, the rate-limit StringIncrementAsync ran on that same key, turning the stored
value into code+1 (e.g. SMS said 337835, Redis held 337836). verify-otp then
compared the entered code to the incremented value, never matched, and returned
INVALID_OTP → 400. Admin OTP login could never succeed.

Give the attempts counter its own key ("otp:admin:attempts:{phone}"), exactly
like the main API (otp:{phone} vs otp:attempts:{phone}). Password login was
unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 08:42:45 +03:30
soroush.asadi 24fbbcb01c fix(admin): don't prefill a fake phone on the admin login in production
CI/CD / CI · API (dotnet build + test) (push) Successful in 50s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m12s
CI/CD / CI · Admin Web (tsc) (push) Successful in 40s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 1m1s
CI/CD / Deploy · all services (push) Successful in 2m0s
The admin login OTP tab hard-coded phone "09120000001" as the initial value.
In production that placeholder belongs to no SystemAdmin, so hitting "send code"
returns NOT_FOUND → 404 (which WCDN then repaints as an HTML error page) — it
looked like the login endpoint was broken. Keep the convenience prefill in
development only; ship an empty field in production so the admin types their
real number (e.g. the registered admin phone).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:44:57 +03:30
soroush.asadi a967e5d211 fix(admin): keep admin panel logged in — refresh the token instead of dying at 7 days
CI/CD / CI · API (dotnet build + test) (push) Successful in 39s
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 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m35s
Prod diag showed every /api/admin/* call returning 401 with
"IDX10223: token expired, ValidTo 06/09" — the admin access token was 6 days
dead and nothing renewed it, so cafes/tickets/integrations/settings all loaded
empty. The admin web (unlike the café dashboard) had NO refresh logic at all:
it only ever sent the access token, and its 401 handler early-returned on any
error code before the login redirect, so the admin wasn't even bounced to login
— pages just showed no data.

Client (admin-client.ts): add a silent refresh-on-401 mirroring the dashboard —
one shared in-flight POST /api/admin/auth/refresh for a burst of 401s, replay
the original request on success, force-logout only on a definitive 4xx, and
ride out a transient failure (API restarting during deploy) without logging out.

Backend (AdminAuthService): make refresh non-rotating + sliding (reuse the
presented refresh token and re-store it) instead of revoke-and-mint, so the
dashboard's many concurrent refreshes don't race the rotated token — same fix
already applied to the main API.

Also bump admin tokens 7d/30d → 30d/365d to match the main API, so the session
is long-lived even before the first refresh round-trip.

tsc clean; Admin.API builds clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:57:21 +03:30
soroush.asadi 82d1cf8e9e fix(auth): stop logging users out on every deploy
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 1m8s
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 50s
CI/CD / Deploy · all services (push) Successful in 2m50s
Diagnostic on prod confirmed the backend keeps sessions valid across deploys
(stable 64-char JWT key, 30-day access tokens, 62 refresh tokens persisting in
Redis with appendonly; redis/db never restart on deploy). The forced logout was
client-side:

1. The axios refresh path treated ANY refresh failure as "session gone" and
   nuked the tokens. During the ~30s API restart window of a deploy, the refresh
   POST gets a 502/timeout (transient) → user kicked to /login. Now refresh
   distinguishes a definitive 4xx (truly invalid/expired refresh → log out) from
   a transient network/5xx failure (reject + keep the session; retry later).
   Refresh tokens are opaque Redis GUIDs, so they survive even a key rotation —
   the only thing that was breaking sessions was this over-eager logout.

2. PWA service worker served a stale app shell after an update, pointing at JS
   chunks the new build replaced. Added skipWaiting + clientsClaim +
   cleanupOutdatedCaches and a NetworkFirst handler for navigations so the HTML
   and its chunk refs always match the live deploy; hashed static stays
   CacheFirst.

Net: a normal update no longer logs anyone out. tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:42:38 +03:30
soroush.asadi 837805b6b8 fix(brand): real Meezi launcher icon for meezi_app (was default Flutter)
CI/CD / CI · API (dotnet build + test) (push) Successful in 43s
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 36s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 24s
Replaced the placeholder Flutter launcher icons in all 5 mipmap densities
(48/72/96/144/192) with the real Meezi mark, ready for the Android APK build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 19:52:34 +03:30
soroush.asadi d4d7b7e679 feat(website): full Meezi knowledge base with per-feature wireframes
CI/CD / CI · API (dotnet build + test) (push) Successful in 40s
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 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 1m53s
Turns the static /docs page into a real help center. Every feature now has a
detail page at /docs/{slug} with a minimal wireframe mockup + concrete Persian
how-to steps (English mirror), grouped into 6 sections.

- guide-data.tsx: typed GUIDE_FEATURES (21 features — pos, tables, kds, queue,
  reservations, menu, inventory, crm, coupons, sms, reviews, reports, expenses,
  shifts, taxes, hr, branches, subscription, settings, qr-menu, koja) with
  fa/en title, tagline, 5–8 steps, tips, tier badge, group, wireframe variant.
- wireframes.tsx: 7 reusable minimal line-art variants (board/order/menu/list/
  dashboard/form/phone), brand-colored, RTL-aware.
- docs/[slug]/page.tsx: dynamic guide page (hero, wireframe + numbered steps,
  tips, prev/next, support CTA); generateStaticParams + generateMetadata; 404
  for unknown slugs.
- docs/page.tsx: module cards now sourced from GUIDE_FEATURES, grouped, linking
  to the detail pages.

Verified via SSR: index lists all 21, detail pages render titles + wireframe,
en mirror 200, unknown slug 404, tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 19:00:10 +03:30
soroush.asadi 32a7cf5b25 ops: nightly DB backup + self-hosted uptime monitoring
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 37s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 1m48s
Backup (production data-loss protection — was none):
- meezi-backup sidecar in docker-compose.yml runs pg_dump nightly at 02:00
  Tehran, gzip, 14-day rotation, atomic .partial→final, into ./backups
  (persists across deploys; rsync off-box per RESTORE.md).
- Wired into the deploy job (up -d --no-deps backup); takes one dump on boot.
- scripts/backup/pg-backup-loop.sh + RESTORE.md (restore + off-box guidance).

Monitoring:
- docker-compose.monitoring.yml: Uptime Kuma stack (own volume), stood up
  once, independent of app deploys.
- Caddyfile status.{$DOMAIN} route; docs/monitoring.md lists the exact
  monitors (incl. /q guest-menu 200 check) + TLS-expiry alerts (catches the
  ~90-day cert breakage early) + alert-channel setup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 18:45:07 +03:30
soroush.asadi d407f0b3e9 fix(brand): real Meezi icon/favicon on website + admin (was missing)
CI/CD / CI · API (dotnet build + test) (push) Successful in 40s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
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 49s
CI/CD / Deploy · all services (push) Has been cancelled
- web/website: manifest referenced /icon-192.png and /icon-512.png that
  didn't exist (broken favicon). Added the real transparent Meezi mark
  (32/180/192/512) + wired icons into metadata. OG image stays dynamic.
- web/admin: had no metadata or icons at all. Added title template,
  favicon/apple icons (icons/ dir), themeColor, noindex.

Dashboard + Koja already carry the real logo; web apps are now consistent.
Mobile launcher icons will be handled with the Android packaging task.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 18:37:21 +03:30
soroush.asadi 72ab09189c fix(brand): real transparent Meezi icon + guest-menu image placeholder
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 39s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 3m24s
- Icons/favicon were a plain solid-green square (a 547B placeholder). Replaced
  with the actual Meezi mark (green rounded square + menu lines) on transparent
  corners, generated at 32/48/180/192/512 + a full-bleed green maskable-512.
  Wired 32/48 favicon + 180 apple-touch-icon into the panel and /q metadata.
  Copied the same icons to Koja for consistent branding.
- Guest QR menu showed blank muted boxes for items without a photo. Added a
  minimal themed café-cup placeholder (MenuImageFallback) across all four
  layouts so the menu looks intentional. (Admin/POS already had placeholders.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 18:17:52 +03:30
soroush.asadi 456a446850 feat(meta): per-page titles + favicon/app icons + PWA across the panel
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 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 2m54s
The app had no metadata anywhere — pages showed no <title> and no favicon
or app icon. Added:
- Root metadata in [locale]/layout: title default + "%s — میزی" template,
  description, icons (favicon + apple-touch-icon → /icons), manifest link,
  appleWebApp, themeColor viewport, noindex (private panel).
- Per-page title on all 22 dashboard route pages (داشبورد, منو, گزارش‌ها, …).
- Guest menu (/q) layout: own title + icon + manifest.

PWA + favicon now use the Meezi icon everywhere. Verified via SSR: titles
render (e.g. "منو — میزی") and icon/manifest/apple-touch-icon links present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:47:00 +03:30
soroush.asadi 4523c8861f feat(ui): grouped thousands separators for price/amount inputs
Price fields showed raw digits (1490000) while typing — hard to read for
Toman amounts. New shared MoneyInput groups as you type (1,490,000),
accepts Persian/Arabic digits, and reports a raw digit string so callers
keep parsing unchanged. Applied to menu item price, branch price override,
expense amount, and payment-correction replacement amount. Displays already
group via formatCurrency (incl. the QR guest-menu preview).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:46:25 +03:30
soroush.asadi a855cf1d80 feat(auth): admin-issued café recovery key login
CI/CD / CI · API (dotnet build + test) (push) Successful in 5m6s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 1m30s
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 1m0s
CI/CD / Deploy · all services (push) Successful in 5m31s
Platform admins can generate a permanent recovery key per café (admin
panel → Cafés). The café Owner uses it to sign in when OTP access is lost;
once authenticated, all server-side data syncs as normal (data is per-café
on the server, the device only caches it).

Backend:
- Cafe.RecoveryKeyHash (SHA-256, unique index) + RecoveryKeyCreatedAt; migration
- RecoveryKeyGenerator util: MZ-XXXXX-XXXXX-XXXXX-XXXXX, ~190-bit entropy,
  stored as SHA-256 (API-token pattern — raw key shown once, never retrievable)
- Admin: POST/DELETE /api/admin/cafes/{id}/recovery-key (key returned once);
  café list now reports HasRecoveryKey + RecoveryKeyCreatedAt
- Login: POST /api/auth/login-key → exact-hash lookup → resolves café Owner →
  issues normal JWT; rate-limited (auth-otp), suspended/no-owner guarded, logged

Admin UI: per-café generate / regenerate / revoke with one-time reveal + copy.
Dashboard login: discreet "ورود با کلید بازیابی" link → key field. fa/en/ar.

86 tests pass; all tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:10:11 +03:30
soroush.asadi 76d4434581 fix(qr): guest menu 500 (SSR) + remove café discovery from owner panel
1. The /q/{code} guest menu returned HTTP 500 on every load. Root cause:
   menu-item-model-viewer.tsx did a top-level `import "@google/model-viewer"`,
   a browser-only lib that touches `self` at module evaluation. Next pulled
   it into the server module graph (page → qr-guest-menu → qr-menu-3d-sheet →
   model-viewer) and SSR crashed with "self is not defined". Now the library
   is imported lazily inside useEffect (client-only); a poster placeholder
   shows until the custom element registers. Verified /q/* now returns 200.

2. Removed the "discover" (browse other cafés) item from the café owner
   sidebar — café discovery belongs in Koja, not the owner panel. The owner
   still manages their OWN Koja listing from Settings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:08:48 +03:30
soroush.asadi 9765491f6f fix(prod): payment/tax gateways never fake success outside Development
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 1m7s
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 1m31s
Production-readiness audit fixes — every mock fallback is now gated on
IsDevelopment; in production these paths fail loudly instead:

- ZarinPal/Tara/SnappPay init: missing credentials returned a MOCK
  payment URL whose callback verified as paid — a café could activate a
  paid plan without paying. Now: "Payment gateway is not configured."
- Tara/SnappPay verify: a forged MOCK-* trace/token on the callback was
  accepted as a verified payment in any environment. Now rejected
  outside Development.
- Taraz (سامانه مودیان): returned a fake MOCK-TARAZ tracking code as if
  invoices reached the tax authority. Now returns an honest error (the
  real integration is not built yet).
- Admin integrations: NextPay/Vandar removed — they were listed but have
  no gateway implementation (selecting them silently used ZarinPal).
- docker-compose: ASPNETCORE_ENVIRONMENT default flipped Development →
  Production so a missing env var can never run prod in dev mode.

86 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:16:01 +03:30
soroush.asadi 00649d0248 feat(sms): bring-your-own-provider — cafés use their own SMS account
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 1m8s
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 50s
CI/CD / Deploy · all services (push) Successful in 5m16s
The platform no longer sells SMS. Each café saves its OWN Kavenegar API
key + sender line (new Cafes columns + migration) and campaigns are sent
and billed through that account.

Backend:
- GET/PUT /sms/settings (Manager/Owner; key echoed masked, verified
  against the provider before saving)
- campaign + balance use the café's credentials; SMS_NOT_CONFIGURED
  error when missing; plan-tier SMS gating removed everywhere
  (PlanLimitChecker, SmsMarketingService, billing status)
- platform Kavenegar config stays ONLY for login OTPs (env/DB)
- design-time DbContext factory so `dotnet ef migrations add` works
  without booting the host

Dashboard:
- SMS screen: provider-settings card, not-configured callout, campaign
  form disabled until configured; quota bar removed (usage stays as info)
- subscription screen + plan comparison no longer show SMS limits

Admin panel:
- Kavenegar/SMS section removed from integrations (request field now
  optional; stored OTP config untouched)
- SMS limit field removed from the plan editor
- nav label "درگاه و پیامک" → "درگاه پرداخت و AI"

fa/en/ar translations. 86 tests pass; all tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:23:50 +03:30
soroush.asadi 615d5348de fix(subscription): plan comparison + checkout read the live plan catalog
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 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 50s
CI/CD / Deploy · all services (push) Successful in 3m27s
The merchant plan page hard-coded 4 tiers, prices and a feature matrix
that drifted from the admin-editable platform catalog (Starter tier
missing, stale prices/features). PlanComparison and CheckoutScreen now
consume /platform/plans + new /platform/features-catalog:

- columns = active plans by SortOrder (incl. Starter), names from
  DisplayNameFa/En, prices from MonthlyPriceToman
- limit rows from PlanLimitsData (int.MaxValue → "نامحدود")
- feature rows from the feature catalog, ticked via FeatureKeys
- checkout validates the ?plan= param against isBillableOnline and
  prices from the catalog — no more client-side price constants

fa/en/ar limit-row labels added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:16:29 +03:30
soroush.asadi 74f46a4781 fix(dashboard): Set spread → Array.from for CI tsconfig target
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 39s
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 3m41s
Local tsconfig.json has uncommitted target changes, so `[...voidIds]`
passed locally but failed CI's tsc (TS2802, target < es2015).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:30:21 +03:30
soroush.asadi c47922414a feat: اصلاح سند payment corrections + audit-log & daily P&L views
CI/CD / CI · API (dotnet build + test) (push) Successful in 51s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
CI/CD / CI · Dashboard (tsc) (push) Failing after 1m12s
CI/CD / CI · Admin Web (tsc) (push) Successful in 40s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 51s
CI/CD / Deploy · all services (push) Has been skipped
Backend:
- POST /orders/{id}/payments/corrections (Manager/Owner): void wrong
  payments (marked Refunded, never deleted) and/or record replacements
  atomically; mandatory reason; requires an open register shift; full
  before/after written to the immutable audit trail.
- GET /orders/closed?date= — closed orders of one Iran-calendar day,
  paged, the browsing surface for corrections.
- CalculateExpectedCash now subtracts cash refunds so corrections keep
  the drawer expectation honest.

Dashboard (reports screen now has three tabs):
- عملکرد و سود: existing KPIs/charts + new day-by-day breakdown table
  (orders, revenue, expenses, net profit per Jalali day).
- اصلاح سند: closed-orders browser with payment chips + correction
  dialog (void checkboxes, replacement rows, live balance, reason).
- گزارش عملیات: filterable audit-log viewer (category, Jalali range,
  branch) with expandable structured details.

fa/en/ar translations included. 86 backend tests pass; dashboard tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:24:19 +03:30
soroush.asadi 2a4cf1d20b feat(dashboard): Jalali date pickers + mobile/tablet responsive shell
CI/CD / CI · API (dotnet build + test) (push) Successful in 51s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 38s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
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 50s
CI/CD / Deploy · all services (push) Successful in 2m41s
Full Persian calendar:
- New JalaliDateField — Shamsi popover picker (Saturday-first weeks,
  Persian digits, امروز shortcut); wire format stays ISO Gregorian
  YYYY-MM-DD. Falls back to the native input for the en locale.
- Replaces all 5 native type="date" inputs (Gregorian-only pickers):
  reservations, expenses from/to, reports from/to.
- Reservations list date now renders Jalali instead of the raw ISO
  string; branches purge timestamp now formats with fa-IR.

Responsive shell (mobile + tablet):
- New MobileNav: hamburger in the topbar (< md) opening an RTL-aware
  slide-over drawer with all nav destinations, permission-filtered,
  Escape/backdrop close and body scroll lock.
- Desktop sidebar hidden below md; header center cluster (clock/plan)
  hidden below md; language switcher hidden below sm.
- Main content padding scales p-3 → p-4 → p-6.
- Verified at 375px and 768px: no horizontal overflow, drawer and
  Jalali picker fully functional.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:10:38 +03:30
soroush.asadi d811b7d6d5 feat(dashboard): simplify navigation — frequency-based IA
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) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Successful in 40s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 2m43s
The sidebar had 22 items in 5 accordion groups, all defaulting closed:
first visit showed five vague headers and zero destinations, there was
no Dashboard/Home link at all, and rare pages (taxes, subscription) had
equal weight with POS. Restructured around usage frequency:

- Flat primary (always visible, no header): Dashboard, POS, Tables,
  Kitchen, Queue, Reservations, Menu, Reports
- Two collapsible groups: Customers & marketing (crm, coupons, sms,
  reviews, discover) and Café management (inventory, expenses, shifts,
  taxes, hr, branches)
- Footer utility icons: settings, subscription, support
- Removed "notifications" from the nav (duplicate of the topbar bell)

Other fixes folded in:
- Deleted [locale]/page.tsx which redirected "/" to /pos — it made the
  POS exit button a no-op loop and left OverviewScreen unreachable.
  "/" now renders the overview home; login still lands on /pos.
- Branch gating moved from group-level to an item whitelist
  (BRANCH_ALLOWED_NAV_KEYS) — also closes the hole where branch
  accounts could deep-link to /reports etc. past the RouteGuard.
- RouteGuard now checks footer items too (subscription stays gated).
- revalidate=300 on the locale layout: Next emitted s-maxage=31536000
  and the WCDN edge kept serving year-old HTML shells after deploys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:06:59 +03:30
soroush.asadi e0c786fcd1 ci: drop AIA cert fetch — mirror chain is fixed at the source
CI/CD / CI · API (dotnet build + test) (push) Successful in 55s
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 47s
CI/CD / CI · Koja (tsc) (push) Successful in 57s
CI/CD / Deploy · all services (push) Successful in 3m1s
Run 77 diagnostics proved http://yr.i.lencr.org/ connects but never
responds from the runner (national filtering), so fetching ISRG Root YR
at build time can never work. Meanwhile the mirror's fullchain.pem now
serves the complete chain: leaf → YR2 → ISRG Root YR cross-signed by
ISRG Root X1, which IS in every stock trust store — verified with
strict curl (ssl_verify_result=0) and openssl verify.

Replace both Trust steps with a cheap s_client sanity check that fails
early with a pointer to the server-side fix if the cert regresses on
its ~90-day renewal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:29:31 +03:30
soroush.asadi bafbfbcadf ci: fix Trust step crash in sh — replace pipefail with POSIX set -eu
CI/CD / CI · API (dotnet build + test) (push) Failing after 18s
CI/CD / CI · Admin API (dotnet build) (push) Failing after 17s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 36s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Has been skipped
Gitea act runner v0.6.1 ignores `shell: bash` step overrides and always
executes with `sh -e {0}`. The `set -euo pipefail` on line 2 caused sh to
exit immediately with "Illegal option -o pipefail" before any curl/openssl
ran. Replace with POSIX-compatible `set -eu` in both api-build and
admin-api-build trust steps so the diagnostic curl output is finally visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:28:52 +03:30
soroush.asadi 206cd7d3c3 ci: fix Trust step — add shell: bash (act runner defaults to sh, no pipefail)
CI/CD / CI · API (dotnet build + test) (push) Successful in 43s
CI/CD / CI · Admin API (dotnet build) (push) Failing after 2s
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) Has been skipped
set -euo pipefail is bash-only; Gitea act runner used sh by default so
the step crashed on line 1 before curl even ran. Adding shell: bash
lets the step actually execute and surface the real AIA/cert output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:17:54 +03:30
soroush.asadi 7b77bb4722 ci: verbose diagnostic Trust step to pinpoint PartialChain cause
CI/CD / CI · API (dotnet build + test) (push) Failing after 3s
CI/CD / CI · Admin API (dotnet build) (push) Failing after 2s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
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 49s
CI/CD / Deploy · all services (push) Has been skipped
Previous attempt used curl -sf which silently swallows failures, so
we never knew if ISRG Root YR was actually fetched. This run:
  • set -euo pipefail  → step fails fast and loudly on any error
  • curl -v            → shows connection result / error in log
  • openssl verify     → confirms cert bundle is good before restore
  • openssl s_client   → shows full chain verify against live mirror

If the AIA URL (http://yr.i.lencr.org/) is unreachable from the
runner, the step will fail HERE rather than silently at dotnet restore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:09:18 +03:30
soroush.asadi 1db8a8f08c fix(ci): fetch ISRG Root YR root cert at build time + belt-and-suspenders
CI/CD / CI · API (dotnet build + test) (push) Failing after 3m35s
CI/CD / CI · Admin API (dotnet build) (push) Failing after 6m23s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
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 49s
CI/CD / Deploy · all services (push) Has been skipped
The prior Trust step added only the YR2 intermediate to the OS trust
store. dotnet's X.509 chain builder requires a self-signed ROOT as the
trust anchor (it does not enable OpenSSL's X509_V_FLAG_PARTIAL_CHAIN),
so intermediate-only still caused PartialChain.

New approach (two jobs: api-build, admin-api-build):
  1. curl http://yr.i.lencr.org/ (plain HTTP AIA) → ISRG Root YR DER
     → convert to PEM → add to /usr/local/share/ca-certificates/
  2. cp YR2 intermediate (docker/nexus-mirror-ca.crt) → same dir
  3. update-ca-certificates  (OS method)
  4. cat both certs >> /etc/ssl/certs/ca-certificates.crt
     (belt-and-suspenders: directly appends to the OpenSSL bundle
      dotnet reads on Linux, works even if step 3 is a no-op)

If the AIA fetch fails (network block) step 4 still appends the
intermediate, which may work if dotnet ever enables partial chains.
Fetch failure is non-fatal (echo warning + continue).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 07:34:52 +03:30
soroush.asadi 82145b0d21 feat(pos2): add dashboard exit button to POS board header
CI/CD / CI · API (dotnet build + test) (push) Failing after 3m19s
CI/CD / CI · Admin API (dotnet build) (push) Failing after 3m19s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
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) Has been cancelled
POS runs in the (fullscreen) layout which strips the sidebar.
Adds a Home → داشبورد button at the top-left of the table board so
users can navigate back to the dashboard without being stuck.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 07:23:14 +03:30
soroush.asadi 59486cdf24 fix(pos2): wait for branch before fetching menu + add left category sidebar
CI/CD / CI · API (dotnet build + test) (push) Failing after 3m20s
CI/CD / CI · Admin API (dotnet build) (push) Failing after 3m19s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 41s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 52s
CI/CD / Deploy · all services (push) Has been cancelled
Race fix: orderBranchId now returns `undefined` (not null) while the /branches
query is in flight. usePos2Menu treats undefined as "not yet determined" and
skips the fetch, preventing getBranchMenu(cafeId, null) → empty array.
Once branchesFetched=true, orderBranchId resolves to the correct branchId
(or null for café-wide fallback).

Layout: desktop order screen now shows a left vertical category sidebar
(116 px, md+) instead of horizontal chips, giving the classic POS sidebar
feel. Horizontal chips kept for mobile (<md). Menu grid columns adjusted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 07:10:03 +03:30
soroush.asadi f02f78a97c fix(pos): POS v2 menu empty — resolve a valid branch like classic POS
CI/CD / CI · API (dotnet build + test) (push) Successful in 42s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
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 2m37s
The menu/tables are branch-scoped. v2 used the raw stored branchId, which is
null or stale for users who never opened the classic POS (it has no branch
picker), so getBranchMenu returned an empty menu. Now v2 fetches /branches,
auto-selects the first valid branch (self-healing the stored id), and loads the
branch menu + tables + order submission against that resolved branch — matching
the classic POS exactly. Also adds a visible "menu failed to load / retry"
state instead of a silent empty grid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 06:17:37 +03:30
soroush.asadi cc0933c514 fix(auth): don't log out fullscreen routes (POS/queue) on refresh
CI/CD / CI · API (dotnet build + test) (push) Successful in 42s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
CI/CD / CI · Admin Web (tsc) (push) Successful in 36s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 48s
CI/CD / Deploy · all services (push) Successful in 2m35s
The (fullscreen) layout redirected to /login whenever user.accessToken was
falsy — but on a page refresh that fires before Zustand finishes rehydrating
the persisted auth from localStorage, so an authenticated user was bounced to
login on every refresh. Gate the redirect on _hasHydrated (and show a loader
while rehydrating), matching RouteGuard. Tokens themselves are already long
(30d access / 365d refresh), so sessions now survive refreshes as expected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 05:56:11 +03:30
soroush.asadi 7c35984096 feat(pos): default the pay sheet to Card
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 1m6s
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 2m42s
Card is now the pre-selected payment method (and split rows default to Card),
matching Iran's card-dominant payments. Card already sits first in the selector.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 05:38:13 +03:30
soroush.asadi bf0ca68fa6 feat(pos): show Card first in pay sheet, keep Cash as default
CI/CD / CI · API (dotnet build + test) (push) Successful in 43s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
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
Reorder the payment method tabs to کارت / نقدی / تقسیم (Card first, most common
in Iran) while keeping Cash as the pre-selected default method.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 05:32:26 +03:30
soroush.asadi 6778c32028 feat(pos): POS v2 feature parity + promote to default /pos
CI/CD / CI · API (dotnet build + test) (push) Successful in 45s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 32s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m7s
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 2m46s
Completes the four POS v2 roadmap items:

1. Real split payments — split tab records N separate payment rows (equal split,
   last row takes the remainder), each row toggles Cash/Card; posts payments[].
2. Card-terminal push — confirmPay sums Card amounts and calls requestPosPayment
   (POS device) before recording; surfaces POS_DEVICE_* errors.
3. Customer + coupons + loyalty — reuses PosCustomerPicker (attach/search/create)
   and validates coupons via /coupons/validate (discount in totals). Pay sheet
   offers loyalty redemption (1 point = 100 toman) when a customer is attached.
4. Promote to default — /pos now renders POS v2 (full-screen, café-themed); the
   classic terminal moves to /pos-classic with its sidebar+topbar chrome. The
   "نسخه کلاسیک" link points there.

Order submission already carried customerId/guestName/guestPhone/couponId via the
shared cart store, so customer + coupon flow straight through send + pay.
tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 05:16:52 +03:30
soroush.asadi 75a0a1c834 feat(pos): wire POS v2 to live data (board, orders, payments)
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 1m5s
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 2m42s
POS v2 is now a real, working point of sale at /[locale]/pos2 (was a static
mock). It reuses the existing data layer so it shares the React Query cache and
offline pipeline with the classic POS:

- Table board ← fetchCafeTableBoard (Free/Busy/Reserved/Cleaning, live totals,
  guest-QR badge); polls every 15s. Open a free table to start an order; open a
  busy table to hydrate its existing order (GET order → cart hydrateFromOrder).
- Order screen ← real branch/café menu + categories, bound to useCartStore
  (add/qty/remove). Send via submitOrderToApi (online + offline outbox) then
  re-hydrate; "ارسال (n)" shows the pending (unsynced) line count.
- Pay sheet ← POST /orders/{id}/payments. Cash (numpad + change), Card, and a
  Split helper (records the full amount; split is cashier guidance for now).
- Online/offline badge, loading/empty states, toasts, busy overlay, and a
  "نسخه کلاسیک" link back to /pos.

The static design mock stays at /[locale]/pos2-preview (dev-only, 404 in prod).
tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 00:16:37 +03:30
soroush.asadi 8a8eaf37e0 chore: never line-ending-convert cert files (.crt/.pem/.cer)
CI/CD / CI · API (dotnet build + test) (push) Successful in 2m27s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 34s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 41s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 51s
CI/CD / Deploy · all services (push) Successful in 3m19s
Protects docker/nexus-mirror-ca.crt from CRLF corruption on Windows commits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:23:42 +03:30
soroush.asadi 9a27858125 ci: trust Nexus mirror CA in backend dotnet restore (fixes skipped deploys)
The mirror's Let's Encrypt cert renewed under the new ISRG Root YR root,
which isn't in the dotnet SDK image's trust store. `dotnet restore` validates
TLS and fails (NU1301 / unable to get local issuer certificate), so both
backend CI jobs fail and the deploy is skipped. The npm jobs are unaffected
because they already pass --strict-ssl=false.

Pin the mirror's intermediate (CN=YR2, CA:TRUE, valid to Sept 2028) and add it
as a trust anchor before restore in:
- CI api-build + admin-api-build jobs (.gitea/workflows/ci-cd.yml)
- docker/api/Dockerfile + docker/admin-api/Dockerfile (deploy image builds)

Also set NUGET_CERT_REVOCATION_MODE=offline in the CI restore steps to avoid
CRL/OCSP fetches to lencr.org (filtered from Iran).

Permanent fix is server-side (re-chain to ISRG Root X1 or update trust stores);
this unblocks CI/deploys without depending on that.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:22:57 +03:30
soroush.asadi 5078af2dd7 feat(pos): clickable POS v2 redesign prototype at /pos2 (static, no backend)
CI/CD / CI · API (dotnet build + test) (push) Failing after 3m18s
CI/CD / CI · Admin API (dotnet build) (push) Failing after 3m17s
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 1m2s
CI/CD / Deploy · all services (push) Has been skipped
Responsive RTL big-touch reimagining of the POS order screen for judging the
redesign on real devices before decomposing the 1568-line pos-screen.tsx + wiring
real logic. Self-contained: mock menu + local cart, no API/store/SignalR.

- 3 zones: category chips + item grid · order ticket · sticky action bar.
- lg+ side-panel ticket; smaller screens get a "view order" bar + slide-over
  (covers landscape tablet, portrait tablet, phone).
- Big-touch (56px primary / 44px qty), brand green #0F6E56, Toman totals.
  Send/Pay are mock toasts. tsc clean.
2026-06-03 17:23:58 +03:30
soroush.asadi 4123654077 build(meezi_app): Android Maven mirrors for Iran (Aliyun)
CI/CD / CI · API (dotnet build + test) (push) Successful in 12m40s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 10m30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m7s
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 23s
Google's Android maven2 artifacts (AGP, androidx, Kotlin) 404 from Iran like
pub.dev does. Route Gradle resolution through the reachable Aliyun mirrors:

- android/settings.gradle.kts (pluginManagement) + build.gradle.kts (allprojects)
  now list maven.aliyun.com/repository/{gradle-plugin,google,central} before the
  originals (kept as fallback).
- BUILD_IRAN.md documents the full setup incl. the machine-local GRADLE_USER_HOME
  init.gradle needed for Flutter's included flutter_tools/gradle build.

Verified: dependency resolution now succeeds via the mirrors (AGP + kotlin-compiler
download from Aliyun). The APK build itself is currently blocked only by low disk
space on this machine, not configuration.
2026-06-03 10:50:17 +03:30
soroush.asadi 55e0c9499d content(website): reflect latest features across all pages
CI/CD / CI · API (dotnet build + test) (push) Successful in 44s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 34s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
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 1m49s
Full pass over the marketing site so every page reflects the current product:

- Features page: +"Works Offline" and +"Get Discovered on Koja" cards (NEW badges);
  rewrote "Real-time Notifications" to describe the dashboard sound+toast alert.
- Solutions: added offline / Koja / real-time-alert bullets across cafés,
  restaurants, chains, and cloud kitchens.
- Tour: POS step now mentions offline + auto-sync; kitchen step describes the
  sound+toast new-order alert on any screen.
- Docs: +Offline Mode and +Koja Discovery module guides (fa/en).
- appPromo: waiter-app "new order" alert notes sound.
- privacy/terms: "Last updated" June 2025 → June 2026.

Website build clean.
2026-06-03 08:37:42 +03:30
soroush.asadi c8ea364ca2 build(meezi_app): Flutter Koja now builds for web (mirror + platform gen + fixes)
CI/CD / CI · API (dotnet build + test) (push) Successful in 43s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
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 23s
Connectivity fixed: pub.dev/googleapis are 403 under sanctions, so PUB_HOSTED_URL
+ FLUTTER_STORAGE_BASE_URL now point at the reachable Flutter mirror
(pub.flutter-io.cn / storage.flutter-io.cn) — set persistently on the build machine.

- `flutter create --platforms=android,web --org ir.meezi` generated android/ + web/
  (#33). `flutter build web` succeeds; `flutter analyze` errors all cleared.
- pubspec: intl ^0.19.0 → ^0.20.2 (SDK pins 0.20.2 via flutter_localizations).
- Fixed 3 pre-existing compile errors (app had never been built):
  * attendance: JalaliFormatter.yyyyMMdd() removed → build the date from year/month/day.
  * qr_scan: dropped a call to a non-existent CartNotifier.setTable (table context is
    already set via tableContextProvider just above).
  * widget_test: default counter test referenced MyApp → minimal MeeziApp smoke test.
- discover_screen: drop redundant foundation import; value → initialValue (non-deprecated).

Verified: flutter build web ✓. (Android packaging still needs a Gradle/Maven mirror.)
2026-06-03 08:20:37 +03:30
soroush.asadi af1794925d feat(meezi_app): café profile parity — cover, open badge, gallery, hours (code-only)
CI/CD / CI · API (dotnet build + test) (push) Successful in 41s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 33s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
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 50s
CI/CD / Deploy · all services (push) Successful in 22s
Enhances the café detail screen toward web-Koja parity. Parsing verified against
the real backend DTOs (CafePublicDto / WorkingHoursPublicDto), still unbuilt (pub blocked).

- Cover image hero (coverImageUrl), open/closed badge (isOpenNow).
- Photo gallery (galleryUrls) horizontal strip.
- Working hours rendered from the day-keyed WorkingHoursPublicDto ({sat..fri} of
  {isOpen,open,close}), Sat→Fri with Persian day labels.
2026-06-03 08:00:22 +03:30
soroush.asadi 2652736d31 feat(meezi_app): discovery screen parity — rich filters + taxonomy (code-only)
Brings the Flutter discover screen toward web-Koja parity. Unverified (pub blocked).

- DiscoverFilters is now a copyWith class so the many optional filters set safely.
- Adds an "open now" chip, rating chips, sort, and a taxonomy-driven filter sheet
  (themes/vibes/occasions/space-features as multi-select chips + price tier),
  feeding the rich discover() query. Active-filter badge + pull-to-refresh.
- Café cards show open/closed status.
2026-06-03 07:52:49 +03:30
soroush.asadi 1d79dde5e1 feat(meezi_app): Meezi green theme + rich discovery API (Koja parity, code-only)
CI/CD / CI · API (dotnet build + test) (push) Successful in 42s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 37s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
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 50s
CI/CD / Deploy · all services (push) Successful in 23s
Head-start on the Koja-Flutter build while pub access is unavailable (pub.dev 403
under sanctions). NOT yet built/verified — needs `flutter create` + `pub get` once
package access is restored.

- core/theme/app_theme.dart: centralized MeeziTheme (brand green #0F6E56, Material 3,
  filled/outlined buttons, inputs), wired into main.dart (was a brown seed, no theme).
- public_api.dart: discover() gains the full filter set (themes/vibes/occasions/
  spaceFeatures/noise/priceTier/size/openNow) + discoverNearby/nlpParse/discoverTaxonomy,
  matching the web Koja's backend surface. Follows the existing dio pattern.
2026-06-03 07:33:12 +03:30
soroush.asadi 45dab8b253 test: update ReportPlanGate test for maxDays signature
CI/CD / CI · API (dotnet build + test) (push) Successful in 41s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 32s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 1m32s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:00:49 +03:30
soroush.asadi e46d833371 feat(plans): report-history + AI-3D limits read from the catalog (S3 finish)
CI/CD / CI · API (dotnet build + test) (push) Successful in 43s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
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 last two limits that still read hardcoded PlanLimits now come from the
admin-editable catalog, so editing them in the admin panel takes effect:

- ReportPlanGate is now limit-driven (takes int maxDays, not a tier); ReportsController
  resolves MaxReportHistoryDays from catalog.GetLimitsAsync. LimitMessage is generic
  (reflects the actual days). EnsureReportDateAllowed is now async.
- MenuAi3dGenerationService.ResolveLimitAsync reads MaxMenuAi3dPerMonth from the catalog.

Every plan limit + feature gate is now DB-driven and admin-editable. 86 tests pass.
2026-06-03 06:57:59 +03:30
soroush.asadi dcdb0d5747 feat(realtime): global guest-order alert on the dashboard
CI/CD / CI · API (dotnet build + test) (push) Successful in 47s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 32s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
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 49s
CI/CD / Deploy · all services (push) Successful in 3m9s
Guest orders from the QR/digital menu already notified via SignalR, but only
screens that were open (KDS/POS/tables) reacted — and silently (a data refresh,
no alert). So staff on any other screen never knew a menu order arrived.

- Add a global useOrderAlerts() mounted in the dashboard shell: connects to
  /hubs/kds, joins the café group, and on a new GUEST order plays a chime + shows
  a toast (localized fa/en/ar) + nudges order/KDS/POS lists to refresh — on every
  screen.
- Filter to guest QR-menu orders only (not staff POS orders): LiveOrderDto now
  carries Source, set in MapLiveOrder (+ the delivery/snappfood mappers).

86 API tests pass; dashboard tsc + build clean.
2026-06-03 02:42:29 +03:30
soroush.asadi 9b2f15151d feat(website): reflect new features + 5-tier pricing
CI/CD / CI · API (dotnet build + test) (push) Successful in 50s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 45s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
CI/CD / CI · Admin Web (tsc) (push) Successful in 36s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 3m18s
- Pricing: add the Starter tier (now Free·Starter·Pro·Business·Enterprise),
  fix currency ₺ (Turkish Lira) → Toman, and rewrite every plan's bullets to the
  agreed matrix (Free: 6 tables/30 orders/Koja/offline + watermark; Starter:
  watermark-removal/custom-styling/review-reply; Pro: CRM/reports/taxes/payroll/
  delivery/3 branches; Business: 3D + AI-3D + unlimited; Enterprise: API/white-label/
  SLA/24-7). 5-column responsive grid.
- Features: add two headliner cards that were missing — "Works offline" and
  "Get discovered on Koja". fa/en.

Website tsc + build clean.
2026-06-03 02:20:16 +03:30
soroush.asadi 7d06f149d3 feat(plans): menu watermark on Free (removed by paid feature)
Guest QR menu shows a "ساخته‌شده با میزی" watermark under the menu unless the café's
plan has the `watermark_removed` feature (Starter+).

- PublicMenuDto gains ShowWatermark; PublicService computes it from
  IsFeatureEnabledForCafeAsync("watermark_removed") for both slug and branch menus.
- Guest menu renders the watermark footer when showWatermark.
- NoOpPlatformCatalogService test double (all features on) for the PublicService
  ctor; QrMenuTests updated.

86 tests pass; dashboard tsc clean.
2026-06-03 02:10:24 +03:30
soroush.asadi 2487f9e30f feat(plans): Stage 3b — DB-driven gates for reviews/styling/limits
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m0s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 42s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
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 2m51s
Make more plan rules read the admin-editable catalog instead of hardcoded values:
- Review reply gated by the `review_reply` feature (Starter+) — 403 if not in plan.
- Custom menu styling gated by `custom_menu_styling` (Starter+): only blocks an
  actual theme change, so a normal settings save re-sending the current theme is fine.
- Menu categories/items limits now read catalog.GetLimitsAsync (Free categories
  editable; message no longer hardcodes a number).
- Terminals limit reads the catalog (enforcement in TerminalRegistryService +
  the displayed max in TerminalsController).

Remaining (small): menu watermark (Free shows it, `watermark_removed` removes it —
needs the public-menu render), report-history (static ReportPlanGate) and AI-3D
routing — these already enforce the correct matrix values, just not yet editable.

86 tests pass; build clean.
2026-06-03 01:40:00 +03:30
soroush.asadi 8f738f6469 feat(plans): Stage 4 — full admin plan/feature editor
The admin → Plans screen now edits EVERYTHING per plan (the backend already
accepted it; only the UI was partial):
- All limits (orders/day, tables, terminals, branches, menu categories, menu
  items, customers, report history, SMS, AI-3D) with an "unlimited (∞)" toggle.
- Display names (fa/en), monthly price, sort order, billable-online, active on/off.
- Per-plan feature checkboxes grouped by module, plus an "all features (*)" toggle
  (Enterprise). Sourced from the live feature catalog (/api/admin/features).
- Plans listed in sort order (Free·Starter·Pro·Business·Enterprise).
- i18n fa/en/ar.

Admin tsc + build clean.
2026-06-03 01:11:18 +03:30
soroush.asadi 7f52b2823f feat(plans): Stage 3a — enforce tables cap from the DB catalog
PlanLimitChecker already enforces orders/customers/branches/SMS from the
admin-editable catalog (GetLimitsAsync). Add the tables cap the same way
(POST /api/cafes/{cafeId}/tables → MaxTables), so Free's 6-table limit is both
enforced and admin-editable. Terminals/categories/report-history already enforce
the correct matrix values via PlanLimits defaults; routing them through the
catalog for editability + the watermark/styling/review-reply feature gates are
the remaining S3 items.

86 tests pass.
2026-06-03 00:58:49 +03:30
soroush.asadi c5d5a4006a feat(plans): Stage 2 — seed 5-tier matrix + feature catalog (editable defaults)
- CanonicalPlans(): single source for Free·Starter·Pro·Business·Enterprise with the
  locked feature sets (Free is broad: KDS/queue/Koja/offline/reviews/reservations/
  coupons/employees; Starter +watermark-removal/custom-styling/review-reply; Pro +CRM/
  reports/taxes/HR/delivery/expenses/branches; Business +3D/AI-3D; Enterprise *).
- Feature catalog: + offline, employees, watermark_removed, custom_menu_styling,
  review_reply, api, white_label.
- New Starter plan (690k Toman default, billable, sort 1).
- One-time, version-guarded matrix upgrade (catalog.planMatrixVersion=2): brings the
  existing (never-yet-admin-edited) prod plans to the canonical limits/features/order/
  price and inserts Starter. Runs once; won't clobber later admin edits.
- Replaced the additive feature-merge (which would wrongly re-add menu_3d to Pro).

Defaults only — admins will be able to change everything in S4. 86 tests pass.
2026-06-03 00:53:02 +03:30
soroush.asadi 4cb640814a feat(plans): Stage 1 — Starter tier + admin-editable limit model
Foundation for the configurable plan system (Free·Starter·Pro·Business·Enterprise).

- PlanTier: append Starter=4 (no renumber → no data migration; existing tier ints
  keep meaning). Ordering/display via PlanDefinition.SortOrder; gating uses explicit
  tier sets, never `tier >= X`.
- PlanLimits: locked 5-tier matrix — Free orders/day 50→30, tables (new) Free 6/
  Starter 15/Pro 40/∞, categories Free 3→10, menu items now unlimited, terminals/
  branches/report-history ladders incl. Starter; CRM/analytics = explicit Pro+; AI-3D
  = Business+. SMS quotas kept (Free/Starter 0, Pro 50, Business 200) until the
  pay-as-you-go credit system ships (don't break paid SMS).
- PlanLimitsData (LimitsJson shape): + MaxTables/MaxMenuCategories/MaxMenuItems/
  MaxMenuAi3dPerMonth; ForTier now derives from PlanLimits (single source of truth).

No migration. 86 tests pass. Next: S2 seed 5 plans + feature catalog (editable),
S3 wire enforcement to DB, S4 admin editor.
2026-06-03 00:40:37 +03:30
soroush.asadi 4c98c2cce1 feat(auth): extend token lifetimes for long offline gaps
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m24s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 1m1s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
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 50s
CI/CD / Deploy · all services (push) Successful in 2m16s
A user can be offline for months (offline-first dashboard) and must stay logged
in / be able to sync on reconnect. Access 7d→30d, refresh 30d→365d, so a ~3-month
offline gap still has a valid refresh token on reconnect (queued writes sync, no
forced logout). Client only logs out on a server 401, never while offline.
2026-06-02 23:47:06 +03:30
soroush.asadi db0c3a4a02 feat(hr): add employees from the dashboard
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m10s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 1m40s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 1m32s
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 9m24s
Previously the only Employee records were the Owner (created at café signup) and
one Manager per branch — there was no way to add a waiter/cashier/chef. Adds it.

Backend:
- POST /api/cafes/{cafeId}/employees (HrController). Owner/Manager only; creating a
  Manager requires Owner; Owner cannot be created here. Validates name/phone/role,
  enforces one-employee-per-phone, validates branch belongs to the café, and can
  optionally set username/password login in the same step (same hashing + uniqueness
  as the credentials endpoint). Returns EmployeeSummaryDto.

Dashboard:
- New "Team" tab on the HR screen (now the default): employee roster (name, role,
  phone, base salary) + an "Add employee" button (owner/manager) opening an inline
  form — name, phone, role, optional branch, optional base salary, optional login.
- Role labels + all form strings in fa/en/ar.

86 API tests pass; dashboard tsc + build clean.
2026-06-02 23:28:36 +03:30
soroush.asadi f1756b491e feat(admin): rich text editor for blog content (TipTap)
CI/CD / CI · API (dotnet build + test) (push) Successful in 3m33s
CI/CD / CI · Admin API (dotnet build) (push) Failing after 3m18s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Failing after 3m43s
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 skipped
Blog post bodies were plain <textarea>s labelled "Markdown". Replace with a
TipTap rich editor (bold/italic/strike, H1–H3, lists, blockquote, code, links,
undo/redo), RTL-aware, producing HTML.

- New RichTextEditor component (TipTap v2: react + starter-kit + pm + link +
  placeholder), immediatelyRender:false for Next SSR, self-contained content
  styling, external-value sync.
- Wired into the FA/EN content fields of the blog editor; labels no longer say
  "Markdown" (fa/en/ar).
- Website blog page now renders HTML when the body is HTML and falls back to
  MDXRemote for older Markdown posts (backward-compatible). Content is authored
  only by trusted SystemAdmins, so HTML is stored/rendered directly.

Admin build + website typecheck clean.
2026-06-02 22:25:47 +03:30
soroush.asadi 97a9481627 feat(media): content-hash dedup for uploads + media-library endpoint
Uploads previously wrote every file to disk with a fresh GUID name, so the
same image uploaded twice produced two identical files. Now:

- New MediaAsset table records each stored upload (SHA-256 hash, size, type,
  url, kind, scope) + migration. Indexed on (CafeId, ContentHash).
- MediaStorageService computes the content hash on upload; if an identical file
  already exists for that café it returns the existing URL instead of writing a
  duplicate (covers images, videos, 3D models). Dedup lookup/record run via a
  scoped DbContext (the service is a singleton) and never block an upload on
  failure.
- GET /api/cafes/{cafeId}/media lists the café's library (newest first, optional
  ?kind=) so the UI can let users pick an existing file instead of re-uploading.

86 API tests pass.
2026-06-02 22:16:11 +03:30
soroush.asadi eb165db182 feat(offline): make every dashboard write durable offline (P2–P5)
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m0s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 38s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 36s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 3m11s
Builds on the outbox engine to take the whole dashboard offline in one place
instead of wiring 114 mutation sites individually.

Frontend (single chokepoint = the API client):
- offline-write: any write auto-queues to the outbox on offline/network failure
  and returns an optimistic value; the online path is unchanged apart from an
  Idempotency-Key header (so even online retries de-dup). entityType is derived
  from the URL; POSTs get a remappable local id.
- client.doWrite unifies POST/PUT/PATCH/DELETE through this path. WriteOptions
  gains `offline: "queue" | "reject" | "manual"`.
- Guardrails: auth / billing / payments / SMS / exports are online-only and throw
  OFFLINE_UNAVAILABLE offline rather than queueing (no queued double-charges or
  surprise SMS blasts). use-api-error resolves the friendly localized message
  (fa/en/ar).
- submit-order opts out ("manual") to keep its richer local-Order mock; shared
  helpers de-duplicated into offline-write.
- Request persistent storage on mount so unsynced writes survive eviction.

Backend:
- IdempotencyCleanupJob: daily purge of idempotency records older than 7 days
  (the table now gets a row per keyed write). Registered in Hangfire. No migration.

86 API tests pass; dashboard tsc + build clean.
2026-06-02 18:34:54 +03:30
soroush.asadi 3b468b48d9 feat(dashboard/offline): generic idempotent outbox + ID remapping
CI/CD / CI · API (dotnet build + test) (push) Successful in 48s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 53s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
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 3m12s
Completes offline Phase 1 (frontend). Generalises the POS-orders-only queue into
a reusable write engine and fixes the two correctness bugs in the old path.

- offline-db: generic `outbox` store (DB v3, order_queue/kv preserved) with
  enqueue/list/update/remove + a persisted client→server id map.
- outbox.ts: drains in causal order — remaps local_* ids to server ids (blocking
  an op until its creator syncs), sends each op with its idempotency key, and
  classifies failures (offline → stop; 5xx / in-progress → retry; 4xx → poison
  after 5 attempts). remap/blocked logic validated against representative cases.
- client: apiPost/Put/Patch/Delete take an optional idempotencyKey →
  `Idempotency-Key` header; ApiClientError now carries HTTP status.
- submit-order: generates ONE idempotency key per submit, used for both the
  online attempt and the queued replay → server de-dups (no more double-create);
  offline create carries createsClientId so a later add-items remaps onto the
  real order instead of spawning a second order.
- use-offline-sync: drains the outbox, one-time migrates legacy order_queue
  items, invalidates queries after a successful sync.

tsc + production build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:19:29 +03:30
soroush.asadi f4583f5169 feat(api/offline): idempotency-key middleware for safe write retries
Backend half of offline Phase 1. Lets the offline outbox replay a write after a
lost response without executing it twice (e.g. an order whose POST reached the
server but whose reply never came back).

- IdempotencyRecord entity + table (unique index on (Scope, Key)); migration
  AddIdempotencyRecords. Standalone POCO — no tenant/soft-delete filters.
- IdempotencyMiddleware (after TenantMiddleware, before plan-limit/controllers):
  opt-in via `Idempotency-Key` header on POST/PUT/PATCH/DELETE.
    * Completed key → replays stored status+body with `Idempotent-Replay: true`.
    * In-progress key → 409 IDEMPOTENCY_IN_PROGRESS; the unique index serializes
      racing first requests; stale (>60s) reservations are recovered after a crash.
    * Only <500 responses are cached; 5xx is released so the client can retry.
  Bookkeeping runs in isolated DI scopes so it never contaminates the controller's
  unit of work. Keys are scoped per café — no cross-tenant collisions.
- 5 middleware tests (replay/execute-once, distinct key, pass-through, tenant
  isolation, 5xx-not-cached). Full suite 86 passing.

Next in Phase 1: generalize the POS order queue into a generic client outbox that
sends these keys and remaps client→server ids.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:03:57 +03:30
soroush.asadi 132f0921e0 feat(dashboard/offline): persist React Query cache for offline reads
First slice of offline-first (Phase 1). Makes every dashboard area *viewable*
offline with last-synced data, instead of empty lists on an offline reload
(previously only next-pwa's 10-min API cache survived).

- offline-db: add a generic `kv` IndexedDB store (DB v2, preserves order_queue)
  with kvGet/kvSet/kvDelete; all degrade silently on quota/unavailable.
- query-persister: debounced snapshot of the React Query cache via
  dehydrate/hydrate (no new dependency). Restore is guarded by a schema buster,
  24h max-age, and a café scope so one tenant never hydrates another's data.
- providers: gcTime 24h so hydrated data isn't GC'd; restore on mount + persist
  on cache changes, re-scoped when the signed-in café changes.

No write-path changes; the existing POS order queue is untouched. Next in
Phase 1: generalize that queue into an idempotent outbox with client→server
ID remapping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:41:15 +03:30
soroush.asadi bb0be19dac feat(billing): queue subscriptions bought while active + cancel queued
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m1s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 49s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m3s
CI/CD / CI · Admin Web (tsc) (push) Successful in 34s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 48s
CI/CD / Deploy · all services (push) Successful in 3m9s
Before, buying a plan immediately switched the tier and stacked the duration.
Now a purchase made while the café still has paid coverage is QUEUED to start
when the current coverage ends, and the owner can cancel a queued one.

Model:
- SubscriptionPayment gains EffectiveFrom/EffectiveTo; status gains Scheduled
  (paid, queued) and Cancelled. EF migration AddSubscriptionScheduling (nullable).

BillingService:
- On payment completion, compute coverage end (latest of active expiry + furthest
  queued period). If it is in the future → Scheduled (queued, café tier/expiry
  untouched); else activate immediately as before. Periods chain correctly.
- GetStatusAsync lazily promotes any due queued period to active, and returns the
  queue (QueuedPlans).
- CancelQueuedAsync cancels a Scheduled period (owner-only) and re-packs the queue
  so later periods slide earlier. Active prepaid plan is never cut short; no
  automatic refund (manual, per product decision).
- Confirmation SMS distinguishes "activated until X" vs "queued, starts X".

API: BillingStatusDto.QueuedPlans + DELETE /api/billing/queued/{paymentId}.

Dashboard:
- Subscription screen shows a "Queued subscriptions" card (tier, window, cancel
  with confirm).
- Checkout shows "you already have an active subscription — this will start on
  {date}" when the café is still covered.
- i18n fa/en/ar.

81 API tests pass; dashboard typechecks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:44:32 +03:30
soroush.asadi 15def7ff1c feat: delete actions for warehouse/reservations/coupons/customers + Koja listing toggle
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m10s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 52s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 55s
CI/CD / Deploy · all services (push) Successful in 3m29s
Delete (every manageable entity that only had "add" now has delete):
- Ingredients (warehouse): new DELETE /inventory/ingredients/{id} (soft-delete via
  the global DeletedAt filter — no FK trouble with recipes/movements) + NoOp stub +
  trash button in the materials cards.
- Reservations: new DELETE /reservations/{id} (soft-delete) + per-card delete button.
- Coupons & Customers: backend DELETE already existed; wired delete buttons in the UI.
- Shared ConfirmDialog component used by all delete flows (RTL-aware).
- Audit result: tables/branches/taxes/kitchen-stations/expenses/menu/terminals already
  had delete; HR has no "add" so no delete needed; shifts intentionally excluded
  (financial open/close records, not add-style entities).

Koja visibility:
- New Cafe.ShowOnKoja flag, default TRUE (DB default true so existing cafés stay
  listed). Discover query now filters IsVerified && !Deleted && ShowOnKoja.
- public-profile GET/PUT expose showOnKoja; dashboard public-profile panel has an
  on-by-default toggle that persists immediately. Platform IsVerified gate unchanged.
- EF migration AddCafeShowOnKoja (defaultValue: true).

Also: added the missing errors.generic i18n key (fa/en/ar) so useApiError's fallback
resolves instead of rendering the literal "errors.generic". 81 API tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:14:40 +03:30
soroush.asadi 60e2ac1355 fix(auth): non-rotating, sliding refresh tokens to stop the OTP storm
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m53s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 1m37s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 1m8s
CI/CD / Deploy · all services (push) Successful in 1m40s
Login already issues a 7-day access token + 30-day refresh token, and the
dashboard persists the session and silently refreshes on 401 — so a session
should last well over a week. The real cause of "re-login every time / massive
OTP" was single-use refresh-token rotation: RefreshAsync revoked the presented
token and minted a new one, so when a café runs POS + KDS + queue display at
once (or two tabs), the first refresh won the race and every other concurrent
refresh hit the now-revoked token -> INVALID_TOKEN -> forced logout -> OTP.

Make refresh idempotent and race-safe:
- IssueTokensAsync takes an optional existingRefreshToken; on refresh we reuse
  the presented token and re-store it (sliding the 30-day TTL) instead of
  minting a new one. Login still mints a fresh token.
- RefreshAsync no longer revokes the presented token.

Net effect: concurrent refreshes all succeed; an active session slides forward
and effectively never forces re-auth. Access stays 7 days, refresh 30 days.
All 81 API tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 15:09:25 +03:30
soroush.asadi a37d93f6cd fix(ui): force dir=ltr on remaining RTL pill toggles
CI/CD / CI · API (dotnet build + test) (push) Successful in 45s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 1m1s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
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 5m43s
The branch-menu-overrides availability switch (dashboard) and the BlogToggle
(admin website editor) still moved their knob with translate-x while inheriting
RTL, so the knob escaped the track on the right. Pin both to dir="ltr" like the
other switches. All four role="switch" toggles in the codebase now share the fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 13:24:20 +03:30
soroush.asadi 7122df57b2 feat(menu): delete category/item + fix RTL availability toggle
CI/CD / CI · API (dotnet build + test) (push) Successful in 56s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 58s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 3m18s
- Add DELETE /api/cafes/{cafeId}/menu/items/{id} (DeleteItemAsync soft-delete,
  mirroring the existing category delete) — item delete had no backend route.
- Dashboard menu admin: destructive "delete" action in the item and category
  edit modals, behind a shared confirm dialog (AlertDialog). Deleting the
  selected category falls back to "all items".
- Fix the availability ToggleSwitch in RTL: force dir="ltr" so the knob's
  translate-x stays inside the track instead of escaping on the right
  (same fix as the admin-panel toggles).
- i18n: deleteItem/deleteCategory confirm + success strings (fa/en/ar).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 12:24:09 +03:30
soroush.asadi 72f95aa0db fix(demo-seed): stop truncating ingredient/table ids to 36 chars
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m9s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 47s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Successful in 36s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 1m24s
BuildDemoIngredients/BuildDemoTables built ids as
"{cafeId}_ing_{guid}"[..36]. For a real cafe (32-char hex id) the
first 36 chars are just "{cafeId}_ing" — the unique guid is cut off,
so all 15 ingredients (and all 10 tables) get the SAME id, causing a
primary-key collision on SaveChanges -> 500. cafe_demo_001 has a short
id so the guid survived, which is why the bug only hit real cafes.

The Id columns are text (no length limit), so the truncation served no
purpose. Removed [..36] from both so the full unique id is kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 11:55:06 +03:30
soroush.asadi bab3453e41 fix(auth): read role claim under mapped name so Owner/Manager gates work
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 1m4s
CI/CD / CI · Admin Web (tsc) (push) Successful in 36s
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 1m27s
ROOT CAUSE of demo-seed/billing/etc. returning 403 for real owners: .NET's JWT
handler remaps the short "role" claim to ClaimTypes.Role on inbound, so
TenantMiddleware's FindFirst("role") returned null and tenant.Role (EmployeeRole?)
stayed null. EnsureManager/EnsureOwner then rejected even a valid Owner token with
MANAGER_REQUIRED / OWNER_REQUIRED, while reads (no role gate) worked and
[Authorize(Roles=...)] worked (it reads the remapped claim). Now reads the role
under both MeeziClaimTypes.Role ("role") and ClaimTypes.Role. Same fix applied to
the AuthController whoami role. Fixes demo seed, subscription billing, and every
other tenant.Role-gated action.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 11:18:10 +03:30
soroush.asadi 24da1e0522 feat(orders): per-item kitchen/bar notes (POS + QR app + KDS)
CI/CD / CI · API (dotnet build + test) (push) Successful in 57s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 57s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m43s
Lets the POS agent and the QR/app customer attach a free-text note to each
order line (e.g. "no tomato", "extra hot") that reaches the kitchen/bar.

- Backend already supported it (OrderItem.Notes persists; CreateOrderItemRequest
  and OrderItemDto carry Notes; LiveOrderDto items include it) — this wires the UI.
- cart.store: add setNotes(menuItemId, notes); notes already travel in
  getPendingLines and round-trip via hydrateFromOrder.
- POS pos-screen: a note input under each cart line.
- QR guest menu: a note input under each cart line (QrCartLine.note).
- KDS: render the note prominently under each item so kitchen/bar sees it.
- i18n: pos.itemNotePlaceholder + qrMenu.itemNote (fa/ar/en).

Note: notes are captured on items being added; editing a note on an
already-submitted line is out of scope (no pending delta to re-send).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 09:37:59 +03:30
soroush.asadi 2203ecbdaf fix(koja): remove over-broad short-URL redirect that 500'd the home page
CI/CD / CI · API (dotnet build + test) (push) Failing after 3m18s
CI/CD / CI · Admin API (dotnet build) (push) Failing after 2m22s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 36s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Has been skipped
The redirect source "/:slug([a-z0-9][a-z0-9-]*[a-z0-9])" matched single-segment
paths including the locale itself, so /fa redirected to /fa/cafe/fa (slug "fa")
and /en to /fa/cafe/en — non-existent cafés that returned Internal Server Error.
Visiting koja.meezi.ir (-> /fa) hit this. Removed the redirect so the home page
renders; short café URLs can be re-added via middleware with reserved-word guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 08:29:27 +03:30
soroush.asadi 1aaab6c593 fix(admin): integrations save uses rendered list (fixes dropped Zarinpal merchantId)
CI/CD / CI · API (dotnet build + test) (push) Successful in 58s
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 38s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 1m42s
The integrations form rendered from  (gateways state, falling back to fetched data) but SAVED from the  state and edited via updateGateway on . If gateways hadn't hydrated, edits (e.g. Zarinpal merchantId) were written to an empty array and the save sent nothing. Now updateGateway seeds from fetched data on first edit, and the save maps over  — render, edit, and save share one source. NOTE: prod admin had also been stale because recent deploys aborted on the main-API crash before the admin containers restarted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 08:15:16 +03:30
soroush.asadi 09bba5f8cd fix(seed): count soft-deleted rows + make platform seeding non-fatal
CI/CD / CI · API (dotnet build + test) (push) Successful in 45s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 33s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
CI/CD / CI · Admin Web (tsc) (push) Successful in 34s
CI/CD / CI · Website (tsc) (push) Successful in 43s
CI/CD / CI · Koja (tsc) (push) Successful in 48s
CI/CD / Deploy · all services (push) Successful in 1m20s
Root cause of the crash-loop: a soft-deleted Free plan still occupies its Tier in the unique index, but the existing-row check queried THROUGH the soft-delete global filter and missed it, so the seeder re-inserted Free and violated IX_PlatformPlanDefinitions_Tier on boot. Fixes: (1) IgnoreQueryFilters() on the plan/feature existing-checks so soft-deleted tiers/keys are counted; (2) wrap plan/feature/location seeding in try/catch so any seeding failure logs and startup continues — non-essential seeding must never crash-loop the API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 02:26:11 +03:30
soroush.asadi 3b8dcf3af6 fix(seed): dedupe plans by Tier and features by Key (hotfix crash-loop)
CI/CD / CI · API (dotnet build + test) (push) Successful in 2m39s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m2s
CI/CD / CI · Admin Web (tsc) (push) Successful in 34s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 1m27s
The previous change deduped on Id, but the unique constraints are on PlatformPlanDefinitions.Tier and PlatformFeatures.Key. Prod's existing Free plan has a different Id, so seeding re-inserted a Free-tier row and crashed on IX_PlatformPlanDefinitions_Tier (23505), crash-looping the API. Now skips any tier/key that already exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 02:11:42 +03:30
soroush.asadi 087563bce7 feat(settings): use-my-current-location button; surface ticket-load error
CI/CD / CI · Admin API (dotnet build) (push) Successful in 52s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 36s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / CI · API (dotnet build + test) (push) Successful in 41s
CI/CD / Deploy · all services (push) Failing after 2m34s
Location card gets a 'موقعیت فعلی من' button that fills lat/lng from the browser's geolocation. Support ticket list now shows the resolved (localized) error instead of a generic message, so a failure is diagnosable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 01:52:29 +03:30
soroush.asadi e839db7331 fix(koja): default to fa (no browser locale guess); guard null discoverProfile
Koja auto-detected locale from the browser Accept-Language (en for many Persian users); set localeDetection:false so locale-less URLs default to fa. Also guarded cafe.discoverProfile across the cafe page, cafe card, and JSON-LD — a café without a discover profile crashed the page (500). The cafe page now resolves the café first and notFound()s an unknown slug before fetching menu/reviews.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 01:51:50 +03:30
soroush.asadi a83edf7667 fix: seed all plans/features in prod (upsert); fix admin toggle RTL knob
CI/CD / CI · API (dotnet build + test) (push) Successful in 50s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 39s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Failing after 5m44s
Plan + feature seeding was dev-gated and all-or-nothing, so production only had the Free plan (admin Plans page showed one). Now runs in every environment and upserts missing rows (adds Pro/Business/Enterprise on top of the existing Free). Also force LTR on the admin toggle switch so the knob doesn't render off-track under the RTL page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 00:23:17 +03:30
soroush.asadi 75d5bbc84a fix(i18n): localize API error messages by code (no more raw English)
Error toasts surfaced the raw English backend message. Added an errors namespace (fa/ar/en) keyed by error code + a useApiError() resolver that maps ApiClientError.code to the localized message (fallback to a localized generic). Wired into menu, tables, demo banner, and subscription checkout; hardened getErrorMessage so it never returns the raw backend message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 00:04:48 +03:30
soroush.asadi 7519f474f3 fix(demo): allow Manager to seed demo data + surface seed errors
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m1s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 46s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
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 3m15s
The dashboard demo-data banner is shown to Owner and Manager, but the /demo/seed endpoint required strictly Owner, so a Manager clicking it got a silent 403 (the banner had no error handler) — appearing as 'nothing happens, no tables or items'. The endpoint now allows Owner or Manager, and the banner shows the error on failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 23:23:39 +03:30
soroush.asadi 35494d8b32 fix(i18n): keep locale on website->dashboard links; dashboard defaults to fa
Marketing-site login/register/dashboard links were locale-less (app.meezi.ir/login), so the dashboard auto-detected locale from the browser Accept-Language (en-US) and redirected Persian users to /en. Links now carry the current locale, and the dashboard sets localeDetection:false so any locale-less entry defaults to fa (Iran-first) instead of guessing from the browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 23:23:09 +03:30
soroush.asadi 4c7783884c feat(map): backfill café coordinates from city on startup (prod-safe)
CI/CD / CI · API (dotnet build + test) (push) Successful in 56s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 32s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 36s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 48s
CI/CD / Deploy · all services (push) Successful in 1m40s
Real cafés without a map pin now get approximate coordinates at their city centre (with a deterministic per-café offset) on every boot, in all environments, so the public Iran map lights up with merchant dots. Only fills rows where Latitude/Longitude is null and the city is recognised (20 major Iranian cities); never overwrites an owner-set pin. Owners can drop an exact pin from Settings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 22:38:28 +03:30
soroush.asadi 8ce0b3e3e8 feat(discover): seed showcase café coordinates so the map shows blinking lights
CI/CD / CI · API (dotnet build + test) (push) Successful in 40s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 32s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 48s
CI/CD / Deploy · all services (push) Successful in 4m11s
Showcase cafés (dev/staging only) now get Latitude/Longitude scattered around their real city (Tehran/Karaj) with a deterministic per-id offset, so the homepage Iran map renders a realistic cluster of blinking merchant lights. Backfills existing rows where coords are null. Production cafés get coordinates when owners set their location in dashboard Settings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 22:00:14 +03:30
315 changed files with 36188 additions and 1670 deletions
+14
View File
@@ -6,6 +6,20 @@
"runtimeExecutable": "dotnet",
"runtimeArgs": ["run", "--project", "F:/Projects/DrSousan/DrSousan.Api", "--urls", "http://localhost:5000"],
"port": 5000
},
{
"name": "meezi-website",
"runtimeExecutable": "node",
"runtimeArgs": ["node_modules/next/dist/bin/next", "dev", "-p", "3013"],
"cwd": "web/website",
"port": 3013
},
{
"name": "meezi-dashboard",
"runtimeExecutable": "node",
"runtimeArgs": ["node_modules/next/dist/bin/next", "dev", "-p", "3015"],
"cwd": "web/dashboard",
"port": 3015
}
]
}
+5
View File
@@ -0,0 +1,5 @@
# Certificate files must never be line-ending converted (CRLF would corrupt
# trust-store parsing on Linux CI runners / Docker builds).
*.crt -text
*.pem -text
*.cer -text
+38
View File
@@ -80,10 +80,30 @@ jobs:
</configuration>
EOF
- name: Verify mirror TLS chain
# The mirror's fullchain.pem now serves leaf → YR2 → ISRG Root YR
# (cross-signed by ISRG Root X1, which IS in every stock trust store),
# so no custom CA is needed. This step only sanity-checks the chain and
# fails early with a clear message if the server cert regresses again.
# POSIX sh only — the Gitea act runner v0.6.1 ignores shell: overrides.
run: |
set -eu
echo | openssl s_client -connect mirror.soroushasadi.com:443 \
-servername mirror.soroushasadi.com 2>/dev/null \
| tee /tmp/sclient.txt | grep "Verify return code" || true
if ! grep -q "Verify return code: 0 (ok)" /tmp/sclient.txt; then
echo "❌ mirror.soroushasadi.com TLS chain is broken again."
echo " Fix the cert ON THE SERVER (/etc/ssl/soroushasadi/fullchain.pem"
echo " must include the full chain up to a publicly-trusted root),"
echo " then: docker exec mirror-nginx nginx -s reload"
exit 1
fi
- name: Restore
run: dotnet restore src/Meezi.API/Meezi.API.csproj --configfile /tmp/nuget.ci.config
env:
DOTNET_CLI_TELEMETRY_OPTOUT: 1
NUGET_CERT_REVOCATION_MODE: offline
- name: Build
run: dotnet build src/Meezi.API/Meezi.API.csproj --no-restore -c Release
@@ -128,10 +148,23 @@ jobs:
</configuration>
EOF
- name: Verify mirror TLS chain
# Same sanity check as api-build — see that job for full comments.
run: |
set -eu
echo | openssl s_client -connect mirror.soroushasadi.com:443 \
-servername mirror.soroushasadi.com 2>/dev/null \
| tee /tmp/sclient.txt | grep "Verify return code" || true
if ! grep -q "Verify return code: 0 (ok)" /tmp/sclient.txt; then
echo "❌ mirror.soroushasadi.com TLS chain is broken again — fix the server cert."
exit 1
fi
- name: Restore
run: dotnet restore src/Meezi.Admin.API/Meezi.Admin.API.csproj --configfile /tmp/nuget.ci.config
env:
DOTNET_CLI_TELEMETRY_OPTOUT: 1
NUGET_CERT_REVOCATION_MODE: offline
- name: Build
run: dotnet build src/Meezi.Admin.API/Meezi.Admin.API.csproj --no-restore -c Release
@@ -413,6 +446,11 @@ jobs:
-f docker-compose.admin.yml \
up -d --no-deps admin-web
- name: Start nightly DB backup
# Sidecar that pg_dumps meezi-db nightly into ./backups (14-day retention).
# --no-deps so it doesn't try to (re)start postgres which isn't compose-managed.
run: docker compose up -d --no-deps backup
- name: Show all running containers
if: always()
run: docker compose -f docker-compose.yml -f docker-compose.admin.yml ps
+8
View File
@@ -7,6 +7,7 @@
# Domains needed in DNS (all → same server IP):
# meezi.ir, app.meezi.ir, api.meezi.ir,
# koja.meezi.ir, admin.meezi.ir, admin-api.meezi.ir
# status.meezi.ir (only if the monitoring stack is running — see docs/monitoring.md)
{
email {$ACME_EMAIL}
@@ -41,3 +42,10 @@ admin.{$DOMAIN} {
admin-api.{$DOMAIN} {
reverse_proxy admin-api:8080
}
# ── Uptime monitoring (Uptime Kuma) ──────────────────────────────────────────
# Only resolves if the monitoring stack is up (docker-compose.monitoring.yml).
# Caddy ignores upstreams that don't exist until the container is running.
status.{$DOMAIN} {
reverse_proxy uptime-kuma:3001
}
+1 -1
View File
@@ -26,7 +26,7 @@ services:
redis:
condition: service_healthy
environment:
ASPNETCORE_ENVIRONMENT: "${ASPNETCORE_ENVIRONMENT:-Development}"
ASPNETCORE_ENVIRONMENT: "${ASPNETCORE_ENVIRONMENT:-Production}"
ASPNETCORE_URLS: http://+:8080
RUN_MIGRATIONS: "${RUN_MIGRATIONS:-true}"
ConnectionStrings__DefaultConnection: "${DB_CONNECTION_STRING:-Host=postgres;Port=5432;Database=meezi;Username=meezi;Password=meezi_local_pass}"
+29
View File
@@ -0,0 +1,29 @@
name: meezi
# Self-hosted uptime monitoring for Meezi — Uptime Kuma.
#
# One-time stand-up (does NOT need redeploying with every app deploy):
# docker compose -f docker-compose.monitoring.yml up -d
#
# Then open https://status.meezi.ir (or http://SERVER:3201) and configure the
# monitors + alert channel as described in docs/monitoring.md.
#
# Config + history persist in the uptime_kuma_data volume.
services:
uptime-kuma:
image: ${UPTIME_KUMA_IMAGE:-mirror.soroushasadi.com/louislam/uptime-kuma:1}
container_name: meezi-uptime-kuma
restart: unless-stopped
volumes:
- uptime_kuma_data:/app/data
ports:
- "${UPTIME_KUMA_PORT:-3201}:3001"
healthcheck:
test: ["CMD-SHELL", "node extra/healthcheck.js || exit 1"]
interval: 60s
timeout: 10s
retries: 3
volumes:
uptime_kuma_data:
+25 -1
View File
@@ -76,7 +76,7 @@ services:
redis:
condition: service_healthy
environment:
ASPNETCORE_ENVIRONMENT: "${ASPNETCORE_ENVIRONMENT:-Development}"
ASPNETCORE_ENVIRONMENT: "${ASPNETCORE_ENVIRONMENT:-Production}"
ASPNETCORE_URLS: http://+:8080
RUN_MIGRATIONS: "${RUN_MIGRATIONS:-true}"
ConnectionStrings__DefaultConnection: "${DB_CONNECTION_STRING:-Host=postgres;Port=5432;Database=meezi;Username=meezi;Password=meezi_local_pass}"
@@ -177,6 +177,30 @@ services:
ports:
- "${KOJA_PORT:-3103}:3000"
# Nightly Postgres backup — dumps the DB every night, keeps the last 14 days.
# Dumps land in the host ./backups dir (bind mount) so they survive a full
# container/volume wipe and can be rsync'd off-box. See scripts/backup/RESTORE.md.
backup:
image: ${POSTGRES_IMAGE:-mirror.soroushasadi.com/postgres:16-alpine}
container_name: meezi-backup
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
PGHOST: postgres
PGPORT: "5432"
PGUSER: meezi
PGPASSWORD: "${DB_PASSWORD:-meezi_local_pass}"
PGDATABASE: meezi
RETAIN_DAYS: "${BACKUP_RETAIN_DAYS:-14}"
BACKUP_HOUR: "${BACKUP_HOUR:-2}"
TZ: Asia/Tehran
entrypoint: ["/bin/sh", "/backup/pg-backup-loop.sh"]
volumes:
- ./scripts/backup:/backup:ro
- ${BACKUP_DIR:-./backups}:/backups
volumes:
postgres_data:
redis_data:
+5
View File
@@ -8,6 +8,11 @@ COPY global.json Directory.Build.props Directory.Packages.props ./
# nuget.docker.config points to Nexus mirror (mirror.soroushasadi.com)
COPY nuget.docker.config ./nuget.config
# Trust the Nexus mirror's TLS CA (new ISRG Root YR chain, not in the SDK image's
# trust store). See docker/api/Dockerfile for the full rationale.
COPY docker/nexus-mirror-ca.crt /usr/local/share/ca-certificates/nexus-mirror-ca.crt
RUN update-ca-certificates
COPY src/Meezi.Shared/Meezi.Shared.csproj src/Meezi.Shared/
COPY src/Meezi.Core/Meezi.Core.csproj src/Meezi.Core/
COPY src/Meezi.Infrastructure/Meezi.Infrastructure.csproj src/Meezi.Infrastructure/
+6
View File
@@ -8,6 +8,12 @@ COPY global.json Directory.Build.props Directory.Packages.props ./
# nuget.docker.config points to Nexus mirror (mirror.soroushasadi.com)
COPY nuget.docker.config ./nuget.config
# Trust the Nexus mirror's TLS CA: its Let's Encrypt cert renewed under the new
# ISRG Root YR, which isn't in the SDK image's trust store yet. Add the mirror's
# intermediate (CA:TRUE, valid to Sept 2028) as an anchor so dotnet restore validates.
COPY docker/nexus-mirror-ca.crt /usr/local/share/ca-certificates/nexus-mirror-ca.crt
RUN update-ca-certificates
COPY src/Meezi.Shared/Meezi.Shared.csproj src/Meezi.Shared/
COPY src/Meezi.Core/Meezi.Core.csproj src/Meezi.Core/
COPY src/Meezi.Infrastructure/Meezi.Infrastructure.csproj src/Meezi.Infrastructure/
+28
View File
@@ -0,0 +1,28 @@
-----BEGIN CERTIFICATE-----
MIIE2jCCAsKgAwIBAgIQTr0klH4k05SALYSlL9WzGTANBgkqhkiG9w0BAQsFADAu
MQswCQYDVQQGEwJVUzENMAsGA1UEChMESVNSRzEQMA4GA1UEAxMHUm9vdCBZUjAe
Fw0yNTA5MDMwMDAwMDBaFw0yODA5MDIyMzU5NTlaMDMxCzAJBgNVBAYTAlVTMRYw
FAYDVQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQDEwNZUjIwggEiMA0GCSqGSIb3
DQEBAQUAA4IBDwAwggEKAoIBAQDZ0LxwBppqh84luqMerV/eeL/fXQ7mLQQv1Lnp
WKZbyvGpx6wh6AfnslAnF6ewTkcHA+gSOoBvm3Dfm06AuGiF+KRut4fAcowqnAQQ
CW98+QPP/eOv/wug7Iyk4NkOxf2I6g2f55T6nJoOTLFcukeRq80JGQEYan+dPFr9
OGUgQK2hGKgNkW87pappsOAuUJcroYhRt5uUis4qaZireiseu32gzDJNBAiKtsvd
6HX4v25bpkRNcS/B/Gtc9kVbUpD+2PLPxdei3Tim55k4tfAEXwD2qyiPTxrTNq6l
N+AMr5g2c1dNqkOTwjxeV6L5lpP1rGiYvLnRaPlOqyZRPW+5AgMBAAGjge4wgesw
DgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUFBwMBMBIGA1UdEwEB/wQI
MAYBAf8CAQAwHQYDVR0OBBYEFEAVLSZ57TIgnt+ach3WMh+BDIEMMB8GA1UdIwQY
MBaAFN7nW2DQIm1AKH0/DQH+pLVStFGUMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEF
BQcwAoYWaHR0cDovL3lyLmkubGVuY3Iub3JnLzATBgNVHSAEDDAKMAgGBmeBDAEC
ATAnBgNVHR8EIDAeMBygGqAYhhZodHRwOi8veXIuYy5sZW5jci5vcmcvMA0GCSqG
SIb3DQEBCwUAA4ICAQB0ZUQWZ9/Yn9COEpo+JfecMnB0h0vwDm/M66IqXqw3LoaL
mx9lZvRTeDIS67PUeI3yCA2W6PKRD0/FE/G57lOmS+Xy5AaaL00ICGOqjNcCaMWW
8o8nevHOd4i4lqgtznE/28QwlcdJyF8yBiWHpnyjhEpmNWJURgOCOg2xpwRMBCsj
MScqYPtOhBeuYQvSwAEeTML2Ukh6uGuX4E14q65Ja8cdjF5bAldnP1eE4FBaAwsZ
G2fOqqrKV03Y85Nw2btedP1AtliQuJZs/Jo/gXxXdc7LrH3McgnpnbTiAncX7yES
hP6kzQejllqMCIt52HOjxDGWafS7Xw+DKwqmH+Eqy8dcbOuag/1AYlQoKNVK3F5q
Hh6tEDiMqQcLIibGKteE6iHo4A/bIScbzrhXUYuism42ZYzmc48FMVIH3qy4L84E
TdAH2gtxw0PAhvRVXp8HP7wfngpzsN/8xOTpeRSbM4+Qbc56G6+Bifmv6sk1ieQb
NA3wJdl4DDUuQSV8hBgx6zoI1ZSGORprDFux7c6rhc77QZMSRrEgomBeklervEve
86ylWmZ3WWHV6RLMi8xNvjd71r4EPIGgY7BZU/VPBkq+uA7Gb6mbJnFgV43uh3xy
LRFgxIAphIukwTGSMZZR+AI+Qnp0BYTWovHXozOf3H8r6hozEoT02JHn0AeTfA==
-----END CERTIFICATE-----
+47
View File
@@ -0,0 +1,47 @@
# Meezi uptime monitoring (Uptime Kuma)
Self-hosted uptime + TLS-expiry monitoring with alerting. Runs as a separate
compose stack so it stays up independently of app deploys.
## Stand it up (one time, on the prod host)
```bash
cd /path/to/meezi
docker compose -f docker-compose.monitoring.yml up -d
```
Then either:
- add a DNS A record `status.meezi.ir → server IP` and reload Caddy
(`docker exec meezi-caddy caddy reload` or restart the caddy stack) — the
`status.{$DOMAIN}` block is already in the Caddyfile, **or**
- reach it directly at `http://SERVER:3201` for the initial setup.
First visit creates the admin account — set a strong password.
## Monitors to add (in the Uptime Kuma UI)
Add one **HTTP(s)** monitor per public surface, interval 60s, accept 2xx/3xx:
| Name | URL | Notes |
|------|-----|-------|
| Website | https://meezi.ir/fa | marketing |
| Dashboard | https://app.meezi.ir/fa/login | merchant panel |
| API health | https://api.meezi.ir/api/public/security-config | returns JSON 200 |
| Koja | https://koja.meezi.ir/fa | public discovery |
| Admin | https://admin.meezi.ir | internal panel |
| Guest menu | https://app.meezi.ir/q/healthcheck | should be 200 (not 500) |
For each HTTPS monitor enable **"Certificate Expiry Notification"** — this
catches the recurring ~90-day Let's Encrypt cert-chain breakages early
(see the mirror-cert runbook). Set the threshold to 14 days.
## Alerts
Settings → Notifications → add a channel (Telegram bot or email/SMTP), then
attach it to every monitor. Telegram is simplest: create a bot via @BotFather,
get the chat id, paste both into Uptime Kuma.
## What this does NOT replace
- **Backups** — see `scripts/backup/RESTORE.md`.
- **Crash auto-recovery** — Docker `restart: unless-stopped` already restarts
crashed containers; Uptime Kuma tells you when one is flapping or down.
## Status page (optional)
Uptime Kuma can publish a public status page (Settings → Status Pages) at
`status.meezi.ir/status/meezi` if you want customers to see uptime.
+45
View File
@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
+33
View File
@@ -0,0 +1,33 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "f6ff1529fd6d8af5f706051d9251ac9231c83407"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
- platform: android
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
- platform: web
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+53
View File
@@ -0,0 +1,53 @@
# Building meezi_app from Iran (sanctions mirrors)
`pub.dev`, Google's package storage, and Google's Android maven2 artifacts are
sanctions-filtered from Iranian IPs (403 / 404). Use the reachable mirrors below.
## 1. Environment (set once, persistently)
```powershell
setx PUB_HOSTED_URL "https://pub.flutter-io.cn"
setx FLUTTER_STORAGE_BASE_URL "https://storage.flutter-io.cn"
```
These make `flutter pub get`, `flutter create`, and engine/artifact downloads work.
**Web already builds** with just these (`flutter build web`).
## 2. Android — Maven/Gradle mirror
Google's Android maven2 (AGP, androidx, etc.) 404s here, so:
- `android/settings.gradle.kts` and `android/build.gradle.kts` already point their
repositories at the Aliyun mirrors (committed).
- Flutter's **included** `flutter_tools/gradle` build has its own repositories, so add a
global Gradle init script. Put this at `%GRADLE_USER_HOME%/init.gradle`
(e.g. `C:\gradlecache\init.gradle`, then build with `GRADLE_USER_HOME=C:\gradlecache`):
```gradle
def aliyun = [
'https://maven.aliyun.com/repository/gradle-plugin',
'https://maven.aliyun.com/repository/google',
'https://maven.aliyun.com/repository/central',
]
beforeSettings { settings ->
settings.pluginManagement { repositories { aliyun.each { u -> maven { url u } } } }
if (settings.rootDir.path.replace('\\', '/').contains('flutter_tools')) {
settings.dependencyResolutionManagement { repositories { aliyun.each { u -> maven { url u } } } }
}
}
```
## 3. Build
```powershell
$env:GRADLE_USER_HOME = "C:\gradlecache" # keep the cache on a drive with space
cd mobile/meezi_app
flutter build apk --debug
```
## Status
-`flutter build web` — works.
- ✅ Android dependency resolution — works via the Aliyun mirrors (verified).
- ⛔ APK build currently blocked only by **disk space** (needs a few GB free for the
Gradle cache + build output). Free space (the large Docker WSL vhdx on C: is the
obvious reclaim), then `flutter build apk` completes.
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
@@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "ir.meezi.meezi_app"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "ir.meezi.meezi_app"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="meezi_app"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package ir.meezi.meezi_app
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+27
View File
@@ -0,0 +1,27 @@
allprojects {
repositories {
// Iran: prefer reachable Aliyun mirrors (Google Android maven2 is filtered here).
maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/central") }
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
@@ -0,0 +1,31 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
// Iran: Google's Android maven2 artifacts 404 here (sanctions-filtered), so
// resolve through the reachable Aliyun mirrors first; keep the originals as fallback.
maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") }
maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/central") }
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
/// Meezi brand palette. Green #0F6E56 matches the dashboard / Koja web.
class MeeziColors {
static const Color brand = Color(0xFF0F6E56);
static const Color brandDark = Color(0xFF0B5544);
static const Color accent = Color(0xFFE1F5EE);
static const Color surface = Color(0xFFF9FAFB);
}
/// Centralized Meezi theme. Uses Vazirmatn when the font is bundled (see pubspec);
/// falls back to the platform font otherwise. Kept to stable Material 3 APIs.
class MeeziTheme {
static ThemeData light() {
final scheme = ColorScheme.fromSeed(
seedColor: MeeziColors.brand,
primary: MeeziColors.brand,
brightness: Brightness.light,
);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
fontFamily: 'Vazirmatn',
scaffoldBackgroundColor: MeeziColors.surface,
appBarTheme: const AppBarTheme(
elevation: 0,
centerTitle: true,
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: MeeziColors.brand,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 18),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: MeeziColors.brand,
side: const BorderSide(color: MeeziColors.brand),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.black.withValues(alpha: 0.10)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.black.withValues(alpha: 0.10)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: MeeziColors.brand, width: 1.5),
),
),
);
}
static ThemeData dark() {
final scheme = ColorScheme.fromSeed(
seedColor: MeeziColors.brand,
brightness: Brightness.dark,
);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
fontFamily: 'Vazirmatn',
);
}
}
@@ -93,11 +93,64 @@ class _CafeDetailScreenState extends ConsumerState<CafeDetailScreen> {
final description = cafe['description'] as String?;
final address = cafe['address'] as String?;
final city = cafe['city'] as String?;
// Defensive parsing — public DTO key names may vary.
final cover = (cafe['coverImageUrl'] ?? cafe['coverUrl'] ?? cafe['cover']) as String?;
final isOpen = cafe['isOpenNow'] as bool?;
final gallery = (cafe['galleryUrls'] ?? cafe['gallery']) is List
? ((cafe['galleryUrls'] ?? cafe['gallery']) as List)
.map((e) => e.toString())
.where((e) => e.isNotEmpty)
.toList()
: <String>[];
// WorkingHoursPublicDto: a day-keyed object {sat..fri}, each {isOpen,open,close}.
final hours = cafe['workingHours'] is Map
? (cafe['workingHours'] as Map)
: const <dynamic, dynamic>{};
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(name, style: Theme.of(context).textTheme.headlineSmall),
if (cover != null && cover.isNotEmpty) ...[
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Image.network(
cover,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Container(color: Colors.black12),
),
),
),
const SizedBox(height: 12),
],
Row(
children: [
Expanded(
child: Text(name,
style: Theme.of(context).textTheme.headlineSmall),
),
if (isOpen != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: (isOpen ? Colors.green : Colors.red)
.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(20),
),
child: Text(
isOpen ? 'باز است' : 'بسته است',
style: TextStyle(
color: isOpen ? Colors.green[800] : Colors.red[800],
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
@@ -117,6 +170,59 @@ class _CafeDetailScreenState extends ConsumerState<CafeDetailScreen> {
const SizedBox(height: 12),
Text(description),
],
if (gallery.isNotEmpty) ...[
const SizedBox(height: 12),
SizedBox(
height: 110,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: gallery.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, i) => ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
gallery[i],
width: 150,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Container(width: 150, color: Colors.black12),
),
),
),
),
],
if (hours.isNotEmpty) ...[
const SizedBox(height: 16),
Text('ساعات کاری',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
...const [
('sat', 'شنبه'),
('sun', 'یکشنبه'),
('mon', 'دوشنبه'),
('tue', 'سه‌شنبه'),
('wed', 'چهارشنبه'),
('thu', 'پنجشنبه'),
('fri', 'جمعه'),
].map((d) {
final m = hours[d.$1] is Map
? hours[d.$1] as Map
: const <dynamic, dynamic>{};
final open = (m['open'] ?? '').toString();
final close = (m['close'] ?? '').toString();
final isOpen = m['isOpen'] == true && open.isNotEmpty;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(d.$2),
Text(isOpen ? '$open - $close' : 'تعطیل'),
],
),
);
}),
],
const SizedBox(height: 16),
Row(
children: [
@@ -4,22 +4,91 @@ import 'package:go_router/go_router.dart';
import '../cart/cart_state.dart';
typedef DiscoverFilters = ({String? q, double? minRating, String sort});
/// Discovery filters. A class (not a record) so the many optional filters can be
/// changed one at a time via copyWith without re-listing every field.
class DiscoverFilters {
const DiscoverFilters({
this.q,
this.minRating,
this.sort = 'rating',
this.openNow = false,
this.priceTier,
this.themes = const [],
this.vibes = const [],
this.occasions = const [],
this.spaceFeatures = const [],
});
final discoverFiltersProvider = StateProvider<DiscoverFilters>(
(_) => (q: null, minRating: null, sort: 'rating'),
);
final String? q;
final double? minRating;
final String sort;
final bool openNow;
final String? priceTier;
final List<String> themes;
final List<String> vibes;
final List<String> occasions;
final List<String> spaceFeatures;
final discoverProvider = FutureProvider.autoDispose<List<Map<String, dynamic>>>((ref) {
final filters = ref.watch(discoverFiltersProvider);
int get activeCount =>
(minRating != null ? 1 : 0) +
(openNow ? 1 : 0) +
(priceTier != null ? 1 : 0) +
themes.length +
vibes.length +
occasions.length +
spaceFeatures.length;
DiscoverFilters copyWith({
ValueGetter<String?>? q,
ValueGetter<double?>? minRating,
String? sort,
bool? openNow,
ValueGetter<String?>? priceTier,
List<String>? themes,
List<String>? vibes,
List<String>? occasions,
List<String>? spaceFeatures,
}) {
return DiscoverFilters(
q: q != null ? q() : this.q,
minRating: minRating != null ? minRating() : this.minRating,
sort: sort ?? this.sort,
openNow: openNow ?? this.openNow,
priceTier: priceTier != null ? priceTier() : this.priceTier,
themes: themes ?? this.themes,
vibes: vibes ?? this.vibes,
occasions: occasions ?? this.occasions,
spaceFeatures: spaceFeatures ?? this.spaceFeatures,
);
}
}
final discoverFiltersProvider =
StateProvider<DiscoverFilters>((_) => const DiscoverFilters());
final discoverProvider =
FutureProvider.autoDispose<List<Map<String, dynamic>>>((ref) {
final f = ref.watch(discoverFiltersProvider);
return ref.watch(publicApiProvider).discover(
city: 'تهران',
q: filters.q,
minRating: filters.minRating,
sort: filters.sort,
q: f.q,
minRating: f.minRating,
sort: f.sort,
openNow: f.openNow,
priceTier: f.priceTier,
themes: f.themes,
vibes: f.vibes,
occasions: f.occasions,
spaceFeatures: f.spaceFeatures,
);
});
/// Available themes/vibes/occasions/spaceFeatures for the filter sheet.
final discoverTaxonomyProvider =
FutureProvider.autoDispose<Map<String, dynamic>?>((ref) {
return ref.watch(publicApiProvider).discoverTaxonomy();
});
class DiscoverScreen extends ConsumerStatefulWidget {
const DiscoverScreen({super.key});
@@ -39,10 +108,19 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
void _applySearch() {
final q = _searchController.text.trim();
ref.read(discoverFiltersProvider.notifier).update(
(s) => (q: q.isEmpty ? null : q, minRating: s.minRating, sort: s.sort),
(s) => s.copyWith(q: () => q.isEmpty ? null : q),
);
}
Future<void> _openFilters() async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => const _DiscoverFilterSheet(),
);
}
@override
Widget build(BuildContext context) {
final cafesAsync = ref.watch(discoverProvider);
@@ -70,17 +148,27 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'جستجوی نام کافه...',
border: const OutlineInputBorder(),
hintText: 'کافه دنج برای کار، نزدیک من...',
isDense: true,
prefixIcon: const Icon(Icons.search),
suffixIcon: IconButton(
icon: const Icon(Icons.search),
icon: const Icon(Icons.arrow_back),
onPressed: _applySearch,
),
),
textInputAction: TextInputAction.search,
onSubmitted: (_) => _applySearch(),
),
),
const SizedBox(width: 8),
Badge(
isLabelVisible: filters.activeCount > 0,
label: Text('${filters.activeCount}'),
child: IconButton.filledTonal(
icon: const Icon(Icons.tune),
onPressed: _openFilters,
),
),
],
),
),
@@ -90,26 +178,29 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
child: Row(
children: [
FilterChip(
label: const Text('همه'),
selected: filters.minRating == null,
onSelected: (_) {
ref.read(discoverFiltersProvider.notifier).update(
(s) => (q: s.q, minRating: null, sort: s.sort),
);
},
label: const Text('باز است'),
selected: filters.openNow,
onSelected: (v) => ref
.read(discoverFiltersProvider.notifier)
.update((s) => s.copyWith(openNow: v)),
),
const SizedBox(width: 8),
FilterChip(
label: const Text('همه امتیازها'),
selected: filters.minRating == null,
onSelected: (_) => ref
.read(discoverFiltersProvider.notifier)
.update((s) => s.copyWith(minRating: () => null)),
),
for (final min in [3.0, 4.0, 4.5])
Padding(
padding: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: Text('$min+'),
selected: filters.minRating == min,
onSelected: (_) {
ref.read(discoverFiltersProvider.notifier).update(
(s) => (q: s.q, minRating: min, sort: s.sort),
);
},
onSelected: (_) => ref
.read(discoverFiltersProvider.notifier)
.update((s) => s.copyWith(minRating: () => min)),
),
),
],
@@ -118,10 +209,9 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: DropdownButtonFormField<String>(
value: filters.sort,
initialValue: filters.sort,
decoration: const InputDecoration(
labelText: 'مرتب‌سازی',
border: OutlineInputBorder(),
isDense: true,
),
items: const [
@@ -131,9 +221,9 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
],
onChanged: (sort) {
if (sort == null) return;
ref.read(discoverFiltersProvider.notifier).update(
(s) => (q: s.q, minRating: s.minRating, sort: sort),
);
ref
.read(discoverFiltersProvider.notifier)
.update((s) => s.copyWith(sort: sort));
},
),
),
@@ -143,36 +233,19 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
if (cafes.isEmpty) {
return const Center(child: Text('کافه‌ای یافت نشد'));
}
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: cafes.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final cafe = cafes[index];
final slug = cafe['slug'] as String;
final name = cafe['name'] as String? ?? slug;
final avg = (cafe['averageRating'] as num?)?.toDouble() ?? 0;
final count = cafe['reviewCount'] as int? ?? 0;
final address = cafe['address'] as String?;
return Card(
child: ListTile(
title: Text(name),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(cafe['city'] as String? ?? ''),
if (address != null && address.isNotEmpty) Text(address),
Text('${avg.toStringAsFixed(1)} · $count نظر'),
],
),
trailing: const Icon(Icons.chevron_left),
onTap: () => context.push('/cafe/$slug'),
),
);
},
return RefreshIndicator(
onRefresh: () async => ref.refresh(discoverProvider.future),
child: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: cafes.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) =>
_CafeCard(cafe: cafes[index]),
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('خطا: $e')),
),
),
@@ -182,3 +255,205 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
);
}
}
class _CafeCard extends StatelessWidget {
const _CafeCard({required this.cafe});
final Map<String, dynamic> cafe;
@override
Widget build(BuildContext context) {
final slug = cafe['slug'] as String;
final name = cafe['name'] as String? ?? slug;
final avg = (cafe['averageRating'] as num?)?.toDouble() ?? 0;
final count = cafe['reviewCount'] as int? ?? 0;
final address = cafe['address'] as String?;
final isOpen = cafe['isOpenNow'] as bool?;
return Card(
child: ListTile(
title: Row(
children: [
Expanded(child: Text(name)),
if (isOpen != null)
Text(
isOpen ? 'باز' : 'بسته',
style: TextStyle(
fontSize: 12,
color: isOpen ? Colors.green : Colors.red,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(cafe['city'] as String? ?? ''),
if (address != null && address.isNotEmpty) Text(address),
Text('${avg.toStringAsFixed(1)} · $count نظر'),
],
),
trailing: const Icon(Icons.chevron_left),
onTap: () => context.push('/cafe/$slug'),
),
);
}
}
class _DiscoverFilterSheet extends ConsumerWidget {
const _DiscoverFilterSheet();
static const _priceTiers = [
('budget', 'اقتصادی'),
('moderate', 'متوسط'),
('upscale', 'لاکچری'),
('luxury', 'بسیار لاکچری'),
];
List<({String key, String label})> _parseTax(dynamic raw) {
if (raw is! List) return const [];
return raw
.map<({String key, String label})>((e) {
if (e is Map) {
final k = (e['key'] ?? e['value'] ?? e['id'] ?? '').toString();
final l = (e['labelFa'] ?? e['label'] ?? e['nameFa'] ?? e['name'] ?? k)
.toString();
return (key: k, label: l);
}
final s = e.toString();
return (key: s, label: s);
})
.where((t) => t.key.isNotEmpty)
.toList();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final filters = ref.watch(discoverFiltersProvider);
final taxonomy = ref.watch(discoverTaxonomyProvider);
final notifier = ref.read(discoverFiltersProvider.notifier);
Widget chips(String title, List<({String key, String label})> items,
List<String> selected, void Function(List<String>) onChange) {
if (items.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(0, 12, 0, 6),
child: Text(title, style: Theme.of(context).textTheme.titleSmall),
),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final it in items)
FilterChip(
label: Text(it.label),
selected: selected.contains(it.key),
onSelected: (v) {
final next = List<String>.from(selected);
if (v) {
next.add(it.key);
} else {
next.remove(it.key);
}
onChange(next);
},
),
],
),
],
);
}
return Directionality(
textDirection: TextDirection.rtl,
child: Padding(
padding: EdgeInsets.fromLTRB(
16,
0,
16,
16 + MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('فیلترها',
style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
if (filters.activeCount > 0)
TextButton(
onPressed: () =>
notifier.state = const DiscoverFilters(),
child: const Text('پاک کردن'),
),
],
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('فقط کافه‌های باز'),
value: filters.openNow,
onChanged: (v) => notifier.update((s) => s.copyWith(openNow: v)),
),
const Padding(
padding: EdgeInsets.fromLTRB(0, 8, 0, 6),
child: Text('محدوده قیمت'),
),
Wrap(
spacing: 8,
children: [
for (final p in _priceTiers)
ChoiceChip(
label: Text(p.$2),
selected: filters.priceTier == p.$1,
onSelected: (v) => notifier.update(
(s) => s.copyWith(priceTier: () => v ? p.$1 : null),
),
),
],
),
taxonomy.when(
data: (tax) {
if (tax == null) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
chips('فضا و حال‌وهوا', _parseTax(tax['themes']),
filters.themes,
(v) => notifier.update((s) => s.copyWith(themes: v))),
chips('وایب', _parseTax(tax['vibes']), filters.vibes,
(v) => notifier.update((s) => s.copyWith(vibes: v))),
chips('مناسبت', _parseTax(tax['occasions']),
filters.occasions,
(v) => notifier.update((s) => s.copyWith(occasions: v))),
chips('امکانات', _parseTax(tax['spaceFeatures']),
filters.spaceFeatures,
(v) => notifier.update(
(s) => s.copyWith(spaceFeatures: v))),
],
);
},
loading: () => const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
),
error: (_, __) => const SizedBox.shrink(),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('نمایش نتایج'),
),
),
],
),
),
),
);
}
}
@@ -85,7 +85,7 @@ class _AttendanceScreenState extends ConsumerState<AttendanceScreen> {
padding: const EdgeInsets.all(16),
children: [
Text(
'امروز: ${todayJalali.formatter.yyyyMMdd()}',
'امروز: ${todayJalali.year}/${todayJalali.month.toString().padLeft(2, '0')}/${todayJalali.day.toString().padLeft(2, '0')}',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
@@ -10,12 +10,28 @@ class PublicApi {
String? q,
double? minRating,
String? sort,
List<String>? themes,
List<String>? vibes,
List<String>? occasions,
List<String>? spaceFeatures,
String? noise,
String? priceTier,
String? size,
bool openNow = false,
}) async {
final params = <String, String>{};
if (city != null && city.isNotEmpty) params['city'] = city;
if (q != null && q.isNotEmpty) params['q'] = q;
if (minRating != null) params['minRating'] = minRating.toString();
if (sort != null && sort.isNotEmpty) params['sort'] = sort;
if (themes != null && themes.isNotEmpty) params['themes'] = themes.join(',');
if (vibes != null && vibes.isNotEmpty) params['vibes'] = vibes.join(',');
if (occasions != null && occasions.isNotEmpty) params['occasions'] = occasions.join(',');
if (spaceFeatures != null && spaceFeatures.isNotEmpty) params['spaceFeatures'] = spaceFeatures.join(',');
if (noise != null && noise.isNotEmpty) params['noise'] = noise;
if (priceTier != null && priceTier.isNotEmpty) params['priceTier'] = priceTier;
if (size != null && size.isNotEmpty) params['size'] = size;
if (openNow) params['openNow'] = 'true';
final res = await _client.dio.get<Map<String, dynamic>>(
'/api/public/discover',
queryParameters: params.isEmpty ? null : params,
@@ -24,6 +40,43 @@ class PublicApi {
return list.cast<Map<String, dynamic>>();
}
/// Cafés near a coordinate, sorted by distance (for "near me").
Future<List<Map<String, dynamic>>> discoverNearby({
required double lat,
required double lng,
String? excludeSlug,
int limit = 12,
}) async {
final res = await _client.dio.get<Map<String, dynamic>>(
'/api/public/discover/near',
queryParameters: {
'lat': lat,
'lng': lng,
if (excludeSlug != null && excludeSlug.isNotEmpty) 'excludeSlug': excludeSlug,
'limit': limit,
},
);
final list = res.data?['data'] as List<dynamic>? ?? [];
return list.cast<Map<String, dynamic>>();
}
/// Parse a free-text query into structured discovery hints (themes/vibes/...).
Future<Map<String, dynamic>?> nlpParse(String q) async {
final res = await _client.dio.get<Map<String, dynamic>>(
'/api/public/discover/nlp-parse',
queryParameters: {'q': q},
);
return res.data?['data'] as Map<String, dynamic>?;
}
/// The discovery taxonomy (available themes, vibes, occasions, space features).
Future<Map<String, dynamic>?> discoverTaxonomy() async {
final res = await _client.dio.get<Map<String, dynamic>>(
'/api/public/discover-profile/taxonomy',
);
return res.data?['data'] as Map<String, dynamic>?;
}
Future<List<Map<String, dynamic>>> getReviews(String slug, {int page = 1}) async {
final res = await _client.dio.get<Map<String, dynamic>>(
'/api/public/cafes/$slug/reviews',
@@ -39,9 +39,6 @@ class _QrScanScreenState extends ConsumerState<QrScanScreen> {
tableNumber: tableNumber ?? '',
cafeSlug: slug,
);
if (tableId != null) {
ref.read(cartProvider.notifier).setTable(tableId);
}
context.push(
'/cafe/$slug/menu?tableId=$tableId&tableNumber=$tableNumber',
);
+4 -4
View File
@@ -3,6 +3,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app/router.dart';
import 'core/theme/app_theme.dart';
void main() {
runApp(const ProviderScope(child: MeeziApp()));
@@ -22,10 +23,9 @@ class MeeziApp extends StatelessWidget {
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6B4F3A)),
useMaterial3: true,
),
theme: MeeziTheme.light(),
darkTheme: MeeziTheme.dark(),
themeMode: ThemeMode.light,
routerConfig: appRouter,
);
}
+639
View File
@@ -0,0 +1,639 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.0"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.1"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.7"
dio:
dependency: "direct main"
description:
name: dio
sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.9.2"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.2"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_riverpod:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.6.1"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
url: "https://pub.flutter-io.cn"
source: hosted
version: "9.2.4"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.3"
flutter_secure_storage_macos:
dependency: transitive
description:
name: flutter_secure_storage_macos
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.3"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.2"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.2"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
go_router:
dependency: "direct main"
description:
name: go_router
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
url: "https://pub.flutter-io.cn"
source: hosted
version: "14.8.1"
hooks:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.2"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.1.2"
intl:
dependency: "direct main"
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.20.2"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.1"
js:
dependency: transitive
description:
name: js
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.6.7"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.flutter-io.cn"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.0.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.17.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.0"
mobile_scanner:
dependency: "direct main"
description:
name: mobile_scanner
sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.2.3"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
url: "https://pub.flutter-io.cn"
source: hosted
version: "9.4.1"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.8"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.6.0"
riverpod:
dependency: transitive
description:
name: riverpod
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.6.1"
shamsi_date:
dependency: "direct main"
description:
name: shamsi_date
sha256: "0383fddc9bce91e9e08de0c909faf93c3ab3a0e532abd271fb0dcf5d0617487b"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.23"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.12.1"
state_notifier:
dependency: transitive
description:
name: state_notifier
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.0"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.7"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.0"
uuid:
dependency: "direct main"
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.5.3"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.flutter-io.cn"
source: hosted
version: "15.2.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.15.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.10.3 <4.0.0"
flutter: ">=3.38.4"
+1 -1
View File
@@ -18,7 +18,7 @@ dependencies:
shamsi_date: ^1.1.1
flutter_secure_storage: ^9.2.2
shared_preferences: ^2.3.2
intl: ^0.19.0
intl: ^0.20.2
uuid: ^4.4.2
mobile_scanner: ^5.2.3
+12
View File
@@ -0,0 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meezi_app/main.dart';
void main() {
testWidgets('App builds without throwing', (WidgetTester tester) async {
await tester.pumpWidget(const ProviderScope(child: MeeziApp()));
expect(find.byType(MaterialApp), findsOneWidget);
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="meezi_app">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>meezi_app</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
{
"name": "meezi_app",
"short_name": "meezi_app",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "A new Flutter project.",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/Icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+55
View File
@@ -0,0 +1,55 @@
# Meezi database backup & restore
## How backups work
The `meezi-backup` container (in `docker-compose.yml`) runs a nightly `pg_dump`
of the whole `meezi` database at **02:00 Asia/Tehran**, gzips it, and keeps the
**last 14 days** in the host `./backups` directory (override with `BACKUP_DIR`).
Filenames: `meezi_YYYYMMDD_HHMMSS.sql.gz`. One backup is also taken immediately
when the container first starts.
Check it's running / list backups:
```bash
docker logs meezi-backup --tail 20
ls -lh ./backups
```
## ⚠️ Copy backups OFF the server
The bind-mounted `./backups` survives a container/volume wipe, but **not a disk
failure**. Add an off-box copy (run from the host via cron), e.g.:
```bash
# rsync to another host nightly at 03:00
0 3 * * * rsync -az --delete /path/to/meezi/backups/ user@backup-host:/srv/meezi-backups/
```
or `rclone copy ./backups remote:meezi-backups` to object storage.
## Restore
1. Pick a dump:
```bash
ls -lh ./backups # choose e.g. meezi_20260615_020000.sql.gz
```
2. (Recommended) stop the API so nothing writes mid-restore:
```bash
docker stop meezi-api
```
3. Restore into the running Postgres container:
```bash
gunzip -c ./backups/meezi_20260615_020000.sql.gz \
| docker exec -i meezi-db psql -U meezi -d meezi
```
For a clean restore into an empty DB, drop & recreate first:
```bash
docker exec -i meezi-db psql -U meezi -d postgres -c "DROP DATABASE meezi;"
docker exec -i meezi-db psql -U meezi -d postgres -c "CREATE DATABASE meezi OWNER meezi;"
gunzip -c ./backups/<dump>.sql.gz | docker exec -i meezi-db psql -U meezi -d meezi
```
4. Start the API again (it runs EF migrations on boot, which is a no-op if the
dump is current):
```bash
docker start meezi-api
```
## Manual one-off backup
```bash
docker exec meezi-db pg_dump -U meezi --no-owner --no-privileges meezi \
| gzip -9 > ./backups/meezi_manual_$(date +%Y%m%d_%H%M%S).sql.gz
```
+63
View File
@@ -0,0 +1,63 @@
#!/bin/sh
# Nightly Postgres backup loop for Meezi.
#
# Runs inside a small postgres-image container (has pg_dump/gzip). Every day at
# ~02:00 Tehran it dumps the whole database, gzips it, and keeps the last
# RETAIN_DAYS files in /backups. Designed to be dead-simple and dependency-free:
# no cron daemon, just sleep-until-next-run so it survives container restarts.
#
# Env:
# PGHOST, PGUSER, PGPASSWORD, PGDATABASE — connection (from compose)
# RETAIN_DAYS — how many daily dumps to keep (default 14)
# BACKUP_HOUR — local hour to run (default 2 = 02:00)
set -eu
RETAIN_DAYS="${RETAIN_DAYS:-14}"
BACKUP_HOUR="${BACKUP_HOUR:-2}"
OUT_DIR=/backups
export TZ="${TZ:-Asia/Tehran}"
log() { echo "[pg-backup $(date '+%Y-%m-%d %H:%M:%S %Z')] $*"; }
run_backup() {
ts=$(date '+%Y%m%d_%H%M%S')
tmp="$OUT_DIR/.meezi_${ts}.sql.gz.partial"
final="$OUT_DIR/meezi_${ts}.sql.gz"
log "starting dump → $final"
# pg_dump streams to gzip; .partial then atomic rename so a crash never
# leaves a truncated file that looks like a good backup.
if pg_dump --no-owner --no-privileges | gzip -9 > "$tmp"; then
mv "$tmp" "$final"
size=$(wc -c < "$final" 2>/dev/null || echo '?')
log "done ($size bytes)"
else
rm -f "$tmp"
log "ERROR: dump failed"
return 1
fi
# Rotate: delete dumps older than RETAIN_DAYS days.
find "$OUT_DIR" -maxdepth 1 -name 'meezi_*.sql.gz' -mtime "+${RETAIN_DAYS}" -print -delete | while read -r f; do
log "rotated out $f"
done
}
seconds_until_next_run() {
now_h=$(date '+%-H'); now_m=$(date '+%-M'); now_s=$(date '+%-S')
now=$(( now_h * 3600 + now_m * 60 + now_s ))
target=$(( BACKUP_HOUR * 3600 ))
if [ "$now" -lt "$target" ]; then
echo $(( target - now ))
else
echo $(( 86400 - now + target ))
fi
}
log "backup loop started (retain ${RETAIN_DAYS}d, daily at ${BACKUP_HOUR}:00 ${TZ})"
# Take one backup immediately on first boot so we never sit a full day with none.
run_backup || true
while true; do
wait_s=$(seconds_until_next_run)
log "next backup in ${wait_s}s"
sleep "$wait_s"
run_backup || true
done
+24 -2
View File
@@ -58,6 +58,23 @@ public class AuthController : ControllerBase
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
}
[HttpPost("login-key")]
[EnableRateLimiting("auth-otp")]
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
public async Task<IActionResult> LoginWithRecoveryKey(
[FromBody] LoginWithRecoveryKeyRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Key))
return BadRequest(ValidationError("Recovery key is required."));
var (success, data, code, message) = await _authService.LoginWithRecoveryKeyAsync(request, cancellationToken);
if (!success)
return ErrorResult(code!, message!);
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
}
[HttpPost("send-otp")]
[EnableRateLimiting("auth-otp")]
[ProducesResponseType(typeof(ApiResponse<SendOtpResponse>), StatusCodes.Status200OK)]
@@ -198,7 +215,10 @@ public class AuthController : ControllerBase
ExpiresAt: expiresAt,
UserId: userId,
CafeId: User.FindFirstValue(MeeziClaimTypes.CafeId) ?? string.Empty,
Role: User.FindFirstValue(MeeziClaimTypes.Role) ?? string.Empty,
// .NET remaps the short "role" claim to ClaimTypes.Role on inbound; read both.
Role: User.FindFirstValue(MeeziClaimTypes.Role)
?? User.FindFirstValue(System.Security.Claims.ClaimTypes.Role)
?? string.Empty,
PlanTier: User.FindFirstValue(MeeziClaimTypes.PlanTier) ?? string.Empty,
Language: User.FindFirstValue(MeeziClaimTypes.Language) ?? string.Empty,
Actor: User.FindFirstValue(MeeziClaimTypes.Actor) ?? MeeziActorKinds.Merchant,
@@ -221,7 +241,9 @@ public class AuthController : ControllerBase
"RATE_LIMITED" => StatusCode(StatusCodes.Status429TooManyRequests,
new ApiResponse<object>(false, null, new ApiError(code, message))),
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message))),
"INVALID_OTP" or "INVALID_TOKEN" => Unauthorized(new ApiResponse<object>(false, null, new ApiError(code, message))),
"INVALID_OTP" or "INVALID_TOKEN" or "INVALID_KEY" => Unauthorized(new ApiResponse<object>(false, null, new ApiError(code, message))),
"CAFE_SUSPENDED" or "NO_OWNER" => StatusCode(StatusCodes.Status403Forbidden,
new ApiResponse<object>(false, null, new ApiError(code, message))),
"BRANCH_FORBIDDEN" => StatusCode(StatusCodes.Status403Forbidden,
new ApiResponse<object>(false, null, new ApiError(code, message))),
"ALREADY_REGISTERED" => Conflict(new ApiResponse<object>(false, null, new ApiError(code, message))),
@@ -103,4 +103,25 @@ public class BillingController : ControllerBase
return Ok(new ApiResponse<BillingStatusDto>(true, data));
}
[Authorize]
[HttpDelete("api/billing/queued/{paymentId}")]
public async Task<IActionResult> CancelQueued(string paymentId, ITenantContext tenant, CancellationToken ct)
{
if (string.IsNullOrEmpty(tenant.CafeId))
return Unauthorized();
if (tenant.Role != Core.Enums.EmployeeRole.Owner)
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("OWNER_REQUIRED", "Only the cafe owner can manage subscription billing.")));
var (ok, code, message) = await _billing.CancelQueuedAsync(tenant.CafeId, paymentId, ct);
if (!ok)
{
return code == "NOT_FOUND"
? NotFound(new ApiResponse<object>(false, null, new ApiError(code, message ?? "Not found.")))
: BadRequest(new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Failed.")));
}
return Ok(new ApiResponse<object>(true, new { id = paymentId }));
}
}
@@ -44,9 +44,14 @@ public abstract class CafeApiControllerBase : ControllerBase
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)
{
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))
return null;
return Forbidden("FORBIDDEN", "You do not have permission to perform this action.");
@@ -36,4 +36,13 @@ public class CafePlatformController : CafeApiControllerBase
var plans = await _catalog.GetPlansAsync(cancellationToken);
return Ok(new ApiResponse<object>(true, plans));
}
/// <summary>Feature catalog (key → display name / module group) so clients can
/// label the FeatureKeys returned by the plans endpoint.</summary>
[HttpGet("features-catalog")]
public async Task<IActionResult> GetFeaturesCatalog(CancellationToken cancellationToken)
{
var features = await _catalog.GetFeaturesAsync(cancellationToken);
return Ok(new ApiResponse<object>(true, features));
}
}
@@ -57,7 +57,8 @@ public class CafePublicProfileController : CafeApiControllerBase
gallery,
cafe.InstagramHandle,
cafe.WebsiteUrl,
ToHoursDto(hours))));
ToHoursDto(hours),
cafe.ShowOnKoja)));
}
// ── PUT (description / social / hours) ───────────────────────────────────
@@ -91,6 +92,10 @@ public class CafePublicProfileController : CafeApiControllerBase
if (request.WorkingHours is not null)
cafe.WorkingHoursJson = JsonSerializer.Serialize(ToHoursSchedule(request.WorkingHours), _jsonOpts);
// Koja (public discovery) listing preference
if (request.ShowOnKoja.HasValue)
cafe.ShowOnKoja = request.ShowOnKoja.Value;
await _db.SaveChangesAsync(ct);
var gallery = Deserialize<List<string>>(cafe.GalleryJson) ?? [];
@@ -101,7 +106,8 @@ public class CafePublicProfileController : CafeApiControllerBase
gallery,
cafe.InstagramHandle,
cafe.WebsiteUrl,
ToHoursDto(hours))));
ToHoursDto(hours),
cafe.ShowOnKoja)));
}
// ── POST gallery/upload ───────────────────────────────────────────────────
@@ -207,13 +213,15 @@ public record UpdateCafePublicProfileRequest(
string? Description,
string? InstagramHandle,
string? WebsiteUrl,
WorkingHoursPublicDto? WorkingHours);
WorkingHoursPublicDto? WorkingHours,
bool? ShowOnKoja = null);
public record CafeProfileEditDto(
string? Description,
IReadOnlyList<string> GalleryUrls,
string? InstagramHandle,
string? WebsiteUrl,
WorkingHoursPublicDto? WorkingHours);
WorkingHoursPublicDto? WorkingHours,
bool ShowOnKoja);
public record GalleryDto(IReadOnlyList<string> GalleryUrls);
@@ -2,7 +2,9 @@ using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Public;
using Meezi.API.Services;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Services.Platform;
using Meezi.Shared;
namespace Meezi.API.Controllers;
@@ -12,11 +14,16 @@ public class CafeReviewsController : CafeApiControllerBase
{
private readonly IReviewService _reviews;
private readonly IValidator<ReplyCafeReviewRequest> _replyValidator;
private readonly IPlatformCatalogService _catalog;
public CafeReviewsController(IReviewService reviews, IValidator<ReplyCafeReviewRequest> replyValidator)
public CafeReviewsController(
IReviewService reviews,
IValidator<ReplyCafeReviewRequest> replyValidator,
IPlatformCatalogService catalog)
{
_reviews = reviews;
_replyValidator = replyValidator;
_catalog = catalog;
}
[HttpGet]
@@ -41,6 +48,13 @@ public class CafeReviewsController : CafeApiControllerBase
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
// Replying to reviews is a paid feature (Starter+).
var tier = tenant.PlanTier ?? PlanTier.Free;
if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, tier, "review_reply", ct))
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("PLAN_FEATURE_DISABLED", "Replying to reviews is not included in your plan. Please upgrade.")));
var validation = await _replyValidator.ValidateAsync(request, ct);
if (!validation.IsValid)
{
@@ -3,10 +3,12 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Cafes;
using Meezi.API.Services;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Core.Utilities;
using Meezi.Infrastructure.Branding;
using Meezi.Infrastructure.Data;
using Meezi.Infrastructure.Services.Platform;
using Meezi.Shared;
namespace Meezi.API.Controllers;
@@ -16,11 +18,16 @@ public class CafeSettingsController : CafeApiControllerBase
{
private readonly AppDbContext _db;
private readonly IValidator<PatchCafeSettingsRequest> _validator;
private readonly IPlatformCatalogService _catalog;
public CafeSettingsController(AppDbContext db, IValidator<PatchCafeSettingsRequest> validator)
public CafeSettingsController(
AppDbContext db,
IValidator<PatchCafeSettingsRequest> validator,
IPlatformCatalogService catalog)
{
_db = db;
_validator = validator;
_catalog = catalog;
}
[HttpGet]
@@ -81,7 +88,19 @@ public class CafeSettingsController : CafeApiControllerBase
if (request.CoverImageUrl is not null) cafe.CoverImageUrl = request.CoverImageUrl.Trim();
if (request.SnappfoodVendorId is not null) cafe.SnappfoodVendorId = request.SnappfoodVendorId.Trim();
if (request.Theme is not null)
cafe.ThemeJson = CafeThemeSerializer.Serialize(CafeThemeMapping.FromDto(request.Theme));
{
// Custom menu styling is a paid feature (Starter+). Only block an actual change,
// so a normal settings save that re-sends the current theme isn't rejected.
var newThemeJson = CafeThemeSerializer.Serialize(CafeThemeMapping.FromDto(request.Theme));
if (newThemeJson != cafe.ThemeJson)
{
var styleTier = tenant.PlanTier ?? PlanTier.Free;
if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, styleTier, "custom_menu_styling", ct))
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("PLAN_FEATURE_DISABLED", "Custom menu styling is not included in your plan. Please upgrade.")));
cafe.ThemeJson = newThemeJson;
}
}
if (request.DefaultTaxRate is decimal taxRate)
cafe.DefaultTaxRate = taxRate;
if (request.AllowBranchTaxOverride is bool allowTax)
@@ -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 (EnsureOwner(tenant) 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 (EnsureOwner(tenant) 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 (EnsureOwner(tenant) 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 (EnsureOwner(tenant) 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 (EnsureOwner(tenant) 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 (EnsureOwner(tenant) 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;
}
}
@@ -25,7 +25,9 @@ public class DemoSeedController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
// Demo data is a setup helper; Owner or Manager may run it (matches the
// dashboard banner, which is shown to both roles).
if (EnsureManager(tenant) is { } managerDenied) return managerDenied;
var result = await _demoSeed.SeedAsync(cafeId, ct);
return Ok(new ApiResponse<DemoSeedResult>(true, result));
+88
View File
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Hr;
using Meezi.API.Services;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Core.Utilities;
@@ -46,6 +47,93 @@ public class HrController : CafeApiControllerBase
return Ok(new ApiResponse<IReadOnlyList<EmployeeSummaryDto>>(true, data));
}
/// <summary>Create a new employee (waiter, cashier, chef, …). Owner/Manager only;
/// creating a Manager requires Owner. Optionally sets login credentials in one step.</summary>
[HttpPost("employees")]
public async Task<IActionResult> CreateEmployee(
string cafeId,
[FromBody] CreateEmployeeRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureManager(tenant) is { } forbidden) return forbidden;
IActionResult Invalid(string message, string field) =>
BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", message, field)));
var name = request.Name?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(name))
return Invalid("Name is required.", "Name");
var phone = request.Phone?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(phone))
return Invalid("Phone is required.", "Phone");
if (!Enum.IsDefined(typeof(EmployeeRole), request.Role))
return Invalid("Invalid role.", "Role");
// An Owner is created only at café registration, never via this endpoint.
if (request.Role == EmployeeRole.Owner)
return Invalid("Cannot create an owner here.", "Role");
// Only an Owner may add a Manager.
if (request.Role == EmployeeRole.Manager && EnsureOwner(tenant) is { } ownerOnly)
return ownerOnly;
// One employee per phone within a café.
var phoneTaken = await _db.Employees
.AnyAsync(e => e.CafeId == cafeId && e.DeletedAt == null && e.Phone == phone, ct);
if (phoneTaken)
return Conflict(new ApiResponse<object>(false, null,
new ApiError("PHONE_TAKEN", "An employee with this phone already exists.", "Phone")));
string? branchId = string.IsNullOrWhiteSpace(request.BranchId) ? null : request.BranchId.Trim();
if (branchId is not null)
{
var branchOk = await _db.Branches.AnyAsync(b => b.Id == branchId && b.CafeId == cafeId, ct);
if (!branchOk) return Invalid("Invalid branch.", "BranchId");
}
var employee = new Employee
{
Id = $"emp_{Guid.NewGuid():N}"[..24],
CafeId = cafeId,
BranchId = branchId,
Name = name,
Phone = phone,
Role = request.Role,
BaseSalary = request.BaseSalary ?? 0m,
NationalId = string.IsNullOrWhiteSpace(request.NationalId) ? null : request.NationalId.Trim(),
CreatedAt = DateTime.UtcNow,
};
// Optional: enable password login in the same step.
var wantsCreds = !string.IsNullOrWhiteSpace(request.Username) || !string.IsNullOrWhiteSpace(request.Password);
if (wantsCreds)
{
var username = (request.Username ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(username))
return Invalid("Username is required when setting a password.", "Username");
if ((request.Password ?? string.Empty).Length < 8)
return Invalid("Password must be at least 8 characters.", "Password");
var usernameTaken = await _db.Employees
.AnyAsync(e => e.CafeId == cafeId && e.DeletedAt == null
&& e.Username != null && e.Username.ToLower() == username, ct);
if (usernameTaken)
return Conflict(new ApiResponse<object>(false, null,
new ApiError("USERNAME_TAKEN", "This username is already in use by another employee.", "Username")));
employee.Username = username;
employee.PasswordHash = PasswordHasher.Hash(request.Password!);
}
_db.Employees.Add(employee);
await _db.SaveChangesAsync(ct);
var dto = new EmployeeSummaryDto(employee.Id, employee.Name, employee.Phone, employee.Role, employee.BaseSalary);
return Ok(new ApiResponse<EmployeeSummaryDto>(true, dto));
}
[HttpGet("employees/{employeeId}")]
public async Task<IActionResult> GetEmployee(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
{
@@ -61,6 +61,19 @@ public class InventoryController : CafeApiControllerBase
return Ok(new ApiResponse<object>(true, updated));
}
[HttpDelete("ingredients/{ingredientId}")]
public async Task<IActionResult> Delete(
string cafeId,
string ingredientId,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var deleted = await _inventory.DeleteAsync(cafeId, ingredientId, ct);
if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id = ingredientId }));
}
[HttpPost("ingredients/{ingredientId}/adjust")]
public async Task<IActionResult> Adjust(
string cafeId,
@@ -1,7 +1,9 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Services;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Meezi.Infrastructure.Services.Platform;
using Meezi.Shared;
@@ -81,6 +83,33 @@ public class MediaController : CafeApiControllerBase
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
=> Upload(cafeId, file, tenant, _media.SaveCafeCoverAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
/// <summary>Media library for this café — previously uploaded files so the UI can
/// reuse one instead of re-uploading. Deduplication means each distinct file appears once.</summary>
[HttpGet]
public async Task<IActionResult> ListMedia(
string cafeId,
ITenantContext tenant,
[FromServices] AppDbContext db,
CancellationToken cancellationToken,
[FromQuery] string? kind = null,
[FromQuery] int limit = 60)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var query = db.MediaAssets.AsNoTracking().Where(m => m.CafeId == cafeId);
if (!string.IsNullOrWhiteSpace(kind))
query = query.Where(m => m.Kind == kind);
var items = await query
.OrderByDescending(m => m.CreatedAt)
.Take(Math.Clamp(limit, 1, 200))
.Select(m => new MediaAssetDto(
m.Id, m.Url, m.Kind, m.ContentType, m.SizeBytes, m.OriginalFileName, m.CreatedAt))
.ToListAsync(cancellationToken);
return Ok(new ApiResponse<List<MediaAssetDto>>(true, items));
}
private async Task<IActionResult> Upload(
string cafeId,
IFormFile file,
@@ -103,3 +132,12 @@ public class MediaController : CafeApiControllerBase
}
public record UploadResultDto(string Url);
public record MediaAssetDto(
string Id,
string Url,
string Kind,
string ContentType,
long SizeBytes,
string? OriginalFileName,
DateTime CreatedAt);
+18 -5
View File
@@ -7,6 +7,7 @@ using Meezi.Core.Constants;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Meezi.Infrastructure.Services.Platform;
using Meezi.Shared;
namespace Meezi.API.Controllers;
@@ -19,24 +20,27 @@ public class MenuController : CafeApiControllerBase
private readonly IValidator<CreateMenuCategoryRequest> _createCategoryValidator;
private readonly IValidator<CreateMenuItemRequest> _createItemValidator;
private readonly AppDbContext _db;
private readonly IPlatformCatalogService _catalog;
private const string CategoryLimitMessage =
"محدودیت دسته‌بندی پلن رایگان (۳ دسته). برای افزودن دسته‌بندی بیشتر، پلن خود را ارتقا دهید.";
"به سقف دسته‌بندی منوی پلن شما رسیدید. برای افزودن دسته‌بندی بیشتر، پلن خود را ارتقا دهید.";
private const string ItemLimitMessage =
"محدودیت آیتم منو پلن رایگان (۳۰ آیتم). برای افزودن آیتم بیشتر، پلن خود را ارتقا دهید.";
"به سقف آیتم منوی پلن شما رسیدید. برای افزودن آیتم بیشتر، پلن خود را ارتقا دهید.";
public MenuController(
IMenuService menuService,
IMenuAi3dGenerationService menuAi3d,
IValidator<CreateMenuCategoryRequest> createCategoryValidator,
IValidator<CreateMenuItemRequest> createItemValidator,
AppDbContext db)
AppDbContext db,
IPlatformCatalogService catalog)
{
_menuService = menuService;
_menuAi3d = menuAi3d;
_createCategoryValidator = createCategoryValidator;
_createItemValidator = createItemValidator;
_db = db;
_catalog = catalog;
}
[HttpGet("categories")]
@@ -59,7 +63,7 @@ public class MenuController : CafeApiControllerBase
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var tier = tenant.PlanTier ?? PlanTier.Free;
var max = PlanLimits.MaxMenuCategories(tier);
var max = (await _catalog.GetLimitsAsync(tier, cancellationToken)).MaxMenuCategories;
if (max != int.MaxValue)
{
var count = await _db.MenuCategories.CountAsync(
@@ -120,7 +124,7 @@ public class MenuController : CafeApiControllerBase
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var tier = tenant.PlanTier ?? PlanTier.Free;
var max = PlanLimits.MaxMenuItems(tier);
var max = (await _catalog.GetLimitsAsync(tier, cancellationToken)).MaxMenuItems;
if (max != int.MaxValue)
{
var count = await _db.MenuItems.CountAsync(
@@ -163,6 +167,15 @@ public class MenuController : CafeApiControllerBase
return Ok(new ApiResponse<MenuItemDto>(true, data));
}
[HttpDelete("items/{id}")]
public async Task<IActionResult> DeleteItem(string cafeId, string id, ITenantContext tenant, CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var deleted = await _menuService.DeleteItemAsync(cafeId, id, cancellationToken);
if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id }));
}
[HttpGet("ai-3d/usage")]
public async Task<IActionResult> GetAi3dUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
{
+87 -1
View File
@@ -20,6 +20,7 @@ public class OrdersController : CafeApiControllerBase
private readonly IValidator<RecordPaymentsRequest> _paymentsValidator;
private readonly IValidator<AppendOrderItemsRequest> _appendValidator;
private readonly IValidator<UpdateOrderSessionRequest> _sessionValidator;
private readonly IValidator<CorrectPaymentsRequest> _correctionValidator;
public OrdersController(
IOrderService orderService,
@@ -28,7 +29,8 @@ public class OrdersController : CafeApiControllerBase
IValidator<UpdateOrderStatusRequest> statusValidator,
IValidator<RecordPaymentsRequest> paymentsValidator,
IValidator<AppendOrderItemsRequest> appendValidator,
IValidator<UpdateOrderSessionRequest> sessionValidator)
IValidator<UpdateOrderSessionRequest> sessionValidator,
IValidator<CorrectPaymentsRequest> correctionValidator)
{
_orderService = orderService;
_audit = audit;
@@ -37,6 +39,7 @@ public class OrdersController : CafeApiControllerBase
_paymentsValidator = paymentsValidator;
_appendValidator = appendValidator;
_sessionValidator = sessionValidator;
_correctionValidator = correctionValidator;
}
[HttpGet]
@@ -63,6 +66,35 @@ public class OrdersController : CafeApiControllerBase
return Ok(new ApiResponse<IReadOnlyList<OrderDto>>(true, data));
}
/// <summary>Closed orders (delivered/cancelled) of one Iran-calendar day — the
/// browsing surface for اصلاح سند payment corrections.</summary>
[HttpGet("closed")]
public async Task<IActionResult> GetClosedOrders(
string cafeId,
ITenantContext tenant,
CancellationToken cancellationToken,
[FromQuery] string? date = null,
[FromQuery] string? branchId = null,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 30)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureBranchAccess(branchId, tenant) is { } branchDenied) return branchDenied;
DateOnly day;
if (string.IsNullOrWhiteSpace(date)) day = IranCalendar.TodayInIran;
else if (!DateOnly.TryParse(date, out day))
return BadRequest(new ApiResponse<object>(
false, null, new ApiError("VALIDATION_ERROR", "Invalid date (expected YYYY-MM-DD).", "date")));
if (page < 1) page = 1;
if (pageSize is < 1 or > 100) pageSize = 30;
var (items, total) = await _orderService.GetClosedOrdersAsync(
cafeId, day, branchId, page, pageSize, cancellationToken);
return Ok(new PagedApiResponse<OrderDto>(true, items, new PagedMeta(total, page, pageSize)));
}
[HttpGet("live")]
public async Task<IActionResult> GetLiveOrders(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
{
@@ -273,6 +305,56 @@ public class OrdersController : CafeApiControllerBase
return Ok(new ApiResponse<IReadOnlyList<PaymentDto>>(true, result.Data));
}
/// <summary>
/// اصلاح سند — void wrongly-recorded payments and/or record replacements on a
/// closed order, atomically, with a mandatory reason. Manager/Owner only;
/// the full before/after is written to the immutable audit trail.
/// </summary>
[HttpPost("{id}/payments/corrections")]
public async Task<IActionResult> CorrectPayments(
string cafeId,
string id,
[FromBody] CorrectPaymentsRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureManager(tenant) is { } forbidden) return forbidden;
var validation = await _correctionValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
// Snapshot the payments before the change so the audit row carries a
// complete before/after picture even after later corrections.
var before = await _orderService.GetOrderAsync(cafeId, id, cancellationToken);
var result = await _orderService.CorrectPaymentsAsync(
cafeId, id, request, tenant.UserId, cancellationToken);
if (!result.Success) return OrderError(result.ErrorCode!, result.Field);
await _audit.LogAsync(new AuditEntry
{
Category = "Payment",
Action = "PaymentCorrected",
EntityType = "Order",
EntityId = id,
Summary = $"اصلاح سند: voided {request.VoidPaymentIds.Count} payment(s), " +
$"recorded {request.Replacements.Count} replacement(s) — {request.Reason}",
Details = new
{
orderId = id,
displayNumber = result.Data!.DisplayNumber,
reason = request.Reason,
voidedPaymentIds = request.VoidPaymentIds,
paymentsBefore = before?.Payments.Select(p => new { p.Id, p.Method, p.Amount, p.Status }),
paymentsAfter = result.Data.Payments.Select(p => new { p.Id, p.Method, p.Amount, p.Status }),
paidAmountAfter = result.Data.PaidAmount,
orderTotal = result.Data.Total
}
}, cancellationToken);
return Ok(new ApiResponse<OrderDto>(true, result.Data));
}
private IActionResult OrderError(string code, string? field = null) =>
code switch
{
@@ -300,6 +382,10 @@ public class OrdersController : CafeApiControllerBase
false, null, new ApiError(code, "Table is being cleaned.", field))),
"NO_OPEN_SHIFT" => BadRequest(new ApiResponse<object>(
false, null, new ApiError(code, "Open the cash register shift before taking payment.", field))),
"PAYMENT_NOT_FOUND" => NotFound(new ApiResponse<object>(
false, null, new ApiError(code, "Payment not found on this order.", field))),
"PAYMENT_ALREADY_REFUNDED" => BadRequest(new ApiResponse<object>(
false, null, new ApiError(code, "Payment is already refunded.", field))),
_ => BadRequest(new ApiResponse<object>(
false, null, new ApiError(code, "Invalid order request.", field)))
};
+22 -14
View File
@@ -4,6 +4,7 @@ using Meezi.API.Services;
using Meezi.API.Utils;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Services.Platform;
using Meezi.Shared;
namespace Meezi.API.Controllers;
@@ -13,13 +14,21 @@ public class ReportsController : CafeApiControllerBase
{
private readonly IReportService _reports;
private readonly IDailyReportService _dailyReports;
private readonly IPlatformCatalogService _catalog;
public ReportsController(IReportService reports, IDailyReportService dailyReports)
public ReportsController(
IReportService reports,
IDailyReportService dailyReports,
IPlatformCatalogService catalog)
{
_reports = reports;
_dailyReports = dailyReports;
_catalog = catalog;
}
private async Task<int> MaxHistoryDaysAsync(ITenantContext tenant, CancellationToken ct) =>
(await _catalog.GetLimitsAsync(tenant.PlanTier ?? PlanTier.Free, ct)).MaxReportHistoryDays;
[HttpGet("daily")]
public async Task<IActionResult> GetDailySnapshot(
string cafeId,
@@ -37,7 +46,7 @@ public class ReportsController : CafeApiControllerBase
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", "Invalid date. Use yyyy-MM-dd.", "date")));
if (EnsureReportDateAllowed(tenant, reportDate) is { } planError) return planError;
if (await EnsureReportDateAllowedAsync(tenant, reportDate, ct) is { } planError) return planError;
var snapshot = await _dailyReports.GetReportAsync(cafeId, branchId, reportDate, ct);
if (snapshot is null)
@@ -62,16 +71,16 @@ public class ReportsController : CafeApiControllerBase
new ApiError("VALIDATION_ERROR", "Invalid from/to. Use yyyy-MM-dd.", "from")));
var today = IranCalendar.TodayInIran;
var tier = tenant.PlanTier ?? PlanTier.Free;
var maxDays = await MaxHistoryDaysAsync(tenant, ct);
if (!ReportPlanGate.IsDateInRange(tier, startDate, today)
|| !ReportPlanGate.IsDateInRange(tier, endDate, today))
if (!ReportPlanGate.IsDateInRange(maxDays, startDate, today)
|| !ReportPlanGate.IsDateInRange(maxDays, endDate, today))
{
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(tier), "date")));
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(maxDays), "date")));
}
var clamped = ReportPlanGate.ClampRange(tier, startDate, endDate, today);
var clamped = ReportPlanGate.ClampRange(maxDays, startDate, endDate, today);
if (clamped is null)
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", "Invalid date range.", "from")));
@@ -91,12 +100,11 @@ public class ReportsController : CafeApiControllerBase
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var tier = tenant.PlanTier ?? PlanTier.Free;
var maxDays = Core.Constants.PlanLimits.MaxReportHistoryDays(tier);
var maxDays = await MaxHistoryDaysAsync(tenant, ct);
if (days > maxDays && maxDays != int.MaxValue)
{
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(tier), "days")));
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(maxDays), "days")));
}
days = Math.Min(days, maxDays == int.MaxValue ? 365 : maxDays);
@@ -180,14 +188,14 @@ public class ReportsController : CafeApiControllerBase
return DateOnly.TryParse(value, out date);
}
private IActionResult? EnsureReportDateAllowed(ITenantContext tenant, DateOnly date)
private async Task<IActionResult?> EnsureReportDateAllowedAsync(ITenantContext tenant, DateOnly date, CancellationToken ct)
{
var tier = tenant.PlanTier ?? PlanTier.Free;
var maxDays = await MaxHistoryDaysAsync(tenant, ct);
var today = IranCalendar.TodayInIran;
if (ReportPlanGate.IsDateInRange(tier, date, today))
if (ReportPlanGate.IsDateInRange(maxDays, date, today))
return null;
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(tier), "date")));
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(maxDays), "date")));
}
}
@@ -66,6 +66,19 @@ public class ReservationsController : CafeApiControllerBase
if (data is null) return NotFoundError();
return Ok(new ApiResponse<ReservationDto>(true, data));
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(
string cafeId,
string id,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var deleted = await _reservations.DeleteAsync(cafeId, id, ct);
if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id }));
}
}
public record UpdateReservationStatusRequest(ReservationStatus Status);
+42 -15
View File
@@ -7,33 +7,64 @@ using Meezi.Shared;
namespace Meezi.API.Controllers;
/// <summary>
/// Marketing SMS — bring-your-own-provider. Each café configures its OWN
/// Kavenegar API key + sender line; the platform does not sell SMS.
/// </summary>
[Route("api/cafes/{cafeId}/sms")]
public class SmsController : CafeApiControllerBase
{
private readonly ISmsMarketingService _smsMarketingService;
private readonly ISmsService _smsService;
private readonly IValidator<SendSmsCampaignRequest> _campaignValidator;
public SmsController(
ISmsMarketingService smsMarketingService,
ISmsService smsService,
IValidator<SendSmsCampaignRequest> campaignValidator)
{
_smsMarketingService = smsMarketingService;
_smsService = smsService;
_campaignValidator = campaignValidator;
}
[HttpGet("settings")]
public async Task<IActionResult> GetSettings(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureManager(tenant) is { } forbidden) return forbidden;
var data = await _smsMarketingService.GetSettingsAsync(cafeId, cancellationToken);
return Ok(new ApiResponse<SmsSettingsDto>(true, data));
}
[HttpPut("settings")]
public async Task<IActionResult> UpdateSettings(
string cafeId,
[FromBody] UpdateSmsSettingsRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureManager(tenant) is { } forbidden) return forbidden;
var (success, data, code, message) = await _smsMarketingService.UpdateSettingsAsync(
cafeId, request, cancellationToken);
if (!success)
{
return code switch
{
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message!))),
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code!, message!)))
};
}
return Ok(new ApiResponse<SmsSettingsDto>(true, data));
}
[HttpGet("balance")]
public async Task<IActionResult> GetBalance(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var info = await _smsService.GetAccountInfoAsync(cancellationToken);
var dto = info is not null
? new SmsBalanceDto(info.RemainCredit, info.AccountType, true)
: new SmsBalanceDto(0, "master", false);
var dto = await _smsMarketingService.GetBalanceAsync(cafeId, cancellationToken);
return Ok(new ApiResponse<SmsBalanceDto>(true, dto));
}
@@ -41,10 +72,8 @@ public class SmsController : CafeApiControllerBase
public async Task<IActionResult> GetUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (tenant.PlanTier is null)
return BadRequest(new ApiResponse<object>(false, null, new ApiError("INVALID", "Plan tier missing.")));
var data = await _smsMarketingService.GetUsageAsync(cafeId, tenant.PlanTier.Value, cancellationToken);
var data = await _smsMarketingService.GetUsageAsync(cafeId, cancellationToken);
return Ok(new ApiResponse<SmsUsageDto>(true, data));
}
@@ -56,20 +85,18 @@ public class SmsController : CafeApiControllerBase
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (tenant.PlanTier is null)
return BadRequest(new ApiResponse<object>(false, null, new ApiError("INVALID", "Plan tier missing.")));
var validation = await _campaignValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var (success, data, code, message) = await _smsMarketingService.SendCampaignAsync(
cafeId, tenant.PlanTier.Value, request, cancellationToken);
cafeId, request, cancellationToken);
if (!success)
{
return code switch
{
"PLAN_LIMIT_REACHED" => StatusCode(StatusCodes.Status403Forbidden,
"SMS_NOT_CONFIGURED" => BadRequest(
new ApiResponse<object>(false, null, new ApiError(code, message!))),
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message!))),
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code!, message!)))
@@ -1,8 +1,8 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.Core.Constants;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.API.Services;
using Meezi.Infrastructure.Services.Platform;
using Meezi.Shared;
namespace Meezi.API.Controllers;
@@ -11,8 +11,13 @@ namespace Meezi.API.Controllers;
public class TerminalsController : CafeApiControllerBase
{
private readonly ITerminalRegistryService _terminals;
private readonly IPlatformCatalogService _catalog;
public TerminalsController(ITerminalRegistryService terminals) => _terminals = terminals;
public TerminalsController(ITerminalRegistryService terminals, IPlatformCatalogService catalog)
{
_terminals = terminals;
_catalog = catalog;
}
[HttpPost("register")]
public async Task<IActionResult> Register(
@@ -35,7 +40,7 @@ public class TerminalsController : CafeApiControllerBase
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var list = await _terminals.ListAsync(cafeId, ct);
var max = PlanLimits.MaxTerminals(tenant.PlanTier ?? PlanTier.Free);
var max = (await _catalog.GetLimitsAsync(tenant.PlanTier ?? PlanTier.Free, ct)).MaxTerminals;
return Ok(new ApiResponse<object>(true, new { terminals = list, max }));
}
@@ -215,6 +215,9 @@ public static class ServiceCollectionExtensions
app.UseMeeziSecurity();
app.UseAuthentication();
app.UseMiddleware<Middleware.TenantMiddleware>();
// After tenant context (keys are scoped per café), before plan-limit + controllers
// so a replayed write short-circuits without re-consuming limits or re-executing.
app.UseMiddleware<Middleware.IdempotencyMiddleware>();
app.UseMiddleware<Middleware.PlanLimitMiddleware>();
app.UseAuthorization();
@@ -242,6 +245,11 @@ public static class ServiceCollectionExtensions
"branch-permanent-delete",
job => job.ExecuteAsync(),
Cron.Hourly);
RecurringJob.AddOrUpdate<IdempotencyCleanupJob>(
"idempotency-cleanup",
job => job.ExecuteAsync(),
Cron.Daily(4));
}
return app;
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using Meezi.Infrastructure.Data;
namespace Meezi.API.Jobs;
/// <summary>
/// Purges old idempotency records. Keys only need to outlive realistic offline
/// gaps and client retries, so a short retention keeps the table small.
/// </summary>
public class IdempotencyCleanupJob
{
private static readonly TimeSpan Retention = TimeSpan.FromDays(7);
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<IdempotencyCleanupJob> _logger;
public IdempotencyCleanupJob(
IServiceScopeFactory scopeFactory,
ILogger<IdempotencyCleanupJob> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
public async Task ExecuteAsync()
{
await using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var cutoff = DateTime.UtcNow - Retention;
var removed = await db.IdempotencyRecords
.Where(r => r.CreatedAt < cutoff)
.ExecuteDeleteAsync();
if (removed > 0)
_logger.LogInformation("Purged {Count} idempotency records older than {Days}d", removed, Retention.TotalDays);
}
}
@@ -0,0 +1,188 @@
using System.Text;
using Microsoft.EntityFrameworkCore;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
namespace Meezi.API.Middleware;
/// <summary>
/// Makes mutating requests safe to retry. A client (e.g. the offline outbox)
/// attaches an <c>Idempotency-Key</c> header; if the same key is seen again, the
/// original response is replayed instead of executing the write twice.
///
/// Bookkeeping runs in isolated DI scopes so it never mixes with the controller's
/// own DbContext unit of work. Opt-in via header → non-idempotent and binary/file
/// endpoints are unaffected unless the client explicitly sends a key.
/// </summary>
public class IdempotencyMiddleware
{
private const string HeaderName = "Idempotency-Key";
private const int MaxKeyLength = 200;
private const int MaxStoredBodyBytes = 256 * 1024;
/// <summary>An InProgress record older than this is assumed crashed mid-flight and re-run.</summary>
private static readonly TimeSpan StaleInProgress = TimeSpan.FromSeconds(60);
private readonly RequestDelegate _next;
private readonly ILogger<IdempotencyMiddleware> _logger;
public IdempotencyMiddleware(RequestDelegate next, ILogger<IdempotencyMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context, ITenantContext tenant, IServiceScopeFactory scopeFactory)
{
var method = context.Request.Method;
var isMutating = HttpMethods.IsPost(method) || HttpMethods.IsPut(method)
|| HttpMethods.IsPatch(method) || HttpMethods.IsDelete(method);
if (!isMutating || !context.Request.Headers.TryGetValue(HeaderName, out var headerValues))
{
await _next(context);
return;
}
var key = headerValues.ToString();
if (string.IsNullOrWhiteSpace(key) || key.Length > MaxKeyLength)
{
// Unusable key — behave as if it wasn't sent rather than reject the write.
await _next(context);
return;
}
var scope = string.IsNullOrEmpty(tenant.CafeId) ? "global" : tenant.CafeId;
var path = context.Request.Path.Value ?? string.Empty;
// 1) Look for an existing record for this (tenant, key).
await using (var lookupScope = scopeFactory.CreateAsyncScope())
{
var db = lookupScope.ServiceProvider.GetRequiredService<AppDbContext>();
var existing = await db.IdempotencyRecords.AsNoTracking()
.FirstOrDefaultAsync(r => r.Scope == scope && r.Key == key, context.RequestAborted);
if (existing is not null)
{
if (existing.Status == IdempotencyStatus.Completed)
{
await ReplayAsync(context, existing);
return;
}
if (DateTime.UtcNow - existing.CreatedAt < StaleInProgress)
{
await WriteConflictAsync(context); // genuine concurrent duplicate
return;
}
// Stale reservation (process likely crashed mid-flight) — drop and re-run.
_logger.LogWarning("Recovering stale idempotency reservation {Key} for scope {Scope}", key, scope);
var stale = await db.IdempotencyRecords
.FirstOrDefaultAsync(r => r.Id == existing.Id, context.RequestAborted);
if (stale is not null)
{
db.IdempotencyRecords.Remove(stale);
await db.SaveChangesAsync(context.RequestAborted);
}
}
}
// 2) Reserve the key. The unique (Scope, Key) index serializes racing first requests.
var record = new IdempotencyRecord
{
Scope = scope,
Key = key,
Method = method,
Path = path,
Status = IdempotencyStatus.InProgress,
};
try
{
await using var reserveScope = scopeFactory.CreateAsyncScope();
var db = reserveScope.ServiceProvider.GetRequiredService<AppDbContext>();
db.IdempotencyRecords.Add(record);
await db.SaveChangesAsync(context.RequestAborted);
}
catch (DbUpdateException)
{
await WriteConflictAsync(context); // another request won the reservation race
return;
}
// 3) Run the real request, capturing its response.
var originalBody = context.Response.Body;
await using var buffer = new MemoryStream();
context.Response.Body = buffer;
try
{
await _next(context);
}
catch
{
context.Response.Body = originalBody;
await DeleteAsync(scopeFactory, record.Id);
throw;
}
var statusCode = context.Response.StatusCode;
buffer.Position = 0;
var bytes = buffer.ToArray();
context.Response.Body = originalBody;
if (bytes.Length > 0)
await originalBody.WriteAsync(bytes, context.RequestAborted);
// 4) Persist the result so retries replay it — except 5xx, which is transient and
// released so the client can retry the same key.
if (statusCode is >= 200 and < 500)
{
var storedBody = bytes.Length is > 0 and <= MaxStoredBodyBytes
? Encoding.UTF8.GetString(bytes)
: null;
await CompleteAsync(scopeFactory, record.Id, statusCode, storedBody);
}
else
{
await DeleteAsync(scopeFactory, record.Id);
}
}
private static async Task ReplayAsync(HttpContext context, IdempotencyRecord record)
{
context.Response.StatusCode = record.ResponseStatusCode;
context.Response.ContentType = "application/json; charset=utf-8";
context.Response.Headers["Idempotent-Replay"] = "true";
if (!string.IsNullOrEmpty(record.ResponseBody))
await context.Response.WriteAsync(record.ResponseBody);
}
private static async Task WriteConflictAsync(HttpContext context)
{
context.Response.StatusCode = StatusCodes.Status409Conflict;
context.Response.ContentType = "application/json; charset=utf-8";
await context.Response.WriteAsync(
"{\"success\":false,\"data\":null,\"error\":{\"code\":\"IDEMPOTENCY_IN_PROGRESS\",\"message\":\"A request with this key is still being processed.\"}}");
}
private static async Task CompleteAsync(IServiceScopeFactory f, string id, int status, string? body)
{
await using var s = f.CreateAsyncScope();
var db = s.ServiceProvider.GetRequiredService<AppDbContext>();
var rec = await db.IdempotencyRecords.FirstOrDefaultAsync(r => r.Id == id);
if (rec is null) return;
rec.Status = IdempotencyStatus.Completed;
rec.ResponseStatusCode = status;
rec.ResponseBody = body;
rec.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
private static async Task DeleteAsync(IServiceScopeFactory f, string id)
{
await using var s = f.CreateAsyncScope();
var db = s.ServiceProvider.GetRequiredService<AppDbContext>();
var rec = await db.IdempotencyRecords.FirstOrDefaultAsync(r => r.Id == id);
if (rec is null) return;
db.IdempotencyRecords.Remove(rec);
await db.SaveChangesAsync();
}
}
+17 -1
View File
@@ -1,5 +1,6 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Meezi.Core.Authorization;
using Meezi.Core.Constants;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
@@ -92,7 +93,12 @@ public class TenantMiddleware
{
scopedMerchant.CafeId = cafeId;
var roleClaim = context.User.FindFirst(MeeziClaimTypes.Role)?.Value;
// .NET's JWT handler remaps the short "role" claim to ClaimTypes.Role
// on inbound, so FindFirst("role") returns null and tenant.Role would
// stay null — making EnsureManager/EnsureOwner reject even a real owner.
// Read both the raw claim and the mapped one.
var roleClaim = context.User.FindFirst(MeeziClaimTypes.Role)?.Value
?? context.User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value;
if (Enum.TryParse<EmployeeRole>(roleClaim, ignoreCase: true, out var role))
scopedMerchant.Role = role;
@@ -111,6 +117,16 @@ public class TenantMiddleware
else
_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)
+3
View File
@@ -5,6 +5,9 @@ public record SendOtpRequest(string Phone);
/// <summary>Username + password login (alternative to OTP). Optional cafeId to scope to a specific café.</summary>
public record LoginWithPasswordRequest(string Username, string Password, string? CafeId = null);
/// <summary>Admin-issued recovery key login — logs the café Owner in when OTP access is lost.</summary>
public record LoginWithRecoveryKeyRequest(string Key);
public record VerifyOtpRequest(string Phone, string Code, string? CafeId = null);
public record RefreshTokenRequest(string RefreshToken);
+10 -3
View File
@@ -15,13 +15,20 @@ public record BillingStatusDto(
int? OrdersDailyLimit,
int CustomersCount,
int? CustomersLimit,
int SmsUsedThisMonth,
int SmsMonthlyLimit,
bool Menu3dEnabled,
bool MenuAi3dEnabled,
int MenuAi3dUsedThisMonth,
int MenuAi3dMonthlyLimit,
bool DiscoverProfileEnabled,
bool IsPlanExpired);
bool IsPlanExpired,
IReadOnlyList<QueuedPlanDto> QueuedPlans);
public record QueuedPlanDto(
string PaymentId,
PlanTier PlanTier,
int Months,
DateTime EffectiveFrom,
DateTime EffectiveTo,
decimal AmountToman);
public record BillingVerifyResult(bool Success, string RedirectUrl);
+8
View File
@@ -13,3 +13,11 @@ public record SmsUsageDto(int UsedThisMonth, int MonthlyLimit, string Month);
/// <summary>Kavenegar account credit balance returned to the dashboard.</summary>
public record SmsBalanceDto(long RemainCredit, string AccountType, bool IsConfigured);
/// <summary>
/// Café's own SMS provider settings (bring-your-own-provider). The API key is
/// returned masked — only the last 4 characters are ever echoed back.
/// </summary>
public record SmsSettingsDto(bool IsConfigured, string? ApiKeyMasked, string? SenderNumber);
public record UpdateSmsSettingsRequest(string? ApiKey, string? SenderNumber);
@@ -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);
+12
View File
@@ -62,3 +62,15 @@ public record TodayShiftDto(ShiftType ShiftType, string Label);
/// <summary>Set or update username/password credentials for an employee.</summary>
public record SetEmployeeCredentialsRequest(string Username, string Password);
/// <summary>Create a new employee. Owner/Manager only; Manager role requires Owner.
/// Username+Password are optional and, when supplied, enable dashboard/POS login.</summary>
public record CreateEmployeeRequest(
string Name,
string Phone,
EmployeeRole Role,
string? BranchId = null,
decimal? BaseSalary = null,
string? NationalId = null,
string? Username = null,
string? Password = null);
+13 -1
View File
@@ -70,6 +70,17 @@ public record RecordPaymentsRequest(
IReadOnlyList<CreatePaymentRequest> Payments,
int? LoyaltyPointsToRedeem = null);
/// <summary>
/// اصلاح سند — amend the payments of an order after the fact (wrong method,
/// wrong amount, or payment recorded on the wrong order). Voids the listed
/// payments (marked Refunded, never deleted) and records the replacements in
/// one atomic operation. A reason is mandatory; the whole change is audit-logged.
/// </summary>
public record CorrectPaymentsRequest(
IReadOnlyList<string> VoidPaymentIds,
IReadOnlyList<CreatePaymentRequest> Replacements,
string Reason);
public record PaymentDto(string Id, PaymentMethod Method, decimal Amount, PaymentStatus Status, string? Reference);
public record LiveOrderDto(
@@ -80,4 +91,5 @@ public record LiveOrderDto(
OrderType OrderType,
decimal Total,
DateTime CreatedAt,
IReadOnlyList<OrderItemDto> Items);
IReadOnlyList<OrderItemDto> Items,
OrderSource Source);
+2 -1
View File
@@ -107,7 +107,8 @@ public record PublicMenuDto(
string CafeName,
string Slug,
CafeThemeDto Theme,
IReadOnlyList<PublicMenuCategoryDto> Categories);
IReadOnlyList<PublicMenuCategoryDto> Categories,
bool ShowWatermark);
public record GuestCreateOrderRequest(
OrderType OrderType,
+66 -7
View File
@@ -253,7 +253,9 @@ public class AuthService : IAuthService
if (employee?.Cafe is null)
return (false, null, "NOT_FOUND", "User no longer exists.");
await _refreshTokenStore.RevokeAsync(request.RefreshToken, cancellationToken);
// Note: we intentionally do NOT revoke the presented refresh token here.
// It is reused (with a slid TTL) so concurrent refreshes from multiple
// tabs/devices stay valid instead of racing each other into a logout.
var allMemberships = await _db.Employees
.Include(e => e.Cafe)
@@ -265,7 +267,9 @@ public class AuthService : IAuthService
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
.ToList();
var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, payload.ActiveBranchId, cancellationToken);
var tokens = await IssueTokensAsync(
employee, employee.Cafe, membershipDtos, payload.ActiveBranchId, cancellationToken,
existingRefreshToken: request.RefreshToken);
return (true, tokens, null, null);
}
@@ -505,17 +509,73 @@ public class AuthService : IAuthService
return (true, tokens, null, null, null);
}
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithRecoveryKeyAsync(
LoginWithRecoveryKeyRequest request,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(request.Key))
return (false, null, "INVALID_KEY", "Invalid recovery key.");
var hash = RecoveryKeyGenerator.HashOf(request.Key);
// Exact-hash lookup — the unique index makes this a single index seek.
var cafe = await _db.Cafes
.FirstOrDefaultAsync(c => c.RecoveryKeyHash == hash && c.DeletedAt == null, cancellationToken);
if (cafe is null)
return (false, null, "INVALID_KEY", "Invalid recovery key.");
if (cafe.IsSuspended)
return (false, null, "CAFE_SUSPENDED", "This café is suspended. Contact support.");
// The key authenticates as the café's Owner.
var owner = await _db.Employees
.Include(e => e.Cafe)
.FirstOrDefaultAsync(
e => e.CafeId == cafe.Id && e.Role == EmployeeRole.Owner && e.DeletedAt == null,
cancellationToken);
if (owner?.Cafe is null)
return (false, null, "NO_OWNER", "This café has no owner account.");
_logger.LogWarning(
"Recovery-key login for café {CafeId} as owner {OwnerId}", cafe.Id, owner.Id);
var membershipDtos = new List<CafeMembershipDto>
{
new(owner.CafeId, owner.Cafe.Name, owner.Role.ToString(), owner.Cafe.PlanTier.ToString())
};
var tokens = await IssueTokensAsync(owner, owner.Cafe, membershipDtos, null, cancellationToken);
return (true, tokens, null, null);
}
private async Task<AuthTokenResponse> IssueTokensAsync(
Core.Entities.Employee employee,
Core.Entities.Cafe cafe,
List<CafeMembershipDto>? memberships,
string? requestedBranchId,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
string? existingRefreshToken = null)
{
var resolution = await ResolveBranchAsync(employee, cafe, requestedBranchId, cancellationToken);
var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe, resolution.EffectiveRole, resolution.ActiveBranchId);
var refreshToken = _jwtTokenService.CreateRefreshToken();
// 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
// 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
// concurrent refresh would get INVALID_TOKEN → forced logout → OTP storm.
// Mint a fresh token only on a real login (existingRefreshToken == null).
var refreshToken = existingRefreshToken ?? _jwtTokenService.CreateRefreshToken();
var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30);
await _refreshTokenStore.StoreAsync(
@@ -531,8 +591,7 @@ public class AuthService : IAuthService
TimeSpan.FromDays(refreshDays),
cancellationToken);
var permissions = Meezi.Core.Authorization.RolePermissions
.For(resolution.EffectiveRole)
var permissions = (customPerms as IEnumerable<Permission> ?? RolePermissions.For(resolution.EffectiveRole))
.Select(p => p.ToString())
.OrderBy(p => p)
.ToList();
+148 -17
View File
@@ -35,6 +35,11 @@ public interface IBillingService
CancellationToken cancellationToken = default);
Task<BillingStatusDto?> GetStatusAsync(string cafeId, PlanTier currentTier, CancellationToken cancellationToken = default);
Task<(bool Ok, string? ErrorCode, string? Message)> CancelQueuedAsync(
string cafeId,
string paymentId,
CancellationToken cancellationToken = default);
}
public class BillingService : IBillingService
@@ -210,31 +215,161 @@ public class BillingService : IBillingService
return new BillingVerifyResult(false, failUrl);
}
payment.Status = SubscriptionPaymentStatus.Completed;
payment.RefId = verify.RefId;
var cafe = payment.Cafe;
cafe.PlanTier = payment.PlanTier;
var baseDate = cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt > DateTime.UtcNow
? cafe.PlanExpiresAt.Value
: DateTime.UtcNow;
cafe.PlanExpiresAt = baseDate.AddMonths(payment.Months);
var now = DateTime.UtcNow;
// Where does the current paid coverage end? = the latest of the active plan's expiry
// and the furthest-out already-queued period. A new purchase is appended to that.
var coverageEnd = await ComputeCoverageEndAsync(cafe, payment.Id, now, cancellationToken);
payment.EffectiveFrom = coverageEnd;
payment.EffectiveTo = coverageEnd.AddMonths(payment.Months);
var queued = coverageEnd > now;
if (queued)
{
// The owner already has active/queued coverage → book this one after it.
payment.Status = SubscriptionPaymentStatus.Scheduled;
}
else
{
// No active coverage → activate immediately.
payment.Status = SubscriptionPaymentStatus.Completed;
cafe.PlanTier = payment.PlanTier;
cafe.PlanExpiresAt = payment.EffectiveTo;
}
await _db.SaveChangesAsync(cancellationToken);
await TrySendConfirmationSmsAsync(cafe, payment, cancellationToken);
await TrySendConfirmationSmsAsync(cafe, payment, queued, cancellationToken);
return new BillingVerifyResult(true, successUrl);
}
/// <summary>End of the cafe's current paid coverage: the later of its active plan expiry
/// and the furthest-out scheduled (queued) period. Returns <paramref name="now"/> if neither
/// extends past now (i.e. nothing active/queued).</summary>
private async Task<DateTime> ComputeCoverageEndAsync(
Cafe cafe, string? excludePaymentId, DateTime now, CancellationToken ct)
{
var end = now;
if (cafe.PlanTier != PlanTier.Free && cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value > end)
end = cafe.PlanExpiresAt.Value;
var lastScheduledEnd = await _db.SubscriptionPayments
.Where(p => p.CafeId == cafe.Id
&& p.Status == SubscriptionPaymentStatus.Scheduled
&& (excludePaymentId == null || p.Id != excludePaymentId)
&& p.EffectiveTo != null)
.OrderByDescending(p => p.EffectiveTo)
.Select(p => p.EffectiveTo)
.FirstOrDefaultAsync(ct);
if (lastScheduledEnd.HasValue && lastScheduledEnd.Value > end)
end = lastScheduledEnd.Value;
return end;
}
/// <summary>When the active plan has lapsed, promote due queued periods to active.
/// Loops so a fully-elapsed short queued period doesn't strand the next one.</summary>
private async Task PromoteDueScheduledAsync(string cafeId, CancellationToken ct)
{
var now = DateTime.UtcNow;
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
if (cafe is null) return;
var changed = false;
while (!(cafe.PlanTier != PlanTier.Free && cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value > now))
{
var next = await _db.SubscriptionPayments
.Where(p => p.CafeId == cafeId
&& p.Status == SubscriptionPaymentStatus.Scheduled
&& p.EffectiveFrom != null && p.EffectiveFrom <= now)
.OrderBy(p => p.EffectiveFrom)
.FirstOrDefaultAsync(ct);
if (next is null) break;
cafe.PlanTier = next.PlanTier;
cafe.PlanExpiresAt = next.EffectiveTo;
next.Status = SubscriptionPaymentStatus.Completed;
changed = true;
}
if (changed) await _db.SaveChangesAsync(ct);
}
public async Task<(bool Ok, string? ErrorCode, string? Message)> CancelQueuedAsync(
string cafeId,
string paymentId,
CancellationToken cancellationToken = default)
{
var payment = await _db.SubscriptionPayments
.FirstOrDefaultAsync(p => p.Id == paymentId && p.CafeId == cafeId, cancellationToken);
if (payment is null)
return (false, "NOT_FOUND", "Subscription not found.");
// Only a queued (not-yet-started) subscription can be cancelled. The active prepaid
// plan keeps running until its paid time ends.
if (payment.Status != SubscriptionPaymentStatus.Scheduled)
return (false, "NOT_CANCELLABLE", "Only a queued subscription can be cancelled.");
payment.Status = SubscriptionPaymentStatus.Cancelled;
await _db.SaveChangesAsync(cancellationToken);
// Re-pack the remaining queue so later periods slide earlier to fill the gap.
await RecomputeQueueAsync(cafeId, cancellationToken);
return (true, null, null);
}
/// <summary>Re-sequences the remaining queued periods contiguously after the active plan
/// (purchase order preserved), so cancelling one in the middle doesn't leave a gap.</summary>
private async Task RecomputeQueueAsync(string cafeId, CancellationToken ct)
{
var now = DateTime.UtcNow;
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
if (cafe is null) return;
var anchor = (cafe.PlanTier != PlanTier.Free && cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value > now)
? cafe.PlanExpiresAt.Value
: now;
var scheduled = await _db.SubscriptionPayments
.Where(p => p.CafeId == cafeId && p.Status == SubscriptionPaymentStatus.Scheduled)
.OrderBy(p => p.CreatedAt)
.ToListAsync(ct);
foreach (var s in scheduled)
{
s.EffectiveFrom = anchor;
s.EffectiveTo = anchor.AddMonths(s.Months);
anchor = s.EffectiveTo.Value;
}
if (scheduled.Count > 0) await _db.SaveChangesAsync(ct);
}
public async Task<BillingStatusDto?> GetStatusAsync(
string cafeId,
PlanTier currentTier,
CancellationToken cancellationToken = default)
{
// Lazily activate any queued plan whose start date has passed before reading status.
await PromoteDueScheduledAsync(cafeId, cancellationToken);
var cafe = await _db.Cafes.AsNoTracking().FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
if (cafe is null) return null;
var queuedPlans = await _db.SubscriptionPayments.AsNoTracking()
.Where(p => p.CafeId == cafeId && p.Status == SubscriptionPaymentStatus.Scheduled
&& p.EffectiveFrom != null && p.EffectiveTo != null)
.OrderBy(p => p.EffectiveFrom)
.Select(p => new QueuedPlanDto(
p.Id, p.PlanTier, p.Months, p.EffectiveFrom!.Value, p.EffectiveTo!.Value, p.AmountToman))
.ToListAsync(cancellationToken);
var todayStart = DateTime.UtcNow.Date;
var ordersToday = await _db.Orders.CountAsync(
o => o.CafeId == cafeId && o.CreatedAt >= todayStart,
@@ -244,12 +379,7 @@ public class BillingService : IBillingService
var maxOrders = PlanLimits.MaxOrdersPerDay(cafe.PlanTier);
var maxCustomers = PlanLimits.MaxCustomers(cafe.PlanTier);
var maxSms = PlanLimits.MaxSmsPerMonth(cafe.PlanTier);
var monthKey = $"sms:usage:{cafeId}:{DateTime.UtcNow:yyyy-MM}";
var redis = _redis.GetDatabase();
var smsUsed = await redis.StringGetAsync(monthKey);
var smsUsedCount = smsUsed.HasValue ? (int)smsUsed : 0;
var menu3d = await _platformCatalog.IsFeatureEnabledForCafeAsync(
cafeId, cafe.PlanTier, FeatureMenu3d, cancellationToken);
@@ -271,19 +401,19 @@ public class BillingService : IBillingService
maxOrders == int.MaxValue ? null : maxOrders,
customersCount,
maxCustomers == int.MaxValue ? null : maxCustomers,
smsUsedCount,
maxSms == int.MaxValue ? -1 : maxSms,
menu3d,
menuAi3d,
ai3dUsedCount,
ai3dLimit,
discoverProfile,
isExpired);
isExpired,
queuedPlans);
}
private async Task TrySendConfirmationSmsAsync(
Cafe cafe,
SubscriptionPayment payment,
bool queued,
CancellationToken cancellationToken)
{
var ownerPhone = await _db.Employees
@@ -293,8 +423,9 @@ public class BillingService : IBillingService
if (string.IsNullOrEmpty(ownerPhone)) return;
var message =
$"میزی: اشتراک {payment.PlanTier} فعال شد تا {cafe.PlanExpiresAt:yyyy-MM-dd}. مبلغ: {payment.AmountToman:N0} ت";
var message = queued
? $"میزی: اشتراک {payment.PlanTier} ثبت شد و از {payment.EffectiveFrom:yyyy-MM-dd} (پس از پایان اشتراک فعلی) آغاز می‌شود. مبلغ: {payment.AmountToman:N0} ت"
: $"میزی: اشتراک {payment.PlanTier} فعال شد تا {cafe.PlanExpiresAt:yyyy-MM-dd}. مبلغ: {payment.AmountToman:N0} ت";
try
{
await _smsService.SendMessageAsync(ownerPhone, message, cancellationToken);
@@ -345,5 +345,6 @@ public class DeliveryOrderProcessor : IDeliveryOrderProcessor
i.UnitPrice,
i.Notes,
i.IsVoided,
i.VoidedAt)).ToList());
i.VoidedAt)).ToList(),
o.Source);
}
+11 -3
View File
@@ -33,7 +33,9 @@ public class DemoSeedService : IDemoSeedService
// 1. Ensure 9% default tax
var taxId = $"{cafeId}_demo_tax";
var taxCreated = false;
if (!await _db.Taxes.AnyAsync(t => t.CafeId == cafeId && t.IsDefault, ct))
// IgnoreQueryFilters: soft-deleted rows still occupy the PK; re-seeding
// after a user deletes demo data must see those rows to avoid a PK collision.
if (!await _db.Taxes.IgnoreQueryFilters().AnyAsync(t => t.CafeId == cafeId && t.IsDefault, ct))
{
_db.Taxes.Add(new Tax
{
@@ -51,6 +53,7 @@ public class DemoSeedService : IDemoSeedService
else
{
taxId = await _db.Taxes
.IgnoreQueryFilters()
.Where(t => t.CafeId == cafeId && t.IsDefault)
.Select(t => t.Id)
.FirstAsync(ct);
@@ -130,7 +133,10 @@ public class DemoSeedService : IDemoSeedService
decimal qty, decimal reorder, decimal cost, decimal par) =>
new()
{
Id = $"{cafeId}_ing_{Guid.NewGuid():N}"[..36],
// No [..36] truncation: Id is a text column, and truncating to 36 chars
// cuts off the unique guid for real (32-char) café ids → every row gets
// the same id → PK collision → 500. Keep the full unique id.
Id = $"{cafeId}_ing_{Guid.NewGuid():N}",
CafeId = cafeId,
Name = name,
Unit = unit,
@@ -160,7 +166,9 @@ public class DemoSeedService : IDemoSeedService
string cafeId, string branchId, string number, int capacity, string floor, int sortOrder) =>
new()
{
Id = $"{cafeId}_tbl_{Guid.NewGuid():N}"[..36],
// No [..36] truncation (see Ingredient above): truncating cuts the guid
// for real 32-char café ids → identical ids → PK collision → 500.
Id = $"{cafeId}_tbl_{Guid.NewGuid():N}",
CafeId = cafeId,
BranchId = branchId,
Number = number,
+5
View File
@@ -20,6 +20,11 @@ public interface IAuthService
LoginWithPasswordRequest request,
CancellationToken cancellationToken = default);
/// <summary>Log in the café Owner using an admin-issued permanent recovery key.</summary>
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithRecoveryKeyAsync(
LoginWithRecoveryKeyRequest request,
CancellationToken cancellationToken = default);
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchCafeAsync(
string employeeId, string targetCafeId,
CancellationToken cancellationToken = default);
+9 -1
View File
@@ -1,3 +1,4 @@
using Meezi.Core.Authorization;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
@@ -11,8 +12,15 @@ public interface IJwtTokenService
/// Issue a token scoped to an active branch. The <paramref name="effectiveRole"/>
/// is the role the employee holds in <paramref name="activeBranchId"/> (or their
/// 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>
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 CreateRefreshToken();
@@ -89,6 +89,7 @@ public interface IInventoryService
Task<IReadOnlyList<IngredientDto>> LowStockAsync(string cafeId, CancellationToken ct = default);
Task<IngredientDto?> CreateAsync(string cafeId, CreateIngredientRequest request, CancellationToken ct = default);
Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default);
Task<bool> DeleteAsync(string cafeId, string ingredientId, CancellationToken ct = default);
Task<IngredientDto?> AdjustAsync(
string cafeId,
string ingredientId,
@@ -205,6 +206,18 @@ public class InventoryService : IInventoryService
return ToDto(entity);
}
public async Task<bool> DeleteAsync(string cafeId, string ingredientId, CancellationToken ct = default)
{
var entity = await _db.Ingredients.FirstOrDefaultAsync(i => i.Id == ingredientId && i.CafeId == cafeId, ct);
if (entity is null) return false;
// Soft delete: Ingredient has a global DeletedAt query filter, so it (and its
// recipe lines / stock movements) drop out of every query without FK trouble.
entity.DeletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return true;
}
public async Task<IngredientDto?> AdjustAsync(
string cafeId,
string ingredientId,
+14 -1
View File
@@ -1,6 +1,7 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Meezi.Core.Authorization;
using Meezi.Core.Constants;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
@@ -21,7 +22,12 @@ public class JwtTokenService : IJwtTokenService
public string CreateAccessToken(Employee employee, Cafe cafe) =>
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 issuer = _configuration["Jwt:Issuer"] ?? "meezi";
@@ -41,6 +47,13 @@ public class JwtTokenService : IJwtTokenService
if (!string.IsNullOrEmpty(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(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
SecurityAlgorithms.HmacSha256);
+95 -7
View File
@@ -1,3 +1,8 @@
using System.Security.Cryptography;
using Microsoft.EntityFrameworkCore;
using Meezi.Core.Entities;
using Meezi.Infrastructure.Data;
namespace Meezi.API.Services;
public interface IMediaStorageService
@@ -37,11 +42,16 @@ public class MediaStorageService : IMediaStorageService
private readonly IWebHostEnvironment _env;
private readonly ILogger<MediaStorageService> _logger;
private readonly IServiceScopeFactory _scopeFactory;
public MediaStorageService(IWebHostEnvironment env, ILogger<MediaStorageService> logger)
public MediaStorageService(
IWebHostEnvironment env,
ILogger<MediaStorageService> logger,
IServiceScopeFactory scopeFactory)
{
_env = env;
_logger = logger;
_scopeFactory = scopeFactory;
}
public Task<string?> SaveMenuImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
@@ -100,16 +110,29 @@ public class MediaStorageService : IMediaStorageService
|| Model3dMime.Contains(file.ContentType);
if (!isGlb) return null;
await using var buffer = new MemoryStream();
await file.CopyToAsync(buffer, cancellationToken);
var bytes = buffer.ToArray();
var hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
var existing = await FindExistingByHashAsync(cafeId, hash, cancellationToken);
if (existing is not null)
{
_logger.LogInformation("Dedup hit for 3D model (cafe {CafeId}); reusing existing file", cafeId);
return existing;
}
var dir = Path.Combine(_env.ContentRootPath, "uploads", cafeId);
Directory.CreateDirectory(dir);
var savedName = $"menu_3d_{Guid.NewGuid():N}.glb";
var path = Path.Combine(dir, savedName);
await using var stream = File.Create(path);
await file.CopyToAsync(stream, cancellationToken);
await File.WriteAllBytesAsync(path, bytes, cancellationToken);
var url = $"/uploads/{cafeId}/{savedName}";
await RecordAsync(cafeId, hash, bytes.LongLength, file.ContentType, url, "menu_3d", file.FileName, cancellationToken);
_logger.LogInformation("Saved 3D model media for cafe {CafeId}", cafeId);
return $"/uploads/{cafeId}/{savedName}";
return url;
}
private async Task<string?> SaveAsync(
@@ -123,6 +146,20 @@ public class MediaStorageService : IMediaStorageService
if (file.Length == 0 || file.Length > maxBytes) return null;
if (!allowedMime.Contains(file.ContentType)) return null;
// Buffer once so we can hash the content and (if new) write it.
await using var buffer = new MemoryStream();
await file.CopyToAsync(buffer, cancellationToken);
var bytes = buffer.ToArray();
var hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
// Dedup: an identical file already stored for this scope is reused as-is.
var existing = await FindExistingByHashAsync(cafeId, hash, cancellationToken);
if (existing is not null)
{
_logger.LogInformation("Dedup hit for {Prefix} (cafe {CafeId}); reusing existing file", prefix, cafeId);
return existing;
}
var ext = file.ContentType.ToLowerInvariant() switch
{
"image/png" => ".png",
@@ -138,10 +175,61 @@ public class MediaStorageService : IMediaStorageService
var fileName = $"{prefix}_{Guid.NewGuid():N}{ext}";
var path = Path.Combine(dir, fileName);
await using var stream = File.Create(path);
await file.CopyToAsync(stream, cancellationToken);
await File.WriteAllBytesAsync(path, bytes, cancellationToken);
var url = $"/uploads/{cafeId}/{fileName}";
await RecordAsync(cafeId, hash, bytes.LongLength, file.ContentType, url, prefix, file.FileName, cancellationToken);
_logger.LogInformation("Saved {Prefix} media for cafe {CafeId}", prefix, cafeId);
return $"/uploads/{cafeId}/{fileName}";
return url;
}
// ─── Deduplication helpers ────────────────────────────────────────────────
// MediaStorageService is a singleton; resolve a scoped DbContext per call.
private async Task<string?> FindExistingByHashAsync(string? cafeId, string hash, CancellationToken ct)
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await db.MediaAssets.AsNoTracking()
.Where(m => m.CafeId == cafeId && m.ContentHash == hash)
.Select(m => m.Url)
.FirstOrDefaultAsync(ct);
}
catch (Exception ex)
{
// Never let a dedup-lookup failure block an upload.
_logger.LogWarning(ex, "Media dedup lookup failed; proceeding with a fresh upload");
return null;
}
}
private async Task RecordAsync(
string? cafeId, string hash, long size, string contentType,
string url, string kind, string? originalName, CancellationToken ct)
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.MediaAssets.Add(new MediaAsset
{
CafeId = cafeId,
ContentHash = hash,
SizeBytes = size,
ContentType = contentType,
Url = url,
Kind = kind,
OriginalFileName = originalName,
});
await db.SaveChangesAsync(ct);
}
catch (Exception ex)
{
// The file is already written; a missing dedup record only means a
// future identical upload won't be de-duplicated. Don't fail the upload.
_logger.LogWarning(ex, "Failed to record media asset for cafe {CafeId}", cafeId);
}
}
}
@@ -141,7 +141,7 @@ public class MenuAi3dGenerationService : IMenuAi3dGenerationService
{
if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, FeatureMenu3dAi, cancellationToken))
return 0;
return PlanLimits.MaxMenuAi3dPerMonth(planTier);
return (await _catalog.GetLimitsAsync(planTier, cancellationToken)).MaxMenuAi3dPerMonth;
}
private static string UsageKey(string cafeId) =>
+11
View File
@@ -16,6 +16,7 @@ public interface IMenuService
Task<MenuItemDto?> CreateItemAsync(string cafeId, CreateMenuItemRequest request, CancellationToken cancellationToken = default);
Task<MenuItemDto?> UpdateItemAsync(string cafeId, string id, UpdateMenuItemRequest request, CancellationToken cancellationToken = default);
Task<MenuItemDto?> SetAvailabilityAsync(string cafeId, string id, bool isAvailable, CancellationToken cancellationToken = default);
Task<bool> DeleteItemAsync(string cafeId, string id, CancellationToken cancellationToken = default);
}
public class MenuService : IMenuService
@@ -192,6 +193,16 @@ public class MenuService : IMenuService
return ToItemDto(entity);
}
public async Task<bool> DeleteItemAsync(string cafeId, string id, CancellationToken cancellationToken = default)
{
var entity = await _db.MenuItems.FirstOrDefaultAsync(i => i.Id == id && i.CafeId == cafeId, cancellationToken);
if (entity is null) return false;
entity.DeletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
return true;
}
private static string? NormalizeOptionalText(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+126 -1
View File
@@ -67,6 +67,19 @@ public interface IOrderService
RecordPaymentsRequest request,
string? userId,
CancellationToken cancellationToken = default);
Task<(IReadOnlyList<OrderDto> Items, int Total)> GetClosedOrdersAsync(
string cafeId,
DateOnly date,
string? branchId,
int page,
int pageSize,
CancellationToken cancellationToken = default);
Task<OrderServiceResult<OrderDto>> CorrectPaymentsAsync(
string cafeId,
string orderId,
CorrectPaymentsRequest request,
string? userId,
CancellationToken cancellationToken = default);
}
public class OrderService : IOrderService
@@ -1119,6 +1132,117 @@ public class OrderService : IOrderService
return new OrderServiceResult<IReadOnlyList<PaymentDto>>(true, dtos);
}
public async Task<(IReadOnlyList<OrderDto> Items, int Total)> GetClosedOrdersAsync(
string cafeId,
DateOnly date,
string? branchId,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
var (utcStart, utcEnd) = IranCalendar.GetUtcRangeForIranDay(date);
var query = _db.Orders
.Where(o => o.CafeId == cafeId
&& (o.Status == OrderStatus.Delivered || o.Status == OrderStatus.Cancelled)
&& o.CreatedAt >= utcStart
&& o.CreatedAt < utcEnd);
if (!string.IsNullOrEmpty(branchId))
query = query.Where(o => o.BranchId == branchId);
var total = await query.CountAsync(cancellationToken);
var orders = await ApplyOrderIncludes(query)
.OrderByDescending(o => o.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.AsNoTracking()
.ToListAsync(cancellationToken);
return (orders.Select(MapOrder).ToList(), total);
}
public async Task<OrderServiceResult<OrderDto>> CorrectPaymentsAsync(
string cafeId,
string orderId,
CorrectPaymentsRequest request,
string? userId,
CancellationToken cancellationToken = default)
{
var order = await LoadOrderAsync(cafeId, orderId, cancellationToken);
if (order is null)
return new OrderServiceResult<OrderDto>(false, null, "ORDER_NOT_FOUND");
// Resolve the payments being voided — they must belong to this order and
// still be live. Payments are never deleted; voiding marks them Refunded
// so the original سند stays visible in history and audit.
var toVoid = new List<Payment>();
foreach (var paymentId in request.VoidPaymentIds.Distinct())
{
var payment = order.Payments.FirstOrDefault(p => p.Id == paymentId);
if (payment is null)
return new OrderServiceResult<OrderDto>(false, null, "PAYMENT_NOT_FOUND", "voidPaymentIds");
if (payment.Status != PaymentStatus.Completed)
return new OrderServiceResult<OrderDto>(false, null, "PAYMENT_ALREADY_REFUNDED", "voidPaymentIds");
toVoid.Add(payment);
}
var branchId = await ResolveOrderBranchIdAsync(order, cafeId, cancellationToken);
if (string.IsNullOrEmpty(branchId))
return new OrderServiceResult<OrderDto>(false, null, "NO_OPEN_SHIFT", "branchId");
// Corrections move money through the drawer, so they need an open shift
// exactly like recording a payment does.
var shiftCheck = await _shiftService.RequireOpenShiftForBranchAsync(cafeId, branchId, cancellationToken);
if (!shiftCheck.Success)
return new OrderServiceResult<OrderDto>(false, null, shiftCheck.ErrorCode, shiftCheck.Field);
var openShift = shiftCheck.Data!;
foreach (var payment in toVoid)
payment.Status = PaymentStatus.Refunded;
var replacements = request.Replacements.Select(p => new Payment
{
OrderId = orderId,
Method = p.Method,
Amount = p.Amount,
Reference = p.Reference,
Status = PaymentStatus.Completed
}).ToList();
_db.Payments.AddRange(replacements);
// Fully paid again after the correction → ensure the order is closed;
// underpaid → leave the status alone (the remainder can be collected
// through the normal payment flow later). EF navigation fixup may have
// already appended the replacements to order.Payments, so exclude them
// by reference to avoid double-counting.
var paidTotal = order.Payments
.Where(p => p.Status == PaymentStatus.Completed && !replacements.Contains(p))
.Sum(p => p.Amount)
+ replacements.Sum(p => p.Amount);
if (paidTotal >= order.Total && OpenForPaymentStatuses.Contains(order.Status))
order.Status = OrderStatus.Delivered;
await _db.SaveChangesAsync(cancellationToken);
var createdBy = userId ?? openShift.OpenedByUserId;
foreach (var payment in toVoid)
{
await _shiftService.RecordTransactionAsync(
cafeId, openShift.Id, CashTransactionType.Refund, payment.Method,
payment.Amount, createdBy, orderId, request.Reason, cancellationToken);
}
foreach (var payment in replacements)
{
await _shiftService.RecordTransactionAsync(
cafeId, openShift.Id, CashTransactionType.OrderPayment, payment.Method,
payment.Amount, createdBy, orderId, request.Reason, cancellationToken);
}
return new OrderServiceResult<OrderDto>(true, MapOrder(order));
}
private static IQueryable<Order> ApplyOrderIncludes(IQueryable<Order> query) =>
query
.Include(o => o.Items)
@@ -1221,5 +1345,6 @@ public class OrderService : IOrderService
i.UnitPrice,
i.Notes,
i.IsVoided,
i.VoidedAt)).ToList());
i.VoidedAt)).ToList(),
o.Source);
}
+16 -18
View File
@@ -99,27 +99,25 @@ public class PlanLimitChecker : IPlanLimitChecker
return (false, "PLAN_LIMIT_REACHED", "Branch limit reached for your plan. Please upgrade.");
}
var smsCampaignPath = $"/api/cafes/{cafeId}/sms/campaign";
if (path.Equals(smsCampaignPath, StringComparison.OrdinalIgnoreCase) ||
path.Equals($"{smsCampaignPath}/", StringComparison.OrdinalIgnoreCase))
var tablesPath = $"/api/cafes/{cafeId}/tables";
if (path.StartsWith(tablesPath, StringComparison.OrdinalIgnoreCase) &&
(path.Equals(tablesPath, StringComparison.OrdinalIgnoreCase) ||
path.Equals($"{tablesPath}/", StringComparison.OrdinalIgnoreCase)))
{
var limitsSms = await _platformCatalog.GetLimitsAsync(tier, cancellationToken);
var maxSms = limitsSms.MaxSmsPerMonth;
if (maxSms == 0)
return (false, "PLAN_LIMIT_REACHED", "SMS is not available on the Free plan. Please upgrade.");
if (maxSms == int.MaxValue)
return (true, null, null);
var monthKey = $"sms:usage:{cafeId}:{DateTime.UtcNow:yyyy-MM}";
var redis = _redis.GetDatabase();
var used = await redis.StringGetAsync(monthKey);
var usedCount = used.HasValue ? (int)used : 0;
if (usedCount >= maxSms)
return (false, "PLAN_LIMIT_REACHED", "Monthly SMS limit reached for your plan. Please upgrade.");
var limitsTables = await _platformCatalog.GetLimitsAsync(tier, cancellationToken);
var maxTables = limitsTables.MaxTables;
if (maxTables != int.MaxValue)
{
var tableCount = await _db.Tables.CountAsync(t => t.CafeId == cafeId, cancellationToken);
if (tableCount >= maxTables)
return (false, "PLAN_LIMIT_REACHED", "Table limit reached for your plan. Please upgrade.");
}
}
// NOTE: SMS is deliberately NOT plan-gated — marketing SMS is
// bring-your-own-provider (the café's own API key + sender line), so the
// café's provider account is the only limit.
return (true, null, null);
}
}
+12 -3
View File
@@ -53,6 +53,7 @@ public class PublicService : IPublicService
private readonly IBranchIdentityService _identity;
private readonly IAbuseProtectionService _abuse;
private readonly IHttpContextAccessor _http;
private readonly Meezi.Infrastructure.Services.Platform.IPlatformCatalogService _catalog;
public PublicService(
AppDbContext db,
@@ -62,7 +63,8 @@ public class PublicService : IPublicService
IBranchMenuService branchMenu,
IBranchIdentityService identity,
IAbuseProtectionService abuse,
IHttpContextAccessor http)
IHttpContextAccessor http,
Meezi.Infrastructure.Services.Platform.IPlatformCatalogService catalog)
{
_db = db;
_orders = orders;
@@ -72,8 +74,13 @@ public class PublicService : IPublicService
_identity = identity;
_abuse = abuse;
_http = http;
_catalog = catalog;
}
/// <summary>Free menus show a Meezi watermark; the `watermark_removed` feature (paid) hides it.</summary>
private async Task<bool> ShowWatermarkAsync(Cafe cafe, CancellationToken ct) =>
!await _catalog.IsFeatureEnabledForCafeAsync(cafe.Id, cafe.PlanTier, "watermark_removed", ct);
public Task<IReadOnlyList<CafeDiscoverDto>> DiscoverAsync(
DiscoverFilterParams filters,
CancellationToken cancellationToken = default) =>
@@ -190,7 +197,8 @@ public class PublicService : IPublicService
.Where(c => c.Items.Count > 0)
.ToList();
return new PublicMenuDto(cafe.Id, cafe.Name, cafe.Slug, CafeThemeMapping.FromJson(cafe.ThemeJson), grouped);
return new PublicMenuDto(cafe.Id, cafe.Name, cafe.Slug, CafeThemeMapping.FromJson(cafe.ThemeJson), grouped,
await ShowWatermarkAsync(cafe, cancellationToken));
}
public async Task<(GuestOrderPlacedDto? Data, string? ErrorCode, string? ErrorMessage)> PlaceOrderAsync(
@@ -357,7 +365,8 @@ public class PublicService : IPublicService
.OrderBy(c => categoryById.GetValueOrDefault(c.Id)?.SortOrder ?? 0)
.ToList();
return new PublicMenuDto(cafe.Id, cafe.Name, cafe.Slug, CafeThemeMapping.FromJson(cafe.ThemeJson), grouped);
return new PublicMenuDto(cafe.Id, cafe.Name, cafe.Slug, CafeThemeMapping.FromJson(cafe.ThemeJson), grouped,
await ShowWatermarkAsync(cafe, cancellationToken));
}
public async Task<(GuestQrOrderPlacedDto? Data, string? ErrorCode, string? Message)> PlaceBranchGuestOrderAsync(
+11 -20
View File
@@ -1,13 +1,13 @@
using Meezi.Core.Constants;
using Meezi.Core.Enums;
namespace Meezi.API.Services;
/// <summary>
/// Report-history window checks. Takes the (admin-editable) max-history days
/// directly so the limit comes from the plan catalog, not a hardcoded tier table.
/// </summary>
public static class ReportPlanGate
{
public static bool IsDateInRange(PlanTier tier, DateOnly date, DateOnly todayIran)
public static bool IsDateInRange(int maxDays, DateOnly date, DateOnly todayIran)
{
var maxDays = PlanLimits.MaxReportHistoryDays(tier);
if (maxDays == int.MaxValue)
return date <= todayIran;
@@ -16,16 +16,15 @@ public static class ReportPlanGate
}
public static (DateOnly From, DateOnly To)? ClampRange(
PlanTier tier,
int maxDays,
DateOnly from,
DateOnly to,
DateOnly todayIran)
{
if (from > to) return null;
if (!IsDateInRange(tier, to, todayIran) || !IsDateInRange(tier, from, todayIran))
if (!IsDateInRange(maxDays, to, todayIran) || !IsDateInRange(maxDays, from, todayIran))
return null;
var maxDays = PlanLimits.MaxReportHistoryDays(tier);
if (maxDays == int.MaxValue)
return (from, to);
@@ -36,16 +35,8 @@ public static class ReportPlanGate
return (clampedFrom, clampedTo);
}
public static string LimitMessage(PlanTier tier)
{
var days = PlanLimits.MaxReportHistoryDays(tier);
return tier switch
{
PlanTier.Free =>
"Daily reports on the Free plan are limited to today and the previous 7 days. Upgrade to Pro for 90 days of history.",
PlanTier.Pro =>
"Daily reports on the Pro plan are limited to the last 90 days. Upgrade to Business for unlimited history.",
_ => "Report date is outside your plan range."
};
}
public static string LimitMessage(int maxDays) =>
maxDays == int.MaxValue
? "Report date is outside the allowed range."
: $"Daily reports on your plan are limited to the last {maxDays} days. Upgrade for more history.";
}
@@ -24,6 +24,11 @@ public interface IReservationService
string reservationId,
ReservationStatus status,
CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(
string cafeId,
string reservationId,
CancellationToken cancellationToken = default);
}
public class ReservationService : IReservationService
@@ -118,6 +123,25 @@ public class ReservationService : IReservationService
return Map(entity);
}
public async Task<bool> DeleteAsync(
string cafeId,
string reservationId,
CancellationToken cancellationToken = default)
{
var entity = await _db.TableReservations
.FirstOrDefaultAsync(r => r.Id == reservationId && r.CafeId == cafeId, cancellationToken);
if (entity is null) return false;
// Soft delete: TableReservation has a global DeletedAt query filter.
entity.DeletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
if (!string.IsNullOrEmpty(entity.TableId))
await _kdsNotifier.NotifyTableStatusChangedAsync(cafeId, cancellationToken);
return true;
}
internal static ReservationDto Map(TableReservation r) => new(
r.Id,
r.CafeId,
+1 -1
View File
@@ -62,7 +62,7 @@ public class ReviewService : IReviewService
DiscoverFilterParams filters,
CancellationToken cancellationToken = default)
{
var query = _db.Cafes.Where(c => c.IsVerified && c.DeletedAt == null);
var query = _db.Cafes.Where(c => c.IsVerified && c.DeletedAt == null && c.ShowOnKoja);
if (!string.IsNullOrWhiteSpace(filters.City))
query = query.Where(c => c.City != null && c.City.Contains(filters.City));
+6 -1
View File
@@ -228,11 +228,16 @@ public class ShiftService : IShiftService
.Where(t => t.Type == CashTransactionType.OrderPayment && t.Method == PaymentMethod.Cash)
.Sum(t => t.Amount);
// Payment corrections (اصلاح سند) refund cash back out of the drawer.
var cashRefunds = transactions
.Where(t => t.Type == CashTransactionType.Refund && t.Method == PaymentMethod.Cash)
.Sum(t => t.Amount);
var withdrawals = transactions
.Where(t => t.Type == CashTransactionType.Withdrawal)
.Sum(t => t.Amount);
return openingCash + cashPayments - withdrawals;
return openingCash + cashPayments - cashRefunds - withdrawals;
}
private static ShiftDto ToDto(Shift s) => new(
+103 -20
View File
@@ -1,6 +1,4 @@
using Meezi.API.Models.Crm;
using Meezi.Core.Constants;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Core.Utilities;
using Meezi.Infrastructure.Data;
@@ -11,14 +9,25 @@ namespace Meezi.API.Services;
public interface ISmsMarketingService
{
Task<SmsUsageDto> GetUsageAsync(string cafeId, PlanTier planTier, CancellationToken cancellationToken = default);
Task<SmsUsageDto> GetUsageAsync(string cafeId, CancellationToken cancellationToken = default);
Task<SmsSettingsDto> GetSettingsAsync(string cafeId, CancellationToken cancellationToken = default);
Task<(bool Success, SmsSettingsDto? Data, string? ErrorCode, string? Message)> UpdateSettingsAsync(
string cafeId,
UpdateSmsSettingsRequest request,
CancellationToken cancellationToken = default);
Task<SmsBalanceDto> GetBalanceAsync(string cafeId, CancellationToken cancellationToken = default);
Task<(bool Success, SmsCampaignResult? Data, string? ErrorCode, string? Message)> SendCampaignAsync(
string cafeId,
PlanTier planTier,
SendSmsCampaignRequest request,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Marketing SMS is bring-your-own-provider: each café configures its OWN
/// Kavenegar API key + sender line and pays its provider directly. The platform
/// neither sells SMS nor meters it against plan limits; the monthly counter is
/// informational only. (Login OTPs still go through the platform account.)
/// </summary>
public class SmsMarketingService : ISmsMarketingService
{
private readonly AppDbContext _db;
@@ -35,40 +44,111 @@ public class SmsMarketingService : ISmsMarketingService
_redis = redis;
}
public async Task<SmsUsageDto> GetUsageAsync(
string cafeId,
PlanTier planTier,
CancellationToken cancellationToken = default)
public async Task<SmsUsageDto> GetUsageAsync(string cafeId, CancellationToken cancellationToken = default)
{
var month = DateTime.UtcNow.ToString("yyyy-MM");
var used = await GetUsedCountAsync(cafeId, month);
var limit = PlanLimits.MaxSmsPerMonth(planTier);
return new SmsUsageDto(used, limit == int.MaxValue ? -1 : limit, month);
// -1 = no platform limit; the café's own provider account is the only cap.
return new SmsUsageDto(used, -1, month);
}
public async Task<SmsSettingsDto> GetSettingsAsync(string cafeId, CancellationToken cancellationToken = default)
{
var cafe = await _db.Cafes.AsNoTracking()
.Where(c => c.Id == cafeId)
.Select(c => new { c.SmsApiKey, c.SmsSenderNumber })
.FirstOrDefaultAsync(cancellationToken);
if (cafe is null || string.IsNullOrWhiteSpace(cafe.SmsApiKey))
return new SmsSettingsDto(false, null, cafe?.SmsSenderNumber);
return new SmsSettingsDto(true, MaskApiKey(cafe.SmsApiKey), cafe.SmsSenderNumber);
}
public async Task<(bool Success, SmsSettingsDto? Data, string? ErrorCode, string? Message)> UpdateSettingsAsync(
string cafeId,
UpdateSmsSettingsRequest request,
CancellationToken cancellationToken = default)
{
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
if (cafe is null)
return (false, null, "NOT_FOUND", "Cafe not found.");
var apiKey = request.ApiKey?.Trim();
var sender = request.SenderNumber?.Trim();
// Empty strings clear the configuration (turn SMS off for this café).
if (string.IsNullOrEmpty(apiKey) && string.IsNullOrEmpty(sender))
{
cafe.SmsApiKey = null;
cafe.SmsSenderNumber = null;
await _db.SaveChangesAsync(cancellationToken);
return (true, new SmsSettingsDto(false, null, null), null, null);
}
if (string.IsNullOrEmpty(apiKey) && string.IsNullOrEmpty(cafe.SmsApiKey))
return (false, null, "VALIDATION_ERROR", "API key is required.");
if (string.IsNullOrEmpty(sender))
return (false, null, "VALIDATION_ERROR", "Sender number is required.");
// A new key was provided — verify it against the provider before saving so
// the owner gets immediate feedback on a typo'd key.
if (!string.IsNullOrEmpty(apiKey))
{
var info = await _smsService.GetAccountInfoAsync(apiKey, cancellationToken);
if (info is null)
return (false, null, "SMS_KEY_INVALID", "The API key was rejected by the SMS provider.");
cafe.SmsApiKey = apiKey;
}
cafe.SmsSenderNumber = sender;
await _db.SaveChangesAsync(cancellationToken);
return (true, new SmsSettingsDto(true, MaskApiKey(cafe.SmsApiKey!), cafe.SmsSenderNumber), null, null);
}
public async Task<SmsBalanceDto> GetBalanceAsync(string cafeId, CancellationToken cancellationToken = default)
{
var apiKey = await _db.Cafes.AsNoTracking()
.Where(c => c.Id == cafeId)
.Select(c => c.SmsApiKey)
.FirstOrDefaultAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(apiKey))
return new SmsBalanceDto(0, "master", false);
var info = await _smsService.GetAccountInfoAsync(apiKey, cancellationToken);
return info is not null
? new SmsBalanceDto(info.RemainCredit, info.AccountType, true)
: new SmsBalanceDto(0, "master", false);
}
public async Task<(bool Success, SmsCampaignResult? Data, string? ErrorCode, string? Message)> SendCampaignAsync(
string cafeId,
PlanTier planTier,
SendSmsCampaignRequest request,
CancellationToken cancellationToken = default)
{
var maxSms = PlanLimits.MaxSmsPerMonth(planTier);
if (maxSms == 0)
return (false, null, "PLAN_LIMIT_REACHED", "SMS is not available on the Free plan.");
var cafe = await _db.Cafes.AsNoTracking()
.Where(c => c.Id == cafeId)
.Select(c => new { c.SmsApiKey, c.SmsSenderNumber })
.FirstOrDefaultAsync(cancellationToken);
if (cafe is null || string.IsNullOrWhiteSpace(cafe.SmsApiKey) || string.IsNullOrWhiteSpace(cafe.SmsSenderNumber))
return (false, null, "SMS_NOT_CONFIGURED",
"Configure your own SMS provider (API key + sender line) in the SMS settings first.");
var phones = await ResolvePhonesAsync(cafeId, request, cancellationToken);
if (phones.Count == 0)
return (false, null, "NOT_FOUND", "No recipients found.");
var month = DateTime.UtcNow.ToString("yyyy-MM");
var used = await GetUsedCountAsync(cafeId, month);
if (maxSms != int.MaxValue && used + phones.Count > maxSms)
return (false, null, "PLAN_LIMIT_REACHED", "Monthly SMS limit would be exceeded.");
var result = await _smsService.SendBulkAsync(phones, request.Message, cancellationToken);
var result = await _smsService.SendBulkWithCredentialsAsync(
cafe.SmsApiKey, cafe.SmsSenderNumber, phones, request.Message, cancellationToken);
if (result.SentCount > 0)
{
var month = DateTime.UtcNow.ToString("yyyy-MM");
await IncrementUsageAsync(cafeId, month, result.SentCount);
}
return (true, new SmsCampaignResult(result.SentCount, result.FailedCount), null, null);
}
@@ -94,6 +174,9 @@ public class SmsMarketingService : ISmsMarketingService
return await query.Select(c => c.Phone).Distinct().ToListAsync(cancellationToken);
}
private static string MaskApiKey(string apiKey) =>
apiKey.Length <= 4 ? "****" : $"****{apiKey[^4..]}";
private async Task<int> GetUsedCountAsync(string cafeId, string month)
{
var redis = _redis.GetDatabase();

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