Compare commits

..

42 Commits

Author SHA1 Message Date
soroush.asadi b5a6b1b68d fix(website): accurate Iran border on homepage map + slow on/off marker blink
CI/CD / CI · API (dotnet build + test) (pull_request) Successful in 46s
CI/CD / CI · Admin API (dotnet build) (pull_request) Successful in 31s
CI/CD / CI · Dashboard (tsc) (pull_request) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (pull_request) Successful in 35s
CI/CD / CI · Website (tsc) (pull_request) Successful in 45s
CI/CD / CI · Koja (tsc) (pull_request) Successful in 51s
CI/CD / Deploy · all services (pull_request) Has been skipped
Replaced the rough 40-point hand-drawn polygon with the real national border (74 vertices, Natural Earth via world.geo.json) and fitted the projection bounding box to Iran's true extent, so the silhouette is recognisable and café markers stay aligned. Reworked the marker animation from a radar-style expanding ring into a slow 3.6s ease-in-out lamp fade (opacity 1->0.2->1) with a halo that glows on and off in sync. Verified via the SVG timeline: opacity 1.0 at 0s, 0.2 at 1.8s, 1.0 at 3.6s.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:38:25 +03:30
soroush.asadi f813cc4854 test: repair test suite broken by feature drift (red -> 81 passing)
CI/CD / CI · API (dotnet build + test) (pull_request) Successful in 43s
CI/CD / CI · Admin API (dotnet build) (pull_request) Successful in 44s
CI/CD / CI · Dashboard (tsc) (pull_request) Successful in 1m7s
CI/CD / CI · Admin Web (tsc) (pull_request) Successful in 36s
CI/CD / CI · Website (tsc) (pull_request) Successful in 45s
CI/CD / CI · Koja (tsc) (pull_request) Successful in 51s
CI/CD / Deploy · all services (pull_request) Has been skipped
The test project no longer compiled: recent feature commits changed
interfaces and DTOs without updating the test doubles/call sites, so the
whole suite (and therefore CI) was failing to build.

- NoOpInventoryService: add IInventoryService.GetPurchasesSummaryAsync and
  the new string? userId param on AdjustAsync.
- NoOpLoyaltyService: add ILoyaltyService.RedeemOnOrderAsync.
- NoOpOrderNotificationService: add NotifyCallWaiterAsync.
- New NoOpAbuseProtectionService and NoOpMediaStorageService test doubles.
- QrMenuTests: ReviewService/PublicService gained IAbuseProtectionService +
  IHttpContextAccessor (and ReviewService an IMediaStorageService); wire the
  new no-op doubles + a real HttpContextAccessor.
- PrintingTests: OrderDto gained a DisplayNumber int between CreatedAt and
  Items; pass it.
- DiscoverFilterTests: add missing `using Xunit;` and the new openNow arg on
  DiscoverFilterParams.FromQuery.

Result: dotnet test -> Passed: 81, Failed: 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 18:44:41 +03:30
soroush.asadi 024a455ab3 fix: menu item/category create, demo banner reach, token refresh, blog publish
Dashboard & API bug fixes for owner-reported breakage:

- MenuController validators (PosValidators): NameEn was required but the
  dashboard sends null when blank, so every manual menu-item create failed
  and category create failed 100% (the form never sends nameEn). Now optional.
- DemoDataBanner: only showed when a cafe was exactly empty, so
  showcase-seeded cafes (2-3 cats / 3-5 items) could never trigger the
  one-click seed. Widened gate to sparse menus (<5 cats && <10 items) and
  added a clear "nothing to add" message when already populated.
- client.ts: added one-time JWT refresh-and-retry on 401 (shared in-flight
  promise) before bouncing to /login. Expired access tokens silently broke
  ticket list, add-table, and other reads.
- Surface API errors as toasts on menu + table mutations (were swallowed
  silently, so failures looked like "nothing happens").
- Admin blog editor: saving an edit dropped IsPublished (defaulted false,
  silently unpublishing the post on every save); now persisted with a
  toggle. Also hoisted the inner Field component to module scope - it was
  remounting every input on each keystroke and dropping focus.
- Admin integrations: replaced raw radio gateway selector with a styled
  RadioDot matching the iOS toggles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 18:25:34 +03:30
soroush.asadi f687178238 fix(migration): add [Migration] attribute so EF discovers AddCafeLocation
CI/CD / CI · API (dotnet build + test) (push) Successful in 57s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 46s
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 1m27s
Manual migration was missing the [Migration("...")] and [DbContext] attributes
that EF Core requires to discover and apply migrations via MigrateAsync().
Without them the Latitude/Longitude columns were never added to Cafes, causing
every query involving the Cafe entity to throw 42703 column-not-found errors.

Columns must be applied manually on the server before the next deploy:
  ALTER TABLE "Cafes" ADD COLUMN IF NOT EXISTS "Latitude" double precision, ...

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 15:59:08 +03:30
soroush.asadi dc07eb9594 ci: prune dangling images after successful deploy
CI/CD / CI · API (dotnet build + test) (push) Successful in 58s
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 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 23s
Removes <none>-tagged images left over from previous builds.
Only affects untagged images (dangling=true) — never touches other
projects' named images (soroushasadi-site, drsousan, etc.).
Also logs disk usage after prune.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 15:35:18 +03:30
soroush.asadi 5e980cdfc0 feat: plan limits, café location, nearby API, Iran map section
CI/CD / CI · API (dotnet build + test) (push) Successful in 56s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 49s
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 44s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 3m15s
• PlanLimits: add MaxMenuCategories (Free→3), MaxMenuItems (Free→30),
  CanAccessCrm and CanAccessStatistics (Pro+ only)
• MenuController: enforce category/item limits before create (403 + PLAN_LIMIT_REACHED)
• Cafe entity + EF migration: Latitude/Longitude (double?, nullable)
• CafeSettingsController: PATCH accepts lat/lng with range validation
• PublicController: GET /api/public/map-markers (marketing SVG map feed)
  and GET /api/public/nearby (Koja nearby-cafés with Haversine sort)
• Dashboard settings: location card with OSM iframe preview + Neshan link
• Website homepage: IranMapSection — stylised SVG silhouette with
  SMIL-animated blinking dots at real café coordinates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 15:09:09 +03:30
soroush.asadi 665e3ca279 fix(demo): scope category/item IDs per café to prevent PK collisions
CI/CD / CI · API (dotnet build + test) (push) Successful in 56s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 52s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m3s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
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 1m31s
DemoMenuSeeder used hardcoded IDs like cat_demo_coffee for every café.
If the dev seeder (runs when ASPNETCORE_ENVIRONMENT=Development) already
inserted those IDs for cafe_demo_001, a production café clicking
"Add demo data" hit a primary-key constraint violation.

Fix: EnsureMenuAsync now accepts useScopedIds=true which prefixes every
category and item ID with cafeId (e.g. cafe_abc_cat_demo_coffee).
CategoryId FKs on items are remapped through the same function.

DemoSeedService (the API endpoint handler) always passes useScopedIds=true.
DevelopmentDataSeeder keeps useScopedIds=false (default) so the existing
cafe_demo_001 rows in dev databases are not touched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 13:59:48 +03:30
soroush.asadi c3ca39ed15 fix(ci): staged deploy with crash detection and full diagnostics
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m4s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 35s
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 54s
CI/CD / Deploy · all services (push) Successful in 22s
- Start api alone first; web/admin-api each wait for their own step
  so a health-check wait never blocks unrelated services
- Detect crash-loops via RestartCount > 1 (restart:unless-stopped hides
  the exited state behind rapid restarts — count is reliable)
- Dump up to 120 lines of api logs immediately on crash/timeout
- Log infra network state (json) in attach step + failure dump so we
  can see exactly which aliases are registered on meezi_default
- admin-api and admin-web are now started in separate steps, same pattern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 13:44:52 +03:30
soroush.asadi dac59cd180 fix(ci): use --alias when connecting infra to meezi_default network
CI/CD / CI · API (dotnet build + test) (push) Successful in 44s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 47s
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 48s
CI/CD / Deploy · all services (push) Successful in 1m36s
Without --alias, meezi-db joins meezi_default but is only reachable
as "meezi-db". The API uses Host=postgres — DNS lookup fails after
~5s, migration throws, container crashes.

Fix: disconnect first, then reconnect with service-name aliases
so "postgres" and "redis" resolve correctly on meezi_default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 09:36:03 +03:30
soroush.asadi c3ea07d6e4 fix(ci): attach infra containers to meezi_default network before deploy
CI/CD / CI · API (dotnet build + test) (push) Successful in 40s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 36s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m2s
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 48s
CI/CD / Deploy · all services (push) Failing after 10s
postgres/redis were created before the compose project name was locked
to "meezi", so they're on a different Docker network. New app containers
join meezi_default — the API crashes immediately because it can't reach
Host=postgres.

Fix: create meezi_default if needed, then docker network connect
meezi-db and meezi-redis to it before starting the app containers.

Also dump API and admin-api logs on failure to make future failures
easier to diagnose.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 08:39:35 +03:30
soroush.asadi aea1d20fdc fix(admin): redirect to edit page after creating blog post
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 1m3s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 43s
CI/CD / CI · Koja (tsc) (push) Successful in 48s
CI/CD / Deploy · all services (push) Failing after 2m2s
Root cause: after successful creation the form stayed on /blog/new.
User couldn't tell it worked, clicked Save again, the second attempt
hit the unique slug constraint and showed an error — making it look
like creation was broken.

Fix: adminPost is now typed, onSuccess redirects to /blog/{id} on new
posts so the user lands on the edit page immediately.

Also fixes commentCount being undefined in the list (MapPost now
includes comment count via eager-loaded Comments).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 08:24:57 +03:30
soroush.asadi 9f002433c7 fix(ci): explicitly stop old app containers before redeploy
CI/CD / CI · API (dotnet build + test) (push) Successful in 39s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
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 43s
CI/CD / CI · Koja (tsc) (push) Successful in 48s
CI/CD / Deploy · all services (push) Failing after 7s
Existing containers lack compose project labels so Compose cannot claim
them — it tries to CREATE alongside them and hits a name conflict.

Fix: stop + rm only the 6 meezi app containers by name before compose up.
Postgres, redis, and all other projects are never touched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 08:08:19 +03:30
soroush.asadi 5ba09c2ef1 fix(website): move launch countdown to 1 Tir 1405 (June 22, 2026)
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 1m3s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 43s
CI/CD / CI · Koja (tsc) (push) Successful in 48s
CI/CD / Deploy · all services (push) Failing after 1m27s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 07:59:22 +03:30
soroush.asadi 631cac8c3c fix(ci): never touch postgres/redis in deploy — app containers only
CI/CD / CI · API (dotnet build + test) (push) Successful in 54s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 45s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m3s
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 meezi-redis container was created before the name:meezi label existed
in docker-compose.yml, so Compose doesn't recognise it as its own project
container and tries to CREATE a new one, causing the name conflict.

Real fix: postgres and redis are persistent infrastructure — CI should
never restart them. Remove them from all deploy steps entirely.
Only api, web, website, koja, admin-api, admin-web are cycled on deploy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 07:56:16 +03:30
soroush.asadi 1a6a0dc495 fix(ci): split infra vs app services to prevent redis name conflict
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) 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) Failing after 10s
docker compose up without --no-recreate tries to recreate postgres/redis
when it detects a config change or finds a stopped container, which causes
"container name already in use" when the container is still running.

Fix: infrastructure (postgres, redis) uses --no-recreate so a healthy
container is never touched. App services (api, web, website, koja,
admin-api, admin-web) use --force-recreate so freshly-built images are
always applied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 07:47:07 +03:30
soroush.asadi ffdc218e20 fix(admin): add missing useEffect import in admin-website-screens
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m8s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 54s
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 44s
CI/CD / CI · Koja (tsc) (push) Successful in 47s
CI/CD / Deploy · all services (push) Failing after 3m34s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 00:29:04 +03:30
soroush.asadi 260429afba feat: demo data seeder — one-click setup for new cafés
CI/CD / CI · API (dotnet build + test) (push) Successful in 47s
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
Adds POST /api/cafes/{cafeId}/demo/seed (owner-only) that seeds:
- 9% default VAT tax
- 7 menu categories + 59+ items via DemoMenuSeeder
- 15 inventory ingredients (coffee shop staples)
- 10 tables across 3 floors on the first active branch

Frontend DemoDataBanner appears on menu, tables, and inventory
pages when the café is completely empty, so owners can populate
demo data in one click instead of entering everything manually.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 00:27:34 +03:30
soroush.asadi ae5896d440 fix: credentials lost on refresh + admin UI improvements + CI safe deploy
CI/CD / CI · API (dotnet build + test) (push) Successful in 39s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m2s
CI/CD / CI · Admin Web (tsc) (push) Failing after 35s
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 skipped
- dashboard layout: wait for Zustand _hasHydrated before redirecting to /login
  (was redirecting on first render before localStorage was read)
- admin shell: same fix using new _hasHydrated on admin auth store
- admin-auth.store: add _hasHydrated + onRehydrateStorage to mirror merchant store
- AdminPlansScreen: replace direct cache mutation with per-plan PlanCard component
  that owns its own useState — fixes other plans disappearing after save
- AdminSettingsScreen: detect boolean values and render iOS-style Toggle switches
- AdminIntegrationsScreen: replace all <input type=checkbox> with Toggle switches;
  replace OpenAI model text input with <select> dropdown (gpt-4o-mini/4o/4-turbo/4/3.5)
- blog editor: fix form never syncing existing post data into state (editing was broken);
  all fields now use local form state, save uses form directly
- blog links: fix broken relative hrefs (website/blog/new → /admin/website/blog/new)
  and back button using proper Link components
- ci-cd: remove image prune step entirely — never removes containers or images

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:56:16 +03:30
soroush.asadi aec5b21f98 fix: lock compose project name to 'meezi', scope image prune to meezi only
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m40s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 1m22s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m0s
CI/CD / CI · Admin Web (tsc) (push) Successful in 33s
CI/CD / CI · Website (tsc) (push) Successful in 43s
CI/CD / CI · Koja (tsc) (push) Successful in 47s
CI/CD / Deploy · all services (push) Failing after 2s
Prevents runner workspace collisions with other projects (DrSousan etc.)
causing containers to be treated as orphans and stopped on deploy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:45:07 +03:30
soroush.asadi 57c83185da fix: zarinpal silent failure + show payment error in checkout
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m15s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
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 43s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 4m45s
Previously the subscribe mutation had no onError handler, so any
payment initiation failure (wrong merchant ID, ZarinPal API error,
disabled payment method) would silently re-enable the button with
no user feedback. Now errors are shown below the Pay button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 22:40:04 +03:30
soroush.asadi cd1af30bbc fix: sidebar accordion + koja slug + support ticket LINQ crash
CI/CD / CI · API (dotnet build + test) (push) Successful in 5m50s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 32s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m3s
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 48s
CI/CD / Deploy · all services (push) Has been cancelled
Sidebar:
- All groups start collapsed on first load (v4 storage key resets old state)
- Opening one group closes all others (accordion)
- Navigating to a section opens only that section's group

Koja slug:
- SlugHelper: Persian->Latin transliteration, slug validation
- Registration accepts optional custom slug; auto-derives from cafe name
- Slug can be updated from dashboard Settings -> Profile
- Settings PATCH validates uniqueness (SLUG_TAKEN) and format (INVALID_SLUG)
- koja.meezi.ir/{slug} now redirects to /fa/cafe/{slug} (short URL support)

Bug fix:
- SupportTicketService: cafeId/status filters applied before Select() projection
  to fix EF "could not be translated" crash on the support tickets page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 22:28:25 +03:30
soroush.asadi 38e3f6a5a2 fix(admin-auth): normalize phone before OTP validation to fix 400 on verify-otp
CI/CD / CI · API (dotnet build + test) (push) Successful in 46s
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 34s
CI/CD / CI · Website (tsc) (push) Successful in 43s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 27s
VerifyOtpRequestValidator was passing the raw phone string to
IsValidIranMobile which requires a pre-normalized 11-digit "09…" string.
Any other format (country code prefix, Persian digits, etc.) failed
validation instantly — causing verify-otp to return HTTP 400 in ~2ms
before the service logic could ever run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 21:00:37 +03:30
soroush.asadi 5ae350e25b fix: auto-create default branch on cafe registration + backfill existing
CI/CD / CI · API (dotnet build + test) (push) Successful in 42s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 34s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m3s
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) Has been cancelled
- VerifyRegisterAsync: create a Branch named after the café alongside
  the Café and Owner, so new owners can use the dashboard immediately
  without hitting the "select a branch" gate
- PlatformDataSeeder: EnsureDefaultBranchesAsync runs on every boot and
  creates a default branch for any existing café that has none (covers
  cafés registered before this fix)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 20:53:33 +03:30
soroush.asadi 255695e8ae fix(deploy): auto-migrate on boot + seed admin credentials from env
CI/CD / CI · API (dotnet build + test) (push) Successful in 47s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 43s
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 46s
CI/CD / CI · Koja (tsc) (push) Successful in 48s
CI/CD / Deploy · all services (push) Successful in 3m36s
- docker-compose.admin.yml: RUN_MIGRATIONS was hardcoded false → now
  uses ${RUN_MIGRATIONS:-true} so migrations run automatically on deploy
- Both compose files: expose Seed__SystemAdminPhone/Username/Password
  env vars so the seeder sets admin credentials without manual SQL
- .env.example: document SEED_ADMIN_* variables

On next deploy: migrations run, Username='admin' is patched on the
existing admin, and password is hashed from SEED_ADMIN_PASSWORD.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 20:10:56 +03:30
soroush.asadi a4975cdb2d fix(seeder): patch existing admin username/password on every boot
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
EnsureOwnerAdminAsync now sets Username='admin' (configurable via
Seed:SystemAdminUsername) on any existing admin that has no username,
and hashes Seed:SystemAdminPassword if provided and no hash is stored.
Covers fresh deploys and existing prod admins created before credentials
were added.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 20:07:33 +03:30
soroush.asadi 639d5c305e feat: username/password authentication for admin and merchant panels
CI/CD / CI · API (dotnet build + test) (push) Successful in 49s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 42s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Has been cancelled
- Add PasswordHasher utility (PBKDF2/SHA-256, 100k iterations)
- Add Username + PasswordHash fields to Employee and SystemAdmin entities
- EF migration: AddPasswordLogin (nullable columns on both tables)
- Meezi.API: POST /api/auth/login (employee password login, CHOOSE_CAFE support)
- Meezi.API: PUT/DELETE /api/cafes/{id}/employees/{id}/credentials (Owner/Manager only)
- Meezi.Admin.API: POST /api/admin/auth/login + PUT /api/admin/auth/password
- Dashboard login page: OTP / Password tabs
- Admin login page: OTP / Password tabs
- HR screen: new Credentials tab for setting employee username/password
- PlatformDataSeeder: ensure system admin + integration settings in production
- Trial countdown banner: updated deadline to 1 Tir 1405 (Jun 22)
- i18n: fa/en/ar updated for all new UI strings

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 19:58:54 +03:30
soroush.asadi d0117f3171 fix(dashboard): sync lockfile and bump three to satisfy model-viewer peer
CI/CD / CI · API (dotnet build + test) (push) Successful in 59s
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 46s
CI/CD / CI · Koja (tsc) (push) Successful in 51s
CI/CD / Deploy · all services (push) Successful in 33s
npm ci failed in Docker because package-lock.json was stale (missing three
and the workbox/PWA deps) and @google/model-viewer@4.2.0 requires three@^0.182.0
while package.json pinned ^0.163.0. Bumped three and regenerated the lockfile.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 14:16:42 +03:30
soroush.asadi 2c15ae0062 CI CD 6
CI/CD / CI · API (dotnet build + test) (push) Successful in 44s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m7s
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 48s
CI/CD / Deploy · all services (push) Failing after 59s
2026-05-31 13:01:38 +03:30
soroush.asadi 861b762e18 CI CD 5
CI/CD / CI · API (dotnet build + test) (push) Successful in 54s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 35s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
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 53s
CI/CD / Deploy · all services (push) Failing after 10m25s
2026-05-31 12:34:04 +03:30
soroush.asadi 234649c65e CI CD 4
CI/CD / CI · API (dotnet build + test) (push) Successful in 41s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 47s
CI/CD / CI · Dashboard (tsc) (push) Successful in 2m3s
CI/CD / CI · Admin Web (tsc) (push) Successful in 44s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Failing after 14m30s
2026-05-31 12:03:46 +03:30
soroush.asadi a9222590ac CI CD 3
CI/CD / CI · API (dotnet build + test) (push) Successful in 41s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 34s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m44s
CI/CD / CI · Website (tsc) (push) Successful in 5m27s
CI/CD / CI · Koja (tsc) (push) Successful in 4m16s
CI/CD / Deploy · all services (push) Failing after 1m19s
2026-05-31 11:36:48 +03:30
soroush.asadi aec68eff34 CI CD 2 2026-05-31 11:31:05 +03:30
soroush.asadi 345ae0a4b5 first commit
CI/CD / CI · Admin API (dotnet build) (push) Successful in 41s
CI/CD / CI · Admin Web (tsc) (push) Failing after 5s
CI/CD / CI · Website (tsc) (push) Failing after 4s
CI/CD / CI · Koja (tsc) (push) Failing after 5s
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m13s
CI/CD / CI · Dashboard (tsc) (push) Failing after 2m32s
CI/CD / Deploy · all services (push) Has been skipped
2026-05-31 11:06:24 +03:30
soroush.asadi 51e422272d bugfix : remove orphan 2026-05-30 09:42:32 +03:30
soroush.asadi 2850ed8ed7 Align advertised branch limits with backend enforcement
Plan comparison and website pricing advertised branch counts that did not
match PlanLimitsData.ForTier: Pro now shows 3 (was 1) and Business shows
unlimited (was 5), matching what the backend actually enforces.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 05:57:42 +03:30
soroush.asadi 86bbefb9e3 Fix admin-web build: drop invalid --webpack flag on Next.js 14
The admin app runs Next.js 14.2.18, where `next build --webpack` is an
unknown option (the flag only exists in Next 15+). This broke the CI
admin-web image build. Other web apps stay on the flag since they're on
Next 16.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 05:57:31 +03:30
soroush.asadi 8ca2cae988 Pull Docker images from Nexus connector port 8087
The Docker daemon reaches the Nexus Docker group over the dedicated
connector port 8087 (its registry mirror), not the main 8081 HTTP port,
which caused HTTPS-to-HTTP pull failures in CI. Repoint all image refs to
171.22.25.73:8087 at the connector root; npm and NuGet stay on 8081.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 05:17:21 +03:30
soroush.asadi 09c55669ca Add proforma invoice step to subscription checkout
Insert a factor/invoice page between plan selection and payment showing
billing-period choice, line items, and totals before redirecting to the
gateway, moving payment-method selection to where the charge happens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 00:29:17 +03:30
soroush.asadi 639573dfde Add dashboard chrome to POS and collapsible sidebar
Wrap the POS terminal in the sidebar + topbar layout via a nested
fullscreen layout, and make the sidebar collapse to an icon-only rail
with a persisted toggle so operators keep navigation on the POS screen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 00:28:56 +03:30
soroush.asadi b6e4f83035 Migrate Kavenegar SMS to official .NET SDK
Replace the raw HttpClient implementation with the Kavenegar NuGet SDK
(v1.2.4) for OTP, single, and bulk sends plus account info, wrapping the
synchronous SDK calls and translating its exceptions. Register the
service as scoped instead of via AddHttpClient.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 00:28:28 +03:30
soroush.asadi e8cd6d3282 Route all package mirrors through local Nexus
Point Docker, NuGet, and npm pulls at the Nexus group repos on
171.22.25.73:8081 for both CI/CD and local builds, so the pipeline and
developers no longer depend on Docker Hub, MCR, nuget.org, or npmjs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 00:28:07 +03:30
soroush.asadi 62bd7a12f5 Build Next.js apps with Webpack instead of Turbopack
Next 16 defaults `next build` to Turbopack, which requires native SWC
bindings unavailable for Alpine musl from our npm mirror (only the WASM
fallback loads). Pass --webpack so the build uses the WASM SWC fallback
and succeeds inside the Docker images.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 17:35:28 +03:30
148 changed files with 21729 additions and 990 deletions
+10 -2
View File
@@ -68,6 +68,14 @@ REDIS_PORT=6381
# ── Migrations ──────────────────────────────────────────────────────────────── # ── Migrations ────────────────────────────────────────────────────────────────
RUN_MIGRATIONS=true RUN_MIGRATIONS=true
# ── System admin seed (admin panel login) ─────────────────────────────────────
# On every boot the seeder ensures this admin exists with these credentials.
# Username defaults to "admin" if not set. Password is required to enable
# password login — leave blank to force OTP-only login.
SEED_ADMIN_PHONE=09190345606
SEED_ADMIN_USERNAME=admin
SEED_ADMIN_PASSWORD=change-me-strong-admin-password
# ── Payment: ZarinPal ───────────────────────────────────────────────────────── # ── Payment: ZarinPal ─────────────────────────────────────────────────────────
# Get your merchant ID from: https://panel.zarinpal.com → API → MerchantID # Get your merchant ID from: https://panel.zarinpal.com → API → MerchantID
ZARINPAL_MERCHANT_ID= ZARINPAL_MERCHANT_ID=
@@ -81,5 +89,5 @@ KAVENEGAR_API_KEY=4C30786935496261332B41685870444E47657A5367453369374F6E2F433346
SNAPPFOOD_WEBHOOK_SECRET=change-me-snappfood-secret SNAPPFOOD_WEBHOOK_SECRET=change-me-snappfood-secret
# ── Docker image overrides (if direct MCR pull fails) ──────────────────────── # ── Docker image overrides (if direct MCR pull fails) ────────────────────────
# DOTNET_SDK_IMAGE=171.22.25.73:5002/dotnet/sdk:10.0 # DOTNET_SDK_IMAGE=mirror.soroushasadi.com/dotnet/sdk:10.0
# DOTNET_ASPNET_IMAGE=171.22.25.73:5002/dotnet/aspnet:10.0 # DOTNET_ASPNET_IMAGE=mirror.soroushasadi.com/dotnet/aspnet:10.0
+138 -54
View File
@@ -17,13 +17,12 @@ concurrency:
# ubuntu-latest:docker://node:20-alpine ← CI jobs run in real Docker containers # ubuntu-latest:docker://node:20-alpine ← CI jobs run in real Docker containers
# self-hosted:host ← deploy runs directly on the server # self-hosted:host ← deploy runs directly on the server
# #
# All images are pulled from local Nexus mirrors (fast, no internet): # All images/packages served from Nexus at mirror.soroushasadi.com:
# Docker Hub → http://171.22.25.73:5000 (docker-hub-proxy repo) # Docker images → mirror.soroushasadi.com (docker-group: Docker Hub + MCR)
# MCR → http://171.22.25.73:5002 (mcr-proxy repo) # NuGet → https://mirror.soroushasadi.com/repository/nuget-group/
# npm → https://mirror.soroushasadi.com/repository/npm-group/
# #
# mirror hostname → host-gateway (docker bridge IP 172.17.0.1) — used for: # Docker daemon: merge docker/daemon-registry-mirror.example.json into daemon.json
# NuGet → http://mirror:8081/repository/nuget-group/
# npm → http://mirror:8081/repository/npm-group/
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
jobs: jobs:
@@ -32,13 +31,12 @@ jobs:
name: "CI · API (dotnet build + test)" name: "CI · API (dotnet build + test)"
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: 171.22.25.73:5002/dotnet/sdk:10.0 image: mirror.soroushasadi.com/dotnet/sdk:10.0
options: >- options: >-
--add-host=gitea:host-gateway --add-host=gitea:host-gateway
--add-host=mirror:host-gateway
services: services:
postgres: postgres:
image: docker-mirror.liara.ir/library/postgres:16-alpine image: mirror.soroushasadi.com/postgres:16-alpine
env: env:
POSTGRES_DB: meezi_test POSTGRES_DB: meezi_test
POSTGRES_USER: meezi POSTGRES_USER: meezi
@@ -49,7 +47,7 @@ jobs:
--health-timeout 5s --health-timeout 5s
--health-retries 10 --health-retries 10
redis: redis:
image: docker-mirror.liara.ir/library/redis:7-alpine image: mirror.soroushasadi.com/redis:7-alpine
options: >- options: >-
--health-cmd "redis-cli ping" --health-cmd "redis-cli ping"
--health-interval 5s --health-interval 5s
@@ -74,8 +72,10 @@ jobs:
<configuration> <configuration>
<packageSources> <packageSources>
<clear /> <clear />
<add key="nexus" value="http://mirror:8081/repository/nuget-group/index.json" <add key="nexus"
protocolVersion="3" allowInsecureConnections="true" /> value="https://mirror.soroushasadi.com/repository/nuget-group/index.json"
protocolVersion="3"
/>
</packageSources> </packageSources>
</configuration> </configuration>
EOF EOF
@@ -98,10 +98,9 @@ jobs:
name: "CI · Admin API (dotnet build)" name: "CI · Admin API (dotnet build)"
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: 171.22.25.73:5002/dotnet/sdk:10.0 image: mirror.soroushasadi.com/dotnet/sdk:10.0
options: >- options: >-
--add-host=gitea:host-gateway --add-host=gitea:host-gateway
--add-host=mirror:host-gateway
steps: steps:
- name: Checkout - name: Checkout
env: env:
@@ -121,8 +120,10 @@ jobs:
<configuration> <configuration>
<packageSources> <packageSources>
<clear /> <clear />
<add key="nexus" value="http://mirror:8081/repository/nuget-group/index.json" <add key="nexus"
protocolVersion="3" allowInsecureConnections="true" /> value="https://mirror.soroushasadi.com/repository/nuget-group/index.json"
protocolVersion="3"
/>
</packageSources> </packageSources>
</configuration> </configuration>
EOF EOF
@@ -139,10 +140,9 @@ jobs:
name: "CI · Dashboard (tsc)" name: "CI · Dashboard (tsc)"
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: 171.22.25.73:5000/library/node:20-alpine image: mirror.soroushasadi.com/node:20-alpine
options: >- options: >-
--add-host=gitea:host-gateway --add-host=gitea:host-gateway
--add-host=mirror:host-gateway
steps: steps:
- name: Checkout - name: Checkout
env: env:
@@ -158,7 +158,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
working-directory: web/dashboard working-directory: web/dashboard
run: npm install --legacy-peer-deps --ignore-scripts --registry http://mirror:8081/repository/npm-group/ run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/ --strict-ssl=false
- name: TypeScript check - name: TypeScript check
working-directory: web/dashboard working-directory: web/dashboard
@@ -170,10 +170,9 @@ jobs:
name: "CI · Admin Web (tsc)" name: "CI · Admin Web (tsc)"
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: 171.22.25.73:5000/library/node:20-alpine image: mirror.soroushasadi.com/node:20-alpine
options: >- options: >-
--add-host=gitea:host-gateway --add-host=gitea:host-gateway
--add-host=mirror:host-gateway
steps: steps:
- name: Checkout - name: Checkout
env: env:
@@ -189,7 +188,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
working-directory: web/admin working-directory: web/admin
run: npm install --legacy-peer-deps --ignore-scripts --registry http://mirror:8081/repository/npm-group/ run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/ --strict-ssl=false
- name: TypeScript check - name: TypeScript check
working-directory: web/admin working-directory: web/admin
@@ -201,10 +200,9 @@ jobs:
name: "CI · Website (tsc)" name: "CI · Website (tsc)"
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: 171.22.25.73:5000/library/node:20-alpine image: mirror.soroushasadi.com/node:20-alpine
options: >- options: >-
--add-host=gitea:host-gateway --add-host=gitea:host-gateway
--add-host=mirror:host-gateway
steps: steps:
- name: Checkout - name: Checkout
env: env:
@@ -220,7 +218,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
working-directory: web/website working-directory: web/website
run: npm install --legacy-peer-deps --ignore-scripts --registry http://mirror:8081/repository/npm-group/ run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/ --strict-ssl=false
- name: TypeScript check - name: TypeScript check
working-directory: web/website working-directory: web/website
@@ -232,10 +230,9 @@ jobs:
name: "CI · Koja (tsc)" name: "CI · Koja (tsc)"
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: 171.22.25.73:5000/library/node:20-alpine image: mirror.soroushasadi.com/node:20-alpine
options: >- options: >-
--add-host=gitea:host-gateway --add-host=gitea:host-gateway
--add-host=mirror:host-gateway
steps: steps:
- name: Checkout - name: Checkout
env: env:
@@ -251,7 +248,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
working-directory: web/koja working-directory: web/koja
run: npm install --legacy-peer-deps --ignore-scripts --registry http://mirror:8081/repository/npm-group/ run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/ --strict-ssl=false
- name: TypeScript check - name: TypeScript check
working-directory: web/koja working-directory: web/koja
@@ -313,46 +310,133 @@ jobs:
DOCKER_BUILDKIT: 1 DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1 COMPOSE_DOCKER_CLI_BUILD: 1
- name: Start main services - name: Stop old app containers
# The existing containers were created before compose labels were added,
# so Compose can't claim them and hits a name conflict on 'up'.
# This step removes only meezi's own 6 app containers — never touches
# postgres, redis, or any other project's containers.
run: | run: |
docker compose up -d \ for name in meezi-api meezi-web meezi-website meezi-koja meezi-admin-api meezi-admin-web; do
--remove-orphans \ docker stop "$name" 2>/dev/null || true
--no-deps \ docker rm "$name" 2>/dev/null || true
postgres redis api web website koja done
- name: Start admin services - name: Attach infrastructure to meezi network
# postgres/redis may be on a different network (created before name:meezi
# was in the compose file). Disconnect/reconnect with service-name aliases
# so the API can resolve "Host=postgres" and "redis:6379".
# App containers are stopped at this point so the brief disconnect is safe.
run: |
docker network inspect meezi_default >/dev/null 2>&1 \
|| docker network create meezi_default
docker network disconnect meezi_default meezi-db 2>/dev/null || true
docker network disconnect meezi_default meezi-redis 2>/dev/null || true
docker network connect --alias postgres meezi_default meezi-db
docker network connect --alias redis meezi_default meezi-redis
echo "=== infra network state ==="
docker inspect meezi-db --format='meezi-db networks={{json .NetworkSettings.Networks}}' 2>&1 || true
docker inspect meezi-redis --format='meezi-redis networks={{json .NetworkSettings.Networks}}' 2>&1 || true
- name: Start API
# --no-deps skips all depends_on checks so compose starts api immediately
# without trying to verify postgres/redis health (they're not compose-managed).
run: docker compose up -d --no-deps api
- name: Wait for API healthy
# Poll ourselves so we can detect crashes early and print logs before
# restart-policy smothers them. Mirrors healthcheck: start_period=40s,
# interval=10s, retries=12 → up to 3 min total.
# Also checks RestartCount: restart:unless-stopped hides crashes behind
# rapid restarts, so state=exited is fleeting — a rising count tells us.
run: |
echo "Waiting for meezi-api (up to 3 min)..."
for i in $(seq 1 36); do
HEALTH=$(docker inspect --format='{{.State.Health.Status}}' meezi-api 2>/dev/null || echo "missing")
STATE=$(docker inspect --format='{{.State.Status}}' meezi-api 2>/dev/null || echo "missing")
RESTARTS=$(docker inspect --format='{{.RestartCount}}' meezi-api 2>/dev/null || echo "0")
echo " [$i/36] state=$STATE health=$HEALTH restarts=$RESTARTS"
[ "$HEALTH" = "healthy" ] && echo "✅ meezi-api healthy" && break
if [ "$STATE" = "exited" ] || [ "$STATE" = "dead" ]; then
echo "❌ meezi-api crashed (state=$STATE) — logs:"
docker logs meezi-api 2>&1 | tail -120
exit 1
fi
if [ "$RESTARTS" -gt 1 ]; then
echo "❌ meezi-api crash-loop (restarts=$RESTARTS) — logs:"
docker logs meezi-api 2>&1 | tail -120
exit 1
fi
[ "$i" = "36" ] && echo "❌ meezi-api timeout (3 min)" \
&& docker logs meezi-api 2>&1 | tail -80 && exit 1
sleep 5
done
- name: Start web services
# API is healthy at this point; start the three Next.js frontends.
run: docker compose up -d --no-deps web website koja
- name: Start admin API
run: | run: |
docker compose \ docker compose \
-f docker-compose.yml \ -f docker-compose.yml \
-f docker-compose.admin.yml \ -f docker-compose.admin.yml \
up -d \ up -d --no-deps admin-api
--no-deps \
admin-api admin-web
- name: Wait for main API healthy
run: |
for i in $(seq 1 24); do
STATUS=$(docker inspect --format='{{.State.Health.Status}}' meezi-api 2>/dev/null || echo "missing")
echo " [$i/24] $STATUS"
[ "$STATUS" = "healthy" ] && echo "✅ meezi-api healthy" && break
[ "$i" = "24" ] && echo "❌ meezi-api timeout" && docker compose logs --tail=40 api && exit 1
sleep 5
done
- name: Wait for admin API healthy - name: Wait for admin API healthy
run: | run: |
for i in $(seq 1 24); do echo "Waiting for meezi-admin-api (up to 3 min)..."
STATUS=$(docker inspect --format='{{.State.Health.Status}}' meezi-admin-api 2>/dev/null || echo "missing") for i in $(seq 1 36); do
echo " [$i/24] $STATUS" HEALTH=$(docker inspect --format='{{.State.Health.Status}}' meezi-admin-api 2>/dev/null || echo "missing")
[ "$STATUS" = "healthy" ] && echo "✅ meezi-admin-api healthy" && break STATE=$(docker inspect --format='{{.State.Status}}' meezi-admin-api 2>/dev/null || echo "missing")
[ "$i" = "24" ] && echo "❌ meezi-admin-api timeout" && docker compose -f docker-compose.yml -f docker-compose.admin.yml logs --tail=40 admin-api && exit 1 RESTARTS=$(docker inspect --format='{{.RestartCount}}' meezi-admin-api 2>/dev/null || echo "0")
echo " [$i/36] state=$STATE health=$HEALTH restarts=$RESTARTS"
[ "$HEALTH" = "healthy" ] && echo "✅ meezi-admin-api healthy" && break
if [ "$STATE" = "exited" ] || [ "$STATE" = "dead" ]; then
echo "❌ meezi-admin-api crashed (state=$STATE) — logs:"
docker logs meezi-admin-api 2>&1 | tail -80
exit 1
fi
if [ "$RESTARTS" -gt 1 ]; then
echo "❌ meezi-admin-api crash-loop (restarts=$RESTARTS) — logs:"
docker logs meezi-admin-api 2>&1 | tail -80
exit 1
fi
[ "$i" = "36" ] && echo "❌ meezi-admin-api timeout (3 min)" \
&& docker logs meezi-admin-api 2>&1 | tail -80 && exit 1
sleep 5 sleep 5
done done
- name: Start admin web
run: |
docker compose \
-f docker-compose.yml \
-f docker-compose.admin.yml \
up -d --no-deps admin-web
- name: Show all running containers - name: Show all running containers
if: always() if: always()
run: docker compose -f docker-compose.yml -f docker-compose.admin.yml ps run: docker compose -f docker-compose.yml -f docker-compose.admin.yml ps
- name: Prune old images - name: Dump logs on failure
if: failure()
run: |
echo "=== meezi-api logs ==="
docker logs meezi-api --tail=120 2>&1 || true
echo "=== meezi-admin-api logs ==="
docker logs meezi-admin-api --tail=80 2>&1 || true
echo "=== meezi_default network ==="
docker network inspect meezi_default 2>&1 || true
echo "=== meezi-db network state ==="
docker inspect meezi-db --format='{{json .NetworkSettings.Networks}}' 2>&1 || true
echo "=== meezi-redis network state ==="
docker inspect meezi-redis --format='{{json .NetworkSettings.Networks}}' 2>&1 || true
- name: Prune dangling images
if: success() if: success()
run: docker image prune -f run: |
# Remove untagged (<none>) images left over from this and previous builds.
# --filter dangling=true only removes images with no tags; never touches
# other projects' named images (soroushasadi-site, drsousan, etc.).
docker image prune -f
echo "Disk after prune:"
df -h /
+3
View File
@@ -0,0 +1,3 @@
registry=https://mirror.soroushasadi.com/repository/npm-group/
strict-ssl=false
legacy-peer-deps=true
+4 -4
View File
@@ -6,7 +6,7 @@
Server: 171.22.25.73 Server: 171.22.25.73
├── Gitea :3000 ← source control + CI runner ├── Gitea :3000 ← source control + CI runner
├── Nexus :8081 ← package mirror (NuGet, npm, Docker) ├── Nexus mirror.soroushasadi.com ← package mirror (NuGet, npm, Docker, MCR)
├── meezi-api :5080 ← .NET main API ├── meezi-api :5080 ← .NET main API
├── meezi-admin-api:5081 ← .NET admin API ├── meezi-admin-api:5081 ← .NET admin API
@@ -128,7 +128,7 @@ CI takes ~510 minutes: builds 6 Docker images, runs all checks, then deploys.
| Main API (Swagger) | http://171.22.25.73:5080/swagger | | Main API (Swagger) | http://171.22.25.73:5080/swagger |
| Admin API (Swagger) | http://171.22.25.73:5081/swagger | | Admin API (Swagger) | http://171.22.25.73:5081/swagger |
| Gitea | http://171.22.25.73:3000 | | Gitea | http://171.22.25.73:3000 |
| Nexus | http://171.22.25.73:8081 | | Nexus | https://mirror.soroushasadi.com/ |
--- ---
@@ -255,8 +255,8 @@ Nexus runs separately and should always be running:
# Start (first time or after server reboot) # Start (first time or after server reboot)
docker compose -f docker-compose.mirror.yml up -d docker compose -f docker-compose.mirror.yml up -d
# Health check # Health check (on server or via domain)
curl -s http://localhost:8081/service/rest/v1/status curl -s https://mirror.soroushasadi.com/service/rest/v1/status
``` ```
Provisioned repos: Provisioned repos:
+1
View File
@@ -24,6 +24,7 @@
<PackageVersion Include="QuestPDF" Version="2024.12.3" /> <PackageVersion Include="QuestPDF" Version="2024.12.3" />
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" /> <PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" /> <PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageVersion Include="Kavenegar" Version="1.2.4" />
<PackageVersion Include="StackExchange.Redis" Version="2.8.16" /> <PackageVersion Include="StackExchange.Redis" Version="2.8.16" />
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.6" /> <PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.6" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="7.2.0" /> <PackageVersion Include="Swashbuckle.AspNetCore" Version="7.2.0" />
+8 -5
View File
@@ -16,8 +16,8 @@ services:
extra_hosts: extra_hosts:
- "mirror:host-gateway" - "mirror:host-gateway"
args: args:
DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-mcr-mirror.liara.ir/dotnet/sdk:10.0} DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-mirror.soroushasadi.com/dotnet/sdk:10.0}
DOTNET_ASPNET_IMAGE: ${DOTNET_ASPNET_IMAGE:-mcr-mirror.liara.ir/dotnet/aspnet:10.0} DOTNET_ASPNET_IMAGE: ${DOTNET_ASPNET_IMAGE:-mirror.soroushasadi.com/dotnet/aspnet:10.0}
container_name: meezi-admin-api container_name: meezi-admin-api
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
@@ -28,7 +28,7 @@ services:
environment: environment:
ASPNETCORE_ENVIRONMENT: "${ASPNETCORE_ENVIRONMENT:-Development}" ASPNETCORE_ENVIRONMENT: "${ASPNETCORE_ENVIRONMENT:-Development}"
ASPNETCORE_URLS: http://+:8080 ASPNETCORE_URLS: http://+:8080
RUN_MIGRATIONS: "false" RUN_MIGRATIONS: "${RUN_MIGRATIONS:-true}"
ConnectionStrings__DefaultConnection: "${DB_CONNECTION_STRING:-Host=postgres;Port=5432;Database=meezi;Username=meezi;Password=meezi_local_pass}" ConnectionStrings__DefaultConnection: "${DB_CONNECTION_STRING:-Host=postgres;Port=5432;Database=meezi;Username=meezi;Password=meezi_local_pass}"
ConnectionStrings__Redis: redis:6379 ConnectionStrings__Redis: redis:6379
Jwt__Key: "${JWT_KEY:-dev-jwt-key-CHANGE-THIS-IN-PRODUCTION-min32chars}" Jwt__Key: "${JWT_KEY:-dev-jwt-key-CHANGE-THIS-IN-PRODUCTION-min32chars}"
@@ -36,6 +36,9 @@ services:
Cors__Origins__1: "${CORS_ORIGIN_0:-http://localhost:3101}" Cors__Origins__1: "${CORS_ORIGIN_0:-http://localhost:3101}"
Kavenegar__ApiKey: "${KAVENEGAR_API_KEY:-}" Kavenegar__ApiKey: "${KAVENEGAR_API_KEY:-}"
Kavenegar__SenderNumber: "${KAVENEGAR_SENDER:-90005671}" Kavenegar__SenderNumber: "${KAVENEGAR_SENDER:-90005671}"
Seed__SystemAdminPhone: "${SEED_ADMIN_PHONE:-}"
Seed__SystemAdminUsername: "${SEED_ADMIN_USERNAME:-admin}"
Seed__SystemAdminPassword: "${SEED_ADMIN_PASSWORD:-}"
ports: ports:
- "${ADMIN_API_PORT:-5081}:8080" - "${ADMIN_API_PORT:-5081}:8080"
healthcheck: healthcheck:
@@ -52,8 +55,8 @@ services:
extra_hosts: extra_hosts:
- "mirror:host-gateway" - "mirror:host-gateway"
args: args:
NODE_IMAGE: ${NODE_IMAGE:-docker-mirror.liara.ir/library/node:20-alpine} NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
NPM_REGISTRY: ${NPM_REGISTRY:-https://package-mirror.liara.ir/repository/npm/} NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
NEXT_PUBLIC_ADMIN_API_URL: ${NEXT_PUBLIC_ADMIN_API_URL:-http://localhost:5081} NEXT_PUBLIC_ADMIN_API_URL: ${NEXT_PUBLIC_ADMIN_API_URL:-http://localhost:5081}
container_name: meezi-admin-web container_name: meezi-admin-web
restart: unless-stopped restart: unless-stopped
+4 -4
View File
@@ -6,10 +6,10 @@
# ./mirrors/nexus/provision.sh # creates all proxy repos + enables anon access # ./mirrors/nexus/provision.sh # creates all proxy repos + enables anon access
# #
# Endpoints (after provisioning): # Endpoints (after provisioning):
# UI → http://SERVER_IP:8081 (admin / see provision.sh output) # UI → https://mirror.soroushasadi.com/ (admin / see provision.sh output)
# NuGet → http://SERVER_IP:8081/repository/nuget-proxy/index.json # NuGet → https://mirror.soroushasadi.com/repository/nuget-group/index.json
# npm → http://SERVER_IP:8081/repository/npm-proxy/ # npm → https://mirror.soroushasadi.com/repository/npm-group/
# Docker → http://SERVER_IP:5000 (add to /etc/docker/daemon.json) # Docker → https://mirror.soroushasadi.com (add to daemon.json registry-mirrors)
# #
# Memory: needs ~2 GB JVM heap — recommended on a server with 4 GB+ total RAM. # Memory: needs ~2 GB JVM heap — recommended on a server with 4 GB+ total RAM.
# Adjust INSTALL4J_ADD_VM_PARAMS below if your server has more/less RAM. # Adjust INSTALL4J_ADD_VM_PARAMS below if your server has more/less RAM.
+22 -10
View File
@@ -1,5 +1,14 @@
name: meezi # Lock project name — prevents runner workspace from overriding it
# Meezi — main stack (Postgres, Redis, API, Dashboard, Website, Koja) # Meezi — main stack (Postgres, Redis, API, Dashboard, Website, Koja)
# #
# All images/packages served from Nexus at mirror.soroushasadi.com:
# Docker images → mirror.soroushasadi.com (docker-group: Docker Hub + MCR)
# NuGet → https://mirror.soroushasadi.com/repository/nuget-group/
# npm → https://mirror.soroushasadi.com/repository/npm-group/
#
# Docker Desktop: merge docker/daemon-registry-mirror.example.json into daemon.json
#
# Local dev: # Local dev:
# cp .env.example .env # cp .env.example .env
# docker compose up -d --build # docker compose up -d --build
@@ -18,7 +27,7 @@
services: services:
postgres: postgres:
image: ${POSTGRES_IMAGE:-docker-mirror.liara.ir/library/postgres:16-alpine} image: ${POSTGRES_IMAGE:-mirror.soroushasadi.com/postgres:16-alpine}
container_name: meezi-db container_name: meezi-db
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -36,7 +45,7 @@ services:
retries: 10 retries: 10
redis: redis:
image: ${REDIS_IMAGE:-docker-mirror.liara.ir/library/redis:7-alpine} image: ${REDIS_IMAGE:-mirror.soroushasadi.com/redis:7-alpine}
container_name: meezi-redis container_name: meezi-redis
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -57,8 +66,8 @@ services:
extra_hosts: extra_hosts:
- "mirror:host-gateway" - "mirror:host-gateway"
args: args:
DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-mcr-mirror.liara.ir/dotnet/sdk:10.0} DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-mirror.soroushasadi.com/dotnet/sdk:10.0}
DOTNET_ASPNET_IMAGE: ${DOTNET_ASPNET_IMAGE:-mcr-mirror.liara.ir/dotnet/aspnet:10.0} DOTNET_ASPNET_IMAGE: ${DOTNET_ASPNET_IMAGE:-mirror.soroushasadi.com/dotnet/aspnet:10.0}
container_name: meezi-api container_name: meezi-api
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
@@ -85,6 +94,9 @@ services:
Snappfood__WebhookSecret: "${SNAPPFOOD_WEBHOOK_SECRET:-meezi-dev-snappfood-secret}" Snappfood__WebhookSecret: "${SNAPPFOOD_WEBHOOK_SECRET:-meezi-dev-snappfood-secret}"
ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}" ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}" ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}"
Seed__SystemAdminPhone: "${SEED_ADMIN_PHONE:-}"
Seed__SystemAdminUsername: "${SEED_ADMIN_USERNAME:-admin}"
Seed__SystemAdminPassword: "${SEED_ADMIN_PASSWORD:-}"
ports: ports:
- "${API_PORT:-5080}:8080" - "${API_PORT:-5080}:8080"
volumes: volumes:
@@ -103,8 +115,8 @@ services:
extra_hosts: extra_hosts:
- "mirror:host-gateway" - "mirror:host-gateway"
args: args:
NODE_IMAGE: ${NODE_IMAGE:-docker-mirror.liara.ir/library/node:20-alpine} NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
NPM_REGISTRY: ${NPM_REGISTRY:-https://package-mirror.liara.ir/repository/npm/} NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080}
container_name: meezi-web container_name: meezi-web
restart: unless-stopped restart: unless-stopped
@@ -124,8 +136,8 @@ services:
extra_hosts: extra_hosts:
- "mirror:host-gateway" - "mirror:host-gateway"
args: args:
NODE_IMAGE: ${NODE_IMAGE:-docker-mirror.liara.ir/library/node:20-alpine} NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
NPM_REGISTRY: ${NPM_REGISTRY:-https://package-mirror.liara.ir/repository/npm/} NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
MEEZI_API_URL: http://api:8080 MEEZI_API_URL: http://api:8080
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3010} NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3010}
container_name: meezi-website container_name: meezi-website
@@ -148,8 +160,8 @@ services:
extra_hosts: extra_hosts:
- "mirror:host-gateway" - "mirror:host-gateway"
args: args:
NODE_IMAGE: ${NODE_IMAGE:-docker-mirror.liara.ir/library/node:20-alpine} NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
NPM_REGISTRY: ${NPM_REGISTRY:-https://package-mirror.liara.ir/repository/npm/} NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_KOJA_URL:-http://localhost:3103} NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_KOJA_URL:-http://localhost:3103}
container_name: meezi-koja container_name: meezi-koja
+3 -3
View File
@@ -1,11 +1,11 @@
ARG DOTNET_SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:10.0 ARG DOTNET_SDK_IMAGE=mirror.soroushasadi.com/dotnet/sdk:10.0
ARG DOTNET_ASPNET_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0 ARG DOTNET_ASPNET_IMAGE=mirror.soroushasadi.com/dotnet/aspnet:10.0
FROM ${DOTNET_SDK_IMAGE} AS build FROM ${DOTNET_SDK_IMAGE} AS build
WORKDIR /src WORKDIR /src
COPY global.json Directory.Build.props Directory.Packages.props ./ COPY global.json Directory.Build.props Directory.Packages.props ./
# nuget.docker.config points to local Nexus mirror (mirror:8081 via extra_hosts in compose) # nuget.docker.config points to Nexus mirror (mirror.soroushasadi.com)
COPY nuget.docker.config ./nuget.config COPY nuget.docker.config ./nuget.config
COPY src/Meezi.Shared/Meezi.Shared.csproj src/Meezi.Shared/ COPY src/Meezi.Shared/Meezi.Shared.csproj src/Meezi.Shared/
+20 -6
View File
@@ -1,27 +1,40 @@
ARG NODE_IMAGE=docker-mirror.liara.ir/library/node:20-alpine ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
# ==================== DEPS STAGE ====================
FROM ${NODE_IMAGE} AS deps FROM ${NODE_IMAGE} AS deps
WORKDIR /app WORKDIR /app
COPY web/admin/package*.json ./ COPY web/admin/package*.json ./
ARG NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/
# Install deps then ensure Alpine (musl) SWC binary is present ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} \
&& NEXT_VER=$(node -e "process.stdout.write(require('./node_modules/next/package.json').version)") \ RUN npm ci --legacy-peer-deps --ignore-scripts \
--registry ${NPM_REGISTRY} \
--strict-ssl=false \
&& NEXT_VER=$(node -p "require('./node_modules/next/package.json').version") \
&& ls node_modules/@next/swc-linux-x64-musl 2>/dev/null \ && ls node_modules/@next/swc-linux-x64-musl 2>/dev/null \
|| npm install --no-save --ignore-scripts --registry ${NPM_REGISTRY} \ || npm install --no-save --ignore-scripts \
--registry ${NPM_REGISTRY} \
--strict-ssl=false \
"@next/swc-linux-x64-musl@${NEXT_VER}" "@next/swc-linux-x64-musl@${NEXT_VER}"
# ==================== BUILDER STAGE ====================
FROM ${NODE_IMAGE} AS builder FROM ${NODE_IMAGE} AS builder
WORKDIR /app WORKDIR /app
ARG NEXT_PUBLIC_ADMIN_API_URL=http://localhost:5081 ARG NEXT_PUBLIC_ADMIN_API_URL=http://localhost:5081
ENV NEXT_PUBLIC_ADMIN_API_URL=$NEXT_PUBLIC_ADMIN_API_URL ENV NEXT_PUBLIC_ADMIN_API_URL=$NEXT_PUBLIC_ADMIN_API_URL
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY web/admin/ . COPY web/admin/ .
RUN npm run build RUN npm run build
# ==================== RUNNER STAGE ====================
FROM ${NODE_IMAGE} AS runner FROM ${NODE_IMAGE} AS runner
WORKDIR /app WORKDIR /app
@@ -38,5 +51,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
CMD ["node", "server.js"] CMD ["node", "server.js"]
+3 -3
View File
@@ -1,11 +1,11 @@
ARG DOTNET_SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:10.0 ARG DOTNET_SDK_IMAGE=mirror.soroushasadi.com/dotnet/sdk:10.0
ARG DOTNET_ASPNET_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0 ARG DOTNET_ASPNET_IMAGE=mirror.soroushasadi.com/dotnet/aspnet:10.0
FROM ${DOTNET_SDK_IMAGE} AS build FROM ${DOTNET_SDK_IMAGE} AS build
WORKDIR /src WORKDIR /src
COPY global.json Directory.Build.props Directory.Packages.props ./ COPY global.json Directory.Build.props Directory.Packages.props ./
# nuget.docker.config points to local Nexus mirror (mirror:8081 via extra_hosts in compose) # nuget.docker.config points to Nexus mirror (mirror.soroushasadi.com)
COPY nuget.docker.config ./nuget.config COPY nuget.docker.config ./nuget.config
COPY src/Meezi.Shared/Meezi.Shared.csproj src/Meezi.Shared/ COPY src/Meezi.Shared/Meezi.Shared.csproj src/Meezi.Shared/
+1 -2
View File
@@ -1,6 +1,5 @@
{ {
"registry-mirrors": [ "registry-mirrors": [
"https://docker.iranrepo.ir", "https://mirror.soroushasadi.com"
"https://registry.docker.ir"
] ]
} }
+3 -3
View File
@@ -1,10 +1,10 @@
ARG NODE_IMAGE=docker-mirror.liara.ir/library/node:20-alpine ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
FROM ${NODE_IMAGE} AS deps FROM ${NODE_IMAGE} AS deps
WORKDIR /app WORKDIR /app
COPY web/koja/package*.json ./ COPY web/koja/package*.json ./
ARG NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/ ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} --strict-ssl=false
FROM ${NODE_IMAGE} AS builder FROM ${NODE_IMAGE} AS builder
WORKDIR /app WORKDIR /app
+24 -4
View File
@@ -1,22 +1,41 @@
ARG NODE_IMAGE=docker-mirror.liara.ir/library/node:20-alpine ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
# ==================== DEPS STAGE ====================
FROM ${NODE_IMAGE} AS deps FROM ${NODE_IMAGE} AS deps
WORKDIR /app WORKDIR /app
COPY web/dashboard/package*.json ./
ARG NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY}
COPY web/dashboard/package*.json ./
ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
# Use npm ci + ensure musl SWC binary (important on Alpine)
RUN npm ci --legacy-peer-deps --ignore-scripts \
--registry ${NPM_REGISTRY} \
--strict-ssl=false \
&& NEXT_VER=$(node -p "require('./node_modules/next/package.json').version") \
&& ls node_modules/@next/swc-linux-x64-musl 2>/dev/null \
|| npm install --no-save --ignore-scripts \
--registry ${NPM_REGISTRY} \
--strict-ssl=false \
"@next/swc-linux-x64-musl@${NEXT_VER}"
# ==================== BUILDER STAGE ====================
FROM ${NODE_IMAGE} AS builder FROM ${NODE_IMAGE} AS builder
WORKDIR /app WORKDIR /app
ARG NEXT_PUBLIC_API_URL=http://localhost:5080 ARG NEXT_PUBLIC_API_URL=http://localhost:5080
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY web/dashboard/ . COPY web/dashboard/ .
RUN npm run build RUN npm run build
# ==================== RUNNER STAGE ====================
FROM ${NODE_IMAGE} AS runner FROM ${NODE_IMAGE} AS runner
WORKDIR /app WORKDIR /app
@@ -33,5 +52,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
CMD ["node", "server.js"] CMD ["node", "server.js"]
+19 -7
View File
@@ -1,29 +1,40 @@
ARG NODE_IMAGE=docker-mirror.liara.ir/library/node:20-alpine ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
# ==================== DEPS STAGE ====================
FROM ${NODE_IMAGE} AS deps FROM ${NODE_IMAGE} AS deps
WORKDIR /app WORKDIR /app
COPY web/website/package*.json ./ COPY web/website/package*.json ./
ARG NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/
# Install deps then ensure Alpine (musl) SWC binary is present ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} \
&& NEXT_VER=$(node -e "process.stdout.write(require('./node_modules/next/package.json').version)") \ RUN npm ci --legacy-peer-deps --ignore-scripts \
--registry ${NPM_REGISTRY} \
--strict-ssl=false \
&& NEXT_VER=$(node -p "require('./node_modules/next/package.json').version") \
&& ls node_modules/@next/swc-linux-x64-musl 2>/dev/null \ && ls node_modules/@next/swc-linux-x64-musl 2>/dev/null \
|| npm install --no-save --ignore-scripts --registry ${NPM_REGISTRY} \ || npm install --no-save --ignore-scripts \
--registry ${NPM_REGISTRY} \
--strict-ssl=false \
"@next/swc-linux-x64-musl@${NEXT_VER}" "@next/swc-linux-x64-musl@${NEXT_VER}"
# ==================== BUILDER STAGE ====================
FROM ${NODE_IMAGE} AS builder FROM ${NODE_IMAGE} AS builder
WORKDIR /app WORKDIR /app
ARG MEEZI_API_URL=http://api:8080 ARG MEEZI_API_URL=http://api:8080
ENV MEEZI_API_URL=$MEEZI_API_URL
ARG NEXT_PUBLIC_SITE_URL=http://localhost:3010 ARG NEXT_PUBLIC_SITE_URL=http://localhost:3010
ENV MEEZI_API_URL=$MEEZI_API_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY web/website/ . COPY web/website/ .
RUN npm run build RUN npm run build
# ==================== RUNNER STAGE ====================
FROM ${NODE_IMAGE} AS runner FROM ${NODE_IMAGE} AS runner
WORKDIR /app WORKDIR /app
@@ -41,5 +52,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/src/content ./src/content COPY --from=builder /app/src/content ./src/content
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
CMD ["node", "server.js"] CMD ["node", "server.js"]
+6 -6
View File
@@ -136,18 +136,18 @@ echo "════════════════════════
echo "🎉 Done!" echo "🎉 Done!"
echo "═══════════════════════════════════════════════════════════════" echo "═══════════════════════════════════════════════════════════════"
echo "" echo ""
echo " npm-group → http://SERVER:8081/repository/npm-group/" echo " npm-group → https://mirror.soroushasadi.com/repository/npm-group/"
echo " Liara first, Runflare as fallback" echo " Liara first, Runflare as fallback"
echo "" echo ""
echo " pypi-group → http://SERVER:8081/repository/pypi-group/" echo " pypi-group → https://mirror.soroushasadi.com/repository/pypi-group/"
echo " Liara first, Runflare as fallback" echo " Liara first, Runflare as fallback"
echo "" echo ""
echo " Ubuntu APT → http://SERVER:8081/repository/ubuntu-proxy/" echo " Ubuntu APT → https://mirror.soroushasadi.com/repository/ubuntu-proxy/"
echo " distribution: $UBUNTU_DIST" echo " distribution: $UBUNTU_DIST"
echo " security: http://SERVER:8081/repository/ubuntu-security-proxy/" echo " security: https://mirror.soroushasadi.com/repository/ubuntu-security-proxy/"
echo "" echo ""
echo "To use Ubuntu APT in a Dockerfile:" echo "To use Ubuntu APT in a Dockerfile:"
echo " RUN echo 'deb http://SERVER:8081/repository/ubuntu-proxy/ $UBUNTU_DIST main restricted universe' > /etc/apt/sources.list && \\" echo " RUN echo 'deb https://mirror.soroushasadi.com/repository/ubuntu-proxy/ $UBUNTU_DIST main restricted universe' > /etc/apt/sources.list && \\"
echo " echo 'deb http://SERVER:8081/repository/ubuntu-security-proxy/ $UBUNTU_DIST-security main restricted universe' >> /etc/apt/sources.list && \\" echo " echo 'deb https://mirror.soroushasadi.com/repository/ubuntu-security-proxy/ $UBUNTU_DIST-security main restricted universe' >> /etc/apt/sources.list && \\"
echo " apt-get update" echo " apt-get update"
echo "" echo ""
+6 -6
View File
@@ -176,12 +176,12 @@ echo "════════════════════════
echo "🎉 Nexus provisioned!" echo "🎉 Nexus provisioned!"
echo "═══════════════════════════════════════════════════════════════" echo "═══════════════════════════════════════════════════════════════"
echo "" echo ""
echo " UI → http://$(hostname -I | awk '{print $1}'):8081" echo " UI → https://mirror.soroushasadi.com/"
echo " admin / $ADMIN_PASS" echo " admin / $ADMIN_PASS"
echo "" echo ""
echo " NuGet → http://$(hostname -I | awk '{print $1}'):8081/repository/nuget-proxy/index.json" echo " NuGet → https://mirror.soroushasadi.com/repository/nuget-group/index.json"
echo " npm → http://$(hostname -I | awk '{print $1}'):8081/repository/npm-proxy/" echo " npm → https://mirror.soroushasadi.com/repository/npm-group/"
echo " Docker → http://$(hostname -I | awk '{print $1}'):8083 ← upstream: $DOCKER_UPSTREAM" echo " Docker → https://mirror.soroushasadi.com ← upstream: $DOCKER_UPSTREAM"
echo "" echo ""
if [ -z "$DOCKER_USER" ]; then if [ -z "$DOCKER_USER" ]; then
echo " 💡 To switch Docker upstream to Liara mirror (faster in Iran):" echo " 💡 To switch Docker upstream to Liara mirror (faster in Iran):"
@@ -194,7 +194,7 @@ if [ -z "$DOCKER_USER" ]; then
echo "" echo ""
fi fi
echo "To activate Docker Hub mirror on this server:" echo "To activate Docker Hub mirror on this server:"
echo " Edit /etc/docker/daemon.json:" echo " Merge docker/daemon-registry-mirror.example.json into /etc/docker/daemon.json"
echo ' { "insecure-registries": ["'"$(hostname -I | awk '{print $1}'):8083"'"], "registry-mirrors": ["http://'"$(hostname -I | awk '{print $1}'):8083"'"] }' echo ' { "registry-mirrors": ["https://mirror.soroushasadi.com"] }'
echo " systemctl restart docker" echo " systemctl restart docker"
echo "" echo ""
+3 -2
View File
@@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- NuGet config for Docker builds — routes restores through Liara NuGet mirror. --> <!-- NuGet config for Docker builds — routes restores through Nexus at mirror.soroushasadi.com -->
<configuration> <configuration>
<packageSources> <packageSources>
<clear /> <clear />
<add key="liara-nuget" value="https://package-mirror.liara.ir/repository/nuget/index.json" <add key="nexus"
value="https://mirror.soroushasadi.com/repository/nuget-group/index.json"
protocolVersion="3" /> protocolVersion="3" />
</packageSources> </packageSources>
<config> <config>
+1 -1
View File
@@ -8,7 +8,7 @@
<clear /> <clear />
<!-- nuget-group aggregates nuget.org-proxy (Liara) + nuget-runflare-proxy (Runflare). <!-- nuget-group aggregates nuget.org-proxy (Liara) + nuget-runflare-proxy (Runflare).
If Liara is down, Nexus automatically falls back to Runflare. --> If Liara is down, Nexus automatically falls back to Runflare. -->
<add key="nexus-nuget" value="http://mirror:8081/repository/nuget-group/index.json" protocolVersion="3" /> <add key="nexus-nuget" value="https://mirror.soroushasadi.com/repository/nuget-group/index.json" protocolVersion="3" />
</packageSources> </packageSources>
<config> <config>
<add key="http_retry_count" value="8" /> <add key="http_retry_count" value="8" />
@@ -0,0 +1,91 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Audit;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Meezi.Shared;
namespace Meezi.API.Controllers;
/// <summary>
/// Read-only access to the immutable POS / management audit trail. Gated by
/// <see cref="Permission.ViewReports"/>; branch-scoped sessions only ever see
/// their own branch's entries (enforced by the DB-level branch isolation filter),
/// café-wide owners see everything.
/// </summary>
[Route("api/cafes/{cafeId}/audit-logs")]
public class AuditController : CafeApiControllerBase
{
private const int MaxPageSize = 100;
private readonly AppDbContext _db;
public AuditController(AppDbContext db)
{
_db = db;
}
[HttpGet]
public async Task<IActionResult> List(
string cafeId,
ITenantContext tenant,
CancellationToken ct,
[FromQuery] string? category = null,
[FromQuery] string? action = null,
[FromQuery] string? branchId = null,
[FromQuery] string? entityType = null,
[FromQuery] string? entityId = null,
[FromQuery] DateTime? from = null,
[FromQuery] DateTime? to = null,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ViewReports) is { } forbidden) return forbidden;
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 50;
if (pageSize > MaxPageSize) pageSize = MaxPageSize;
var query = _db.AuditLogs.AsNoTracking().Where(x => x.CafeId == cafeId);
if (!string.IsNullOrWhiteSpace(category))
query = query.Where(x => x.Category == category);
if (!string.IsNullOrWhiteSpace(action))
query = query.Where(x => x.Action == action);
if (!string.IsNullOrWhiteSpace(branchId))
query = query.Where(x => x.BranchId == branchId);
if (!string.IsNullOrWhiteSpace(entityType))
query = query.Where(x => x.EntityType == entityType);
if (!string.IsNullOrWhiteSpace(entityId))
query = query.Where(x => x.EntityId == entityId);
if (from is { } f)
query = query.Where(x => x.CreatedAt >= f);
if (to is { } t)
query = query.Where(x => x.CreatedAt <= t);
var total = await query.CountAsync(ct);
var items = await query
.OrderByDescending(x => x.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new AuditLogDto(
x.Id,
x.Category,
x.Action,
x.EntityType,
x.EntityId,
x.BranchId,
x.ActorId,
x.ActorName,
x.ActorRole,
x.Summary,
x.DetailsJson,
x.CreatedAt))
.ToListAsync(ct);
return Ok(new PagedApiResponse<AuditLogDto>(true, items, new PagedMeta(total, page, pageSize)));
}
}
+46 -1
View File
@@ -6,7 +6,6 @@ using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using Meezi.API.Models.Auth; using Meezi.API.Models.Auth;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.API.Services;
using Meezi.Core.Constants; using Meezi.Core.Constants;
using Meezi.Shared; using Meezi.Shared;
@@ -39,6 +38,26 @@ public class AuthController : ControllerBase
_verifyRegisterValidator = verifyRegisterValidator; _verifyRegisterValidator = verifyRegisterValidator;
} }
[HttpPost("login")]
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
public async Task<IActionResult> LoginWithPassword(
[FromBody] LoginWithPasswordRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest(ValidationError("Username and password are required."));
var (success, data, code, message, choices) = await _authService.LoginWithPasswordAsync(request, cancellationToken);
if (!success && code == "CHOOSE_CAFE")
return Ok(new ApiResponse<CafeChoicesResponse>(false, choices, new ApiError("CHOOSE_CAFE", "Please select a café to continue.")));
if (!success)
return ErrorResult(code!, message!);
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
}
[HttpPost("send-otp")] [HttpPost("send-otp")]
[EnableRateLimiting("auth-otp")] [EnableRateLimiting("auth-otp")]
[ProducesResponseType(typeof(ApiResponse<SendOtpResponse>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<SendOtpResponse>), StatusCodes.Status200OK)]
@@ -91,6 +110,27 @@ public class AuthController : ControllerBase
return Ok(new ApiResponse<AuthTokenResponse>(true, data)); return Ok(new ApiResponse<AuthTokenResponse>(true, data));
} }
[HttpPost("switch-branch")]
[Authorize]
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
public async Task<IActionResult> SwitchBranch([FromBody] SwitchBranchRequest request, CancellationToken cancellationToken)
{
var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId))
return Unauthorized();
var cafeId = User.FindFirstValue(MeeziClaimTypes.CafeId);
if (string.IsNullOrEmpty(cafeId))
return Unauthorized();
var (success, data, code, message) = await _authService.SwitchBranchAsync(userId, cafeId, request.BranchId, cancellationToken);
if (!success)
return ErrorResult(code!, message!);
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
}
[HttpPost("refresh")] [HttpPost("refresh")]
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken) public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
@@ -173,12 +213,17 @@ public class AuthController : ControllerBase
return new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)); return new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName));
} }
private static ApiResponse<object> ValidationError(string message) =>
new(false, null, new ApiError("VALIDATION_ERROR", message));
private IActionResult ErrorResult(string code, string message) => code switch private IActionResult ErrorResult(string code, string message) => code switch
{ {
"RATE_LIMITED" => StatusCode(StatusCodes.Status429TooManyRequests, "RATE_LIMITED" => StatusCode(StatusCodes.Status429TooManyRequests,
new ApiResponse<object>(false, null, new ApiError(code, message))), new ApiResponse<object>(false, null, new ApiError(code, message))),
"NOT_FOUND" => NotFound(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" => Unauthorized(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))), "ALREADY_REGISTERED" => Conflict(new ApiResponse<object>(false, null, new ApiError(code, message))),
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code, message))) _ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code, message)))
}; };
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.Core.Authorization;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Shared; using Meezi.Shared;
@@ -27,6 +28,50 @@ public abstract class CafeApiControllerBase : ControllerBase
new ApiError("OWNER_REQUIRED", "Only the cafe owner can perform this action."))); new ApiError("OWNER_REQUIRED", "Only the cafe owner can perform this action.")));
} }
/// <summary>Owner or Manager may act.</summary>
protected IActionResult? EnsureManager(ITenantContext tenant)
{
if (tenant.Role is EmployeeRole.Owner or EmployeeRole.Manager)
return null;
return Forbidden("MANAGER_REQUIRED", "Manager access required.");
}
/// <summary>The employee acting on their own record, or a manager/owner.</summary>
protected IActionResult? EnsureSelfOrManager(string employeeId, ITenantContext tenant)
{
if (tenant.UserId == employeeId)
return null;
return EnsureManager(tenant);
}
/// <summary>Gate by an explicit capability from the role→permission matrix.</summary>
protected IActionResult? EnsurePermission(ITenantContext tenant, Permission permission)
{
if (tenant.Role is { } role && RolePermissions.Has(role, permission))
return null;
return Forbidden("FORBIDDEN", "You do not have permission to perform this action.");
}
/// <summary>
/// Strict branch isolation at the controller boundary: a branch-scoped session
/// may only touch its own branch. Café-wide sessions (Owner) and sessions with
/// no active branch are unrestricted here (DB query filters back this up).
/// </summary>
protected IActionResult? EnsureBranchAccess(string? routeBranchId, ITenantContext tenant)
{
if (tenant.Role is { } role && RolePermissions.IsCafeWide(role))
return null;
if (string.IsNullOrEmpty(tenant.BranchId))
return null;
if (string.IsNullOrEmpty(routeBranchId) || routeBranchId == tenant.BranchId)
return null;
return Forbidden("BRANCH_FORBIDDEN", "You do not have access to this branch.");
}
private ObjectResult Forbidden(string code, string message) =>
StatusCode(StatusCodes.Status403Forbidden,
new ApiResponse<object>(false, null, new ApiError(code, message)));
protected static ApiResponse<object> ValidationError(FluentValidation.Results.ValidationResult validation) protected static ApiResponse<object> ValidationError(FluentValidation.Results.ValidationResult validation)
{ {
var first = validation.Errors.First(); var first = validation.Errors.First();
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Cafes; using Meezi.API.Models.Cafes;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Core.Utilities;
using Meezi.Infrastructure.Branding; using Meezi.Infrastructure.Branding;
using Meezi.Infrastructure.Data; using Meezi.Infrastructure.Data;
using Meezi.Shared; using Meezi.Shared;
@@ -57,6 +58,21 @@ public class CafeSettingsController : CafeApiControllerBase
if (cafe is null) return NotFoundError(); if (cafe is null) return NotFoundError();
if (request.Name is not null) cafe.Name = request.Name.Trim(); if (request.Name is not null) cafe.Name = request.Name.Trim();
if (request.Slug is not null)
{
var newSlug = request.Slug.Trim().ToLowerInvariant();
if (!SlugHelper.IsValidSlug(newSlug))
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("INVALID_SLUG", "Slug must be 2-80 lowercase letters, digits, or hyphens.")));
var taken = await _db.Cafes.AnyAsync(c => c.Slug == newSlug && c.Id != cafeId, ct);
if (taken)
return Conflict(new ApiResponse<object>(false, null,
new ApiError("SLUG_TAKEN", "This Koja profile address is already in use. Please choose another.")));
cafe.Slug = newSlug;
}
if (request.Phone is not null) cafe.Phone = request.Phone.Trim(); if (request.Phone is not null) cafe.Phone = request.Phone.Trim();
if (request.Address is not null) cafe.Address = request.Address.Trim(); if (request.Address is not null) cafe.Address = request.Address.Trim();
if (request.City is not null) cafe.City = request.City.Trim(); if (request.City is not null) cafe.City = request.City.Trim();
@@ -71,6 +87,21 @@ public class CafeSettingsController : CafeApiControllerBase
if (request.AllowBranchTaxOverride is bool allowTax) if (request.AllowBranchTaxOverride is bool allowTax)
cafe.AllowBranchTaxOverride = allowTax; cafe.AllowBranchTaxOverride = allowTax;
// Location: explicit null-clear flag OR new values
if (request.ClearLocation)
{
cafe.Latitude = null;
cafe.Longitude = null;
}
else if (request.Latitude.HasValue && request.Longitude.HasValue)
{
if (request.Latitude is < -90 or > 90 || request.Longitude is < -180 or > 180)
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("INVALID_LOCATION", "Latitude must be 90…90 and longitude 180…180.")));
cafe.Latitude = request.Latitude;
cafe.Longitude = request.Longitude;
}
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return Ok(new ApiResponse<CafeSettingsDto>(true, ToDto(cafe))); return Ok(new ApiResponse<CafeSettingsDto>(true, ToDto(cafe)));
} }
@@ -90,5 +121,7 @@ public class CafeSettingsController : CafeApiControllerBase
cafe.PlanExpiresAt, cafe.PlanExpiresAt,
CafeThemeMapping.FromJson(cafe.ThemeJson), CafeThemeMapping.FromJson(cafe.ThemeJson),
cafe.DefaultTaxRate, cafe.DefaultTaxRate,
cafe.AllowBranchTaxOverride); cafe.AllowBranchTaxOverride,
cafe.Latitude,
cafe.Longitude);
} }
@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Services;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/demo")]
[Authorize]
public class DemoSeedController : CafeApiControllerBase
{
private readonly IDemoSeedService _demoSeed;
public DemoSeedController(IDemoSeedService demoSeed)
{
_demoSeed = demoSeed;
}
/// <summary>Seeds demo menu, tables, and inventory for any café. Owner-only.</summary>
[HttpPost("seed")]
public async Task<IActionResult> Seed(
string cafeId,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
var result = await _demoSeed.SeedAsync(cafeId, ct);
return Ok(new ApiResponse<DemoSeedResult>(true, result));
}
}
+62 -11
View File
@@ -1,9 +1,12 @@
using FluentValidation; using FluentValidation;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Hr; using Meezi.API.Models.Hr;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Core.Utilities;
using Meezi.Infrastructure.Data;
using Meezi.Shared; using Meezi.Shared;
namespace Meezi.API.Controllers; namespace Meezi.API.Controllers;
@@ -15,17 +18,20 @@ public class HrController : CafeApiControllerBase
private readonly IValidator<CreateLeaveRequest> _leaveValidator; private readonly IValidator<CreateLeaveRequest> _leaveValidator;
private readonly IValidator<ReviewLeaveRequest> _reviewValidator; private readonly IValidator<ReviewLeaveRequest> _reviewValidator;
private readonly IValidator<CreateSalaryRequest> _salaryValidator; private readonly IValidator<CreateSalaryRequest> _salaryValidator;
private readonly AppDbContext _db;
public HrController( public HrController(
IHrService hr, IHrService hr,
IValidator<CreateLeaveRequest> leaveValidator, IValidator<CreateLeaveRequest> leaveValidator,
IValidator<ReviewLeaveRequest> reviewValidator, IValidator<ReviewLeaveRequest> reviewValidator,
IValidator<CreateSalaryRequest> salaryValidator) IValidator<CreateSalaryRequest> salaryValidator,
AppDbContext db)
{ {
_hr = hr; _hr = hr;
_leaveValidator = leaveValidator; _leaveValidator = leaveValidator;
_reviewValidator = reviewValidator; _reviewValidator = reviewValidator;
_salaryValidator = salaryValidator; _salaryValidator = salaryValidator;
_db = db;
} }
[HttpGet("employees")] [HttpGet("employees")]
@@ -202,20 +208,65 @@ public class HrController : CafeApiControllerBase
return Ok(new ApiResponse<EmployeeSalaryDto>(true, data)); return Ok(new ApiResponse<EmployeeSalaryDto>(true, data));
} }
private static IActionResult? EnsureSelfOrManager(string employeeId, ITenantContext tenant) /// <summary>Set or update username/password credentials for an employee. Owner/Manager only.</summary>
[HttpPut("employees/{employeeId}/credentials")]
public async Task<IActionResult> SetCredentials(
string cafeId,
string employeeId,
[FromBody] SetEmployeeCredentialsRequest request,
ITenantContext tenant,
CancellationToken ct)
{ {
if (tenant.UserId == employeeId) return null; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
return EnsureManager(tenant); if (EnsureManager(tenant) is { } forbidden) return forbidden;
var username = request.Username.Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(username))
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Username is required.", "Username")));
if (request.Password.Length < 8)
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Password must be at least 8 characters.", "Password")));
var employee = await _db.Employees
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
if (employee is null) return NotFoundError();
// Check username uniqueness within the cafe (excluding the employee itself)
var conflict = await _db.Employees
.AnyAsync(e => e.CafeId == cafeId && e.Id != employeeId && e.DeletedAt == null
&& e.Username != null && e.Username.ToLower() == username, ct);
if (conflict)
return Conflict(new ApiResponse<object>(false, null, new ApiError("USERNAME_TAKEN", "This username is already in use by another employee.")));
employee.Username = username;
employee.PasswordHash = PasswordHasher.Hash(request.Password);
await _db.SaveChangesAsync(ct);
return Ok(new ApiResponse<object>(true, null));
} }
private static IActionResult? EnsureManager(ITenantContext tenant) /// <summary>Remove username/password credentials from an employee. Owner/Manager only.</summary>
[HttpDelete("employees/{employeeId}/credentials")]
public async Task<IActionResult> RemoveCredentials(
string cafeId,
string employeeId,
ITenantContext tenant,
CancellationToken ct)
{ {
if (tenant.Role is EmployeeRole.Owner or EmployeeRole.Manager) if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
return null; if (EnsureManager(tenant) is { } forbidden) return forbidden;
return new ObjectResult(new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "Manager access required."))) var employee = await _db.Employees
{ .FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
StatusCode = StatusCodes.Status403Forbidden
}; if (employee is null) return NotFoundError();
employee.Username = null;
employee.PasswordHash = null;
await _db.SaveChangesAsync(ct);
return Ok(new ApiResponse<object>(true, null));
} }
} }
+34 -1
View File
@@ -1,9 +1,12 @@
using FluentValidation; using FluentValidation;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Menu; using Meezi.API.Models.Menu;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Constants;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Meezi.Shared; using Meezi.Shared;
namespace Meezi.API.Controllers; namespace Meezi.API.Controllers;
@@ -15,17 +18,25 @@ public class MenuController : CafeApiControllerBase
private readonly IMenuAi3dGenerationService _menuAi3d; private readonly IMenuAi3dGenerationService _menuAi3d;
private readonly IValidator<CreateMenuCategoryRequest> _createCategoryValidator; private readonly IValidator<CreateMenuCategoryRequest> _createCategoryValidator;
private readonly IValidator<CreateMenuItemRequest> _createItemValidator; private readonly IValidator<CreateMenuItemRequest> _createItemValidator;
private readonly AppDbContext _db;
private const string CategoryLimitMessage =
"محدودیت دسته‌بندی پلن رایگان (۳ دسته). برای افزودن دسته‌بندی بیشتر، پلن خود را ارتقا دهید.";
private const string ItemLimitMessage =
"محدودیت آیتم منو پلن رایگان (۳۰ آیتم). برای افزودن آیتم بیشتر، پلن خود را ارتقا دهید.";
public MenuController( public MenuController(
IMenuService menuService, IMenuService menuService,
IMenuAi3dGenerationService menuAi3d, IMenuAi3dGenerationService menuAi3d,
IValidator<CreateMenuCategoryRequest> createCategoryValidator, IValidator<CreateMenuCategoryRequest> createCategoryValidator,
IValidator<CreateMenuItemRequest> createItemValidator) IValidator<CreateMenuItemRequest> createItemValidator,
AppDbContext db)
{ {
_menuService = menuService; _menuService = menuService;
_menuAi3d = menuAi3d; _menuAi3d = menuAi3d;
_createCategoryValidator = createCategoryValidator; _createCategoryValidator = createCategoryValidator;
_createItemValidator = createItemValidator; _createItemValidator = createItemValidator;
_db = db;
} }
[HttpGet("categories")] [HttpGet("categories")]
@@ -47,6 +58,17 @@ public class MenuController : CafeApiControllerBase
var validation = await _createCategoryValidator.ValidateAsync(request, cancellationToken); var validation = await _createCategoryValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); if (!validation.IsValid) return BadRequest(ValidationError(validation));
var tier = tenant.PlanTier ?? PlanTier.Free;
var max = PlanLimits.MaxMenuCategories(tier);
if (max != int.MaxValue)
{
var count = await _db.MenuCategories.CountAsync(
c => c.CafeId == cafeId && c.DeletedAt == null, cancellationToken);
if (count >= max)
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("PLAN_LIMIT_REACHED", CategoryLimitMessage)));
}
var data = await _menuService.CreateCategoryAsync(cafeId, request, cancellationToken); var data = await _menuService.CreateCategoryAsync(cafeId, request, cancellationToken);
return Ok(new ApiResponse<MenuCategoryDto>(true, data)); return Ok(new ApiResponse<MenuCategoryDto>(true, data));
} }
@@ -97,6 +119,17 @@ public class MenuController : CafeApiControllerBase
var validation = await _createItemValidator.ValidateAsync(request, cancellationToken); var validation = await _createItemValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); if (!validation.IsValid) return BadRequest(ValidationError(validation));
var tier = tenant.PlanTier ?? PlanTier.Free;
var max = PlanLimits.MaxMenuItems(tier);
if (max != int.MaxValue)
{
var count = await _db.MenuItems.CountAsync(
i => i.CafeId == cafeId && i.DeletedAt == null, cancellationToken);
if (count >= max)
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("PLAN_LIMIT_REACHED", ItemLimitMessage)));
}
var data = await _menuService.CreateItemAsync(cafeId, request, cancellationToken); var data = await _menuService.CreateItemAsync(cafeId, request, cancellationToken);
if (data is null) return NotFoundError("Category not found."); if (data is null) return NotFoundError("Category not found.");
return Ok(new ApiResponse<MenuItemDto>(true, data)); return Ok(new ApiResponse<MenuItemDto>(true, data));
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Orders; using Meezi.API.Models.Orders;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Shared; using Meezi.Shared;
@@ -13,6 +14,7 @@ namespace Meezi.API.Controllers;
public class OrdersController : CafeApiControllerBase public class OrdersController : CafeApiControllerBase
{ {
private readonly IOrderService _orderService; private readonly IOrderService _orderService;
private readonly IAuditLogService _audit;
private readonly IValidator<CreateOrderRequest> _createValidator; private readonly IValidator<CreateOrderRequest> _createValidator;
private readonly IValidator<UpdateOrderStatusRequest> _statusValidator; private readonly IValidator<UpdateOrderStatusRequest> _statusValidator;
private readonly IValidator<RecordPaymentsRequest> _paymentsValidator; private readonly IValidator<RecordPaymentsRequest> _paymentsValidator;
@@ -21,6 +23,7 @@ public class OrdersController : CafeApiControllerBase
public OrdersController( public OrdersController(
IOrderService orderService, IOrderService orderService,
IAuditLogService audit,
IValidator<CreateOrderRequest> createValidator, IValidator<CreateOrderRequest> createValidator,
IValidator<UpdateOrderStatusRequest> statusValidator, IValidator<UpdateOrderStatusRequest> statusValidator,
IValidator<RecordPaymentsRequest> paymentsValidator, IValidator<RecordPaymentsRequest> paymentsValidator,
@@ -28,6 +31,7 @@ public class OrdersController : CafeApiControllerBase
IValidator<UpdateOrderSessionRequest> sessionValidator) IValidator<UpdateOrderSessionRequest> sessionValidator)
{ {
_orderService = orderService; _orderService = orderService;
_audit = audit;
_createValidator = createValidator; _createValidator = createValidator;
_statusValidator = statusValidator; _statusValidator = statusValidator;
_paymentsValidator = paymentsValidator; _paymentsValidator = paymentsValidator;
@@ -131,6 +135,16 @@ public class OrdersController : CafeApiControllerBase
if (!result.Success) if (!result.Success)
return OrderError(result.ErrorCode!, result.Field); return OrderError(result.ErrorCode!, result.Field);
await _audit.LogAsync(new AuditEntry
{
Category = "Order",
Action = "ItemVoided",
EntityType = "Order",
EntityId = id,
Summary = $"Voided a line item on order #{result.Data!.DisplayNumber}",
Details = new { orderId = id, itemId, displayNumber = result.Data.DisplayNumber }
}, cancellationToken);
return Ok(new ApiResponse<OrderDto>(true, result.Data)); return Ok(new ApiResponse<OrderDto>(true, result.Data));
} }
@@ -188,6 +202,42 @@ public class OrdersController : CafeApiControllerBase
return Ok(new ApiResponse<OrderDto>(true, data)); return Ok(new ApiResponse<OrderDto>(true, data));
} }
[HttpPost("{id}/cancel")]
public async Task<IActionResult> CancelOrder(
string cafeId,
string id,
[FromBody] CancelOrderRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ProcessOrders) is { } forbidden) return forbidden;
var result = await _orderService.CancelOrderAsync(
cafeId, id, request.Reason, tenant.UserId, cancellationToken);
if (!result.Success)
return OrderError(result.ErrorCode!, result.Field);
await _audit.LogAsync(new AuditEntry
{
Category = "Order",
Action = "OrderCancelled",
EntityType = "Order",
EntityId = id,
Summary = $"Order #{result.Data!.DisplayNumber} cancelled"
+ (string.IsNullOrWhiteSpace(request.Reason) ? "" : $": {request.Reason!.Trim()}"),
Details = new
{
orderId = id,
displayNumber = result.Data.DisplayNumber,
total = result.Data.Total,
reason = request.Reason
}
}, cancellationToken);
return Ok(new ApiResponse<OrderDto>(true, result.Data));
}
[HttpPost("{id}/payments")] [HttpPost("{id}/payments")]
public async Task<IActionResult> RecordPayments( public async Task<IActionResult> RecordPayments(
string cafeId, string cafeId,
@@ -203,6 +253,23 @@ public class OrdersController : CafeApiControllerBase
var result = await _orderService.RecordPaymentsAsync( var result = await _orderService.RecordPaymentsAsync(
cafeId, id, request, tenant.UserId, cancellationToken); cafeId, id, request, tenant.UserId, cancellationToken);
if (!result.Success) return OrderError(result.ErrorCode!, result.Field); if (!result.Success) return OrderError(result.ErrorCode!, result.Field);
var paidTotal = result.Data!.Sum(p => p.Amount);
await _audit.LogAsync(new AuditEntry
{
Category = "Payment",
Action = "PaymentRecorded",
EntityType = "Order",
EntityId = id,
Summary = $"Recorded payment(s) totalling {paidTotal:0.##} on order",
Details = new
{
orderId = id,
total = paidTotal,
methods = result.Data!.Select(p => new { p.Method, p.Amount })
}
}, cancellationToken);
return Ok(new ApiResponse<IReadOnlyList<PaymentDto>>(true, result.Data)); return Ok(new ApiResponse<IReadOnlyList<PaymentDto>>(true, result.Data));
} }
@@ -219,6 +286,10 @@ public class OrdersController : CafeApiControllerBase
false, null, new ApiError(code, "Order not found.", field))), false, null, new ApiError(code, "Order not found.", field))),
"ORDER_ALREADY_CLOSED" => BadRequest(new ApiResponse<object>( "ORDER_ALREADY_CLOSED" => BadRequest(new ApiResponse<object>(
false, null, new ApiError(code, "Order is already closed.", field))), false, null, new ApiError(code, "Order is already closed.", field))),
"ORDER_ALREADY_CANCELLED" => BadRequest(new ApiResponse<object>(
false, null, new ApiError(code, "Order is already cancelled.", field))),
"ORDER_HAS_PAYMENTS" => Conflict(new ApiResponse<object>(
false, null, new ApiError(code, "Refund the recorded payments before cancelling this order.", field))),
"ITEM_NOT_FOUND" => NotFound(new ApiResponse<object>( "ITEM_NOT_FOUND" => NotFound(new ApiResponse<object>(
false, null, new ApiError(code, "Line item not found.", field))), false, null, new ApiError(code, "Line item not found.", field))),
"ITEM_ALREADY_VOIDED" => BadRequest(new ApiResponse<object>( "ITEM_ALREADY_VOIDED" => BadRequest(new ApiResponse<object>(
@@ -367,4 +367,101 @@ public class PublicController : ControllerBase
return Ok(new ApiResponse<object>(true, null)); return Ok(new ApiResponse<object>(true, null));
} }
/// <summary>
/// Returns all cafés that have a known location (Latitude/Longitude set).
/// Used by the marketing website SVG map to render blinking dots.
/// </summary>
[HttpGet("map-markers")]
[EnableRateLimiting("public-read")]
public async Task<IActionResult> GetMapMarkers(
[FromServices] AppDbContext db,
CancellationToken ct)
{
var markers = await db.Cafes
.AsNoTracking()
.Where(c => c.DeletedAt == null && c.Latitude != null && c.Longitude != null)
.Select(c => new
{
c.Id,
c.Name,
c.Slug,
c.City,
c.Latitude,
c.Longitude,
c.LogoUrl
})
.ToListAsync(ct);
return Ok(new ApiResponse<object>(true, markers));
}
/// <summary>
/// Returns cafés near a given coordinate, sorted by distance ascending.
/// Used by Koja guest page to show "nearby cafés" section.
/// At most <paramref name="limit"/> results (default 5, max 20).
/// </summary>
[HttpGet("nearby")]
[EnableRateLimiting("public-read")]
public async Task<IActionResult> GetNearbyCafes(
[FromQuery] double lat,
[FromQuery] double lng,
[FromQuery] string? excludeSlug,
[FromQuery] int limit,
[FromServices] AppDbContext db,
CancellationToken ct)
{
limit = Math.Clamp(limit <= 0 ? 5 : limit, 1, 20);
// Pull all located cafés from DB (typically small set) and sort in memory with Haversine.
var cafes = await db.Cafes
.AsNoTracking()
.Where(c => c.DeletedAt == null
&& c.Latitude != null
&& c.Longitude != null
&& (excludeSlug == null || c.Slug != excludeSlug))
.Select(c => new
{
c.Id,
c.Name,
c.Slug,
c.City,
c.Latitude,
c.Longitude,
c.LogoUrl,
c.CoverImageUrl
})
.ToListAsync(ct);
static double ToRad(double deg) => deg * Math.PI / 180.0;
static double Haversine(double lat1, double lon1, double lat2, double lon2)
{
const double R = 6371; // km
var dLat = ToRad(lat2 - lat1);
var dLon = ToRad(lon2 - lon1);
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2)
+ Math.Cos(ToRad(lat1)) * Math.Cos(ToRad(lat2))
* Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
return R * 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
}
var nearby = cafes
.Select(c => new
{
c.Id,
c.Name,
c.Slug,
c.City,
c.Latitude,
c.Longitude,
c.LogoUrl,
c.CoverImageUrl,
DistanceKm = Math.Round(Haversine(lat, lng, c.Latitude!.Value, c.Longitude!.Value), 1)
})
.OrderBy(c => c.DistanceKm)
.Take(limit)
.ToList();
return Ok(new ApiResponse<object>(true, nearby));
}
} }
@@ -0,0 +1,195 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Staff;
using Meezi.Core.Authorization;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Meezi.Shared;
namespace Meezi.API.Controllers;
/// <summary>
/// Manage the per-branch role assignments that drive the active-branch session model.
/// Owner/Manager gated; branch-scoped managers may only touch their own branch.
/// </summary>
[Route("api/cafes/{cafeId}/employees/{employeeId}/branch-roles")]
public class StaffBranchRolesController : CafeApiControllerBase
{
private readonly AppDbContext _db;
private readonly Meezi.API.Services.IAuditLogService _audit;
public StaffBranchRolesController(AppDbContext db, Meezi.API.Services.IAuditLogService audit)
{
_db = db;
_audit = audit;
}
[HttpGet]
public async Task<IActionResult> List(
string cafeId,
string employeeId,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageStaff) is { } forbidden) return forbidden;
var employeeExists = await _db.Employees
.AnyAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
if (!employeeExists) return NotFoundError("Employee not found.");
var data = await _db.EmployeeBranchRoles
.Where(r => r.EmployeeId == employeeId && r.CafeId == cafeId && r.DeletedAt == null)
.Join(_db.Branches, r => r.BranchId, b => b.Id, (r, b) => new BranchRoleAssignmentDto(r.Id, b.Id, b.Name, r.Role))
.OrderBy(d => d.BranchName)
.ToListAsync(ct);
return Ok(new ApiResponse<IReadOnlyList<BranchRoleAssignmentDto>>(true, data));
}
[HttpPost]
public async Task<IActionResult> Assign(
string cafeId,
string employeeId,
[FromBody] AssignBranchRoleRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageStaff) is { } forbidden) return forbidden;
if (EnsureBranchAccess(request.BranchId, tenant) is { } branchDenied) return branchDenied;
if (request.Role == EmployeeRole.Owner)
return BadRequest(Error("INVALID_ROLE", "Owners are café-wide and cannot be assigned to a branch."));
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 (employee.Role == EmployeeRole.Owner)
return BadRequest(Error("INVALID_ROLE", "The café owner cannot hold per-branch roles."));
var branchExists = await _db.Branches
.AnyAsync(b => b.Id == request.BranchId && b.CafeId == cafeId && b.DeletedAt == null, ct);
if (!branchExists) return NotFoundError("Branch not found.");
var existing = await _db.EmployeeBranchRoles
.FirstOrDefaultAsync(r => r.EmployeeId == employeeId && r.BranchId == request.BranchId && r.DeletedAt == null, ct);
if (existing is not null)
return Conflict(new ApiResponse<object>(false, null,
new ApiError("ALREADY_ASSIGNED", "This employee already has a role in this branch. Update it instead.")));
var assignment = new EmployeeBranchRole
{
CafeId = cafeId,
EmployeeId = employeeId,
BranchId = request.BranchId,
Role = request.Role,
};
_db.EmployeeBranchRoles.Add(assignment);
await _db.SaveChangesAsync(ct);
var branchName = await _db.Branches
.Where(b => b.Id == request.BranchId)
.Select(b => b.Name)
.FirstAsync(ct);
await _audit.LogAsync(new Meezi.API.Services.AuditEntry
{
Category = "Staff",
Action = "BranchRoleAssigned",
EntityType = "Employee",
EntityId = employeeId,
BranchId = request.BranchId,
Summary = $"Assigned {request.Role} role in {branchName} to {employee.Name}",
Details = new { employeeId, branchId = request.BranchId, role = request.Role.ToString() }
}, ct);
return Ok(new ApiResponse<BranchRoleAssignmentDto>(true,
new BranchRoleAssignmentDto(assignment.Id, request.BranchId, branchName, request.Role)));
}
[HttpPatch("{assignmentId}")]
public async Task<IActionResult> Update(
string cafeId,
string employeeId,
string assignmentId,
[FromBody] UpdateBranchRoleRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageStaff) is { } forbidden) return forbidden;
if (request.Role == EmployeeRole.Owner)
return BadRequest(Error("INVALID_ROLE", "Owners are café-wide and cannot be assigned to a branch."));
var assignment = await _db.EmployeeBranchRoles
.FirstOrDefaultAsync(r => r.Id == assignmentId && r.EmployeeId == employeeId
&& r.CafeId == cafeId && r.DeletedAt == null, ct);
if (assignment is null) return NotFoundError("Branch role assignment not found.");
if (EnsureBranchAccess(assignment.BranchId, tenant) is { } branchDenied) return branchDenied;
assignment.Role = request.Role;
await _db.SaveChangesAsync(ct);
var branchName = await _db.Branches
.Where(b => b.Id == assignment.BranchId)
.Select(b => b.Name)
.FirstAsync(ct);
await _audit.LogAsync(new Meezi.API.Services.AuditEntry
{
Category = "Staff",
Action = "BranchRoleUpdated",
EntityType = "Employee",
EntityId = employeeId,
BranchId = assignment.BranchId,
Summary = $"Changed role to {request.Role} in {branchName}",
Details = new { employeeId, branchId = assignment.BranchId, role = request.Role.ToString() }
}, ct);
return Ok(new ApiResponse<BranchRoleAssignmentDto>(true,
new BranchRoleAssignmentDto(assignment.Id, assignment.BranchId, branchName, assignment.Role)));
}
[HttpDelete("{assignmentId}")]
public async Task<IActionResult> Remove(
string cafeId,
string employeeId,
string assignmentId,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageStaff) is { } forbidden) return forbidden;
var assignment = await _db.EmployeeBranchRoles
.FirstOrDefaultAsync(r => r.Id == assignmentId && r.EmployeeId == employeeId
&& r.CafeId == cafeId && r.DeletedAt == null, ct);
if (assignment is null) return NotFoundError("Branch role assignment not found.");
if (EnsureBranchAccess(assignment.BranchId, tenant) is { } branchDenied) return branchDenied;
assignment.DeletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
await _audit.LogAsync(new Meezi.API.Services.AuditEntry
{
Category = "Staff",
Action = "BranchRoleRemoved",
EntityType = "Employee",
EntityId = employeeId,
BranchId = assignment.BranchId,
Summary = $"Removed {assignment.Role} branch role",
Details = new { employeeId, branchId = assignment.BranchId, role = assignment.Role.ToString() }
}, ct);
return Ok(new ApiResponse<object>(true, null));
}
private static ApiResponse<object> Error(string code, string message) =>
new(false, null, new ApiError(code, message));
}
@@ -28,6 +28,7 @@ public static class ServiceCollectionExtensions
services.AddMeeziSecurity(configuration); services.AddMeeziSecurity(configuration);
services.AddInfrastructure(configuration); services.AddInfrastructure(configuration);
services.AddScoped<IAuthService, AuthService>(); services.AddScoped<IAuthService, AuthService>();
services.AddScoped<IAuditLogService, AuditLogService>();
services.AddScoped<IConsumerAuthService, ConsumerAuthService>(); services.AddScoped<IConsumerAuthService, ConsumerAuthService>();
services.AddScoped<IConsumerOrdersService, ConsumerOrdersService>(); services.AddScoped<IConsumerOrdersService, ConsumerOrdersService>();
services.AddScoped<IKitchenStationService, KitchenStationService>(); services.AddScoped<IKitchenStationService, KitchenStationService>();
@@ -90,6 +91,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<IQueueService, QueueService>(); services.AddScoped<IQueueService, QueueService>();
services.AddScoped<IShiftService, ShiftService>(); services.AddScoped<IShiftService, ShiftService>();
services.AddScoped<IExpenseService, ExpenseService>(); services.AddScoped<IExpenseService, ExpenseService>();
services.AddScoped<IDemoSeedService, DemoSeedService>();
services.AddScoped<ReceiptBuilder>(); services.AddScoped<ReceiptBuilder>();
services.AddScoped<IPrinterService, NetworkPrinterService>(); services.AddScoped<IPrinterService, NetworkPrinterService>();
services.AddHttpClient(nameof(PosDeviceService)); services.AddHttpClient(nameof(PosDeviceService));
@@ -0,0 +1,16 @@
namespace Meezi.API.Models.Audit;
/// <summary>A single audit-trail entry as exposed to the dashboard.</summary>
public record AuditLogDto(
string Id,
string Category,
string Action,
string? EntityType,
string? EntityId,
string? BranchId,
string? ActorId,
string? ActorName,
string? ActorRole,
string Summary,
string? DetailsJson,
DateTime CreatedAt);
+17 -2
View File
@@ -2,14 +2,21 @@ namespace Meezi.API.Models.Auth;
public record SendOtpRequest(string Phone); 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);
public record VerifyOtpRequest(string Phone, string Code, string? CafeId = null); public record VerifyOtpRequest(string Phone, string Code, string? CafeId = null);
public record RefreshTokenRequest(string RefreshToken); public record RefreshTokenRequest(string RefreshToken);
public record SwitchCafeRequest(string CafeId); public record SwitchCafeRequest(string CafeId);
/// <summary>Switch the active branch within the current café. Null = café-wide (Owner only).</summary>
public record SwitchBranchRequest(string? BranchId);
/// <summary>Step 1 of self-registration: send OTP to a new phone number.</summary> /// <summary>Step 1 of self-registration: send OTP to a new phone number.</summary>
public record RegisterRequest(string Phone, string CafeName); /// <param name="Slug">Optional custom Koja slug (e.g. "lamiz-enghelab"). Auto-derived from CafeName if omitted.</param>
public record RegisterRequest(string Phone, string CafeName, string? Slug = null);
/// <summary>Step 2 of self-registration: verify OTP and create the cafe account.</summary> /// <summary>Step 2 of self-registration: verify OTP and create the cafe account.</summary>
public record VerifyRegisterRequest(string Phone, string Code); public record VerifyRegisterRequest(string Phone, string Code);
@@ -17,6 +24,9 @@ public record VerifyRegisterRequest(string Phone, string Code);
/// <summary>One café membership entry returned when user belongs to multiple cafés.</summary> /// <summary>One café membership entry returned when user belongs to multiple cafés.</summary>
public record CafeMembershipDto(string CafeId, string CafeName, string Role, string PlanTier); public record CafeMembershipDto(string CafeId, string CafeName, string Role, string PlanTier);
/// <summary>A branch the signed-in employee may operate as, with their role there.</summary>
public record BranchMembershipDto(string BranchId, string BranchName, string Role);
public record AuthTokenResponse( public record AuthTokenResponse(
string AccessToken, string AccessToken,
string RefreshToken, string RefreshToken,
@@ -28,7 +38,12 @@ public record AuthTokenResponse(
string Language, string Language,
string Actor = Meezi.Core.Constants.MeeziActorKinds.Merchant, string Actor = Meezi.Core.Constants.MeeziActorKinds.Merchant,
string? BranchId = null, string? BranchId = null,
List<CafeMembershipDto>? Memberships = null); List<CafeMembershipDto>? Memberships = null,
string? BranchName = null,
bool IsCafeWide = false,
List<BranchMembershipDto>? Branches = null,
/// <summary>Effective capabilities for the active role — drives client-side page/action gating.</summary>
List<string>? Permissions = null);
public record SendOtpResponse(bool Sent, int ExpiresInSeconds); public record SendOtpResponse(bool Sent, int ExpiresInSeconds);
+12 -2
View File
@@ -15,10 +15,14 @@ public record CafeSettingsDto(
DateTime? PlanExpiresAt, DateTime? PlanExpiresAt,
CafeThemeDto Theme, CafeThemeDto Theme,
decimal DefaultTaxRate, decimal DefaultTaxRate,
bool AllowBranchTaxOverride); bool AllowBranchTaxOverride,
double? Latitude,
double? Longitude);
public record PatchCafeSettingsRequest( public record PatchCafeSettingsRequest(
string? Name, string? Name,
/// <summary>Custom Koja profile slug (e.g. "lamiz-enghelab"). Must be unique across all cafés.</summary>
string? Slug,
string? Phone, string? Phone,
string? Address, string? Address,
string? City, string? City,
@@ -28,4 +32,10 @@ public record PatchCafeSettingsRequest(
string? SnappfoodVendorId, string? SnappfoodVendorId,
CafeThemeDto? Theme, CafeThemeDto? Theme,
decimal? DefaultTaxRate, decimal? DefaultTaxRate,
bool? AllowBranchTaxOverride); bool? AllowBranchTaxOverride,
/// <summary>WGS-84 latitude. Send null to clear.</summary>
double? Latitude,
/// <summary>WGS-84 longitude. Send null to clear.</summary>
double? Longitude,
/// <summary>When true, Latitude and Longitude are explicitly being cleared (set to null).</summary>
bool ClearLocation = false);
+3
View File
@@ -59,3 +59,6 @@ public record CreateSalaryRequest(
decimal Deductions); decimal Deductions);
public record TodayShiftDto(ShiftType ShiftType, string Label); 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);
+2
View File
@@ -62,6 +62,8 @@ public record CreateOrderRequest(
public record UpdateOrderStatusRequest(OrderStatus Status); public record UpdateOrderStatusRequest(OrderStatus Status);
public record CancelOrderRequest(string? Reason);
public record CreatePaymentRequest(PaymentMethod Method, decimal Amount, string? Reference); public record CreatePaymentRequest(PaymentMethod Method, decimal Amount, string? Reference);
public record RecordPaymentsRequest( public record RecordPaymentsRequest(
@@ -0,0 +1,16 @@
using Meezi.Core.Enums;
namespace Meezi.API.Models.Staff;
/// <summary>A single per-branch role assignment for an employee.</summary>
public record BranchRoleAssignmentDto(
string Id,
string BranchId,
string BranchName,
EmployeeRole Role);
/// <summary>Assign (or move) an employee into a branch with a specific role.</summary>
public record AssignBranchRoleRequest(string BranchId, EmployeeRole Role);
/// <summary>Change the role an employee holds in an existing branch assignment.</summary>
public record UpdateBranchRoleRequest(EmployeeRole Role);
+80
View File
@@ -0,0 +1,80 @@
using System.Text.Json;
using Meezi.Core.Entities;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Microsoft.Extensions.DependencyInjection;
namespace Meezi.API.Services;
/// <summary>
/// Persists audit entries on a fresh, isolated <see cref="AppDbContext"/> so the
/// write never participates in (or rolls back with) the caller's transaction, and
/// swallows all failures — auditing must never break the recorded operation.
/// </summary>
public sealed class AuditLogService : IAuditLogService
{
private static readonly JsonSerializerOptions DetailsJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
private readonly ITenantContext _tenant;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<AuditLogService> _logger;
public AuditLogService(
ITenantContext tenant,
IServiceScopeFactory scopeFactory,
ILogger<AuditLogService> logger)
{
_tenant = tenant;
_scopeFactory = scopeFactory;
_logger = logger;
}
public async Task LogAsync(AuditEntry entry, CancellationToken ct = default)
{
try
{
var cafeId = _tenant.CafeId;
if (string.IsNullOrEmpty(cafeId))
{
_logger.LogWarning(
"Skipping audit log '{Category}/{Action}' — no cafe context.",
entry.Category, entry.Action);
return;
}
var log = new AuditLog
{
CafeId = cafeId,
BranchId = entry.BranchId ?? _tenant.BranchId,
Category = entry.Category,
Action = entry.Action,
EntityType = entry.EntityType,
EntityId = entry.EntityId,
ActorId = _tenant.UserId,
ActorName = entry.ActorName,
ActorRole = _tenant.Role?.ToString(),
Summary = entry.Summary,
DetailsJson = entry.Details is null
? null
: JsonSerializer.Serialize(entry.Details, DetailsJsonOptions)
};
// Fresh scope → fresh DbContext (café-wide, unfiltered) so this write is
// independent of the business operation's change-tracker and transaction.
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.AuditLogs.Add(log);
await db.SaveChangesAsync(ct);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to write audit log '{Category}/{Action}' for entity {EntityType}:{EntityId}.",
entry.Category, entry.Action, entry.EntityType, entry.EntityId);
}
}
}
+260 -18
View File
@@ -1,5 +1,6 @@
using Meezi.API.Models.Auth; using Meezi.API.Models.Auth;
using Meezi.API.Security; using Meezi.API.Security;
using Meezi.Core.Authorization;
using Meezi.Core.Entities; using Meezi.Core.Entities;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
@@ -156,7 +157,7 @@ public class AuthService : IAuthService
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString())) .Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
.ToList(); .ToList();
var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, cancellationToken); var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, null, cancellationToken);
return (true, tokens, null, null, null); return (true, tokens, null, null, null);
} }
@@ -187,7 +188,53 @@ public class AuthService : IAuthService
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString())) .Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
.ToList(); .ToList();
var tokens = await IssueTokensAsync(targetEmployee, targetEmployee.Cafe, membershipDtos, cancellationToken); var tokens = await IssueTokensAsync(targetEmployee, targetEmployee.Cafe, membershipDtos, null, cancellationToken);
return (true, tokens, null, null);
}
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchBranchAsync(
string employeeId, string cafeId, string? targetBranchId,
CancellationToken cancellationToken = default)
{
var employee = await _db.Employees
.Include(e => e.Cafe)
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, cancellationToken);
if (employee?.Cafe is null)
return (false, null, "NOT_FOUND", "User not found.");
// null target = café-wide (Owner only)
if (string.IsNullOrWhiteSpace(targetBranchId))
{
if (employee.Role != EmployeeRole.Owner)
return (false, null, "BRANCH_FORBIDDEN", "Only owners can operate café-wide.");
}
else
{
var branchExists = await _db.Branches
.AnyAsync(b => b.Id == targetBranchId && b.CafeId == cafeId && b.DeletedAt == null, cancellationToken);
if (!branchExists)
return (false, null, "NOT_FOUND", "Branch not found.");
if (employee.Role != EmployeeRole.Owner)
{
var assigned = await _db.EmployeeBranchRoles
.AnyAsync(r => r.EmployeeId == employeeId && r.BranchId == targetBranchId && r.DeletedAt == null, cancellationToken);
if (!assigned && employee.BranchId != targetBranchId)
return (false, null, "BRANCH_FORBIDDEN", "You don't have access to this branch.");
}
}
var allMemberships = await _db.Employees
.Include(e => e.Cafe)
.Where(e => e.Phone == employee.Phone && e.DeletedAt == null)
.ToListAsync(cancellationToken);
var membershipDtos = allMemberships
.Where(e => e.Cafe is not null)
.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, targetBranchId, cancellationToken);
return (true, tokens, null, null); return (true, tokens, null, null);
} }
@@ -218,7 +265,7 @@ public class AuthService : IAuthService
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString())) .Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
.ToList(); .ToList();
var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, cancellationToken); var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, payload.ActiveBranchId, cancellationToken);
return (true, tokens, null, null); return (true, tokens, null, null);
} }
@@ -256,8 +303,15 @@ public class AuthService : IAuthService
var otp = Random.Shared.Next(100000, 999999).ToString(); var otp = Random.Shared.Next(100000, 999999).ToString();
await redis.StringSetAsync($"otp:{phone}", otp, TimeSpan.FromSeconds(OtpTtlSeconds)); await redis.StringSetAsync($"otp:{phone}", otp, TimeSpan.FromSeconds(OtpTtlSeconds));
// Store the cafe name alongside the OTP so verify-register can create the cafe
await redis.StringSetAsync($"reg_meta:{phone}", cafeName, TimeSpan.FromSeconds(OtpTtlSeconds)); // Determine the requested slug: use provided slug, or auto-derive from café name.
// Format stored: "cafeName||slug" (double-pipe delimiter). Slug may be empty.
var requestedSlug = string.IsNullOrWhiteSpace(request.Slug)
? Meezi.Core.Utilities.SlugHelper.Slugify(cafeName)
: request.Slug.Trim().ToLowerInvariant();
var regMeta = $"{cafeName}||{requestedSlug}";
await redis.StringSetAsync($"reg_meta:{phone}", regMeta, TimeSpan.FromSeconds(OtpTtlSeconds));
try try
{ {
@@ -295,10 +349,25 @@ public class AuthService : IAuthService
if (storedOtp.IsNullOrEmpty || storedOtp.ToString() != code) if (storedOtp.IsNullOrEmpty || storedOtp.ToString() != code)
return (false, null, "INVALID_OTP", "Invalid or expired verification code."); return (false, null, "INVALID_OTP", "Invalid or expired verification code.");
var cafeName = (await redis.StringGetAsync($"reg_meta:{phone}")).ToString(); var regMetaRaw = (await redis.StringGetAsync($"reg_meta:{phone}")).ToString();
if (string.IsNullOrWhiteSpace(cafeName)) if (string.IsNullOrWhiteSpace(regMetaRaw))
return (false, null, "REGISTRATION_EXPIRED", "Registration session expired. Please start again."); return (false, null, "REGISTRATION_EXPIRED", "Registration session expired. Please start again.");
// Parse "cafeName||slug" format (double-pipe delimiter)
string cafeName;
string? requestedSlug;
var sepIdx = regMetaRaw.IndexOf("||", StringComparison.Ordinal);
if (sepIdx >= 0)
{
cafeName = regMetaRaw[..sepIdx];
requestedSlug = regMetaRaw[(sepIdx + 2)..];
}
else
{
cafeName = regMetaRaw;
requestedSlug = null;
}
// Double-check no owner was created in the meantime (race condition guard) // Double-check no owner was created in the meantime (race condition guard)
var alreadyOwner = await _db.Employees var alreadyOwner = await _db.Employees
.AnyAsync(e => e.Phone == phone && e.Role == EmployeeRole.Owner && e.DeletedAt == null, cancellationToken); .AnyAsync(e => e.Phone == phone && e.Role == EmployeeRole.Owner && e.DeletedAt == null, cancellationToken);
@@ -309,8 +378,8 @@ public class AuthService : IAuthService
return (false, null, "ALREADY_REGISTERED", "An account already exists for this phone number. Please sign in."); return (false, null, "ALREADY_REGISTERED", "An account already exists for this phone number. Please sign in.");
} }
// Generate a unique slug // Generate a unique slug (try requested slug first, fall back to random)
var slug = await GenerateUniqueSlugAsync(cancellationToken); var slug = await GenerateUniqueSlugAsync(requestedSlug, cancellationToken);
var cafe = new Cafe var cafe = new Cafe
{ {
@@ -320,6 +389,15 @@ public class AuthService : IAuthService
PlanTier = PlanTier.Free, PlanTier = PlanTier.Free,
}; };
// Auto-create a default main branch so the owner can start using the
// dashboard immediately without hitting the "select a branch" gate.
var defaultBranch = new Branch
{
CafeId = cafe.Id,
Name = cafeName,
IsActive = true,
};
var owner = new Employee var owner = new Employee
{ {
CafeId = cafe.Id, CafeId = cafe.Id,
@@ -329,6 +407,7 @@ public class AuthService : IAuthService
}; };
_db.Cafes.Add(cafe); _db.Cafes.Add(cafe);
_db.Branches.Add(defaultBranch);
_db.Employees.Add(owner); _db.Employees.Add(owner);
await _db.SaveChangesAsync(cancellationToken); await _db.SaveChangesAsync(cancellationToken);
@@ -341,28 +420,101 @@ public class AuthService : IAuthService
{ {
new(cafe.Id, cafe.Name, owner.Role.ToString(), cafe.PlanTier.ToString()) new(cafe.Id, cafe.Name, owner.Role.ToString(), cafe.PlanTier.ToString())
}; };
var tokens = await IssueTokensAsync(owner, cafe, ownerMembership, cancellationToken); var tokens = await IssueTokensAsync(owner, cafe, ownerMembership, null, cancellationToken);
return (true, tokens, null, null); return (true, tokens, null, null);
} }
private async Task<string> GenerateUniqueSlugAsync(CancellationToken ct) private async Task<string> GenerateUniqueSlugAsync(string? preferred, CancellationToken ct)
{ {
// Try the preferred/derived slug first
if (Meezi.Core.Utilities.SlugHelper.IsValidSlug(preferred))
{
if (!await _db.Cafes.AnyAsync(c => c.Slug == preferred, ct))
return preferred!;
// Preferred slug is taken — append a short random suffix
for (var i = 0; i < 10; i++)
{
var candidate = $"{preferred}-{Guid.NewGuid().ToString("N")[..4]}";
if (!await _db.Cafes.AnyAsync(c => c.Slug == candidate, ct))
return candidate;
}
}
// Full random fallback
string slug; string slug;
do do
{ {
// e.g. "cafe-a3f9b2c"
slug = "cafe-" + Guid.NewGuid().ToString("N")[..7]; slug = "cafe-" + Guid.NewGuid().ToString("N")[..7];
} while (await _db.Cafes.AnyAsync(c => c.Slug == slug, ct)); } while (await _db.Cafes.AnyAsync(c => c.Slug == slug, ct));
return slug; return slug;
} }
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage, CafeChoicesResponse? Choices)> LoginWithPasswordAsync(
LoginWithPasswordRequest request,
CancellationToken cancellationToken = default)
{
var username = request.Username.Trim();
var candidates = await _db.Employees
.Include(e => e.Cafe)
.Where(e => e.Username == username
&& e.PasswordHash != null
&& e.DeletedAt == null
&& e.Cafe.DeletedAt == null)
.ToListAsync(cancellationToken);
if (candidates.Count == 0)
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);
// Constant-time verification (check all matches to avoid username enumeration)
var matched = candidates.Where(e => PasswordHasher.Verify(request.Password, e.PasswordHash!)).ToList();
if (matched.Count == 0)
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);
// Scope to a specific café if requested
if (!string.IsNullOrWhiteSpace(request.CafeId))
{
matched = matched.Where(e => e.CafeId == request.CafeId).ToList();
if (matched.Count == 0)
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);
}
// Multiple cafés — ask frontend to pick one
if (matched.Count > 1)
{
var choices = new CafeChoicesResponse(
matched
.Where(e => e.Cafe is not null)
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
.ToList());
return (false, null, "CHOOSE_CAFE", null, choices);
}
var employee = matched[0];
if (employee.Cafe is null)
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);
var membershipDtos = matched
.Where(e => e.Cafe is not null)
.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, null, cancellationToken);
return (true, tokens, null, null, null);
}
private async Task<AuthTokenResponse> IssueTokensAsync( private async Task<AuthTokenResponse> IssueTokensAsync(
Core.Entities.Employee employee, Core.Entities.Employee employee,
Core.Entities.Cafe cafe, Core.Entities.Cafe cafe,
List<CafeMembershipDto>? memberships, List<CafeMembershipDto>? memberships,
string? requestedBranchId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe); var resolution = await ResolveBranchAsync(employee, cafe, requestedBranchId, cancellationToken);
var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe, resolution.EffectiveRole, resolution.ActiveBranchId);
var refreshToken = _jwtTokenService.CreateRefreshToken(); var refreshToken = _jwtTokenService.CreateRefreshToken();
var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30); var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30);
@@ -371,24 +523,114 @@ public class AuthService : IAuthService
new RefreshTokenPayload( new RefreshTokenPayload(
employee.Id, employee.Id,
cafe.Id, cafe.Id,
employee.Role.ToString(), resolution.EffectiveRole.ToString(),
cafe.PlanTier.ToString(), cafe.PlanTier.ToString(),
cafe.PreferredLanguage, cafe.PreferredLanguage,
Meezi.Core.Constants.MeeziActorKinds.Merchant), Meezi.Core.Constants.MeeziActorKinds.Merchant,
resolution.ActiveBranchId),
TimeSpan.FromDays(refreshDays), TimeSpan.FromDays(refreshDays),
cancellationToken); cancellationToken);
var permissions = Meezi.Core.Authorization.RolePermissions
.For(resolution.EffectiveRole)
.Select(p => p.ToString())
.OrderBy(p => p)
.ToList();
return new AuthTokenResponse( return new AuthTokenResponse(
accessToken, accessToken,
refreshToken, refreshToken,
_jwtTokenService.GetAccessTokenExpiry(), _jwtTokenService.GetAccessTokenExpiry(),
employee.Id, employee.Id,
cafe.Id, cafe.Id,
employee.Role.ToString(), resolution.EffectiveRole.ToString(),
cafe.PlanTier.ToString(), cafe.PlanTier.ToString(),
cafe.PreferredLanguage, cafe.PreferredLanguage,
Meezi.Core.Constants.MeeziActorKinds.Merchant, Meezi.Core.Constants.MeeziActorKinds.Merchant,
employee.BranchId, resolution.ActiveBranchId,
memberships); memberships,
resolution.ActiveBranchName,
resolution.IsCafeWide,
resolution.Branches,
permissions);
}
private sealed record BranchResolution(
EmployeeRole EffectiveRole,
string? ActiveBranchId,
string? ActiveBranchName,
bool IsCafeWide,
List<BranchMembershipDto> Branches);
/// <summary>
/// Determine the active branch, the role the employee holds there, and the
/// full list of branches they may operate as. Owners are café-wide by default
/// (null active branch) but may scope to a specific branch. Other staff are
/// resolved from their <see cref="EmployeeBranchRole"/> assignments, falling
/// back to the legacy single <see cref="Employee.BranchId"/> pin.
/// </summary>
private async Task<BranchResolution> ResolveBranchAsync(
Core.Entities.Employee employee,
Core.Entities.Cafe cafe,
string? requestedBranchId,
CancellationToken ct)
{
var cafeBranches = await _db.Branches
.Where(b => b.CafeId == cafe.Id && b.DeletedAt == null && b.IsActive)
.OrderBy(b => b.Name)
.Select(b => new { b.Id, b.Name })
.ToListAsync(ct);
var branchNames = cafeBranches.ToDictionary(b => b.Id, b => b.Name);
// Owner = café-wide. May optionally scope to a branch when requested & valid.
if (employee.Role == EmployeeRole.Owner)
{
var ownerBranches = cafeBranches
.Select(b => new BranchMembershipDto(b.Id, b.Name, EmployeeRole.Owner.ToString()))
.ToList();
if (!string.IsNullOrWhiteSpace(requestedBranchId) && branchNames.TryGetValue(requestedBranchId, out var rname))
return new BranchResolution(EmployeeRole.Owner, requestedBranchId, rname, false, ownerBranches);
return new BranchResolution(EmployeeRole.Owner, null, null, true, ownerBranches);
}
// Non-owner: explicit per-branch role assignments, plus the legacy pin as a fallback.
var assignments = await _db.EmployeeBranchRoles
.Where(r => r.EmployeeId == employee.Id && r.DeletedAt == null)
.Select(r => new { r.BranchId, r.Role })
.ToListAsync(ct);
var membershipMap = new Dictionary<string, EmployeeRole>();
foreach (var a in assignments)
membershipMap[a.BranchId] = a.Role;
if (!string.IsNullOrWhiteSpace(employee.BranchId) && !membershipMap.ContainsKey(employee.BranchId))
membershipMap[employee.BranchId] = employee.Role;
var branches = membershipMap
.Where(kv => branchNames.ContainsKey(kv.Key))
.Select(kv => new BranchMembershipDto(kv.Key, branchNames[kv.Key], kv.Value.ToString()))
.OrderBy(b => b.BranchName)
.ToList();
// 1. Honour an explicit, valid request.
if (!string.IsNullOrWhiteSpace(requestedBranchId)
&& membershipMap.TryGetValue(requestedBranchId, out var reqRole)
&& branchNames.TryGetValue(requestedBranchId, out var reqName))
{
return new BranchResolution(reqRole, requestedBranchId, reqName, false, branches);
}
// 2/3. One or many memberships → default to the first (frontend can switch).
if (branches.Count >= 1)
{
var first = branches[0];
return new BranchResolution(membershipMap[first.BranchId], first.BranchId, first.BranchName, false, branches);
}
// 4. No assignments and no pin → back-compat: café role, no branch claim (isolation off).
return new BranchResolution(employee.Role, null, null, false, branches);
} }
} }
+174
View File
@@ -0,0 +1,174 @@
using Meezi.Core.Entities;
using Meezi.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Meezi.API.Services;
public record DemoSeedResult(
int CategoriesAdded,
int ItemsAdded,
int TablesAdded,
int IngredientsAdded,
bool TaxCreated);
public interface IDemoSeedService
{
Task<DemoSeedResult> SeedAsync(string cafeId, CancellationToken ct = default);
}
public class DemoSeedService : IDemoSeedService
{
private readonly AppDbContext _db;
private readonly ILogger<DemoSeedService> _logger;
public DemoSeedService(AppDbContext db, ILogger<DemoSeedService> logger)
{
_db = db;
_logger = logger;
}
public async Task<DemoSeedResult> SeedAsync(string cafeId, CancellationToken ct = default)
{
// 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))
{
_db.Taxes.Add(new Tax
{
Id = taxId,
CafeId = cafeId,
Name = "مالیات ارزش افزوده",
Rate = 9,
IsDefault = true,
IsRequired = true,
IsCompound = false
});
await _db.SaveChangesAsync(ct);
taxCreated = true;
}
else
{
taxId = await _db.Taxes
.Where(t => t.CafeId == cafeId && t.IsDefault)
.Select(t => t.Id)
.FirstAsync(ct);
}
// 2. Seed menu (categories + items) using café-agnostic seeder.
// useScopedIds=true prefixes all IDs with cafeId so multiple cafés
// can each have their own demo menu without primary-key collisions.
var beforeCats = await _db.MenuCategories.CountAsync(c => c.CafeId == cafeId, ct);
var beforeItems = await _db.MenuItems.CountAsync(i => i.CafeId == cafeId, ct);
await DemoMenuSeeder.EnsureMenuAsync(_db, cafeId, taxId, _logger, useScopedIds: true);
var afterCats = await _db.MenuCategories.CountAsync(c => c.CafeId == cafeId, ct);
var afterItems = await _db.MenuItems.CountAsync(i => i.CafeId == cafeId, ct);
// 3. Seed ingredients if warehouse is empty
var ingredientsAdded = 0;
if (!await _db.Ingredients.AnyAsync(i => i.CafeId == cafeId, ct))
{
var demoIngredients = BuildDemoIngredients(cafeId);
_db.Ingredients.AddRange(demoIngredients);
await _db.SaveChangesAsync(ct);
ingredientsAdded = demoIngredients.Count;
}
// 4. Seed 10 tables if no tables exist for this café's first active branch
var tablesAdded = 0;
if (!await _db.Tables.AnyAsync(t => t.CafeId == cafeId, ct))
{
var branchId = await _db.Branches
.Where(b => b.CafeId == cafeId && b.IsActive && b.DeletedAt == null)
.OrderBy(b => b.Id)
.Select(b => b.Id)
.FirstOrDefaultAsync(ct);
if (branchId is not null)
{
var tables = BuildDemoTables(cafeId, branchId);
_db.Tables.AddRange(tables);
await _db.SaveChangesAsync(ct);
tablesAdded = tables.Count;
}
}
_logger.LogInformation(
"Demo seed complete for cafe {CafeId}: +{Cats} cats, +{Items} items, +{Tables} tables, +{Ing} ingredients, tax={TaxCreated}",
cafeId, afterCats - beforeCats, afterItems - beforeItems, tablesAdded, ingredientsAdded, taxCreated);
return new DemoSeedResult(
CategoriesAdded: afterCats - beforeCats,
ItemsAdded: afterItems - beforeItems,
TablesAdded: tablesAdded,
IngredientsAdded: ingredientsAdded,
TaxCreated: taxCreated);
}
private static List<Ingredient> BuildDemoIngredients(string cafeId) =>
[
Ingredient(cafeId, "قهوه اسپرسو", "گرم", 2000, 500, 80, 2000),
Ingredient(cafeId, "شیر", "میلی‌لیتر", 10000, 2000, 15, 10000),
Ingredient(cafeId, "شکر", "گرم", 5000, 1000, 5, 5000),
Ingredient(cafeId, "وانیل", "میلی‌لیتر", 500, 100, 50, 500),
Ingredient(cafeId, "شکلات تلخ", "گرم", 1000, 200, 120, 1000),
Ingredient(cafeId, "خامه", "میلی‌لیتر", 2000, 500, 30, 2000),
Ingredient(cafeId, "دارچین", "گرم", 300, 50, 40, 300),
Ingredient(cafeId, "چای سیاه", "گرم", 1000, 200, 60, 1000),
Ingredient(cafeId, "آب معدنی", "میلی‌لیتر", 20000, 5000, 3, 20000),
Ingredient(cafeId, "نان تست", "عدد", 100, 20, 8000, 100),
Ingredient(cafeId, "تخم‌مرغ", "عدد", 60, 12, 6000, 60),
Ingredient(cafeId, "کره", "گرم", 500, 100, 80, 500),
Ingredient(cafeId, "پنیر", "گرم", 1000, 200, 90, 1000),
Ingredient(cafeId, "اسپاتولا یخ", "عدد", 200, 50, 2000, 200),
Ingredient(cafeId, "سس کارامل", "میلی‌لیتر", 1000, 200, 60, 1000),
];
private static Ingredient Ingredient(
string cafeId, string name, string unit,
decimal qty, decimal reorder, decimal cost, decimal par) =>
new()
{
Id = $"{cafeId}_ing_{Guid.NewGuid():N}"[..36],
CafeId = cafeId,
Name = name,
Unit = unit,
QuantityOnHand = qty,
ReorderLevel = reorder,
UnitCost = cost,
ParLevel = par,
LowStockWarningPercent = 20m
};
private static List<Table> BuildDemoTables(string cafeId, string branchId)
{
var tables = new List<Table>();
// Floor 1: tables 1-4
for (var i = 1; i <= 4; i++)
tables.Add(Table(cafeId, branchId, i.ToString(), 4, "طبقه اول", i));
// Floor 2: tables 5-8
for (var i = 5; i <= 8; i++)
tables.Add(Table(cafeId, branchId, i.ToString(), 4, "طبقه دوم", i));
// VIP: tables 9-10
for (var i = 9; i <= 10; i++)
tables.Add(Table(cafeId, branchId, i.ToString(), 6, "VIP", i));
return tables;
}
private static Table Table(
string cafeId, string branchId, string number, int capacity, string floor, int sortOrder) =>
new()
{
Id = $"{cafeId}_tbl_{Guid.NewGuid():N}"[..36],
CafeId = cafeId,
BranchId = branchId,
Number = number,
Capacity = capacity,
Floor = floor,
SortOrder = sortOrder,
QrCode = Guid.NewGuid().ToString("N"),
IsActive = true,
IsCleaning = false
};
}
@@ -0,0 +1,33 @@
namespace Meezi.API.Services;
/// <summary>
/// One sensitive POS / management action to record. Actor and tenant fields are
/// resolved from the current request context when not supplied explicitly.
/// </summary>
public sealed record AuditEntry
{
public required string Category { get; init; }
public required string Action { get; init; }
public required string Summary { get; init; }
public string? EntityType { get; init; }
public string? EntityId { get; init; }
/// <summary>Optional branch override; defaults to the active branch from context.</summary>
public string? BranchId { get; init; }
/// <summary>Optional structured payload — serialized to JSON.</summary>
public object? Details { get; init; }
/// <summary>Optional actor name override (display only).</summary>
public string? ActorName { get; init; }
}
/// <summary>
/// Writes immutable audit-trail entries for sensitive actions. Implementations
/// must never throw into the caller — a failed audit write must not abort the
/// business operation it records.
/// </summary>
public interface IAuditLogService
{
Task LogAsync(AuditEntry entry, CancellationToken ct = default);
}
+12
View File
@@ -16,10 +16,22 @@ public interface IAuthService
VerifyOtpRequest request, VerifyOtpRequest request,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage, CafeChoicesResponse? Choices)> LoginWithPasswordAsync(
LoginWithPasswordRequest request,
CancellationToken cancellationToken = default);
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchCafeAsync( Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchCafeAsync(
string employeeId, string targetCafeId, string employeeId, string targetCafeId,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary>
/// Re-issue a token scoped to a different branch within the current café.
/// <paramref name="targetBranchId"/> null means café-wide (Owner only).
/// </summary>
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchBranchAsync(
string employeeId, string cafeId, string? targetBranchId,
CancellationToken cancellationToken = default);
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync( Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync(
RefreshTokenRequest request, RefreshTokenRequest request,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
@@ -6,6 +6,14 @@ namespace Meezi.API.Services;
public interface IJwtTokenService public interface IJwtTokenService
{ {
string CreateAccessToken(Employee employee, Cafe cafe); string CreateAccessToken(Employee employee, Cafe cafe);
/// <summary>
/// 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).
/// </summary>
string CreateAccessToken(Employee employee, Cafe cafe, EmployeeRole effectiveRole, string? activeBranchId);
string CreateConsumerAccessToken(ConsumerAccount account, string language = "fa"); string CreateConsumerAccessToken(ConsumerAccount account, string language = "fa");
string CreateRefreshToken(); string CreateRefreshToken();
DateTime GetAccessTokenExpiry(); DateTime GetAccessTokenExpiry();
+8 -4
View File
@@ -3,6 +3,7 @@ using System.Security.Claims;
using System.Text; using System.Text;
using Meezi.Core.Constants; using Meezi.Core.Constants;
using Meezi.Core.Entities; using Meezi.Core.Entities;
using Meezi.Core.Enums;
using ConsumerAccount = Meezi.Core.Entities.ConsumerAccount; using ConsumerAccount = Meezi.Core.Entities.ConsumerAccount;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
@@ -17,7 +18,10 @@ public class JwtTokenService : IJwtTokenService
_configuration = configuration; _configuration = configuration;
} }
public string CreateAccessToken(Employee employee, Cafe cafe) 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)
{ {
var key = _configuration["Jwt:Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured."); var key = _configuration["Jwt:Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured.");
var issuer = _configuration["Jwt:Issuer"] ?? "meezi"; var issuer = _configuration["Jwt:Issuer"] ?? "meezi";
@@ -28,14 +32,14 @@ public class JwtTokenService : IJwtTokenService
{ {
new(JwtRegisteredClaimNames.Sub, employee.Id), new(JwtRegisteredClaimNames.Sub, employee.Id),
new(MeeziClaimTypes.CafeId, cafe.Id), new(MeeziClaimTypes.CafeId, cafe.Id),
new(MeeziClaimTypes.Role, employee.Role.ToString()), new(MeeziClaimTypes.Role, effectiveRole.ToString()),
new(MeeziClaimTypes.PlanTier, cafe.PlanTier.ToString()), new(MeeziClaimTypes.PlanTier, cafe.PlanTier.ToString()),
new(MeeziClaimTypes.Language, cafe.PreferredLanguage), new(MeeziClaimTypes.Language, cafe.PreferredLanguage),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")) new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N"))
}; };
if (!string.IsNullOrEmpty(employee.BranchId)) if (!string.IsNullOrEmpty(activeBranchId))
claims.Add(new Claim(MeeziClaimTypes.BranchId, employee.BranchId)); claims.Add(new Claim(MeeziClaimTypes.BranchId, activeBranchId));
var credentials = new SigningCredentials( var credentials = new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)), new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
+53
View File
@@ -55,6 +55,12 @@ public interface IOrderService
string targetTableId, string targetTableId,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task<OrderDto?> UpdateStatusAsync(string cafeId, string orderId, OrderStatus status, CancellationToken cancellationToken = default); Task<OrderDto?> UpdateStatusAsync(string cafeId, string orderId, OrderStatus status, CancellationToken cancellationToken = default);
Task<OrderServiceResult<OrderDto>> CancelOrderAsync(
string cafeId,
string orderId,
string? reason,
string? cancelledByEmployeeId,
CancellationToken cancellationToken = default);
Task<OrderServiceResult<IReadOnlyList<PaymentDto>>> RecordPaymentsAsync( Task<OrderServiceResult<IReadOnlyList<PaymentDto>>> RecordPaymentsAsync(
string cafeId, string cafeId,
string orderId, string orderId,
@@ -957,6 +963,53 @@ public class OrderService : IOrderService
return await GetOrderAsync(cafeId, orderId, cancellationToken); return await GetOrderAsync(cafeId, orderId, cancellationToken);
} }
public async Task<OrderServiceResult<OrderDto>> CancelOrderAsync(
string cafeId,
string orderId,
string? reason,
string? cancelledByEmployeeId,
CancellationToken cancellationToken = default)
{
var order = await _db.Orders
.Include(o => o.Payments)
.FirstOrDefaultAsync(o => o.Id == orderId && o.CafeId == cafeId, cancellationToken);
if (order is null)
return new OrderServiceResult<OrderDto>(false, null, "ORDER_NOT_FOUND");
if (order.Status == OrderStatus.Cancelled)
return new OrderServiceResult<OrderDto>(false, null, "ORDER_ALREADY_CANCELLED");
if (!OpenForPaymentStatuses.Contains(order.Status))
return new OrderServiceResult<OrderDto>(false, null, "ORDER_NOT_OPEN");
// A paid order must be refunded through the payment flow first — cancelling it
// here would silently strip the recorded money. Block and surface the reason.
if (order.Payments.Any(p => p.DeletedAt == null))
return new OrderServiceResult<OrderDto>(false, null, "ORDER_HAS_PAYMENTS");
order.Status = OrderStatus.Cancelled;
order.StatusUpdatedAt = DateTime.UtcNow;
order.CancelledAt = DateTime.UtcNow;
order.CancelReason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim();
order.CancelledByEmployeeId = cancelledByEmployeeId;
await _db.SaveChangesAsync(cancellationToken);
await _kdsNotifier.NotifyOrderStatusChangedAsync(cafeId, orderId, OrderStatus.Cancelled, cancellationToken);
if (!string.IsNullOrEmpty(order.TableId))
await _kdsNotifier.NotifyTableStatusChangedAsync(cafeId, cancellationToken);
await _deliverySync.SyncInternalStatusAsync(cafeId, orderId, OrderStatus.Cancelled, cancellationToken);
var loaded = await LoadOrderAsync(cafeId, orderId, cancellationToken);
if (loaded is not null)
await _orderNotifications.NotifyOrderStatusChangedAsync(loaded, cancellationToken);
return loaded is null
? new OrderServiceResult<OrderDto>(false, null, "ORDER_NOT_FOUND")
: new OrderServiceResult<OrderDto>(true, MapOrder(loaded));
}
public async Task<OrderServiceResult<IReadOnlyList<PaymentDto>>> RecordPaymentsAsync( public async Task<OrderServiceResult<IReadOnlyList<PaymentDto>>> RecordPaymentsAsync(
string cafeId, string cafeId,
string orderId, string orderId,
+2 -1
View File
@@ -9,7 +9,8 @@ public record RefreshTokenPayload(
string Role, string Role,
string PlanTier, string PlanTier,
string Language, string Language,
string Actor = Meezi.Core.Constants.MeeziActorKinds.Merchant); string Actor = Meezi.Core.Constants.MeeziActorKinds.Merchant,
string? ActiveBranchId = null);
public interface IRefreshTokenStore public interface IRefreshTokenStore
{ {
@@ -1,6 +1,7 @@
using FluentValidation; using FluentValidation;
using Meezi.API.Models.Auth; using Meezi.API.Models.Auth;
using Meezi.Core.Utilities; using Meezi.Core.Utilities;
using System.Text.RegularExpressions;
namespace Meezi.API.Validators; namespace Meezi.API.Validators;
@@ -51,6 +52,10 @@ public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
.NotEmpty() .NotEmpty()
.MaximumLength(100) .MaximumLength(100)
.WithMessage("Cafe name must be between 1 and 100 characters."); .WithMessage("Cafe name must be between 1 and 100 characters.");
RuleFor(x => x.Slug)
.Must(s => s == null || SlugHelper.IsValidSlug(s))
.WithMessage("Slug must be 2-80 lowercase letters, digits, or hyphens (e.g. my-cafe).");
} }
} }
@@ -1,5 +1,6 @@
using FluentValidation; using FluentValidation;
using Meezi.API.Models.Cafes; using Meezi.API.Models.Cafes;
using Meezi.Core.Utilities;
namespace Meezi.API.Validators; namespace Meezi.API.Validators;
@@ -8,6 +9,10 @@ public class PatchCafeSettingsRequestValidator : AbstractValidator<PatchCafeSett
public PatchCafeSettingsRequestValidator() public PatchCafeSettingsRequestValidator()
{ {
RuleFor(x => x.Name).MaximumLength(200).When(x => x.Name is not null); RuleFor(x => x.Name).MaximumLength(200).When(x => x.Name is not null);
RuleFor(x => x.Slug)
.Must(s => s == null || SlugHelper.IsValidSlug(s))
.WithMessage("Slug must be 2-80 lowercase letters, digits, or hyphens (e.g. my-cafe).")
.When(x => x.Slug is not null);
RuleFor(x => x.Phone).MaximumLength(20).When(x => x.Phone is not null); RuleFor(x => x.Phone).MaximumLength(20).When(x => x.Phone is not null);
RuleFor(x => x.Address).MaximumLength(500).When(x => x.Address is not null); RuleFor(x => x.Address).MaximumLength(500).When(x => x.Address is not null);
RuleFor(x => x.City).MaximumLength(100).When(x => x.City is not null); RuleFor(x => x.City).MaximumLength(100).When(x => x.City is not null);
+2 -2
View File
@@ -12,7 +12,7 @@ public class CreateMenuCategoryRequestValidator : AbstractValidator<CreateMenuCa
public CreateMenuCategoryRequestValidator() public CreateMenuCategoryRequestValidator()
{ {
RuleFor(x => x.Name).NotEmpty().MaximumLength(200); RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(200); RuleFor(x => x.NameEn).MaximumLength(200).When(x => !string.IsNullOrWhiteSpace(x.NameEn));
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100); RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100);
RuleFor(x => x.Icon).MaximumLength(32).When(x => x.Icon is not null); RuleFor(x => x.Icon).MaximumLength(32).When(x => x.Icon is not null);
@@ -39,7 +39,7 @@ public class CreateMenuItemRequestValidator : AbstractValidator<CreateMenuItemRe
{ {
RuleFor(x => x.CategoryId).NotEmpty(); RuleFor(x => x.CategoryId).NotEmpty();
RuleFor(x => x.Name).NotEmpty().MaximumLength(200); RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(200); RuleFor(x => x.NameEn).MaximumLength(200).When(x => !string.IsNullOrWhiteSpace(x.NameEn));
RuleFor(x => x.Price).GreaterThanOrEqualTo(0); RuleFor(x => x.Price).GreaterThanOrEqualTo(0);
RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100); RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100);
} }
@@ -1,8 +1,11 @@
using FluentValidation; using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.Admin.API.Models; using Meezi.Admin.API.Models;
using Meezi.Admin.API.Services; using Meezi.Admin.API.Services;
using Meezi.Shared; using Meezi.Shared;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
namespace Meezi.Admin.API.Controllers; namespace Meezi.Admin.API.Controllers;
@@ -55,6 +58,39 @@ public class AdminAuthController : ControllerBase
return Ok(new ApiResponse<AuthTokenResponse>(true, data)); return Ok(new ApiResponse<AuthTokenResponse>(true, data));
} }
[HttpPost("login")]
public async Task<IActionResult> LoginWithPassword(
[FromBody] LoginWithPasswordRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest(ValidationError("Username and password are required."));
var (success, data, code, message) = await _auth.LoginWithPasswordAsync(request, cancellationToken);
if (!success)
return ErrorResult(code!, message!);
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
}
[HttpPut("password")]
[Authorize]
public async Task<IActionResult> ChangePassword(
[FromBody] ChangePasswordRequest request,
CancellationToken cancellationToken)
{
var adminId = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(adminId))
return Unauthorized();
var (success, code, message) = await _auth.ChangePasswordAsync(adminId, request, cancellationToken);
if (!success)
return ErrorResult(code!, message!);
return Ok(new ApiResponse<object>(true, null));
}
[HttpPost("refresh")] [HttpPost("refresh")]
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken) public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
{ {
@@ -75,6 +111,9 @@ public class AdminAuthController : ControllerBase
return new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)); return new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName));
} }
private static ApiResponse<object> ValidationError(string message) =>
new(false, null, new ApiError("VALIDATION_ERROR", message));
private IActionResult ErrorResult(string code, string message) => private IActionResult ErrorResult(string code, string message) =>
code switch code switch
{ {
+4
View File
@@ -8,6 +8,10 @@ public record VerifyOtpRequest(string Phone, string Code);
public record RefreshTokenRequest(string RefreshToken); public record RefreshTokenRequest(string RefreshToken);
public record LoginWithPasswordRequest(string Username, string Password);
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
public record AuthTokenResponse( public record AuthTokenResponse(
string AccessToken, string AccessToken,
string RefreshToken, string RefreshToken,
@@ -19,6 +19,15 @@ public interface IAdminAuthService
VerifyOtpRequest request, VerifyOtpRequest request,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithPasswordAsync(
LoginWithPasswordRequest request,
CancellationToken cancellationToken = default);
Task<(bool Success, string? ErrorCode, string? ErrorMessage)> ChangePasswordAsync(
string adminId,
ChangePasswordRequest request,
CancellationToken cancellationToken = default);
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync( Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync(
RefreshTokenRequest request, RefreshTokenRequest request,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
@@ -141,6 +150,49 @@ public class AdminAuthService : IAdminAuthService
return (true, tokens, null, null); return (true, tokens, null, null);
} }
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithPasswordAsync(
LoginWithPasswordRequest request,
CancellationToken cancellationToken = default)
{
var username = request.Username.Trim();
var admin = await _db.SystemAdmins
.FirstOrDefaultAsync(a => a.Username == username && a.IsActive && a.DeletedAt == null, cancellationToken);
if (admin is null || string.IsNullOrWhiteSpace(admin.PasswordHash))
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.");
if (!PasswordHasher.Verify(request.Password, admin.PasswordHash))
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.");
var tokens = await IssueTokensAsync(admin, cancellationToken);
return (true, tokens, null, null);
}
public async Task<(bool Success, string? ErrorCode, string? ErrorMessage)> ChangePasswordAsync(
string adminId,
ChangePasswordRequest request,
CancellationToken cancellationToken = default)
{
var admin = await _db.SystemAdmins
.FirstOrDefaultAsync(a => a.Id == adminId && a.IsActive && a.DeletedAt == null, cancellationToken);
if (admin is null)
return (false, "NOT_FOUND", "Admin not found.");
// If a password is already set, require the current one
if (!string.IsNullOrWhiteSpace(admin.PasswordHash))
{
if (!PasswordHasher.Verify(request.CurrentPassword, admin.PasswordHash))
return (false, "INVALID_CREDENTIALS", "Current password is incorrect.");
}
if (string.IsNullOrWhiteSpace(request.NewPassword) || request.NewPassword.Length < 8)
return (false, "VALIDATION_ERROR", "New password must be at least 8 characters.");
admin.PasswordHash = PasswordHasher.Hash(request.NewPassword);
await _db.SaveChangesAsync(cancellationToken);
return (true, null, null);
}
private async Task<AuthTokenResponse> IssueTokensAsync( private async Task<AuthTokenResponse> IssueTokensAsync(
Core.Entities.SystemAdmin admin, Core.Entities.SystemAdmin admin,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -15,7 +15,9 @@ public class AdminWebsiteService(AppDbContext db) : IAdminWebsiteService
var q = db.WebsiteBlogPosts.AsQueryable(); var q = db.WebsiteBlogPosts.AsQueryable();
if (published.HasValue) q = q.Where(p => p.IsPublished == published.Value); if (published.HasValue) q = q.Where(p => p.IsPublished == published.Value);
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
var posts = await q.OrderByDescending(p => p.CreatedAt) var posts = await q
.Include(p => p.Comments)
.OrderByDescending(p => p.CreatedAt)
.Skip((page - 1) * limit).Take(limit).ToListAsync(ct); .Skip((page - 1) * limit).Take(limit).ToListAsync(ct);
return new { Posts = posts.Select(MapPost), Total = total, Page = page, Limit = limit }; return new { Posts = posts.Select(MapPost), Total = total, Page = page, Limit = limit };
} }
@@ -162,5 +164,6 @@ public class AdminWebsiteService(AppDbContext db) : IAdminWebsiteService
p.Id, p.Slug, p.TitleFa, p.TitleEn, p.ExcerptFa, p.ExcerptEn, p.Id, p.Slug, p.TitleFa, p.TitleEn, p.ExcerptFa, p.ExcerptEn,
p.ContentFa, p.ContentEn, p.CategoryFa, p.CategoryEn, p.Author, p.ContentFa, p.ContentEn, p.CategoryFa, p.CategoryEn, p.Author,
p.TagsJson, p.CoverImage, p.IsPublished, p.PublishedAt, p.ViewCount, p.CreatedAt, p.TagsJson, p.CoverImage, p.IsPublished, p.PublishedAt, p.ViewCount, p.CreatedAt,
CommentCount = p.Comments?.Count ?? 0,
}; };
} }
@@ -8,7 +8,9 @@ public class SendOtpRequestValidator : AbstractValidator<SendOtpRequest>
{ {
public SendOtpRequestValidator() public SendOtpRequestValidator()
{ {
RuleFor(x => x.Phone).Must(PhoneNormalizer.IsValidIranMobile).WithMessage("Invalid phone number."); RuleFor(x => x.Phone)
.Must(p => PhoneNormalizer.IsValidIranMobile(PhoneNormalizer.Normalize(p)))
.WithMessage("Invalid phone number.");
} }
} }
@@ -16,7 +18,9 @@ public class VerifyOtpRequestValidator : AbstractValidator<VerifyOtpRequest>
{ {
public VerifyOtpRequestValidator() public VerifyOtpRequestValidator()
{ {
RuleFor(x => x.Phone).Must(PhoneNormalizer.IsValidIranMobile); RuleFor(x => x.Phone)
.Must(p => PhoneNormalizer.IsValidIranMobile(PhoneNormalizer.Normalize(p)))
.WithMessage("Invalid phone number.");
RuleFor(x => x.Code) RuleFor(x => x.Code)
.Must(OtpNormalizer.IsValidSixDigitCode) .Must(OtpNormalizer.IsValidSixDigitCode)
.WithMessage("OTP must be 6 digits."); .WithMessage("OTP must be 6 digits.");
@@ -0,0 +1,41 @@
namespace Meezi.Core.Authorization;
/// <summary>
/// Capabilities a café employee can be granted. These are the single source of
/// truth for authorization — controllers check a <see cref="Permission"/> rather
/// than hard-coding role names, so the role→capability mapping lives in exactly
/// one place (<see cref="RolePermissions"/>).
/// </summary>
public enum Permission
{
// Café-level administration (Owner only)
ManageCafeSettings,
ManageBilling,
ManageBranches,
// Management (Owner + Manager)
ManageStaff,
ManageMenu,
ManageInventory,
ManageExpenses,
ManageTaxes,
ManageCoupons,
ManageReservations,
ManageTables,
ViewReports,
ReviewLeave,
ManageSalaries,
ManagePrintSettings,
// Front-of-house operations
ProcessOrders,
HandlePayments,
OperateRegister,
ManageQueue,
// Kitchen
ViewKitchen,
// Delivery
HandleDelivery,
}
@@ -0,0 +1,76 @@
using Meezi.Core.Enums;
namespace Meezi.Core.Authorization;
/// <summary>
/// The authoritative role→capability matrix. Change what a role can do here and
/// every controller that calls <c>EnsurePermission</c> updates automatically.
/// </summary>
public static class RolePermissions
{
private static readonly IReadOnlyDictionary<EmployeeRole, HashSet<Permission>> Matrix =
new Dictionary<EmployeeRole, HashSet<Permission>>
{
[EmployeeRole.Owner] = AllPermissions(),
[EmployeeRole.Manager] = new()
{
Permission.ManageStaff,
Permission.ManageMenu,
Permission.ManageInventory,
Permission.ManageExpenses,
Permission.ManageTaxes,
Permission.ManageCoupons,
Permission.ManageReservations,
Permission.ManageTables,
Permission.ViewReports,
Permission.ReviewLeave,
Permission.ManageSalaries,
Permission.ManagePrintSettings,
Permission.ProcessOrders,
Permission.HandlePayments,
Permission.OperateRegister,
Permission.ManageQueue,
Permission.ViewKitchen,
Permission.HandleDelivery,
},
[EmployeeRole.Cashier] = new()
{
Permission.ProcessOrders,
Permission.HandlePayments,
Permission.OperateRegister,
Permission.ManageQueue,
Permission.ManageReservations,
},
[EmployeeRole.Waiter] = new()
{
Permission.ProcessOrders,
Permission.ManageReservations,
Permission.ManageQueue,
},
[EmployeeRole.Chef] = new()
{
Permission.ViewKitchen,
},
[EmployeeRole.Delivery] = new()
{
Permission.HandleDelivery,
},
};
public static bool Has(EmployeeRole role, Permission permission) =>
Matrix.TryGetValue(role, out var set) && set.Contains(permission);
public static IReadOnlySet<Permission> For(EmployeeRole role) =>
Matrix.TryGetValue(role, out var set) ? set : new HashSet<Permission>();
/// <summary>True for roles that administer the whole café across all branches.</summary>
public static bool IsCafeWide(EmployeeRole role) => role == EmployeeRole.Owner;
private static HashSet<Permission> AllPermissions() =>
new(Enum.GetValues<Permission>());
}
+20
View File
@@ -54,4 +54,24 @@ public static class PlanLimits
PlanTier.Enterprise => 100, PlanTier.Enterprise => 100,
_ => 0 _ => 0
}; };
/// <summary>Maximum active menu categories. Free tier is capped at 3; Pro+ is unlimited.</summary>
public static int MaxMenuCategories(PlanTier tier) => tier switch
{
PlanTier.Free => 3,
_ => int.MaxValue
};
/// <summary>Maximum menu items. Free tier is capped at 30; Pro+ is unlimited.</summary>
public static int MaxMenuItems(PlanTier tier) => tier switch
{
PlanTier.Free => 30,
_ => int.MaxValue
};
/// <summary>CRM (customers, loyalty) is only available on Pro and above.</summary>
public static bool CanAccessCrm(PlanTier tier) => tier >= PlanTier.Pro;
/// <summary>Statistics and analytics dashboards are only available on Pro and above.</summary>
public static bool CanAccessStatistics(PlanTier tier) => tier >= PlanTier.Pro;
} }
+34
View File
@@ -0,0 +1,34 @@
namespace Meezi.Core.Entities;
/// <summary>
/// Immutable record of a sensitive POS / management action. Written by
/// <c>IAuditLogService</c> and never updated. Branch-scoped so the strict
/// branch isolation filter applies (café-wide sessions see all).
/// </summary>
public class AuditLog : TenantEntity
{
/// <summary>High-level grouping, e.g. "Order", "Payment", "Register", "Staff".</summary>
public string Category { get; set; } = string.Empty;
/// <summary>Specific action, e.g. "OrderCancelled", "ItemVoided", "PaymentRecorded".</summary>
public string Action { get; set; } = string.Empty;
/// <summary>The entity acted upon, e.g. "Order", "Shift".</summary>
public string? EntityType { get; set; }
/// <summary>Id of the affected entity.</summary>
public string? EntityId { get; set; }
public string? BranchId { get; set; }
/// <summary>Employee who performed the action (null for system/automated).</summary>
public string? ActorId { get; set; }
public string? ActorName { get; set; }
public string? ActorRole { get; set; }
/// <summary>Human-readable one-line summary (already localized at write time or neutral).</summary>
public string Summary { get; set; } = string.Empty;
/// <summary>Optional structured payload (before/after, amounts, reason) as JSON.</summary>
public string? DetailsJson { get; set; }
}
+3
View File
@@ -39,4 +39,7 @@ public class Branch : TenantEntity
public ICollection<Table> Tables { get; set; } = []; public ICollection<Table> Tables { get; set; } = [];
public ICollection<Order> Orders { get; set; } = []; public ICollection<Order> Orders { get; set; } = [];
public ICollection<Employee> Staff { get; set; } = []; public ICollection<Employee> Staff { get; set; } = [];
/// <summary>Per-branch role assignments scoped to this branch.</summary>
public ICollection<EmployeeBranchRole> StaffRoles { get; set; } = [];
} }
+4
View File
@@ -37,6 +37,10 @@ public class Cafe : BaseEntity
public string? InstagramHandle { get; set; } public string? InstagramHandle { get; set; }
/// <summary>Cafe website URL, max 300 chars.</summary> /// <summary>Cafe website URL, max 300 chars.</summary>
public string? WebsiteUrl { get; set; } public string? WebsiteUrl { get; set; }
/// <summary>WGS-84 latitude (positive = north). Null until owner sets location.</summary>
public double? Latitude { get; set; }
/// <summary>WGS-84 longitude (positive = east). Null until owner sets location.</summary>
public double? Longitude { get; set; }
/// <summary>Default VAT/sales tax % for all branches unless branch override is allowed.</summary> /// <summary>Default VAT/sales tax % for all branches unless branch override is allowed.</summary>
public decimal DefaultTaxRate { get; set; } = 9m; public decimal DefaultTaxRate { get; set; } = 9m;
public bool AllowBranchTaxOverride { get; set; } public bool AllowBranchTaxOverride { get; set; }
+9
View File
@@ -12,6 +12,12 @@ public class Employee : TenantEntity
public decimal BaseSalary { get; set; } public decimal BaseSalary { get; set; }
public string? PinCode { get; set; } public string? PinCode { get; set; }
/// <summary>Optional username for password-based dashboard/POS login (set by cafe admin).</summary>
public string? Username { get; set; }
/// <summary>PBKDF2/SHA-256 hash. Null means password login is not enabled for this employee.</summary>
public string? PasswordHash { get; set; }
public Cafe Cafe { get; set; } = null!; public Cafe Cafe { get; set; } = null!;
public Branch? Branch { get; set; } public Branch? Branch { get; set; }
public ICollection<Order> Orders { get; set; } = []; public ICollection<Order> Orders { get; set; } = [];
@@ -19,4 +25,7 @@ public class Employee : TenantEntity
public ICollection<Attendance> Attendances { get; set; } = []; public ICollection<Attendance> Attendances { get; set; } = [];
public ICollection<EmployeeSchedule> Schedules { get; set; } = []; public ICollection<EmployeeSchedule> Schedules { get; set; } = [];
public ICollection<LeaveRequest> LeaveRequests { get; set; } = []; public ICollection<LeaveRequest> LeaveRequests { get; set; } = [];
/// <summary>Per-branch role assignments (multi-branch staff). Owners are café-wide and may have none.</summary>
public ICollection<EmployeeBranchRole> BranchRoles { get; set; } = [];
} }
@@ -0,0 +1,19 @@
using Meezi.Core.Enums;
namespace Meezi.Core.Entities;
/// <summary>
/// Per-branch role assignment for an employee. An employee row is scoped to one café
/// (a "membership"); this join lets that same employee hold a different
/// <see cref="EmployeeRole"/> in each branch they work at.
/// Owners remain café-wide via <see cref="Employee.Role"/> and need no rows here.
/// </summary>
public class EmployeeBranchRole : TenantEntity
{
public string EmployeeId { get; set; } = string.Empty;
public string BranchId { get; set; } = string.Empty;
public EmployeeRole Role { get; set; }
public Employee Employee { get; set; } = null!;
public Branch Branch { get; set; } = null!;
}
+6
View File
@@ -34,6 +34,12 @@ public class Order : TenantEntity
/// <summary>JSON snapshot: driver, address, delivery ETA, etc.</summary> /// <summary>JSON snapshot: driver, address, delivery ETA, etc.</summary>
public string? DeliveryMetaJson { get; set; } public string? DeliveryMetaJson { get; set; }
/// <summary>Reason captured when the order was cancelled (POS audit / accountability).</summary>
public string? CancelReason { get; set; }
/// <summary>Employee who cancelled the order (null for system/automated).</summary>
public string? CancelledByEmployeeId { get; set; }
public DateTime? CancelledAt { get; set; }
public Cafe Cafe { get; set; } = null!; public Cafe Cafe { get; set; } = null!;
public Branch? Branch { get; set; } public Branch? Branch { get; set; }
public Table? Table { get; set; } public Table? Table { get; set; }
+6
View File
@@ -5,4 +5,10 @@ public class SystemAdmin : BaseEntity
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string Phone { get; set; } = string.Empty; public string Phone { get; set; } = string.Empty;
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
/// <summary>Optional username for password-based login (alternative to OTP).</summary>
public string? Username { get; set; }
/// <summary>PBKDF2/SHA-256 hash. Null means password login is not enabled.</summary>
public string? PasswordHash { get; set; }
} }
@@ -0,0 +1,40 @@
using System.Security.Cryptography;
namespace Meezi.Core.Utilities;
/// <summary>
/// PBKDF2/SHA-256 password hashing with no external dependencies.
/// Format stored: "{iterations}.{salt_b64}.{hash_b64}"
/// </summary>
public static class PasswordHasher
{
private const int SaltSize = 16; // 128-bit salt
private const int HashSize = 32; // 256-bit hash
private const int Iterations = 100_000; // NIST-recommended minimum
private static readonly HashAlgorithmName Algo = HashAlgorithmName.SHA256;
public static string Hash(string password)
{
var salt = RandomNumberGenerator.GetBytes(SaltSize);
var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, Algo, HashSize);
return $"{Iterations}.{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}";
}
public static bool Verify(string password, string storedHash)
{
var parts = storedHash.Split('.');
if (parts.Length != 3) return false;
if (!int.TryParse(parts[0], out var iterations)) return false;
byte[] salt, expectedHash;
try
{
salt = Convert.FromBase64String(parts[1]);
expectedHash = Convert.FromBase64String(parts[2]);
}
catch (FormatException) { return false; }
var actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, Algo, expectedHash.Length);
return CryptographicOperations.FixedTimeEquals(actual, expectedHash);
}
}
+87
View File
@@ -0,0 +1,87 @@
using System.Text;
using System.Text.RegularExpressions;
namespace Meezi.Core.Utilities;
/// <summary>
/// Converts Persian/Arabic café names to URL-safe Latin slugs.
/// Used for Koja profile URLs (koja.meezi.ir/fa/cafe/{slug}).
/// </summary>
public static partial class SlugHelper
{
private static readonly Dictionary<char, string> PersianToLatin = new()
{
// Alef variants
{ 'آ', "a" }, { 'ا', "a" }, { 'أ', "a" }, { 'إ', "a" },
// Ba, Pa, Ta, Tha
{ 'ب', "b" }, { 'پ', "p" }, { 'ت', "t" }, { 'ث', "s" },
// Jim, Che, He, Khe
{ 'ج', "j" }, { 'چ', "ch" }, { 'ح', "h" }, { 'خ', "kh" },
// Dal, Zal, Re, Ze, Zhe
{ 'د', "d" }, { 'ذ', "z" }, { 'ر', "r" }, { 'ز', "z" }, { 'ژ', "zh" },
// Sin, Shin, Sad, Zad
{ 'س', "s" }, { 'ش', "sh" }, { 'ص', "s" }, { 'ض', "z" },
// Ta, Za, Ain, Ghain
{ 'ط', "t" }, { 'ظ', "z" }, { 'ع', "a" }, { 'غ', "gh" },
// Fa, Ghaf, Kaf (Arabic+Persian), Gaf
{ 'ف', "f" }, { 'ق', "gh" }, { 'ک', "k" }, { 'ك', "k" }, { 'گ', "g" },
// Lam, Mim, Nun, Vav, He, Ye
{ 'ل', "l" }, { 'م', "m" }, { 'ن', "n" }, { 'و', "v" },
{ 'ه', "h" }, { 'ی', "i" }, { 'ي', "i" },
// Special
{ 'ئ', "y" }, { 'ء', "" }, { 'ة', "t" }, { 'ى', "a" }, { 'ؤ', "o" },
// Persian digits
{ '۰', "0" }, { '۱', "1" }, { '۲', "2" }, { '۳', "3" }, { '۴', "4" },
{ '۵', "5" }, { '۶', "6" }, { '۷', "7" }, { '۸', "8" }, { '۹', "9" },
// Arabic-Indic digits
{ '٠', "0" }, { '١', "1" }, { '٢', "2" }, { '٣', "3" }, { '٤', "4" },
{ '٥', "5" }, { '٦', "6" }, { '٧', "7" }, { '٨', "8" }, { '٩', "9" },
};
/// <summary>
/// Converts a café name (Persian or Latin) to a URL-safe lowercase slug.
/// Returns an empty string if no valid characters can be extracted.
/// </summary>
public static string Slugify(string input)
{
if (string.IsNullOrWhiteSpace(input)) return string.Empty;
var sb = new StringBuilder(input.Length * 2);
foreach (var ch in input)
{
if (PersianToLatin.TryGetValue(ch, out var latin))
{
sb.Append(latin);
}
else if (char.IsAsciiLetterOrDigit(ch))
{
sb.Append(char.ToLowerInvariant(ch));
}
else if (ch is ' ' or '-' or '_' or '\t')
{
sb.Append('-');
}
// else: skip punctuation/unsupported characters
}
// Collapse consecutive hyphens and trim
return MultipleHyphen().Replace(sb.ToString(), "-").Trim('-');
}
/// <summary>
/// Returns true if the slug is a valid Koja URL slug:
/// 280 lowercase letters, digits, or internal hyphens. Must start and end with a letter/digit.
/// </summary>
public static bool IsValidSlug(string? slug)
{
if (string.IsNullOrWhiteSpace(slug)) return false;
if (slug.Length < 2 || slug.Length > 80) return false;
return ValidSlugPattern().IsMatch(slug);
}
[GeneratedRegex(@"-{2,}")]
private static partial Regex MultipleHyphen();
[GeneratedRegex(@"^[a-z0-9][a-z0-9\-]*[a-z0-9]$")]
private static partial Regex ValidSlugPattern();
}
+61 -11
View File
@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using Meezi.Core.Entities; using Meezi.Core.Entities;
using Meezi.Core.Interfaces;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -8,8 +9,22 @@ namespace Meezi.Infrastructure.Data;
public class AppDbContext : DbContext public class AppDbContext : DbContext
{ {
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) // Strict branch isolation. When an active branch scope is present (a
// branch-scoped staff session), every branch-owned entity is filtered to that
// branch at the DB layer — independent of, and backing up, controller checks.
// Café-wide sessions (Owner / "all branches") and non-HTTP contexts (migrations,
// background jobs, seeders) leave the scope empty so nothing is filtered.
private readonly string? _branchScopeId;
private readonly bool _branchScoped;
public AppDbContext(DbContextOptions<AppDbContext> options, IBranchContext? branch = null)
: base(options)
{ {
if (branch is { HasBranch: true })
{
_branchScopeId = branch.BranchId;
_branchScoped = true;
}
} }
public DbSet<Cafe> Cafes => Set<Cafe>(); public DbSet<Cafe> Cafes => Set<Cafe>();
@@ -17,6 +32,7 @@ public class AppDbContext : DbContext
public DbSet<Table> Tables => Set<Table>(); public DbSet<Table> Tables => Set<Table>();
public DbSet<TableSection> TableSections => Set<TableSection>(); public DbSet<TableSection> TableSections => Set<TableSection>();
public DbSet<Employee> Employees => Set<Employee>(); public DbSet<Employee> Employees => Set<Employee>();
public DbSet<EmployeeBranchRole> EmployeeBranchRoles => Set<EmployeeBranchRole>();
public DbSet<MenuCategory> MenuCategories => Set<MenuCategory>(); public DbSet<MenuCategory> MenuCategories => Set<MenuCategory>();
public DbSet<MenuItem> MenuItems => Set<MenuItem>(); public DbSet<MenuItem> MenuItems => Set<MenuItem>();
public DbSet<BranchMenuItemOverride> BranchMenuItemOverrides => Set<BranchMenuItemOverride>(); public DbSet<BranchMenuItemOverride> BranchMenuItemOverrides => Set<BranchMenuItemOverride>();
@@ -63,6 +79,9 @@ public class AppDbContext : DbContext
// Push notifications (Pushe) // Push notifications (Pushe)
public DbSet<PushDevice> PushDevices => Set<PushDevice>(); public DbSet<PushDevice> PushDevices => Set<PushDevice>();
// Immutable audit trail of sensitive POS / management actions.
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
@@ -120,7 +139,7 @@ public class AppDbContext : DbContext
e.HasIndex(x => new { x.BranchId, x.Name }); e.HasIndex(x => new { x.BranchId, x.Name });
e.HasIndex(x => x.CafeId); e.HasIndex(x => x.CafeId);
e.HasOne(x => x.Branch).WithMany(b => b.Sections).HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade); e.HasOne(x => x.Branch).WithMany(b => b.Sections).HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade);
e.HasQueryFilter(x => x.DeletedAt == null); e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
}); });
modelBuilder.Entity<Table>(e => modelBuilder.Entity<Table>(e =>
@@ -134,7 +153,7 @@ public class AppDbContext : DbContext
e.HasOne(x => x.Cafe).WithMany(c => c.Tables).HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade); e.HasOne(x => x.Cafe).WithMany(c => c.Tables).HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Branch).WithMany(b => b.Tables).HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Restrict); e.HasOne(x => x.Branch).WithMany(b => b.Tables).HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Section).WithMany(s => s.Tables).HasForeignKey(x => x.SectionId).OnDelete(DeleteBehavior.SetNull); e.HasOne(x => x.Section).WithMany(s => s.Tables).HasForeignKey(x => x.SectionId).OnDelete(DeleteBehavior.SetNull);
e.HasQueryFilter(x => x.DeletedAt == null); e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
}); });
modelBuilder.Entity<Employee>(e => modelBuilder.Entity<Employee>(e =>
@@ -149,6 +168,37 @@ public class AppDbContext : DbContext
e.HasQueryFilter(x => x.DeletedAt == null); e.HasQueryFilter(x => x.DeletedAt == null);
}); });
modelBuilder.Entity<EmployeeBranchRole>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => new { x.EmployeeId, x.BranchId })
.IsUnique()
.HasFilter("\"DeletedAt\" IS NULL");
e.HasIndex(x => new { x.CafeId, x.BranchId });
e.HasOne(x => x.Employee).WithMany(emp => emp.BranchRoles)
.HasForeignKey(x => x.EmployeeId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Branch).WithMany(b => b.StaffRoles)
.HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade);
e.HasQueryFilter(x => x.DeletedAt == null);
});
modelBuilder.Entity<AuditLog>(e =>
{
e.HasKey(x => x.Id);
e.Property(x => x.Category).HasMaxLength(64).IsRequired();
e.Property(x => x.Action).HasMaxLength(96).IsRequired();
e.Property(x => x.EntityType).HasMaxLength(64);
e.Property(x => x.EntityId).HasMaxLength(64);
e.Property(x => x.ActorName).HasMaxLength(160);
e.Property(x => x.ActorRole).HasMaxLength(32);
e.Property(x => x.Summary).HasMaxLength(500).IsRequired();
e.HasIndex(x => new { x.CafeId, x.Category });
e.HasIndex(x => new { x.CafeId, x.BranchId });
e.HasIndex(x => new { x.CafeId, x.CreatedAt });
e.HasOne<Cafe>().WithMany().HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade);
e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
});
modelBuilder.Entity<MenuCategory>(e => modelBuilder.Entity<MenuCategory>(e =>
{ {
e.HasKey(x => x.Id); e.HasKey(x => x.Id);
@@ -180,7 +230,7 @@ public class AppDbContext : DbContext
e.HasIndex(x => x.CafeId); e.HasIndex(x => x.CafeId);
e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade); e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.MenuItem).WithMany(m => m.BranchOverrides).HasForeignKey(x => x.MenuItemId).OnDelete(DeleteBehavior.Cascade); e.HasOne(x => x.MenuItem).WithMany(m => m.BranchOverrides).HasForeignKey(x => x.MenuItemId).OnDelete(DeleteBehavior.Cascade);
e.HasQueryFilter(x => x.DeletedAt == null); e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
}); });
modelBuilder.Entity<Order>(e => modelBuilder.Entity<Order>(e =>
@@ -204,7 +254,7 @@ public class AppDbContext : DbContext
e.HasOne(x => x.Customer).WithMany(c => c.Orders).HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.SetNull); e.HasOne(x => x.Customer).WithMany(c => c.Orders).HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(x => x.Employee).WithMany(emp => emp.Orders).HasForeignKey(x => x.EmployeeId).OnDelete(DeleteBehavior.SetNull); e.HasOne(x => x.Employee).WithMany(emp => emp.Orders).HasForeignKey(x => x.EmployeeId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(x => x.Coupon).WithMany(c => c.Orders).HasForeignKey(x => x.CouponId).OnDelete(DeleteBehavior.SetNull); e.HasOne(x => x.Coupon).WithMany(c => c.Orders).HasForeignKey(x => x.CouponId).OnDelete(DeleteBehavior.SetNull);
e.HasQueryFilter(x => x.DeletedAt == null); e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
}); });
modelBuilder.Entity<OrderItem>(e => modelBuilder.Entity<OrderItem>(e =>
@@ -287,7 +337,7 @@ public class AppDbContext : DbContext
e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade); e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.OpenedBy).WithMany().HasForeignKey(x => x.OpenedByUserId).OnDelete(DeleteBehavior.Restrict); e.HasOne(x => x.OpenedBy).WithMany().HasForeignKey(x => x.OpenedByUserId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.ClosedBy).WithMany().HasForeignKey(x => x.ClosedByUserId).OnDelete(DeleteBehavior.SetNull); e.HasOne(x => x.ClosedBy).WithMany().HasForeignKey(x => x.ClosedByUserId).OnDelete(DeleteBehavior.SetNull);
e.HasQueryFilter(x => x.DeletedAt == null); e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
}); });
modelBuilder.Entity<CashTransaction>(e => modelBuilder.Entity<CashTransaction>(e =>
@@ -298,7 +348,7 @@ public class AppDbContext : DbContext
e.HasIndex(x => new { x.CafeId, x.BranchId }); e.HasIndex(x => new { x.CafeId, x.BranchId });
e.HasOne(x => x.Shift).WithMany(s => s.Transactions).HasForeignKey(x => x.ShiftId).OnDelete(DeleteBehavior.Cascade); e.HasOne(x => x.Shift).WithMany(s => s.Transactions).HasForeignKey(x => x.ShiftId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.SetNull); e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.SetNull);
e.HasQueryFilter(x => x.DeletedAt == null); e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
}); });
modelBuilder.Entity<LeaveRequest>(e => modelBuilder.Entity<LeaveRequest>(e =>
@@ -353,7 +403,7 @@ public class AppDbContext : DbContext
e.HasIndex(x => new { x.CafeId, x.SortOrder }); e.HasIndex(x => new { x.CafeId, x.SortOrder });
e.HasOne(x => x.Cafe).WithMany().HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade); e.HasOne(x => x.Cafe).WithMany().HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.SetNull); e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.SetNull);
e.HasQueryFilter(x => x.DeletedAt == null); e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
}); });
modelBuilder.Entity<SubscriptionPayment>(e => modelBuilder.Entity<SubscriptionPayment>(e =>
@@ -414,7 +464,7 @@ public class AppDbContext : DbContext
e.HasOne(x => x.Cafe).WithMany().HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade); e.HasOne(x => x.Cafe).WithMany().HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.SetNull); e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(x => x.Order).WithMany().HasForeignKey(x => x.OrderId).OnDelete(DeleteBehavior.SetNull); e.HasOne(x => x.Order).WithMany().HasForeignKey(x => x.OrderId).OnDelete(DeleteBehavior.SetNull);
e.HasQueryFilter(x => x.DeletedAt == null); e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
}); });
modelBuilder.Entity<Expense>(e => modelBuilder.Entity<Expense>(e =>
@@ -426,7 +476,7 @@ public class AppDbContext : DbContext
e.HasIndex(x => new { x.CafeId, x.BranchId, x.CreatedAt }); e.HasIndex(x => new { x.CafeId, x.BranchId, x.CreatedAt });
e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade); e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Shift).WithMany().HasForeignKey(x => x.ShiftId).OnDelete(DeleteBehavior.SetNull); e.HasOne(x => x.Shift).WithMany().HasForeignKey(x => x.ShiftId).OnDelete(DeleteBehavior.SetNull);
e.HasQueryFilter(x => x.DeletedAt == null); e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
}); });
modelBuilder.Entity<DailyReport>(e => modelBuilder.Entity<DailyReport>(e =>
@@ -457,7 +507,7 @@ public class AppDbContext : DbContext
.HasConversion(topProductsConverter, topProductsComparer) .HasConversion(topProductsConverter, topProductsComparer)
.HasColumnType("jsonb"); .HasColumnType("jsonb");
e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade); e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade);
e.HasQueryFilter(x => x.DeletedAt == null); e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
}); });
modelBuilder.Entity<WebhookLog>(e => modelBuilder.Entity<WebhookLog>(e =>
@@ -6,8 +6,23 @@ namespace Meezi.Infrastructure.Data;
public static class DemoMenuSeeder public static class DemoMenuSeeder
{ {
public static async Task EnsureMenuAsync(AppDbContext db, string cafeId, string taxId, ILogger logger) /// <param name="useScopedIds">
/// When true, category and item IDs are prefixed with <paramref name="cafeId"/> so
/// multiple cafés can each have their own copy of the demo menu without a primary-key
/// collision. Pass false only for the legacy demo café (cafe_demo_001) whose IDs are
/// already in the database without a café prefix.
/// </param>
public static async Task EnsureMenuAsync(
AppDbContext db, string cafeId, string taxId, ILogger logger,
bool useScopedIds = false)
{ {
// When useScopedIds=true every row gets a deterministic ID that is unique per café:
// category → "{cafeId}_{catalogId}"
// item → "{cafeId}_{catalogId}"
// The catalog item's CategoryId is remapped through the same function.
string Scoped(string catalogId) =>
useScopedIds ? $"{cafeId}_{catalogId}" : catalogId;
if (!await db.Taxes.AnyAsync(t => t.Id == taxId && t.CafeId == cafeId)) if (!await db.Taxes.AnyAsync(t => t.Id == taxId && t.CafeId == cafeId))
{ {
db.Taxes.Add(new Tax db.Taxes.Add(new Tax
@@ -29,7 +44,8 @@ public static class DemoMenuSeeder
var categoriesAdded = 0; var categoriesAdded = 0;
foreach (var cat in DemoMenuCatalog.Categories) foreach (var cat in DemoMenuCatalog.Categories)
{ {
if (existingCategoryIds.TryGetValue(cat.Id, out var row)) var catId = Scoped(cat.Id);
if (existingCategoryIds.TryGetValue(catId, out var row))
{ {
if (string.IsNullOrWhiteSpace(row.Icon) && !string.IsNullOrWhiteSpace(cat.Icon)) if (string.IsNullOrWhiteSpace(row.Icon) && !string.IsNullOrWhiteSpace(cat.Icon))
row.Icon = cat.Icon; row.Icon = cat.Icon;
@@ -46,7 +62,7 @@ public static class DemoMenuSeeder
db.MenuCategories.Add(new MenuCategory db.MenuCategories.Add(new MenuCategory
{ {
Id = cat.Id, Id = catId,
CafeId = cafeId, CafeId = cafeId,
Name = cat.Name, Name = cat.Name,
NameEn = cat.NameEn, NameEn = cat.NameEn,
@@ -69,14 +85,15 @@ public static class DemoMenuSeeder
var itemsAdded = 0; var itemsAdded = 0;
foreach (var item in DemoMenuCatalog.Items) foreach (var item in DemoMenuCatalog.Items)
{ {
if (existingItemIds.Contains(item.Id)) var itemId = Scoped(item.Id);
if (existingItemIds.Contains(itemId))
continue; continue;
db.MenuItems.Add(new MenuItem db.MenuItems.Add(new MenuItem
{ {
Id = item.Id, Id = itemId,
CafeId = cafeId, CafeId = cafeId,
CategoryId = item.CategoryId, CategoryId = Scoped(item.CategoryId), // FK must point at scoped category ID
Name = item.Name, Name = item.Name,
NameEn = item.NameEn, NameEn = item.NameEn,
NameAr = item.NameAr, NameAr = item.NameAr,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,83 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddEmployeeBranchRole : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "EmployeeBranchRoles",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
EmployeeId = table.Column<string>(type: "text", nullable: false),
BranchId = table.Column<string>(type: "text", nullable: false),
Role = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CafeId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_EmployeeBranchRoles", x => x.Id);
table.ForeignKey(
name: "FK_EmployeeBranchRoles_Branches_BranchId",
column: x => x.BranchId,
principalTable: "Branches",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_EmployeeBranchRoles_Employees_EmployeeId",
column: x => x.EmployeeId,
principalTable: "Employees",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_EmployeeBranchRoles_BranchId",
table: "EmployeeBranchRoles",
column: "BranchId");
migrationBuilder.CreateIndex(
name: "IX_EmployeeBranchRoles_CafeId_BranchId",
table: "EmployeeBranchRoles",
columns: new[] { "CafeId", "BranchId" });
migrationBuilder.CreateIndex(
name: "IX_EmployeeBranchRoles_EmployeeId_BranchId",
table: "EmployeeBranchRoles",
columns: new[] { "EmployeeId", "BranchId" },
unique: true,
filter: "\"DeletedAt\" IS NULL");
// Backfill: every existing branch-pinned, non-owner employee gets an
// explicit per-branch role row mirroring their current (BranchId, Role).
// Owners (Role = 0) and café-wide non-pinned staff (BranchId IS NULL) are
// left untouched — they remain café-wide via Employee.Role.
migrationBuilder.Sql(@"
INSERT INTO ""EmployeeBranchRoles""
(""Id"", ""EmployeeId"", ""BranchId"", ""Role"", ""CafeId"", ""CreatedAt"")
SELECT replace(gen_random_uuid()::text, '-', ''),
e.""Id"", e.""BranchId"", e.""Role"", e.""CafeId"", now()
FROM ""Employees"" e
WHERE e.""BranchId"" IS NOT NULL
AND e.""DeletedAt"" IS NULL
AND e.""Role"" <> 0;
");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EmployeeBranchRoles");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddAuditLog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AuditLogs",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
Category = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Action = table.Column<string>(type: "character varying(96)", maxLength: 96, nullable: false),
EntityType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
EntityId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
BranchId = table.Column<string>(type: "text", nullable: true),
ActorId = table.Column<string>(type: "text", nullable: true),
ActorName = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: true),
ActorRole = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
Summary = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
DetailsJson = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CafeId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AuditLogs", x => x.Id);
table.ForeignKey(
name: "FK_AuditLogs_Cafes_CafeId",
column: x => x.CafeId,
principalTable: "Cafes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AuditLogs_CafeId_BranchId",
table: "AuditLogs",
columns: new[] { "CafeId", "BranchId" });
migrationBuilder.CreateIndex(
name: "IX_AuditLogs_CafeId_Category",
table: "AuditLogs",
columns: new[] { "CafeId", "Category" });
migrationBuilder.CreateIndex(
name: "IX_AuditLogs_CafeId_CreatedAt",
table: "AuditLogs",
columns: new[] { "CafeId", "CreatedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AuditLogs");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddOrderCancellationFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "CancelReason",
table: "Orders",
type: "text",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CancelledAt",
table: "Orders",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CancelledByEmployeeId",
table: "Orders",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CancelReason",
table: "Orders");
migrationBuilder.DropColumn(
name: "CancelledAt",
table: "Orders");
migrationBuilder.DropColumn(
name: "CancelledByEmployeeId",
table: "Orders");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddPasswordLogin : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PasswordHash",
table: "SystemAdmins",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Username",
table: "SystemAdmins",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PasswordHash",
table: "Employees",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Username",
table: "Employees",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PasswordHash",
table: "SystemAdmins");
migrationBuilder.DropColumn(
name: "Username",
table: "SystemAdmins");
migrationBuilder.DropColumn(
name: "PasswordHash",
table: "Employees");
migrationBuilder.DropColumn(
name: "Username",
table: "Employees");
}
}
}
@@ -0,0 +1,42 @@
using Meezi.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260601120000_AddCafeLocation")]
public partial class AddCafeLocation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "Latitude",
table: "Cafes",
type: "double precision",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "Longitude",
table: "Cafes",
type: "double precision",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Latitude",
table: "Cafes");
migrationBuilder.DropColumn(
name: "Longitude",
table: "Cafes");
}
}
}
@@ -57,6 +57,72 @@ namespace Meezi.Infrastructure.Data.Migrations
b.ToTable("Attendances"); b.ToTable("Attendances");
}); });
modelBuilder.Entity("Meezi.Core.Entities.AuditLog", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(96)
.HasColumnType("character varying(96)");
b.Property<string>("ActorId")
.HasColumnType("text");
b.Property<string>("ActorName")
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<string>("ActorRole")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("BranchId")
.HasColumnType("text");
b.Property<string>("CafeId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DetailsJson")
.HasColumnType("text");
b.Property<string>("EntityId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("EntityType")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Summary")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.HasKey("Id");
b.HasIndex("CafeId", "BranchId");
b.HasIndex("CafeId", "Category");
b.HasIndex("CafeId", "CreatedAt");
b.ToTable("AuditLogs");
});
modelBuilder.Entity("Meezi.Core.Entities.Branch", b => modelBuilder.Entity("Meezi.Core.Entities.Branch", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@@ -261,9 +327,15 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Property<bool>("IsVerified") b.Property<bool>("IsVerified")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<double?>("Latitude")
.HasColumnType("double precision");
b.Property<string>("LogoUrl") b.Property<string>("LogoUrl")
.HasColumnType("text"); .HasColumnType("text");
b.Property<double?>("Longitude")
.HasColumnType("double precision");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(200) .HasMaxLength(200)
@@ -863,6 +935,9 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Property<string>("NationalId") b.Property<string>("NationalId")
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("Phone") b.Property<string>("Phone")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -873,6 +948,9 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Property<int>("Role") b.Property<int>("Role")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<string>("Username")
.HasColumnType("text");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("BranchId"); b.HasIndex("BranchId");
@@ -884,6 +962,45 @@ namespace Meezi.Infrastructure.Data.Migrations
b.ToTable("Employees"); b.ToTable("Employees");
}); });
modelBuilder.Entity("Meezi.Core.Entities.EmployeeBranchRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("BranchId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CafeId")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("EmployeeId")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Role")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("BranchId");
b.HasIndex("CafeId", "BranchId");
b.HasIndex("EmployeeId", "BranchId")
.IsUnique()
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("EmployeeBranchRoles");
});
modelBuilder.Entity("Meezi.Core.Entities.EmployeeSalary", b => modelBuilder.Entity("Meezi.Core.Entities.EmployeeSalary", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@@ -1317,6 +1434,15 @@ namespace Meezi.Infrastructure.Data.Migrations
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("CancelReason")
.HasColumnType("text");
b.Property<DateTime?>("CancelledAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CancelledByEmployeeId")
.HasColumnType("text");
b.Property<string>("CouponId") b.Property<string>("CouponId")
.HasColumnType("text"); .HasColumnType("text");
@@ -2005,11 +2131,17 @@ namespace Meezi.Infrastructure.Data.Migrations
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("character varying(200)"); .HasColumnType("character varying(200)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("Phone") b.Property<string>("Phone")
.IsRequired() .IsRequired()
.HasMaxLength(20) .HasMaxLength(20)
.HasColumnType("character varying(20)"); .HasColumnType("character varying(20)");
b.Property<string>("Username")
.HasColumnType("text");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Phone") b.HasIndex("Phone")
@@ -2424,6 +2556,15 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Navigation("Employee"); b.Navigation("Employee");
}); });
modelBuilder.Entity("Meezi.Core.Entities.AuditLog", b =>
{
b.HasOne("Meezi.Core.Entities.Cafe", null)
.WithMany()
.HasForeignKey("CafeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Meezi.Core.Entities.Branch", b => modelBuilder.Entity("Meezi.Core.Entities.Branch", b =>
{ {
b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") b.HasOne("Meezi.Core.Entities.Cafe", "Cafe")
@@ -2565,6 +2706,25 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Navigation("Cafe"); b.Navigation("Cafe");
}); });
modelBuilder.Entity("Meezi.Core.Entities.EmployeeBranchRole", b =>
{
b.HasOne("Meezi.Core.Entities.Branch", "Branch")
.WithMany("StaffRoles")
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Meezi.Core.Entities.Employee", "Employee")
.WithMany("BranchRoles")
.HasForeignKey("EmployeeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Employee");
});
modelBuilder.Entity("Meezi.Core.Entities.EmployeeSalary", b => modelBuilder.Entity("Meezi.Core.Entities.EmployeeSalary", b =>
{ {
b.HasOne("Meezi.Core.Entities.Employee", "Employee") b.HasOne("Meezi.Core.Entities.Employee", "Employee")
@@ -3012,6 +3172,8 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Navigation("Staff"); b.Navigation("Staff");
b.Navigation("StaffRoles");
b.Navigation("Tables"); b.Navigation("Tables");
}); });
@@ -3061,6 +3223,8 @@ namespace Meezi.Infrastructure.Data.Migrations
{ {
b.Navigation("Attendances"); b.Navigation("Attendances");
b.Navigation("BranchRoles");
b.Navigation("LeaveRequests"); b.Navigation("LeaveRequests");
b.Navigation("Orders"); b.Navigation("Orders");
@@ -3,7 +3,9 @@ using Meezi.Core.Constants;
using Meezi.Core.Entities; using Meezi.Core.Entities;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Platform; using Meezi.Core.Platform;
using Meezi.Core.Utilities;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -17,18 +19,25 @@ public static class PlatformDataSeeder
public static async Task SeedAsync(IServiceProvider services) public static async Task SeedAsync(IServiceProvider services)
{ {
var env = services.GetRequiredService<IHostEnvironment>(); var env = services.GetRequiredService<IHostEnvironment>();
if (!env.IsDevelopment())
return;
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("PlatformDataSeeder"); var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("PlatformDataSeeder");
await using var scope = services.CreateAsyncScope(); await using var scope = services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
await EnsureCatalogUpgradesAsync(db, logger); // Production-safe: ensure the platform owner's system-admin account exists
// on every boot (ALL environments) so the admin panel is reachable on a
// fresh deploy. Idempotent. Phone is overridable via "Seed:SystemAdminPhone".
await EnsureOwnerAdminAsync(db, config, logger);
if (!env.IsDevelopment()) if (!env.IsDevelopment())
{
// Production: also ensure integration settings (Kavenegar enabled/template,
// etc.) exist so the admin Integrations page is populated. Idempotent.
await EnsureIntegrationSettingsAsync(db, logger);
return; return;
}
await EnsureCatalogUpgradesAsync(db, logger);
await SeedSystemAdminAsync(db, logger); await SeedSystemAdminAsync(db, logger);
await SeedPlansAsync(db, logger); await SeedPlansAsync(db, logger);
await SeedFeaturesAsync(db, logger); await SeedFeaturesAsync(db, logger);
@@ -36,6 +45,77 @@ public static class PlatformDataSeeder
await EnsureIntegrationSettingsAsync(db, logger); await EnsureIntegrationSettingsAsync(db, logger);
} }
/// <summary>
/// Ensures the platform owner's system-admin account exists in EVERY environment
/// (including production), so the admin panel is reachable on a fresh deploy.
/// The phone is configurable via "Seed:SystemAdminPhone" (env Seed__SystemAdminPhone)
/// and defaults to the platform owner's number. Idempotent — never duplicates.
/// </summary>
private static async Task EnsureOwnerAdminAsync(AppDbContext db, IConfiguration config, ILogger logger)
{
const string DefaultOwnerPhone = "09190345606";
const string DefaultAdminUsername = "admin";
var configuredPhone = config["Seed:SystemAdminPhone"];
var phone = PhoneNormalizer.Normalize(
string.IsNullOrWhiteSpace(configuredPhone) ? DefaultOwnerPhone : configuredPhone);
if (!PhoneNormalizer.IsValidIranMobile(phone))
{
logger.LogWarning("Owner system-admin seed skipped — invalid phone '{Phone}'", phone);
return;
}
var configuredUsername = config["Seed:SystemAdminUsername"];
var username = string.IsNullOrWhiteSpace(configuredUsername) ? DefaultAdminUsername : configuredUsername.Trim().ToLowerInvariant();
var defaultPassword = config["Seed:SystemAdminPassword"]; // optional — only set if provided
var existing = await db.SystemAdmins.FirstOrDefaultAsync(a => a.Phone == phone);
if (existing is null)
{
var admin = new SystemAdmin
{
Id = "sysadmin_owner",
Name = "مدیر سامانه",
Phone = phone,
IsActive = true,
Username = username,
PasswordHash = string.IsNullOrWhiteSpace(defaultPassword) ? null : PasswordHasher.Hash(defaultPassword)
};
db.SystemAdmins.Add(admin);
try
{
await db.SaveChangesAsync();
logger.LogInformation("Seeded owner system admin with phone {Phone}, username '{Username}'", phone, username);
}
catch (DbUpdateException)
{
logger.LogInformation("Owner system admin already seeded by another instance");
}
return;
}
// Patch existing admin: fill in missing username / password without overwriting set values
var patched = false;
if (string.IsNullOrWhiteSpace(existing.Username))
{
existing.Username = username;
patched = true;
}
if (string.IsNullOrWhiteSpace(existing.PasswordHash) && !string.IsNullOrWhiteSpace(defaultPassword))
{
existing.PasswordHash = PasswordHasher.Hash(defaultPassword);
patched = true;
}
if (patched)
{
await db.SaveChangesAsync();
logger.LogInformation("Patched owner system admin credentials (username/password)");
}
}
/// <summary>Idempotent plan/feature upgrades for all environments (including production).</summary> /// <summary>Idempotent plan/feature upgrades for all environments (including production).</summary>
public static async Task EnsureCatalogUpgradesAsync(IServiceProvider services) public static async Task EnsureCatalogUpgradesAsync(IServiceProvider services)
{ {
@@ -45,8 +125,46 @@ public static class PlatformDataSeeder
await EnsureCatalogUpgradesAsync(db, logger); await EnsureCatalogUpgradesAsync(db, logger);
} }
/// <summary>
/// Ensures every café has at least one active branch. Idempotent.
/// Creates a default branch named after the café for any café that has none.
/// </summary>
private static async Task EnsureDefaultBranchesAsync(AppDbContext db, ILogger logger)
{
// Load café IDs that have zero branches in one query
var cafeIdsWithBranches = await db.Branches
.Where(b => b.DeletedAt == null)
.Select(b => b.CafeId)
.Distinct()
.ToListAsync();
var cafesWithoutBranch = await db.Cafes
.Where(c => c.DeletedAt == null && !cafeIdsWithBranches.Contains(c.Id))
.Select(c => new { c.Id, c.Name })
.ToListAsync();
if (cafesWithoutBranch.Count == 0) return;
foreach (var cafe in cafesWithoutBranch)
{
db.Branches.Add(new Branch
{
CafeId = cafe.Id,
Name = cafe.Name,
IsActive = true,
});
}
await db.SaveChangesAsync();
logger.LogInformation("Created default branch for {Count} café(s) that had none", cafesWithoutBranch.Count);
}
private static async Task EnsureCatalogUpgradesAsync(AppDbContext db, ILogger logger) private static async Task EnsureCatalogUpgradesAsync(AppDbContext db, ILogger logger)
{ {
// Ensure every café has at least one branch. Cafés registered before the
// auto-branch feature was added are patched on the first boot after upgrade.
await EnsureDefaultBranchesAsync(db, logger);
var featureAdds = new[] var featureAdds = new[]
{ {
("menu_3d", "منوی سه‌بعدی", "3D menu", "growth"), ("menu_3d", "منوی سه‌بعدی", "3D menu", "growth"),
@@ -126,7 +244,7 @@ public static class PlatformDataSeeder
S("payment.vandar.enabled", "false", "payment", "فعال وندار"), S("payment.vandar.enabled", "false", "payment", "فعال وندار"),
S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"), S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"),
S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوه‌نگار"), S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوه‌نگار"),
S("integrations.kavenegar.otpTemplate", "verify", "integrations", "قالب OTP"), S("integrations.kavenegar.otpTemplate", "meeziotp", "integrations", "قالب OTP"),
S("integrations.openai.enabled", "false", "integrations", "فعال OpenAI"), S("integrations.openai.enabled", "false", "integrations", "فعال OpenAI"),
S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"), S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"),
S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"), S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"),
@@ -296,7 +414,7 @@ public static class PlatformDataSeeder
S("payment.vandar.enabled", "false", "payment", "فعال وندار"), S("payment.vandar.enabled", "false", "payment", "فعال وندار"),
S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"), S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"),
S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوه‌نگار"), S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوه‌نگار"),
S("integrations.kavenegar.otpTemplate", "verify", "integrations", "قالب OTP"), S("integrations.kavenegar.otpTemplate", "meeziotp", "integrations", "قالب OTP"),
S("integrations.openai.enabled", "false", "integrations", "فعال OpenAI"), S("integrations.openai.enabled", "false", "integrations", "فعال OpenAI"),
S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"), S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"),
S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"), S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"),
@@ -29,7 +29,7 @@ public static class DependencyInjection
services.AddScoped<IPlatformCatalogService, PlatformCatalogService>(); services.AddScoped<IPlatformCatalogService, PlatformCatalogService>();
services.AddScoped<ISupportTicketService, SupportTicketService>(); services.AddScoped<ISupportTicketService, SupportTicketService>();
services.AddHttpClient<ISmsService, KavenegarSmsService>(); services.AddScoped<ISmsService, KavenegarSmsService>();
services.AddHttpClient<IZarinPalGateway, ZarinPalGateway>(); services.AddHttpClient<IZarinPalGateway, ZarinPalGateway>();
services.AddHttpClient<ISnappPayGateway, SnappPayGateway>(); services.AddHttpClient<ISnappPayGateway, SnappPayGateway>();
services.AddHttpClient<ITaraPaymentGateway, TaraPaymentGateway>(); services.AddHttpClient<ITaraPaymentGateway, TaraPaymentGateway>();
@@ -1,5 +1,5 @@
using System.Net.Http.Json; using Kavenegar;
using System.Text.Json.Serialization; using Kavenegar.Exceptions;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Services.Platform; using Meezi.Infrastructure.Services.Platform;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
@@ -9,7 +9,7 @@ using Microsoft.Extensions.Logging;
namespace Meezi.Infrastructure.ExternalServices; namespace Meezi.Infrastructure.ExternalServices;
/// <summary> /// <summary>
/// Kavenegar SMS gateway implementation. /// Kavenegar SMS gateway implementation using the official Kavenegar .NET SDK.
/// Reads config from DB (via IPlatformRuntimeConfig) first, then falls back /// Reads config from DB (via IPlatformRuntimeConfig) first, then falls back
/// to IConfiguration ("Kavenegar:ApiKey", "Kavenegar:SenderNumber", etc.). /// to IConfiguration ("Kavenegar:ApiKey", "Kavenegar:SenderNumber", etc.).
/// </summary> /// </summary>
@@ -21,23 +21,19 @@ public class KavenegarSmsService : ISmsService
private const string DbKeySender = "integrations.kavenegar.senderNumber"; private const string DbKeySender = "integrations.kavenegar.senderNumber";
private const string DbKeyOtpTemplate = "integrations.kavenegar.otpTemplate"; private const string DbKeyOtpTemplate = "integrations.kavenegar.otpTemplate";
private const string BaseUrl = "https://api.kavenegar.com/v1";
private const int MaxBatchSize = 200; private const int MaxBatchSize = 200;
private readonly HttpClient _httpClient;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly IPlatformRuntimeConfig _platform; private readonly IPlatformRuntimeConfig _platform;
private readonly IHostEnvironment _environment; private readonly IHostEnvironment _environment;
private readonly ILogger<KavenegarSmsService> _logger; private readonly ILogger<KavenegarSmsService> _logger;
public KavenegarSmsService( public KavenegarSmsService(
HttpClient httpClient,
IConfiguration configuration, IConfiguration configuration,
IPlatformRuntimeConfig platform, IPlatformRuntimeConfig platform,
IHostEnvironment environment, IHostEnvironment environment,
ILogger<KavenegarSmsService> logger) ILogger<KavenegarSmsService> logger)
{ {
_httpClient = httpClient;
_configuration = configuration; _configuration = configuration;
_platform = platform; _platform = platform;
_environment = environment; _environment = environment;
@@ -61,16 +57,11 @@ public class KavenegarSmsService : ISmsService
return; return;
} }
var url = $"{BaseUrl}/{apiKey}/verify/lookup.json"; var receptor = NormalizePhone(phone);
var content = new FormUrlEncodedContent(new Dictionary<string, string> await RunSdkAsync(apiKey, api =>
{ {
["receptor"] = NormalizePhone(phone), api.VerifyLookup(receptor, otp, null, null, template);
["token"] = otp, }, "OTP");
["template"] = template,
});
var response = await _httpClient.PostAsync(url, content, cancellationToken);
await EnsureKavenegarSuccessAsync(response, "OTP", cancellationToken);
} }
public async Task SendMessageAsync(string phone, string message, CancellationToken cancellationToken = default) public async Task SendMessageAsync(string phone, string message, CancellationToken cancellationToken = default)
@@ -82,11 +73,11 @@ public class KavenegarSmsService : ISmsService
return; return;
} }
var url = $"{BaseUrl}/{apiKey}/sms/send.json"; var receptor = NormalizePhone(phone);
var content = BuildSendForm(phone, message, sender); await RunSdkAsync(apiKey, api =>
{
var response = await _httpClient.PostAsync(url, content, cancellationToken); api.Send(sender, receptor, message);
await EnsureKavenegarSuccessAsync(response, "Send", cancellationToken); }, "Send");
} }
public async Task<BulkSendResult> SendBulkAsync( public async Task<BulkSendResult> SendBulkAsync(
@@ -103,17 +94,18 @@ public class KavenegarSmsService : ISmsService
return new BulkSendResult(0, phones.Count); return new BulkSendResult(0, phones.Count);
} }
var url = $"{BaseUrl}/{apiKey}/sms/send.json";
int sent = 0, failed = 0; int sent = 0, failed = 0;
foreach (var batch in phones.Chunk(MaxBatchSize)) foreach (var batch in phones.Chunk(MaxBatchSize))
{ {
try try
{ {
// Kavenegar /sms/send.json accepts comma-separated receptors var receptors = batch.Select(NormalizePhone).ToList();
var content = BuildSendForm(string.Join(",", batch), message, sender); await RunSdkAsync(apiKey, api =>
var response = await _httpClient.PostAsync(url, content, cancellationToken); {
await EnsureKavenegarSuccessAsync(response, "BulkSend", cancellationToken); api.Send(sender, receptors, message);
}, "BulkSend");
sent += batch.Length; sent += batch.Length;
_logger.LogInformation("Kavenegar bulk batch: {Count} sent", batch.Length); _logger.LogInformation("Kavenegar bulk batch: {Count} sent", batch.Length);
} }
@@ -134,20 +126,12 @@ public class KavenegarSmsService : ISmsService
try try
{ {
var url = $"{BaseUrl}/{apiKey}/account/info.json"; return await Task.Run(() =>
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{ {
_logger.LogWarning("Kavenegar account info returned HTTP {Status}", response.StatusCode); var api = new KavenegarApi(apiKey);
return null; var info = api.AccountInfo();
} return new KavenegarAccountInfo(info.RemainCredit, info.Type ?? "master");
}, cancellationToken);
var body = await response.Content.ReadFromJsonAsync<KavenegarAccountInfoResponse>(cancellationToken: cancellationToken);
if (body?.Return?.Status is not 200 || body.Entries is null)
return null;
return new KavenegarAccountInfo(body.Entries.RemainCredit, body.Entries.Type ?? "master");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -156,42 +140,42 @@ public class KavenegarSmsService : ISmsService
} }
} }
// ── SDK runner ────────────────────────────────────────────────────────────
/// <summary>
/// Runs a synchronous Kavenegar SDK call on the thread pool.
/// Translates SDK exceptions to logged InvalidOperationException.
/// </summary>
private async Task RunSdkAsync(string apiKey, Action<KavenegarApi> action, string operation)
{
await Task.Run(() =>
{
try
{
var api = new KavenegarApi(apiKey);
action(api);
}
catch (ApiException ex)
{
_logger.LogWarning(
"Kavenegar {Op} API error {Code}: {Message}",
operation, ex.Code, ex.Message);
throw new InvalidOperationException(
$"Kavenegar {operation} failed (code {ex.Code}): {ex.Message}", ex);
}
catch (HttpException ex)
{
_logger.LogWarning(
"Kavenegar {Op} HTTP error {Code}: {Message}",
operation, ex.Code, ex.Message);
throw new InvalidOperationException(
$"Kavenegar {operation} HTTP error (code {ex.Code}): {ex.Message}", ex);
}
});
}
// ── Helpers ─────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────
private static FormUrlEncodedContent BuildSendForm(string receptor, string message, string sender)
{
var dict = new Dictionary<string, string>
{
["receptor"] = receptor,
["message"] = message,
};
if (!string.IsNullOrWhiteSpace(sender))
dict["sender"] = sender;
return new FormUrlEncodedContent(dict);
}
private async Task EnsureKavenegarSuccessAsync(
HttpResponseMessage response,
string operation,
CancellationToken cancellationToken)
{
if (!response.IsSuccessStatusCode)
{
var errorCode = (int)response.StatusCode;
var detail = KavenegarHttpError(errorCode);
_logger.LogWarning("Kavenegar {Op} HTTP {Code}: {Detail}", operation, errorCode, detail);
throw new InvalidOperationException($"Kavenegar {operation} failed (HTTP {errorCode}): {detail}");
}
var body = await response.Content.ReadFromJsonAsync<KavenegarReturnEnvelope>(cancellationToken: cancellationToken);
if (body?.Return?.Status is not 200)
{
var status = body?.Return?.Status ?? -1;
_logger.LogWarning("Kavenegar {Op} returned status {Status}: {Message}", operation, status, body?.Return?.Message);
throw new InvalidOperationException($"Kavenegar {operation} failed (status {status}): {body?.Return?.Message}");
}
}
// Strip leading 0 from Iranian mobile numbers (09xxxxxxxxx → 9xxxxxxxxx) // Strip leading 0 from Iranian mobile numbers (09xxxxxxxxx → 9xxxxxxxxx)
private static string NormalizePhone(string phone) private static string NormalizePhone(string phone)
{ {
@@ -200,35 +184,6 @@ public class KavenegarSmsService : ISmsService
return p; return p;
} }
private static string KavenegarHttpError(int code) => code switch
{
400 => "Missing or invalid parameters",
401 => "Account is inactive",
403 => "Invalid API key",
404 => "Method not found",
405 => "Wrong HTTP method",
406 => "Recipient is on the blacklist or number is deactivated",
411 => "Invalid recipient number",
412 => "Invalid sender number",
413 => "Message empty or too long",
414 => "Too many recipients",
415 => "Server error on Kavenegar side",
416 => "Recipient is invalid, blacklisted, or deactivated",
417 => "Invalid scheduled date",
418 => "Insufficient credit",
419 => "OTP token already used or expired",
420 => "IP not allowed",
421 => "Message could not be sent",
422 => "Invalid characters in message",
423 => "Kavenegar server unreachable",
424 => "OTP template not found — check template name in Kavenegar panel",
426 => "IP is not whitelisted",
428 => "Voice call requires numeric token",
431 => "SMS sending is disabled on this account",
432 => "Code parameter missing in OTP template",
_ => $"Undocumented Kavenegar error {code}"
};
private async Task<(string? ApiKey, string Sender, string OtpTemplate)> GetConfigAsync(CancellationToken ct) private async Task<(string? ApiKey, string Sender, string OtpTemplate)> GetConfigAsync(CancellationToken ct)
{ {
var enabled = await _platform.GetAsync(DbKeyEnabled, ct); var enabled = await _platform.GetAsync(DbKeyEnabled, ct);
@@ -250,42 +205,4 @@ public class KavenegarSmsService : ISmsService
return (apiKey, sender, template); return (apiKey, sender, template);
} }
// ── Response models ───────────────────────────────────────────────────────
private sealed class KavenegarReturnEnvelope
{
[JsonPropertyName("return")]
public KavenegarReturn? Return { get; set; }
}
private sealed class KavenegarReturn
{
[JsonPropertyName("status")]
public int Status { get; set; }
[JsonPropertyName("message")]
public string? Message { get; set; }
}
private sealed class KavenegarAccountInfoResponse
{
[JsonPropertyName("return")]
public KavenegarReturn? Return { get; set; }
[JsonPropertyName("entries")]
public KavenegarAccountEntries? Entries { get; set; }
}
private sealed class KavenegarAccountEntries
{
[JsonPropertyName("remaincredit")]
public long RemainCredit { get; set; }
[JsonPropertyName("expiredate")]
public long ExpireDate { get; set; }
[JsonPropertyName("type")]
public string? Type { get; set; }
}
} }
@@ -14,6 +14,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Kavenegar" />
<PackageReference Include="System.Security.Cryptography.Xml" /> <PackageReference Include="System.Security.Cryptography.Xml" />
</ItemGroup> </ItemGroup>
@@ -50,8 +50,10 @@ public class SupportTicketService : ISupportTicketService
string cafeId, string cafeId,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
return await QueryTickets() // NOTE: The Where MUST be applied on the EF entity set BEFORE the Select projection.
.Where(t => t.CafeId == cafeId) // Applying Where() after Select() onto a DTO record causes an EF translation error
// because EF can't translate "new SupportTicketDto(...).CafeId == x".
return await QueryTickets(cafeId)
.OrderByDescending(t => t.UpdatedAt) .OrderByDescending(t => t.UpdatedAt)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
@@ -119,11 +121,10 @@ public class SupportTicketService : ISupportTicketService
SupportTicketStatus? status, SupportTicketStatus? status,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var q = QueryTickets(); // status filter is applied on the entity before projection — safe for EF translation.
if (status.HasValue) return await QueryTickets(cafeId: null, status: status)
q = q.Where(t => t.Status == status.Value); .OrderByDescending(t => t.UpdatedAt)
.ToListAsync(cancellationToken);
return await q.OrderByDescending(t => t.UpdatedAt).ToListAsync(cancellationToken);
} }
public async Task<SupportTicketDetailDto?> GetAdminAsync( public async Task<SupportTicketDetailDto?> GetAdminAsync(
@@ -185,10 +186,23 @@ public class SupportTicketService : ISupportTicketService
return await GetAdminAsync(ticketId, cancellationToken); return await GetAdminAsync(ticketId, cancellationToken);
} }
private IQueryable<SupportTicketDto> QueryTickets() => /// <summary>
_db.SupportTickets /// Builds an EF-translatable query for support ticket list rows.
.AsNoTracking() /// Filters are applied on the entity BEFORE the Select projection to avoid EF translation errors.
.Select(t => new SupportTicketDto( /// </summary>
private IQueryable<SupportTicketDto> QueryTickets(
string? cafeId = null,
SupportTicketStatus? status = null)
{
var q = _db.SupportTickets.AsNoTracking().AsQueryable();
// Apply entity-level filters BEFORE Select so EF can translate them.
if (cafeId is not null)
q = q.Where(t => t.CafeId == cafeId);
if (status.HasValue)
q = q.Where(t => t.Status == status.Value);
return q.Select(t => new SupportTicketDto(
t.Id, t.Id,
t.CafeId, t.CafeId,
t.Cafe != null ? t.Cafe.Name : "", t.Cafe != null ? t.Cafe.Name : "",
@@ -201,6 +215,7 @@ public class SupportTicketService : ISupportTicketService
t.CreatedAt, t.CreatedAt,
t.UpdatedAt, t.UpdatedAt,
t.Messages.Count)); t.Messages.Count));
}
private static SupportTicketDto MapTicket(SupportTicket t) => private static SupportTicketDto MapTicket(SupportTicket t) =>
new( new(
+3 -1
View File
@@ -1,5 +1,6 @@
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Discover; using Meezi.Core.Discover;
using Xunit;
namespace Meezi.API.Tests; namespace Meezi.API.Tests;
@@ -44,7 +45,8 @@ public class DiscoverFilterTests
noise: "quiet", noise: "quiet",
priceTier: "mid", priceTier: "mid",
size: null, size: null,
requireProfile: true); requireProfile: true,
openNow: false);
Assert.Equal("تهران", f.City); Assert.Equal("تهران", f.City);
Assert.Equal(4, f.MinRating); Assert.Equal(4, f.MinRating);
Assert.Contains("modern", f.Themes!); Assert.Contains("modern", f.Themes!);
@@ -0,0 +1,26 @@
using Meezi.API.Security;
namespace Meezi.API.Tests;
/// <summary>Test double that allows every action and has no captcha configured.</summary>
internal sealed class NoOpAbuseProtectionService : IAbuseProtectionService
{
public bool IsCaptchaConfigured => false;
public string? CaptchaSiteKey => null;
public Task<(bool Allowed, string? ErrorCode, string? Message)> CheckAuthOtpByIpAsync(
string clientIp, CancellationToken cancellationToken = default) =>
Task.FromResult<(bool, string?, string?)>((true, null, null));
public Task<(bool Allowed, string? ErrorCode, string? Message)> CheckGuestOrderAsync(
string cafeId, string clientIp, CancellationToken cancellationToken = default) =>
Task.FromResult<(bool, string?, string?)>((true, null, null));
public Task<(bool Allowed, string? ErrorCode, string? Message)> CheckPublicWriteByIpAsync(
string clientIp, CancellationToken cancellationToken = default) =>
Task.FromResult<(bool, string?, string?)>((true, null, null));
public Task<(bool Ok, string? ErrorCode, string? Message)> VerifyCaptchaAsync(
string? captchaToken, CancellationToken cancellationToken = default) =>
Task.FromResult<(bool, string?, string?)>((true, null, null));
}
@@ -16,9 +16,17 @@ internal sealed class NoOpInventoryService : IInventoryService
public Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default) => public Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default) =>
Task.FromResult<IngredientDto?>(null); Task.FromResult<IngredientDto?>(null);
public Task<IngredientDto?> AdjustAsync(string cafeId, string ingredientId, AdjustStockRequest request, CancellationToken ct = default) => public Task<IngredientDto?> AdjustAsync(string cafeId, string ingredientId, AdjustStockRequest request, string? userId, CancellationToken ct = default) =>
Task.FromResult<IngredientDto?>(null); Task.FromResult<IngredientDto?>(null);
public Task<InventoryPurchasesSummaryDto> GetPurchasesSummaryAsync(
string cafeId,
string branchId,
DateOnly from,
DateOnly to,
CancellationToken ct = default) =>
Task.FromResult(new InventoryPurchasesSummaryDto(0, 0, []));
public Task<MenuItemRecipeDto?> GetRecipeAsync(string cafeId, string menuItemId, CancellationToken ct = default) => public Task<MenuItemRecipeDto?> GetRecipeAsync(string cafeId, string menuItemId, CancellationToken ct = default) =>
Task.FromResult<MenuItemRecipeDto?>(null); Task.FromResult<MenuItemRecipeDto?>(null);
@@ -1,4 +1,5 @@
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Entities;
namespace Meezi.API.Tests; namespace Meezi.API.Tests;
@@ -10,4 +11,11 @@ internal sealed class NoOpLoyaltyService : ILoyaltyService
decimal paidAmount, decimal paidAmount,
CancellationToken ct = default) => CancellationToken ct = default) =>
Task.CompletedTask; Task.CompletedTask;
public Task<(bool Success, LoyaltyRedeemResult? Data, string? ErrorCode)> RedeemOnOrderAsync(
string cafeId,
Order order,
int pointsRequested,
CancellationToken ct = default) =>
Task.FromResult<(bool, LoyaltyRedeemResult?, string?)>((false, null, null));
} }
@@ -0,0 +1,29 @@
using Meezi.API.Services;
using Microsoft.AspNetCore.Http;
namespace Meezi.API.Tests;
/// <summary>Test double that stores nothing and returns no URL.</summary>
internal sealed class NoOpMediaStorageService : IMediaStorageService
{
public Task<string?> SaveMenuImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveMenuVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveTableImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveTableVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveCafeLogoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveCafeCoverAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveMenuModel3dAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveMenuModel3dFromBytesAsync(string cafeId, byte[] glbBytes, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveReviewPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveCafeGalleryPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
}
@@ -11,4 +11,7 @@ internal sealed class NoOpOrderNotificationService : IOrderNotificationService
public Task NotifyOrderStatusChangedAsync(Order order, CancellationToken ct = default) => public Task NotifyOrderStatusChangedAsync(Order order, CancellationToken ct = default) =>
Task.CompletedTask; Task.CompletedTask;
public Task NotifyCallWaiterAsync(string cafeId, string tableId, string tableNumber, CancellationToken ct = default) =>
Task.CompletedTask;
} }
+1
View File
@@ -28,6 +28,7 @@ public class PrintingTests
218_000m, 218_000m,
0m, 0m,
DateTime.UtcNow, DateTime.UtcNow,
1,
[ [
new OrderItemDto("i1", "m1", "Espresso", 2, 100_000m, null), new OrderItemDto("i1", "m1", "Espresso", 2, 100_000m, null),
new OrderItemDto("i2", "m2", "Void Latte", 1, 50_000m, null, true) new OrderItemDto("i2", "m2", "Void Latte", 1, 50_000m, null, true)
+7 -1
View File
@@ -1,11 +1,13 @@
using Meezi.API.Models.Menu; using Meezi.API.Models.Menu;
using Meezi.API.Models.Orders; using Meezi.API.Models.Orders;
using Meezi.API.Models.Public; using Meezi.API.Models.Public;
using Meezi.API.Security;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Entities; using Meezi.Core.Entities;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data; using Meezi.Infrastructure.Data;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Xunit; using Xunit;
@@ -114,7 +116,11 @@ public class QrMenuTests
var tables = new TableService(db, config, kds, identity); var tables = new TableService(db, config, kds, identity);
var shifts = new ShiftService(db); var shifts = new ShiftService(db);
var orders = new OrderService(db, kds, new NoOpSnappfood(), new NoOpDeliverySync(), shifts, TestServiceScopeFactory.Create(), new NoOpOrderNotificationService(), new NoOpInventoryService(), new NoOpLoyaltyService()); var orders = new OrderService(db, kds, new NoOpSnappfood(), new NoOpDeliverySync(), shifts, TestServiceScopeFactory.Create(), new NoOpOrderNotificationService(), new NoOpInventoryService(), new NoOpLoyaltyService());
var publicSvc = new PublicService(db, orders, new ReviewService(db), kds, branchMenu, identity); var abuse = new NoOpAbuseProtectionService();
var http = new HttpContextAccessor();
var media = new NoOpMediaStorageService();
var reviews = new ReviewService(db, abuse, http, media);
var publicSvc = new PublicService(db, orders, reviews, kds, branchMenu, identity, abuse, http);
return (db, tables, publicSvc, cafeId, branchId, tableId, itemA, itemB, qrCode); return (db, tables, publicSvc, cafeId, branchId, tableId, itemA, itemB, qrCode);
} }
+9 -1
View File
@@ -1057,6 +1057,7 @@
"fieldCategoryEn": "الفئة بالإنجليزية", "fieldCategoryEn": "الفئة بالإنجليزية",
"fieldContentFa": "المحتوى بالفارسية (Markdown)", "fieldContentFa": "المحتوى بالفارسية (Markdown)",
"fieldContentEn": "المحتوى بالإنجليزية (Markdown)", "fieldContentEn": "المحتوى بالإنجليزية (Markdown)",
"fieldPublished": "منشور",
"commentsTitle": "إدارة التعليقات", "commentsTitle": "إدارة التعليقات",
"noComments": "لا توجد تعليقات", "noComments": "لا توجد تعليقات",
"approved": "موافق عليه", "approved": "موافق عليه",
@@ -1093,7 +1094,14 @@
"otp": "رمز التحقق", "otp": "رمز التحقق",
"login": "دخول", "login": "دخول",
"error": "فشل تسجيل الدخول", "error": "فشل تسجيل الدخول",
"devHint": "في التطوير يُطبع الرمز في سجل Admin API." "devHint": "في التطوير يُطبع الرمز في سجل Admin API.",
"tabOtp": "رمز مؤقت",
"tabPassword": "كلمة المرور",
"username": "اسم المستخدم",
"usernamePlaceholder": "اسم المستخدم",
"password": "كلمة المرور",
"passwordPlaceholder": "كلمة المرور",
"invalidCredentials": "اسم المستخدم أو كلمة المرور غير صحيحة."
}, },
"dashboard": { "dashboard": {
"title": "نظرة عامة", "title": "نظرة عامة",
+9 -1
View File
@@ -1058,6 +1058,7 @@
"fieldCategoryEn": "Category (English)", "fieldCategoryEn": "Category (English)",
"fieldContentFa": "Content (Persian, Markdown)", "fieldContentFa": "Content (Persian, Markdown)",
"fieldContentEn": "Content (English, Markdown)", "fieldContentEn": "Content (English, Markdown)",
"fieldPublished": "Published",
"commentsTitle": "Comment management", "commentsTitle": "Comment management",
"noComments": "No comments found", "noComments": "No comments found",
"approved": "Approved", "approved": "Approved",
@@ -1086,7 +1087,14 @@
"otp": "Verification code", "otp": "Verification code",
"login": "Sign in", "login": "Sign in",
"error": "Login failed", "error": "Login failed",
"devHint": "In development the OTP is logged by Admin API (DEV admin OTP)." "devHint": "In development the OTP is logged by Admin API (DEV admin OTP).",
"tabOtp": "One-time code",
"tabPassword": "Password",
"username": "Username",
"usernamePlaceholder": "Username",
"password": "Password",
"passwordPlaceholder": "Password",
"invalidCredentials": "Incorrect username or password."
}, },
"dashboard": { "dashboard": {
"title": "Platform overview", "title": "Platform overview",
+9 -1
View File
@@ -1058,6 +1058,7 @@
"fieldCategoryEn": "دسته‌بندی انگلیسی", "fieldCategoryEn": "دسته‌بندی انگلیسی",
"fieldContentFa": "محتوا فارسی (Markdown)", "fieldContentFa": "محتوا فارسی (Markdown)",
"fieldContentEn": "محتوا انگلیسی (Markdown)", "fieldContentEn": "محتوا انگلیسی (Markdown)",
"fieldPublished": "وضعیت انتشار",
"commentsTitle": "مدیریت نظرات", "commentsTitle": "مدیریت نظرات",
"noComments": "نظری یافت نشد", "noComments": "نظری یافت نشد",
"approved": "تأیید شده", "approved": "تأیید شده",
@@ -1086,7 +1087,14 @@
"otp": "کد تأیید", "otp": "کد تأیید",
"login": "ورود", "login": "ورود",
"error": "خطا در ورود", "error": "خطا در ورود",
"devHint": "در حالت توسعه کد در لاگ Admin API چاپ می‌شود (DEV admin OTP)." "devHint": "در حالت توسعه کد در لاگ Admin API چاپ می‌شود (DEV admin OTP).",
"tabOtp": "کد یکبارمصرف",
"tabPassword": "رمز عبور",
"username": "نام کاربری",
"usernamePlaceholder": "نام کاربری",
"password": "رمز عبور",
"passwordPlaceholder": "رمز عبور",
"invalidCredentials": "نام کاربری یا رمز عبور اشتباه است."
}, },
"dashboard": { "dashboard": {
"title": "خلاصه سامانه", "title": "خلاصه سامانه",
+119 -8
View File
@@ -13,14 +13,25 @@ import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field"; import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
type LoginTab = "otp" | "password";
export default function AdminLoginPage() { export default function AdminLoginPage() {
const t = useTranslations("admin.auth"); const t = useTranslations("admin.auth");
const tAuth = useTranslations("auth"); const tAuth = useTranslations("auth");
const router = useRouter(); const router = useRouter();
const setAuth = useAdminAuthStore((s) => s.setAuth); const setAuth = useAdminAuthStore((s) => s.setAuth);
const [tab, setTab] = useState<LoginTab>("otp");
// OTP state
const [phone, setPhone] = useState("09120000001"); const [phone, setPhone] = useState("09120000001");
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [step, setStep] = useState<"phone" | "otp">("phone"); const [otpStep, setOtpStep] = useState<"phone" | "otp">("phone");
// Password state
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -34,6 +45,8 @@ export default function AdminLoginPage() {
case "INVALID_OTP": case "INVALID_OTP":
case "VALIDATION_ERROR": case "VALIDATION_ERROR":
return tAuth("invalidOtp"); return tAuth("invalidOtp");
case "INVALID_TOKEN":
return t("invalidCredentials");
default: default:
return err.message; return err.message;
} }
@@ -46,7 +59,7 @@ export default function AdminLoginPage() {
setError(null); setError(null);
try { try {
await adminPost("/api/admin/auth/send-otp", { phone }); await adminPost("/api/admin/auth/send-otp", { phone });
setStep("otp"); setOtpStep("otp");
setCode(""); setCode("");
} catch (e) { } catch (e) {
setError(authErrorMessage(e)); setError(authErrorMessage(e));
@@ -55,7 +68,7 @@ export default function AdminLoginPage() {
} }
}; };
const verify = async () => { const verifyOtp = async () => {
const normalized = normalizeOtpInput(code); const normalized = normalizeOtpInput(code);
if (normalized.length !== 6) { if (normalized.length !== 6) {
setError(tAuth("invalidOtp")); setError(tAuth("invalidOtp"));
@@ -77,6 +90,34 @@ export default function AdminLoginPage() {
} }
}; };
const loginWithPassword = async () => {
if (!username.trim() || !password) {
setError(t("invalidCredentials"));
return;
}
setLoading(true);
setError(null);
try {
const data = await adminPost<AuthTokenResponse>("/api/admin/auth/login", {
username: username.trim(),
password,
});
setAuth(data);
router.push("/admin");
} catch (e) {
setError(authErrorMessage(e));
} finally {
setLoading(false);
}
};
const switchTab = (next: LoginTab) => {
setTab(next);
setError(null);
setOtpStep("phone");
setCode("");
};
return ( return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4" dir="rtl"> <div className="flex min-h-screen items-center justify-center bg-muted/30 p-4" dir="rtl">
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
@@ -87,8 +128,36 @@ export default function AdminLoginPage() {
<p className="text-center text-xs text-muted-foreground">{t("devHint")}</p> <p className="text-center text-xs text-muted-foreground">{t("devHint")}</p>
) : null} ) : null}
</CardHeader> </CardHeader>
<CardContent className="space-y-4">
{step === "phone" ? ( {/* Tab switcher */}
<div className="flex border-b px-6">
<button
type="button"
className={`flex-1 py-2 text-sm font-medium transition-colors cursor-pointer ${
tab === "otp"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => switchTab("otp")}
>
{t("tabOtp")}
</button>
<button
type="button"
className={`flex-1 py-2 text-sm font-medium transition-colors cursor-pointer ${
tab === "password"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => switchTab("password")}
>
{t("tabPassword")}
</button>
</div>
<CardContent className="space-y-4 pt-4">
{/* ───── OTP tab ───── */}
{tab === "otp" && otpStep === "phone" && (
<form <form
className="space-y-4" className="space-y-4"
onSubmit={(e) => { onSubmit={(e) => {
@@ -111,12 +180,14 @@ export default function AdminLoginPage() {
{loading ? "..." : t("sendOtp")} {loading ? "..." : t("sendOtp")}
</Button> </Button>
</form> </form>
) : ( )}
{tab === "otp" && otpStep === "otp" && (
<form <form
className="space-y-4" className="space-y-4"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
if (!loading) void verify(); if (!loading) void verifyOtp();
}} }}
> >
<LabeledField label={t("otp")} htmlFor="admin-login-otp"> <LabeledField label={t("otp")} htmlFor="admin-login-otp">
@@ -142,7 +213,7 @@ export default function AdminLoginPage() {
className="w-full" className="w-full"
disabled={loading} disabled={loading}
onClick={() => { onClick={() => {
setStep("phone"); setOtpStep("phone");
setCode(""); setCode("");
setError(null); setError(null);
}} }}
@@ -151,6 +222,46 @@ export default function AdminLoginPage() {
</Button> </Button>
</form> </form>
)} )}
{/* ───── Password tab ───── */}
{tab === "password" && (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
if (!loading) void loginWithPassword();
}}
>
<LabeledField label={t("username")} htmlFor="admin-login-username">
<Input
id="admin-login-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("usernamePlaceholder")}
dir="ltr"
className="text-start"
autoComplete="username"
autoFocus
/>
</LabeledField>
<LabeledField label={t("password")} htmlFor="admin-login-password">
<Input
id="admin-login-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("passwordPlaceholder")}
dir="ltr"
className="text-start"
autoComplete="current-password"
/>
</LabeledField>
<Button type="submit" className="w-full" disabled={loading || !username.trim() || !password}>
{loading ? "..." : t("login")}
</Button>
</form>
)}
{error ? <p className="text-center text-sm text-destructive">{error}</p> : null} {error ? <p className="text-center text-sm text-destructive">{error}</p> : null}
</CardContent> </CardContent>
</Card> </Card>
+164 -87
View File
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Link } from "@/i18n/routing"; import { Link } from "@/i18n/routing";
import { cn } from "@/lib/utils";
import { import {
adminDelete, adminDelete,
adminGet, adminGet,
@@ -37,6 +38,57 @@ import {
type TicketStatus, type TicketStatus,
} from "@/components/support/ticket-status-badge"; } from "@/components/support/ticket-status-badge";
// iOS-style toggle switch used throughout this file
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
checked ? "bg-[#0F6E56]" : "bg-muted-foreground/30"
)}
>
<span
className={cn(
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out",
checked ? "translate-x-5" : "translate-x-0"
)}
/>
</button>
);
}
// Styled single-select indicator (replaces raw <input type="radio">).
function RadioDot({
selected,
onSelect,
disabled,
}: {
selected: boolean;
onSelect: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
role="radio"
aria-checked={selected}
disabled={disabled}
onClick={onSelect}
className={cn(
"relative inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
selected ? "border-[#0F6E56]" : "border-muted-foreground/40 hover:border-muted-foreground/70"
)}
>
{selected ? <span className="h-2.5 w-2.5 rounded-full bg-[#0F6E56]" /> : null}
</button>
);
}
export function AdminDashboardScreen() { export function AdminDashboardScreen() {
const t = useTranslations("admin.dashboard"); const t = useTranslations("admin.dashboard");
const { data } = useQuery({ const { data } = useQuery({
@@ -78,6 +130,50 @@ function StatCard({ label, value }: { label: string; value: number }) {
); );
} }
function PlanCard({ plan, onSave }: { plan: AdminPlan; onSave: (p: AdminPlan) => void }) {
const t = useTranslations("admin.plans");
const [price, setPrice] = useState(plan.monthlyPriceToman);
const [maxOrders, setMaxOrders] = useState(plan.limits.maxOrdersPerDay);
// Sync server values if they change (e.g. after successful save + refetch)
useEffect(() => { setPrice(plan.monthlyPriceToman); }, [plan.monthlyPriceToman]);
useEffect(() => { setMaxOrders(plan.limits.maxOrdersPerDay); }, [plan.limits.maxOrdersPerDay]);
const flush = () =>
onSave({ ...plan, monthlyPriceToman: price, limits: { ...plan.limits, maxOrdersPerDay: maxOrders } });
return (
<Card className="rounded-xl border border-border/80">
<CardHeader className="pb-2">
<CardTitle className="text-base">{plan.displayNameFa}</CardTitle>
<p className="text-xs text-muted-foreground">{plan.tier}</p>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2">
<label className="text-sm">
{t("monthlyPrice")}
<Input
type="number"
className="mt-1"
value={price}
onChange={(e) => setPrice(Number(e.target.value))}
onBlur={flush}
/>
</label>
<label className="text-sm">
{t("maxOrders")}
<Input
type="number"
className="mt-1"
value={maxOrders}
onChange={(e) => setMaxOrders(Number(e.target.value))}
onBlur={flush}
/>
</label>
</CardContent>
</Card>
);
}
export function AdminPlansScreen() { export function AdminPlansScreen() {
const t = useTranslations("admin.plans"); const t = useTranslations("admin.plans");
const qc = useQueryClient(); const qc = useQueryClient();
@@ -108,38 +204,7 @@ export function AdminPlansScreen() {
<div className="space-y-4"> <div className="space-y-4">
<h1 className="text-lg font-medium">{t("title")}</h1> <h1 className="text-lg font-medium">{t("title")}</h1>
{plans.map((plan) => ( {plans.map((plan) => (
<Card key={plan.tier} className="rounded-xl border border-border/80"> <PlanCard key={plan.tier} plan={plan} onSave={(p) => save.mutate(p)} />
<CardHeader className="pb-2">
<CardTitle className="text-base">{plan.displayNameFa}</CardTitle>
<p className="text-xs text-muted-foreground">{plan.tier}</p>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2">
<label className="text-sm">
{t("monthlyPrice")}
<Input
type="number"
className="mt-1"
value={plan.monthlyPriceToman}
onChange={(e) => {
plan.monthlyPriceToman = Number(e.target.value);
}}
onBlur={() => save.mutate(plan)}
/>
</label>
<label className="text-sm">
{t("maxOrders")}
<Input
type="number"
className="mt-1"
defaultValue={plan.limits.maxOrdersPerDay}
onBlur={(e) => {
plan.limits.maxOrdersPerDay = Number(e.target.value);
save.mutate(plan);
}}
/>
</label>
</CardContent>
</Card>
))} ))}
</div> </div>
); );
@@ -166,17 +231,34 @@ export function AdminSettingsScreen() {
<div className="space-y-4"> <div className="space-y-4">
<h1 className="text-lg font-medium">{t("title")}</h1> <h1 className="text-lg font-medium">{t("title")}</h1>
<div className="space-y-2"> <div className="space-y-2">
{settings.map((s) => ( {settings.map((s) => {
const isBool = s.value === "true" || s.value === "false";
return (
<Card key={s.id} className="rounded-xl border border-border/80 p-4"> <Card key={s.id} className="rounded-xl border border-border/80 p-4">
<p className="text-xs text-muted-foreground">{s.key}</p> <div className={isBool ? "flex items-center justify-between gap-3" : undefined}>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">{s.key}</p>
{s.descriptionFa ? (
<p className="text-[11px] text-muted-foreground">{s.descriptionFa}</p> <p className="text-[11px] text-muted-foreground">{s.descriptionFa}</p>
) : null}
</div>
{isBool ? (
<Toggle
checked={s.value === "true"}
onChange={(v) => save.mutate({ key: s.key, value: String(v) })}
disabled={save.isPending}
/>
) : (
<Input <Input
className="mt-2" className="mt-2"
defaultValue={s.value} defaultValue={s.value}
onBlur={(e) => save.mutate({ key: s.key, value: e.target.value })} onBlur={(e) => save.mutate({ key: s.key, value: e.target.value })}
/> />
)}
</div>
</Card> </Card>
))} );
})}
</div> </div>
</div> </div>
); );
@@ -577,34 +659,30 @@ export function AdminIntegrationsScreen() {
<Card key={g.id} className="rounded-xl border border-border/80 p-4 space-y-3"> <Card key={g.id} className="rounded-xl border border-border/80 p-4 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <RadioDot
type="radio" selected={activeGateway === g.id}
name="activeGateway" onSelect={() => setActiveGateway(g.id)}
checked={activeGateway === g.id}
onChange={() => setActiveGateway(g.id)}
/> />
<span className="font-medium">{g.displayNameFa}</span> <span className="font-medium">{g.displayNameFa}</span>
{activeGateway === g.id ? ( {activeGateway === g.id ? (
<Badge className="bg-[#E1F5EE] text-[#0F6E56]">{t("active")}</Badge> <Badge className="bg-[#E1F5EE] text-[#0F6E56]">{t("active")}</Badge>
) : null} ) : null}
</div> </div>
<label className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<input <Toggle
type="checkbox"
checked={g.isEnabled} checked={g.isEnabled}
onChange={(e) => updateGateway(g.id, { isEnabled: e.target.checked })} onChange={(v) => updateGateway(g.id, { isEnabled: v })}
/> />
{t("enabled")} <span>{t("enabled")}</span>
</label>
</div> </div>
<label className="flex items-center gap-2 text-sm text-muted-foreground"> </div>
<input <div className="flex items-center gap-2 text-sm text-muted-foreground">
type="checkbox" <Toggle
checked={g.sandbox} checked={g.sandbox}
onChange={(e) => updateGateway(g.id, { sandbox: e.target.checked })} onChange={(v) => updateGateway(g.id, { sandbox: v })}
/> />
{t("sandbox")} <span>{t("sandbox")}</span>
</label> </div>
{g.id === "zarinpal" ? ( {g.id === "zarinpal" ? (
<label className="block text-sm"> <label className="block text-sm">
{t("merchantId")} {t("merchantId")}
@@ -779,14 +857,13 @@ export function AdminIntegrationsScreen() {
{t("kavenegarTitle")} {t("kavenegarTitle")}
</p> </p>
<Card className="rounded-xl border border-border/80 p-4 space-y-3"> <Card className="rounded-xl border border-border/80 p-4 space-y-3">
<label className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<input <Toggle
type="checkbox"
checked={kavenegar.isEnabled} checked={kavenegar.isEnabled}
onChange={(e) => setKavenegar((k) => ({ ...k, isEnabled: e.target.checked }))} onChange={(v) => setKavenegar((k) => ({ ...k, isEnabled: v }))}
/> />
{t("enabled")} <span>{t("enabled")}</span>
</label> </div>
<label className="block text-sm"> <label className="block text-sm">
{t("apiKey")} {t("apiKey")}
<Input <Input
@@ -815,14 +892,13 @@ export function AdminIntegrationsScreen() {
<Card className="rounded-xl border border-border/80 p-4 space-y-3"> <Card className="rounded-xl border border-border/80 p-4 space-y-3">
<p className="text-sm font-medium">{t("openAiTitle")}</p> <p className="text-sm font-medium">{t("openAiTitle")}</p>
<p className="text-xs text-muted-foreground">{t("openAiHint")}</p> <p className="text-xs text-muted-foreground">{t("openAiHint")}</p>
<label className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<input <Toggle
type="checkbox"
checked={openAi.isEnabled} checked={openAi.isEnabled}
onChange={(e) => setOpenAi((o) => ({ ...o, isEnabled: e.target.checked }))} onChange={(v) => setOpenAi((o) => ({ ...o, isEnabled: v }))}
/> />
{t("enabled")} <span>{t("enabled")}</span>
</label> </div>
<label className="block text-sm"> <label className="block text-sm">
{t("openAiApiKey")} {t("openAiApiKey")}
<Input <Input
@@ -836,35 +912,37 @@ export function AdminIntegrationsScreen() {
</label> </label>
<label className="block text-sm"> <label className="block text-sm">
{t("openAiModel")} {t("openAiModel")}
<Input <select
className="mt-1" className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
dir="ltr" dir="ltr"
value={openAi.model} value={openAi.model}
onChange={(e) => setOpenAi((o) => ({ ...o, model: e.target.value }))} onChange={(e) => setOpenAi((o) => ({ ...o, model: e.target.value }))}
/> >
<option value="gpt-4o-mini">gpt-4o-mini (fast, cheap)</option>
<option value="gpt-4o">gpt-4o (best quality)</option>
<option value="gpt-4-turbo">gpt-4-turbo</option>
<option value="gpt-4">gpt-4</option>
<option value="gpt-3.5-turbo">gpt-3.5-turbo (legacy)</option>
</select>
</label> </label>
<label className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<input <Toggle
type="checkbox"
checked={openAi.coffeeAdvisorEnabled} checked={openAi.coffeeAdvisorEnabled}
onChange={(e) => onChange={(v) => setOpenAi((o) => ({ ...o, coffeeAdvisorEnabled: v }))}
setOpenAi((o) => ({ ...o, coffeeAdvisorEnabled: e.target.checked }))
}
/> />
{t("coffeeAdvisorEnabled")} <span>{t("coffeeAdvisorEnabled")}</span>
</label> </div>
</Card> </Card>
<Card className="rounded-xl border border-border/80 p-4 space-y-3"> <Card className="rounded-xl border border-border/80 p-4 space-y-3">
<p className="text-sm font-medium">{t("meshyTitle")}</p> <p className="text-sm font-medium">{t("meshyTitle")}</p>
<p className="text-xs text-muted-foreground">{t("meshyHint")}</p> <p className="text-xs text-muted-foreground">{t("meshyHint")}</p>
<label className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<input <Toggle
type="checkbox"
checked={meshy.isEnabled} checked={meshy.isEnabled}
onChange={(e) => setMeshy((m) => ({ ...m, isEnabled: e.target.checked }))} onChange={(v) => setMeshy((m) => ({ ...m, isEnabled: v }))}
/> />
{t("enabled")} <span>{t("enabled")}</span>
</label> </div>
<label className="block text-sm"> <label className="block text-sm">
{t("meshyApiKey")} {t("meshyApiKey")}
<Input <Input
@@ -876,14 +954,13 @@ export function AdminIntegrationsScreen() {
onChange={(e) => setMeshy((m) => ({ ...m, apiKey: e.target.value }))} onChange={(e) => setMeshy((m) => ({ ...m, apiKey: e.target.value }))}
/> />
</label> </label>
<label className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<input <Toggle
type="checkbox"
checked={meshy.menu3dEnabled} checked={meshy.menu3dEnabled}
onChange={(e) => setMeshy((m) => ({ ...m, menu3dEnabled: e.target.checked }))} onChange={(v) => setMeshy((m) => ({ ...m, menu3dEnabled: v }))}
/> />
{t("menu3dEnabled")} <span>{t("menu3dEnabled")}</span>
</label> </div>
</Card> </Card>
</section> </section>
</div> </div>
@@ -44,11 +44,12 @@ export function AdminShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const user = useAdminAuthStore((s) => s.user); const user = useAdminAuthStore((s) => s.user);
const hasHydrated = useAdminAuthStore((s) => s._hasHydrated);
const clearAuth = useAdminAuthStore((s) => s.clearAuth); const clearAuth = useAdminAuthStore((s) => s.clearAuth);
useEffect(() => { useEffect(() => {
if (!user?.accessToken) router.replace("/admin/login"); if (hasHydrated && !user?.accessToken) router.replace("/admin/login");
}, [user, router]); }, [user, hasHydrated, router]);
return ( return (
<div className="flex min-h-screen bg-muted/30" dir="rtl"> <div className="flex min-h-screen bg-muted/30" dir="rtl">
@@ -2,7 +2,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "@/i18n/routing";
import { adminDelete, adminGet, adminPatch, adminPost, adminPut } from "@/lib/api/admin-client"; import { adminDelete, adminGet, adminPatch, adminPost, adminPut } from "@/lib/api/admin-client";
import type { import type {
AdminBlogPost, AdminBlogPost,
@@ -15,6 +16,8 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { notify } from "@/lib/notify"; import { notify } from "@/lib/notify";
import { cn } from "@/lib/utils";
import { Link } from "@/i18n/routing";
import { import {
CheckCircle2, CheckCircle2,
XCircle, XCircle,
@@ -66,13 +69,13 @@ export function AdminBlogListScreen() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-lg font-medium">{t("blogTitle")}</h1> <h1 className="text-lg font-medium">{t("blogTitle")}</h1>
<a <Link
href="website/blog/new" href="/admin/website/blog/new"
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90" className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
> >
<Plus className="size-4" /> <Plus className="size-4" />
{t("newPost")} {t("newPost")}
</a> </Link>
</div> </div>
{isLoading ? ( {isLoading ? (
@@ -113,7 +116,7 @@ export function AdminBlogListScreen() {
asChild asChild
className="h-8 px-2 text-xs" className="h-8 px-2 text-xs"
> >
<a href={`website/blog/${post.id}`}>{t("edit")}</a> <Link href={`/admin/website/blog/${post.id}`}>{t("edit")}</Link>
</Button> </Button>
<Button <Button
size="sm" size="sm"
@@ -147,6 +150,74 @@ export function AdminBlogListScreen() {
// ── Blog Post Editor ───────────────────────────────────────────────────────── // ── Blog Post Editor ─────────────────────────────────────────────────────────
// iOS-style toggle (mirrors the one in admin-screens.tsx).
function BlogToggle({
checked,
onChange,
}: {
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
checked ? "bg-[#0F6E56]" : "bg-muted-foreground/30"
)}
>
<span
className={cn(
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out",
checked ? "translate-x-5" : "translate-x-0"
)}
/>
</button>
);
}
// Module-level so it keeps a stable component identity across renders.
// (Previously defined inside the editor, which remounted every input on each
// keystroke and dropped focus after a single character.)
function BlogField({
label,
value,
onChange,
multiline,
dir,
}: {
label: string;
value: string;
onChange: (v: string) => void;
multiline?: boolean;
dir: "rtl" | "ltr";
}) {
return (
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>
{multiline ? (
<textarea
rows={8}
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full resize-y rounded-lg border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus:ring-2 focus:ring-primary/30"
dir={dir}
/>
) : (
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-9 text-sm"
dir={dir}
/>
)}
</div>
);
}
interface PostEditorProps { interface PostEditorProps {
postId?: string; // undefined = new post postId?: string; // undefined = new post
} }
@@ -154,6 +225,7 @@ interface PostEditorProps {
export function AdminBlogEditorScreen({ postId }: PostEditorProps) { export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
const t = useTranslations("admin.website"); const t = useTranslations("admin.website");
const qc = useQueryClient(); const qc = useQueryClient();
const router = useRouter();
const isNew = !postId; const isNew = !postId;
const { data: post } = useQuery({ const { data: post } = useQuery({
@@ -162,7 +234,7 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
enabled: !isNew, enabled: !isNew,
}); });
const [form, setForm] = useState({ const emptyForm = {
slug: "", slug: "",
titleFa: "", titleFa: "",
titleEn: "", titleEn: "",
@@ -173,85 +245,63 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
author: "تیم میزی", author: "تیم میزی",
categoryFa: "", categoryFa: "",
categoryEn: "", categoryEn: "",
}); isPublished: false,
};
// Sync fetched data into form once loaded const [form, setForm] = useState(emptyForm);
const initialised = !isNew && post; const [formReady, setFormReady] = useState(isNew);
const displayForm = initialised
? { // Populate form from server data the first time it arrives
slug: post!.slug, useEffect(() => {
titleFa: post!.titleFa, if (post && !formReady) {
titleEn: post!.titleEn, setForm({
excerptFa: post!.excerptFa, slug: post.slug,
excerptEn: post!.excerptEn, titleFa: post.titleFa,
contentFa: post!.contentFa, titleEn: post.titleEn,
contentEn: post!.contentEn, excerptFa: post.excerptFa,
author: post!.author, excerptEn: post.excerptEn,
categoryFa: post!.categoryFa, contentFa: post.contentFa,
categoryEn: post!.categoryEn, contentEn: post.contentEn,
author: post.author,
categoryFa: post.categoryFa,
categoryEn: post.categoryEn,
isPublished: post.isPublished,
});
setFormReady(true);
} }
: form; }, [post, formReady]);
const setField = (key: keyof typeof form) => (v: string) =>
setForm((f) => ({ ...f, [key]: v }));
const saveMut = useMutation({ const saveMut = useMutation({
mutationFn: (data: typeof form) => mutationFn: () =>
isNew isNew
? adminPost("/api/admin/website/posts", data) ? adminPost<{ id: string }>("/api/admin/website/posts", form)
: adminPut(`/api/admin/website/posts/${postId}`, data), : adminPut<{ id: string }>(`/api/admin/website/posts/${postId}`, form),
onSuccess: () => { onSuccess: (result) => {
qc.invalidateQueries({ queryKey: ["admin", "website", "blog"] }); qc.invalidateQueries({ queryKey: ["admin", "website", "blog"] });
notify.success(t("saved")); notify.success(t("saved"));
// After creating a new post, go to its edit page so the user can
// continue editing and won't accidentally hit Save again (which would
// fail on the unique slug constraint).
if (isNew) router.push(`/admin/website/blog/${result.id}`);
}, },
onError: () => notify.error(t("errorGeneric")), onError: () => notify.error(t("errorGeneric")),
}); });
const Field = ({ if (!isNew && !formReady) {
label, return <p className="text-sm text-muted-foreground">{t("loading")}</p>;
value,
onChange,
multiline,
}: {
label: string;
value: string;
onChange: (v: string) => void;
multiline?: boolean;
}) => (
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>
{multiline ? (
<textarea
rows={8}
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full resize-y rounded-lg border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus:ring-2 focus:ring-primary/30"
dir={label.toLowerCase().includes("fa") || label.includes("فارسی") ? "rtl" : "ltr"}
/>
) : (
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-9 text-sm"
dir={label.toLowerCase().includes("fa") || label.includes("فارسی") ? "rtl" : "ltr"}
/>
)}
</div>
);
const current = initialised ? post! : form;
const setField = (key: keyof typeof form) => (v: string) => {
if (initialised) {
// We'd need local state override — keep it simple for demo
} }
setForm((f) => ({ ...f, [key]: v }));
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="ghost" size="sm" asChild> <Button variant="ghost" size="sm" asChild>
<a href="." className="flex items-center gap-1.5"> <Link href="/admin/website/blog" className="flex items-center gap-1.5">
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
{t("backToBlog")} {t("backToBlog")}
</a> </Link>
</Button> </Button>
<h1 className="text-lg font-medium"> <h1 className="text-lg font-medium">
{isNew ? t("newPost") : t("editPost")} {isNew ? t("newPost") : t("editPost")}
@@ -261,80 +311,35 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
<Card className="rounded-xl border-border/80"> <Card className="rounded-xl border-border/80">
<CardContent className="space-y-4 pt-5"> <CardContent className="space-y-4 pt-5">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<Field <BlogField label={t("fieldSlug")} value={form.slug} onChange={setField("slug")} dir="ltr" />
label={t("fieldSlug")} <BlogField label={t("fieldAuthor")} value={form.author} onChange={setField("author")} dir="rtl" />
value={isNew ? form.slug : (post?.slug ?? "")}
onChange={setField("slug")}
/>
<Field
label={t("fieldAuthor")}
value={isNew ? form.author : (post?.author ?? "")}
onChange={setField("author")}
/>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<Field <BlogField label={t("fieldTitleFa")} value={form.titleFa} onChange={setField("titleFa")} dir="rtl" />
label={t("fieldTitleFa")} <BlogField label={t("fieldTitleEn")} value={form.titleEn} onChange={setField("titleEn")} dir="ltr" />
value={isNew ? form.titleFa : (post?.titleFa ?? "")}
onChange={setField("titleFa")}
/>
<Field
label={t("fieldTitleEn")}
value={isNew ? form.titleEn : (post?.titleEn ?? "")}
onChange={setField("titleEn")}
/>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<Field <BlogField label={t("fieldExcerptFa")} value={form.excerptFa} onChange={setField("excerptFa")} dir="rtl" />
label={t("fieldExcerptFa")} <BlogField label={t("fieldExcerptEn")} value={form.excerptEn} onChange={setField("excerptEn")} dir="ltr" />
value={isNew ? form.excerptFa : (post?.excerptFa ?? "")}
onChange={setField("excerptFa")}
/>
<Field
label={t("fieldExcerptEn")}
value={isNew ? form.excerptEn : (post?.excerptEn ?? "")}
onChange={setField("excerptEn")}
/>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<Field <BlogField label={t("fieldCategoryFa")} value={form.categoryFa} onChange={setField("categoryFa")} dir="rtl" />
label={t("fieldCategoryFa")} <BlogField label={t("fieldCategoryEn")} value={form.categoryEn} onChange={setField("categoryEn")} dir="ltr" />
value={isNew ? form.categoryFa : (post?.categoryFa ?? "")} </div>
onChange={setField("categoryFa")} <BlogField label={t("fieldContentFa")} value={form.contentFa} onChange={setField("contentFa")} dir="rtl" multiline />
/> <BlogField label={t("fieldContentEn")} value={form.contentEn} onChange={setField("contentEn")} dir="ltr" multiline />
<Field
label={t("fieldCategoryEn")} <div className="flex items-center justify-between rounded-lg border border-border/80 px-3 py-2">
value={isNew ? form.categoryEn : (post?.categoryEn ?? "")} <span className="text-sm font-medium">{t("fieldPublished")}</span>
onChange={setField("categoryEn")} <BlogToggle
checked={form.isPublished}
onChange={(v) => setForm((f) => ({ ...f, isPublished: v }))}
/> />
</div> </div>
<Field
label={t("fieldContentFa")}
value={isNew ? form.contentFa : (post?.contentFa ?? "")}
onChange={setField("contentFa")}
multiline
/>
<Field
label={t("fieldContentEn")}
value={isNew ? form.contentEn : (post?.contentEn ?? "")}
onChange={setField("contentEn")}
multiline
/>
<div className="flex justify-end pt-2"> <div className="flex justify-end pt-2">
<Button <Button
onClick={() => saveMut.mutate(isNew ? form : { onClick={() => saveMut.mutate()}
slug: post?.slug ?? form.slug,
titleFa: post?.titleFa ?? form.titleFa,
titleEn: post?.titleEn ?? form.titleEn,
excerptFa: post?.excerptFa ?? form.excerptFa,
excerptEn: post?.excerptEn ?? form.excerptEn,
contentFa: post?.contentFa ?? form.contentFa,
contentEn: post?.contentEn ?? form.contentEn,
author: post?.author ?? form.author,
categoryFa: post?.categoryFa ?? form.categoryFa,
categoryEn: post?.categoryEn ?? form.categoryEn,
})}
disabled={saveMut.isPending} disabled={saveMut.isPending}
className="min-w-[100px]" className="min-w-[100px]"
> >
+11 -1
View File
@@ -4,15 +4,20 @@ import type { AuthTokenResponse } from "@/lib/api/types";
interface AdminAuthState { interface AdminAuthState {
user: AuthTokenResponse | null; user: AuthTokenResponse | null;
/** True once Zustand has finished rehydrating from localStorage. */
_hasHydrated: boolean;
setAuth: (user: AuthTokenResponse) => void; setAuth: (user: AuthTokenResponse) => void;
clearAuth: () => void; clearAuth: () => void;
isAuthenticated: () => boolean; isAuthenticated: () => boolean;
_setHasHydrated: (v: boolean) => void;
} }
export const useAdminAuthStore = create<AdminAuthState>()( export const useAdminAuthStore = create<AdminAuthState>()(
persist( persist(
(set, get) => ({ (set, get) => ({
user: null, user: null,
_hasHydrated: false,
_setHasHydrated: (v) => set({ _hasHydrated: v }),
setAuth: (user) => { setAuth: (user) => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
localStorage.setItem("meezi_admin_access_token", user.accessToken); localStorage.setItem("meezi_admin_access_token", user.accessToken);
@@ -29,6 +34,11 @@ export const useAdminAuthStore = create<AdminAuthState>()(
}, },
isAuthenticated: () => !!get().user?.accessToken, isAuthenticated: () => !!get().user?.accessToken,
}), }),
{ name: "meezi_admin_auth" } {
name: "meezi_admin_auth",
onRehydrateStorage: () => (state) => {
state?._setHasHydrated(true);
},
}
) )
); );
File diff suppressed because one or more lines are too long
+87 -5
View File
@@ -45,7 +45,17 @@
"chooseCafe": "اختر المقهى", "chooseCafe": "اختر المقهى",
"chooseCafeSubtitle": "هذا الرقم لديه صلاحية على عدة مقاهٍ. اختر واحداً للمتابعة.", "chooseCafeSubtitle": "هذا الرقم لديه صلاحية على عدة مقاهٍ. اختر واحداً للمتابعة.",
"createNewCafe": "إنشاء مقهى جديد", "createNewCafe": "إنشاء مقهى جديد",
"createNewCafeHint": "هل تريد بدء مقهاك الخاص بهذا الرقم؟" "createNewCafeHint": "هل تريد بدء مقهاك الخاص بهذا الرقم؟",
"tabOtp": "رمز مؤقت",
"tabPassword": "كلمة المرور",
"username": "اسم المستخدم",
"usernamePlaceholder": "اسم المستخدم",
"password": "كلمة المرور",
"passwordPlaceholder": "كلمة المرور",
"invalidCredentials": "اسم المستخدم أو كلمة المرور غير صحيحة.",
"kojaSlug": "عنوان الملف الشخصي في كوجا",
"kojaSlugHint": "يجد الزوار مقهاكم على هذا العنوان",
"kojaSlugPlaceholder": "مثال: my-cafe"
}, },
"roles": { "roles": {
"owner": "المالك", "owner": "المالك",
@@ -56,8 +66,33 @@
"delivery": "عامل التوصيل", "delivery": "عامل التوصيل",
"unknown": "مستخدم" "unknown": "مستخدم"
}, },
"branchSwitcher": {
"title": "الفرع النشط",
"allBranches": "كل الفروع",
"selectBranch": "اختر الفرع"
},
"branchAccess": {
"title": "صلاحيات الفروع",
"staff": "الموظفون",
"noStaff": "لا يوجد موظفون بعد",
"selectStaff": "اختر موظفًا لإدارة الصلاحيات",
"ownerNote": "المالك لديه صلاحية الوصول لكل الفروع ولا يحتاج إلى أدوار خاصة بكل فرع.",
"noAssignments": "لم يتم تعيين أي دور للفروع بعد",
"loading": "جارٍ التحميل...",
"branch": "الفرع",
"role": "الدور",
"selectBranch": "اختر الفرع",
"add": "إضافة",
"remove": "حذف"
},
"access": {
"deniedTitle": "لا تملك صلاحية الوصول إلى هذه الصفحة",
"deniedBody": "دورك لا يملك صلاحية عرض هذه الصفحة. تواصل مع المدير أو المالك إذا كنت بحاجة إلى الوصول."
},
"nav": { "nav": {
"aria": "القائمة الرئيسية", "aria": "القائمة الرئيسية",
"collapseSidebar": "طي الشريط الجانبي",
"expandSidebar": "توسيع الشريط الجانبي",
"groups": { "groups": {
"operations": "العمليات اليومية", "operations": "العمليات اليومية",
"menuSales": "القائمة والمبيعات", "menuSales": "القائمة والمبيعات",
@@ -161,6 +196,8 @@
"cancelOrderConfirm": "غادر العميل دون دفع؟ سيُلغى الطلب ويُحرَّر الطاولة.", "cancelOrderConfirm": "غادر العميل دون دفع؟ سيُلغى الطلب ويُحرَّر الطاولة.",
"cancelOrderSuccess": "تم إلغاء الطلب", "cancelOrderSuccess": "تم إلغاء الطلب",
"cancelOrderError": "تعذّر إلغاء الطلب", "cancelOrderError": "تعذّر إلغاء الطلب",
"cancelReasonPlaceholder": "سبب الإلغاء (اختياري)",
"cancelOrderHasPayments": "استرجع المدفوعات المسجّلة أولاً ثم ألغِ الطلب",
"itemsCount": "صنف", "itemsCount": "صنف",
"applyCoupon": "تطبيق القسيمة", "applyCoupon": "تطبيق القسيمة",
"couponPlaceholder": "رمز القسيمة", "couponPlaceholder": "رمز القسيمة",
@@ -358,7 +395,9 @@
"tabs": { "tabs": {
"attendance": "الحضور", "attendance": "الحضور",
"leave": "الإجازة", "leave": "الإجازة",
"payroll": "الرواتب" "payroll": "الرواتب",
"access": "صلاحيات الفروع",
"credentials": "بيانات الدخول"
}, },
"myAttendance": "حضوري", "myAttendance": "حضوري",
"clockIn": "تسجيل دخول", "clockIn": "تسجيل دخول",
@@ -368,7 +407,22 @@
"paid": "مدفوع", "paid": "مدفوع",
"markPaid": "تسجيل الدفع", "markPaid": "تسجيل الدفع",
"employeeCount": "الموظفون", "employeeCount": "الموظفون",
"monthYear": "شهر الرواتب" "monthYear": "شهر الرواتب",
"credentials": {
"title": "بيانات دخول الموظفين",
"subtitle": "حدد اسم مستخدم وكلمة مرور لكل موظف حتى يتمكن من تسجيل الدخول دون رمز OTP.",
"selectEmployee": "اختر موظفاً أولاً",
"username": "اسم المستخدم",
"usernamePlaceholder": "مثال: ali_barista",
"password": "كلمة المرور (8 أحرف على الأقل)",
"passwordPlaceholder": "كلمة مرور جديدة",
"set": "حفظ بيانات الدخول",
"remove": "حذف بيانات الدخول",
"removeConfirm": "هل أنت متأكد؟ لن يتمكن الموظف من تسجيل الدخول بكلمة مرور بعد الآن.",
"saved": "تم حفظ بيانات الدخول.",
"removed": "تم حذف بيانات الدخول.",
"usernameTaken": "اسم المستخدم هذا مستخدم بالفعل."
}
}, },
"reviews": { "reviews": {
"title": "تقييمات العملاء", "title": "تقييمات العملاء",
@@ -945,7 +999,29 @@
"featureDiscover": "ملف الاكتشاف (ذكاء اصطناعي)", "featureDiscover": "ملف الاكتشاف (ذكاء اصطناعي)",
"featureOn": "مفعّل", "featureOn": "مفعّل",
"featureOff": "غير متاح — ترقية", "featureOff": "غير متاح — ترقية",
"featureMenu3dUpgrade": "القائمة 3D متاحة في برو وما فوق." "featureMenu3dUpgrade": "القائمة 3D متاحة في برو وما فوق.",
"checkout": {
"title": "الفاتورة والدفع",
"subtitle": "راجع طلبك وادفع",
"backToPlans": "العودة إلى الخطط",
"invalidPlan": "الخطة المحددة غير متاحة للشراء عبر الإنترنت.",
"invoiceLabel": "فاتورة مبدئية",
"invoiceNo": "رقم الفاتورة",
"issuedAt": "تاريخ الإصدار",
"billingPeriod": "مدة الاشتراك",
"monthsCount": "{count} شهر",
"description": "الوصف",
"qty": "الكمية",
"unitPrice": "سعر الوحدة",
"amount": "المبلغ",
"planLine": "اشتراك خطة {plan}",
"subtotal": "المجموع الفرعي",
"total": "المبلغ المستحق",
"secureNote": "تتم المعالجة عبر بوابة دفع بنكية آمنة.",
"payTotal": "ادفع {total}",
"redirecting": "جارٍ التحويل إلى البوابة...",
"paymentFailed": "فشل الدفع. الرجاء المحاولة مرة أخرى."
}
}, },
"settings": { "settings": {
"title": "الإعدادات", "title": "الإعدادات",
@@ -1074,7 +1150,13 @@
"uploadLogo": "رفع الشعار", "uploadLogo": "رفع الشعار",
"uploadCover": "رفع الغلاف", "uploadCover": "رفع الغلاف",
"saved": "تم حفظ الملف.", "saved": "تم حفظ الملف.",
"reloginHint": "تم تحديث الخطة؛ سجّل الخروج والدخول إن لزم." "reloginHint": "تم تحديث الخطة؛ سجّل الخروج والدخول إن لزم.",
"slug": "عنوان ملف كوجا",
"slugHint": "صفحة مقهاكم على كوجا — أحرف صغيرة وأرقام وشرطات فقط",
"slugPlaceholder": "my-cafe",
"slugTaken": "هذا العنوان مأخوذ. الرجاء اختيار عنوان آخر.",
"slugInvalid": "عنوان غير صالح. استخدم الأحرف الصغيرة والأرقام والشرطات فقط.",
"kojaUrl": "رابط كوجا"
}, },
"taraz": "تاراز (الضرائب)", "taraz": "تاراز (الضرائب)",
"tarazHint": "إرسال فواتير الأمس إلى تاراز (وضع تجريبي).", "tarazHint": "إرسال فواتير الأمس إلى تاراز (وضع تجريبي).",

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