admin felület fejlesztése garázs, előfizeztési csomagok

This commit is contained in:
Roo
2026-06-25 01:58:04 +00:00
parent 80a5d67f79
commit 52011606ff
334 changed files with 46636 additions and 1606 deletions

View File

@@ -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: ✅ Tiszn 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`

View File

@@ -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

View File

@@ -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)"])

View File

@@ -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,
} }

View 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,
},
}

View File

@@ -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)

View 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}.",
)

View File

@@ -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):

View File

@@ -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,

View 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
]

View File

@@ -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,
}

View File

@@ -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",

View File

@@ -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):
""" """

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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))

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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.",

View 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()

View File

@@ -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,
}

View 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)

View 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())

View 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())

View 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())

View 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())

View 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.*

View File

@@ -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

View 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};

View File

@@ -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};

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

View File

@@ -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

View 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};

File diff suppressed because one or more lines are too long

View 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};

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

View 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

File diff suppressed because one or more lines are too long

View 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};

File diff suppressed because one or more lines are too long

View File

@@ -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};

View 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};

View File

@@ -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

View File

@@ -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};

File diff suppressed because one or more lines are too long

View File

@@ -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};

File diff suppressed because one or more lines are too long

View File

@@ -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

View File

@@ -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;"}

View 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

View File

@@ -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}

View File

@@ -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

View File

@@ -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

View File

@@ -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

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

File diff suppressed because one or more lines are too long

View 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

View File

@@ -0,0 +1 @@
{"version":3,"file":"en-BScPj1A-.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}

View 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

View File

@@ -1 +0,0 @@
{"version":3,"file":"entry-styles-1.mjs-CF-ChPYj.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}

View File

@@ -1 +0,0 @@
{"file":"entry-styles-1.mjs-CF-ChPYj.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"version":3,"file":"entry-styles-1.mjs-DTfYoqFp.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}

View File

@@ -0,0 +1 @@
{"file":"entry-styles-1.mjs-DTfYoqFp.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"version":3,"file":"entry-styles-2.mjs-CM2W3GZ1.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}

View 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

View File

@@ -1 +0,0 @@
{"version":3,"file":"entry-styles-2.mjs-CntxBZab.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}

View File

@@ -1 +0,0 @@
{"file":"entry-styles-2.mjs-CntxBZab.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}

View File

@@ -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
]

View 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

View File

@@ -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

View File

@@ -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

File diff suppressed because one or more lines are too long

View 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