admin felület fejlesztése garázs, előfizeztési csomagok
This commit is contained in:
586
.roo/history.md
586
.roo/history.md
@@ -1,428 +1,240 @@
|
|||||||
# Service Finder Fejlesztési Történet
|
1 | # Service Finder Fejlesztési Történet
|
||||||
|
2 |
|
||||||
|
3 | ## 2026-06-21 - P0 Deep Audit: Database Consistency & Zombie API Hunt
|
||||||
|
4 |
|
||||||
|
5 | ### 🎯 Cél
|
||||||
|
6 | Teljes körű adatbázis konzisztencia ellenőrzés és zombie API végpontok felderítése a `vehicle`, `finance`, `fleet_finance` sémákban, mielőtt a frontend fejlesztés elkezdődik.
|
||||||
|
7 |
|
||||||
|
8 | ### 🔧 Eredmények
|
||||||
|
9 |
|
||||||
|
10 | **1. ADATBÁZIS TISZTASÁG:** ✅ PASS
|
||||||
|
11 | - API modul import: ✅ Sikeres (2 route: GET, POST)
|
||||||
|
12 | - Sync Engine: ✅ 1210 OK, 0 Fixed, 0 Extra
|
||||||
|
13 | - E2E test: ⚠️ Pre-existing conftest hiba (verification token timeout - nem kapcsolódó)
|
||||||
|
14 |
|
||||||
|
15 | ## 2026-06-23 - Cost Entry Wizard (CostEntryWizard.vue) - 4-lépéses Számla Űrlap
|
||||||
|
16 |
|
||||||
|
17 | ### 🎯 Cél
|
||||||
|
18 | 4-lépéses stepper wizard építése részletes számla/invoice költségek rögzítésére a CostsActionsCard-ból indíthatóan.
|
||||||
|
19 |
|
||||||
|
20 | ### 🔧 Módosított fájlok
|
||||||
|
21 |
|
||||||
|
22 | ## 2026-06-24 - P0 Fix & Enhance: Permissions Page Data Flow, Editing & i18n
|
||||||
|
23 |
|
||||||
|
24 | ### 🎯 Cél
|
||||||
|
25 | Permissions oldal adatáramlás javítása, szerkesztés/mentés funkció bevezetése, és @nuxtjs/i18n telepítése/konfigurálása.
|
||||||
|
26 |
|
||||||
|
27 | ### 🔧 Módosított fájlok
|
||||||
|
28 | - `frontend_admin/pages/permissions/index.vue` - Teljes átírás: client-side data fetch onMounted-ben, szerkeszthető toggle-ok (modifiedPermissions Map), PATCH mentés, toast notification, $t() hívások
|
||||||
|
29 | - `frontend_admin/nuxt.config.ts` - @nuxtjs/i18n modul hozzáadása, locales konfiguráció (hu/en)
|
||||||
|
30 | - `frontend_admin/i18n/locales/hu.json` - Magyar fordítási fájl (permissions oldal összes szövege)
|
||||||
|
31 | - `frontend_admin/i18n/locales/en.json` - Angol fordítási fájl
|
||||||
|
32 |
|
||||||
|
33 | ### ✅ Eredmények
|
||||||
|
34 | - Build: ✅ Sikeres (hu-DbjfOUfH.mjs, en-DnJ23n0t.mjs locale chunk-ok)
|
||||||
|
35 | - i18n modul: @nuxtjs/i18n v10.4.0 telepítve, no_prefix stratégiával
|
||||||
|
36 | - Szerkesztés: modified/original Map-ek, handleToggle, savePermissions (PATCH /admin/permissions/override/{org_id})
|
||||||
|
37 | - Adatmentés: success/error toast notification auto-clear-el
|
||||||
|
38 |
|
||||||
|
39 | ## 2026-06-24 - P0 Execution: Wire Packages UI to Real Database
|
||||||
|
40 |
|
||||||
|
41 | ### 🎯 Cél
|
||||||
|
42 | A frontend_admin Packages oldal (packages/index.vue) mock adatok helyett valós API adatbázisból töltse a SubscriptionTier csomagokat. "Új Csomag Létrehozása" gomb és modal hozzáadása.
|
||||||
|
43 |
|
||||||
|
44 | ### 🔧 Módosított fájlok
|
||||||
|
45 | - `backend/app/schemas/subscription.py` - `tier_level` és `feature_capabilities` mezők hozzáadva a SubscriptionTierResponse, SubscriptionTierCreate, SubscriptionTierUpdate modellekhez
|
||||||
|
46 | - `backend/app/api/v1/endpoints/admin_packages.py` - create/update végpontok frissítve az új mezők kezelésére
|
||||||
|
47 | - `frontend_admin/pages/packages/index.vue` - Teljes átírás: mock adatok eltávolítva, fetchPackages() API hívás, create/edit/delete modal-ok, loading/error/empty állapotok, i18n támogatás
|
||||||
|
48 | - `frontend_admin/locales/hu.json` - packages i18n kulcsok (magyar)
|
||||||
|
49 | - `frontend_admin/locales/en.json` - packages i18n kulcsok (angol)
|
||||||
|
50 |
|
||||||
|
51 | ### ✅ Eredmények
|
||||||
|
52 | - Backend API: GET /api/v1/admin/packages → 200 OK, 10 tier (tier_level, feature_capabilities mezőkkel)
|
||||||
|
53 | - Frontend: mock adatok eltávolítva, valós API hívás onMounted-ben, loading/error/empty állapotok
|
||||||
|
54 | - CRUD: Létrehozás (POST), Szerkesztés (PATCH), Törlés (DELETE soft-delete) teljes körűen implementálva
|
||||||
|
55 | - i18n: Minden UI szöveg magyar és angol nyelven elérhető
|
||||||
|
56 |
|
||||||
|
57 | ## 2026-06-24 - P0 CRITICAL FIX: Deep Merge JSONB (Multi-zone Pricing Data Loss)
|
||||||
|
58 |
|
||||||
|
59 | ### 🎯 Cél
|
||||||
|
60 | Javítani a `PATCH /api/v1/admin/packages/{tier_id}` végpontot, hogy a `rules` JSONB oszlop frissítésekor a meglévő nested kulcsok (pl. `pricing_zones.HU`, `pricing_zones.US`, `lifecycle.available_until`) ne vesszenek el, ha a frontend csak egy részhalmazt küld (pl. csak `pricing_zones.DEFAULT`).
|
||||||
|
61 |
|
||||||
|
62 | ### 🔧 Módosított fájlok
|
||||||
|
63 | - `backend/app/api/v1/endpoints/admin_packages.py` — `deep_merge_dict()` függvény hozzáadva, `update_package` endpoint átírva deep merge szemantikára
|
||||||
|
64 | - `tests/active/test_deep_merge_fix.py` — Verifikációs teszt (unit + API PATCH)
|
||||||
|
65 |
|
||||||
|
66 | ### ✅ Eredmények
|
||||||
|
67 | - **Unit test:** ✅ PASS — `deep_merge_dict()` helyesen működik: nested dict-ek rekurzív merge, None értékek megőrzése, üres override megőrzése
|
||||||
|
68 | - **API test:** ✅ PASS — `private_pro_v1` (id=14) csomagon: HU, US, DEFAULT zónák mind megmaradtak a PATCH után, DEFAULT ára frissült, lifecycle.available_until megőrződött
|
||||||
|
69 | - **Sync Engine:** ✅ 1220 OK, 0 Fixed — adatbázis séma konzisztens
|
||||||
|
70 | - **Root cause:** A frontend `packages/index.vue` `rulesPayload` függvénye csak `pricing_zones.DEFAULT`-ot állítja elő, a backend pedig wholesale replace-t végzett → multi-zone adatvesztés
|
||||||
|
|
||||||
## 2026-06-21 - P0 Deep Audit: Database Consistency & Zombie API Hunt
|
## 2026-06-24 - P0 System Architecture: Global Region Config & Smart Pricing
|
||||||
|
|
||||||
### 🎯 Cél
|
### 🎯 Cél
|
||||||
Teljes körű adatbázis konzisztencia ellenőrzés és zombie API végpontok felderítése a `vehicle`, `finance`, `fleet_finance` sémákban, mielőtt a frontend fejlesztés elkezdődik.
|
Globális régió konfigurációs rendszer (RegionConfig modell + API) megvalósítása, frontend region store és formatter composable létrehozása, Smart Pricing Calculator beépítése a package edit modalba, valamint nyelvi preferencia perzisztálása a layoutban.
|
||||||
|
|
||||||
### 🔧 Eredmények
|
|
||||||
|
|
||||||
**1. ADATBÁZIS TISZTASÁG:** ✅ PASS
|
|
||||||
- API modul import: ✅ Sikeres (2 route: GET, POST)
|
|
||||||
- Sync Engine: ✅ 1210 OK, 0 Fixed, 0 Extra
|
|
||||||
- E2E test: ⚠️ Pre-existing conftest hiba (verification token timeout - nem kapcsolódó)
|
|
||||||
|
|
||||||
## 2026-06-23 - Cost Entry Wizard (CostEntryWizard.vue) - 4-lépéses Számla Űrlap
|
|
||||||
|
|
||||||
### 🎯 Cél
|
|
||||||
4-lépéses stepper wizard építése részletes számla/invoice költségek rögzítésére a CostsActionsCard-ból indíthatóan.
|
|
||||||
|
|
||||||
### 🔧 Módosított fájlok
|
### 🔧 Módosított fájlok
|
||||||
- **`frontend/src/components/cost/CostEntryWizard.vue`** (ÚJ) - 4-step stepper wizard komponens
|
- `backend/app/models/core_logic.py` — `RegionConfig` osztály hozzáadva (`system.region_config` séma, mezők: country_code, name, currency, default_vat_rate, locale_code, timezone, is_active)
|
||||||
- **`frontend/src/components/dashboard/CostsActionsCard.vue`** - "🧾 Részletes Számla" gomb hozzáadva, CostEntryWizard import és modal state
|
- `backend/app/models/__init__.py` — `RegionConfig` import hozzáadva
|
||||||
- **`frontend/src/i18n/en.ts`** - costWizard i18n szekció hozzáadva
|
- `backend/app/api/v1/endpoints/regions.py` — **LÉTREHOZVA**: `GET /system/regions` publikus végpont
|
||||||
- **`frontend/src/i18n/hu.ts`** - costWizard i18n szekció hozzáadva
|
- `backend/app/api/v1/api.py` — regions router regisztrálva `/system` prefix-szel
|
||||||
|
- `backend/scripts/seed_regions.py` — **LÉTREHOZVA**: 3 régió seedelése (HU, GB, DEFAULT)
|
||||||
|
- `backend/app/api/v1/endpoints/auth.py` — `PATCH /auth/me/language` végpont hozzáadva nyelvi preferencia perzisztáláshoz
|
||||||
|
- `frontend_admin/stores/region.ts` — **LÉTREHOZVA**: Pinia store régiók lekérésére, aktív régió getterekkel (currency, locale, VAT, timezone)
|
||||||
|
- `frontend_admin/composables/useFormatter.ts` — **LÉTREHOZVA**: Lokalizált formatter (number, currency, date, datetime, percent, VAT számítás)
|
||||||
|
- `frontend_admin/pages/packages/index.vue` — Tabbed modal refaktor (Basic Info, Pricing, Features), per-régió árazási grid, Smart Pricing Calculator net/VAT/gross kijelzéssel
|
||||||
|
- `frontend_admin/layouts/default.vue` — Nyelvváltó perzisztálás PATCH hívással, region store inicializálás onMounted-ban
|
||||||
|
|
||||||
### 🧩 CostEntryWizard lépések
|
### ✅ Eredmények
|
||||||
1. **Alapadatok**: Jármű (read-only), kategória cascade (fő+alkategória), dátum
|
- **Sync Engine:** ✅ 1220 OK, 1 Fixed, 0 Shadow — `system.region_config` tábla létrejött
|
||||||
2. **Pénzügyek**: Nettó összeg, ÁFA kulcs (0/5/10/20/27%), Bruttó összeg (GROSS-FIRST), Pénznem
|
- **GET /api/v1/system/regions:** ✅ 200 — 3 régió (DEFAULT/EUR/0%, GB/GBP/20%, HU/HUF/27%)
|
||||||
3. **Admin & Szállító**: Dinamikus vendor autocomplete (GET /organizations?org_type=SERVICE_PROVIDER) szabad szöveges fallbackkel, számlaszám, számla dátuma, fizetési határidő
|
- **PATCH /auth/me/language:** ✅ 200 — `preferred_language` perzisztálódik és visszaolvasható
|
||||||
4. **Fájlok**: Drag & drop fájl feltöltő UI (csak UI szintű file picker)
|
- **Frontend build (admin):** ✅ Sikeres — `region-*.mjs` chunk generálódott
|
||||||
|
- **Adatbázis:** `country_code` VARCHAR(5)→VARCHAR(10) manuális ALTER a seed előtt
|
||||||
|
|
||||||
### 🔌 API Integráció
|
## 2026-06-25 - P0 CRITICAL SECURITY & UI FIX - RBAC Middleware & Mock Data Removal
|
||||||
- POST `/expenses/` a `costStore.addExpense()`-on keresztül AssetCostBase payload-dal
|
|
||||||
- Payload tartalmazza: amount_gross (GROSS-FIRST), category_id, currency, date, data JSONB (net_amount, vat_rate, invoice_number, invoice_date, payment_deadline, vendor_id, vendor_name, file_names)
|
|
||||||
|
|
||||||
### ✅ Verifikáció
|
|
||||||
- Vite build: ✅ Sikeres (253 modules, 6.94s)
|
|
||||||
- Gitea kártya: #277
|
|
||||||
|
|
||||||
## 2026-06-23 - Dashboard Cost Card Navigation Fix & Dedicated Costs Page
|
|
||||||
|
|
||||||
### 🎯 Cél
|
### 🎯 Cél
|
||||||
A dashboard költség kártya üres területére kattintva és a "További költségek" gombbal is a költségek oldalra navigálás. Dedikált CostsView oldal létrehozása vissza gombbal és garázs háttérrel.
|
Szigorú frontend Route-Based Access Control (RBAC) bevezetése az admin middleware-ben, és a hardcoded mock email (`admin@servicefinder.hu`) eltávolítása a Topbar-ból.
|
||||||
|
|
||||||
### 🔧 Módosított fájlok
|
### 🔧 Módosított fájlok
|
||||||
- **`frontend/src/views/costs/CostsView.vue`** (ÚJ) - Teljes költség oldal garázs háttérrel, vissza gombbal, költség táblázattal és paginációval
|
- `frontend_admin/middleware/auth.ts` — Teljes átírás: token cookie ellenőrzés után `authStore.fetchUser()` hívás, majd `authStore.user.role` ellenőrzés az allowed staff role-ok listája (`SUPERADMIN`, `ADMIN`, `MODERATOR`, `SALES_REP`, `SERVICE_MGR`) alapján. Ha a role nem staff szintű → `authStore.logout()` + `navigateTo('/login')`.
|
||||||
- **`frontend/src/router/index.ts`** - `/dashboard/costs` route hozzáadva a CostsView-hoz
|
- `frontend_admin/layouts/default.vue` — `userEmail` computed fallback `'admin@servicefinder.hu'` → `'Ismeretlen Felhasználó'`, `userInitials` fallback `'AD'` → `'??'`, dropdown role label `'Administrator'` → dinamikus `authStore.userRole` alapú.
|
||||||
- **`frontend/src/components/dashboard/CostsActionsCard.vue`** - Header bar kattinthatóvá téve + "További költségek" gomb átirányítva a `/dashboard/costs` oldalra
|
|
||||||
- **`frontend/src/layouts/PrivateLayout.vue`** - Hamburger menü "Költségek" navigáció `/dashboard/costs`-ra módosítva
|
|
||||||
- **`frontend/src/i18n/hu.ts`** - `costs.backToDashboard` kulcs hozzáadva
|
|
||||||
- **`frontend/src/i18n/en.ts`** - `costs.backToDashboard` kulcs hozzáadva
|
|
||||||
|
|
||||||
### ✅ Verifikáció
|
### ✅ Eredmények
|
||||||
- Vite build: ✅ Sikeres (CostsView-DaFsqPnD.js chunk generálva)
|
- **RBAC Middleware:** ✅ Standard USER role esetén a middleware kilépteti a felhasználót és a `/login` oldalra irányítja
|
||||||
|
- **Mock adatok eltávolítva:** ✅ `admin@servicefinder.hu` sehol sem szerepel hardcoded értékként
|
||||||
|
- **Auth Store:** ✅ Tiszta, nincs benne mock/default user adat
|
||||||
|
- **Minden admin oldal** (`index.vue`, `garages/index.vue`, `packages/index.vue`, `permissions/index.vue`) használja a `definePageMeta({ middleware: 'auth' })` direktívát
|
||||||
|
|
||||||
## 2026-06-23 - Hotfix: Backend Expenses GET / endpoint & AssetCost.organization FK fix
|
## 2026-06-25 - P0 Data Architecture: Populate Full EU Region Registry
|
||||||
|
|
||||||
### 🎯 Cél
|
### 🎯 Cél
|
||||||
Két kritikus backend hiba javítása:
|
A `system.region_config` tábla feltöltése mind a 27 EU tagállammal, plusz UK, Svájc és DEFAULT/Eurozone fallback rekorddal. Korábban csak 3 dummy/seed régió (HU, GB, DEFAULT) volt az adatbázisban.
|
||||||
1. A `GET /api/v1/expenses/` hívás 404-et adott (nem létezett `GET /` végpont), amit a reverse proxy 307-es HTTPS→HTTP átirányítással válaszolt, ami Mixed Content blokkolást okozott a böngészőben.
|
|
||||||
2. A backend indításkor `InvalidRequestError`-ral összeomlott az `AssetCost.organization` relationship hibás konfigurációja miatt (két FK ugyanarra a táblára, de a `foreign_keys` paraméter hiányzott).
|
|
||||||
|
|
||||||
### 🔧 Módosított fájlok
|
### 🔧 Módosított fájlok
|
||||||
- **`backend/app/api/v1/endpoints/expenses.py`** - `GET /` végpont hozzáadva: list_all_expenses() - szervezet összes költségének listázása paginációval, Asset, CostCategory, Organization JOIN-okkal
|
- `backend/scripts/seed_regions.py` — Teljes átírás: 30 régiós adatbázis (27 EU + GB + CH + DEFAULT), UPSERT logika (meglévő rekordok frissítése, új rekordok beszúrása), pontos VAT kulcsok, devizanemek, locale kódok és időzónák minden országra.
|
||||||
- **`backend/app/models/fleet_finance/models.py`** - `AssetCost.organization` relationship javítva: `foreign_keys=[organization_id]` paraméter hozzáadva a kétértelmű FK kapcsolat feloldásához
|
|
||||||
|
|
||||||
### 🔧 Eredmények
|
### ✅ Eredmények
|
||||||
- Backend konténer: ✅ Tisztán indul, nincs SQLAlchemy hiba
|
- **Seed futtatás:** ✅ 27 új régió létrehozva, 3 meglévő frissítve (HU, GB, DEFAULT)
|
||||||
- Login: ✅ Működik (nem omlik össze)
|
- **API verifikáció:** ✅ `GET /api/v1/system/regions` → 30 régió visszaadva, minden adat helyes
|
||||||
- `GET /api/v1/expenses/`: ✅ Végpont elérhető, paginált költség listát ad vissza
|
- **UPSERT idempotencia:** ✅ A script többször is futtatható, frissíti a meglévő rekordokat ahelyett, hogy duplikációt okozna
|
||||||
|
|
||||||
## 2026-06-23 - Hotfix #2: Expenses API 307 redirect / Mixed Content - trailing slash fix
|
## 2026-06-25 - Garages CRM Wiring & Custom B2B Deals UI
|
||||||
|
|
||||||
### 🎯 Cél
|
### 🎯 Cél
|
||||||
A `GET /api/v1/expenses` hívás 307-es HTTPS→HTTP átirányítást kapott, mert a backend route `@router.get("/")` (perjellel) volt definiálva, de a frontend `/expenses` (perjel nélkül) hívta. A Starlette automatikus 307 redirect-je a külső openresty proxy miatt `http://` címmé alakult → Mixed Content blokk.
|
Garázsok (Organizations) CRM felületének teljes körű implementálása: backend API végpontok, frontend adattábla mock adatok nélkül, előfizetéskezelő modál egyedi lejárati dátumokkal (B2B deal-ekhez).
|
||||||
|
|
||||||
### 🔧 Módosított fájlok
|
### 🔧 Módosított/Létrehozott fájlok
|
||||||
- **`backend/app/api/v1/endpoints/expenses.py`** - `@router.get("/")` → `@router.get("")` (perjel eltávolítva a route-ból)
|
|
||||||
|
|
||||||
### 🔧 Eredmények
|
**1. `backend/app/api/v1/endpoints/admin_organizations.py`** (LÉTREHOZVA)
|
||||||
- Backend konténer: ✅ Tisztán indul
|
- `GET /admin/organizations` — Garázsok listázása előfizetési adatokkal, taglétszámmal, kereséssel, szűréssel, lapozással
|
||||||
- `GET /api/v1/expenses` most direkt egyezik a route-tal → nincs 307 redirect → nincs Mixed Content
|
- `PATCH /admin/organizations/{org_id}/subscription` — Előfizetés módosítása tier_id és opcionális expires_at megadásával
|
||||||
|
- Tömeges subscription és member_count lekérdezés (N+1 elkerülése)
|
||||||
|
|
||||||
## 2026-06-23 - Hotfix #3: Expenses API 500 Internal Server Error - AssetCost.cost_date → AssetCost.date
|
**2. `backend/app/api/v1/api.py`** (MÓDOSÍTVA)
|
||||||
|
- `admin_organizations` router regisztrációja
|
||||||
|
|
||||||
|
**3. `frontend_admin/pages/garages/index.vue`** (ÁTÍRVA)
|
||||||
|
- Mock adatok eltávolítva, valós API hívások (`$fetch`)
|
||||||
|
- Statisztikai kártyák (összes, aktív, premium, enterprise)
|
||||||
|
- Keresés és szűrés (tier alapján)
|
||||||
|
- Adattábla: ID, cégnév, státusz, csomag, lejárat, műveletek
|
||||||
|
- Előfizetéskezelő modál: tier dropdown + egyéni datetime-local input
|
||||||
|
- Mentés utáni automatikus refresh
|
||||||
|
|
||||||
|
**4. `frontend_admin/i18n/locales/hu.json`** (MÓDOSÍTVA)
|
||||||
|
- Garázs CRM-hez kapcsolódó összes magyar fordítás hozzáadva
|
||||||
|
|
||||||
|
**5. `frontend_admin/i18n/locales/en.json`** (MÓDOSÍTVA)
|
||||||
|
- Garázs CRM-hez kapcsolódó összes angol fordítás hozzáadva
|
||||||
|
|
||||||
|
### ✅ Teszt Eredmények
|
||||||
|
- Sync Engine: ✅ 1230 OK, 0 Fixed, 0 Extra (tökéletes szinkron)
|
||||||
|
- `GET /admin/organizations?limit=5`: ✅ 200 — 31 garázs, 5 visszaadva
|
||||||
|
- `GET /admin/packages?limit=5`: ✅ 200 — 5 tier elérhető
|
||||||
|
- `PATCH /admin/organizations/2406/subscription`: ✅ 200 — Celebro Kft. → corp_premium_v1, lejárat: 2027-06-25
|
||||||
|
- Verifikáció: ✅ Tier és lejárati dátum helyesen perzisztálva
|
||||||
|
|
||||||
|
## 2026-06-25 - P0 FIX & ENHANCE: Auth Token Missing & Duplicate Package Button
|
||||||
|
|
||||||
### 🎯 Cél
|
### 🎯 Cél
|
||||||
A `GET /api/v1/expenses` végpont 500-as hibát dobott: `AttributeError: type object 'AssetCost' has no attribute 'cost_date'`. Az `AssetCost` modellben a mező neve `date` (110. sor), de a kód `cost_date`-t használt.
|
1. Garázsok oldal "Not authenticated" hiba javítása — hiányzó Authorization header pótlása
|
||||||
|
2. "Másolás" (Duplicate) gomb implementálása a csomagok oldalon
|
||||||
### 🔧 Módosított fájlok
|
|
||||||
- **`backend/app/api/v1/endpoints/expenses.py`** - 2 helyen javítva:
|
|
||||||
- 157. sor: `AssetCost.cost_date` → `AssetCost.date`
|
|
||||||
- 193. sor: `cost.cost_date.isoformat()` → `cost.date.isoformat()`
|
|
||||||
|
|
||||||
### 🔧 Eredmények
|
|
||||||
- Backend konténer: ✅ Tisztán indul
|
|
||||||
- `GET /api/v1/expenses`: ✅ Várhatóan 200 OK (nem 500)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-06-23: Dashboard üres AdPlacementWidget elrejtése
|
|
||||||
|
|
||||||
### 🎯 Cél
|
|
||||||
A felhasználó kérésére: a dashboardon a "PRO" feliratú `SubscriptionStatusWidget` mellett egy üres csempe volt látható (`AdPlacementWidget`), mert nincs hirdetés beállítva a rendszerben. A kérés az volt, hogy rejtsük el ezt az üres csempét.
|
|
||||||
|
|
||||||
### 🔧 Módosított fájlok
|
|
||||||
- **`frontend/src/components/dashboard/AdPlacementWidget.vue`** - `defineEmits` hozzáadva: `ads-loaded` esemény, amely jelzi a szülőnek, hogy van-e hirdetés (`hasAds: boolean`). Az esemény a `fetchAds` siker és hiba ágában is meghívásra kerül.
|
|
||||||
- **`frontend/src/views/DashboardView.vue`** - `adWidgetHasAds` ref hozzáadva (alapértelmezett: `false`). A hirdetési csempe wrapper `<div>`-je `v-if="adWidgetHasAds"` feltételt kapott, így csak akkor jelenik meg, ha az API ténylegesen adott vissza hirdetéseket. Az `@ads-loaded` esemény frissíti a ref értékét.
|
|
||||||
|
|
||||||
### ✅ Verifikáció
|
|
||||||
- Frontend build: ✅ Sikeres (Vite build, 0 hiba)
|
|
||||||
|
|
||||||
## 2026-06-23 - P0 Fix: cost_date vs date mezőnév mismatch + CostsView.vue upgrade
|
|
||||||
|
|
||||||
### 🎯 Cél
|
|
||||||
Két produkciós hiba javítása a cost rögzítésben:
|
|
||||||
1. **P0 - 500 Internal Server Error** POST `/api/v1/expenses/` végponton: négyrétegű mezőnév-eltérés (`cost_date` vs `date`) a frontend, Pydantic séma, endpoint és SQLAlchemy model között.
|
|
||||||
2. **P1 - `/dashboard/costs` oldal** nem mutatott semmilyen információt és nem volt költség hozzáadási lehetőség.
|
|
||||||
|
|
||||||
### 🔧 Módosított fájlok
|
### 🔧 Módosított fájlok
|
||||||
|
|
||||||
**Fix 1a - Backend endpoint** (`backend/app/api/v1/endpoints/expenses.py:464`):
|
**1. `frontend_admin/pages/garages/index.vue`** (MÓDOSÍTVA)
|
||||||
- `cost_date=expense.cost_date` → `date=expense.cost_date` (SQLAlchemy model mezőneve `date`)
|
- `getAuthHeaders()` helper hozzáadva — `access_token` cookie-ból olvassa a tokent
|
||||||
|
- `fetchGarages()` és `fetchTiers()` hívásokhoz `headers: getAuthHeaders()` hozzáadva
|
||||||
|
- `saveSubscription()` PATCH híváshoz `Authorization` header hozzáadva
|
||||||
|
|
||||||
**Fix 1b - Backend Pydantic schema** (`backend/app/schemas/asset_cost.py:37`):
|
**2. `frontend_admin/pages/packages/index.vue`** (MÓDOSÍTVA)
|
||||||
- `validation_alias="date"` hozzáadva a `cost_date` mezőhöz, hogy mind a `date`, mind a `cost_date` kulcsot elfogadja
|
- "Másolás" (Duplicate) gomb hozzáadva a kártya akcióihoz (Szerkesztés mellett, zöld színnel)
|
||||||
|
- `duplicatePackage(pkg)` függvény implementálva:
|
||||||
|
- Megnyitja a Create Modalt a kiválasztott csomag adataival előtöltve
|
||||||
|
- `id` mező törölve (új rekordként mentődik)
|
||||||
|
- Névhez `_copy` utótag fűzve, display_name-hez ` (másolat)` fűzve
|
||||||
|
- `is_default_fallback` false-ra állítva (nem duplikálható a visszaesési státusz)
|
||||||
|
- Árazási zónák, allowance-ok, feature_capabilities teljes körű másolása
|
||||||
|
|
||||||
**Fix 2 - Frontend CostsView.vue** (`frontend/src/views/costs/CostsView.vue`):
|
**3. `frontend_admin/i18n/locales/en.json`** (MÓDOSÍTVA)
|
||||||
- Vezérlősáv hozzáadva járműszűrő dropdown-nal
|
- `packages.duplicate: "Duplicate"` kulcs hozzáadva
|
||||||
- "➕ Új Költség" gomb hozzáadva
|
|
||||||
- CostEntryWizard integráció (modal formában)
|
|
||||||
- Mezőnevek javítva: `cost_type` → `category_name`, `amount` → `amount_gross`, `vehicle_label` → `vehicle_name`
|
|
||||||
- `useVehicleStore` importálva a járművek listájához
|
|
||||||
|
|
||||||
### ✅ Verifikáció
|
**4. `frontend_admin/i18n/locales/hu.json`** (MÓDOSÍTVA)
|
||||||
- Python szintaxis: ✅ asset_cost.py OK, expenses.py OK
|
- `packages.duplicate: "Másolás"` kulcs hozzáadva
|
||||||
- Backend indítás: ✅ Uvicorn fut, nincs hiba
|
|
||||||
- Frontend build: ✅ Sikeres (Vite build 6.77s, 0 hiba)
|
|
||||||
- Gitea kártyák: #278 (P0) és #279 (P1) lezárva
|
|
||||||
|
|
||||||
## 2026-06-23 - #280 Vizsgálat: Miért nem jelennek meg költségek?
|
### ✅ Domain Audit
|
||||||
|
- `backend/app/api/v1/endpoints/admin_organizations.py` végpont kizárólag core táblákat érint:
|
||||||
|
- `Organization`, `OrganizationMember`, `OrganizationSubscription`, `SubscriptionTier`, `User`
|
||||||
|
- Nincs scraped provider tábla lekérdezés — domain határok helyesek
|
||||||
|
|
||||||
|
## 2026-06-25: P0 Audit - Domain Conflation in Expense/Cost Service Provider Flow
|
||||||
|
|
||||||
|
**Gitea Card:** #291
|
||||||
|
**Scope:** Backend
|
||||||
|
**Type:** Bug / Domain Conflation
|
||||||
|
|
||||||
|
### Findings
|
||||||
|
Identified **6 code locations** causing domain conflation when `quick_add_provider()` creates `Organization()` (→ `fleet.organizations`) instead of `ServiceProvider()` (→ `marketplace.service_providers`):
|
||||||
|
|
||||||
|
1. **PRIMARY (provider_service.py:507-550):** `quick_add_provider()` creates `Organization()` instead of `ServiceProvider()`
|
||||||
|
2. **SECONDARY (provider_service.py:561-570):** `ServiceProfile.organization_id` FK to incorrectly created fleet.organizations
|
||||||
|
3. **TERTIARY (provider_service.py:618-630):** Branch creation in fleet.branches for lightweight vendor
|
||||||
|
4. **QUATERNARY (provider_service.py:639-646):** OrganizationMember addition for creating user
|
||||||
|
5. **SYSTEMIC (fleet_finance/models.py:104-110):** `AssetCost.vendor_organization_id` FK references ONLY `fleet.organizations.id`
|
||||||
|
6. **STRUCTURAL (provider_service.py):** `search_providers()` UNION masks domain conflation
|
||||||
|
|
||||||
|
### Outcome
|
||||||
|
- Report: `/opt/docker/docs/p0_domain_conflation_cost_provider_audit.md` (in roo-helper container)
|
||||||
|
- **No code changes made.** Awaiting Architect decision on refactoring approach.
|
||||||
|
- Refactoring Options proposed: A (flag in fleet), B (split to marketplace), C (hybrid - recommended)
|
||||||
|
|
||||||
|
## 2026-06-25 - P0 Hybrid Vendor Refactor & Categorization (Option C)
|
||||||
|
|
||||||
### 🎯 Cél
|
### 🎯 Cél
|
||||||
Annak kivizsgálása, hogy az admin@profibot.hu teszt felhasználó miért nem lát költségeket.
|
Implementálni az Option C (Hybrid) architektúrát: `quick_add_provider()` mostantól `ServiceProvider`-t (`marketplace.service_providers`) hoz létre `Organization` helyett (`fleet.organizations`). A valódi garázsok (safe list: 13 ID) továbbra is Organization-ként maradnak.
|
||||||
|
|
||||||
### 🔍 Vizsgálat
|
|
||||||
- `fleet_finance.asset_costs` táblában összesen **1 rekord** van (org_id=43, asset=PTB162 Mazda 2, 18118 HUF, "Finanszírozás")
|
|
||||||
- admin@profibot.hu (user_id=2) tagja: org 1 (Test Company), 57, 58, 62, 67
|
|
||||||
- admin **NEM tagja** org 43-nak ("Accipe garage"), ahova az egyetlen költség tartozik
|
|
||||||
- admin szervezeteiben (1, 57, 58, 62, 67) összesen **0 db költség** van
|
|
||||||
|
|
||||||
### ✅ Következtetés
|
|
||||||
**NINCS backend/frontend hiba.** A probléma egyszerű **adathiány**:
|
|
||||||
1. Nincs rögzítve költség admin egyetlen járművéhez sem
|
|
||||||
2. Az egyetlen DB-ben lévő költség egy másik szervezethez tartozik
|
|
||||||
3. A #278 és #279 javítások után most már lehetővé vált a költségrögzítés
|
|
||||||
- Dokumentáció: `/opt/docker/docs/cost_visibility_investigation_280.md`
|
|
||||||
|
|
||||||
## 2026-06-23 - #281 Hotfix: Hiányos #278 javítás - expenses.py:533 new_cost.cost_date → new_cost.date
|
|
||||||
|
|
||||||
### 🎯 Cél
|
|
||||||
A #278-as javítás hiányos volt: a konstruktor paramétert kijavítottuk (expenses.py:464: cost_date= → date=), de a response dict-ben (expenses.py:533) továbbra is `new_cost.cost_date`-et használtunk az SQLAlchemy AssetCost modellen, aminek a mezőneve `date`. Ez `AttributeError: 'AssetCost' object has no attribute 'cost_date'` hibát okozott minden POST /api/v1/expenses/ híváskor.
|
|
||||||
|
|
||||||
### 🔧 Módosított fájlok
|
|
||||||
- **`backend/app/api/v1/endpoints/expenses.py:533`** - `new_cost.cost_date.isoformat()` → `new_cost.date.isoformat()`
|
|
||||||
|
|
||||||
### ✅ Verifikáció
|
|
||||||
- Python szintaxis: ✅ OK
|
|
||||||
- Backend újraindítás: ✅ OK (Application startup complete)
|
|
||||||
- POST /api/v1/expenses/ teszt: ✅ **201 Created** - sikeres költség rögzítés a QWE432 járműre
|
|
||||||
- Response tartalmazza: `"date":"2026-06-23T13:07:34.785065+00:00"` (helyes)
|
|
||||||
- Gitea kártya: #281 lezárva
|
|
||||||
|
|
||||||
## 2026-06-23 - P0 Fix: User 28 Data Anomaly Resolution
|
|
||||||
|
|
||||||
### 🎯 Cél
|
|
||||||
User 28 (tester_pro@profibot.hu) adat-anomália javítása ORM szkript segítségével.
|
|
||||||
|
|
||||||
### 🔧 Végrehajtott műveletek
|
|
||||||
|
|
||||||
**A) Org 1 javítása:** `fleet.organizations` ID=1 org_type átállítva `fleet_owner` → `individual`
|
|
||||||
|
|
||||||
**B) Tag hozzáadása:** `fleet.organization_members` rekord létrehozva: org=1, user=28, role=OWNER, status=active. Org 1 owner_id=28 beállítva.
|
|
||||||
|
|
||||||
**C) Scope törlése:** User 28 `scope_level` visszaállítva `individual`-ra (NOT NULL constraint miatt), `scope_id` None-ra.
|
|
||||||
|
|
||||||
**D) Fantom garázs törlése:** Org 68 ("Tester Garázsa") és hozzá tartozó OrganizationMember rekordok törlése.
|
|
||||||
|
|
||||||
### ✅ Verifikáció
|
|
||||||
- Org 1: org_type=individual, owner_id=28
|
|
||||||
- Org 1 Member: role=OWNER, status=active, is_verified=True
|
|
||||||
- User 28: scope_level=individual, scope_id=None
|
|
||||||
- Org 68: nem létezik (deleted)
|
|
||||||
- Ideiglenes szkript törölve: `backend/scripts/fix_user_28.py`
|
|
||||||
|
|
||||||
## 2026-06-23 - P0 Cost Pipeline Visibility Audit (#282)
|
|
||||||
|
|
||||||
### 🔍 Felfedezés
|
|
||||||
A `/dashboard/costs` oldal üres User 28 számára, annak ellenére, hogy Org 1-ben 5 db asset_cost rekord van (BMW123, QWE432).
|
|
||||||
|
|
||||||
### 🐛 Gyökér Ok
|
|
||||||
A `GET /expenses/` endpoint (`backend/app/api/v1/endpoints/expenses.py:130-139`) a user első aktív tagságát használja org feloldáshoz (`.limit(1)` ORDER BY nélkül). User 28-nak 3 tagsága van: org 63 (ADMIN, nincs költség), org 21 (OWNER), org 1 (OWNER, 5 költség). Az első tagság org 63-ra mutat, ami üres.
|
|
||||||
|
|
||||||
### 📄 Dokumentáció
|
|
||||||
`docs/cost_pipeline_audit.md` - Teljes pipeline audit a frontendtől a DB-ig.
|
|
||||||
|
|
||||||
## 2026-06-23 - P0 Architecture Audit Fixes: Vendor Leak & Expense Priority
|
|
||||||
|
|
||||||
### 🎯 Cél
|
|
||||||
Az Architect által auditált P0 hibák kijavítása: vendor org szivárgás megszüntetése, expense organization_id prioritás javítása, frontend payload kiegészítése vendor_organization_id-val.
|
|
||||||
|
|
||||||
### 🔧 Módosított fájlok
|
|
||||||
- **`backend/app/services/provider_service.py`** (632-651. sor) - `quick_add_provider`: Eltávolítva az OrganizationMember létrehozás. A létrehozó user NEM lesz tagja a beszállító szervezetének. Owner_id=None marad.
|
|
||||||
- **`backend/app/api/v1/endpoints/expenses.py`** (398-429. sor) - `create_expense`: Átírva az organization_id feloldási prioritás: 1) asset.current_organization_id, 2) asset.owner_org_id, 3) user active org membership fallback.
|
|
||||||
- **`frontend/src/components/cost/CostEntryWizard.vue`** (690-714. sor) - `vendor_organization_id` és `external_vendor_name` root szintű payload mezők hozzáadva.
|
|
||||||
- **`frontend/src/stores/cost.ts`** - `AddExpensePayload` interface kiegészítve `vendor_organization_id` és `external_vendor_name` mezőkkel; `addExpense()` body-ba is átadva.
|
|
||||||
|
|
||||||
### 🗄️ Adatbázis Cleanup
|
|
||||||
- **`backend/scripts/fix_vendor_leak.py`** (ideiglenes, futás után törölve)
|
|
||||||
- A) Törölve `fleet.organization_members` rekord (org_id=63, user_id=28) - phantom ADMIN jog megvonva
|
|
||||||
- B) Javítva `fleet_finance.asset_costs` rekord: organization_id=63 → 21, vendor_organization_id=63 beállítva
|
|
||||||
|
|
||||||
### ✅ Verifikáció
|
|
||||||
- Backend restart: ✅ Sikeres
|
|
||||||
- Python syntax check: ✅ provider_service.py OK, expenses.py OK
|
|
||||||
- Vite build: ✅ Sikeres (255 modules, 6.97s)
|
|
||||||
|
|
||||||
## 2026-06-23 - P0 Tenant Isolation & Vehicle Mapping Audit
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
Full-stack audit of vehicle dropdown tenant isolation failure in CostsView.vue
|
|
||||||
|
|
||||||
### Elemzett fájlok
|
|
||||||
- `backend/app/api/v1/endpoints/assets.py` - GET /assets/vehicles (229-318)
|
|
||||||
- `frontend/src/views/costs/CostsView.vue` - Vehicle dropdown & org switch (330-565)
|
|
||||||
- `frontend/src/stores/vehicle.ts` - fetchVehicles() (141-158)
|
|
||||||
- `frontend/src/stores/auth.ts` - switchOrganization() & active_organization_id (687-717)
|
|
||||||
- `frontend/src/stores/cost.ts` - defense-in-depth org injection (71)
|
|
||||||
|
|
||||||
### Adatbázis: tester_pro (User ID: 28)
|
|
||||||
- 2 organization: Test Company (ID:1, 19 vehicles) + Private_28 (ID:21, 6 vehicles) = 25 total
|
|
||||||
- Schema: identity.users, fleet.organization_members, fleet.organizations, vehicle.assets
|
|
||||||
|
|
||||||
### Root Cause
|
|
||||||
Frontend cache staleness: vehicles load once in onMounted(), never refresh on org switch. The watch on active_organization_id only re-fetches costs, not vehicles.
|
|
||||||
|
|
||||||
### Verdict
|
|
||||||
Backend filtering is correct (JWT scope_id). Database schema is correct. Fix requires adding `vehicleStore.fetchVehicles()` to the org switch watch in CostsView.vue:549.
|
|
||||||
|
|
||||||
### Report
|
|
||||||
`docs/p0_tenant_isolation_vehicle_mapping_audit_report.md`
|
|
||||||
|
|
||||||
## 2026-06-23 - P0 Tenant Isolation Fix: CostsView.vue org switch watcher
|
|
||||||
|
|
||||||
### 🎯 Cél
|
|
||||||
Frontend tenant isolation fix: garázsváltáskor a CostsView.vue watch blokkja most már reseteli a jármű szűrőt (`selectedVehicleFilter.value = null`), újratölti a járműveket az új garázs scope-jával (`vehicleStore.fetchVehicles()`), és frissíti a költségeket.
|
|
||||||
|
|
||||||
### 🔧 Módosított fájlok
|
|
||||||
- `frontend/src/views/costs/CostsView.vue` — watch blokk kiegészítése: jármű szűrő reset + vehicleStore.fetchVehicles() hívás
|
|
||||||
|
|
||||||
### ✅ Verifikáció
|
|
||||||
- Vite build: ✅ Sikeres (0 hiba)
|
|
||||||
- `vehicleStore.fetchVehicles()` mindig küld GET kérést (nincs belső cache guard)
|
|
||||||
|
|
||||||
## 2026-06-23 - P0 EXECUTION: UI Switcher, Subscription Widget & Menu Alignment
|
|
||||||
|
|
||||||
### 🎯 Cél
|
|
||||||
A P0 audit riportban azonosított hibák kijavítása: garázsválasztó szűrés, előfizetés widget jármű limit megjelenítés, mobil menü igazítás, backend admin jogosultság javítás.
|
|
||||||
|
|
||||||
### 🔧 Változtatások
|
|
||||||
|
|
||||||
**1. HeaderCompanySwitcher.vue — Garázsválasztó javítás**
|
|
||||||
- Eltávolítva az `org_type !== 'individual'` szűrő a `companyOrganizations` computed property-ből
|
|
||||||
- Létrehozva új `individualOrganizations` computed property a privát garázsokhoz
|
|
||||||
- Dropdown most két csoportra van osztva: "👤 Saját garázsok" (individual) és "🏢 Céges garázsok" (business)
|
|
||||||
- Privát garázsokra kattintva `switchOrganization()` hívódik, majd `/dashboard` navigáció
|
|
||||||
- Eltávolítva a keménykódolt "Private Garage" gomb — helyette dinamikus lista
|
|
||||||
|
|
||||||
**2. SubscriptionStatusWidget.vue — Jármű limit megjelenítés**
|
|
||||||
- Hozzáadva `useVehicleStore` import és `vehicleCount` computed property
|
|
||||||
- Hozzáadva `maxVehicles` computed property (org vagy user szintről)
|
|
||||||
- Második progress bar a jármű telítettséghez (🚗 Járművek: X / Y)
|
|
||||||
- Színkódolt sáv: zöld (<75%), sárga (75-90%), piros (>90%)
|
|
||||||
- Ha nincs limit (0 vagy null): "Korlátlan" felirat
|
|
||||||
- Dátum formázás és lejárati információ hozzáadva
|
|
||||||
|
|
||||||
**3. PrivateLayout.vue — Mobil menü igazítás**
|
|
||||||
- "Költségek" gombhoz hozzáadva `justify-end` és `text-right` osztályok
|
|
||||||
- A gomb ikonja és szövege jobbra igazítva
|
|
||||||
|
|
||||||
**4. organizations.py — Backend admin jogosultság javítás**
|
|
||||||
- `PUT /{org_id}/subscription` végpontról eltávolítva a `RequireSystemCapability(Capability.CAN_MANAGE_SUBSCRIPTIONS)` függőség
|
|
||||||
- Helyette `_check_org_admin_access(org_id, current_user.id, db)` hívás — így org ADMIN/OWNER is kezelheti a saját előfizetését
|
|
||||||
|
|
||||||
**5. organization.ts — TypeScript típus javítás**
|
|
||||||
- `org_type` mező kötelezővé téve (eltávolítva a `?` optional flag)
|
|
||||||
|
|
||||||
### ✅ Verifikáció
|
|
||||||
- Vite build: ✅ Sikeres (6.75s, 0 hiba)
|
|
||||||
- Backend Python szintaxis: ✅ Sikeres
|
|
||||||
- API konténer újraindítás: ✅ Sikeres
|
|
||||||
|
|
||||||
## 2026-06-23 - P0 HOTFIX: Cost Wizard Edit Mode Bug (POST vs PUT)
|
|
||||||
|
|
||||||
### 🎯 Cél
|
|
||||||
A CostEntryWizard szerkesztés módban POST helyett PUT hívást indítson, hogy ne hozzon létre duplikátumot.
|
|
||||||
|
|
||||||
### 🔧 Módosított fájlok
|
### 🔧 Módosított fájlok
|
||||||
|
|
||||||
1. **`backend/app/schemas/asset_cost.py`** - Új `AssetCostUpdate` Pydantic schema (minden mező opcionális)
|
**PHASE 1 - Schema Updates:**
|
||||||
2. **`backend/app/api/v1/endpoints/expenses.py`** - Új `PUT /expenses/{expense_id}` végpont a meglévő költségrekordok módosítására
|
- `backend/app/models/fleet_finance/models.py` — `service_provider_id` hozzáadva `AssetCost`-hoz + FK javítás (relációk indentálása)
|
||||||
3. **`frontend/src/stores/cost.ts`** - Új `updateExpense(id, payload)` action a store-ban, ami `PUT /expenses/{id}`-t hív
|
- `backend/app/models/marketplace/service.py` — `service_provider_id` hozzáadva `ServiceProfile`-hoz
|
||||||
4. **`frontend/src/components/cost/CostEntryWizard.vue`** - `handleSubmit()` logika javítva: ha `props.editCost.id` létezik, `updateExpense()`-t hív, egyébként `addExpense()`-t
|
- `backend/app/models/identity/social.py` — `ServiceProvider` kiterjesztve 10 új mezővel (cím, kontakt)
|
||||||
|
- `unified_db_sync.py` futtatva → 10 új oszlop létrehozva az adatbázisban
|
||||||
|
|
||||||
### 🗑️ Adatbázis cleanup
|
**PHASE 2 - Refactor quick_add_provider():**
|
||||||
- Duplikált rekord törölve: `55214098-74be-4261-a101-7fd9159e10d8` (asset_id=`deb42aea-1f48-4a70-85b5-ba451d005577`, amount_gross=5500.00)
|
- `backend/app/services/provider_service.py` — `quick_add_provider()` átírva: `ServiceProvider` + `ServiceProfile` létrehozása (NO Organization, NO Branch, NO OrganizationMember)
|
||||||
|
- `backend/app/schemas/asset_cost.py` — `service_provider_id` hozzáadva a Pydantic modellekhez
|
||||||
|
- `backend/app/schemas/provider.py` — `ProviderQuickAddIn` kiegészítve új mezőkkel
|
||||||
|
- `backend/app/api/v1/endpoints/expenses.py` — `service_provider_id` átadva `AssetCost` creation-nél
|
||||||
|
|
||||||
### ✅ Verifikáció
|
**PHASE 3 - Data Migration:**
|
||||||
- Vite build: ✅ Sikeres (7.09s, 255 module, 0 hiba)
|
- `backend/scripts/migrate_providers.py` — Létrehozva: 7 Organization → ServiceProvider migrálva
|
||||||
- Backend Python AST parse: ✅ Sikeres (expenses.py, asset_cost.py)
|
|
||||||
- `AssetCostUpdate` schema import: ✅ Sikeres (14 field)
|
|
||||||
- Duplikátum törlés: ✅ Sikeres
|
|
||||||
|
|
||||||
## 2026-06-23 - P0 Final Polish: Wizard UX & Menu Alignment
|
**PHASE 4 - Verification:**
|
||||||
|
- 7 ServiceProvider rekord létrejött (`marketplace.service_providers`)
|
||||||
### 🎯 Cél
|
- 6/7 rendelkezik ServiceProfile kapcsolattal (MOL-nak 0 profilja volt)
|
||||||
Három kritikus UI hiba javítása: dátum hydratáció Edit módban, Fizetési Mód logika és UI átrendezés, mobil menü jobbra igazítás.
|
- 1 AssetCost linkelve (Aszalós Motorszervíz)
|
||||||
|
- Safe list garázsok (13) érintetlenek a `fleet.organizations` táblában
|
||||||
### 🔧 Módosított fájlok
|
|
||||||
|
|
||||||
1. **`frontend/src/components/cost/CostEntryWizard.vue`**
|
|
||||||
- **Date Hydration Fix (watch block):** `form.invoiceDate` és `form.paymentDeadline` most már mindig érvényes `YYYY-MM-DD` stringet kap (vagy `''` ha null). Két forrást is ellenőriz: `data.invoice_date` (JSONB) és `editData.invoice_date` (top-level). Használ `rawDate.split('T')[0] || rawDate.slice(0, 10) || ''` fallback láncot.
|
|
||||||
- **Payment Method UI áthelyezés:** A `<select>` a STEP 3 blokkban a Számla kelte és Fizetési határidő ELÉ került (Invoice Number után, Invoice Date előtt).
|
|
||||||
- **Payment Deadline dinamikus rejtés:** `v-if="form.paymentMethod === 'TRANSFER'"` a Payment Deadline inputon — CASH/CARD esetén eltűnik.
|
|
||||||
- **Mentési automatizmus:** `handleSubmit()`-ben, ha `form.paymentMethod === 'CASH' || 'CARD'`, a `form.paymentDeadline` automatikusan `form.invoiceDate || form.date` értékre áll.
|
|
||||||
|
|
||||||
2. **`frontend/src/layouts/PrivateLayout.vue`**
|
|
||||||
- **Mobile Menu "Költségek" jobbra igazítás:** `flex-row-reverse` osztály hozzáadva, hogy az ikon a jobb szélre tapadjon. Megtartva `justify-end` és `text-right`.
|
|
||||||
|
|
||||||
3. **`frontend/src/layouts/OrganizationLayout.vue`**
|
|
||||||
- **"Költségek" gomb hozzáadva** a hamburger menübe (hiányzott). Jobbra igazított `flex-row-reverse` stílussal, a `navigateToCosts()` függvény az `/organization/:id/costs` oldalra navigál.
|
|
||||||
|
|
||||||
### ✅ Verifikáció
|
|
||||||
- Vite build: ✅ Sikeres (7.31s, 255 modules, 0 hiba)
|
|
||||||
|
|
||||||
## 2026-06-24 — P0 3D Capability Matrix Audit (Issue #283)
|
|
||||||
|
|
||||||
**Architect:** Completed read-only audit of all SQLAlchemy models for 3D RBAC readiness.
|
|
||||||
|
|
||||||
**Findings:**
|
|
||||||
- **D1 (Global Roles):** `UserRole` enum with 6 values (SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR, USER) — ✅ sufficient
|
|
||||||
- **D2 (Tenant Roles):** `OrgUserRole` enum (OWNER, ADMIN, ACCOUNTANT, DRIVER, VIEWER) + `OrgRole.permissions` JSONB + `OrganizationMember.permissions` JSON — ✅ sufficient
|
|
||||||
- **D3 (Subscription Capabilities):** ⚠️ Partial — `SubscriptionTier.rules` JSONB exists but no standardized feature capability format. **Missing:** `custom_features` JSONB on Organization model
|
|
||||||
- **D4 (Cost Category Gating):** ⚠️ Partial — `CostCategory.min_tier` exists but no `required_feature` column
|
|
||||||
|
|
||||||
**Report:** `docs/p0_3d_capability_matrix_audit_report.md`
|
|
||||||
**Proposed migrations:** 3 ALTER TABLE statements (SubscriptionTier.feature_capabilities, Organization.custom_features, CostCategory.required_feature) + optional FeatureCapability enum
|
|
||||||
|
|
||||||
## 2026-06-24 — P0 3D Capability Matrix Implementation (Code)
|
|
||||||
|
|
||||||
### 🎯 Cél
|
|
||||||
A 3D Capability Matrix teljes implementációja: modellek bővítése, adatbázis migráció, Capability Resolver Service, és a /categories API végpont capability-alapú védelme.
|
|
||||||
|
|
||||||
### 🔧 Módosított fájlok
|
|
||||||
- `backend/app/models/core_logic.py` — SubscriptionTier.feature_capabilities (JSONB)
|
|
||||||
- `backend/app/models/marketplace/organization.py` — Organization.custom_features (JSONB) + subscription_tier reláció
|
|
||||||
- `backend/app/models/fleet_finance/models.py` — CostCategory.required_feature (String(50))
|
|
||||||
- `backend/app/services/capability_service.py` — Új Capability Resolver Service (Superadmin Fast-Pass)
|
|
||||||
- `backend/app/api/v1/endpoints/dictionaries.py` — /cost-categories endpoint capability filtering
|
|
||||||
|
|
||||||
### ✅ Verifikáció
|
|
||||||
- Sync Engine: 1213 OK, 0 Fixed, 0 Extra — teljes szinkron
|
|
||||||
- Python import/attribute check: minden modelloszlop és reláció jelen van
|
|
||||||
- resolve_org_capabilities() callable, Fast-Pass működik
|
|
||||||
|
|
||||||
## 2026-06-24 — P0 EXECUTION: Backoffice SSO & Staff Guards
|
|
||||||
|
|
||||||
### 🎯 Cél
|
|
||||||
Cross-subdomain SSO cookie fix, backend staff role dependencies, frontend admin routing guard expansion, and dead link cleanup in AdminLayout.
|
|
||||||
|
|
||||||
### 🔧 Módosított fájlok
|
|
||||||
|
|
||||||
**Phase 1 — Cross-Subdomain Cookies (Backend)**
|
|
||||||
- `backend/app/core/config.py` — Added `COOKIE_DOMAIN: str = ".servicefinder.hu"` setting
|
|
||||||
- `backend/app/api/v1/endpoints/auth.py` — Added `domain=settings.COOKIE_DOMAIN` to both `set_cookie()` calls (login:127, verify-email:221)
|
|
||||||
|
|
||||||
**Phase 2 — Scalable Backend Dependencies**
|
|
||||||
- `backend/app/api/deps.py` — Added `STAFF_ROLES` set (SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR)
|
|
||||||
- `backend/app/api/deps.py` — Added `get_current_staff` dependency (admits all staff roles)
|
|
||||||
- `backend/app/api/deps.py` — Added `RequireRole(allowed_roles: List[UserRole])` flexible dependency factory
|
|
||||||
|
|
||||||
**Phase 3 — Frontend Guards & Store**
|
|
||||||
- `frontend/src/stores/auth.ts` — Expanded `isAdmin` getter to include SALES_REP and SERVICE_MGR
|
|
||||||
- `frontend/src/router/index.ts` — Expanded `requiresAdmin` guard to admit all 5 staff roles
|
|
||||||
|
|
||||||
**Phase 4 — Admin UI Cleanup**
|
|
||||||
- `frontend/src/layouts/AdminLayout.vue` — Removed dead sidebar links: /admin/vehicles, /admin/translations, /admin/system, /admin/audit
|
|
||||||
|
|
||||||
### ✅ Verifikáció
|
|
||||||
- Vite build: ✅ Sikeres (255 modules, 7.13s, 0 hiba)
|
|
||||||
- Python AST parse: ✅ config.py, auth.py, deps.py — all syntax OK
|
|
||||||
- Python import test: ✅ `settings.COOKIE_DOMAIN` = `.servicefinder.hu`
|
|
||||||
- Python import test: ✅ `STAFF_ROLES` contains all 5 roles, `get_current_staff` and `RequireRole` are callable
|
|
||||||
|
|
||||||
## 2026-06-24 - P0 READ & PLAN: Admin Container Separation Audit
|
|
||||||
|
|
||||||
### 🎯 Cél
|
|
||||||
Architecture audit for strictly isolated Admin container. Read docker-compose.yml, NPM proxy configs, and directory structure.
|
|
||||||
|
|
||||||
### 🔧 Eredmények
|
|
||||||
|
|
||||||
**1. DOCKER ARCHITECTURE:** ✅ 3 admin-related containers exist
|
|
||||||
- `sf_admin_ui` (Streamlit, port 8501) — Active HITL panel
|
|
||||||
- `sf_admin_frontend` (Nuxt 3, port 8502) — Placeholder only
|
|
||||||
- `sf_public_frontend` (Vite/Vue, port 8503) — Contains embedded admin views
|
|
||||||
|
|
||||||
**2. CRITICAL FINDING:** Admin views are split across two codebases
|
|
||||||
- Full admin views (Dashboard, Users, Packages, Services, Login) exist in `frontend/src/views/admin/`
|
|
||||||
- Separate Nuxt 3 container at `frontend/admin/` is provisioned but empty (placeholder app.vue only)
|
|
||||||
- NPM routing already correct: `admin.servicefinder.hu` → `sf_admin_frontend:8502`
|
|
||||||
|
|
||||||
**3. GITEA CARDS CREATED:**
|
|
||||||
- #284 — Main audit card (FINISHED)
|
|
||||||
- #285 — Build Nuxt 3 Admin App
|
|
||||||
- #286 — Remove Admin Views from Public Frontend
|
|
||||||
- #287 — Production Dockerfile for Admin
|
|
||||||
- #288 — Deprecate Streamlit Admin
|
|
||||||
|
|
||||||
**4. REPORT:** `plans/admin_container_separation_audit.md`
|
|
||||||
|
|||||||
@@ -210,28 +210,63 @@ async def get_current_staff(
|
|||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
def RequireRole(allowed_roles: List[UserRole]):
|
def RequireRole(allowed_roles: List[UserRole], target_org_id: Optional[int] = None):
|
||||||
"""
|
"""
|
||||||
🎯 Flexible role-checking dependency factory.
|
🎯 Flexible role-checking dependency factory with scope-based access.
|
||||||
|
|
||||||
Accepts a list of UserRole values. If the current user's role is
|
Accepts a list of UserRole values. If the current user's role is
|
||||||
in the list, access is granted. Otherwise, 403 Forbidden.
|
in the list, access is granted. Otherwise, 403 Forbidden.
|
||||||
|
|
||||||
|
When target_org_id is provided, the RBACService is automatically
|
||||||
|
invoked to check if the staff member has scope-based access to
|
||||||
|
that specific organization.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
# Simple role check
|
||||||
@router.get("/admin/sales")
|
@router.get("/admin/sales")
|
||||||
async def sales_dashboard(
|
async def sales_dashboard(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
_ = Depends(RequireRole([UserRole.SUPERADMIN, UserRole.SALES_REP]))
|
_ = Depends(RequireRole([UserRole.SUPERADMIN, UserRole.SALES_REP]))
|
||||||
):
|
):
|
||||||
|
|
||||||
|
# Scope-based access check
|
||||||
|
@router.get("/admin/organizations/{org_id}")
|
||||||
|
async def get_organization(
|
||||||
|
org_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
_ = Depends(RequireRole(
|
||||||
|
[UserRole.SUPERADMIN, UserRole.ADMIN, UserRole.SALES_REP],
|
||||||
|
target_org_id=org_id # ← triggers scope check
|
||||||
|
))
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
async def role_checker(
|
async def role_checker(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
# 1. Check if the user's role is in the allowed list
|
||||||
if current_user.role not in allowed_roles:
|
if current_user.role not in allowed_roles:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="AUTH.INSUFFICIENT_PERMISSIONS"
|
detail="AUTH.INSUFFICIENT_PERMISSIONS"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 2. If target_org_id is provided, perform scope-based access check
|
||||||
|
if target_org_id is not None:
|
||||||
|
from app.services.rbac_service import rbac_service
|
||||||
|
try:
|
||||||
|
await rbac_service.check_admin_access(
|
||||||
|
db=db,
|
||||||
|
user=current_user,
|
||||||
|
action="VIEW_DATA", # Generic read access for the dependency
|
||||||
|
target_org_id=target_org_id,
|
||||||
|
)
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
return role_checker
|
return role_checker
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ from app.api.v1.endpoints import (
|
|||||||
services, admin, expenses, evidence, social, security,
|
services, admin, expenses, evidence, social, security,
|
||||||
billing, finance_admin, analytics, vehicles, system_parameters,
|
billing, finance_admin, analytics, vehicles, system_parameters,
|
||||||
gamification, translations, users, reports, dictionaries,
|
gamification, translations, users, reports, dictionaries,
|
||||||
admin_packages, admin_services, constants, providers,
|
admin_packages, admin_services, admin_organizations, constants, providers,
|
||||||
subscriptions, marketing,
|
subscriptions, marketing, admin_permissions, regions,
|
||||||
)
|
)
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
@@ -27,6 +27,7 @@ api_router.include_router(finance_admin.router, prefix="/finance/issuers", tags=
|
|||||||
api_router.include_router(analytics.router, prefix="/analytics", tags=["Analytics"])
|
api_router.include_router(analytics.router, prefix="/analytics", tags=["Analytics"])
|
||||||
api_router.include_router(vehicles.router, prefix="/vehicles", tags=["Vehicles"])
|
api_router.include_router(vehicles.router, prefix="/vehicles", tags=["Vehicles"])
|
||||||
api_router.include_router(system_parameters.router, prefix="/system/parameters", tags=["System Parameters"])
|
api_router.include_router(system_parameters.router, prefix="/system/parameters", tags=["System Parameters"])
|
||||||
|
api_router.include_router(regions.router, prefix="/system", tags=["Global Region Registry"])
|
||||||
api_router.include_router(gamification.router, prefix="/gamification", tags=["Gamification"])
|
api_router.include_router(gamification.router, prefix="/gamification", tags=["Gamification"])
|
||||||
api_router.include_router(translations.router, prefix="/translations", tags=["i18n"])
|
api_router.include_router(translations.router, prefix="/translations", tags=["i18n"])
|
||||||
api_router.include_router(users.router, prefix="/users", tags=["Users"])
|
api_router.include_router(users.router, prefix="/users", tags=["Users"])
|
||||||
@@ -34,8 +35,10 @@ api_router.include_router(reports.router, prefix="/reports", tags=["Reports"])
|
|||||||
api_router.include_router(dictionaries.router, prefix="/dictionaries", tags=["Dictionaries"])
|
api_router.include_router(dictionaries.router, prefix="/dictionaries", tags=["Dictionaries"])
|
||||||
api_router.include_router(admin_packages.router, prefix="/admin/packages", tags=["Admin Package Management"])
|
api_router.include_router(admin_packages.router, prefix="/admin/packages", tags=["Admin Package Management"])
|
||||||
api_router.include_router(admin_services.router, prefix="/admin/services", tags=["Admin Service Catalog"])
|
api_router.include_router(admin_services.router, prefix="/admin/services", tags=["Admin Service Catalog"])
|
||||||
|
api_router.include_router(admin_organizations.router, prefix="/admin/organizations", tags=["Admin Garages CRM"])
|
||||||
api_router.include_router(billing.router, prefix="/billing", tags=["Billing & Wallet"])
|
api_router.include_router(billing.router, prefix="/billing", tags=["Billing & Wallet"])
|
||||||
api_router.include_router(constants.router, prefix="/constants", tags=["Constants"])
|
api_router.include_router(constants.router, prefix="/constants", tags=["Constants"])
|
||||||
api_router.include_router(providers.router, prefix="/providers", tags=["Providers"])
|
api_router.include_router(providers.router, prefix="/providers", tags=["Providers"])
|
||||||
api_router.include_router(subscriptions.router, prefix="/subscriptions", tags=["Subscriptions"])
|
api_router.include_router(subscriptions.router, prefix="/subscriptions", tags=["Subscriptions"])
|
||||||
api_router.include_router(marketing.router, prefix="/marketing", tags=["Marketing & Ads"])
|
api_router.include_router(marketing.router, prefix="/marketing", tags=["Marketing & Ads"])
|
||||||
|
api_router.include_router(admin_permissions.router, prefix="", tags=["Admin Permissions (RBAC)"])
|
||||||
@@ -734,4 +734,86 @@ async def bulk_user_action(
|
|||||||
"total_requested": len(request.user_ids),
|
"total_requested": len(request.user_ids),
|
||||||
"missing_ids": missing_ids if missing_ids else None,
|
"missing_ids": missing_ids if missing_ids else None,
|
||||||
"message": f"{request.action} művelet végrehajtva {affected_count} felhasználón."
|
"message": f"{request.action} művelet végrehajtva {affected_count} felhasználón."
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== ORGANIZATION SEARCH (EPIC 10: Admin Frontend) ====================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/organizations/search", tags=["Organization Management"])
|
||||||
|
async def search_organizations(
|
||||||
|
name: str = Query(..., min_length=2, description="Cégnév vagy garázsnév keresése (ILIKE)"),
|
||||||
|
limit: int = Query(20, ge=1, le=100, description="Maximum találatok száma"),
|
||||||
|
db: AsyncSession = Depends(deps.get_db),
|
||||||
|
current_user: User = Depends(deps.get_current_admin),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
🔍 Szervezetek / Garázsok keresése név alapján.
|
||||||
|
|
||||||
|
Csak SUPERADMIN, ADMIN és MODERATOR szerepkörű felhasználók számára elérhető.
|
||||||
|
ILIKE keresést használ a full_name, name és display_name mezőkben.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: A keresett cégnév vagy garázsnév (minimum 2 karakter).
|
||||||
|
limit: Maximum visszaadott találatok száma (1-100).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lista a megtalált szervezetek alapadataival.
|
||||||
|
"""
|
||||||
|
from app.models.marketplace.organization import Organization
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
Organization.id,
|
||||||
|
Organization.name,
|
||||||
|
Organization.full_name,
|
||||||
|
Organization.display_name,
|
||||||
|
Organization.tax_number,
|
||||||
|
Organization.org_type,
|
||||||
|
Organization.status,
|
||||||
|
Organization.is_verified,
|
||||||
|
Organization.is_active,
|
||||||
|
Organization.is_deleted,
|
||||||
|
Organization.address_city,
|
||||||
|
Organization.region,
|
||||||
|
Organization.segment,
|
||||||
|
Organization.created_at,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
or_(
|
||||||
|
Organization.full_name.ilike(f"%{name}%"),
|
||||||
|
Organization.name.ilike(f"%{name}%"),
|
||||||
|
Organization.display_name.ilike(f"%{name}%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Organization.full_name.asc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
organizations = []
|
||||||
|
for row in rows:
|
||||||
|
organizations.append({
|
||||||
|
"id": row.id,
|
||||||
|
"name": row.name,
|
||||||
|
"full_name": row.full_name,
|
||||||
|
"display_name": row.display_name,
|
||||||
|
"tax_number": row.tax_number,
|
||||||
|
"org_type": row.org_type.value if hasattr(row.org_type, 'value') else str(row.org_type),
|
||||||
|
"status": row.status,
|
||||||
|
"is_verified": row.is_verified,
|
||||||
|
"is_active": row.is_active,
|
||||||
|
"is_deleted": row.is_deleted,
|
||||||
|
"city": row.address_city,
|
||||||
|
"region": row.region,
|
||||||
|
"segment": row.segment,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": len(organizations),
|
||||||
|
"query": name,
|
||||||
|
"organizations": organizations,
|
||||||
}
|
}
|
||||||
346
backend/app/api/v1/endpoints/admin_organizations.py
Normal file
346
backend/app/api/v1/endpoints/admin_organizations.py
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
"""
|
||||||
|
🏢 Admin Garages (Organizations) CRM API
|
||||||
|
|
||||||
|
Végpontok:
|
||||||
|
GET /admin/organizations — Garázsok listázása előfizetési adatokkal
|
||||||
|
PATCH /admin/organizations/{org_id}/subscription — Előfizetés módosítása
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy import select, func, update as sa_update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload, joinedload, contains_eager
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.api import deps
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.core_logic import SubscriptionTier, OrganizationSubscription
|
||||||
|
from app.models.marketplace.organization import Organization, OrganizationMember
|
||||||
|
from app.models.identity import User
|
||||||
|
|
||||||
|
logger = logging.getLogger("admin-organizations")
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Pydantic Schemas
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class OrgSubscriptionUpdate(BaseModel):
|
||||||
|
"""Előfizetés módosításának kérése."""
|
||||||
|
tier_id: int = Field(..., description="Az új SubscriptionTier ID-ja")
|
||||||
|
expires_at: Optional[datetime] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Egyedi lejárati dátum (pl. 2-5 éves B2B deal esetén). "
|
||||||
|
"Ha None, a csomag duration.days alapján számolódik."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrgSubscriptionResponse(BaseModel):
|
||||||
|
"""Előfizetés adatainak válasza."""
|
||||||
|
id: int
|
||||||
|
org_id: int
|
||||||
|
tier_id: int
|
||||||
|
tier_name: str = ""
|
||||||
|
valid_from: Optional[str] = None
|
||||||
|
valid_until: Optional[str] = None
|
||||||
|
is_active: bool = True
|
||||||
|
extra_allowances: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class GarageListItem(BaseModel):
|
||||||
|
"""Egy garázs adatai a listában."""
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
full_name: str
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
status: str
|
||||||
|
is_active: bool
|
||||||
|
is_verified: bool
|
||||||
|
is_deleted: bool
|
||||||
|
org_type: str
|
||||||
|
city: Optional[str] = None
|
||||||
|
region: Optional[str] = None
|
||||||
|
segment: Optional[str] = None
|
||||||
|
member_count: int = 0
|
||||||
|
created_at: Optional[str] = None
|
||||||
|
# Előfizetési adatok
|
||||||
|
subscription: Optional[OrgSubscriptionResponse] = None
|
||||||
|
subscription_tier_name: str = "Free/Fallback"
|
||||||
|
subscription_expires_at: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class GarageListResponse(BaseModel):
|
||||||
|
"""Garázsok listájának válasza."""
|
||||||
|
total: int
|
||||||
|
skip: int
|
||||||
|
limit: int
|
||||||
|
garages: List[GarageListItem]
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GET / — Garázsok listázása
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=GarageListResponse,
|
||||||
|
summary="Garázsok listázása",
|
||||||
|
description="Visszaadja az összes szervezetet (garázst) előfizetési adatokkal és taglétszámmal.",
|
||||||
|
)
|
||||||
|
async def list_organizations(
|
||||||
|
skip: int = Query(0, ge=0, description="Hány rekordot hagyjunk ki (lapozás)"),
|
||||||
|
limit: int = Query(50, ge=1, le=200, description="Maximum visszaadott rekordok száma"),
|
||||||
|
search_term: Optional[str] = Query(None, description="Keresés név vagy e-mail alapján"),
|
||||||
|
status_filter: Optional[str] = Query(None, description="Szűrés státusz szerint (active/inactive/suspended/pending_verification)"),
|
||||||
|
org_type_filter: Optional[str] = Query(None, description="Szűrés szervezet típus szerint"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: User = Depends(deps.get_current_admin),
|
||||||
|
) -> GarageListResponse:
|
||||||
|
"""
|
||||||
|
Garázsok / Szervezetek listázása lapozással és kereséssel.
|
||||||
|
|
||||||
|
Minden garázshoz visszaadja:
|
||||||
|
- Alapadatokat (név, státusz, típus, város)
|
||||||
|
- Taglétszámot (OrganizationMember-ek száma)
|
||||||
|
- Aktív előfizetés adatait (tier név, lejárati dátum)
|
||||||
|
"""
|
||||||
|
# Alap lekérdezés
|
||||||
|
query = select(Organization).options(
|
||||||
|
selectinload(Organization.subscription_tier),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Szűrés
|
||||||
|
if search_term:
|
||||||
|
like_pattern = f"%{search_term}%"
|
||||||
|
query = query.where(
|
||||||
|
Organization.full_name.ilike(like_pattern) |
|
||||||
|
Organization.name.ilike(like_pattern) |
|
||||||
|
Organization.display_name.ilike(like_pattern)
|
||||||
|
)
|
||||||
|
if status_filter:
|
||||||
|
query = query.where(Organization.status == status_filter)
|
||||||
|
if org_type_filter:
|
||||||
|
query = query.where(Organization.org_type == org_type_filter)
|
||||||
|
|
||||||
|
# Teljes számolás
|
||||||
|
count_stmt = select(func.count()).select_from(query.subquery())
|
||||||
|
total_result = await db.execute(count_stmt)
|
||||||
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
|
# Lapozás
|
||||||
|
query = query.order_by(Organization.id.desc()).offset(skip).limit(limit)
|
||||||
|
result = await db.execute(query)
|
||||||
|
organizations = result.scalars().all()
|
||||||
|
|
||||||
|
# Összes org_id begyűjtése a tömeges subscription lekérdezéshez
|
||||||
|
org_ids = [org.id for org in organizations]
|
||||||
|
|
||||||
|
# Tömeges subscription lekérdezés
|
||||||
|
subscriptions_map: Dict[int, OrganizationSubscription] = {}
|
||||||
|
if org_ids:
|
||||||
|
sub_stmt = (
|
||||||
|
select(OrganizationSubscription)
|
||||||
|
.options(selectinload(OrganizationSubscription.tier))
|
||||||
|
.where(
|
||||||
|
OrganizationSubscription.org_id.in_(org_ids),
|
||||||
|
OrganizationSubscription.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sub_result = await db.execute(sub_stmt)
|
||||||
|
for sub in sub_result.scalars().all():
|
||||||
|
# Ha több subscription is van, a legutolsót vegyük
|
||||||
|
if sub.org_id not in subscriptions_map:
|
||||||
|
subscriptions_map[sub.org_id] = sub
|
||||||
|
|
||||||
|
# Tömeges taglétszám lekérdezés
|
||||||
|
member_counts: Dict[int, int] = {}
|
||||||
|
if org_ids:
|
||||||
|
from sqlalchemy import func as sa_func
|
||||||
|
member_stmt = (
|
||||||
|
select(
|
||||||
|
OrganizationMember.organization_id,
|
||||||
|
sa_func.count(OrganizationMember.id)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
OrganizationMember.organization_id.in_(org_ids),
|
||||||
|
OrganizationMember.status == "active",
|
||||||
|
)
|
||||||
|
.group_by(OrganizationMember.organization_id)
|
||||||
|
)
|
||||||
|
member_result = await db.execute(member_stmt)
|
||||||
|
for row in member_result.all():
|
||||||
|
member_counts[row[0]] = row[1]
|
||||||
|
|
||||||
|
# Válasz összeállítása
|
||||||
|
garage_list = []
|
||||||
|
for org in organizations:
|
||||||
|
sub = subscriptions_map.get(org.id)
|
||||||
|
sub_response = None
|
||||||
|
tier_name = "Free/Fallback"
|
||||||
|
expires_at = None
|
||||||
|
|
||||||
|
if sub and sub.tier:
|
||||||
|
tier_name = sub.tier.name
|
||||||
|
expires_at = sub.valid_until.isoformat() if sub.valid_until else None
|
||||||
|
sub_response = OrgSubscriptionResponse(
|
||||||
|
id=sub.id,
|
||||||
|
org_id=sub.org_id,
|
||||||
|
tier_id=sub.tier_id,
|
||||||
|
tier_name=sub.tier.name,
|
||||||
|
valid_from=sub.valid_from.isoformat() if sub.valid_from else None,
|
||||||
|
valid_until=expires_at,
|
||||||
|
is_active=sub.is_active,
|
||||||
|
extra_allowances=sub.extra_allowances or {},
|
||||||
|
)
|
||||||
|
elif org.subscription_tier:
|
||||||
|
# Fallback: Organization.subscription_tier_id kapcsolat
|
||||||
|
tier_name = org.subscription_tier.name
|
||||||
|
expires_at = org.subscription_expires_at.isoformat() if org.subscription_expires_at else None
|
||||||
|
|
||||||
|
garage_list.append(GarageListItem(
|
||||||
|
id=org.id,
|
||||||
|
name=org.name,
|
||||||
|
full_name=org.full_name,
|
||||||
|
display_name=org.display_name,
|
||||||
|
email=None, # Organization-nak nincs közvetlen email mezője
|
||||||
|
status=org.status,
|
||||||
|
is_active=org.is_active,
|
||||||
|
is_verified=org.is_verified,
|
||||||
|
is_deleted=org.is_deleted,
|
||||||
|
org_type=org.org_type.value if hasattr(org.org_type, "value") else str(org.org_type),
|
||||||
|
city=org.address_city,
|
||||||
|
region=org.region,
|
||||||
|
segment=org.segment,
|
||||||
|
member_count=member_counts.get(org.id, 0),
|
||||||
|
created_at=org.created_at.isoformat() if org.created_at else None,
|
||||||
|
subscription=sub_response,
|
||||||
|
subscription_tier_name=tier_name,
|
||||||
|
subscription_expires_at=expires_at,
|
||||||
|
))
|
||||||
|
|
||||||
|
return GarageListResponse(
|
||||||
|
total=total,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
garages=garage_list,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# PATCH /{org_id}/subscription — Előfizetés módosítása
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/{org_id}/subscription",
|
||||||
|
summary="Garázs előfizetésének módosítása",
|
||||||
|
description="Frissíti vagy létrehozza egy garázs OrganizationSubscription rekordját. "
|
||||||
|
"Lehetőség van egyedi lejárati dátum megadására (pl. 2-5 éves B2B deal).",
|
||||||
|
)
|
||||||
|
async def update_org_subscription(
|
||||||
|
org_id: int,
|
||||||
|
payload: OrgSubscriptionUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: User = Depends(deps.get_current_admin),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Garázs előfizetésének módosítása.
|
||||||
|
|
||||||
|
- Ellenőrzi, hogy a szervezet létezik-e.
|
||||||
|
- Ellenőrzi, hogy a SubscriptionTier létezik-e.
|
||||||
|
- Ha van aktív OrganizationSubscription, inaktiválja (is_active=False).
|
||||||
|
- Létrehoz egy új OrganizationSubscription rekordot a megadott tier_id-val
|
||||||
|
és opcionális expires_at dátummal.
|
||||||
|
- Ha expires_at nincs megadva, a csomag rules.duration.days alapján számol.
|
||||||
|
"""
|
||||||
|
# 1. Ellenőrizzük a szervezetet
|
||||||
|
org_stmt = select(Organization).where(Organization.id == org_id)
|
||||||
|
org_result = await db.execute(org_stmt)
|
||||||
|
org = org_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not org:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Szervezet nem található ID-vel: {org_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Ellenőrizzük a SubscriptionTier-t
|
||||||
|
tier_stmt = select(SubscriptionTier).where(SubscriptionTier.id == payload.tier_id)
|
||||||
|
tier_result = await db.execute(tier_stmt)
|
||||||
|
tier = tier_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tier:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Előfizetési csomag nem található ID-vel: {payload.tier_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Inaktiváljuk a meglévő aktív subscription-öket
|
||||||
|
await db.execute(
|
||||||
|
sa_update(OrganizationSubscription)
|
||||||
|
.where(
|
||||||
|
OrganizationSubscription.org_id == org_id,
|
||||||
|
OrganizationSubscription.is_active == True,
|
||||||
|
)
|
||||||
|
.values(is_active=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Számoljuk a lejárati dátumot
|
||||||
|
now = datetime.utcnow()
|
||||||
|
if payload.expires_at:
|
||||||
|
valid_until = payload.expires_at
|
||||||
|
else:
|
||||||
|
# Ha nincs megadva, a csomag duration.days alapján számolunk
|
||||||
|
rules = tier.rules or {}
|
||||||
|
duration = rules.get("duration", {})
|
||||||
|
days = duration.get("days", 30) if isinstance(duration, dict) else 30
|
||||||
|
from datetime import timedelta
|
||||||
|
valid_until = now + timedelta(days=days)
|
||||||
|
|
||||||
|
# 5. Létrehozzuk az új subscription rekordot
|
||||||
|
new_sub = OrganizationSubscription(
|
||||||
|
org_id=org_id,
|
||||||
|
tier_id=payload.tier_id,
|
||||||
|
valid_from=now,
|
||||||
|
valid_until=valid_until,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(new_sub)
|
||||||
|
|
||||||
|
# 6. Frissítjük a Organization denormalizált mezőit is
|
||||||
|
org.subscription_tier_id = payload.tier_id
|
||||||
|
org.subscription_expires_at = valid_until
|
||||||
|
org.subscription_plan = tier.name
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(new_sub)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Admin {admin.id} ({admin.email}) updated subscription for org {org_id}: "
|
||||||
|
f"tier_id={payload.tier_id}, tier_name={tier.name}, "
|
||||||
|
f"valid_until={valid_until.isoformat()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": f"Előfizetés frissítve a '{org.full_name}' garázs számára.",
|
||||||
|
"subscription": {
|
||||||
|
"id": new_sub.id,
|
||||||
|
"org_id": new_sub.org_id,
|
||||||
|
"tier_id": new_sub.tier_id,
|
||||||
|
"tier_name": tier.name,
|
||||||
|
"valid_from": new_sub.valid_from.isoformat() if new_sub.valid_from else None,
|
||||||
|
"valid_until": new_sub.valid_until.isoformat() if new_sub.valid_until else None,
|
||||||
|
"is_active": new_sub.is_active,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from copy import deepcopy
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
@@ -38,6 +39,47 @@ logger = logging.getLogger("admin-packages")
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# P0 CRITICAL FIX: Recursive Deep Merge for JSONB fields
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def deep_merge_dict(base: dict, override: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Recursive deep merge of two dictionaries.
|
||||||
|
|
||||||
|
P0 CRITICAL FIX: Prevents data loss when updating nested JSONB fields.
|
||||||
|
Unlike a simple dict.update(), this function recursively merges nested
|
||||||
|
dictionaries instead of replacing them.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- If both values are dicts, recurse into them.
|
||||||
|
- If the override value is None, the base value is preserved.
|
||||||
|
- Otherwise, the override value replaces the base value (scalars, lists, etc.).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
base = {"pricing_zones": {"HU": {...}, "DEFAULT": {...}}}
|
||||||
|
override = {"pricing_zones": {"DEFAULT": {"monthly_price": 5.0}}}
|
||||||
|
|
||||||
|
Result: {"pricing_zones": {"HU": {...}, "DEFAULT": {"monthly_price": 5.0}}}
|
||||||
|
The HU zone is PRESERVED because it was not in the override.
|
||||||
|
"""
|
||||||
|
result = deepcopy(base)
|
||||||
|
|
||||||
|
for key, override_val in override.items():
|
||||||
|
if override_val is None:
|
||||||
|
# Preserve existing value when override is explicitly None
|
||||||
|
continue
|
||||||
|
if key in result and isinstance(result[key], dict) and isinstance(override_val, dict):
|
||||||
|
# Recursive merge for nested dicts
|
||||||
|
result[key] = deep_merge_dict(result[key], override_val)
|
||||||
|
else:
|
||||||
|
# Scalar, list, or new key — replace/add
|
||||||
|
result[key] = deepcopy(override_val)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# GET / — Listázza az összes SubscriptionTier rekordot
|
# GET / — Listázza az összes SubscriptionTier rekordot
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -149,6 +191,8 @@ async def create_package(
|
|||||||
- A `name` mező egyedi kell legyen (unique constraint).
|
- A `name` mező egyedi kell legyen (unique constraint).
|
||||||
- A `rules` mezőben a teljes SubscriptionRulesModel struktúrát kell megadni.
|
- A `rules` mezőben a teljes SubscriptionRulesModel struktúrát kell megadni.
|
||||||
- Alapértelmezetten a lifecycle.is_public = True lesz beállítva.
|
- Alapértelmezetten a lifecycle.is_public = True lesz beállítva.
|
||||||
|
- Singleton szabály: ha is_default_fallback=True, minden más csomag False lesz.
|
||||||
|
- Singleton szabály: ha trial_days_on_signup>0, minden más csomag 0-ra áll.
|
||||||
"""
|
"""
|
||||||
# Ellenőrizzük, hogy létezik-e már ilyen nevű csomag
|
# Ellenőrizzük, hogy létezik-e már ilyen nevű csomag
|
||||||
existing = await db.execute(
|
existing = await db.execute(
|
||||||
@@ -160,6 +204,22 @@ async def create_package(
|
|||||||
detail=f"Már létezik csomag ezzel a névvel: '{payload.name}'",
|
detail=f"Már létezik csomag ezzel a névvel: '{payload.name}'",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── P0 SINGLETON RULE: is_default_fallback ──
|
||||||
|
if payload.is_default_fallback:
|
||||||
|
await db.execute(
|
||||||
|
update(SubscriptionTier)
|
||||||
|
.where(SubscriptionTier.is_default_fallback == True)
|
||||||
|
.values(is_default_fallback=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── P0 SINGLETON RULE: trial_days_on_signup ──
|
||||||
|
if payload.trial_days_on_signup > 0:
|
||||||
|
await db.execute(
|
||||||
|
update(SubscriptionTier)
|
||||||
|
.where(SubscriptionTier.trial_days_on_signup > 0)
|
||||||
|
.values(trial_days_on_signup=0)
|
||||||
|
)
|
||||||
|
|
||||||
# Alapértelmezett lifecycle beállítás, ha nincs megadva
|
# Alapértelmezett lifecycle beállítás, ha nincs megadva
|
||||||
rules_dict = payload.rules.model_dump()
|
rules_dict = payload.rules.model_dump()
|
||||||
if rules_dict.get("lifecycle") is None:
|
if rules_dict.get("lifecycle") is None:
|
||||||
@@ -169,6 +229,10 @@ async def create_package(
|
|||||||
name=payload.name,
|
name=payload.name,
|
||||||
rules=rules_dict,
|
rules=rules_dict,
|
||||||
is_custom=payload.is_custom,
|
is_custom=payload.is_custom,
|
||||||
|
tier_level=payload.tier_level,
|
||||||
|
feature_capabilities=payload.feature_capabilities,
|
||||||
|
is_default_fallback=payload.is_default_fallback,
|
||||||
|
trial_days_on_signup=payload.trial_days_on_signup,
|
||||||
)
|
)
|
||||||
db.add(tier)
|
db.add(tier)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -176,7 +240,9 @@ async def create_package(
|
|||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Admin {admin.id} ({admin.email}) created subscription tier: "
|
f"Admin {admin.id} ({admin.email}) created subscription tier: "
|
||||||
f"name={tier.name}, id={tier.id}"
|
f"name={tier.name}, id={tier.id}, "
|
||||||
|
f"is_default_fallback={tier.is_default_fallback}, "
|
||||||
|
f"trial_days_on_signup={tier.trial_days_on_signup}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return SubscriptionTierResponse.model_validate(tier)
|
return SubscriptionTierResponse.model_validate(tier)
|
||||||
@@ -203,9 +269,12 @@ async def update_package(
|
|||||||
Meglévő előfizetési csomag részleges frissítése.
|
Meglévő előfizetési csomag részleges frissítése.
|
||||||
|
|
||||||
- Csak a megadott mezők frissülnek (PATCH szemantika).
|
- Csak a megadott mezők frissülnek (PATCH szemantika).
|
||||||
- A `rules` mezőben a teljes SubscriptionRulesModel-t át kell adni,
|
- A `rules` JSONB oszlop **deep merge** szemantikával frissül:
|
||||||
ha módosítani szeretnéd (a JSONB oszlop teljes egészében lecserélődik).
|
a meglévő kulcsok (pl. pricing_zones.HU, lifecycle.available_until)
|
||||||
|
NEM vesznek el, ha a payload nem tartalmazza őket.
|
||||||
- A lifecycle.is_public false-ra állításával a csomag elrejthető.
|
- A lifecycle.is_public false-ra állításával a csomag elrejthető.
|
||||||
|
- Singleton szabály: ha is_default_fallback=True, minden más csomag False lesz.
|
||||||
|
- Singleton szabály: ha trial_days_on_signup>0, minden más csomag 0-ra áll.
|
||||||
"""
|
"""
|
||||||
# Betöltjük a meglévő rekordot
|
# Betöltjük a meglévő rekordot
|
||||||
stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
|
stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
|
||||||
@@ -238,22 +307,66 @@ async def update_package(
|
|||||||
tier.name = update_data["name"]
|
tier.name = update_data["name"]
|
||||||
|
|
||||||
if "rules" in update_data and update_data["rules"] is not None:
|
if "rules" in update_data and update_data["rules"] is not None:
|
||||||
# A meglévő rules-ból megtartjuk a lifecycle-t, ha nem adtak újat
|
# ── P0 CRITICAL FIX: Deep merge instead of wholesale replace ──
|
||||||
|
# Previously: tier.rules = new_rules (wholesale replacement)
|
||||||
|
# This caused data loss: pricing_zones.HU, lifecycle.available_until, etc.
|
||||||
|
# were silently destroyed when the frontend sent incomplete payloads.
|
||||||
|
#
|
||||||
|
# Now: deep_merge_dict() recursively merges the incoming rules into
|
||||||
|
# the existing tier.rules, preserving all keys not present in the payload.
|
||||||
new_rules = update_data["rules"]
|
new_rules = update_data["rules"]
|
||||||
# Biztosítjuk a JSON kompatibilis szerializációt (model_dump(mode='json') már megtörtént)
|
if isinstance(new_rules, dict):
|
||||||
if isinstance(new_rules, dict) and new_rules.get("lifecycle") is None and tier.rules.get("lifecycle"):
|
merged_rules = deep_merge_dict(tier.rules or {}, new_rules)
|
||||||
new_rules["lifecycle"] = tier.rules["lifecycle"]
|
tier.rules = merged_rules
|
||||||
tier.rules = new_rules
|
else:
|
||||||
|
tier.rules = new_rules
|
||||||
|
|
||||||
if "is_custom" in update_data and update_data["is_custom"] is not None:
|
if "is_custom" in update_data and update_data["is_custom"] is not None:
|
||||||
tier.is_custom = update_data["is_custom"]
|
tier.is_custom = update_data["is_custom"]
|
||||||
|
|
||||||
|
if "tier_level" in update_data and update_data["tier_level"] is not None:
|
||||||
|
tier.tier_level = update_data["tier_level"]
|
||||||
|
|
||||||
|
if "feature_capabilities" in update_data and update_data["feature_capabilities"] is not None:
|
||||||
|
tier.feature_capabilities = update_data["feature_capabilities"]
|
||||||
|
|
||||||
|
# ── P0 SINGLETON RULE: is_default_fallback ──
|
||||||
|
if "is_default_fallback" in update_data and update_data["is_default_fallback"] is True:
|
||||||
|
# Clear fallback on all other tiers first
|
||||||
|
await db.execute(
|
||||||
|
update(SubscriptionTier)
|
||||||
|
.where(
|
||||||
|
SubscriptionTier.is_default_fallback == True,
|
||||||
|
SubscriptionTier.id != tier_id,
|
||||||
|
)
|
||||||
|
.values(is_default_fallback=False)
|
||||||
|
)
|
||||||
|
tier.is_default_fallback = True
|
||||||
|
elif "is_default_fallback" in update_data and update_data["is_default_fallback"] is False:
|
||||||
|
tier.is_default_fallback = False
|
||||||
|
|
||||||
|
# ── P0 SINGLETON RULE: trial_days_on_signup ──
|
||||||
|
if "trial_days_on_signup" in update_data and update_data["trial_days_on_signup"] is not None:
|
||||||
|
if update_data["trial_days_on_signup"] > 0:
|
||||||
|
# Clear trial on all other tiers first
|
||||||
|
await db.execute(
|
||||||
|
update(SubscriptionTier)
|
||||||
|
.where(
|
||||||
|
SubscriptionTier.trial_days_on_signup > 0,
|
||||||
|
SubscriptionTier.id != tier_id,
|
||||||
|
)
|
||||||
|
.values(trial_days_on_signup=0)
|
||||||
|
)
|
||||||
|
tier.trial_days_on_signup = update_data["trial_days_on_signup"]
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(tier)
|
await db.refresh(tier)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Admin {admin.id} ({admin.email}) updated subscription tier: "
|
f"Admin {admin.id} ({admin.email}) updated subscription tier: "
|
||||||
f"id={tier.id}, name={tier.name}"
|
f"id={tier.id}, name={tier.name}, "
|
||||||
|
f"is_default_fallback={tier.is_default_fallback}, "
|
||||||
|
f"trial_days_on_signup={tier.trial_days_on_signup}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return SubscriptionTierResponse.model_validate(tier)
|
return SubscriptionTierResponse.model_validate(tier)
|
||||||
|
|||||||
212
backend/app/api/v1/endpoints/admin_permissions.py
Normal file
212
backend/app/api/v1/endpoints/admin_permissions.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/admin_permissions.py
|
||||||
|
"""
|
||||||
|
🎯 RBAC Permission Matrix & Override Endpoints (P0)
|
||||||
|
|
||||||
|
Provides admin-facing endpoints for:
|
||||||
|
- GET /admin/permissions/matrix — Returns the full permission matrix
|
||||||
|
showing which roles can perform which actions.
|
||||||
|
- PATCH /admin/permissions/override/{org_id} — Updates custom permissions
|
||||||
|
for a specific organization (scope-based override).
|
||||||
|
|
||||||
|
All endpoints are protected by Depends(RequireRole(...)) so only
|
||||||
|
authorized staff (SUPERADMIN, ADMIN) can access them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Body
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
|
from app.api import deps
|
||||||
|
from app.api.deps import RequireRole, get_db, get_current_user
|
||||||
|
from app.models.identity import User, UserRole
|
||||||
|
from app.models.marketplace.organization import Organization
|
||||||
|
from app.services.rbac_service import (
|
||||||
|
rbac_service,
|
||||||
|
AdminAction,
|
||||||
|
ScopeType,
|
||||||
|
ADMIN_SCOPE_ACTIONS,
|
||||||
|
MODERATOR_SCOPE_ACTIONS,
|
||||||
|
SALES_REP_SCOPE_ACTIONS,
|
||||||
|
SERVICE_MGR_SCOPE_ACTIONS,
|
||||||
|
)
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Pydantic Schemas
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionMatrixEntry(BaseModel):
|
||||||
|
"""A single entry in the permission matrix."""
|
||||||
|
role: str
|
||||||
|
actions: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionMatrixResponse(BaseModel):
|
||||||
|
"""Full permission matrix response."""
|
||||||
|
matrix: List[PermissionMatrixEntry]
|
||||||
|
all_actions: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionOverrideRequest(BaseModel):
|
||||||
|
"""Request body for overriding organization permissions."""
|
||||||
|
action: str = Field(..., description="The action to override (e.g., EDIT_DATA)")
|
||||||
|
granted: bool = Field(..., description="Whether to grant or revoke this action")
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionOverrideResponse(BaseModel):
|
||||||
|
"""Response after updating custom permissions."""
|
||||||
|
status: str
|
||||||
|
org_id: int
|
||||||
|
action: str
|
||||||
|
granted: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Endpoints
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/admin/permissions/matrix",
|
||||||
|
response_model=PermissionMatrixResponse,
|
||||||
|
tags=["Admin Permissions"],
|
||||||
|
summary="Get the full RBAC permission matrix",
|
||||||
|
)
|
||||||
|
async def get_permission_matrix(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
_ = Depends(RequireRole([UserRole.SUPERADMIN, UserRole.ADMIN])),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
🔐 Returns the complete RBAC permission matrix.
|
||||||
|
|
||||||
|
Shows which roles are permitted to perform which admin actions.
|
||||||
|
Only SUPERADMIN and ADMIN roles can access this endpoint.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- matrix: List of {role, actions[]} entries
|
||||||
|
- all_actions: Complete list of all possible admin actions
|
||||||
|
"""
|
||||||
|
matrix = []
|
||||||
|
for role in [UserRole.SUPERADMIN, UserRole.ADMIN, UserRole.MODERATOR,
|
||||||
|
UserRole.SALES_REP, UserRole.SERVICE_MGR]:
|
||||||
|
actions = rbac_service.get_permitted_actions(role)
|
||||||
|
matrix.append(PermissionMatrixEntry(
|
||||||
|
role=role.value,
|
||||||
|
actions=sorted(actions),
|
||||||
|
))
|
||||||
|
|
||||||
|
all_actions = [a.value for a in AdminAction]
|
||||||
|
|
||||||
|
return PermissionMatrixResponse(
|
||||||
|
matrix=matrix,
|
||||||
|
all_actions=sorted(all_actions),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/admin/permissions/override/{org_id}",
|
||||||
|
response_model=PermissionOverrideResponse,
|
||||||
|
tags=["Admin Permissions"],
|
||||||
|
summary="Override custom permissions for an organization",
|
||||||
|
)
|
||||||
|
async def override_org_permission(
|
||||||
|
org_id: int,
|
||||||
|
payload: PermissionOverrideRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
_ = Depends(RequireRole([UserRole.SUPERADMIN, UserRole.ADMIN])),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
🔐 Updates custom permissions for a specific organization.
|
||||||
|
|
||||||
|
This endpoint allows SUPERADMIN and ADMIN to grant or revoke
|
||||||
|
specific actions for an organization, overriding the default
|
||||||
|
role-based permissions.
|
||||||
|
|
||||||
|
The RBACService.check_admin_access() is invoked to verify that
|
||||||
|
the requesting admin has scope-based access to the target
|
||||||
|
organization before applying the override.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_id: The target organization ID.
|
||||||
|
payload.action: The action to override (e.g., "EDIT_DATA").
|
||||||
|
payload.granted: True to grant, False to revoke.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation of the override operation.
|
||||||
|
"""
|
||||||
|
# 1. Validate that the action exists
|
||||||
|
valid_actions = {a.value for a in AdminAction}
|
||||||
|
if payload.action not in valid_actions:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid action '{payload.action}'. Valid actions: {sorted(valid_actions)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Verify scope-based access to the target organization
|
||||||
|
try:
|
||||||
|
await rbac_service.check_admin_access(
|
||||||
|
db=db,
|
||||||
|
user=current_user,
|
||||||
|
action=AdminAction.EDIT_DATA,
|
||||||
|
target_org_id=org_id,
|
||||||
|
)
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Fetch the organization
|
||||||
|
stmt = select(Organization).where(Organization.id == org_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
org = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not org:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Organization {org_id} not found.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Update custom permissions via the settings JSONB field
|
||||||
|
# The Organization model has a `settings` JSONB column that stores
|
||||||
|
# dynamic org-level configuration. We store permission overrides
|
||||||
|
# under a "permission_overrides" key within settings.
|
||||||
|
if not isinstance(org.settings, dict):
|
||||||
|
org.settings = {}
|
||||||
|
|
||||||
|
# Ensure the permission_overrides sub-key exists
|
||||||
|
if "permission_overrides" not in org.settings:
|
||||||
|
org.settings["permission_overrides"] = {}
|
||||||
|
|
||||||
|
# Apply the override
|
||||||
|
org.settings["permission_overrides"][payload.action] = payload.granted
|
||||||
|
|
||||||
|
# 5. Persist
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(org)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Permission override applied: org={org_id}, "
|
||||||
|
f"action={payload.action}, granted={payload.granted}, "
|
||||||
|
f"by_user={current_user.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return PermissionOverrideResponse(
|
||||||
|
status="success",
|
||||||
|
org_id=org_id,
|
||||||
|
action=payload.action,
|
||||||
|
granted=payload.granted,
|
||||||
|
message=f"Permission '{payload.action}' {'granted' if payload.granted else 'revoked'} for organization {org_id}.",
|
||||||
|
)
|
||||||
@@ -310,6 +310,37 @@ async def get_current_user_profile(
|
|||||||
return UserResponse.model_validate(response_data)
|
return UserResponse.model_validate(response_data)
|
||||||
|
|
||||||
|
|
||||||
|
class LanguageUpdateRequest(BaseModel):
|
||||||
|
preferred_language: str = Field(
|
||||||
|
..., min_length=2, max_length=10,
|
||||||
|
description="BCP 47 language code, e.g. 'hu', 'en'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me/language")
|
||||||
|
async def update_user_language(
|
||||||
|
request: LanguageUpdateRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
PATCH /auth/me/language
|
||||||
|
|
||||||
|
Update the authenticated user's preferred language.
|
||||||
|
Saves the language preference to the user's profile so it persists
|
||||||
|
across sessions and devices.
|
||||||
|
"""
|
||||||
|
current_user.preferred_language = request.preferred_language
|
||||||
|
db.add(current_user)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(current_user)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"preferred_language": current_user.preferred_language,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── 30 NAPOS FIÓKVISSZAÁLLÍTÁS (RESTORE) ──
|
# ── 30 NAPOS FIÓKVISSZAÁLLÍTÁS (RESTORE) ──
|
||||||
|
|
||||||
class RestoreRequestIn(BaseModel):
|
class RestoreRequestIn(BaseModel):
|
||||||
|
|||||||
@@ -579,6 +579,8 @@ async def create_expense(
|
|||||||
data=data,
|
data=data,
|
||||||
# === B2B VENDOR FIELDS ===
|
# === B2B VENDOR FIELDS ===
|
||||||
vendor_organization_id=expense.vendor_organization_id,
|
vendor_organization_id=expense.vendor_organization_id,
|
||||||
|
# P0 HYBRID VENDOR REFACTOR: service_provider_id az expense creation-ben
|
||||||
|
service_provider_id=expense.service_provider_id,
|
||||||
external_vendor_name=expense.external_vendor_name,
|
external_vendor_name=expense.external_vendor_name,
|
||||||
# === INVOICE DATES ===
|
# === INVOICE DATES ===
|
||||||
invoice_date=expense.invoice_date,
|
invoice_date=expense.invoice_date,
|
||||||
|
|||||||
54
backend/app/api/v1/endpoints/regions.py
Normal file
54
backend/app/api/v1/endpoints/regions.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/regions.py
|
||||||
|
"""
|
||||||
|
🌍 Global Region Registry API Endpoint.
|
||||||
|
|
||||||
|
Public endpoint that returns the list of active region configurations.
|
||||||
|
Used by the frontend to drive locale-aware formatting (dates, numbers, currencies)
|
||||||
|
and financial calculations (VAT, pricing).
|
||||||
|
|
||||||
|
Schema: system.region_config
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.deps import get_db
|
||||||
|
from app.models.core_logic import RegionConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/regions", response_model=List[dict])
|
||||||
|
async def list_active_regions(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
GET /api/v1/system/regions
|
||||||
|
|
||||||
|
Returns all active region configurations from the system.region_config table.
|
||||||
|
This is a public endpoint (no auth required) used by the frontend to:
|
||||||
|
- Format dates, numbers, and currencies according to locale
|
||||||
|
- Calculate VAT rates for pricing
|
||||||
|
- Determine timezone for date displays
|
||||||
|
"""
|
||||||
|
stmt = select(RegionConfig).where(RegionConfig.is_active == True).order_by(RegionConfig.country_code)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
regions = result.scalars().all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"country_code": r.country_code,
|
||||||
|
"name": r.name,
|
||||||
|
"currency": r.currency,
|
||||||
|
"default_vat_rate": r.default_vat_rate,
|
||||||
|
"locale_code": r.locale_code,
|
||||||
|
"timezone": r.timezone,
|
||||||
|
"is_active": r.is_active,
|
||||||
|
}
|
||||||
|
for r in regions
|
||||||
|
]
|
||||||
@@ -5,17 +5,20 @@ Subscription Public API Endpoints.
|
|||||||
Provides:
|
Provides:
|
||||||
- GET /public — Public package catalog with dynamic pricing resolution
|
- GET /public — Public package catalog with dynamic pricing resolution
|
||||||
based on the user's organization country (or fallback to DEFAULT zone).
|
based on the user's organization country (or fallback to DEFAULT zone).
|
||||||
|
- GET /my — Current user's/org's subscription details with feature flags
|
||||||
|
- GET /feature-flags — All feature flags resolved for the current user
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.api import deps
|
from app.api import deps
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.core_logic import SubscriptionTier
|
from app.models.core_logic import SubscriptionTier, OrganizationSubscription, UserSubscription
|
||||||
from app.models.identity import User
|
from app.models.identity import User
|
||||||
from app.models.marketplace.organization import Organization
|
from app.models.marketplace.organization import Organization
|
||||||
from app.schemas.subscription import (
|
from app.schemas.subscription import (
|
||||||
@@ -23,6 +26,7 @@ from app.schemas.subscription import (
|
|||||||
SubscriptionTierResponse,
|
SubscriptionTierResponse,
|
||||||
PricingZoneModel,
|
PricingZoneModel,
|
||||||
)
|
)
|
||||||
|
from app.services.subscription_service import SubscriptionService, SUBSCRIPTION_FEATURES
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -174,3 +178,179 @@ async def get_public_subscriptions(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ── P0 Feature Flag Endpoints ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/my",
|
||||||
|
summary="Saját előfizetés részletei",
|
||||||
|
description=(
|
||||||
|
"Visszaadja a bejelentkezett felhasználó aktuális előfizetési adatait. "
|
||||||
|
"Először a szervezeti előfizetést (OrganizationSubscription) ellenőrzi, "
|
||||||
|
"ha nincs, akkor a felhasználói előfizetést (UserSubscription). "
|
||||||
|
"Tartalmazza a tier adatokat, allowances, pricing, feature_capabilities mezőket."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def get_my_subscription(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(deps.get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Visszaadja a felhasználó aktuális előfizetési adatait.
|
||||||
|
|
||||||
|
**Feloldási sorrend:**
|
||||||
|
1. Aktív szervezet OrganizationSubscription (ha van active_organization_id)
|
||||||
|
2. UserSubscription (ha nincs org subscription)
|
||||||
|
3. Fallback: alapértelmezett 'free' adatok
|
||||||
|
|
||||||
|
**Response struktúra:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subscription": {
|
||||||
|
"tier_id": 16,
|
||||||
|
"tier_name": "corp_premium_v1",
|
||||||
|
"display_name": "Céges Prémium",
|
||||||
|
"valid_from": "...",
|
||||||
|
"valid_until": "...",
|
||||||
|
"is_active": true,
|
||||||
|
"allowances": {"max_vehicles": 20, ...},
|
||||||
|
"pricing": {"monthly_price": 29.99, ...},
|
||||||
|
"feature_capabilities": {"can_export_data": true, ...}
|
||||||
|
},
|
||||||
|
"tier": "premium",
|
||||||
|
"features": {
|
||||||
|
"export_csv": true,
|
||||||
|
"analytics_tco": true,
|
||||||
|
...
|
||||||
|
},
|
||||||
|
"source": "organization" | "user" | "default"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
user_id = current_user.id
|
||||||
|
active_org_id = getattr(current_user, "active_organization_id", None)
|
||||||
|
|
||||||
|
subscription_data = None
|
||||||
|
source = "default"
|
||||||
|
|
||||||
|
# 1. Try org subscription first
|
||||||
|
if active_org_id:
|
||||||
|
org_sub = await SubscriptionService.get_org_subscription_details(db, active_org_id)
|
||||||
|
if org_sub:
|
||||||
|
subscription_data = org_sub
|
||||||
|
source = "organization"
|
||||||
|
|
||||||
|
# 2. Fallback to user subscription
|
||||||
|
if not subscription_data:
|
||||||
|
user_sub = await SubscriptionService.get_user_subscription_details(db, user_id)
|
||||||
|
if user_sub:
|
||||||
|
subscription_data = user_sub
|
||||||
|
source = "user"
|
||||||
|
|
||||||
|
# 3. Resolve tier and feature flags
|
||||||
|
user_tier = await SubscriptionService.get_user_tier(db, user_id)
|
||||||
|
feature_flags = await SubscriptionService.get_user_feature_flags(db, user_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"subscription": subscription_data,
|
||||||
|
"tier": user_tier,
|
||||||
|
"features": feature_flags.get("features", {}),
|
||||||
|
"expires_at": feature_flags.get("expires_at"),
|
||||||
|
"source": source,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/feature-flags",
|
||||||
|
summary="Feature flag-ek listázása",
|
||||||
|
description=(
|
||||||
|
"Visszaadja az összes feature flag állapotát a felhasználó "
|
||||||
|
"előfizetési szintje alapján. A frontend useFeatureFlag() composable "
|
||||||
|
"ezt a végpontot használja a funkciók engedélyezéséhez/letiltásához."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def get_feature_flags(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(deps.get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Visszaadja az összes feature flag állapotát.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tier": "premium",
|
||||||
|
"features": {
|
||||||
|
"export_csv": true,
|
||||||
|
"analytics_tco": true,
|
||||||
|
"analytics_detailed": false,
|
||||||
|
"multi_vehicle": true,
|
||||||
|
"unlimited_vehicles": true,
|
||||||
|
"subscription_management": true,
|
||||||
|
"vehicle_tracking": true,
|
||||||
|
"cost_tracking": true,
|
||||||
|
"service_booking": true,
|
||||||
|
"advanced_reports": true,
|
||||||
|
"team_management": true,
|
||||||
|
"priority_support": true,
|
||||||
|
"white_label": false,
|
||||||
|
"api_webhooks": false,
|
||||||
|
"custom_integrations": false
|
||||||
|
},
|
||||||
|
"expires_at": "2026-07-24T12:00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
feature_flags = await SubscriptionService.get_user_feature_flags(
|
||||||
|
db, current_user.id
|
||||||
|
)
|
||||||
|
return feature_flags
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/check/{feature_key}",
|
||||||
|
summary="Egy adott feature flag ellenőrzése",
|
||||||
|
description=(
|
||||||
|
"Ellenőrzi, hogy a felhasználó hozzáfér-e egy adott funkcióhoz. "
|
||||||
|
"Használható a frontend által komponens szintű guard-okhoz."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def check_feature_access(
|
||||||
|
feature_key: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(deps.get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Ellenőrzi egy adott feature elérhetőségét.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"feature_key": "analytics_tco",
|
||||||
|
"granted": true,
|
||||||
|
"tier": "premium",
|
||||||
|
"required_tier": "premium"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Hibák:
|
||||||
|
- 404: Ismeretlen feature_key
|
||||||
|
"""
|
||||||
|
if feature_key not in SUBSCRIPTION_FEATURES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Unknown feature key: {feature_key}",
|
||||||
|
)
|
||||||
|
|
||||||
|
user_tier = await SubscriptionService.get_user_tier(db, current_user.id)
|
||||||
|
granted = SubscriptionService.can_access_feature(user_tier, feature_key)
|
||||||
|
required_tier = SUBSCRIPTION_FEATURES[feature_key]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"feature_key": feature_key,
|
||||||
|
"granted": granted,
|
||||||
|
"tier": user_tier,
|
||||||
|
"required_tier": required_tier,
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from .vehicle.asset import Asset, AssetCatalog, AssetEvent, AssetAssignment, Ass
|
|||||||
from .fleet_finance import AssetCost, CostCategory, AssetFinancials, InsuranceProvider, VehicleInsurancePolicy, VehicleTaxObligation
|
from .fleet_finance import AssetCost, CostCategory, AssetFinancials, InsuranceProvider, VehicleInsurancePolicy, VehicleTaxObligation
|
||||||
|
|
||||||
# 6. Üzleti logika és előfizetések
|
# 6. Üzleti logika és előfizetések
|
||||||
from .core_logic import SubscriptionTier, OrganizationSubscription, CreditTransaction, ServiceSpecialty
|
from .core_logic import SubscriptionTier, OrganizationSubscription, CreditTransaction, ServiceSpecialty, RegionConfig
|
||||||
from .marketplace.payment import PaymentIntent, PaymentIntentStatus
|
from .marketplace.payment import PaymentIntent, PaymentIntentStatus
|
||||||
from .marketplace.finance import Issuer, IssuerType
|
from .marketplace.finance import Issuer, IssuerType
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ __all__ = [
|
|||||||
"ServiceProvider", "Vote", "Competition", "UserScore", "ServiceReview", "ModerationStatus", "SourceType",
|
"ServiceProvider", "Vote", "Competition", "UserScore", "ServiceReview", "ModerationStatus", "SourceType",
|
||||||
|
|
||||||
"Document", "Translation", "PendingAction", "ActionStatus",
|
"Document", "Translation", "PendingAction", "ActionStatus",
|
||||||
"SubscriptionTier", "OrganizationSubscription", "CreditTransaction", "ServiceSpecialty",
|
"SubscriptionTier", "OrganizationSubscription", "CreditTransaction", "ServiceSpecialty", "RegionConfig",
|
||||||
"PaymentIntent", "PaymentIntentStatus",
|
"PaymentIntent", "PaymentIntentStatus",
|
||||||
"AuditLog", "VehicleOwnership", "LogSeverity",
|
"AuditLog", "VehicleOwnership", "LogSeverity",
|
||||||
"SecurityAuditLog", "OperationalLog", "ProcessLog",
|
"SecurityAuditLog", "OperationalLog", "ProcessLog",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# /opt/docker/dev/service_finder/backend/app/models/core_logic.py
|
# /opt/docker/dev/service_finder/backend/app/models/core_logic.py
|
||||||
from typing import Optional, List, Any
|
from typing import Optional, List, Any
|
||||||
from datetime import datetime # Python saját típusa a típusjelöléshez
|
from datetime import datetime # Python saját típusa a típusjelöléshez
|
||||||
from sqlalchemy import String, Integer, ForeignKey, Boolean, DateTime, Numeric, text
|
from sqlalchemy import String, Integer, ForeignKey, Boolean, DateTime, Numeric, text, Float
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
@@ -9,9 +9,65 @@ from sqlalchemy.sql import func
|
|||||||
# MB 2.0: A központi aszinkron adatbázis motorból húzzuk be a Base-t
|
# MB 2.0: A központi aszinkron adatbázis motorból húzzuk be a Base-t
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# P0 SYSTEM ARCHITECTURE: Global Region Registry
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class RegionConfig(Base):
|
||||||
|
"""
|
||||||
|
🌍 Global Region Configuration Registry.
|
||||||
|
|
||||||
|
Central table for L10n/i18n configuration. Drives all UI formatting
|
||||||
|
(dates, numbers, currencies) and financial calculations (VAT, pricing).
|
||||||
|
|
||||||
|
Each row represents a country/region with its own locale, currency,
|
||||||
|
VAT rate, and timezone settings.
|
||||||
|
|
||||||
|
Schema: system.region_config
|
||||||
|
"""
|
||||||
|
__tablename__ = "region_config"
|
||||||
|
__table_args__ = {"schema": "system"}
|
||||||
|
|
||||||
|
country_code: Mapped[str] = mapped_column(
|
||||||
|
String(10), primary_key=True, index=True,
|
||||||
|
comment="ISO 3166-1 alpha-2 country code, e.g. 'HU', 'GB', or 'DEFAULT' for fallback"
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(
|
||||||
|
String, nullable=False,
|
||||||
|
comment="Human-readable region name, e.g. 'Hungary', 'United Kingdom'"
|
||||||
|
)
|
||||||
|
currency: Mapped[str] = mapped_column(
|
||||||
|
String(3), nullable=False,
|
||||||
|
comment="ISO 4217 currency code, e.g. 'HUF', 'GBP', 'EUR'"
|
||||||
|
)
|
||||||
|
default_vat_rate: Mapped[float] = mapped_column(
|
||||||
|
Float, nullable=False, default=0.0,
|
||||||
|
comment="Default VAT rate as decimal, e.g. 27.0 for 27%"
|
||||||
|
)
|
||||||
|
locale_code: Mapped[str] = mapped_column(
|
||||||
|
String(10), nullable=False,
|
||||||
|
comment="BCP 47 locale code, e.g. 'hu-HU', 'en-GB', 'de-DE'"
|
||||||
|
)
|
||||||
|
timezone: Mapped[str] = mapped_column(
|
||||||
|
String(50), nullable=False,
|
||||||
|
comment="IANA timezone, e.g. 'Europe/Budapest', 'Europe/London'"
|
||||||
|
)
|
||||||
|
is_active: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=True, server_default=text("true"),
|
||||||
|
comment="Soft-toggle for region availability"
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime(timezone=True), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionTier(Base):
|
class SubscriptionTier(Base):
|
||||||
"""
|
"""
|
||||||
Előfizetési csomagok definíciója (pl. Free, Premium, VIP).
|
Előfizetési csomagok definíciója (pl. Free, Premium, VIP).
|
||||||
A csomagok határozzák meg a korlátokat (pl. max járműszám).
|
A csomagok határozzák meg a korlátokat (pl. max járműszám).
|
||||||
"""
|
"""
|
||||||
__tablename__ = "subscription_tiers"
|
__tablename__ = "subscription_tiers"
|
||||||
@@ -32,6 +88,42 @@ class SubscriptionTier(Base):
|
|||||||
server_default=text("'{}'::jsonb")
|
server_default=text("'{}'::jsonb")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── P0 AUDIT FIX: Database-driven tier level instead of hardcoded string mapping ──
|
||||||
|
# Numeric level for the entitlement hierarchy: 0=free, 1=premium, 2=enterprise.
|
||||||
|
# This replaces the hardcoded _map_tier_name() string-matching logic.
|
||||||
|
# New tiers can be added with any name; the tier_level determines their position.
|
||||||
|
tier_level: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
default=0,
|
||||||
|
server_default=text("0"),
|
||||||
|
comment="Entitlement hierarchy level: 0=free, 1=premium, 2=enterprise"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── P0 SINGLETON PACKAGE RULES: is_default_fallback ──
|
||||||
|
# Ha True, ez a csomag az alapértelmezett fallback csomag, amit a
|
||||||
|
# subscription_service használ, ha a user előfizetése lejárt.
|
||||||
|
# MAXIMUM EGY csomag lehet True értékkel (singleton).
|
||||||
|
is_default_fallback: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
nullable=False,
|
||||||
|
default=False,
|
||||||
|
server_default=text("false"),
|
||||||
|
comment="Singleton: maximum one tier can be the default fallback package"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── P0 SINGLETON PACKAGE RULES: trial_days_on_signup ──
|
||||||
|
# Ha > 0, ez a csomag a próbaidős csomag, amit új regisztrációnál
|
||||||
|
# automatikusan hozzárendelünk a felhasználóhoz.
|
||||||
|
# MAXIMUM EGY csomag lehet > 0 értékkel (singleton).
|
||||||
|
trial_days_on_signup: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
default=0,
|
||||||
|
server_default=text("0"),
|
||||||
|
comment="Singleton: maximum one tier can have trial_days_on_signup > 0"
|
||||||
|
)
|
||||||
|
|
||||||
class OrganizationSubscription(Base):
|
class OrganizationSubscription(Base):
|
||||||
"""
|
"""
|
||||||
Szervezetek aktuális előfizetései és azok érvényessége.
|
Szervezetek aktuális előfizetései és azok érvényessége.
|
||||||
@@ -56,6 +148,13 @@ class OrganizationSubscription(Base):
|
|||||||
# Példa: {"extra_vehicles": 1, "extra_garages": 1, "extra_credits": 100}
|
# Példa: {"extra_vehicles": 1, "extra_garages": 1, "extra_credits": 100}
|
||||||
extra_allowances: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"), default=dict)
|
extra_allowances: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"), default=dict)
|
||||||
|
|
||||||
|
# ── P0 FEATURE FLAG: relationship a SubscriptionTier-hez ──
|
||||||
|
tier: Mapped[Optional["SubscriptionTier"]] = relationship(
|
||||||
|
"SubscriptionTier",
|
||||||
|
foreign_keys=[tier_id],
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
||||||
class UserSubscription(Base):
|
class UserSubscription(Base):
|
||||||
"""
|
"""
|
||||||
Felhasználók aktuális előfizetései és azok érvényessége.
|
Felhasználók aktuális előfizetései és azok érvényessége.
|
||||||
@@ -78,6 +177,13 @@ class UserSubscription(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
# ── P0 FEATURE FLAG: relationship a SubscriptionTier-hez ──
|
||||||
|
tier: Mapped[Optional["SubscriptionTier"]] = relationship(
|
||||||
|
"SubscriptionTier",
|
||||||
|
foreign_keys=[tier_id],
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CreditTransaction(Base):
|
class CreditTransaction(Base):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ if TYPE_CHECKING:
|
|||||||
from app.models.vehicle.asset import Asset
|
from app.models.vehicle.asset import Asset
|
||||||
from app.models.marketplace.organization import Organization
|
from app.models.marketplace.organization import Organization
|
||||||
from app.models.system.document import Document
|
from app.models.system.document import Document
|
||||||
|
from app.models.identity.social import ServiceProvider
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -138,6 +139,10 @@ class AssetCost(Base):
|
|||||||
vendor_organization_id: Mapped[Optional[int]] = mapped_column(
|
vendor_organization_id: Mapped[Optional[int]] = mapped_column(
|
||||||
Integer, ForeignKey("fleet.organizations.id"), nullable=True, index=True
|
Integer, ForeignKey("fleet.organizations.id"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
|
# P0 HYBRID VENDOR REFACTOR: Új elsődleges hivatkozás a marketplace.service_providers táblára
|
||||||
|
service_provider_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("marketplace.service_providers.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
# Szabadon gépelhető beszállító név (ha nincs a rendszerben)
|
# Szabadon gépelhető beszállító név (ha nincs a rendszerben)
|
||||||
external_vendor_name: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
external_vendor_name: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
||||||
|
|
||||||
@@ -162,6 +167,11 @@ class AssetCost(Base):
|
|||||||
"Organization", foreign_keys=[vendor_organization_id]
|
"Organization", foreign_keys=[vendor_organization_id]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# P0 HYBRID VENDOR REFACTOR: ServiceProvider kapcsolat
|
||||||
|
service_provider: Mapped[Optional["ServiceProvider"]] = relationship(
|
||||||
|
"ServiceProvider", foreign_keys=[service_provider_id]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# AssetFinancials (áthelyezve vehicle -> fleet_finance, kibővítve)
|
# AssetFinancials (áthelyezve vehicle -> fleet_finance, kibővítve)
|
||||||
|
|||||||
@@ -170,6 +170,14 @@ class User(Base):
|
|||||||
|
|
||||||
scope_level: Mapped[str] = mapped_column(String(30), server_default="individual")
|
scope_level: Mapped[str] = mapped_column(String(30), server_default="individual")
|
||||||
scope_id: Mapped[Optional[str]] = mapped_column(String(50))
|
scope_id: Mapped[Optional[str]] = mapped_column(String(50))
|
||||||
|
|
||||||
|
# ── P0: Scope-Based Admin RBAC ──
|
||||||
|
# Defines the admin's scope type and value for organization-level access control.
|
||||||
|
# scope_type: "GLOBAL", "REGION", or "SEGMENT"
|
||||||
|
# scope_value: The specific value (e.g., "Pest_County", "Fleet", "Budapest")
|
||||||
|
scope_type: Mapped[Optional[str]] = mapped_column(String(30), nullable=True, server_default=None)
|
||||||
|
scope_value: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, server_default=None)
|
||||||
|
|
||||||
custom_permissions: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
custom_permissions: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
||||||
|
|
||||||
# E-mail történetiség és alternatív e-mailek (MB 2.0.1)
|
# E-mail történetiség és alternatív e-mailek (MB 2.0.1)
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ class SourceType(str, enum.Enum):
|
|||||||
api_import = "import"
|
api_import = "import"
|
||||||
|
|
||||||
class ServiceProvider(Base):
|
class ServiceProvider(Base):
|
||||||
""" Közösség által beküldött szolgáltatók (v1.3.1). """
|
""" Közösség által beküldött szolgáltatók (v1.3.1).
|
||||||
|
|
||||||
|
P0 HYBRID VENDOR REFACTOR: Kibővítve atomizált címmezőkkel és
|
||||||
|
kapcsolatfelvételi adatokkal a quick_add_provider() refactorhoz.
|
||||||
|
"""
|
||||||
__tablename__ = "service_providers"
|
__tablename__ = "service_providers"
|
||||||
__table_args__ = {"schema": "marketplace"}
|
__table_args__ = {"schema": "marketplace"}
|
||||||
|
|
||||||
@@ -29,6 +33,19 @@ class ServiceProvider(Base):
|
|||||||
address: Mapped[str] = mapped_column(String, nullable=False)
|
address: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
category: Mapped[Optional[str]] = mapped_column(String)
|
category: Mapped[Optional[str]] = mapped_column(String)
|
||||||
|
|
||||||
|
# === P0 HYBRID VENDOR REFACTOR: Atomizált címmezők ===
|
||||||
|
city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
address_zip: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||||
|
address_street_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True)
|
||||||
|
address_street_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||||
|
address_house_number: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||||
|
plus_code: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||||
|
|
||||||
|
# === P0 HYBRID VENDOR REFACTOR: Kapcsolatfelvételi adatok ===
|
||||||
|
contact_phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||||
|
contact_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
website: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
status: Mapped[ModerationStatus] = mapped_column(
|
status: Mapped[ModerationStatus] = mapped_column(
|
||||||
PG_ENUM(ModerationStatus, name="moderation_status", inherit_schema=True),
|
PG_ENUM(ModerationStatus, name="moderation_status", inherit_schema=True),
|
||||||
default=ModerationStatus.pending
|
default=ModerationStatus.pending
|
||||||
|
|||||||
@@ -113,6 +113,12 @@ class Organization(Base):
|
|||||||
country_code: Mapped[str] = mapped_column(String(2), default="HU")
|
country_code: Mapped[str] = mapped_column(String(2), default="HU")
|
||||||
language: Mapped[str] = mapped_column(String(5), default="hu")
|
language: Mapped[str] = mapped_column(String(5), default="hu")
|
||||||
|
|
||||||
|
# ── P0: Scope-Based Admin RBAC ──
|
||||||
|
# Geographic region for scope-based admin access (e.g., "Pest_County", "Budapest")
|
||||||
|
region: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
|
||||||
|
# Business segment for scope-based admin access (e.g., "Fleet", "Dealer", "Service")
|
||||||
|
segment: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
|
||||||
|
|
||||||
address_zip: Mapped[Optional[str]] = mapped_column(String(10))
|
address_zip: Mapped[Optional[str]] = mapped_column(String(10))
|
||||||
address_city: Mapped[Optional[str]] = mapped_column(String(100))
|
address_city: Mapped[Optional[str]] = mapped_column(String(100))
|
||||||
address_street_name: Mapped[Optional[str]] = mapped_column(String(150))
|
address_street_name: Mapped[Optional[str]] = mapped_column(String(150))
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ class ServiceProfile(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
organization_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("fleet.organizations.id"), unique=True)
|
organization_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("fleet.organizations.id"), unique=True)
|
||||||
|
# P0 HYBRID VENDOR REFACTOR: Új elsődleges hivatkozás a marketplace.service_providers táblára
|
||||||
|
service_provider_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("marketplace.service_providers.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
parent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("marketplace.service_profiles.id"))
|
parent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("marketplace.service_profiles.id"))
|
||||||
|
|
||||||
fingerprint: Mapped[str] = mapped_column(String(255), index=True, nullable=False)
|
fingerprint: Mapped[str] = mapped_column(String(255), index=True, nullable=False)
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ class AssetCostBase(BaseModel):
|
|||||||
|
|
||||||
# === BESZÁLLÍTÓI ADATOK (B2B expansion) ===
|
# === BESZÁLLÍTÓI ADATOK (B2B expansion) ===
|
||||||
vendor_organization_id: Optional[int] = Field(None, description="Beszállító szervezet ID (FK fleet.organizations)")
|
vendor_organization_id: Optional[int] = Field(None, description="Beszállító szervezet ID (FK fleet.organizations)")
|
||||||
|
# P0 HYBRID VENDOR REFACTOR: Új elsődleges hivatkozás a marketplace.service_providers táblára
|
||||||
|
service_provider_id: Optional[int] = Field(None, description="Szolgáltató ID (FK marketplace.service_providers) - P0 elsődleges")
|
||||||
external_vendor_name: Optional[str] = Field(None, max_length=200, description="Szabadon gépelhető beszállító név (ha nincs a rendszerben)")
|
external_vendor_name: Optional[str] = Field(None, max_length=200, description="Szabadon gépelhető beszállító név (ha nincs a rendszerben)")
|
||||||
|
|
||||||
# === KÖNYVELÉSI DÁTUMOK ===
|
# === KÖNYVELÉSI DÁTUMOK ===
|
||||||
@@ -99,6 +101,8 @@ class AssetCostUpdate(BaseModel):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
invoice_number: Optional[str] = None
|
invoice_number: Optional[str] = None
|
||||||
vendor_organization_id: Optional[int] = None
|
vendor_organization_id: Optional[int] = None
|
||||||
|
# P0 HYBRID VENDOR REFACTOR: service_provider_id az update-hez
|
||||||
|
service_provider_id: Optional[int] = None
|
||||||
external_vendor_name: Optional[str] = None
|
external_vendor_name: Optional[str] = None
|
||||||
invoice_date: Optional[date] = None
|
invoice_date: Optional[date] = None
|
||||||
fulfillment_date: Optional[date] = None
|
fulfillment_date: Optional[date] = None
|
||||||
@@ -121,5 +125,8 @@ class AssetCostResponse(AssetCostBase):
|
|||||||
|
|
||||||
# Enriched vendor name (resolved from vendor_organization_id relationship)
|
# Enriched vendor name (resolved from vendor_organization_id relationship)
|
||||||
vendor_name: Optional[str] = None # Resolved from Organization.name via vendor_organization_id
|
vendor_name: Optional[str] = None # Resolved from Organization.name via vendor_organization_id
|
||||||
|
|
||||||
|
# P0 HYBRID VENDOR REFACTOR: service_provider_id a response-ban
|
||||||
|
service_provider_id: Optional[int] = None
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -156,6 +156,10 @@ class SubscriptionTierResponse(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
rules: SubscriptionRulesModel
|
rules: SubscriptionRulesModel
|
||||||
is_custom: bool
|
is_custom: bool
|
||||||
|
tier_level: int = Field(default=0, description="Entitlement hierarchy level: 0=free, 1=premium, 2=enterprise")
|
||||||
|
feature_capabilities: dict = Field(default_factory=dict, description="Feature capability flags JSONB")
|
||||||
|
is_default_fallback: bool = Field(default=False, description="Singleton: maximum one tier can be the default fallback")
|
||||||
|
trial_days_on_signup: int = Field(default=0, ge=0, description="Singleton: maximum one tier can have trial_days_on_signup > 0")
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -182,6 +186,10 @@ class SubscriptionTierCreate(BaseModel):
|
|||||||
name: str = Field(..., min_length=2, max_length=100, description="Csomag neve (pl. private_pro_v2)")
|
name: str = Field(..., min_length=2, max_length=100, description="Csomag neve (pl. private_pro_v2)")
|
||||||
rules: SubscriptionRulesModel
|
rules: SubscriptionRulesModel
|
||||||
is_custom: bool = Field(default=False, description="Egyedi (custom) csomag-e")
|
is_custom: bool = Field(default=False, description="Egyedi (custom) csomag-e")
|
||||||
|
tier_level: int = Field(default=0, ge=0, description="Entitlement hierarchy level: 0=free, 1=premium, 2=enterprise")
|
||||||
|
feature_capabilities: dict = Field(default_factory=dict, description="Feature capability flags JSONB")
|
||||||
|
is_default_fallback: bool = Field(default=False, description="Singleton: maximum one tier can be the default fallback")
|
||||||
|
trial_days_on_signup: int = Field(default=0, ge=0, description="Singleton: maximum one tier can have trial_days_on_signup > 0")
|
||||||
|
|
||||||
@field_validator("name")
|
@field_validator("name")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -196,6 +204,10 @@ class SubscriptionTierUpdate(BaseModel):
|
|||||||
name: Optional[str] = Field(default=None, min_length=2, max_length=100, description="Új csomagnév")
|
name: Optional[str] = Field(default=None, min_length=2, max_length=100, description="Új csomagnév")
|
||||||
rules: Optional[SubscriptionRulesModel] = Field(default=None, description="Frissített rules JSONB")
|
rules: Optional[SubscriptionRulesModel] = Field(default=None, description="Frissített rules JSONB")
|
||||||
is_custom: Optional[bool] = Field(default=None, description="Egyedi csomag jelölés")
|
is_custom: Optional[bool] = Field(default=None, description="Egyedi csomag jelölés")
|
||||||
|
tier_level: Optional[int] = Field(default=None, ge=0, description="Entitlement hierarchy level")
|
||||||
|
feature_capabilities: Optional[dict] = Field(default=None, description="Feature capability flags JSONB")
|
||||||
|
is_default_fallback: Optional[bool] = Field(default=None, description="Singleton: maximum one tier can be the default fallback")
|
||||||
|
trial_days_on_signup: Optional[int] = Field(default=None, ge=0, description="Singleton: maximum one tier can have trial_days_on_signup > 0")
|
||||||
|
|
||||||
@field_validator("name")
|
@field_validator("name")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -478,22 +478,19 @@ async def quick_add_provider(
|
|||||||
"""
|
"""
|
||||||
Gyors szolgáltató felvétel crowdsourcingból.
|
Gyors szolgáltató felvétel crowdsourcingból.
|
||||||
|
|
||||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
P0 HYBRID VENDOR REFACTOR (2026-06-25):
|
||||||
- data.street -> data.address_street_name, data.address_street_type, data.address_house_number
|
========================================
|
||||||
- A kapcsolatfelvételi adatok a ServiceProfile-ba kerülnek.
|
A quick_add_provider() MOST MÁR ServiceProvider-t hoz létre, NEM Organization-t.
|
||||||
|
Ez a P0 Architecture Refactor (Option C - Hybrid) része.
|
||||||
|
|
||||||
P0 CRITICAL BUGFIX (2026-06-17): category_id most már opcionális.
|
Új folyamat:
|
||||||
- Ha category_id nincs megadva, a primary_category kihagyásra kerül.
|
|
||||||
- A 4-level kategória rendszerből érkező category_ids és new_tags
|
|
||||||
feldolgozásra kerülnek a ServiceExpertise kapcsolatok létrehozásához.
|
|
||||||
|
|
||||||
Folyamat:
|
|
||||||
1. (Opcionális) Elsődleges kategória ellenőrzése (expertise_tags)
|
1. (Opcionális) Elsődleges kategória ellenőrzése (expertise_tags)
|
||||||
2. Organization létrehozása (is_verified=False, org_type='service_provider')
|
2. ServiceProvider létrehozása (marketplace.service_providers)
|
||||||
3. ServiceProfile létrehozása
|
3. ServiceProfile létrehozása (service_provider_id hivatkozással)
|
||||||
4. ServiceExpertise kapcsolatok létrehozása (category_id + category_ids + new_tags)
|
4. ServiceExpertise kapcsolatok létrehozása (category_id + category_ids + new_tags)
|
||||||
5. Branch létrehozása a címmel
|
5. 🎮 Gamification pontozás
|
||||||
6. OrganizationMember létrehozása a felhasználóhoz
|
|
||||||
|
NINCS: Organization, Branch, OrganizationMember létrehozás!
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: AsyncSession
|
db: AsyncSession
|
||||||
@@ -501,7 +498,7 @@ async def quick_add_provider(
|
|||||||
user_id: A felhasználó ID-ja
|
user_id: A felhasználó ID-ja
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ProviderQuickAddResponse
|
ProviderQuickAddResponse (id = ServiceProvider.id)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: Ha a kategória nem létezik
|
ValueError: Ha a kategória nem létezik
|
||||||
@@ -513,47 +510,39 @@ async def quick_add_provider(
|
|||||||
if not category:
|
if not category:
|
||||||
raise ValueError(f"Category with id={data.category_id} not found")
|
raise ValueError(f"Category with id={data.category_id} not found")
|
||||||
primary_category_key = category.key
|
primary_category_key = category.key
|
||||||
else:
|
|
||||||
category = None
|
|
||||||
|
|
||||||
# 2. Organization létrehozása
|
# 2. ServiceProvider létrehozása (NEM Organization!)
|
||||||
folder_slug = hashlib.md5(
|
provider = ServiceProvider(
|
||||||
f"{data.name}-{uuid.uuid4()}".encode()
|
name=data.name,
|
||||||
).hexdigest()[:12]
|
address=", ".join(filter(None, [
|
||||||
|
data.address_street_name or "",
|
||||||
# P1 CRITICAL ALIGN: address_street_name, address_street_type, address_house_number
|
data.address_street_type or "",
|
||||||
# használata a régi street helyett.
|
data.address_house_number or "",
|
||||||
org = Organization(
|
])) or data.name,
|
||||||
name=data.name[:100],
|
category=primary_category_key,
|
||||||
full_name=data.name,
|
# Atomizált címmezők
|
||||||
display_name=data.name[:50],
|
city=data.city,
|
||||||
folder_slug=folder_slug,
|
|
||||||
org_type=OrgType.service_provider,
|
|
||||||
is_verified=False,
|
|
||||||
status="pending_verification",
|
|
||||||
subscription_plan="FREE",
|
|
||||||
base_asset_limit=1,
|
|
||||||
purchased_extra_slots=0,
|
|
||||||
notification_settings={"notify_owner": True, "alert_days_before": [30, 15, 7, 1]},
|
|
||||||
external_integration_config={"source": "crowdsourced"},
|
|
||||||
is_ownership_transferable=True,
|
|
||||||
address_city=data.city,
|
|
||||||
address_zip=data.address_zip,
|
address_zip=data.address_zip,
|
||||||
address_street_name=data.address_street_name,
|
address_street_name=data.address_street_name,
|
||||||
address_street_type=data.address_street_type,
|
address_street_type=data.address_street_type,
|
||||||
address_house_number=data.address_house_number,
|
address_house_number=data.address_house_number,
|
||||||
plus_code=data.plus_code,
|
plus_code=data.plus_code,
|
||||||
owner_id=None,
|
# Kapcsolatfelvételi adatok
|
||||||
first_registered_at=datetime.now(timezone.utc),
|
contact_phone=data.contact_phone,
|
||||||
current_lifecycle_started_at=datetime.now(timezone.utc),
|
contact_email=data.contact_email,
|
||||||
created_at=datetime.now(timezone.utc),
|
website=data.website,
|
||||||
|
# Státusz és forrás
|
||||||
|
status="pending",
|
||||||
|
source="manual",
|
||||||
|
validation_score=50,
|
||||||
|
added_by_user_id=user_id,
|
||||||
)
|
)
|
||||||
db.add(org)
|
db.add(provider)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
# 3. ServiceProfile létrehozása
|
# 3. ServiceProfile létrehozása (service_provider_id hivatkozással)
|
||||||
fingerprint = hashlib.sha256(
|
fingerprint = hashlib.sha256(
|
||||||
f"quick-add-{org.id}-{datetime.now().isoformat()}".encode()
|
f"quick-add-{provider.id}-{datetime.now().isoformat()}".encode()
|
||||||
).hexdigest()[:64]
|
).hexdigest()[:64]
|
||||||
|
|
||||||
# specialization_tags összeállítása: primary_category + user-supplied tags
|
# specialization_tags összeállítása: primary_category + user-supplied tags
|
||||||
@@ -564,7 +553,7 @@ async def quick_add_provider(
|
|||||||
spec_tags["user_tags"] = data.tags
|
spec_tags["user_tags"] = data.tags
|
||||||
|
|
||||||
profile = ServiceProfile(
|
profile = ServiceProfile(
|
||||||
organization_id=org.id,
|
service_provider_id=provider.id,
|
||||||
fingerprint=fingerprint,
|
fingerprint=fingerprint,
|
||||||
status="ghost",
|
status="ghost",
|
||||||
location=func.ST_SetSRID(func.ST_MakePoint(19.040236, 47.497913), 4326),
|
location=func.ST_SetSRID(func.ST_MakePoint(19.040236, 47.497913), 4326),
|
||||||
@@ -579,7 +568,6 @@ async def quick_add_provider(
|
|||||||
# =====================================================================
|
# =====================================================================
|
||||||
# 4. 🏗️ ServiceExpertise kapcsolatok létrehozása (4-LEVEL CATEGORY)
|
# 4. 🏗️ ServiceExpertise kapcsolatok létrehozása (4-LEVEL CATEGORY)
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
# Összegyűjtjük az összes kategória ID-t a ServiceExpertise kapcsolatokhoz.
|
|
||||||
all_category_ids: List[int] = []
|
all_category_ids: List[int] = []
|
||||||
|
|
||||||
# 4a. Elsődleges kategória (ha meg van adva)
|
# 4a. Elsődleges kategória (ha meg van adva)
|
||||||
@@ -605,13 +593,12 @@ async def quick_add_provider(
|
|||||||
|
|
||||||
# 4d. ServiceExpertise kapcsolatok létrehozása
|
# 4d. ServiceExpertise kapcsolatok létrehozása
|
||||||
if all_category_ids:
|
if all_category_ids:
|
||||||
# Deduplikáció: csak egyedi ID-k
|
|
||||||
unique_ids = list(set(all_category_ids))
|
unique_ids = list(set(all_category_ids))
|
||||||
for expertise_id in unique_ids:
|
for expertise_id in unique_ids:
|
||||||
expertise = ServiceExpertise(
|
expertise = ServiceExpertise(
|
||||||
service_id=profile.id,
|
service_id=profile.id,
|
||||||
expertise_id=expertise_id,
|
expertise_id=expertise_id,
|
||||||
confidence_level=50, # Közepes bizonyosság (crowdsourced)
|
confidence_level=50,
|
||||||
)
|
)
|
||||||
db.add(expertise)
|
db.add(expertise)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -620,29 +607,8 @@ async def quick_add_provider(
|
|||||||
)
|
)
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
|
|
||||||
# 5. Branch létrehozása atomizált címmezőkkel
|
|
||||||
branch = Branch(
|
|
||||||
organization_id=org.id,
|
|
||||||
name=data.name[:100],
|
|
||||||
is_main=True,
|
|
||||||
city=data.city,
|
|
||||||
postal_code=data.address_zip,
|
|
||||||
street_name=data.address_street_name,
|
|
||||||
street_type=data.address_street_type,
|
|
||||||
house_number=data.address_house_number,
|
|
||||||
status="active",
|
|
||||||
)
|
|
||||||
db.add(branch)
|
|
||||||
|
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
# 6. 👤 ORGANIZATION MEMBER LÉTREHOZÁSA (KIHAGYVA - P0 ARCHITECTURE FIX)
|
# 5. 🎮 GAMIFICATION INTEGRÁCIÓ (Dinamikus pontozás)
|
||||||
# =====================================================================
|
|
||||||
# DÖNTÉS: A létrehozó felhasználó NEM lehet tagja a beszállító szervezetnek.
|
|
||||||
# A szerviz jöjjön létre owner_id=None beállítással, tagok nélkül.
|
|
||||||
# =====================================================================
|
|
||||||
|
|
||||||
# =====================================================================
|
|
||||||
# 7. 🎮 GAMIFICATION INTEGRÁCIÓ (Dinamikus pontozás)
|
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
earned_points = await _award_provider_points(
|
earned_points = await _award_provider_points(
|
||||||
db=db,
|
db=db,
|
||||||
@@ -652,15 +618,15 @@ async def quick_add_provider(
|
|||||||
# =====================================================================
|
# =====================================================================
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(org)
|
await db.refresh(provider)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Quick-add provider created: org_id={org.id}, name={data.name}, "
|
f"Quick-add provider created: service_provider_id={provider.id}, name={data.name}, "
|
||||||
f"user_id={user_id}, earned_points={earned_points}"
|
f"user_id={user_id}, earned_points={earned_points}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return ProviderQuickAddResponse(
|
return ProviderQuickAddResponse(
|
||||||
id=org.id,
|
id=provider.id,
|
||||||
name=data.name,
|
name=data.name,
|
||||||
status="pending_verification",
|
status="pending_verification",
|
||||||
message="Szolgáltató sikeresen rögzítve. Ellenőrzés alatt.",
|
message="Szolgáltató sikeresen rögzítve. Ellenőrzés alatt.",
|
||||||
|
|||||||
321
backend/app/services/rbac_service.py
Normal file
321
backend/app/services/rbac_service.py
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
# /opt/docker/dev/service_finder/backend/app/services/rbac_service.py
|
||||||
|
"""
|
||||||
|
🎯 Scope-Based Admin RBAC Service (P0)
|
||||||
|
|
||||||
|
Implements scope-based access control for admin/staff roles.
|
||||||
|
Instead of direct organization membership, admins are assigned
|
||||||
|
a scope (e.g., REGION → "Pest_County") that determines which
|
||||||
|
organizations they can manage.
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
- SUPERADMIN: Unrestricted access (bypasses all scope checks)
|
||||||
|
- ADMIN/MODERATOR: Full access within their assigned scope
|
||||||
|
- SALES_REP/SERVICE_MGR: Scoped access with action-level filtering
|
||||||
|
|
||||||
|
Scope Types:
|
||||||
|
- REGION: Geographic region (e.g., "Pest_County", "Budapest")
|
||||||
|
- SEGMENT: Business segment (e.g., "Fleet", "Dealer", "Service")
|
||||||
|
- GLOBAL: Unrestricted (equivalent to SUPERADMIN for scope purposes)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
rbac = RBACService()
|
||||||
|
await rbac.check_admin_access(db, user, "EDIT_DATA", target_org_id=42)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import logging
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional, Dict, Set, List, Union
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.models.identity import User, UserRole
|
||||||
|
from app.models.marketplace.organization import Organization
|
||||||
|
from app.core.capabilities import SYSTEM_CAPABILITIES_MATRIX, role_has_capability
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ScopeType(str, Enum):
|
||||||
|
"""The type of scope assigned to an admin user."""
|
||||||
|
GLOBAL = "GLOBAL"
|
||||||
|
REGION = "REGION"
|
||||||
|
SEGMENT = "SEGMENT"
|
||||||
|
|
||||||
|
|
||||||
|
class AdminAction(str, Enum):
|
||||||
|
"""Admin actions that can be checked against scope."""
|
||||||
|
# ── Data Management ──
|
||||||
|
EDIT_DATA = "EDIT_DATA"
|
||||||
|
VIEW_DATA = "VIEW_DATA"
|
||||||
|
DELETE_DATA = "DELETE_DATA"
|
||||||
|
EXPORT_DATA = "EXPORT_DATA"
|
||||||
|
|
||||||
|
# ── Subscription Management ──
|
||||||
|
VIEW_SUBSCRIPTION = "VIEW_SUBSCRIPTION"
|
||||||
|
EDIT_SUBSCRIPTION = "EDIT_SUBSCRIPTION"
|
||||||
|
CANCEL_SUBSCRIPTION = "CANCEL_SUBSCRIPTION"
|
||||||
|
|
||||||
|
# ── User Management ──
|
||||||
|
MANAGE_USERS = "MANAGE_USERS"
|
||||||
|
SUSPEND_USERS = "SUSPEND_USERS"
|
||||||
|
BAN_USERS = "BAN_USERS"
|
||||||
|
|
||||||
|
# ── Financial ──
|
||||||
|
VIEW_FINANCIALS = "VIEW_FINANCIALS"
|
||||||
|
MANAGE_BILLING = "MANAGE_BILLING"
|
||||||
|
ISSUE_REFUNDS = "ISSUE_REFUNDS"
|
||||||
|
|
||||||
|
# ── Moderation ──
|
||||||
|
APPROVE_CONTENT = "APPROVE_CONTENT"
|
||||||
|
MODERATE_REVIEWS = "MODERATE_REVIEWS"
|
||||||
|
VERIFY_PROVIDERS = "VERIFY_PROVIDERS"
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# ACTION-TO-ROLE MAPPING
|
||||||
|
# Which roles are permitted to perform which actions
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Actions that SUPERADMIN can always do (implicitly all)
|
||||||
|
# Actions that ADMIN can do within their scope
|
||||||
|
ADMIN_SCOPE_ACTIONS: Set[str] = {
|
||||||
|
AdminAction.EDIT_DATA,
|
||||||
|
AdminAction.VIEW_DATA,
|
||||||
|
AdminAction.DELETE_DATA,
|
||||||
|
AdminAction.EXPORT_DATA,
|
||||||
|
AdminAction.VIEW_SUBSCRIPTION,
|
||||||
|
AdminAction.EDIT_SUBSCRIPTION,
|
||||||
|
AdminAction.MANAGE_USERS,
|
||||||
|
AdminAction.SUSPEND_USERS,
|
||||||
|
AdminAction.BAN_USERS,
|
||||||
|
AdminAction.VIEW_FINANCIALS,
|
||||||
|
AdminAction.MANAGE_BILLING,
|
||||||
|
AdminAction.APPROVE_CONTENT,
|
||||||
|
AdminAction.MODERATE_REVIEWS,
|
||||||
|
AdminAction.VERIFY_PROVIDERS,
|
||||||
|
}
|
||||||
|
|
||||||
|
# MODERATOR can do within their scope
|
||||||
|
MODERATOR_SCOPE_ACTIONS: Set[str] = {
|
||||||
|
AdminAction.VIEW_DATA,
|
||||||
|
AdminAction.VIEW_SUBSCRIPTION,
|
||||||
|
AdminAction.APPROVE_CONTENT,
|
||||||
|
AdminAction.MODERATE_REVIEWS,
|
||||||
|
AdminAction.VERIFY_PROVIDERS,
|
||||||
|
AdminAction.SUSPEND_USERS,
|
||||||
|
}
|
||||||
|
|
||||||
|
# SALES_REP can do within their scope
|
||||||
|
SALES_REP_SCOPE_ACTIONS: Set[str] = {
|
||||||
|
AdminAction.VIEW_DATA,
|
||||||
|
AdminAction.VIEW_SUBSCRIPTION,
|
||||||
|
AdminAction.EDIT_SUBSCRIPTION,
|
||||||
|
AdminAction.VIEW_FINANCIALS,
|
||||||
|
AdminAction.EXPORT_DATA,
|
||||||
|
}
|
||||||
|
|
||||||
|
# SERVICE_MGR can do within their scope
|
||||||
|
SERVICE_MGR_SCOPE_ACTIONS: Set[str] = {
|
||||||
|
AdminAction.VIEW_DATA,
|
||||||
|
AdminAction.EDIT_DATA,
|
||||||
|
AdminAction.APPROVE_CONTENT,
|
||||||
|
AdminAction.VERIFY_PROVIDERS,
|
||||||
|
AdminAction.MODERATE_REVIEWS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# RBAC SERVICE
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
class RBACService:
|
||||||
|
"""
|
||||||
|
Scope-Based Admin RBAC Service.
|
||||||
|
|
||||||
|
Provides methods to check whether a staff user has access
|
||||||
|
to perform a specific action on a target organization,
|
||||||
|
based on the user's role and assigned scope.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Mapping of role → set of permitted actions
|
||||||
|
ROLE_ACTIONS: Dict[UserRole, Set[str]] = {
|
||||||
|
UserRole.SUPERADMIN: set(AdminAction), # All actions
|
||||||
|
UserRole.ADMIN: ADMIN_SCOPE_ACTIONS,
|
||||||
|
UserRole.MODERATOR: MODERATOR_SCOPE_ACTIONS,
|
||||||
|
UserRole.SALES_REP: SALES_REP_SCOPE_ACTIONS,
|
||||||
|
UserRole.SERVICE_MGR: SERVICE_MGR_SCOPE_ACTIONS,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def check_admin_access(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
action: Union[str, AdminAction],
|
||||||
|
target_org_id: Optional[int] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a user has scope-based access to perform an action.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
user: The staff user to check
|
||||||
|
action: The action being attempted (AdminAction enum or string)
|
||||||
|
target_org_id: Optional target organization ID for scope matching
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if access is granted
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PermissionError: If access is denied (with details)
|
||||||
|
"""
|
||||||
|
action_str = action.value if isinstance(action, AdminAction) else action
|
||||||
|
|
||||||
|
# ── 1. SUPERADMIN bypass ──
|
||||||
|
if user.role == UserRole.SUPERADMIN:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ── 2. Check if the role is permitted for this action ──
|
||||||
|
permitted_actions = self.ROLE_ACTIONS.get(user.role, set())
|
||||||
|
if action_str not in permitted_actions:
|
||||||
|
raise PermissionError(
|
||||||
|
f"Action '{action_str}' is not permitted for role '{user.role.value}'."
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 3. If no target_org_id, just check action permission ──
|
||||||
|
if target_org_id is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ── 4. Scope-based organization access check ──
|
||||||
|
return await self._check_org_scope_access(db, user, target_org_id)
|
||||||
|
|
||||||
|
async def _check_org_scope_access(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
target_org_id: int,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the user's scope covers the target organization.
|
||||||
|
|
||||||
|
The user's scope is defined by:
|
||||||
|
- scope_type: "REGION", "SEGMENT", or "GLOBAL"
|
||||||
|
- scope_value: The specific value (e.g., "Pest_County", "Fleet")
|
||||||
|
|
||||||
|
The organization's scope attributes:
|
||||||
|
- region: Geographic region (e.g., "Pest_County")
|
||||||
|
- segment: Business segment (e.g., "Fleet")
|
||||||
|
"""
|
||||||
|
# Get user scope info
|
||||||
|
scope_type = getattr(user, 'scope_type', None)
|
||||||
|
scope_value = getattr(user, 'scope_value', None)
|
||||||
|
|
||||||
|
# GLOBAL scope → access to all organizations
|
||||||
|
if scope_type == ScopeType.GLOBAL.value:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# No scope assigned → deny
|
||||||
|
if not scope_type or not scope_value:
|
||||||
|
raise PermissionError(
|
||||||
|
f"User {user.id} has no scope assigned. "
|
||||||
|
f"Contact a SUPERADMIN to assign a scope."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch the target organization
|
||||||
|
stmt = select(Organization).where(Organization.id == target_org_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
org = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not org:
|
||||||
|
raise PermissionError(f"Organization {target_org_id} not found.")
|
||||||
|
|
||||||
|
# ── Scope matching logic ──
|
||||||
|
if scope_type == ScopeType.REGION.value:
|
||||||
|
# Check if org's region matches user's scope_value
|
||||||
|
org_region = getattr(org, 'region', None)
|
||||||
|
if not org_region:
|
||||||
|
raise PermissionError(
|
||||||
|
f"Organization {target_org_id} has no region assigned. "
|
||||||
|
f"Cannot match against REGION scope '{scope_value}'."
|
||||||
|
)
|
||||||
|
if org_region != scope_value:
|
||||||
|
raise PermissionError(
|
||||||
|
f"REGION scope mismatch: user scope='{scope_value}', "
|
||||||
|
f"org region='{org_region}'."
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif scope_type == ScopeType.SEGMENT.value:
|
||||||
|
# Check if org's segment matches user's scope_value
|
||||||
|
org_segment = getattr(org, 'segment', None)
|
||||||
|
if not org_segment:
|
||||||
|
raise PermissionError(
|
||||||
|
f"Organization {target_org_id} has no segment assigned. "
|
||||||
|
f"Cannot match against SEGMENT scope '{scope_value}'."
|
||||||
|
)
|
||||||
|
if org_segment != scope_value:
|
||||||
|
raise PermissionError(
|
||||||
|
f"SEGMENT scope mismatch: user scope='{scope_value}', "
|
||||||
|
f"org segment='{org_segment}'."
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise PermissionError(f"Unknown scope type: '{scope_type}'.")
|
||||||
|
|
||||||
|
async def get_accessible_org_ids(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
) -> Optional[List[int]]:
|
||||||
|
"""
|
||||||
|
Get list of organization IDs accessible by this user based on their scope.
|
||||||
|
|
||||||
|
Returns None for SUPERADMIN/GLOBAL scope (unrestricted).
|
||||||
|
Returns a list of matching org IDs for REGION/SEGMENT scope.
|
||||||
|
"""
|
||||||
|
# SUPERADMIN and GLOBAL scope → unrestricted
|
||||||
|
if user.role == UserRole.SUPERADMIN:
|
||||||
|
return None
|
||||||
|
|
||||||
|
scope_type = getattr(user, 'scope_type', None)
|
||||||
|
scope_value = getattr(user, 'scope_value', None)
|
||||||
|
|
||||||
|
if not scope_type or not scope_value:
|
||||||
|
return [] # No scope → no orgs
|
||||||
|
|
||||||
|
if scope_type == ScopeType.GLOBAL.value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Build query based on scope type
|
||||||
|
if scope_type == ScopeType.REGION.value:
|
||||||
|
stmt = select(Organization.id).where(
|
||||||
|
Organization.region == scope_value,
|
||||||
|
Organization.is_deleted == False
|
||||||
|
)
|
||||||
|
elif scope_type == ScopeType.SEGMENT.value:
|
||||||
|
stmt = select(Organization.id).where(
|
||||||
|
Organization.segment == scope_value,
|
||||||
|
Organization.is_deleted == False
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return [row[0] for row in result.all()]
|
||||||
|
|
||||||
|
def get_permitted_actions(self, role: UserRole) -> Set[str]:
|
||||||
|
"""Get the set of actions permitted for a given role."""
|
||||||
|
return self.ROLE_ACTIONS.get(role, set())
|
||||||
|
|
||||||
|
def is_action_permitted(self, role: UserRole, action: Union[str, AdminAction]) -> bool:
|
||||||
|
"""Check if a specific action is permitted for a given role."""
|
||||||
|
action_str = action.value if isinstance(action, AdminAction) else action
|
||||||
|
return action_str in self.ROLE_ACTIONS.get(role, set())
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Singleton instance
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
rbac_service = RBACService()
|
||||||
@@ -10,43 +10,64 @@ Hierarchia:
|
|||||||
- free: alap kategóriák (üzemanyag, szerviz, gumik, biztosítás, adók)
|
- free: alap kategóriák (üzemanyag, szerviz, gumik, biztosítás, adók)
|
||||||
- premium: free + útdíj/parkolás, ápolás, egyéb
|
- premium: free + útdíj/parkolás, ápolás, egyéb
|
||||||
- enterprise: premium + minden (nincs korlátozás)
|
- enterprise: premium + minden (nincs korlátozás)
|
||||||
|
|
||||||
|
P0 Feature Flag System:
|
||||||
|
- A get_user_tier() most már valós adatbázis lekérdezést végez a UserSubscription
|
||||||
|
és OrganizationSubscription táblákból.
|
||||||
|
- A get_user_feature_flags() metódus visszaadja az összes feature flag állapotát
|
||||||
|
a felhasználó tier-je alapján.
|
||||||
|
- A get_org_subscription_details() metódus visszaadja a szervezet előfizetési
|
||||||
|
adatait a frontend számára.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, List
|
from datetime import datetime
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
from app.models.fleet_finance import CostCategory
|
from app.models.fleet_finance import CostCategory
|
||||||
|
from app.models.core_logic import SubscriptionTier, UserSubscription, OrganizationSubscription
|
||||||
|
from app.models.identity import User
|
||||||
|
from app.models.marketplace.organization import Organization
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── Subscription Feature Matrix ──────────────────────────────────────────────
|
# ── Subscription Feature Matrix ──────────────────────────────────────────────
|
||||||
# Minden funkció/kategória minimális tier szintje.
|
# Minden funkció/kategória minimális tier szintje.
|
||||||
# A tier-ek: free < premium < enterprise
|
# A tier-ek: 0=free, 1=premium, 2=enterprise
|
||||||
TIER_HIERARCHY = {
|
# A TIER_HIERARCHY a SubscriptionTier.tier_level mezőből származik.
|
||||||
"free": 0,
|
# A SUBSCRIPTION_FEATURES itt a minimális tier_level értéket tárolja (int).
|
||||||
"premium": 1,
|
|
||||||
"enterprise": 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
SUBSCRIPTION_FEATURES: dict[str, str] = {
|
SUBSCRIPTION_FEATURES: dict[str, int] = {
|
||||||
# Feature -> minimum required tier
|
# Feature -> minimum required tier_level (0=free, 1=premium, 2=enterprise)
|
||||||
"cost_category:fuel": "free",
|
"cost_category:fuel": 0,
|
||||||
"cost_category:service": "free",
|
"cost_category:service": 0,
|
||||||
"cost_category:tires": "free",
|
"cost_category:tires": 0,
|
||||||
"cost_category:insurance": "free",
|
"cost_category:insurance": 0,
|
||||||
"cost_category:taxes": "free",
|
"cost_category:taxes": 0,
|
||||||
"cost_category:toll_parking": "premium",
|
"cost_category:toll_parking": 1,
|
||||||
"cost_category:cleaning": "premium",
|
"cost_category:cleaning": 1,
|
||||||
"cost_category:other": "premium",
|
"cost_category:other": 1,
|
||||||
"cost_category:all": "enterprise",
|
"cost_category:all": 2,
|
||||||
# Feature flags
|
# Feature flags
|
||||||
"export_csv": "free",
|
"export_csv": 0,
|
||||||
"analytics_tco": "premium",
|
"analytics_tco": 1,
|
||||||
"analytics_detailed": "enterprise",
|
"analytics_detailed": 2,
|
||||||
"api_access": "free",
|
"api_access": 0,
|
||||||
"multi_vehicle": "free",
|
"multi_vehicle": 0,
|
||||||
"unlimited_vehicles": "premium",
|
"unlimited_vehicles": 1,
|
||||||
|
# P0 Frontend feature flags
|
||||||
|
"subscription_management": 0,
|
||||||
|
"vehicle_tracking": 0,
|
||||||
|
"cost_tracking": 0,
|
||||||
|
"service_booking": 0,
|
||||||
|
"advanced_reports": 1,
|
||||||
|
"team_management": 1,
|
||||||
|
"priority_support": 1,
|
||||||
|
"white_label": 2,
|
||||||
|
"api_webhooks": 2,
|
||||||
|
"custom_integrations": 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -56,33 +77,346 @@ class SubscriptionService:
|
|||||||
|
|
||||||
Metódusok:
|
Metódusok:
|
||||||
get_user_tier(db, user_id) -> str
|
get_user_tier(db, user_id) -> str
|
||||||
|
get_user_feature_flags(db, user_id) -> dict[str, bool]
|
||||||
|
get_org_subscription_details(db, org_id) -> dict | None
|
||||||
can_access_feature(tier, feature_key) -> bool
|
can_access_feature(tier, feature_key) -> bool
|
||||||
get_visible_categories(db, tier) -> list[CostCategory]
|
get_visible_categories(db, tier) -> list[CostCategory]
|
||||||
require_tier(tier, required_feature_or_min_tier) -> bool
|
require_tier(tier, required_feature_or_min_tier) -> bool
|
||||||
|
|
||||||
|
P0 AUDIT FIX: A tier feloldás már a SubscriptionTier.tier_level mezőt használja
|
||||||
|
ahelyett, hogy a tier nevét string-matchinggel próbálná meg kitalálni.
|
||||||
|
A tier_level egy integer: 0=free, 1=premium, 2=enterprise.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# ── Tier level → display name mapping ──────────────────────────────
|
||||||
|
# This is the canonical mapping from DB tier_level to the human-readable
|
||||||
|
# entitlement tier name used in feature flag resolution.
|
||||||
|
TIER_LEVEL_NAMES: dict[int, str] = {
|
||||||
|
0: "free",
|
||||||
|
1: "premium",
|
||||||
|
2: "enterprise",
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _tier_to_int(tier: str) -> int:
|
def _tier_level_to_name(level: int) -> str:
|
||||||
"""Tier string konvertálása numerikus értékre a hierarchiában."""
|
"""Convert a numeric tier_level to the canonical tier name."""
|
||||||
return TIER_HIERARCHY.get(tier.lower(), 0)
|
return SubscriptionService.TIER_LEVEL_NAMES.get(level, "free")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _get_default_fallback_tier(db: AsyncSession) -> Optional[SubscriptionTier]:
|
||||||
|
"""
|
||||||
|
Lekérdezi az egyetlen csomagot, ahol is_default_fallback == True.
|
||||||
|
Ha nincs ilyen, None-t ad vissza.
|
||||||
|
"""
|
||||||
|
stmt = select(SubscriptionTier).where(
|
||||||
|
SubscriptionTier.is_default_fallback == True
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_user_tier(db: AsyncSession, user_id: int) -> str:
|
async def get_user_tier(db: AsyncSession, user_id: int) -> str:
|
||||||
"""
|
"""
|
||||||
Visszaadja a felhasználó aktuális előfizetési szintjét.
|
Visszaadja a felhasználó aktuális előfizetési szintjét valós adatbázis lekérdezéssel.
|
||||||
|
|
||||||
Jelenlegi implementáció: minden user 'free' tier-t kap.
|
P0 Feature Flag: Valós tier feloldás a UserSubscription és OrganizationSubscription
|
||||||
A jövőben ez a subscription/org táblákból lesz lekérdezve.
|
táblákból, valamint a User.subscription_plan mezőből.
|
||||||
|
|
||||||
|
Feloldási sorrend:
|
||||||
|
1. Aktív UserSubscription lekérdezése (finance.user_subscriptions)
|
||||||
|
- Ha van aktív és nem járt le, a tier.tier_level alapján
|
||||||
|
2. Ha nincs user subscription, de a user aktív szervezethez tartozik:
|
||||||
|
- OrganizationSubscription lekérdezése
|
||||||
|
3. Ha egyik sincs, visszaesés a User.subscription_plan mezőre
|
||||||
|
4. Ha az is üres, ellenőrizzük a default fallback csomagot
|
||||||
|
5. Alapértelmezett 'free'
|
||||||
|
|
||||||
|
P0 AUDIT FIX: A tier_level mezőt használjuk a SubscriptionTier modellből,
|
||||||
|
nem pedig a tier name string-matchingjét.
|
||||||
|
|
||||||
|
P0 SINGLETON FALLBACK: Ha a subscription lejárt (expired), dinamikusan
|
||||||
|
lekérdezzük a is_default_fallback==True csomagot és annak feature-jeit adjuk vissza.
|
||||||
"""
|
"""
|
||||||
# TODO: Éles implementáció — subscription tábla lekérdezése
|
# ── 1. UserSubscription lekérdezése ──
|
||||||
# TODO: Szervezeti tier felülírás (pl. enterprise org)
|
us_stmt = (
|
||||||
|
select(UserSubscription)
|
||||||
|
.options(selectinload(UserSubscription.tier))
|
||||||
|
.where(
|
||||||
|
UserSubscription.user_id == user_id,
|
||||||
|
UserSubscription.is_active == True,
|
||||||
|
)
|
||||||
|
.order_by(UserSubscription.id.desc())
|
||||||
|
)
|
||||||
|
us_result = await db.execute(us_stmt)
|
||||||
|
user_sub = us_result.scalars().first()
|
||||||
|
|
||||||
|
if user_sub and user_sub.tier:
|
||||||
|
# Ellenőrizzük a lejáratot
|
||||||
|
if user_sub.valid_until is None or user_sub.valid_until > datetime.utcnow():
|
||||||
|
# P0 FIX: Use tier_level from DB instead of hardcoded string matching
|
||||||
|
level = user_sub.tier.tier_level
|
||||||
|
return SubscriptionService._tier_level_to_name(level)
|
||||||
|
else:
|
||||||
|
# ── P0 SINGLETON FALLBACK: subscription lejárt → default fallback tier ──
|
||||||
|
logger.info(
|
||||||
|
f"UserSubscription {user_sub.id} for user {user_id} has expired "
|
||||||
|
f"(valid_until={user_sub.valid_until}). Falling back to default fallback tier."
|
||||||
|
)
|
||||||
|
fallback = await SubscriptionService._get_default_fallback_tier(db)
|
||||||
|
if fallback:
|
||||||
|
level = fallback.tier_level
|
||||||
|
return SubscriptionService._tier_level_to_name(level)
|
||||||
|
|
||||||
|
# ── 2. Aktív szervezet subscription ──
|
||||||
|
user_stmt = select(User).where(User.id == user_id)
|
||||||
|
user_result = await db.execute(user_stmt)
|
||||||
|
user = user_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# active_organization_id is stored in the User.scope_id column (String)
|
||||||
|
# and set on current_user by the auth dependency from the JWT token.
|
||||||
|
# When querying from DB directly, we use scope_id.
|
||||||
|
active_org_id = None
|
||||||
|
if user and user.scope_id:
|
||||||
|
try:
|
||||||
|
active_org_id = int(user.scope_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if active_org_id:
|
||||||
|
org_stmt = (
|
||||||
|
select(OrganizationSubscription)
|
||||||
|
.options(selectinload(OrganizationSubscription.tier))
|
||||||
|
.where(
|
||||||
|
OrganizationSubscription.org_id == active_org_id,
|
||||||
|
OrganizationSubscription.is_active == True,
|
||||||
|
)
|
||||||
|
.order_by(OrganizationSubscription.id.desc())
|
||||||
|
)
|
||||||
|
org_result = await db.execute(org_stmt)
|
||||||
|
org_sub = org_result.scalars().first()
|
||||||
|
|
||||||
|
if org_sub and org_sub.tier:
|
||||||
|
if org_sub.valid_until is None or org_sub.valid_until > datetime.utcnow():
|
||||||
|
# P0 FIX: Use tier_level from DB instead of hardcoded string matching
|
||||||
|
level = org_sub.tier.tier_level
|
||||||
|
return SubscriptionService._tier_level_to_name(level)
|
||||||
|
else:
|
||||||
|
# ── P0 SINGLETON FALLBACK: org subscription lejárt ──
|
||||||
|
logger.info(
|
||||||
|
f"OrganizationSubscription {org_sub.id} for org {active_org_id} has expired "
|
||||||
|
f"(valid_until={org_sub.valid_until}). Falling back to default fallback tier."
|
||||||
|
)
|
||||||
|
fallback = await SubscriptionService._get_default_fallback_tier(db)
|
||||||
|
if fallback:
|
||||||
|
level = fallback.tier_level
|
||||||
|
return SubscriptionService._tier_level_to_name(level)
|
||||||
|
|
||||||
|
# ── 3. Fallback: User.subscription_plan ──
|
||||||
|
if user and user.subscription_plan:
|
||||||
|
plan = user.subscription_plan.lower()
|
||||||
|
# For the fallback plan field, we still need a simple heuristic
|
||||||
|
# since there's no SubscriptionTier object. We use a minimal mapping.
|
||||||
|
if "enterprise" in plan or "vip" in plan:
|
||||||
|
return "enterprise"
|
||||||
|
if "premium" in plan or "pro" in plan:
|
||||||
|
return "premium"
|
||||||
|
return "free"
|
||||||
|
|
||||||
|
# ── 4. Default fallback tier ──
|
||||||
|
fallback = await SubscriptionService._get_default_fallback_tier(db)
|
||||||
|
if fallback:
|
||||||
|
level = fallback.tier_level
|
||||||
|
return SubscriptionService._tier_level_to_name(level)
|
||||||
|
|
||||||
|
# ── 5. Alapértelmezett ──
|
||||||
return "free"
|
return "free"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_user_feature_flags(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Visszaadja az összes feature flag állapotát a felhasználó tier-je alapján.
|
||||||
|
|
||||||
|
P0 Feature: Ez a metódus a frontend useFeatureFlag() composable számára
|
||||||
|
biztosítja a backend által validált feature flag-eket.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict a következő struktúrával:
|
||||||
|
{
|
||||||
|
"tier": "premium",
|
||||||
|
"features": {
|
||||||
|
"export_csv": True,
|
||||||
|
"analytics_tco": True,
|
||||||
|
...
|
||||||
|
},
|
||||||
|
"expires_at": "2026-07-24T12:00:00Z" | None
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
user_tier = await SubscriptionService.get_user_tier(db, user_id)
|
||||||
|
|
||||||
|
# Lekérdezzük a lejárati időt is
|
||||||
|
expires_at = None
|
||||||
|
us_stmt = (
|
||||||
|
select(UserSubscription)
|
||||||
|
.where(
|
||||||
|
UserSubscription.user_id == user_id,
|
||||||
|
UserSubscription.is_active == True,
|
||||||
|
)
|
||||||
|
.order_by(UserSubscription.id.desc())
|
||||||
|
)
|
||||||
|
us_result = await db.execute(us_stmt)
|
||||||
|
user_sub = us_result.scalars().first()
|
||||||
|
if user_sub and user_sub.valid_until:
|
||||||
|
expires_at = user_sub.valid_until.isoformat()
|
||||||
|
|
||||||
|
# Összes feature flag feloldása
|
||||||
|
features = {}
|
||||||
|
for feature_key, required_tier in SUBSCRIPTION_FEATURES.items():
|
||||||
|
features[feature_key] = SubscriptionService.can_access_feature(user_tier, feature_key)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tier": user_tier,
|
||||||
|
"features": features,
|
||||||
|
"expires_at": expires_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_org_subscription_details(
|
||||||
|
db: AsyncSession,
|
||||||
|
org_id: int,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Visszaadja a szervezet előfizetési adatait a frontend számára.
|
||||||
|
|
||||||
|
P0 Feature: Ez a metódus a GET /subscriptions/my végpont számára
|
||||||
|
biztosítja a szervezet előfizetési adatait.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict a következő struktúrával:
|
||||||
|
{
|
||||||
|
"tier_id": 16,
|
||||||
|
"tier_name": "corp_premium_v1",
|
||||||
|
"display_name": "Céges Prémium",
|
||||||
|
"valid_from": "2026-06-01T00:00:00Z",
|
||||||
|
"valid_until": "2026-07-01T00:00:00Z",
|
||||||
|
"is_active": True,
|
||||||
|
"allowances": { ... },
|
||||||
|
"pricing": { ... },
|
||||||
|
"feature_capabilities": { ... },
|
||||||
|
}
|
||||||
|
vagy None, ha nincs aktív előfizetés.
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(OrganizationSubscription)
|
||||||
|
.options(selectinload(OrganizationSubscription.tier))
|
||||||
|
.where(
|
||||||
|
OrganizationSubscription.org_id == org_id,
|
||||||
|
OrganizationSubscription.is_active == True,
|
||||||
|
)
|
||||||
|
.order_by(OrganizationSubscription.id.desc())
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
org_sub = result.scalars().first()
|
||||||
|
|
||||||
|
if not org_sub or not org_sub.tier:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tier = org_sub.tier
|
||||||
|
rules = tier.rules or {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tier_id": tier.id,
|
||||||
|
"tier_name": tier.name,
|
||||||
|
"display_name": rules.get("display_name", tier.name),
|
||||||
|
"valid_from": org_sub.valid_from.isoformat() if org_sub.valid_from else None,
|
||||||
|
"valid_until": org_sub.valid_until.isoformat() if org_sub.valid_until else None,
|
||||||
|
"is_active": org_sub.is_active,
|
||||||
|
"allowances": rules.get("allowances", {}),
|
||||||
|
"pricing": rules.get("pricing", {}),
|
||||||
|
"duration": rules.get("duration", {}),
|
||||||
|
"ad_policy": rules.get("ad_policy", {}),
|
||||||
|
"entitlements": rules.get("entitlements", []),
|
||||||
|
"feature_capabilities": tier.feature_capabilities or {},
|
||||||
|
"extra_allowances": org_sub.extra_allowances or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_user_subscription_details(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Visszaadja a felhasználó személyes előfizetési adatait.
|
||||||
|
|
||||||
|
P0 Feature: Ez a metódus a GET /subscriptions/my végpont számára
|
||||||
|
biztosítja a felhasználói előfizetés adatait (ha nincs org subscription).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict a következő struktúrával:
|
||||||
|
{
|
||||||
|
"tier_id": 5,
|
||||||
|
"tier_name": "private_pro_v1",
|
||||||
|
"display_name": "Privát Pro",
|
||||||
|
"valid_from": "...",
|
||||||
|
"valid_until": "...",
|
||||||
|
"is_active": True,
|
||||||
|
"allowances": { ... },
|
||||||
|
"pricing": { ... },
|
||||||
|
}
|
||||||
|
vagy None, ha nincs aktív előfizetés.
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(UserSubscription)
|
||||||
|
.options(selectinload(UserSubscription.tier))
|
||||||
|
.where(
|
||||||
|
UserSubscription.user_id == user_id,
|
||||||
|
UserSubscription.is_active == True,
|
||||||
|
)
|
||||||
|
.order_by(UserSubscription.id.desc())
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
user_sub = result.scalars().first()
|
||||||
|
|
||||||
|
if not user_sub or not user_sub.tier:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tier = user_sub.tier
|
||||||
|
rules = tier.rules or {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tier_id": tier.id,
|
||||||
|
"tier_name": tier.name,
|
||||||
|
"display_name": rules.get("display_name", tier.name),
|
||||||
|
"valid_from": user_sub.valid_from.isoformat() if user_sub.valid_from else None,
|
||||||
|
"valid_until": user_sub.valid_until.isoformat() if user_sub.valid_until else None,
|
||||||
|
"is_active": user_sub.is_active,
|
||||||
|
"allowances": rules.get("allowances", {}),
|
||||||
|
"pricing": rules.get("pricing", {}),
|
||||||
|
"duration": rules.get("duration", {}),
|
||||||
|
"ad_policy": rules.get("ad_policy", {}),
|
||||||
|
"entitlements": rules.get("entitlements", []),
|
||||||
|
"feature_capabilities": tier.feature_capabilities or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_user_level(user_tier: str) -> int:
|
||||||
|
"""
|
||||||
|
Convert a user tier name to its numeric level.
|
||||||
|
Uses the canonical TIER_LEVEL_NAMES mapping.
|
||||||
|
"""
|
||||||
|
reverse_map = {v: k for k, v in SubscriptionService.TIER_LEVEL_NAMES.items()}
|
||||||
|
return reverse_map.get(user_tier.lower(), 0)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def can_access_feature(user_tier: str, feature_key: str) -> bool:
|
def can_access_feature(user_tier: str, feature_key: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Ellenőrzi, hogy a felhasználó hozzáfér-e egy adott funkcióhoz.
|
Ellenőrzi, hogy a felhasználó hozzáfér-e egy adott funkcióhoz.
|
||||||
|
|
||||||
|
P0 AUDIT FIX: A SUBSCRIPTION_FEATURES most már integer tier_level értékeket
|
||||||
|
tartalmaz (0=free, 1=premium, 2=enterprise), így nincs szükség string konverzióra.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_tier: A felhasználó tier szintje (free/premium/enterprise)
|
user_tier: A felhasználó tier szintje (free/premium/enterprise)
|
||||||
feature_key: A funkció kulcsa (pl. 'cost_category:fuel', 'analytics_tco')
|
feature_key: A funkció kulcsa (pl. 'cost_category:fuel', 'analytics_tco')
|
||||||
@@ -90,14 +424,13 @@ class SubscriptionService:
|
|||||||
Returns:
|
Returns:
|
||||||
bool: True ha hozzáfér, False ha nem
|
bool: True ha hozzáfér, False ha nem
|
||||||
"""
|
"""
|
||||||
required_tier = SUBSCRIPTION_FEATURES.get(feature_key)
|
required_level = SUBSCRIPTION_FEATURES.get(feature_key)
|
||||||
if required_tier is None:
|
if required_level is None:
|
||||||
# Ismeretlen feature — alapértelmezetten tiltva
|
# Ismeretlen feature — alapértelmezetten tiltva
|
||||||
logger.warning(f"Unknown feature key: {feature_key}")
|
logger.warning(f"Unknown feature key: {feature_key}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
user_level = SubscriptionService._tier_to_int(user_tier)
|
user_level = SubscriptionService._get_user_level(user_tier)
|
||||||
required_level = SubscriptionService._tier_to_int(required_tier)
|
|
||||||
return user_level >= required_level
|
return user_level >= required_level
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -121,7 +454,7 @@ class SubscriptionService:
|
|||||||
Returns:
|
Returns:
|
||||||
List[CostCategory]: A látható kategóriák listája
|
List[CostCategory]: A látható kategóriák listája
|
||||||
"""
|
"""
|
||||||
user_level = SubscriptionService._tier_to_int(user_tier)
|
user_level = SubscriptionService._get_user_level(user_tier)
|
||||||
|
|
||||||
# Lekérjük az összes kategóriát
|
# Lekérjük az összes kategóriát
|
||||||
stmt = select(CostCategory).order_by(CostCategory.id)
|
stmt = select(CostCategory).order_by(CostCategory.id)
|
||||||
@@ -132,7 +465,7 @@ class SubscriptionService:
|
|||||||
visible = []
|
visible = []
|
||||||
for cat in all_categories:
|
for cat in all_categories:
|
||||||
cat_tier = getattr(cat, "min_tier", "free") or "free"
|
cat_tier = getattr(cat, "min_tier", "free") or "free"
|
||||||
cat_level = SubscriptionService._tier_to_int(cat_tier)
|
cat_level = SubscriptionService._get_user_level(cat_tier)
|
||||||
if user_level >= cat_level:
|
if user_level >= cat_level:
|
||||||
visible.append(cat)
|
visible.append(cat)
|
||||||
|
|
||||||
@@ -157,6 +490,75 @@ class SubscriptionService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Ha nem feature kulcs, akkor tier névként kezeljük
|
# Ha nem feature kulcs, akkor tier névként kezeljük
|
||||||
user_level = SubscriptionService._tier_to_int(user_tier)
|
user_level = SubscriptionService._get_user_level(user_tier)
|
||||||
required_level = SubscriptionService._tier_to_int(required_feature_or_min_tier)
|
required_level = SubscriptionService._get_user_level(required_feature_or_min_tier)
|
||||||
return user_level >= required_level
|
return user_level >= required_level
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def assign_default_trial(
|
||||||
|
db: AsyncSession,
|
||||||
|
org_id: int,
|
||||||
|
user_id: int,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Új regisztrációnál automatikusan hozzárendeli a próbaidős csomagot.
|
||||||
|
|
||||||
|
P0 SINGLETON TRIAL: Lekérdezi a trial_days_on_signup > 0 csomagot.
|
||||||
|
Ha van ilyen, létrehoz egy OrganizationSubscription rekordot a megadott
|
||||||
|
org_id-hoz, ami a trial_days_on_signup nap múlva jár le.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Adatbázis session
|
||||||
|
org_id: A szervezet ID-ja, amelyhez a trial subscription tartozik
|
||||||
|
user_id: A felhasználó ID-ja (naplózáshoz)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict a subscription adatokkal, vagy None ha nincs trial csomag beállítva.
|
||||||
|
"""
|
||||||
|
# Lekérdezzük a trial csomagot (ahol trial_days_on_signup > 0)
|
||||||
|
stmt = select(SubscriptionTier).where(
|
||||||
|
SubscriptionTier.trial_days_on_signup > 0
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
trial_tier = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not trial_tier:
|
||||||
|
logger.info(
|
||||||
|
f"No trial tier configured (no tier with trial_days_on_signup > 0). "
|
||||||
|
f"Skipping trial assignment for org {org_id}, user {user_id}."
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
# Számoljuk a lejárati időt
|
||||||
|
trial_days = trial_tier.trial_days_on_signup
|
||||||
|
now = datetime.utcnow()
|
||||||
|
valid_until = now + timedelta(days=trial_days)
|
||||||
|
|
||||||
|
# Létrehozzuk a subscription rekordot
|
||||||
|
org_sub = OrganizationSubscription(
|
||||||
|
org_id=org_id,
|
||||||
|
tier_id=trial_tier.id,
|
||||||
|
valid_from=now,
|
||||||
|
valid_until=valid_until,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(org_sub)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(org_sub)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Assigned trial subscription for org {org_id}, user {user_id}: "
|
||||||
|
f"tier='{trial_tier.name}' (id={trial_tier.id}), "
|
||||||
|
f"trial_days={trial_days}, valid_until={valid_until}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tier_id": trial_tier.id,
|
||||||
|
"tier_name": trial_tier.name,
|
||||||
|
"valid_from": now.isoformat(),
|
||||||
|
"valid_until": valid_until.isoformat(),
|
||||||
|
"is_active": True,
|
||||||
|
"trial_days": trial_days,
|
||||||
|
}
|
||||||
|
|||||||
186
backend/app/tests/test_rbac_service.py
Normal file
186
backend/app/tests/test_rbac_service.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
"""
|
||||||
|
Test script for Scope-Based Admin RBAC Service.
|
||||||
|
Verifies that the RBACService correctly handles scope-based access control.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
from app.database import AsyncSessionLocal
|
||||||
|
from app.models.identity.identity import User, UserRole
|
||||||
|
from app.models.marketplace.organization import Organization
|
||||||
|
from app.services.rbac_service import rbac_service, AdminAction
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rbac_service():
|
||||||
|
print("=" * 60)
|
||||||
|
print("RBAC SERVICE TEST SUITE")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
# 1. Check that scope_type and scope_value columns exist on User
|
||||||
|
print("\n[TEST 1] Checking User model has scope_type/scope_value columns...")
|
||||||
|
result = await db.execute(
|
||||||
|
text("SELECT column_name FROM information_schema.columns WHERE table_schema = 'identity' AND table_name = 'users' AND column_name IN ('scope_type', 'scope_value')")
|
||||||
|
)
|
||||||
|
cols = result.scalars().all()
|
||||||
|
assert "scope_type" in cols, "scope_type column missing!"
|
||||||
|
assert "scope_value" in cols, "scope_value column missing!"
|
||||||
|
print(f" ✅ Columns found: {cols}")
|
||||||
|
|
||||||
|
# 2. Check that region and segment columns exist on Organization
|
||||||
|
print("\n[TEST 2] Checking Organization model has region/segment columns...")
|
||||||
|
result = await db.execute(
|
||||||
|
text("SELECT column_name FROM information_schema.columns WHERE table_schema = 'fleet' AND table_name = 'organizations' AND column_name IN ('region', 'segment')")
|
||||||
|
)
|
||||||
|
cols = result.scalars().all()
|
||||||
|
assert "region" in cols, "region column missing!"
|
||||||
|
assert "segment" in cols, "segment column missing!"
|
||||||
|
print(f" ✅ Columns found: {cols}")
|
||||||
|
|
||||||
|
# 3. Test RBACService.get_permitted_actions for each role
|
||||||
|
print("\n[TEST 3] Testing get_permitted_actions for each role...")
|
||||||
|
for role in UserRole:
|
||||||
|
actions = rbac_service.get_permitted_actions(role)
|
||||||
|
print(f" {role.value}: {len(actions)} permitted actions")
|
||||||
|
assert isinstance(actions, (set, list)), f"Actions for {role} should be a set or list"
|
||||||
|
print(" ✅ All roles return valid action lists")
|
||||||
|
|
||||||
|
# 4. Test is_action_permitted
|
||||||
|
print("\n[TEST 4] Testing is_action_permitted...")
|
||||||
|
assert rbac_service.is_action_permitted(UserRole.SUPERADMIN, AdminAction.EDIT_DATA) is True
|
||||||
|
assert rbac_service.is_action_permitted(UserRole.ADMIN, AdminAction.EDIT_DATA) is True
|
||||||
|
assert rbac_service.is_action_permitted(UserRole.MODERATOR, AdminAction.EDIT_DATA) is False
|
||||||
|
assert rbac_service.is_action_permitted(UserRole.SALES_REP, AdminAction.VIEW_DATA) is True
|
||||||
|
assert rbac_service.is_action_permitted(UserRole.SERVICE_MGR, AdminAction.VIEW_DATA) is True
|
||||||
|
print(" ✅ All action permission checks pass")
|
||||||
|
|
||||||
|
# 5. Test SUPERADMIN bypasses all scope checks
|
||||||
|
print("\n[TEST 5] Testing SUPERADMIN scope bypass...")
|
||||||
|
# Create a mock user object
|
||||||
|
class MockUser:
|
||||||
|
role = UserRole.SUPERADMIN
|
||||||
|
scope_type = None
|
||||||
|
scope_value = None
|
||||||
|
id = 999999
|
||||||
|
|
||||||
|
try:
|
||||||
|
await rbac_service.check_admin_access(db, MockUser(), AdminAction.VIEW_DATA, target_org_id=1)
|
||||||
|
print(" ✅ SUPERADMIN bypasses scope check - no PermissionError raised")
|
||||||
|
except PermissionError as e:
|
||||||
|
print(f" ❌ SUPERADMIN should bypass scope check but got: {e}")
|
||||||
|
|
||||||
|
# 6. Test that a regular user without scope gets PermissionError
|
||||||
|
print("\n[TEST 6] Testing regular user without scope...")
|
||||||
|
class MockUserNoScope:
|
||||||
|
role = UserRole.ADMIN
|
||||||
|
scope_type = None
|
||||||
|
scope_value = None
|
||||||
|
id = 999998
|
||||||
|
|
||||||
|
try:
|
||||||
|
await rbac_service.check_admin_access(db, MockUserNoScope(), AdminAction.VIEW_DATA, target_org_id=1)
|
||||||
|
print(" ❌ Should have raised PermissionError for user without scope")
|
||||||
|
except PermissionError as e:
|
||||||
|
print(f" ✅ Correctly raised PermissionError: {e}")
|
||||||
|
|
||||||
|
# 7. Test GLOBAL scope
|
||||||
|
print("\n[TEST 7] Testing GLOBAL scope...")
|
||||||
|
class MockUserGlobal:
|
||||||
|
role = UserRole.ADMIN
|
||||||
|
scope_type = "GLOBAL"
|
||||||
|
scope_value = None
|
||||||
|
id = 999997
|
||||||
|
|
||||||
|
try:
|
||||||
|
await rbac_service.check_admin_access(db, MockUserGlobal(), AdminAction.VIEW_DATA, target_org_id=1)
|
||||||
|
print(" ✅ GLOBAL scope allows access to any org")
|
||||||
|
except PermissionError as e:
|
||||||
|
print(f" ❌ GLOBAL scope should allow access but got: {e}")
|
||||||
|
|
||||||
|
# 8. Test get_accessible_org_ids for GLOBAL scope
|
||||||
|
print("\n[TEST 8] Testing get_accessible_org_ids for GLOBAL scope...")
|
||||||
|
org_ids = await rbac_service.get_accessible_org_ids(db, MockUserGlobal())
|
||||||
|
assert isinstance(org_ids, list), "Should return a list"
|
||||||
|
print(f" ✅ GLOBAL scope returns {len(org_ids)} accessible orgs")
|
||||||
|
|
||||||
|
# 9. Test REGION scope matching
|
||||||
|
print("\n[TEST 9] Testing REGION scope matching...")
|
||||||
|
# First, set an org's region to a known value for testing
|
||||||
|
await db.execute(
|
||||||
|
text("UPDATE fleet.organizations SET region = 'Test_Region' WHERE id = 1")
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
class MockUserRegion:
|
||||||
|
role = UserRole.ADMIN
|
||||||
|
scope_type = "REGION"
|
||||||
|
scope_value = "Test_Region"
|
||||||
|
id = 999996
|
||||||
|
|
||||||
|
try:
|
||||||
|
await rbac_service.check_admin_access(db, MockUserRegion(), AdminAction.VIEW_DATA, target_org_id=1)
|
||||||
|
print(" ✅ REGION scope matches org with same region")
|
||||||
|
except PermissionError as e:
|
||||||
|
print(f" ❌ REGION scope should match but got: {e}")
|
||||||
|
|
||||||
|
# Test non-matching region
|
||||||
|
class MockUserRegionWrong:
|
||||||
|
role = UserRole.ADMIN
|
||||||
|
scope_type = "REGION"
|
||||||
|
scope_value = "Other_Region"
|
||||||
|
id = 999995
|
||||||
|
|
||||||
|
try:
|
||||||
|
await rbac_service.check_admin_access(db, MockUserRegionWrong(), AdminAction.VIEW_DATA, target_org_id=1)
|
||||||
|
print(" ❌ Should have raised PermissionError for wrong region")
|
||||||
|
except PermissionError as e:
|
||||||
|
print(f" ✅ Correctly denied access for wrong region: {e}")
|
||||||
|
|
||||||
|
# 10. Test SEGMENT scope matching
|
||||||
|
print("\n[TEST 10] Testing SEGMENT scope matching...")
|
||||||
|
await db.execute(
|
||||||
|
text("UPDATE fleet.organizations SET segment = 'Test_Segment' WHERE id = 1")
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
class MockUserSegment:
|
||||||
|
role = UserRole.ADMIN
|
||||||
|
scope_type = "SEGMENT"
|
||||||
|
scope_value = "Test_Segment"
|
||||||
|
id = 999994
|
||||||
|
|
||||||
|
try:
|
||||||
|
await rbac_service.check_admin_access(db, MockUserSegment(), AdminAction.VIEW_DATA, target_org_id=1)
|
||||||
|
print(" ✅ SEGMENT scope matches org with same segment")
|
||||||
|
except PermissionError as e:
|
||||||
|
print(f" ❌ SEGMENT scope should match but got: {e}")
|
||||||
|
|
||||||
|
# 11. Test action not permitted for role
|
||||||
|
print("\n[TEST 11] Testing action not permitted for role...")
|
||||||
|
class MockUserModerator:
|
||||||
|
role = UserRole.MODERATOR
|
||||||
|
scope_type = "GLOBAL"
|
||||||
|
scope_value = None
|
||||||
|
id = 999993
|
||||||
|
|
||||||
|
try:
|
||||||
|
await rbac_service.check_admin_access(db, MockUserModerator(), AdminAction.DELETE_DATA, target_org_id=1)
|
||||||
|
print(" ❌ Should have raised PermissionError - MODERATOR cannot DELETE_DATA")
|
||||||
|
except PermissionError as e:
|
||||||
|
print(f" ✅ Correctly denied DELETE_DATA for MODERATOR: {e}")
|
||||||
|
|
||||||
|
# Cleanup test data
|
||||||
|
await db.execute(
|
||||||
|
text("UPDATE fleet.organizations SET region = NULL, segment = NULL WHERE id = 1")
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("ALL TESTS PASSED ✅")
|
||||||
|
print("=" * 60)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
success = asyncio.run(test_rbac_service())
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
274
backend/backend/tests/active/test_deep_merge_fix.py
Normal file
274
backend/backend/tests/active/test_deep_merge_fix.py
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
P0 CRITICAL FIX Verification: Deep Merge for JSONB fields.
|
||||||
|
|
||||||
|
This test verifies that the deep_merge_dict() function in admin_packages.py
|
||||||
|
correctly preserves nested JSONB keys (pricing_zones.HU, lifecycle.available_until)
|
||||||
|
when a PATCH request only sends a subset of the data.
|
||||||
|
|
||||||
|
Test scenarios:
|
||||||
|
1. deep_merge_dict unit test — verify the function works correctly in isolation
|
||||||
|
2. API PATCH test — send a PATCH with only DEFAULT pricing zone, verify HU/US zones survive
|
||||||
|
3. API PATCH test — send a PATCH with lifecycle.is_public, verify available_until survives
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
# Add backend to path
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../backend'))
|
||||||
|
|
||||||
|
# ── Import the deep_merge_dict function directly ──
|
||||||
|
from app.api.v1.endpoints.admin_packages import deep_merge_dict
|
||||||
|
|
||||||
|
|
||||||
|
def test_deep_merge_dict_unit():
|
||||||
|
"""Unit test: verify deep_merge_dict preserves nested keys."""
|
||||||
|
print("=" * 70)
|
||||||
|
print("🧪 TEST 1: deep_merge_dict() unit test")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# ── Scenario A: Multi-zone pricing preservation ──
|
||||||
|
base = {
|
||||||
|
"type": "private",
|
||||||
|
"pricing_zones": {
|
||||||
|
"HU": {"monthly_price": 990.0, "yearly_price": 9990.0, "currency": "HUF", "credit_price": 25000},
|
||||||
|
"US": {"monthly_price": 3.5, "yearly_price": 35.9, "currency": "USD", "credit_price": 25000},
|
||||||
|
"DEFAULT": {"monthly_price": 2.99, "yearly_price": 29.9, "currency": "EUR", "credit_price": 25000},
|
||||||
|
},
|
||||||
|
"lifecycle": {"is_public": True, "available_until": "2026-12-31T23:59:59Z"},
|
||||||
|
"allowances": {"max_vehicles": 3, "max_garages": 1, "monthly_free_credits": 50},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Frontend sends ONLY DEFAULT zone update (simulating the bug scenario)
|
||||||
|
override = {
|
||||||
|
"type": "private",
|
||||||
|
"pricing_zones": {
|
||||||
|
"DEFAULT": {"monthly_price": 5.99, "yearly_price": 59.9, "currency": "EUR"},
|
||||||
|
},
|
||||||
|
"lifecycle": {"is_public": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = deep_merge_dict(base, override)
|
||||||
|
|
||||||
|
# Verify HU zone is PRESERVED
|
||||||
|
assert "HU" in merged["pricing_zones"], "❌ FAIL: HU pricing zone was LOST!"
|
||||||
|
assert merged["pricing_zones"]["HU"]["monthly_price"] == 990.0, "❌ FAIL: HU price changed!"
|
||||||
|
assert merged["pricing_zones"]["HU"]["currency"] == "HUF", "❌ FAIL: HU currency changed!"
|
||||||
|
|
||||||
|
# Verify US zone is PRESERVED
|
||||||
|
assert "US" in merged["pricing_zones"], "❌ FAIL: US pricing zone was LOST!"
|
||||||
|
assert merged["pricing_zones"]["US"]["monthly_price"] == 3.5, "❌ FAIL: US price changed!"
|
||||||
|
|
||||||
|
# Verify DEFAULT zone is UPDATED
|
||||||
|
assert merged["pricing_zones"]["DEFAULT"]["monthly_price"] == 5.99, "❌ FAIL: DEFAULT price not updated!"
|
||||||
|
assert merged["pricing_zones"]["DEFAULT"]["yearly_price"] == 59.9, "❌ FAIL: DEFAULT yearly price not updated!"
|
||||||
|
|
||||||
|
# Verify lifecycle.available_until is PRESERVED
|
||||||
|
assert merged["lifecycle"]["available_until"] == "2026-12-31T23:59:59Z", \
|
||||||
|
"❌ FAIL: lifecycle.available_until was LOST!"
|
||||||
|
assert merged["lifecycle"]["is_public"] is True, "❌ FAIL: lifecycle.is_public changed!"
|
||||||
|
|
||||||
|
# Verify allowances are PRESERVED
|
||||||
|
assert merged["allowances"]["max_vehicles"] == 3, "❌ FAIL: allowances.max_vehicles changed!"
|
||||||
|
|
||||||
|
print(" ✅ PASS: All pricing zones preserved (HU, US, DEFAULT)")
|
||||||
|
print(" ✅ PASS: lifecycle.available_until preserved")
|
||||||
|
print(" ✅ PASS: Unrelated fields (allowances) preserved")
|
||||||
|
print(" ✅ PASS: Updated fields correctly applied (DEFAULT price changed)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ── Scenario B: None values are skipped ──
|
||||||
|
base_b = {"pricing_zones": {"DEFAULT": {"monthly_price": 10.0}}}
|
||||||
|
override_b = {"pricing_zones": {"DEFAULT": {"monthly_price": None}}}
|
||||||
|
merged_b = deep_merge_dict(base_b, override_b)
|
||||||
|
assert merged_b["pricing_zones"]["DEFAULT"]["monthly_price"] == 10.0, \
|
||||||
|
"❌ FAIL: None override should preserve existing value!"
|
||||||
|
print(" ✅ PASS: None values correctly preserve existing data")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ── Scenario C: Empty override doesn't destroy data ──
|
||||||
|
base_c = {"pricing_zones": {"HU": {"monthly_price": 990.0}}}
|
||||||
|
override_c = {}
|
||||||
|
merged_c = deep_merge_dict(base_c, override_c)
|
||||||
|
assert merged_c["pricing_zones"]["HU"]["monthly_price"] == 990.0, \
|
||||||
|
"❌ FAIL: Empty override should preserve everything!"
|
||||||
|
print(" ✅ PASS: Empty override preserves all existing data")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("🎉 All unit tests PASSED!")
|
||||||
|
print()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_api_patch_preserves_zones():
|
||||||
|
"""
|
||||||
|
Test the actual API endpoint by sending a PATCH request.
|
||||||
|
Uses the admin auth token and tests against private_pro_v1 (id=14).
|
||||||
|
"""
|
||||||
|
print("=" * 70)
|
||||||
|
print("🧪 TEST 2: API PATCH — pricing zone preservation")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
# Get admin token
|
||||||
|
BASE_URL = os.environ.get("API_BASE_URL", "http://localhost:8000")
|
||||||
|
ADMIN_EMAIL = os.environ.get("ADMIN_EMAIL", "admin@profibot.hu")
|
||||||
|
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "Admin123!")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(base_url=BASE_URL, timeout=30) as client:
|
||||||
|
# ── Login (OAuth2 form) ──
|
||||||
|
login_resp = await client.post("/api/v1/auth/login", data={
|
||||||
|
"username": ADMIN_EMAIL,
|
||||||
|
"password": ADMIN_PASSWORD,
|
||||||
|
})
|
||||||
|
if login_resp.status_code != 200:
|
||||||
|
print(f" ⚠️ Login failed ({login_resp.status_code}), skipping API test.")
|
||||||
|
print(f" Response: {login_resp.text[:200]}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
token = login_resp.json().get("access_token")
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
# ── Fetch private_pro_v1 (id=14) ──
|
||||||
|
list_resp = await client.get("/api/v1/admin/packages", headers=headers)
|
||||||
|
if list_resp.status_code != 200:
|
||||||
|
print(f" ⚠️ List failed ({list_resp.status_code}), skipping API test.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
tiers = list_resp.json().get("tiers", [])
|
||||||
|
target = next((t for t in tiers if t["name"] == "private_pro_v1"), None)
|
||||||
|
if not target:
|
||||||
|
print(" ⚠️ private_pro_v1 not found, trying first package with pricing_zones...")
|
||||||
|
target = next((t for t in tiers if t.get("rules", {}).get("pricing_zones")), None)
|
||||||
|
|
||||||
|
if not target:
|
||||||
|
print(" ⚠️ No package with pricing_zones found, skipping API test.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
tier_id = target["id"]
|
||||||
|
print(f" Target package: {target['name']} (id={tier_id})")
|
||||||
|
|
||||||
|
# Record zones BEFORE patch
|
||||||
|
before_zones = dict(target["rules"].get("pricing_zones", {}))
|
||||||
|
before_lifecycle = dict(target["rules"].get("lifecycle", {}))
|
||||||
|
print(f" Zones before: {list(before_zones.keys())}")
|
||||||
|
print(f" Lifecycle before: {before_lifecycle}")
|
||||||
|
|
||||||
|
# ── Send PATCH with ONLY DEFAULT zone ──
|
||||||
|
patch_payload = {
|
||||||
|
"rules": {
|
||||||
|
"type": target["rules"]["type"],
|
||||||
|
"pricing_zones": {
|
||||||
|
"DEFAULT": {
|
||||||
|
"monthly_price": 999.99,
|
||||||
|
"yearly_price": 9999.99,
|
||||||
|
"currency": "EUR",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lifecycle": {"is_public": True},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
patch_resp = await client.patch(
|
||||||
|
f"/api/v1/admin/packages/{tier_id}",
|
||||||
|
headers=headers,
|
||||||
|
json=patch_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if patch_resp.status_code != 200:
|
||||||
|
print(f" ❌ PATCH failed ({patch_resp.status_code}): {patch_resp.text[:300]}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
updated = patch_resp.json()
|
||||||
|
after_zones = updated["rules"].get("pricing_zones", {})
|
||||||
|
after_lifecycle = updated["rules"].get("lifecycle", {})
|
||||||
|
|
||||||
|
print(f" Zones after: {list(after_zones.keys())}")
|
||||||
|
print(f" Lifecycle after: {after_lifecycle}")
|
||||||
|
|
||||||
|
# ── VERIFY: All original zones still exist ──
|
||||||
|
all_preserved = True
|
||||||
|
for zone_key in before_zones:
|
||||||
|
if zone_key not in after_zones:
|
||||||
|
print(f" ❌ FAIL: Zone '{zone_key}' was LOST!")
|
||||||
|
all_preserved = False
|
||||||
|
else:
|
||||||
|
print(f" ✅ Zone '{zone_key}' preserved")
|
||||||
|
|
||||||
|
# ── VERIFY: DEFAULT price was updated ──
|
||||||
|
if "DEFAULT" in after_zones:
|
||||||
|
if after_zones["DEFAULT"]["monthly_price"] == 999.99:
|
||||||
|
print(f" ✅ DEFAULT price updated correctly")
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ DEFAULT price mismatch: {after_zones['DEFAULT']['monthly_price']}")
|
||||||
|
|
||||||
|
# ── VERIFY: lifecycle.available_until preserved ──
|
||||||
|
if before_lifecycle.get("available_until"):
|
||||||
|
if after_lifecycle.get("available_until") == before_lifecycle["available_until"]:
|
||||||
|
print(f" ✅ lifecycle.available_until preserved")
|
||||||
|
else:
|
||||||
|
print(f" ❌ FAIL: lifecycle.available_until was LOST or changed!")
|
||||||
|
print(f" Before: {before_lifecycle.get('available_until')}")
|
||||||
|
print(f" After: {after_lifecycle.get('available_until')}")
|
||||||
|
all_preserved = False
|
||||||
|
|
||||||
|
if all_preserved:
|
||||||
|
print("\n🎉 API test PASSED! Deep merge is working correctly.")
|
||||||
|
else:
|
||||||
|
print("\n❌ API test FAILED — some data was lost!")
|
||||||
|
|
||||||
|
# ── Restore original data ──
|
||||||
|
restore_payload = {
|
||||||
|
"rules": {
|
||||||
|
"type": target["rules"]["type"],
|
||||||
|
"pricing_zones": before_zones,
|
||||||
|
"lifecycle": before_lifecycle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await client.patch(
|
||||||
|
f"/api/v1/admin/packages/{tier_id}",
|
||||||
|
headers=headers,
|
||||||
|
json=restore_payload,
|
||||||
|
)
|
||||||
|
print(" 🔄 Original data restored")
|
||||||
|
|
||||||
|
return all_preserved
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("🔬 P0 CRITICAL FIX VERIFICATION: Deep Merge for JSONB")
|
||||||
|
print("=" * 70)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Test 1: Unit test
|
||||||
|
unit_ok = test_deep_merge_dict_unit()
|
||||||
|
|
||||||
|
# Test 2: API test
|
||||||
|
print()
|
||||||
|
api_ok = await test_api_patch_preserves_zones()
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("📊 SUMMARY")
|
||||||
|
print("=" * 70)
|
||||||
|
print(f" Unit test: {'✅ PASS' if unit_ok else '❌ FAIL'}")
|
||||||
|
print(f" API test: {'✅ PASS' if api_ok else '❌ FAIL (or skipped)'}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if unit_ok:
|
||||||
|
print("✅ The deep_merge_dict() function is working correctly.")
|
||||||
|
print(" Multi-zone pricing data loss vulnerability is CLOSED.")
|
||||||
|
else:
|
||||||
|
print("❌ Fix verification FAILED!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
139
backend/backend/tests/active/test_subscription_feature_flags.py
Normal file
139
backend/backend/tests/active/test_subscription_feature_flags.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script for subscription feature flag endpoints (Gitea #239).
|
||||||
|
Tests: GET /subscriptions/my, GET /subscriptions/feature-flags, GET /subscriptions/check/{key}
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
import sys
|
||||||
|
|
||||||
|
BASE_URL = "http://sf_api:8000/api/v1"
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||||
|
# ── Step 1: Login with OAuth2 form data ──
|
||||||
|
print("=" * 60)
|
||||||
|
print("STEP 1: Login with admin user")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
login_data = {
|
||||||
|
"username": "admin@profibot.hu",
|
||||||
|
"password": "Admin123!",
|
||||||
|
"grant_type": "password",
|
||||||
|
}
|
||||||
|
|
||||||
|
login_resp = await client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data=login_data, # OAuth2 form data
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if login_resp.status_code != 200:
|
||||||
|
print(f"❌ Login failed: {login_resp.status_code}")
|
||||||
|
print(f" Response: {login_resp.text}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
token_data = login_resp.json()
|
||||||
|
token = token_data.get("access_token")
|
||||||
|
print(f"✅ Login OK — token: {token[:50]}...")
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
# ── Step 2: GET /subscriptions/my ──
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 2: GET /subscriptions/my")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
resp = await client.get("/subscriptions/my", headers=headers)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
print(f"✅ Status: 200")
|
||||||
|
print(f" Tier: {data.get('tier')}")
|
||||||
|
print(f" Source: {data.get('source')}")
|
||||||
|
print(f" Expires: {data.get('expires_at')}")
|
||||||
|
features = data.get('features', {})
|
||||||
|
print(f" Features count: {len(features)}")
|
||||||
|
# Show first 5 features
|
||||||
|
for i, (k, v) in enumerate(features.items()):
|
||||||
|
if i >= 5:
|
||||||
|
print(f" ... and {len(features) - 5} more")
|
||||||
|
break
|
||||||
|
print(f" - {k}: {v}")
|
||||||
|
sub = data.get('subscription')
|
||||||
|
if sub:
|
||||||
|
print(f" Subscription tier_name: {sub.get('tier_name')}")
|
||||||
|
print(f" Subscription display_name: {sub.get('display_name')}")
|
||||||
|
else:
|
||||||
|
print(f"❌ Failed: {resp.status_code}")
|
||||||
|
print(f" Response: {resp.text[:500]}")
|
||||||
|
|
||||||
|
# ── Step 3: GET /subscriptions/feature-flags ──
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 3: GET /subscriptions/feature-flags")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
resp = await client.get("/subscriptions/feature-flags", headers=headers)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
print(f"✅ Status: 200")
|
||||||
|
print(f" Tier: {data.get('tier')}")
|
||||||
|
features = data.get('features', {})
|
||||||
|
print(f" Features count: {len(features)}")
|
||||||
|
# Show all features
|
||||||
|
for k, v in sorted(features.items()):
|
||||||
|
print(f" - {k}: {v}")
|
||||||
|
else:
|
||||||
|
print(f"❌ Failed: {resp.status_code}")
|
||||||
|
print(f" Response: {resp.text[:500]}")
|
||||||
|
|
||||||
|
# ── Step 4: GET /subscriptions/check/{feature_key} ──
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 4: GET /subscriptions/check/analytics_tco")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
resp = await client.get("/subscriptions/check/analytics_tco", headers=headers)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
print(f"✅ Status: 200")
|
||||||
|
print(f" feature_key: {data.get('feature_key')}")
|
||||||
|
print(f" granted: {data.get('granted')}")
|
||||||
|
print(f" tier: {data.get('tier')}")
|
||||||
|
print(f" required_tier: {data.get('required_tier')}")
|
||||||
|
else:
|
||||||
|
print(f"❌ Failed: {resp.status_code}")
|
||||||
|
print(f" Response: {resp.text[:500]}")
|
||||||
|
|
||||||
|
# ── Step 5: GET /subscriptions/check/unknown_feature ──
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 5: GET /subscriptions/check/unknown_feature (expect 404)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
resp = await client.get("/subscriptions/check/unknown_feature", headers=headers)
|
||||||
|
if resp.status_code == 404:
|
||||||
|
print(f"✅ Correctly returned 404 for unknown feature")
|
||||||
|
else:
|
||||||
|
print(f"❌ Expected 404, got {resp.status_code}")
|
||||||
|
print(f" Response: {resp.text[:500]}")
|
||||||
|
|
||||||
|
# ── Step 6: GET /subscriptions/public ──
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 6: GET /subscriptions/public")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
resp = await client.get("/subscriptions/public", headers=headers)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
print(f"✅ Status: 200")
|
||||||
|
print(f" Public tiers count: {len(data)}")
|
||||||
|
for tier in data[:3]:
|
||||||
|
print(f" - {tier.get('name')}: {tier.get('resolved_pricing', {}).get('monthly_price', 'N/A')}")
|
||||||
|
else:
|
||||||
|
print(f"❌ Failed: {resp.status_code}")
|
||||||
|
print(f" Response: {resp.text[:500]}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("✅ ALL TESTS COMPLETED")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
190
backend/scripts/migrate_providers.py
Normal file
190
backend/scripts/migrate_providers.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
P0 HYBRID VENDOR REFACTOR: Data Migration Script
|
||||||
|
|
||||||
|
Migrates existing crowdsourced service provider organizations to the new
|
||||||
|
marketplace.service_providers table.
|
||||||
|
|
||||||
|
Safe List (valódi garázsok, amelyek NEM kerülnek migrálásra):
|
||||||
|
(15, 21, 43, 45, 47, 48, 49, 50, 66, 67, 69, 70, 71)
|
||||||
|
|
||||||
|
Folyamat:
|
||||||
|
1. Lekérdezi az összes Organization-t, ahol org_type='service_provider'
|
||||||
|
és az id NINCS a safe list-ben.
|
||||||
|
2. Minden ilyen Organization-hoz létrehoz egy ServiceProvider rekordot.
|
||||||
|
3. Frissíti a ServiceProfile-okat: service_provider_id beállítása.
|
||||||
|
4. Frissíti az AssetCost-okat: service_provider_id beállítása.
|
||||||
|
5. Jelentés a migrációról.
|
||||||
|
|
||||||
|
Használat:
|
||||||
|
docker compose exec sf_api python3 /app/scripts/migrate_providers.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Konfiguráció
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Safe list: ezek az Organization ID-k valódi garázsok, NEM migráljuk őket
|
||||||
|
SAFE_ORG_IDS = (15, 21, 43, 45, 47, 48, 49, 50, 66, 67, 69, 70, 71)
|
||||||
|
|
||||||
|
# Adatbázis URL a settings-ből
|
||||||
|
DATABASE_URL = settings.DATABASE_URL
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
handlers=[logging.StreamHandler(sys.stdout)],
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate_providers():
|
||||||
|
"""
|
||||||
|
Main migration logic.
|
||||||
|
"""
|
||||||
|
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||||
|
|
||||||
|
async with AsyncSession(engine) as db:
|
||||||
|
# ── 1. Keresd meg a migrálható Organization-öket ──
|
||||||
|
logger.info("🔍 Searching for service_provider organizations outside safe list...")
|
||||||
|
|
||||||
|
# Build safe_ids placeholder list for asyncpg
|
||||||
|
safe_list = ", ".join([str(x) for x in SAFE_ORG_IDS])
|
||||||
|
sql = f"""
|
||||||
|
SELECT id, name, display_name, address_city, address_zip,
|
||||||
|
address_street_name, address_street_type, address_house_number,
|
||||||
|
plus_code, owner_id, created_at
|
||||||
|
FROM fleet.organizations
|
||||||
|
WHERE org_type = 'service_provider'
|
||||||
|
AND id NOT IN ({safe_list})
|
||||||
|
"""
|
||||||
|
result = await db.execute(text(sql))
|
||||||
|
orgs = result.fetchall()
|
||||||
|
|
||||||
|
logger.info(f"📊 Found {len(orgs)} organizations to migrate.")
|
||||||
|
|
||||||
|
if not orgs:
|
||||||
|
logger.info("✅ No organizations to migrate. Nothing to do.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── 2. Minden Organization-hoz hozz létre egy ServiceProvider-t ──
|
||||||
|
migrated_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
error_count = 0
|
||||||
|
|
||||||
|
for org in orgs:
|
||||||
|
org_id = org[0]
|
||||||
|
org_name = org[1]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Ellenőrizd, hogy már létezik-e ServiceProvider ehhez az org-hoz
|
||||||
|
check_stmt = select(text("id")).select_from(
|
||||||
|
text("marketplace.service_providers")
|
||||||
|
).where(text("name = :name")).params(name=org_name)
|
||||||
|
check_result = await db.execute(check_stmt)
|
||||||
|
existing = check_result.fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
logger.warning(f"⚠️ ServiceProvider already exists for org_id={org_id} (name={org_name}), skipping...")
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Összeállítjuk a címet
|
||||||
|
street_parts = [p for p in [org[5], org[6], org[7]] if p]
|
||||||
|
address = ", ".join(street_parts) if street_parts else org_name
|
||||||
|
|
||||||
|
# ServiceProvider létrehozása
|
||||||
|
insert_sp = text("""
|
||||||
|
INSERT INTO marketplace.service_providers
|
||||||
|
(name, address, city, address_zip, address_street_name,
|
||||||
|
address_street_type, address_house_number, plus_code,
|
||||||
|
status, source, validation_score, added_by_user_id, created_at)
|
||||||
|
VALUES
|
||||||
|
(:name, :address, :city, :zip, :street_name,
|
||||||
|
:street_type, :house_number, :plus_code,
|
||||||
|
'pending', 'manual', 50, :added_by, :created_at)
|
||||||
|
RETURNING id
|
||||||
|
""")
|
||||||
|
|
||||||
|
sp_result = await db.execute(
|
||||||
|
insert_sp,
|
||||||
|
{
|
||||||
|
"name": org_name,
|
||||||
|
"address": address,
|
||||||
|
"city": org[3],
|
||||||
|
"zip": org[4],
|
||||||
|
"street_name": org[5],
|
||||||
|
"street_type": org[6],
|
||||||
|
"house_number": org[7],
|
||||||
|
"plus_code": org[8],
|
||||||
|
"added_by": org[9],
|
||||||
|
"created_at": org[10] or datetime.now(timezone.utc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
sp_id = sp_result.scalar()
|
||||||
|
|
||||||
|
# ── 3. Frissítsd a ServiceProfile-okat ──
|
||||||
|
update_profile = text("""
|
||||||
|
UPDATE marketplace.service_profiles
|
||||||
|
SET service_provider_id = :sp_id
|
||||||
|
WHERE organization_id = :org_id
|
||||||
|
""")
|
||||||
|
await db.execute(update_profile, {"sp_id": sp_id, "org_id": org_id})
|
||||||
|
|
||||||
|
# ── 4. Frissítsd az AssetCost-okat ──
|
||||||
|
update_cost = text("""
|
||||||
|
UPDATE fleet_finance.asset_costs
|
||||||
|
SET service_provider_id = :sp_id
|
||||||
|
WHERE vendor_organization_id = :org_id
|
||||||
|
""")
|
||||||
|
await db.execute(update_cost, {"sp_id": sp_id, "org_id": org_id})
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
migrated_count += 1
|
||||||
|
logger.info(f"✅ Migrated org_id={org_id} (name={org_name}) -> service_provider_id={sp_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
error_count += 1
|
||||||
|
logger.error(f"❌ Error migrating org_id={org_id} (name={org_name}): {e}")
|
||||||
|
|
||||||
|
# ── 5. Jelentés ──
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("📋 MIGRATION REPORT")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info(f" Total organizations found: {len(orgs)}")
|
||||||
|
logger.info(f" Successfully migrated: {migrated_count}")
|
||||||
|
logger.info(f" Skipped (already exists): {skipped_count}")
|
||||||
|
logger.info(f" Errors: {error_count}")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
if error_count > 0:
|
||||||
|
logger.warning("⚠️ Some migrations failed. Check logs above for details.")
|
||||||
|
else:
|
||||||
|
logger.info("✅ Migration completed successfully!")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
logger.info("🚀 P0 Hybrid Vendor Refactor - Data Migration Script")
|
||||||
|
logger.info(f" Safe org IDs: {SAFE_ORG_IDS}")
|
||||||
|
logger.info(f" Database: {DATABASE_URL}")
|
||||||
|
logger.info("")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await migrate_providers()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"💥 Fatal error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
373
backend/scripts/seed_regions.py
Normal file
373
backend/scripts/seed_regions.py
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
🌍 Seed script for Global Region Registry (system.region_config).
|
||||||
|
|
||||||
|
Populates the system.region_config table with ALL 27 EU member states,
|
||||||
|
plus United Kingdom (GB), Switzerland (CH), and a DEFAULT/Eurozone fallback.
|
||||||
|
|
||||||
|
Total: 30 regions with accurate VAT rates, currencies, locales, and timezones.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
docker compose exec sf_api python3 /app/scripts/seed_regions.py
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Ensure we can import from the project
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.core_logic import RegionConfig
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 🌍 COMPREHENSIVE EU + EEA REGION REGISTRY
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# All 27 EU member states + GB + CH + DEFAULT fallback = 30 entries
|
||||||
|
# VAT rates verified against official EU VAT rates (2025-2026)
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
DEFAULT_REGIONS = [
|
||||||
|
# ── EU MEMBER STATES (27) ──────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"country_code": "AT",
|
||||||
|
"name": "Austria",
|
||||||
|
"currency": "EUR",
|
||||||
|
"default_vat_rate": 20.0,
|
||||||
|
"locale_code": "de-AT",
|
||||||
|
"timezone": "Europe/Vienna",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "BE",
|
||||||
|
"name": "Belgium",
|
||||||
|
"currency": "EUR",
|
||||||
|
"default_vat_rate": 21.0,
|
||||||
|
"locale_code": "nl-BE",
|
||||||
|
"timezone": "Europe/Brussels",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "BG",
|
||||||
|
"name": "Bulgaria",
|
||||||
|
"currency": "BGN",
|
||||||
|
"default_vat_rate": 20.0,
|
||||||
|
"locale_code": "bg-BG",
|
||||||
|
"timezone": "Europe/Sofia",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "HR",
|
||||||
|
"name": "Croatia",
|
||||||
|
"currency": "EUR",
|
||||||
|
"default_vat_rate": 25.0,
|
||||||
|
"locale_code": "hr-HR",
|
||||||
|
"timezone": "Europe/Zagreb",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "CY",
|
||||||
|
"name": "Cyprus",
|
||||||
|
"currency": "EUR",
|
||||||
|
"default_vat_rate": 19.0,
|
||||||
|
"locale_code": "el-CY",
|
||||||
|
"timezone": "Asia/Nicosia",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "CZ",
|
||||||
|
"name": "Czech Republic",
|
||||||
|
"currency": "CZK",
|
||||||
|
"default_vat_rate": 21.0,
|
||||||
|
"locale_code": "cs-CZ",
|
||||||
|
"timezone": "Europe/Prague",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "DK",
|
||||||
|
"name": "Denmark",
|
||||||
|
"currency": "DKK",
|
||||||
|
"default_vat_rate": 25.0,
|
||||||
|
"locale_code": "da-DK",
|
||||||
|
"timezone": "Europe/Copenhagen",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "EE",
|
||||||
|
"name": "Estonia",
|
||||||
|
"currency": "EUR",
|
||||||
|
"default_vat_rate": 22.0,
|
||||||
|
"locale_code": "et-EE",
|
||||||
|
"timezone": "Europe/Tallinn",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "FI",
|
||||||
|
"name": "Finland",
|
||||||
|
"currency": "EUR",
|
||||||
|
"default_vat_rate": 25.5,
|
||||||
|
"locale_code": "fi-FI",
|
||||||
|
"timezone": "Europe/Helsinki",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "FR",
|
||||||
|
"name": "France",
|
||||||
|
"currency": "EUR",
|
||||||
|
"default_vat_rate": 20.0,
|
||||||
|
"locale_code": "fr-FR",
|
||||||
|
"timezone": "Europe/Paris",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "DE",
|
||||||
|
"name": "Germany",
|
||||||
|
"currency": "EUR",
|
||||||
|
"default_vat_rate": 19.0,
|
||||||
|
"locale_code": "de-DE",
|
||||||
|
"timezone": "Europe/Berlin",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "GR",
|
||||||
|
"name": "Greece",
|
||||||
|
"currency": "EUR",
|
||||||
|
"default_vat_rate": 24.0,
|
||||||
|
"locale_code": "el-GR",
|
||||||
|
"timezone": "Europe/Athens",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "HU",
|
||||||
|
"name": "Hungary",
|
||||||
|
"currency": "HUF",
|
||||||
|
"default_vat_rate": 27.0,
|
||||||
|
"locale_code": "hu-HU",
|
||||||
|
"timezone": "Europe/Budapest",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "IE",
|
||||||
|
"name": "Ireland",
|
||||||
|
"currency": "EUR",
|
||||||
|
"default_vat_rate": 23.0,
|
||||||
|
"locale_code": "en-IE",
|
||||||
|
"timezone": "Europe/Dublin",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "IT",
|
||||||
|
"name": "Italy",
|
||||||
|
"currency": "EUR",
|
||||||
|
"default_vat_rate": 22.0,
|
||||||
|
"locale_code": "it-IT",
|
||||||
|
"timezone": "Europe/Rome",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "LV",
|
||||||
|
"name": "Latvia",
|
||||||
|
"currency": "EUR",
|
||||||
|
"default_vat_rate": 21.0,
|
||||||
|
"locale_code": "lv-LV",
|
||||||
|
"timezone": "Europe/Riga",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "LT",
|
||||||
|
"name": "Lithuania",
|
||||||
|
"currency": "EUR",
|
||||||
|
"default_vat_rate": 21.0,
|
||||||
|
"locale_code": "lt-LT",
|
||||||
|
"timezone": "Europe/Vilnius",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "LU",
|
||||||
|
"name": "Luxembourg",
|
||||||
|
"currency": "EUR",
|
||||||
|
"default_vat_rate": 17.0,
|
||||||
|
"locale_code": "lb-LU",
|
||||||
|
"timezone": "Europe/Luxembourg",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "MT",
|
||||||
|
"name": "Malta",
|
||||||
|
"currency": "EUR",
|
||||||
|
"default_vat_rate": 18.0,
|
||||||
|
"locale_code": "mt-MT",
|
||||||
|
"timezone": "Europe/Malta",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "NL",
|
||||||
|
"name": "Netherlands",
|
||||||
|
"currency": "EUR",
|
||||||
|
"default_vat_rate": 21.0,
|
||||||
|
"locale_code": "nl-NL",
|
||||||
|
"timezone": "Europe/Amsterdam",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "PL",
|
||||||
|
"name": "Poland",
|
||||||
|
"currency": "PLN",
|
||||||
|
"default_vat_rate": 23.0,
|
||||||
|
"locale_code": "pl-PL",
|
||||||
|
"timezone": "Europe/Warsaw",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "PT",
|
||||||
|
"name": "Portugal",
|
||||||
|
"currency": "EUR",
|
||||||
|
"default_vat_rate": 23.0,
|
||||||
|
"locale_code": "pt-PT",
|
||||||
|
"timezone": "Europe/Lisbon",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "RO",
|
||||||
|
"name": "Romania",
|
||||||
|
"currency": "RON",
|
||||||
|
"default_vat_rate": 19.0,
|
||||||
|
"locale_code": "ro-RO",
|
||||||
|
"timezone": "Europe/Bucharest",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "SK",
|
||||||
|
"name": "Slovakia",
|
||||||
|
"currency": "EUR",
|
||||||
|
"default_vat_rate": 23.0,
|
||||||
|
"locale_code": "sk-SK",
|
||||||
|
"timezone": "Europe/Bratislava",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "SI",
|
||||||
|
"name": "Slovenia",
|
||||||
|
"currency": "EUR",
|
||||||
|
"default_vat_rate": 22.0,
|
||||||
|
"locale_code": "sl-SI",
|
||||||
|
"timezone": "Europe/Ljubljana",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "ES",
|
||||||
|
"name": "Spain",
|
||||||
|
"currency": "EUR",
|
||||||
|
"default_vat_rate": 21.0,
|
||||||
|
"locale_code": "es-ES",
|
||||||
|
"timezone": "Europe/Madrid",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "SE",
|
||||||
|
"name": "Sweden",
|
||||||
|
"currency": "SEK",
|
||||||
|
"default_vat_rate": 25.0,
|
||||||
|
"locale_code": "sv-SE",
|
||||||
|
"timezone": "Europe/Stockholm",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
# ── NON-EU EEA / ASSOCIATED ────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"country_code": "GB",
|
||||||
|
"name": "United Kingdom",
|
||||||
|
"currency": "GBP",
|
||||||
|
"default_vat_rate": 20.0,
|
||||||
|
"locale_code": "en-GB",
|
||||||
|
"timezone": "Europe/London",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country_code": "CH",
|
||||||
|
"name": "Switzerland",
|
||||||
|
"currency": "CHF",
|
||||||
|
"default_vat_rate": 8.1,
|
||||||
|
"locale_code": "de-CH",
|
||||||
|
"timezone": "Europe/Zurich",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
# ── FALLBACK / DEFAULT ─────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"country_code": "DEFAULT",
|
||||||
|
"name": "Eurozone (Default)",
|
||||||
|
"currency": "EUR",
|
||||||
|
"default_vat_rate": 0.0,
|
||||||
|
"locale_code": "en-EU",
|
||||||
|
"timezone": "Europe/Brussels",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_regions():
|
||||||
|
"""
|
||||||
|
Seed the system.region_config table with all EU regions + GB + CH + DEFAULT.
|
||||||
|
|
||||||
|
Uses UPSERT logic: if a region with the same country_code already exists,
|
||||||
|
its fields are UPDATED (name, currency, VAT rate, locale, timezone, is_active)
|
||||||
|
rather than skipped. This ensures the script is idempotent and can be re-run
|
||||||
|
to update VAT rates or other configuration changes.
|
||||||
|
"""
|
||||||
|
engine = create_async_engine(str(settings.SQLALCHEMY_DATABASE_URI), echo=False)
|
||||||
|
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
async with session.begin():
|
||||||
|
for region_data in DEFAULT_REGIONS:
|
||||||
|
country_code = region_data["country_code"]
|
||||||
|
|
||||||
|
# Check if region already exists
|
||||||
|
stmt = select(RegionConfig).where(
|
||||||
|
RegionConfig.country_code == country_code
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# UPSERT: update existing record with new values
|
||||||
|
existing.name = region_data["name"]
|
||||||
|
existing.currency = region_data["currency"]
|
||||||
|
existing.default_vat_rate = region_data["default_vat_rate"]
|
||||||
|
existing.locale_code = region_data["locale_code"]
|
||||||
|
existing.timezone = region_data["timezone"]
|
||||||
|
existing.is_active = region_data["is_active"]
|
||||||
|
logger.info(
|
||||||
|
f"🔄 Updated region: {country_code} - {region_data['name']} "
|
||||||
|
f"(VAT: {region_data['default_vat_rate']}%, "
|
||||||
|
f"Currency: {region_data['currency']})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# INSERT: create new region
|
||||||
|
region = RegionConfig(**region_data)
|
||||||
|
session.add(region)
|
||||||
|
logger.info(
|
||||||
|
f"✅ Created region: {country_code} - {region_data['name']} "
|
||||||
|
f"(VAT: {region_data['default_vat_rate']}%, "
|
||||||
|
f"Currency: {region_data['currency']})"
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Count total regions
|
||||||
|
count_stmt = select(RegionConfig)
|
||||||
|
count_result = await session.execute(count_stmt)
|
||||||
|
total = len(count_result.scalars().all())
|
||||||
|
logger.info(f"🎉 Region seeding completed! Total regions in database: {total}")
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(seed_regions())
|
||||||
201
docs/p0_save_bug_pricing_schema_audit_report_2026-06-24.md
Normal file
201
docs/p0_save_bug_pricing_schema_audit_report_2026-06-24.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
# P0 AUDIT REPORT: Save Bug & Pricing Schema Investigation
|
||||||
|
|
||||||
|
**Date:** 2026-06-24
|
||||||
|
**Status:** Read-Only Investigation Complete
|
||||||
|
**Files Audited:** 4 (+1 DB query)
|
||||||
|
**Bugs Found:** 3 (1 Critical, 1 Medium, 1 Low)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. FRONTEND PAYLOAD AUDIT
|
||||||
|
|
||||||
|
### File: [`frontend_admin/pages/packages/index.vue`](../frontend_admin/pages/packages/index.vue)
|
||||||
|
|
||||||
|
#### ✅ `is_default_fallback` and `trial_days_on_signup` ARE in payload
|
||||||
|
|
||||||
|
Both fields are correctly mapped in both POST (create) and PATCH (update):
|
||||||
|
|
||||||
|
- **POST (lines 670-687):** Sends both fields at root level.
|
||||||
|
```javascript
|
||||||
|
body: {
|
||||||
|
name: form.name.trim().toLowerCase(),
|
||||||
|
rules: rulesPayload,
|
||||||
|
is_custom: form.is_custom,
|
||||||
|
tier_level: form.tier_level,
|
||||||
|
feature_capabilities: {},
|
||||||
|
is_default_fallback: form.is_default_fallback, // ✅ Present
|
||||||
|
trial_days_on_signup: form.trial_days_on_signup, // ✅ Present
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **PATCH (lines 697-704):** Sends both fields at root level.
|
||||||
|
```javascript
|
||||||
|
body: {
|
||||||
|
name: form.name.trim().toLowerCase(),
|
||||||
|
rules: rulesPayload,
|
||||||
|
is_custom: form.is_custom,
|
||||||
|
tier_level: form.tier_level,
|
||||||
|
is_default_fallback: form.is_default_fallback, // ✅ Present
|
||||||
|
trial_days_on_signup: form.trial_days_on_signup, // ✅ Present
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ❌ `rulesPayload` structure (lines 646-668)
|
||||||
|
|
||||||
|
The `rulesPayload` is reconstructed fresh on every save with a flat structure:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const rulesPayload: any = {
|
||||||
|
type: form.type,
|
||||||
|
display_name: form.display_name || null,
|
||||||
|
pricing_zones: {
|
||||||
|
DEFAULT: { // ⚠️ ONLY DEFAULT ZONE!
|
||||||
|
monthly_price: form.monthly_price,
|
||||||
|
yearly_price: form.yearly_price,
|
||||||
|
currency: form.currency.toUpperCase(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
allowances: { ... },
|
||||||
|
marketing: { ... },
|
||||||
|
lifecycle: { is_public: true }, // ⚠️ available_until NOT included
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. BACKEND API AUDIT
|
||||||
|
|
||||||
|
### File: [`backend/app/api/v1/endpoints/admin_packages.py`](../backend/app/api/v1/endpoints/admin_packages.py)
|
||||||
|
|
||||||
|
#### ✅ Singleton Logic is CORRECT
|
||||||
|
|
||||||
|
- **`is_default_fallback` True (lines 283-294):** Correctly clears fallback on ALL OTHER tiers using `SubscriptionTier.id != tier_id`, then sets current to True.
|
||||||
|
- **`is_default_fallback` False (lines 295-296):** Sets current tier to False. Safe.
|
||||||
|
- **`trial_days_on_signup` > 0 (lines 299-310):** Correctly clears all other tiers using `SubscriptionTier.id != tier_id`.
|
||||||
|
|
||||||
|
No logical flaw found in the singleton implementation.
|
||||||
|
|
||||||
|
#### ✅ `exclude_unset=True` is used correctly
|
||||||
|
|
||||||
|
Line 248: `update_data = payload.model_dump(exclude_unset=True)` — This ensures that only the fields the frontend sends are applied, leaving untouched fields as-is.
|
||||||
|
|
||||||
|
#### ❌ Lifecycle preservation logic has a gap (lines 270-272)
|
||||||
|
|
||||||
|
```python
|
||||||
|
if isinstance(new_rules, dict) and new_rules.get("lifecycle") is None and tier.rules.get("lifecycle"):
|
||||||
|
new_rules["lifecycle"] = tier.rules["lifecycle"]
|
||||||
|
```
|
||||||
|
|
||||||
|
This preserves the existing lifecycle **only if `lifecycle` is absent** from the incoming payload. But the frontend **always sends** `lifecycle: { is_public: true }` (without `available_until`), so the condition `new_rules.get("lifecycle") is None` is **FALSE**, and the existing `available_until` gets overwritten/lost.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. PRICING SCHEMA AUDIT
|
||||||
|
|
||||||
|
### Database Schema: [`system.subscription_tiers`](../backend/app/models/core_logic.py)
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `id` | `INTEGER PK` | |
|
||||||
|
| `name` | `VARCHAR` | Unique, indexed |
|
||||||
|
| `rules` | **`JSONB`** | **All pricing stored here** |
|
||||||
|
| `is_custom` | `BOOLEAN` | Default: false |
|
||||||
|
| `feature_capabilities` | `JSONB` | Default: `{}` |
|
||||||
|
| `tier_level` | `INTEGER` | Default: 0 |
|
||||||
|
| `is_default_fallback` | `BOOLEAN` | Default: false, singleton |
|
||||||
|
| `trial_days_on_signup` | `INTEGER` | Default: 0, singleton |
|
||||||
|
|
||||||
|
**There are NO dedicated pricing columns.** The price is stored entirely inside the `rules` JSONB column.
|
||||||
|
|
||||||
|
### Pricing JSONB Structure
|
||||||
|
|
||||||
|
Current schema via [`SubscriptionRulesModel`](../backend/app/schemas/subscription.py:86):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pricing_zones": {
|
||||||
|
"HU": { "monthly_price": 990.0, "yearly_price": 9990.0, "currency": "HUF", "credit_price": 25000 },
|
||||||
|
"US": { "monthly_price": 3.5, "yearly_price": 35.9, "currency": "USD", "credit_price": 25000 },
|
||||||
|
"DEFAULT": { "monthly_price": 2.99, "yearly_price": 29.9, "currency": "EUR", "credit_price": 25000 }
|
||||||
|
},
|
||||||
|
"pricing": { // Legacy single-zone fallback
|
||||||
|
"monthly_price": 2.99, "yearly_price": 29.9, "currency": "EUR", "credit_price": 25000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Existing data confirms** (from `private_pro_v1`, id=14): The `pricing_zones` dictionary supports **multiple country keys** (HU, US, DEFAULT), each with `PricingZoneModel`:
|
||||||
|
- `monthly_price: float`
|
||||||
|
- `yearly_price: float`
|
||||||
|
- `currency: str` (ISO 4217)
|
||||||
|
- `credit_price: Optional[int]`
|
||||||
|
|
||||||
|
### Multi-Currency / Finance Models
|
||||||
|
|
||||||
|
The project has these finance models that could be linked:
|
||||||
|
- [`OrganizationSubscription`](../backend/app/models/core_logic.py:71) — `finance.org_subscriptions` — links orgs to tiers
|
||||||
|
- [`UserSubscription`](../backend/app/models/core_logic.py:102) — `finance.user_subscriptions` — links users to tiers
|
||||||
|
- [`CreditTransaction`](../backend/app/models/core_logic.py:132) — `finance.credit_logs` — tracks credit usage
|
||||||
|
- `CostCategory` in [`fleet_finance`](../backend/app/models/fleet_finance.py) — cost categories with `min_tier` access control
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 🐛 BUG REGISTRY
|
||||||
|
|
||||||
|
### 🐛 BUG #1 — CRITICAL: Multi-Zone Pricing Data Loss on Edit
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| **Severity** | 🔴 **CRITICAL** |
|
||||||
|
| **Location** | [`index.vue:609`](../frontend_admin/pages/packages/index.vue:609) (read) + [`index.vue:649-654`](../frontend_admin/pages/packages/index.vue:649-654) (write) |
|
||||||
|
| **Root Cause** | The form is single-zone only. `openEditModal()` reads only `pricing_zones.DEFAULT` (or `rules.pricing`). `savePackage()` writes ONLY `pricing_zones: { DEFAULT: {...} }`. |
|
||||||
|
| **Impact** | Every time an admin edits a package with multiple pricing zones (e.g., `private_pro_v1` with HU, US, DEFAULT), the HU and US zones are **silently destroyed**. This is irreversible data loss. |
|
||||||
|
| **Extracted Code** | [`index.vue:609`](../frontend_admin/pages/packages/index.vue:609): `const pricing = rules.pricing_zones?.DEFAULT \|\| rules.pricing \|\| {}` → reads ONE zone
|
||||||
|
[`index.vue:649-654`](../frontend_admin/pages/packages/index.vue:649-654): `pricing_zones: { DEFAULT: { monthly_price, yearly_price, currency } }` → writes ONLY DEFAULT |
|
||||||
|
| **Fix Suggestion** | The frontend form needs multi-zone pricing support (add/remove/edit zones dynamically). Or, backend PATCH should merge `pricing_zones` instead of replacing them. |
|
||||||
|
|
||||||
|
### 🐛 BUG #2 — MEDIUM: Lifecycle `available_until` Overwritten on Edit
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| **Severity** | 🟡 **MEDIUM** |
|
||||||
|
| **Location** | [`index.vue:665-668`](../frontend_admin/pages/packages/index.vue:665-668) (frontend) + [`admin_packages.py:270-272`](../backend/app/api/v1/endpoints/admin_packages.py:270-272) (backend gap) |
|
||||||
|
| **Root Cause** | Frontend always sends `lifecycle: { is_public: true }` without `available_until`. The backend only preserves existing lifecycle if it's **wholly absent** from the payload. Since frontend always provides it (stripped), `available_until` is lost. |
|
||||||
|
| **Impact** | Packages with time-limited availability (`available_until` set) lose that data on edit. |
|
||||||
|
| **Extracted Code** | [`admin_packages.py:270`](../backend/app/api/v1/endpoints/admin_packages.py:270): `if isinstance(new_rules, dict) and new_rules.get("lifecycle") is None ...` — the guard fails because frontend always sends `lifecycle`. |
|
||||||
|
| **Fix Suggestion** | Backend: merge lifecycle fields individually instead of wholesale replacement. Or frontend: read and include `available_until` from existing data. |
|
||||||
|
|
||||||
|
### 🐛 BUG #3 — LOW: Legacy `pricing` Field Converted to `pricing_zones.DEFAULT`
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| **Severity** | 🟢 **LOW** |
|
||||||
|
| **Location** | [`index.vue:609`](../frontend_admin/pages/packages/index.vue:609) + [`index.vue:649-654`](../frontend_admin/pages/packages/index.vue:649-654) |
|
||||||
|
| **Root Cause** | On read, falls back to `rules.pricing` (legacy single-zone). On write, converts to `pricing_zones.DEFAULT` format. The legacy `pricing` field is not preserved. |
|
||||||
|
| **Impact** | Existing packages using legacy `pricing` field get restructured on edit. While functionally equivalent, this changes the data shape. |
|
||||||
|
| **Fix Suggestion** | Backend PATCH endpoint should preserve the legacy `pricing` field if present but not updated. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. ARCHITECT SUMMARY
|
||||||
|
|
||||||
|
### What works correctly:
|
||||||
|
1. **`is_default_fallback`** singleton logic — both in `create_package` and `update_package` endpoints
|
||||||
|
2. **`trial_days_on_signup`** singleton logic — both endpoints
|
||||||
|
3. **Pydantic schema validation** — `SubscriptionRulesModel` properly validates the JSONB structure
|
||||||
|
4. **Fallback tier resolution** in `subscription_service.py` — correctly queries `is_default_fallback == True` for expired subscriptions
|
||||||
|
5. **Trial assignment** in `assign_default_trial()` — correctly queries `trial_days_on_signup > 0`
|
||||||
|
|
||||||
|
### What is BROKEN:
|
||||||
|
1. **🔴 CRITICAL: Multi-zone pricing** — every edit destroys all non-DEFAULT pricing zones
|
||||||
|
2. **🟡 MEDIUM: Lifecycle data loss** — `available_until` is silently overwritten on every edit
|
||||||
|
3. **🟢 LOW: Legacy pricing** — `pricing` field is converted to `pricing_zones.DEFAULT` on edit
|
||||||
|
|
||||||
|
### Key architectural note:
|
||||||
|
The pricing model uses **JSONB with `pricing_zones: Dict[str, PricingZoneModel]`** — this is already a flexible multi-currency design, but the **frontend form is single-zone only**, which is the root cause of Bug #1.
|
||||||
|
|
||||||
|
The `SubscriptionTier` model has **no dedicated pricing columns** — everything is in the `rules` JSONB blob. This is consistent with DDD principles but means any pricing display/editing must go through the `SubscriptionRulesModel` Pydantic schema.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Report generated by Architect mode. No code was modified during this investigation.*
|
||||||
4
frontend_admin/.nuxt/components.d.ts
vendored
4
frontend_admin/.nuxt/components.d.ts
vendored
@@ -26,6 +26,8 @@ export const NuxtTime: typeof import("../node_modules/nuxt/dist/app/components/n
|
|||||||
export const NuxtRouteAnnouncer: typeof import("../node_modules/nuxt/dist/app/components/nuxt-route-announcer")['default']
|
export const NuxtRouteAnnouncer: typeof import("../node_modules/nuxt/dist/app/components/nuxt-route-announcer")['default']
|
||||||
export const NuxtImg: typeof import("../node_modules/nuxt/dist/app/components/nuxt-stubs")['NuxtImg']
|
export const NuxtImg: typeof import("../node_modules/nuxt/dist/app/components/nuxt-stubs")['NuxtImg']
|
||||||
export const NuxtPicture: typeof import("../node_modules/nuxt/dist/app/components/nuxt-stubs")['NuxtPicture']
|
export const NuxtPicture: typeof import("../node_modules/nuxt/dist/app/components/nuxt-stubs")['NuxtPicture']
|
||||||
|
export const NuxtLinkLocale: typeof import("../node_modules/@nuxtjs/i18n/dist/runtime/components/NuxtLinkLocale")['default']
|
||||||
|
export const SwitchLocalePathLink: typeof import("../node_modules/@nuxtjs/i18n/dist/runtime/components/SwitchLocalePathLink")['default']
|
||||||
export const NuxtPage: typeof import("../node_modules/nuxt/dist/pages/runtime/page")['default']
|
export const NuxtPage: typeof import("../node_modules/nuxt/dist/pages/runtime/page")['default']
|
||||||
export const NoScript: typeof import("../node_modules/nuxt/dist/head/runtime/components")['NoScript']
|
export const NoScript: typeof import("../node_modules/nuxt/dist/head/runtime/components")['NoScript']
|
||||||
export const Link: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Link']
|
export const Link: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Link']
|
||||||
@@ -49,6 +51,8 @@ export const LazyNuxtTime: LazyComponent<typeof import("../node_modules/nuxt/dis
|
|||||||
export const LazyNuxtRouteAnnouncer: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-route-announcer")['default']>
|
export const LazyNuxtRouteAnnouncer: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-route-announcer")['default']>
|
||||||
export const LazyNuxtImg: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-stubs")['NuxtImg']>
|
export const LazyNuxtImg: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-stubs")['NuxtImg']>
|
||||||
export const LazyNuxtPicture: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-stubs")['NuxtPicture']>
|
export const LazyNuxtPicture: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-stubs")['NuxtPicture']>
|
||||||
|
export const LazyNuxtLinkLocale: LazyComponent<typeof import("../node_modules/@nuxtjs/i18n/dist/runtime/components/NuxtLinkLocale")['default']>
|
||||||
|
export const LazySwitchLocalePathLink: LazyComponent<typeof import("../node_modules/@nuxtjs/i18n/dist/runtime/components/SwitchLocalePathLink")['default']>
|
||||||
export const LazyNuxtPage: LazyComponent<typeof import("../node_modules/nuxt/dist/pages/runtime/page")['default']>
|
export const LazyNuxtPage: LazyComponent<typeof import("../node_modules/nuxt/dist/pages/runtime/page")['default']>
|
||||||
export const LazyNoScript: LazyComponent<typeof import("../node_modules/nuxt/dist/head/runtime/components")['NoScript']>
|
export const LazyNoScript: LazyComponent<typeof import("../node_modules/nuxt/dist/head/runtime/components")['NoScript']>
|
||||||
export const LazyLink: LazyComponent<typeof import("../node_modules/nuxt/dist/head/runtime/components")['Link']>
|
export const LazyLink: LazyComponent<typeof import("../node_modules/nuxt/dist/head/runtime/components")['Link']>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/client/_nuxt/9W9VXtTt.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/9W9VXtTt.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{_ as h}from"./Da4aCGAe.js";import{p as v,e as y,g as _,c as n,a as t,f as k,h as s,t as c,i as u,j as S,k as p,v as m,l as x,b as V,w as L,m as f,o as i,d as C}from"./CqYPuxVf.js";import{u as N}from"./CWA6Xjbg.js";const B=v("/sf_logo.png"),E={class:"min-h-screen bg-slate-900 flex items-center justify-center px-4"},R={class:"w-full max-w-md"},j={class:"bg-slate-800 rounded-xl shadow-2xl border border-slate-700 p-8"},A={key:0,class:"mb-6 p-3 rounded-lg bg-red-900/40 border border-red-700 text-red-300 text-sm"},D=["disabled"],F={key:0,class:"animate-spin h-5 w-5 text-white",xmlns:"http://www.w3.org/2000/svg",fill:"none",viewBox:"0 0 24 24"},I={class:"mt-6 text-center"},T=y({__name:"login",setup(M){const a=f(""),l=f(""),b=_(),o=N();async function g(){try{await o.login(a.value,l.value),b.push("/")}catch(d){console.error("Login failed:",d)}}return(d,e)=>{const w=h;return i(),n("div",E,[t("div",R,[e[6]||(e[6]=k('<div class="text-center mb-8"><div class="flex items-center justify-center gap-3 mb-4"><img src="'+B+'" class="h-12 w-auto drop-shadow-md" alt="ServiceFinder Logo"><span class="text-3xl font-extrabold tracking-wider"><span class="text-white">SERVICE</span><span class="text-cyan-400">FINDER</span></span></div><h1 class="text-xl font-semibold text-slate-300">Staff Portal</h1><p class="text-sm text-slate-500 mt-1">Authorised personnel only</p></div>',1)),t("div",j,[s(o).error?(i(),n("div",A,c(s(o).error),1)):u("",!0),t("form",{onSubmit:S(g,["prevent"]),class:"space-y-5"},[t("div",null,[e[2]||(e[2]=t("label",{for:"email",class:"block text-sm font-medium text-slate-300 mb-1.5"}," Email Address ",-1)),p(t("input",{id:"email","onUpdate:modelValue":e[0]||(e[0]=r=>x(a)?a.value=r:null),type:"email",autocomplete:"email",required:"",placeholder:"admin@example.com",class:"w-full px-4 py-2.5 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition"},null,512),[[m,s(a)]])]),t("div",null,[e[3]||(e[3]=t("label",{for:"password",class:"block text-sm font-medium text-slate-300 mb-1.5"}," Password ",-1)),p(t("input",{id:"password","onUpdate:modelValue":e[1]||(e[1]=r=>x(l)?l.value=r:null),type:"password",autocomplete:"current-password",required:"",placeholder:"••••••••",class:"w-full px-4 py-2.5 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition"},null,512),[[m,s(l)]])]),t("button",{type:"submit",disabled:s(o).isLoading,class:"w-full py-2.5 px-4 bg-cyan-600 hover:bg-cyan-500 disabled:bg-cyan-800 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition duration-200 flex items-center justify-center gap-2"},[s(o).isLoading?(i(),n("svg",F,[...e[4]||(e[4]=[t("circle",{class:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"currentColor","stroke-width":"4"},null,-1),t("path",{class:"opacity-75",fill:"currentColor",d:"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"},null,-1)])])):u("",!0),t("span",null,c(s(o).isLoading?"Signing in...":"Sign In"),1)],8,D)],32),t("div",I,[V(w,{to:"/",class:"text-sm text-slate-500 hover:text-cyan-400 transition"},{default:L(()=>[...e[5]||(e[5]=[C(" ← Back to ServiceFinder ",-1)])]),_:1})])])])])}}});export{T as default};
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{y as e,z as t}from"./C3Ck-LNv.js";import{u as a}from"./BoAo7BJa.js";const u=e((o,i)=>{if(o.path==="/login")return;if(!a("access_token").value)return t("/login")});export{u as default};
|
|
||||||
1
frontend_admin/.nuxt/dist/client/_nuxt/BO90Bn6x.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/BO90Bn6x.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
import{I as y,J as C,x as u,H as l}from"./C3Ck-LNv.js";import{u as r}from"./BoAo7BJa.js";const x=y("/sf_logo.png"),I=C("auth",()=>{const a=u(null),n=u(null),c=u(!1),s=u(null),v=u(!1),p=l(()=>!!n.value&&!!a.value),d=l(()=>{const e=a.value?.role;return e?["SUPERADMIN","ADMIN","MODERATOR","SALES_REP","SERVICE_MGR"].includes(e.toUpperCase()):!1}),h=l(()=>{if(!a.value)return"";const{first_name:e,last_name:o}=a.value;return e&&o?`${e} ${o}`:e||o||a.value.email}),k=l(()=>a.value?.email??""),_=l(()=>a.value?.role??"guest");async function w(e,o){c.value=!0,s.value=null;try{const t=new URLSearchParams;t.append("username",e),t.append("password",o);const m=await $fetch("/api/v1/auth/login",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:t}),A=r("access_token",{maxAge:3600*24*7,path:"/",sameSite:"lax",secure:!0});A.value=m.access_token,n.value=m.access_token,await i()}catch(t){throw s.value=t?.data?.detail||t?.message||"Login failed",t}finally{c.value=!1}}async function i(){const o=r("access_token").value||n.value;if(o)try{const t=await $fetch("/api/v1/auth/me",{headers:{Authorization:`Bearer ${o}`}});a.value=t}catch(t){throw t?.response?.status===401&&f(),t}}function f(){n.value=null,a.value=null,s.value=null;const e=r("access_token");e.value=null}async function R(){const e=r("access_token");if(e.value){n.value=e.value;try{await i()}catch{f()}}v.value=!0}function g(){s.value=null}return{user:a,token:n,isLoading:c,error:s,isInitialized:v,isAuthenticated:p,isAdmin:d,userName:h,userEmail:k,userRole:_,login:w,fetchUser:i,logout:f,init:R,clearError:g}});export{x as _,I as u};
|
|
||||||
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/client/_nuxt/C8yzOtqa.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/C8yzOtqa.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{_ as a}from"./Da4aCGAe.js";import{_ as i,u,o as c,c as l,a as e,t as r,b as d,w as p,d as f}from"./CqYPuxVf.js";const m={class:"antialiased bg-white dark:bg-black dark:text-white font-sans grid min-h-screen overflow-hidden place-content-center text-black"},g={class:"max-w-520px text-center z-20"},b=["textContent"],h=["textContent"],x={class:"flex items-center justify-center w-full"},y={__name:"error-404",props:{appName:{type:String,default:"Nuxt"},version:{type:String,default:""},status:{type:Number,default:404},statusText:{type:String,default:"Not Found"},description:{type:String,default:"Sorry, the page you are looking for could not be found."},backHome:{type:String,default:"Go back home"}},setup(t){const n=t;return u({title:`${n.status} - ${n.statusText} | ${n.appName}`,script:[{innerHTML:`!function(){const e=document.createElement("link").relList;if(!(e&&e.supports&&e.supports("modulepreload"))){for(const e of document.querySelectorAll('link[rel="modulepreload"]'))r(e);new MutationObserver(e=>{for(const o of e)if("childList"===o.type)for(const e of o.addedNodes)"LINK"===e.tagName&&"modulepreload"===e.rel&&r(e)}).observe(document,{childList:!0,subtree:!0})}function r(e){if(e.ep)return;e.ep=!0;const r=function(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),"use-credentials"===e.crossOrigin?r.credentials="include":"anonymous"===e.crossOrigin?r.credentials="omit":r.credentials="same-origin",r}(e);fetch(e.href,r)}}();`}],style:[{innerHTML:'*,:after,:before{border-color:var(--un-default-border-color,#e5e7eb);border-style:solid;border-width:0;box-sizing:border-box}:after,:before{--un-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}h1{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}h1,p{margin:0}*,:after,:before{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 transparent;--un-ring-shadow:0 0 transparent;--un-shadow-inset: ;--un-shadow:0 0 transparent;--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgba(147,197,253,.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: }'}]}),(k,o)=>{const s=a;return c(),l("div",m,[o[0]||(o[0]=e("div",{class:"fixed left-0 right-0 spotlight z-10"},null,-1)),e("div",g,[e("h1",{class:"font-medium mb-8 sm:text-10xl text-8xl",textContent:r(t.status)},null,8,b),e("p",{class:"font-light leading-tight mb-16 px-8 sm:px-0 sm:text-4xl text-xl",textContent:r(t.description)},null,8,h),e("div",x,[d(s,{to:"/",class:"cursor-pointer gradient-border px-4 py-2 sm:px-6 sm:py-3 sm:text-xl text-md"},{default:p(()=>[f(r(t.backHome),1)]),_:1})])])])}}},v=i(y,[["__scopeId","data-v-1bd9e11a"]]);export{v as default};
|
||||||
1
frontend_admin/.nuxt/dist/client/_nuxt/CC4zKGJo.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/CC4zKGJo.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/client/_nuxt/CWA6Xjbg.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/CWA6Xjbg.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{K as C,m as u,x as l,A as r}from"./CqYPuxVf.js";const g=C("auth",()=>{const t=u(null),o=u(null),c=u(!1),s=u(null),v=u(!1),h=l(()=>!!o.value&&!!t.value),k=l(()=>{const e=t.value?.role;return e?["SUPERADMIN","ADMIN","MODERATOR","SALES_REP","SERVICE_MGR"].includes(e.toUpperCase()):!1}),m=l(()=>{if(!t.value)return"";const{first_name:e,last_name:n}=t.value;return e&&n?`${e} ${n}`:e||n||t.value.email}),p=l(()=>t.value?.email??""),w=l(()=>t.value?.role??"guest");async function A(e,n){c.value=!0,s.value=null;try{const a=new URLSearchParams;a.append("username",e),a.append("password",n);const d=await $fetch("/api/v1/auth/login",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:a}),y=r("access_token",{maxAge:3600*24*7,path:"/",sameSite:"lax",secure:!0});y.value=d.access_token,o.value=d.access_token,await i()}catch(a){throw s.value=a?.data?.detail||a?.message||"Login failed",a}finally{c.value=!1}}async function i(){const n=r("access_token").value||o.value;if(n)try{const a=await $fetch("/api/v1/auth/me",{headers:{Authorization:`Bearer ${n}`}});t.value=a}catch(a){throw a?.response?.status===401&&f(),a}}function f(){o.value=null,t.value=null,s.value=null;const e=r("access_token");e.value=null}async function R(){const e=r("access_token");if(e.value){o.value=e.value;try{await i()}catch{f()}}v.value=!0}function _(){s.value=null}return{user:t,token:o,isLoading:c,error:s,isInitialized:v,isAuthenticated:h,isAdmin:k,userName:m,userEmail:p,userRole:w,login:A,fetchUser:i,logout:f,init:R,clearError:_}});export{g as u};
|
||||||
1
frontend_admin/.nuxt/dist/client/_nuxt/CgyjcGAF.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/CgyjcGAF.js
vendored
Normal file
File diff suppressed because one or more lines are too long
6
frontend_admin/.nuxt/dist/client/_nuxt/CqYPuxVf.js
vendored
Normal file
6
frontend_admin/.nuxt/dist/client/_nuxt/CqYPuxVf.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/client/_nuxt/Cx40nPNe.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/Cx40nPNe.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{_ as s,u as a,o as i,c as u,a as e,t as o}from"./CqYPuxVf.js";const l={class:"antialiased bg-white dark:bg-black dark:text-white font-sans grid min-h-screen overflow-hidden place-content-center text-black"},c={class:"max-w-520px text-center"},d=["textContent"],p=["textContent"],f={__name:"error-500",props:{appName:{type:String,default:"Nuxt"},version:{type:String,default:""},status:{type:Number,default:500},statusText:{type:String,default:"Server error"},description:{type:String,default:"This page is temporarily unavailable."}},setup(t){const n=t;return a({title:`${n.status} - ${n.statusText} | ${n.appName}`,script:[{innerHTML:`!function(){const e=document.createElement("link").relList;if(!(e&&e.supports&&e.supports("modulepreload"))){for(const e of document.querySelectorAll('link[rel="modulepreload"]'))r(e);new MutationObserver(e=>{for(const o of e)if("childList"===o.type)for(const e of o.addedNodes)"LINK"===e.tagName&&"modulepreload"===e.rel&&r(e)}).observe(document,{childList:!0,subtree:!0})}function r(e){if(e.ep)return;e.ep=!0;const r=function(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),"use-credentials"===e.crossOrigin?r.credentials="include":"anonymous"===e.crossOrigin?r.credentials="omit":r.credentials="same-origin",r}(e);fetch(e.href,r)}}();`}],style:[{innerHTML:'*,:after,:before{border-color:var(--un-default-border-color,#e5e7eb);border-style:solid;border-width:0;box-sizing:border-box}:after,:before{--un-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}h1{font-size:inherit;font-weight:inherit}h1,p{margin:0}*,:after,:before{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 transparent;--un-ring-shadow:0 0 transparent;--un-shadow-inset: ;--un-shadow:0 0 transparent;--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgba(147,197,253,.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: }'}]}),(g,r)=>(i(),u("div",l,[r[0]||(r[0]=e("div",{class:"-bottom-1/2 fixed h-1/2 left-0 right-0 spotlight"},null,-1)),e("div",c,[e("h1",{class:"font-medium mb-8 sm:text-10xl text-8xl",textContent:o(t.status)},null,8,d),e("p",{class:"font-light leading-tight mb-16 px-8 sm:px-0 sm:text-4xl text-xl",textContent:o(t.description)},null,8,p)])]))}},b=s(f,[["__scopeId","data-v-a01dd0ba"]]);export{b as default};
|
||||||
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/client/_nuxt/DP_n9i02.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/DP_n9i02.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/client/_nuxt/DWjNh9rV.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/DWjNh9rV.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{K as _,m as u,x as a}from"./CqYPuxVf.js";const h=_("region",()=>{const t=u([]),n=u(null),c=u(!1),l=u(null),o=a(()=>t.value.find(e=>e.country_code==="DEFAULT")||null),s=a(()=>{const e=new Map;for(const r of t.value)e.set(r.country_code,r);return e}),i=a(()=>n.value?.currency||o.value?.currency||"EUR"),v=a(()=>n.value?.locale_code||o.value?.locale_code||"en-EU"),f=a(()=>n.value?.default_vat_rate??o.value?.default_vat_rate??0),d=a(()=>n.value?.timezone||o.value?.timezone||"Europe/Brussels");async function g(){if(!(t.value.length>0)){c.value=!0,l.value=null;try{const e=await $fetch("/api/v1/system/regions");t.value=e||[]}catch(e){l.value=e?.data?.detail||e?.message||"Failed to load regions",console.error("[RegionStore] Fetch error:",e)}finally{c.value=!1}}}function m(e){const r=t.value.find(R=>R.country_code===e);n.value=r||o.value}function y(e){return s.value.get(e)}return{regions:t,activeRegion:n,isLoading:c,error:l,defaultRegion:o,regionMap:s,activeCurrency:i,activeLocale:v,activeVatRate:f,activeTimezone:d,fetchRegions:g,setActiveRegion:m,getRegion:y}});export{h as u};
|
||||||
1
frontend_admin/.nuxt/dist/client/_nuxt/Da4aCGAe.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/Da4aCGAe.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
import{u as a,e as s,h as u,i as r,f as o}from"./C3Ck-LNv.js";function i(e){const t=e||s();return t?.ssrContext?.head||t?.runWithContext(()=>{if(u())return r(o)})}function x(e,t={}){const n=i(t.nuxt);if(n)return a(e,{head:n,...t})}export{x as u};
|
|
||||||
1
frontend_admin/.nuxt/dist/client/_nuxt/DuYZ8wdr.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/DuYZ8wdr.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{B as o,A as t,C as a}from"./CqYPuxVf.js";const s=o((e,i)=>{if(e.path==="/login")return;if(!t("access_token").value)return a("/login")});export{s as default};
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{_ as a}from"./QKJPEFuj.js";import{_ as i,o as u,c,a as e,t as r,b as l,w as d,d as p}from"./C3Ck-LNv.js";import{u as f}from"./Di-57MrI.js";const m={class:"antialiased bg-white dark:bg-black dark:text-white font-sans grid min-h-screen overflow-hidden place-content-center text-black"},g={class:"max-w-520px text-center z-20"},b=["textContent"],h=["textContent"],x={class:"flex items-center justify-center w-full"},y={__name:"error-404",props:{appName:{type:String,default:"Nuxt"},version:{type:String,default:""},status:{type:Number,default:404},statusText:{type:String,default:"Not Found"},description:{type:String,default:"Sorry, the page you are looking for could not be found."},backHome:{type:String,default:"Go back home"}},setup(t){const n=t;return f({title:`${n.status} - ${n.statusText} | ${n.appName}`,script:[{innerHTML:`!function(){const e=document.createElement("link").relList;if(!(e&&e.supports&&e.supports("modulepreload"))){for(const e of document.querySelectorAll('link[rel="modulepreload"]'))r(e);new MutationObserver(e=>{for(const o of e)if("childList"===o.type)for(const e of o.addedNodes)"LINK"===e.tagName&&"modulepreload"===e.rel&&r(e)}).observe(document,{childList:!0,subtree:!0})}function r(e){if(e.ep)return;e.ep=!0;const r=function(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),"use-credentials"===e.crossOrigin?r.credentials="include":"anonymous"===e.crossOrigin?r.credentials="omit":r.credentials="same-origin",r}(e);fetch(e.href,r)}}();`}],style:[{innerHTML:'*,:after,:before{border-color:var(--un-default-border-color,#e5e7eb);border-style:solid;border-width:0;box-sizing:border-box}:after,:before{--un-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}h1{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}h1,p{margin:0}*,:after,:before{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 transparent;--un-ring-shadow:0 0 transparent;--un-shadow-inset: ;--un-shadow:0 0 transparent;--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgba(147,197,253,.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: }'}]}),(k,o)=>{const s=a;return u(),c("div",m,[o[0]||(o[0]=e("div",{class:"fixed left-0 right-0 spotlight z-10"},null,-1)),e("div",g,[e("h1",{class:"font-medium mb-8 sm:text-10xl text-8xl",textContent:r(t.status)},null,8,b),e("p",{class:"font-light leading-tight mb-16 px-8 sm:px-0 sm:text-4xl text-xl",textContent:r(t.description)},null,8,h),e("div",x,[l(s,{to:"/",class:"cursor-pointer gradient-border px-4 py-2 sm:px-6 sm:py-3 sm:text-xl text-md"},{default:d(()=>[p(r(t.backHome),1)]),_:1})])])])}}},z=i(y,[["__scopeId","data-v-1bd9e11a"]]);export{z as default};
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
import{_ as v}from"./QKJPEFuj.js";import{g as y,k as h,c as n,a as t,j as _,l as s,t as c,m as u,p as k,q as p,v as m,s as x,b as S,w as V,x as f,o as i,d as C}from"./C3Ck-LNv.js";import{u as L,_ as N}from"./ByPKVH1D.js";import"./BoAo7BJa.js";const B={class:"min-h-screen bg-slate-900 flex items-center justify-center px-4"},E={class:"w-full max-w-md"},j={class:"bg-slate-800 rounded-xl shadow-2xl border border-slate-700 p-8"},R={key:0,class:"mb-6 p-3 rounded-lg bg-red-900/40 border border-red-700 text-red-300 text-sm"},q=["disabled"],A={key:0,class:"animate-spin h-5 w-5 text-white",xmlns:"http://www.w3.org/2000/svg",fill:"none",viewBox:"0 0 24 24"},D={class:"mt-6 text-center"},U=y({__name:"login",setup(F){const a=f(""),r=f(""),b=h(),o=L();async function g(){try{await o.login(a.value,r.value),b.push("/")}catch(d){console.error("Login failed:",d)}}return(d,e)=>{const w=v;return i(),n("div",B,[t("div",E,[e[6]||(e[6]=_('<div class="text-center mb-8"><div class="flex items-center justify-center gap-3 mb-4"><img src="'+N+'" class="h-12 w-auto drop-shadow-md" alt="ServiceFinder Logo"><span class="text-3xl font-extrabold tracking-wider"><span class="text-white">SERVICE</span><span class="text-cyan-400">FINDER</span></span></div><h1 class="text-xl font-semibold text-slate-300">Staff Portal</h1><p class="text-sm text-slate-500 mt-1">Authorised personnel only</p></div>',1)),t("div",j,[s(o).error?(i(),n("div",R,c(s(o).error),1)):u("",!0),t("form",{onSubmit:k(g,["prevent"]),class:"space-y-5"},[t("div",null,[e[2]||(e[2]=t("label",{for:"email",class:"block text-sm font-medium text-slate-300 mb-1.5"}," Email Address ",-1)),p(t("input",{id:"email","onUpdate:modelValue":e[0]||(e[0]=l=>x(a)?a.value=l:null),type:"email",autocomplete:"email",required:"",placeholder:"admin@example.com",class:"w-full px-4 py-2.5 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition"},null,512),[[m,s(a)]])]),t("div",null,[e[3]||(e[3]=t("label",{for:"password",class:"block text-sm font-medium text-slate-300 mb-1.5"}," Password ",-1)),p(t("input",{id:"password","onUpdate:modelValue":e[1]||(e[1]=l=>x(r)?r.value=l:null),type:"password",autocomplete:"current-password",required:"",placeholder:"••••••••",class:"w-full px-4 py-2.5 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition"},null,512),[[m,s(r)]])]),t("button",{type:"submit",disabled:s(o).isLoading,class:"w-full py-2.5 px-4 bg-cyan-600 hover:bg-cyan-500 disabled:bg-cyan-800 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition duration-200 flex items-center justify-center gap-2"},[s(o).isLoading?(i(),n("svg",A,[...e[4]||(e[4]=[t("circle",{class:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"currentColor","stroke-width":"4"},null,-1),t("path",{class:"opacity-75",fill:"currentColor",d:"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"},null,-1)])])):u("",!0),t("span",null,c(s(o).isLoading?"Signing in...":"Sign In"),1)],8,q)],32),t("div",D,[S(w,{to:"/",class:"text-sm text-slate-500 hover:text-cyan-400 transition"},{default:V(()=>[...e[5]||(e[5]=[C(" ← Back to ServiceFinder ",-1)])]),_:1})])])])])}}});export{U as default};
|
|
||||||
5
frontend_admin/.nuxt/dist/client/_nuxt/ncKxzOzI.js
vendored
Normal file
5
frontend_admin/.nuxt/dist/client/_nuxt/ncKxzOzI.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
import{_ as s,o as a,c as i,a as e,t as o}from"./C3Ck-LNv.js";import{u}from"./Di-57MrI.js";const l={class:"antialiased bg-white dark:bg-black dark:text-white font-sans grid min-h-screen overflow-hidden place-content-center text-black"},c={class:"max-w-520px text-center"},d=["textContent"],p=["textContent"],f={__name:"error-500",props:{appName:{type:String,default:"Nuxt"},version:{type:String,default:""},status:{type:Number,default:500},statusText:{type:String,default:"Server error"},description:{type:String,default:"This page is temporarily unavailable."}},setup(t){const r=t;return u({title:`${r.status} - ${r.statusText} | ${r.appName}`,script:[{innerHTML:`!function(){const e=document.createElement("link").relList;if(!(e&&e.supports&&e.supports("modulepreload"))){for(const e of document.querySelectorAll('link[rel="modulepreload"]'))r(e);new MutationObserver(e=>{for(const o of e)if("childList"===o.type)for(const e of o.addedNodes)"LINK"===e.tagName&&"modulepreload"===e.rel&&r(e)}).observe(document,{childList:!0,subtree:!0})}function r(e){if(e.ep)return;e.ep=!0;const r=function(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),"use-credentials"===e.crossOrigin?r.credentials="include":"anonymous"===e.crossOrigin?r.credentials="omit":r.credentials="same-origin",r}(e);fetch(e.href,r)}}();`}],style:[{innerHTML:'*,:after,:before{border-color:var(--un-default-border-color,#e5e7eb);border-style:solid;border-width:0;box-sizing:border-box}:after,:before{--un-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}h1{font-size:inherit;font-weight:inherit}h1,p{margin:0}*,:after,:before{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 transparent;--un-ring-shadow:0 0 transparent;--un-shadow-inset: ;--un-shadow:0 0 transparent;--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgba(147,197,253,.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: }'}]}),(g,n)=>(a(),i("div",l,[n[0]||(n[0]=e("div",{class:"-bottom-1/2 fixed h-1/2 left-0 right-0 spotlight"},null,-1)),e("div",c,[e("h1",{class:"font-medium mb-8 sm:text-10xl text-8xl",textContent:o(t.status)},null,8,d),e("p",{class:"font-light leading-tight mb-16 px-8 sm:px-0 sm:text-4xl text-xl",textContent:o(t.description)},null,8,p)])]))}},h=s(f,[["__scopeId","data-v-a01dd0ba"]]);export{h as default};
|
|
||||||
1
frontend_admin/.nuxt/dist/client/_nuxt/zPyClsoP.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/zPyClsoP.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,5 +1,4 @@
|
|||||||
import { d as defineNuxtRouteMiddleware, n as navigateTo } from "../server.mjs";
|
import { d as defineNuxtRouteMiddleware, b as useCookie, n as navigateTo } from "../server.mjs";
|
||||||
import { u as useCookie } from "./cookie-CWIsZYm7.js";
|
|
||||||
import "vue";
|
import "vue";
|
||||||
import "/app/node_modules/ofetch/dist/node.mjs";
|
import "/app/node_modules/ofetch/dist/node.mjs";
|
||||||
import "#internal/nuxt/paths";
|
import "#internal/nuxt/paths";
|
||||||
@@ -11,10 +10,12 @@ import "/app/node_modules/defu/dist/defu.mjs";
|
|||||||
import "vue-router";
|
import "vue-router";
|
||||||
import "/app/node_modules/ufo/dist/index.mjs";
|
import "/app/node_modules/ufo/dist/index.mjs";
|
||||||
import "/app/node_modules/klona/dist/index.mjs";
|
import "/app/node_modules/klona/dist/index.mjs";
|
||||||
import "vue/server-renderer";
|
|
||||||
import "/app/node_modules/cookie-es/dist/index.mjs";
|
import "/app/node_modules/cookie-es/dist/index.mjs";
|
||||||
import "/app/node_modules/destr/dist/index.mjs";
|
import "/app/node_modules/destr/dist/index.mjs";
|
||||||
import "/app/node_modules/ohash/dist/index.mjs";
|
import "/app/node_modules/ohash/dist/index.mjs";
|
||||||
|
import "/app/node_modules/@unhead/vue/dist/index.mjs";
|
||||||
|
import "@vue/devtools-api";
|
||||||
|
import "vue/server-renderer";
|
||||||
const auth = defineNuxtRouteMiddleware((to, from) => {
|
const auth = defineNuxtRouteMiddleware((to, from) => {
|
||||||
if (to.path === "/login") {
|
if (to.path === "/login") {
|
||||||
return;
|
return;
|
||||||
@@ -27,4 +28,4 @@ const auth = defineNuxtRouteMiddleware((to, from) => {
|
|||||||
export {
|
export {
|
||||||
auth as default
|
auth as default
|
||||||
};
|
};
|
||||||
//# sourceMappingURL=auth-D_CY0lMs.js.map
|
//# sourceMappingURL=auth-BgwR5zMA.js.map
|
||||||
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"auth-D_CY0lMs.js","sources":["../../../../middleware/auth.ts"],"sourcesContent":["export default defineNuxtRouteMiddleware((to, from) => {\n // Skip auth check on the login page itself\n if (to.path === '/login') {\n return\n }\n\n // Check for the access_token cookie\n const tokenCookie = useCookie('access_token')\n if (!tokenCookie.value) {\n return navigateTo('/login')\n }\n\n // If the store is already initialized, check isAuthenticated\n // Otherwise, the token cookie presence is sufficient for the guard\n // The store's init() will validate the token on app mount\n})\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;AAAA,MAAA,OAAe,0BAA0B,CAAC,IAAI,SAAS;AAErD,MAAI,GAAG,SAAS,UAAU;AACxB;AAAA,EACF;AAGA,QAAM,cAAc,UAAU,cAAc;AAC5C,MAAI,CAAC,YAAY,OAAO;AACtB,WAAO,WAAW,QAAQ;AAAA,EAC5B;AAKF,CAAC;"}
|
{"version":3,"file":"auth-BgwR5zMA.js","sources":["../../../../middleware/auth.ts"],"sourcesContent":["export default defineNuxtRouteMiddleware((to, from) => {\n // Skip auth check on the login page itself\n if (to.path === '/login') {\n return\n }\n\n // Check for the access_token cookie\n const tokenCookie = useCookie('access_token')\n if (!tokenCookie.value) {\n return navigateTo('/login')\n }\n\n // If the store is already initialized, check isAuthenticated\n // Otherwise, the token cookie presence is sufficient for the guard\n // The store's init() will validate the token on app mount\n})\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;AAAA,MAAA,OAAe,0BAA0B,CAAC,IAAI,SAAS;AAErD,MAAI,GAAG,SAAS,UAAU;AACxB;AAAA,EACF;AAGA,QAAM,cAAc,UAAU,cAAc;AAC5C,MAAI,CAAC,YAAY,OAAO;AACtB,WAAO,WAAW,QAAQ;AAAA,EAC5B;AAKF,CAAC;"}
|
||||||
1
frontend_admin/.nuxt/dist/server/_nuxt/auth-BgwR5zMA.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/auth-BgwR5zMA.js.map.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"file":"auth-BgwR5zMA.js","mappings":";;;;;;;;;;;;;;;;;;AAAA,MAAA,OAAe,0BAA0B,CAAC,IAAI,SAAS;AAErD,MAAI,GAAG,SAAS,UAAU;AACxB;AAAA,EACF;AAGA,QAAM,cAAc,UAAU,cAAc;AAC5C,MAAI,CAAC,YAAY,OAAO;AACtB,WAAO,WAAW,QAAQ;AAAA,EAC5B;AAKF,CAAC;","names":[],"sources":["../../../../middleware/auth.ts"],"sourcesContent":["export default defineNuxtRouteMiddleware((to, from) => {\n // Skip auth check on the login page itself\n if (to.path === '/login') {\n return\n }\n\n // Check for the access_token cookie\n const tokenCookie = useCookie('access_token')\n if (!tokenCookie.value) {\n return navigateTo('/login')\n }\n\n // If the store is already initialized, check isAuthenticated\n // Otherwise, the token cookie presence is sufficient for the guard\n // The store's init() will validate the token on app mount\n})\n"],"version":3}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
{"file":"auth-D_CY0lMs.js","mappings":";;;;;;;;;;;;;;;;;AAAA,MAAA,OAAe,0BAA0B,CAAC,IAAI,SAAS;AAErD,MAAI,GAAG,SAAS,UAAU;AACxB;AAAA,EACF;AAGA,QAAM,cAAc,UAAU,cAAc;AAC5C,MAAI,CAAC,YAAY,OAAO;AACtB,WAAO,WAAW,QAAQ;AAAA,EAC5B;AAKF,CAAC;","names":[],"sources":["../../../../middleware/auth.ts"],"sourcesContent":["export default defineNuxtRouteMiddleware((to, from) => {\n // Skip auth check on the login page itself\n if (to.path === '/login') {\n return\n }\n\n // Check for the access_token cookie\n const tokenCookie = useCookie('access_token')\n if (!tokenCookie.value) {\n return navigateTo('/login')\n }\n\n // If the store is already initialized, check isAuthenticated\n // Otherwise, the token cookie presence is sufficient for the guard\n // The store's init() will validate the token on app mount\n})\n"],"version":3}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import { publicAssetsURL } from "#internal/nuxt/paths";
|
|
||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import { u as useCookie } from "./cookie-CWIsZYm7.js";
|
import { b as useCookie } from "../server.mjs";
|
||||||
const _imports_0 = publicAssetsURL("/sf_logo.png");
|
|
||||||
const useAuthStore = defineStore("auth", () => {
|
const useAuthStore = defineStore("auth", () => {
|
||||||
const user = ref(null);
|
const user = ref(null);
|
||||||
const token = ref(null);
|
const token = ref(null);
|
||||||
@@ -113,7 +111,6 @@ const useAuthStore = defineStore("auth", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
export {
|
export {
|
||||||
_imports_0 as _,
|
|
||||||
useAuthStore as u
|
useAuthStore as u
|
||||||
};
|
};
|
||||||
//# sourceMappingURL=auth-CRAMW6e4.js.map
|
//# sourceMappingURL=auth-mCjBoLcA.js.map
|
||||||
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/auth-mCjBoLcA.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/auth-mCjBoLcA.js.map.json
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,79 +0,0 @@
|
|||||||
import { ref } from "vue";
|
|
||||||
import { parse } from "/app/node_modules/cookie-es/dist/index.mjs";
|
|
||||||
import { getRequestHeader, setCookie, getCookie, deleteCookie } from "/app/node_modules/h3/dist/index.mjs";
|
|
||||||
import destr from "/app/node_modules/destr/dist/index.mjs";
|
|
||||||
import { isEqual } from "/app/node_modules/ohash/dist/index.mjs";
|
|
||||||
import { klona } from "/app/node_modules/klona/dist/index.mjs";
|
|
||||||
import { a as useNuxtApp } from "../server.mjs";
|
|
||||||
function useRequestEvent(nuxtApp) {
|
|
||||||
nuxtApp ||= useNuxtApp();
|
|
||||||
return nuxtApp.ssrContext?.event;
|
|
||||||
}
|
|
||||||
const CookieDefaults = {
|
|
||||||
path: "/",
|
|
||||||
watch: true,
|
|
||||||
decode: (val) => {
|
|
||||||
const decoded = decodeURIComponent(val);
|
|
||||||
const parsed = destr(decoded);
|
|
||||||
if (typeof parsed === "number" && (!Number.isFinite(parsed) || String(parsed) !== decoded)) {
|
|
||||||
return decoded;
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
},
|
|
||||||
encode: (val) => encodeURIComponent(typeof val === "string" ? val : JSON.stringify(val))
|
|
||||||
};
|
|
||||||
function useCookie(name, _opts) {
|
|
||||||
const opts = { ...CookieDefaults, ..._opts };
|
|
||||||
opts.filter ??= (key) => key === name;
|
|
||||||
const cookies = readRawCookies(opts) || {};
|
|
||||||
let delay;
|
|
||||||
if (opts.maxAge !== void 0) {
|
|
||||||
delay = opts.maxAge * 1e3;
|
|
||||||
} else if (opts.expires) {
|
|
||||||
delay = opts.expires.getTime() - Date.now();
|
|
||||||
}
|
|
||||||
const hasExpired = delay !== void 0 && delay <= 0;
|
|
||||||
const cookieValue = klona(hasExpired ? void 0 : cookies[name] ?? opts.default?.());
|
|
||||||
const cookie = ref(cookieValue);
|
|
||||||
{
|
|
||||||
const nuxtApp = useNuxtApp();
|
|
||||||
const writeFinalCookieValue = () => {
|
|
||||||
if (opts.readonly || isEqual(cookie.value, cookies[name])) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
nuxtApp._cookies ||= {};
|
|
||||||
if (name in nuxtApp._cookies) {
|
|
||||||
if (isEqual(cookie.value, nuxtApp._cookies[name])) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nuxtApp._cookies[name] = cookie.value;
|
|
||||||
writeServerCookie(useRequestEvent(nuxtApp), name, cookie.value, opts);
|
|
||||||
};
|
|
||||||
const unhook = nuxtApp.hooks.hookOnce("app:rendered", writeFinalCookieValue);
|
|
||||||
nuxtApp.hooks.hookOnce("app:error", () => {
|
|
||||||
unhook();
|
|
||||||
return writeFinalCookieValue();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return cookie;
|
|
||||||
}
|
|
||||||
function readRawCookies(opts = {}) {
|
|
||||||
{
|
|
||||||
return parse(getRequestHeader(useRequestEvent(), "cookie") || "", opts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function writeServerCookie(event, name, value, opts = {}) {
|
|
||||||
if (event) {
|
|
||||||
if (value !== null && value !== void 0) {
|
|
||||||
return setCookie(event, name, value, opts);
|
|
||||||
}
|
|
||||||
if (getCookie(event, name) !== void 0) {
|
|
||||||
return deleteCookie(event, name, opts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export {
|
|
||||||
useCookie as u
|
|
||||||
};
|
|
||||||
//# sourceMappingURL=cookie-CWIsZYm7.js.map
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,22 +1,25 @@
|
|||||||
import { _ as __nuxt_component_0 } from "./nuxt-link-DNVrgHUL.js";
|
import { _ as __nuxt_component_0 } from "./nuxt-link-Cp-CAJwR.js";
|
||||||
import { defineComponent, ref, computed, mergeProps, withCtx, createVNode, toDisplayString, withDirectives, vShow, openBlock, createBlock, createTextVNode, useSSRContext } from "vue";
|
import { defineComponent, computed, ref, mergeProps, withCtx, createVNode, toDisplayString, openBlock, createBlock, createTextVNode, useSSRContext } from "vue";
|
||||||
import { ssrRenderAttrs, ssrRenderClass, ssrRenderAttr, ssrRenderList, ssrInterpolate, ssrRenderStyle, ssrRenderComponent, ssrRenderSlot } from "vue/server-renderer";
|
import { ssrRenderAttrs, ssrRenderClass, ssrRenderComponent, ssrRenderAttr, ssrRenderList, ssrRenderStyle, ssrInterpolate, ssrRenderSlot } from "vue/server-renderer";
|
||||||
import { u as useAuthStore, _ as _imports_0 } from "./auth-CRAMW6e4.js";
|
import { publicAssetsURL } from "#internal/nuxt/paths";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { u as useRouter } from "../server.mjs";
|
import { u as useRegionStore } from "./region-0mvXqaMM.js";
|
||||||
|
import { a as useRouter, c as useI18n } from "../server.mjs";
|
||||||
|
import { u as useAuthStore } from "./auth-mCjBoLcA.js";
|
||||||
|
import "/app/node_modules/klona/dist/index.mjs";
|
||||||
|
import "/app/node_modules/hookable/dist/index.mjs";
|
||||||
import "/app/node_modules/ufo/dist/index.mjs";
|
import "/app/node_modules/ufo/dist/index.mjs";
|
||||||
import "/app/node_modules/defu/dist/defu.mjs";
|
import "/app/node_modules/defu/dist/defu.mjs";
|
||||||
import "#internal/nuxt/paths";
|
|
||||||
import "pinia";
|
import "pinia";
|
||||||
import "./cookie-CWIsZYm7.js";
|
import "/app/node_modules/ofetch/dist/node.mjs";
|
||||||
import "/app/node_modules/cookie-es/dist/index.mjs";
|
import "/app/node_modules/unctx/dist/index.mjs";
|
||||||
import "/app/node_modules/h3/dist/index.mjs";
|
import "/app/node_modules/h3/dist/index.mjs";
|
||||||
|
import "/app/node_modules/cookie-es/dist/index.mjs";
|
||||||
import "/app/node_modules/destr/dist/index.mjs";
|
import "/app/node_modules/destr/dist/index.mjs";
|
||||||
import "/app/node_modules/ohash/dist/index.mjs";
|
import "/app/node_modules/ohash/dist/index.mjs";
|
||||||
import "/app/node_modules/klona/dist/index.mjs";
|
import "/app/node_modules/@unhead/vue/dist/index.mjs";
|
||||||
import "/app/node_modules/ofetch/dist/node.mjs";
|
import "@vue/devtools-api";
|
||||||
import "/app/node_modules/hookable/dist/index.mjs";
|
const _imports_0 = publicAssetsURL("/logo.svg");
|
||||||
import "/app/node_modules/unctx/dist/index.mjs";
|
|
||||||
const _sfc_main = /* @__PURE__ */ defineComponent({
|
const _sfc_main = /* @__PURE__ */ defineComponent({
|
||||||
__name: "default",
|
__name: "default",
|
||||||
__ssrInlineRender: true,
|
__ssrInlineRender: true,
|
||||||
@@ -24,6 +27,10 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
useRouter();
|
useRouter();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
useRegionStore();
|
||||||
|
const { locale, locales } = useI18n();
|
||||||
|
const availableLocales = computed(() => locales.value);
|
||||||
|
const currentLocale = computed(() => locale.value);
|
||||||
const sidebarOpen = ref(false);
|
const sidebarOpen = ref(false);
|
||||||
const sidebarCollapsed = ref(false);
|
const sidebarCollapsed = ref(false);
|
||||||
const dropdownOpen = ref(false);
|
const dropdownOpen = ref(false);
|
||||||
@@ -73,6 +80,16 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Pénzügyek",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
path: "/packages",
|
||||||
|
label: "Csomagok",
|
||||||
|
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m8.25 3v6.75m0 0l-3-3m3 3l3-3M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" /></svg>'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Rendszer",
|
title: "Rendszer",
|
||||||
items: [
|
items: [
|
||||||
@@ -101,6 +118,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|||||||
if (route.path.startsWith("/garages")) return "Garázsok";
|
if (route.path.startsWith("/garages")) return "Garázsok";
|
||||||
if (route.path.startsWith("/users")) return "Felhasználók";
|
if (route.path.startsWith("/users")) return "Felhasználók";
|
||||||
if (route.path.startsWith("/logs")) return "Rendszernaplók";
|
if (route.path.startsWith("/logs")) return "Rendszernaplók";
|
||||||
|
if (route.path.startsWith("/packages")) return "Csomagok";
|
||||||
return "Admin Panel";
|
return "Admin Panel";
|
||||||
});
|
});
|
||||||
function isActive(path) {
|
function isActive(path) {
|
||||||
@@ -120,27 +138,47 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|||||||
_push(`<!---->`);
|
_push(`<!---->`);
|
||||||
}
|
}
|
||||||
_push(`<aside class="${ssrRenderClass([
|
_push(`<aside class="${ssrRenderClass([
|
||||||
"fixed lg:static inset-y-0 left-0 z-30 flex flex-col bg-slate-800 border-r border-slate-700 transition-all duration-300",
|
"fixed lg:static inset-y-0 left-0 z-30 flex flex-col bg-slate-800 border-r border-slate-700 transition-all duration-300 ease-in-out overflow-hidden",
|
||||||
sidebarOpen.value ? "w-64 translate-x-0" : "-translate-x-full lg:translate-x-0 lg:w-20"
|
sidebarOpen.value || !sidebarCollapsed.value ? "w-52" : "w-20"
|
||||||
])}"><div class="flex items-center h-16 px-4 border-b border-slate-700">`);
|
])}">`);
|
||||||
if (sidebarOpen.value || !sidebarCollapsed.value) {
|
_push(ssrRenderComponent(_component_NuxtLink, {
|
||||||
_push(`<div class="flex items-center gap-3"><img${ssrRenderAttr("src", _imports_0)} class="h-8 w-auto" alt="SF Logo"><span class="text-lg font-bold text-white whitespace-nowrap">Admin Panel</span></div>`);
|
to: "/",
|
||||||
} else {
|
class: "flex items-center h-16 px-4 border-b border-slate-700 overflow-hidden hover:bg-slate-700/50 transition"
|
||||||
_push(`<div class="mx-auto"><img${ssrRenderAttr("src", _imports_0)} class="h-8 w-auto" alt="SF Logo"></div>`);
|
}, {
|
||||||
}
|
default: withCtx((_, _push2, _parent2, _scopeId) => {
|
||||||
_push(`</div><button class="hidden lg:flex items-center justify-center h-10 mx-2 mt-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition"><svg class="${ssrRenderClass([{ "rotate-180": sidebarCollapsed.value }, "w-5 h-5 transition-transform duration-300"])}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7"></path></svg></button><nav class="flex-1 px-2 py-4 space-y-2 overflow-y-auto"><!--[-->`);
|
if (_push2) {
|
||||||
|
_push2(`<div class="flex items-center gap-3 min-w-0"${_scopeId}><img${ssrRenderAttr("src", _imports_0)} class="h-8 w-auto flex-shrink-0" alt="SF Logo"${_scopeId}><div class="${ssrRenderClass([sidebarOpen.value || !sidebarCollapsed.value ? "opacity-100 max-w-[200px]" : "opacity-0 max-w-0", "transition-all duration-300 ease-in-out overflow-hidden whitespace-nowrap"])}"${_scopeId}><div class="text-sm font-bold text-white leading-tight"${_scopeId}>Service Finder</div><div class="text-[10px] font-semibold uppercase tracking-wider text-indigo-400 leading-tight"${_scopeId}>Admin</div></div></div>`);
|
||||||
|
} else {
|
||||||
|
return [
|
||||||
|
createVNode("div", { class: "flex items-center gap-3 min-w-0" }, [
|
||||||
|
createVNode("img", {
|
||||||
|
src: _imports_0,
|
||||||
|
class: "h-8 w-auto flex-shrink-0",
|
||||||
|
alt: "SF Logo"
|
||||||
|
}),
|
||||||
|
createVNode("div", {
|
||||||
|
class: ["transition-all duration-300 ease-in-out overflow-hidden whitespace-nowrap", sidebarOpen.value || !sidebarCollapsed.value ? "opacity-100 max-w-[200px]" : "opacity-0 max-w-0"]
|
||||||
|
}, [
|
||||||
|
createVNode("div", { class: "text-sm font-bold text-white leading-tight" }, "Service Finder"),
|
||||||
|
createVNode("div", { class: "text-[10px] font-semibold uppercase tracking-wider text-indigo-400 leading-tight" }, "Admin")
|
||||||
|
], 2)
|
||||||
|
])
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
_: 1
|
||||||
|
}, _parent));
|
||||||
|
_push(`<button class="hidden lg:flex items-center justify-center h-10 mx-2 mt-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition flex-shrink-0"><svg class="${ssrRenderClass([{ "rotate-180": sidebarCollapsed.value }, "w-5 h-5 transition-transform duration-300"])}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7"></path></svg></button><nav class="flex-1 px-2 py-4 space-y-2 overflow-y-auto overflow-x-hidden"><!--[-->`);
|
||||||
ssrRenderList(menuGroups, (group) => {
|
ssrRenderList(menuGroups, (group) => {
|
||||||
_push(`<!--[-->`);
|
_push(`<!--[--><div class="px-3 py-1 text-xs font-semibold uppercase tracking-wider text-slate-500 whitespace-nowrap overflow-hidden" style="${ssrRenderStyle(sidebarOpen.value || !sidebarCollapsed.value ? null : { display: "none" })}">${ssrInterpolate(group.title)}</div><!--[-->`);
|
||||||
if (!sidebarCollapsed.value || sidebarOpen.value) {
|
|
||||||
_push(`<div class="px-3 py-1 text-xs font-semibold uppercase tracking-wider text-slate-500">${ssrInterpolate(group.title)}</div>`);
|
|
||||||
} else {
|
|
||||||
_push(`<!---->`);
|
|
||||||
}
|
|
||||||
_push(`<!--[-->`);
|
|
||||||
ssrRenderList(group.items, (item) => {
|
ssrRenderList(group.items, (item) => {
|
||||||
_push(`<!--[-->`);
|
_push(`<!--[-->`);
|
||||||
if (item.children) {
|
if (item.children) {
|
||||||
_push(`<div><button class="${ssrRenderClass([isGroupActive(item) ? "bg-indigo-600/20 text-indigo-300" : "text-slate-300 hover:bg-slate-700 hover:text-white", "flex items-center w-full gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition"])}"><span class="flex-shrink-0 w-5 h-5">${item.icon ?? ""}</span><span class="flex-1 text-left whitespace-nowrap" style="${ssrRenderStyle(!sidebarCollapsed.value || sidebarOpen.value ? null : { display: "none" })}">${ssrInterpolate(item.label)}</span><svg class="${ssrRenderClass([{ "rotate-90": expandedGroups.value.has(item.label) }, "w-4 h-4 transition-transform duration-200"])}" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="${ssrRenderStyle(!sidebarCollapsed.value || sidebarOpen.value ? null : { display: "none" })}"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg></button><div class="ml-2 mt-1 space-y-1 overflow-hidden transition-all duration-200" style="${ssrRenderStyle(expandedGroups.value.has(item.label) && (!sidebarCollapsed.value || sidebarOpen.value) ? null : { display: "none" })}"><!--[-->`);
|
_push(`<div><button class="${ssrRenderClass([isGroupActive(item) ? "bg-indigo-600/20 text-indigo-300" : "text-slate-300 hover:bg-slate-700 hover:text-white", "flex items-center w-full gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition"])}"><span class="flex-shrink-0 w-5 h-5">${item.icon ?? ""}</span><span class="${ssrRenderClass([sidebarOpen.value || !sidebarCollapsed.value ? "opacity-100 max-w-[200px]" : "opacity-0 max-w-0", "flex-1 text-left whitespace-nowrap overflow-hidden transition-all duration-300 ease-in-out"])}">${ssrInterpolate(item.label)}</span><svg class="${ssrRenderClass([{
|
||||||
|
"rotate-90": expandedGroups.value.has(item.label),
|
||||||
|
"opacity-100": sidebarOpen.value || !sidebarCollapsed.value,
|
||||||
|
"opacity-0": sidebarCollapsed.value && !sidebarOpen.value
|
||||||
|
}, "w-4 h-4 transition-all duration-300 ease-in-out flex-shrink-0"])}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg></button><div class="ml-2 mt-1 space-y-1 overflow-hidden transition-all duration-200" style="${ssrRenderStyle(expandedGroups.value.has(item.label) && (sidebarOpen.value || !sidebarCollapsed.value) ? null : { display: "none" })}"><!--[-->`);
|
||||||
ssrRenderList(item.children, (child) => {
|
ssrRenderList(item.children, (child) => {
|
||||||
_push(ssrRenderComponent(_component_NuxtLink, {
|
_push(ssrRenderComponent(_component_NuxtLink, {
|
||||||
key: child.path,
|
key: child.path,
|
||||||
@@ -150,14 +188,14 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|||||||
}, {
|
}, {
|
||||||
default: withCtx((_, _push2, _parent2, _scopeId) => {
|
default: withCtx((_, _push2, _parent2, _scopeId) => {
|
||||||
if (_push2) {
|
if (_push2) {
|
||||||
_push2(`<span class="flex-shrink-0 w-4 h-4"${_scopeId}>${child.icon ?? ""}</span><span class="whitespace-nowrap"${_scopeId}>${ssrInterpolate(child.label)}</span>`);
|
_push2(`<span class="flex-shrink-0 w-4 h-4"${_scopeId}>${child.icon ?? ""}</span><span class="whitespace-nowrap overflow-hidden"${_scopeId}>${ssrInterpolate(child.label)}</span>`);
|
||||||
} else {
|
} else {
|
||||||
return [
|
return [
|
||||||
createVNode("span", {
|
createVNode("span", {
|
||||||
class: "flex-shrink-0 w-4 h-4",
|
class: "flex-shrink-0 w-4 h-4",
|
||||||
innerHTML: child.icon
|
innerHTML: child.icon
|
||||||
}, null, 8, ["innerHTML"]),
|
}, null, 8, ["innerHTML"]),
|
||||||
createVNode("span", { class: "whitespace-nowrap" }, toDisplayString(child.label), 1)
|
createVNode("span", { class: "whitespace-nowrap overflow-hidden" }, toDisplayString(child.label), 1)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -173,16 +211,16 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|||||||
}, {
|
}, {
|
||||||
default: withCtx((_, _push2, _parent2, _scopeId) => {
|
default: withCtx((_, _push2, _parent2, _scopeId) => {
|
||||||
if (_push2) {
|
if (_push2) {
|
||||||
_push2(`<span class="flex-shrink-0 w-5 h-5"${_scopeId}>${item.icon ?? ""}</span><span class="whitespace-nowrap" style="${ssrRenderStyle(!sidebarCollapsed.value || sidebarOpen.value ? null : { display: "none" })}"${_scopeId}>${ssrInterpolate(item.label)}</span>`);
|
_push2(`<span class="flex-shrink-0 w-5 h-5"${_scopeId}>${item.icon ?? ""}</span><span class="${ssrRenderClass([sidebarOpen.value || !sidebarCollapsed.value ? "opacity-100 max-w-[200px]" : "opacity-0 max-w-0", "whitespace-nowrap overflow-hidden transition-all duration-300 ease-in-out"])}"${_scopeId}>${ssrInterpolate(item.label)}</span>`);
|
||||||
} else {
|
} else {
|
||||||
return [
|
return [
|
||||||
createVNode("span", {
|
createVNode("span", {
|
||||||
class: "flex-shrink-0 w-5 h-5",
|
class: "flex-shrink-0 w-5 h-5",
|
||||||
innerHTML: item.icon
|
innerHTML: item.icon
|
||||||
}, null, 8, ["innerHTML"]),
|
}, null, 8, ["innerHTML"]),
|
||||||
withDirectives(createVNode("span", { class: "whitespace-nowrap" }, toDisplayString(item.label), 513), [
|
createVNode("span", {
|
||||||
[vShow, !sidebarCollapsed.value || sidebarOpen.value]
|
class: ["whitespace-nowrap overflow-hidden transition-all duration-300 ease-in-out", sidebarOpen.value || !sidebarCollapsed.value ? "opacity-100 max-w-[200px]" : "opacity-0 max-w-0"]
|
||||||
])
|
}, toDisplayString(item.label), 3)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -193,13 +231,11 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|||||||
});
|
});
|
||||||
_push(`<!--]--><!--]-->`);
|
_push(`<!--]--><!--]-->`);
|
||||||
});
|
});
|
||||||
_push(`<!--]--></nav><div class="p-4 border-t border-slate-700">`);
|
_push(`<!--]--></nav><div class="p-4 border-t border-slate-700 overflow-hidden"><div class="${ssrRenderClass([sidebarOpen.value || !sidebarCollapsed.value ? "opacity-100 max-w-[200px]" : "opacity-0 max-w-0", "text-xs text-slate-500 whitespace-nowrap transition-all duration-300 ease-in-out"])}"> ServiceFinder v2.0 </div></div></aside><div class="flex-1 flex flex-col min-w-0 transition-all duration-300 ease-in-out"><header class="sticky top-0 z-10 bg-slate-800/95 backdrop-blur-sm border-b border-slate-700"><div class="flex items-center justify-between h-16 px-4 lg:px-6"><div class="flex items-center gap-3"><button class="lg:hidden p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition"><svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg></button><h1 class="text-lg font-semibold text-white truncate">${ssrInterpolate(pageTitle.value)}</h1></div><div class="flex items-center gap-3"><div class="flex items-center bg-slate-700/50 rounded-lg p-0.5"><!--[-->`);
|
||||||
if (!sidebarCollapsed.value || sidebarOpen.value) {
|
ssrRenderList(availableLocales.value, (locale2) => {
|
||||||
_push(`<div class="text-xs text-slate-500"> ServiceFinder v2.0 </div>`);
|
_push(`<button class="${ssrRenderClass([currentLocale.value === locale2.code ? "bg-indigo-600 text-white shadow-sm" : "text-slate-400 hover:text-white", "px-2.5 py-1 text-xs font-medium rounded-md transition"])}">${ssrInterpolate(locale2.code === "hu" ? "HU" : "EN")}</button>`);
|
||||||
} else {
|
});
|
||||||
_push(`<!---->`);
|
_push(`<!--]--></div><div class="relative"><button class="flex items-center gap-2 p-1.5 rounded-lg hover:bg-slate-700 transition"><div class="w-8 h-8 rounded-full bg-indigo-600 flex items-center justify-center text-sm font-bold text-white">${ssrInterpolate(userInitials.value)}</div><span class="hidden sm:block text-sm text-slate-300">${ssrInterpolate(userEmail.value)}</span><svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg></button>`);
|
||||||
}
|
|
||||||
_push(`</div></aside><div class="flex-1 flex flex-col min-w-0"><header class="sticky top-0 z-10 bg-slate-800/95 backdrop-blur-sm border-b border-slate-700"><div class="flex items-center justify-between h-16 px-4 lg:px-6"><div class="flex items-center gap-3"><button class="lg:hidden p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition"><svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg></button><h1 class="text-lg font-semibold text-white truncate">${ssrInterpolate(pageTitle.value)}</h1></div><div class="relative"><button class="flex items-center gap-2 p-1.5 rounded-lg hover:bg-slate-700 transition"><div class="w-8 h-8 rounded-full bg-indigo-600 flex items-center justify-center text-sm font-bold text-white">${ssrInterpolate(userInitials.value)}</div><span class="hidden sm:block text-sm text-slate-300">${ssrInterpolate(userEmail.value)}</span><svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg></button>`);
|
|
||||||
if (dropdownOpen.value) {
|
if (dropdownOpen.value) {
|
||||||
_push(`<div class="absolute right-0 mt-2 w-56 bg-slate-800 border border-slate-700 rounded-xl shadow-xl py-1 z-50"><div class="px-4 py-3 border-b border-slate-700"><p class="text-sm font-medium text-white">${ssrInterpolate(userEmail.value)}</p><p class="text-xs text-slate-400 mt-0.5">Administrator</p></div>`);
|
_push(`<div class="absolute right-0 mt-2 w-56 bg-slate-800 border border-slate-700 rounded-xl shadow-xl py-1 z-50"><div class="px-4 py-3 border-b border-slate-700"><p class="text-sm font-medium text-white">${ssrInterpolate(userEmail.value)}</p><p class="text-xs text-slate-400 mt-0.5">Administrator</p></div>`);
|
||||||
_push(ssrRenderComponent(_component_NuxtLink, {
|
_push(ssrRenderComponent(_component_NuxtLink, {
|
||||||
@@ -235,7 +271,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|||||||
} else {
|
} else {
|
||||||
_push(`<!---->`);
|
_push(`<!---->`);
|
||||||
}
|
}
|
||||||
_push(`</div></div></header><main class="flex-1 p-4 lg:p-6 overflow-auto">`);
|
_push(`</div></div></div></header><main class="flex-1 p-4 lg:p-6 overflow-auto">`);
|
||||||
ssrRenderSlot(_ctx.$slots, "default", {}, null, _push, _parent);
|
ssrRenderSlot(_ctx.$slots, "default", {}, null, _push, _parent);
|
||||||
_push(`</main></div></div>`);
|
_push(`</main></div></div>`);
|
||||||
};
|
};
|
||||||
@@ -250,4 +286,4 @@ _sfc_main.setup = (props, ctx) => {
|
|||||||
export {
|
export {
|
||||||
_sfc_main as default
|
_sfc_main as default
|
||||||
};
|
};
|
||||||
//# sourceMappingURL=default-DbJQlxCD.js.map
|
//# sourceMappingURL=default-B9tlXlqE.js.map
|
||||||
1
frontend_admin/.nuxt/dist/server/_nuxt/default-B9tlXlqE.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/default-B9tlXlqE.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/default-B9tlXlqE.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/default-B9tlXlqE.js.map.json
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
116
frontend_admin/.nuxt/dist/server/_nuxt/en-BScPj1A-.js
vendored
Normal file
116
frontend_admin/.nuxt/dist/server/_nuxt/en-BScPj1A-.js
vendored
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
const resource = {
|
||||||
|
"permissions": {
|
||||||
|
"title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Permissions Management" } },
|
||||||
|
"subtitle": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Permissions Management — Role-based access control & subscription feature flags" } },
|
||||||
|
"loading": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Loading permissions data..." } },
|
||||||
|
"error_title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Failed to load permissions" } },
|
||||||
|
"retry": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Retry" } },
|
||||||
|
"save_changes": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Save Changes" } },
|
||||||
|
"discard": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Discard" } },
|
||||||
|
"modified_count": { "t": 0, "b": { "t": 2, "i": [{ "t": 4, "k": "count" }, { "t": 3, "v": " modification(s)" }] } },
|
||||||
|
"current_tier": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Current Tier" } },
|
||||||
|
"expires": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Expires" } },
|
||||||
|
"superadmin": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Superadmin" } },
|
||||||
|
"superadmin_desc": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Full system access — user management, billing, robots, and system configuration." } },
|
||||||
|
"garages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Garages" } },
|
||||||
|
"garages_desc": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Garage-level access — vehicle management, service booking, customer data." } },
|
||||||
|
"feature_flags": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Feature Flags" } },
|
||||||
|
"feature_flags_desc": { "t": 0, "b": { "t": 2, "i": [{ "t": 3, "v": "Subscription feature toggles — resolved from your current tier (" }, { "t": 4, "k": "tier" }, { "t": 3, "v": ")." }] } },
|
||||||
|
"rbac_title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "RBAC Permission Matrix" } },
|
||||||
|
"rbac_desc": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Role-based access control — which roles can perform which admin actions." } },
|
||||||
|
"action": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Action" } },
|
||||||
|
"perms": {
|
||||||
|
"user_mgmt": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "User Management" } },
|
||||||
|
"billing_admin": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Billing Administration" } },
|
||||||
|
"robot_config": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Robot Configuration" } },
|
||||||
|
"system_logs": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "System Logs Access" } },
|
||||||
|
"api_keys": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "API Key Management" } },
|
||||||
|
"vehicle_crud": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Vehicle CRUD" } },
|
||||||
|
"service_booking": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Service Booking" } },
|
||||||
|
"customer_view": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Customer Data View" } },
|
||||||
|
"report_export": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Report Export" } },
|
||||||
|
"bulk_import": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Bulk Import" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Package Management" } },
|
||||||
|
"subtitle": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Manage subscription packages and feature flags — Free, Premium, Enterprise" } },
|
||||||
|
"loading": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Loading packages..." } },
|
||||||
|
"retry": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Retry" } },
|
||||||
|
"create_new": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Create Package" } },
|
||||||
|
"no_packages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "No packages yet" } },
|
||||||
|
"no_packages_desc": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Create your first subscription package to get started." } },
|
||||||
|
"create_first": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Create First Package" } },
|
||||||
|
"popular": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Popular" } },
|
||||||
|
"fallback_badge": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Fallback" } },
|
||||||
|
"trial_badge": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Trial" } },
|
||||||
|
"month": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "month" } },
|
||||||
|
"feature_flags": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Available Features" } },
|
||||||
|
"edit": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Edit" } },
|
||||||
|
"delete": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Delete" } },
|
||||||
|
"set_as_default": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Set as default" } },
|
||||||
|
"set_as_default_success": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "set as default package!" } },
|
||||||
|
"edit_title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Edit Package" } },
|
||||||
|
"create_title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Create Package" } },
|
||||||
|
"cancel": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Cancel" } },
|
||||||
|
"save": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Save Changes" } },
|
||||||
|
"create": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Create" } },
|
||||||
|
"save_success": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Package updated successfully!" } },
|
||||||
|
"delete_title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Delete Package" } },
|
||||||
|
"delete_confirm": { "t": 0, "b": { "t": 2, "i": [{ "t": 3, "v": 'Are you sure you want to delete "' }, { "t": 4, "k": "name" }, { "t": 3, "v": '"?' }] } },
|
||||||
|
"delete_btn": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Delete" } },
|
||||||
|
"allowances": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Allowances" } },
|
||||||
|
"max_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Vehicles" } },
|
||||||
|
"max_garages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Garages" } },
|
||||||
|
"monthly_credits": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Monthly Credits" } },
|
||||||
|
"tier_level": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Tier Level" } },
|
||||||
|
"form_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "System Name" } },
|
||||||
|
"form_name_placeholder": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "e.g. premium_2024" } },
|
||||||
|
"form_display_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Display Name" } },
|
||||||
|
"form_display_name_placeholder": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "e.g. Premium Plus" } },
|
||||||
|
"form_type": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Type" } },
|
||||||
|
"type_private": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Private" } },
|
||||||
|
"type_corporate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Corporate" } },
|
||||||
|
"form_tier_level": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Tier Level" } },
|
||||||
|
"form_tier_level_hint": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Higher number = more features & higher rank" } },
|
||||||
|
"form_monthly_price": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Monthly Price" } },
|
||||||
|
"form_yearly_price": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Yearly Price" } },
|
||||||
|
"form_currency": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Currency" } },
|
||||||
|
"form_subtitle": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Subtitle / Description" } },
|
||||||
|
"form_subtitle_placeholder": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "e.g. Best for small businesses" } },
|
||||||
|
"form_badge": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Badge Text" } },
|
||||||
|
"form_badge_placeholder": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "e.g. Most Popular" } },
|
||||||
|
"form_is_custom": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Custom Package (not publicly listed)" } },
|
||||||
|
"singleton_rules": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Singleton Rules" } },
|
||||||
|
"form_is_default_fallback": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Default Fallback Package — assigned when a subscription expires" } },
|
||||||
|
"form_trial_days": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Trial Days on Signup" } },
|
||||||
|
"form_trial_days_hint": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "New organizations get this many free days. Only one package can have trial > 0." } }
|
||||||
|
},
|
||||||
|
"garages": {
|
||||||
|
"title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Garages CRM" } },
|
||||||
|
"subtitle": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Garage list, package management and status changes" } },
|
||||||
|
"total_garages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Total Garages" } },
|
||||||
|
"active_garages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Active Garages" } },
|
||||||
|
"premium_garages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Premium Garages" } },
|
||||||
|
"enterprise_garages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Enterprise Garages" } },
|
||||||
|
"search_placeholder": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Search by name, contact or email..." } },
|
||||||
|
"all_tiers": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "All packages" } },
|
||||||
|
"company_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Company Name" } },
|
||||||
|
"email": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Email" } },
|
||||||
|
"status": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Status" } },
|
||||||
|
"current_package": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Current Package" } },
|
||||||
|
"actions": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Actions" } },
|
||||||
|
"active": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Active" } },
|
||||||
|
"inactive": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Inactive" } },
|
||||||
|
"suspended": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Suspended" } },
|
||||||
|
"change_package": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Change Package" } },
|
||||||
|
"view": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "View" } },
|
||||||
|
"activate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Activate" } },
|
||||||
|
"deactivate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Deactivate" } },
|
||||||
|
"no_results": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "No garages match your search." } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export {
|
||||||
|
resource as default
|
||||||
|
};
|
||||||
|
//# sourceMappingURL=en-BScPj1A-.js.map
|
||||||
1
frontend_admin/.nuxt/dist/server/_nuxt/en-BScPj1A-.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/en-BScPj1A-.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"en-BScPj1A-.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
|
||||||
1
frontend_admin/.nuxt/dist/server/_nuxt/en-BScPj1A-.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/en-BScPj1A-.js.map.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"file":"en-BScPj1A-.js","mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","names":[],"sources":[],"sourcesContent":[],"version":3}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"entry-styles-1.mjs-CF-ChPYj.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"file":"entry-styles-1.mjs-CF-ChPYj.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}
|
|
||||||
5
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-1.mjs-DTfYoqFp.js
vendored
Normal file
5
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-1.mjs-DTfYoqFp.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-1.mjs-DTfYoqFp.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-1.mjs-DTfYoqFp.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"entry-styles-1.mjs-DTfYoqFp.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
|
||||||
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-1.mjs-DTfYoqFp.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-1.mjs-DTfYoqFp.js.map.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"file":"entry-styles-1.mjs-DTfYoqFp.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}
|
||||||
5
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-2.mjs-CM2W3GZ1.js
vendored
Normal file
5
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-2.mjs-CM2W3GZ1.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-2.mjs-CM2W3GZ1.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-2.mjs-CM2W3GZ1.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"entry-styles-2.mjs-CM2W3GZ1.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
|
||||||
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-2.mjs-CM2W3GZ1.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-2.mjs-CM2W3GZ1.js.map.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"file":"entry-styles-2.mjs-CM2W3GZ1.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"entry-styles-2.mjs-CntxBZab.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"file":"entry-styles-2.mjs-CntxBZab.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import style_0 from "./entry-styles-1.mjs-CF-ChPYj.js";
|
|
||||||
import style_1 from "./entry-styles-2.mjs-CntxBZab.js";
|
|
||||||
import style_2 from "./entry-styles-3.mjs-C1rWf53M.js";
|
|
||||||
export default [
|
|
||||||
style_0,
|
|
||||||
style_1,
|
|
||||||
style_2
|
|
||||||
]
|
|
||||||
8
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles.w3HlJ-9O.mjs
vendored
Normal file
8
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles.w3HlJ-9O.mjs
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import style_0 from "./entry-styles-1.mjs-DTfYoqFp.js";
|
||||||
|
import style_1 from "./entry-styles-2.mjs-CM2W3GZ1.js";
|
||||||
|
import style_2 from "./entry-styles-3.mjs-C1rWf53M.js";
|
||||||
|
export default [
|
||||||
|
style_0,
|
||||||
|
style_1,
|
||||||
|
style_2
|
||||||
|
]
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,8 +1,7 @@
|
|||||||
import { _ as __nuxt_component_0 } from "./nuxt-link-DNVrgHUL.js";
|
import { _ as __nuxt_component_0 } from "./nuxt-link-Cp-CAJwR.js";
|
||||||
import { mergeProps, withCtx, createTextVNode, toDisplayString, useSSRContext } from "vue";
|
import { mergeProps, withCtx, createTextVNode, toDisplayString, useSSRContext } from "vue";
|
||||||
import { ssrRenderAttrs, ssrInterpolate, ssrRenderComponent } from "vue/server-renderer";
|
import { ssrRenderAttrs, ssrInterpolate, ssrRenderComponent } from "vue/server-renderer";
|
||||||
import { _ as _export_sfc } from "../server.mjs";
|
import { _ as _export_sfc, u as useHead } from "../server.mjs";
|
||||||
import { u as useHead } from "./v3-DnDMBKvA.js";
|
|
||||||
import "/app/node_modules/ufo/dist/index.mjs";
|
import "/app/node_modules/ufo/dist/index.mjs";
|
||||||
import "/app/node_modules/defu/dist/defu.mjs";
|
import "/app/node_modules/defu/dist/defu.mjs";
|
||||||
import "/app/node_modules/ofetch/dist/node.mjs";
|
import "/app/node_modules/ofetch/dist/node.mjs";
|
||||||
@@ -13,7 +12,11 @@ import "/app/node_modules/h3/dist/index.mjs";
|
|||||||
import "pinia";
|
import "pinia";
|
||||||
import "vue-router";
|
import "vue-router";
|
||||||
import "/app/node_modules/klona/dist/index.mjs";
|
import "/app/node_modules/klona/dist/index.mjs";
|
||||||
|
import "/app/node_modules/cookie-es/dist/index.mjs";
|
||||||
|
import "/app/node_modules/destr/dist/index.mjs";
|
||||||
|
import "/app/node_modules/ohash/dist/index.mjs";
|
||||||
import "/app/node_modules/@unhead/vue/dist/index.mjs";
|
import "/app/node_modules/@unhead/vue/dist/index.mjs";
|
||||||
|
import "@vue/devtools-api";
|
||||||
const _sfc_main = {
|
const _sfc_main = {
|
||||||
__name: "error-404",
|
__name: "error-404",
|
||||||
__ssrInlineRender: true,
|
__ssrInlineRender: true,
|
||||||
@@ -90,4 +93,4 @@ const error404 = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-1
|
|||||||
export {
|
export {
|
||||||
error404 as default
|
error404 as default
|
||||||
};
|
};
|
||||||
//# sourceMappingURL=error-404-BDmq62MM.js.map
|
//# sourceMappingURL=error-404-C_elhLNc.js.map
|
||||||
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/error-404-C_elhLNc.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/error-404-C_elhLNc.js.map.json
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,7 +1,6 @@
|
|||||||
import { mergeProps, useSSRContext } from "vue";
|
import { mergeProps, useSSRContext } from "vue";
|
||||||
import { ssrRenderAttrs, ssrInterpolate } from "vue/server-renderer";
|
import { ssrRenderAttrs, ssrInterpolate } from "vue/server-renderer";
|
||||||
import { _ as _export_sfc } from "../server.mjs";
|
import { _ as _export_sfc, u as useHead } from "../server.mjs";
|
||||||
import { u as useHead } from "./v3-DnDMBKvA.js";
|
|
||||||
import "/app/node_modules/ofetch/dist/node.mjs";
|
import "/app/node_modules/ofetch/dist/node.mjs";
|
||||||
import "#internal/nuxt/paths";
|
import "#internal/nuxt/paths";
|
||||||
import "/app/node_modules/hookable/dist/index.mjs";
|
import "/app/node_modules/hookable/dist/index.mjs";
|
||||||
@@ -12,7 +11,11 @@ import "/app/node_modules/defu/dist/defu.mjs";
|
|||||||
import "vue-router";
|
import "vue-router";
|
||||||
import "/app/node_modules/ufo/dist/index.mjs";
|
import "/app/node_modules/ufo/dist/index.mjs";
|
||||||
import "/app/node_modules/klona/dist/index.mjs";
|
import "/app/node_modules/klona/dist/index.mjs";
|
||||||
|
import "/app/node_modules/cookie-es/dist/index.mjs";
|
||||||
|
import "/app/node_modules/destr/dist/index.mjs";
|
||||||
|
import "/app/node_modules/ohash/dist/index.mjs";
|
||||||
import "/app/node_modules/@unhead/vue/dist/index.mjs";
|
import "/app/node_modules/@unhead/vue/dist/index.mjs";
|
||||||
|
import "@vue/devtools-api";
|
||||||
const _sfc_main = {
|
const _sfc_main = {
|
||||||
__name: "error-500",
|
__name: "error-500",
|
||||||
__ssrInlineRender: true,
|
__ssrInlineRender: true,
|
||||||
@@ -68,4 +71,4 @@ const error500 = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-a
|
|||||||
export {
|
export {
|
||||||
error500 as default
|
error500 as default
|
||||||
};
|
};
|
||||||
//# sourceMappingURL=error-500-gzzPk_-U.js.map
|
//# sourceMappingURL=error-500-C6MJq2Cl.js.map
|
||||||
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/error-500-C6MJq2Cl.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/error-500-C6MJq2Cl.js.map.json
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
116
frontend_admin/.nuxt/dist/server/_nuxt/hu-CoGcC81v.js
vendored
Normal file
116
frontend_admin/.nuxt/dist/server/_nuxt/hu-CoGcC81v.js
vendored
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
const resource = {
|
||||||
|
"permissions": {
|
||||||
|
"title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Jogosultság Kezelés" } },
|
||||||
|
"subtitle": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Permissions Management — Role-based access control & subscription feature flags" } },
|
||||||
|
"loading": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Jogosultságok betöltése..." } },
|
||||||
|
"error_title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Nem sikerült betölteni a jogosultságokat" } },
|
||||||
|
"retry": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Újra" } },
|
||||||
|
"save_changes": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Változtatások Mentése" } },
|
||||||
|
"discard": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Elvetés" } },
|
||||||
|
"modified_count": { "t": 0, "b": { "t": 2, "i": [{ "t": 4, "k": "count" }, { "t": 3, "v": " módosítás" }] } },
|
||||||
|
"current_tier": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Aktuális Csomag" } },
|
||||||
|
"expires": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Lejárat" } },
|
||||||
|
"superadmin": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Superadmin" } },
|
||||||
|
"superadmin_desc": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Teljes rendszer hozzáférés — felhasználók, számlázás, robotok és rendszerkonfiguráció." } },
|
||||||
|
"garages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Garázsok" } },
|
||||||
|
"garages_desc": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Garázs szintű hozzáférés — járműkezelés, szervizfoglalás, ügyféladatok." } },
|
||||||
|
"feature_flags": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Feature Flags" } },
|
||||||
|
"feature_flags_desc": { "t": 0, "b": { "t": 2, "i": [{ "t": 3, "v": "Előfizetési funkciók — a jelenlegi csomagod alapján (" }, { "t": 4, "k": "tier" }, { "t": 3, "v": ")." }] } },
|
||||||
|
"rbac_title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "RBAC Jogosultsági Mátrix" } },
|
||||||
|
"rbac_desc": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Szerepkör alapú hozzáférés-vezérlés — mely szerepkörök mely admin műveleteket végezhetik." } },
|
||||||
|
"action": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Művelet" } },
|
||||||
|
"perms": {
|
||||||
|
"user_mgmt": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Felhasználókezelés" } },
|
||||||
|
"billing_admin": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Számlázási Adminisztráció" } },
|
||||||
|
"robot_config": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Robot Konfiguráció" } },
|
||||||
|
"system_logs": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Rendszernaplók Elérése" } },
|
||||||
|
"api_keys": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "API Kulcs Kezelés" } },
|
||||||
|
"vehicle_crud": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Jármű CRUD" } },
|
||||||
|
"service_booking": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Szervizfoglalás" } },
|
||||||
|
"customer_view": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Ügyféladatok Megtekintése" } },
|
||||||
|
"report_export": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Jelentés Exportálás" } },
|
||||||
|
"bulk_import": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Tömeges Import" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Csomagok Kezelése" } },
|
||||||
|
"subtitle": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Előfizetési csomagok és funkciók kezelése — Free, Premium, Enterprise" } },
|
||||||
|
"loading": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Csomagok betöltése..." } },
|
||||||
|
"retry": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Újra" } },
|
||||||
|
"create_new": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Új Csomag" } },
|
||||||
|
"no_packages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Még nincsenek csomagok" } },
|
||||||
|
"no_packages_desc": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Hozd létre az első előfizetési csomagot a kezdéshez." } },
|
||||||
|
"create_first": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Első Csomag Létrehozása" } },
|
||||||
|
"popular": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Népszerű" } },
|
||||||
|
"fallback_badge": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Visszaesési" } },
|
||||||
|
"trial_badge": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Próba" } },
|
||||||
|
"month": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "hó" } },
|
||||||
|
"feature_flags": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Elérhető Funkciók" } },
|
||||||
|
"edit": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Szerkesztés" } },
|
||||||
|
"delete": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Törlés" } },
|
||||||
|
"set_as_default": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Beállítás alapértelmezettként" } },
|
||||||
|
"set_as_default_success": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "alapértelmezett csomagként beállítva!" } },
|
||||||
|
"edit_title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Csomag Szerkesztése" } },
|
||||||
|
"create_title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Csomag Létrehozása" } },
|
||||||
|
"cancel": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Mégse" } },
|
||||||
|
"save": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Változtatások Mentése" } },
|
||||||
|
"create": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Létrehozás" } },
|
||||||
|
"save_success": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Csomag sikeresen frissítve!" } },
|
||||||
|
"delete_title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Csomag Törlése" } },
|
||||||
|
"delete_confirm": { "t": 0, "b": { "t": 2, "i": [{ "t": 3, "v": 'Biztosan törölni szeretnéd a "' }, { "t": 4, "k": "name" }, { "t": 3, "v": '" csomagot?' }] } },
|
||||||
|
"delete_btn": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Törlés" } },
|
||||||
|
"allowances": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Korlátok" } },
|
||||||
|
"max_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Jármű" } },
|
||||||
|
"max_garages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Garázs" } },
|
||||||
|
"monthly_credits": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Havi Kredit" } },
|
||||||
|
"tier_level": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Szint" } },
|
||||||
|
"form_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Rendszer Név" } },
|
||||||
|
"form_name_placeholder": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "pl. premium_2024" } },
|
||||||
|
"form_display_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Megjelenítési Név" } },
|
||||||
|
"form_display_name_placeholder": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "pl. Premium Plus" } },
|
||||||
|
"form_type": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Típus" } },
|
||||||
|
"type_private": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Magán" } },
|
||||||
|
"type_corporate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Vállalati" } },
|
||||||
|
"form_tier_level": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Szintszám" } },
|
||||||
|
"form_tier_level_hint": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Magasabb szám = több funkció és magasabb rang" } },
|
||||||
|
"form_monthly_price": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Havi Díj" } },
|
||||||
|
"form_yearly_price": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Éves Díj" } },
|
||||||
|
"form_currency": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Pénznem" } },
|
||||||
|
"form_subtitle": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Felirat / Leírás" } },
|
||||||
|
"form_subtitle_placeholder": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "pl. Ideális kisvállalkozásoknak" } },
|
||||||
|
"form_badge": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Jelvény Szöveg" } },
|
||||||
|
"form_badge_placeholder": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "pl. Legnépszerűbb" } },
|
||||||
|
"form_is_custom": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Egyedi Csomag (nem publikus)" } },
|
||||||
|
"singleton_rules": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Egyediségi Szabályok" } },
|
||||||
|
"form_is_default_fallback": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Alapértelmezett Visszaesési Csomag — lejárt előfizetés esetén erre vált" } },
|
||||||
|
"form_trial_days": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Próba Napok Regisztrációnál" } },
|
||||||
|
"form_trial_days_hint": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Az új szervezetek ennyi ingyenes napot kapnak. Csak egy csomagnál lehet > 0." } }
|
||||||
|
},
|
||||||
|
"garages": {
|
||||||
|
"title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Garázsok CRM" } },
|
||||||
|
"subtitle": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Garázsok listája, csomagkezelés és státuszmódosítás" } },
|
||||||
|
"total_garages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Összes Garázs" } },
|
||||||
|
"active_garages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Aktív Garázsok" } },
|
||||||
|
"premium_garages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Premium Garázsok" } },
|
||||||
|
"enterprise_garages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Enterprise Garázsok" } },
|
||||||
|
"search_placeholder": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Keresés név, kapcsolattartó vagy e-mail alapján..." } },
|
||||||
|
"all_tiers": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Összes csomag" } },
|
||||||
|
"company_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Cégnév" } },
|
||||||
|
"email": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "E-mail" } },
|
||||||
|
"status": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Státusz" } },
|
||||||
|
"current_package": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Aktuális Csomag" } },
|
||||||
|
"actions": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Műveletek" } },
|
||||||
|
"active": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Aktív" } },
|
||||||
|
"inactive": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Inaktív" } },
|
||||||
|
"suspended": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Felfüggesztve" } },
|
||||||
|
"change_package": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Csomag Módosítása" } },
|
||||||
|
"view": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Megtekintés" } },
|
||||||
|
"activate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Aktiválás" } },
|
||||||
|
"deactivate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Deaktiválás" } },
|
||||||
|
"no_results": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Nincs a keresésnek megfelelő garázs." } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export {
|
||||||
|
resource as default
|
||||||
|
};
|
||||||
|
//# sourceMappingURL=hu-CoGcC81v.js.map
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user