RABC fejlesztése és beélesítése hibák kijavításával

This commit is contained in:
Roo
2026-06-18 18:09:51 +00:00
parent 611307a24b
commit fe3c32597d
57 changed files with 4689 additions and 655 deletions

View File

@@ -446,3 +446,508 @@ Két P0 szintű probléma javítása: (1) "Univerzális Szolgáltatások" blokkb
- Seed script: ✅ PASS (134 rekord, ebből 12 univerzális: 4 Level 1 + 8 Level 2)
- Autocomplete API `q=Benzinkút`: ✅ PASS (1 találat: Benzinkút, level=2)
- Autocomplete API `q=Étterem`: ✅ PASS (3 találat: Étterem, Gyorsétterem / Büfé, Étterem / Büfé)
## 2026-06-18 - P0 Critical: Financial Math Reversal (Gross-First Accounting)
### 🎯 Cél
A Phase 2 Financial Architecture felülvizsgálata során a Vezető Tervező (Architect) jelezte, hogy a pénzügyi logika fordítva van: az EU-s számvitelben a BRUTTÓ (Gross) az elsődleges forrásigazság (Source of Truth), nem a Nettó. A teljes matematika megfordítása: `_calculate_gross_from_net()` → `_calculate_net_from_gross()`, `amount_gross` kötelező, `amount_net` opcionális.
### 🔧 Változtatások
**1. SCHEMA - [`asset_cost.py`](backend/app/schemas/asset_cost.py:9):**
- `amount_gross` áthelyezve kötelező mezővé (`Field(..., description="Bruttó összeg — KÖTELEZŐ, elsődleges forrás")`)
- `amount_net` opcionális (`Optional[Decimal] = Field(None, ...)`)
- `@model_validator(mode='after')` hozzáadva: ha `amount_net=None` és `vat_rate=None`, akkor `amount_net = amount_gross` és `vat_rate = 0`
**2. SCHEMA - [`asset_event.py`](backend/app/schemas/asset_event.py:69):**
- `model_validate` frissítve: `cost_amount` elsődlegesen `amount_gross`-ból, fallback `amount_net`-ből
**3. SCHEMA - [`asset.py`](backend/app/schemas/asset.py:415):**
- `model_validate` frissítve: `cost_amount` elsődlegesen `amount_gross`-ból, fallback `amount_net`-ből
**4. ENDPOINT - [`expenses.py`](backend/app/api/v1/endpoints/expenses.py:49):**
- `_calculate_gross_from_net()` → `_calculate_net_from_gross(amount_gross, vat_rate)` átnevezve
- Képlet megfordítva: `net = gross / (1 + vat_rate/100)`
- VAT handling logika átírva: ha `vat_rate` adott és `amount_net` nincs, net kiszámítása gross-ból
- Ha `amount_net` és `vat_rate` is adott, `vat_rate` felülírva a back-számított értékkel
- Ha egyik sincs, `amount_net = amount_gross`, `vat_rate = 0`
**5. ENDPOINT - [`assets.py`](backend/app/api/v1/endpoints/assets.py):**
- `create_maintenance_record`: `cost` mező gross-ként kezelve, `amount_gross` és `amount_net` is a cost értékkel töltve
- `create_asset_event`: `cost_amount` gross-ként kezelve
- `list_asset_costs`: response-ban `amount_gross` az `amount_net` előtt
### ✅ Verifikáció
- Python szintaxis ellenőrzés: ✅ 5/5 fájl OK
- Sync engine: ✅ 1087/1087 OK, 0 fix, 0 extra
- Unit math tesztek: ✅ 5/5 passed (Gross=12700→Net=10000 27% VAT, Gross=10500→Net=10000 5% VAT, stb.)
- E2E API tesztek: ✅ 3/3 passed (Gross only, Gross+27%, Gross+5%)
- A teljes Gross-first matematika helyesen működik: a bruttó összeg a Source of Truth, a nettó és ÁFA visszaszámolva
## 2026-06-18 - P0 Frontend Cost Payload Audit & Fix (Gross-First Compliance)
### 🎯 Cél
A backend már szigorúan Gross-first rendszer a pénzügyi adatokra, de a frontend űrlapok még `amount_net`-et küldtek a payloadban, ami `amount_gross: null`-t eredményezett az adatbázisban. Teljes audit és javítás.
### 🔧 Változtatások
**1. FRONTEND - [`cost.ts`](frontend/src/stores/cost.ts:8):**
- `AddExpensePayload` interface: `amount_net` → `amount_gross` (elsődleges mező)
- `ExpenseResponse` interface: `amount_net` → `amount_gross`
- `addExpense()` body builder: `amount_net: payload.amount_net` → `amount_gross: payload.amount_gross`
**2. FRONTEND - [`ComplexExpenseModal.vue`](frontend/src/components/dashboard/ComplexExpenseModal.vue:268):**
- Payload `amount_net: form.amount` → `amount_gross: form.amount`
**3. FRONTEND - [`SimpleFuelModal.vue`](frontend/src/components/dashboard/SimpleFuelModal.vue:193):**
- Payload `amount_net: form.amount` → `amount_gross: form.amount`
**4. BACKEND - [`asset_cost.py`](backend/app/schemas/asset_cost.py:27):**
- `@model_validator(mode='before')` safety net hozzáadva: ha a bejövő JSON `amount_net`-et tartalmaz, de `amount_gross` hiányzik, automatikusan átmásolja az `amount_net` értékét az `amount_gross`-ba. Ez véd a régebbi cache-elt böngészők ellen.
### ✅ Verifikáció
- Vite build: ✅ Sikeres (216 module transformed, 0 error)
- Backend schema import: ✅ Sikeres
- Safety net tesztek: ✅ 3/3 passed
- `amount_net` only → `amount_gross` auto-copy: ✅
- Both fields present → no interference: ✅
- `amount_gross` only → no change: ✅
### 🔍 Utólagos Adatjavítás (Data Fix)
A felhasználó jelentése alapján az UOK795 (Honda CB1000R) járműnél az utolsó 2 rögzített költség 0 Ft-ot mutatott. Vizsgálat után kiderült:
**Gyökér ok:** A P0 audit előtt rögzített rekordoknál a frontend `amount_net`-et küldött, de a backend `amount_gross`-t 0-ra állította (a `validate_gross_first` `mode='after'` validátor `None` → `Decimal("0")`-ra állította a hiányzó gross mezőt).
**Érintett rekordok (országosan 2 db):**
1. `3468e81a` - UOK795 (Honda CB1000R) - 12 000 Ft tankolás → `amount_gross` javítva 0→12000
2. `ea0395ea` - QWE123 (APRILIA af1) - 4 000 Ft → `amount_gross` javítva 0→4000
**Javítás:** `UPDATE vehicle.asset_costs SET amount_gross = amount_net, vat_rate = 0.00 WHERE amount_gross=0 AND amount_net>0`
## 2026-06-18 - Jogosultságkezelés Audit (Permission System Audit)
### 🎯 Cél
Teljes körű audit a jogosultsági szintek kezeléséről, kiterjedve az adatbázis táblákra, a backend kódra és a frontend rétegre.
### 🔍 Vizsgált Területek
1. **Adatbázis (6 tábla):** identity.users, fleet.org_roles, fleet.organization_members, system.system_parameters, system.pending_actions, system.subscription_tiers
2. **Backend:** UserRole enum, DEFAULT_RANK_MAP, RBAC osztály, get_current_admin, scoped RBAC, Dual Control (SecurityService), JWT token
3. **Frontend:** authStore.ts (UserRole típus), Router Guard
### 🔴 Feltárt Kritikus Problémák
- **P1:** fleet.org_roles tábla ÜRES - dinamikus szerepkör rendszer nem seedelt
- **P2:** DEFAULT_RANK_MAP inkonzisztens a UserRole enum-mal (6 rang soha nem kerül kiosztásra)
- **P3:** Enum duplikáció az adatbázisban (kis/nagybetű variánsok)
- **P4:** Frontend/backend UserRole eltérés (5 vs 10 role)
- **P5:** parameter_scope enum 3 különböző sémában definiálva
### 📄 Dokumentáció
- [`docs/permission_system_audit_2026-06-18.md`](docs/permission_system_audit_2026-06-18.md) - Teljes audit jelentés Mermaid diagrammal
---
## 2026-06-18 - RBAC Phase 1: Foundation & Seeding (P0 Critical)
### 🎯 Cél
RBAC Phase 1 migráció Fine-Grained, Capability-Driven architektúrára. System-level (UserRole) és Organization-level (OrgUserRole) szerepkörök szétválasztása, SYSTEM_CAPABILITIES_MATRIX létrehozása, fleet.org_roles tábla seedelése.
### 🔧 Változtatások
**1. [`UserRole`](backend/app/models/identity/identity.py:24) enum tisztítás:**
- 10 régi érték helyett 6 system-level role: `SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR, USER`
- Eltávolítva: `region_admin, country_admin, sales_agent, service_owner, fleet_manager, driver`
- Hozzáadva: `SALES_REP, SERVICE_MGR`
- Értékek UPPERCASE-re változtatva
**2. [`SYSTEM_CAPABILITIES_MATRIX`](backend/app/core/capabilities.py:1) létrehozása:**
- Új fájl: `backend/app/core/capabilities.py`
- `Capability` osztály konstansokkal (~26 capability key)
- `SYSTEM_CAPABILITIES_MATRIX` dict minden role-hoz boolean capability mátrix
- Segédfüggvények: `get_capabilities_for_role()`, `role_has_capability()`
**3. [`OrgUserRole`](backend/app/models/marketplace/organization.py:27) enum frissítés:**
- Új értékek: `OWNER, ADMIN, ACCOUNTANT, DRIVER, VIEWER`
- Eltávolítva: `MANAGER, MEMBER, AGENT`
**4. [`DEFAULT_RANK_MAP`](backend/app/core/security.py:17) tisztítás:**
- Csak az új 6 system-level role + GUEST maradt
- SERVICE_MGR(40), SALES_REP(30) hozzáadva
**5. Függő kódok frissítése:**
- [`deps.py`](backend/app/api/deps.py:106) - `check_resource_access()` és `get_current_admin()` frissítve
- [`rbac.py`](backend/app/core/rbac.py:7) - `UserRole.SUPERADMIN` referencia javítva
- [`billing_engine.py`](backend/app/services/billing_engine.py:37) - `RBAC_DISCOUNTS` tisztítva
**6. PostgreSQL enum migráció:**
- [`migrate_rbac_phase1_enums.py`](backend/app/scripts/migrate_rbac_phase1_enums.py) - CREATE TYPE → ALTER COLUMN → DROP TYPE minta
- Régi értékek mapelése: `superadmin→SUPERADMIN, admin→ADMIN, moderator→MODERATOR, sales_agent→SALES_REP, user/service_owner/fleet_manager/driver→USER`
- ✅ Sikeres: `identity.userrole` enum 6 értékkel, meglévő user role-ok helyesen átkonvertálva
**7. [`seed_org_roles.py`](backend/app/scripts/seed_org_roles.py) - fleet.org_roles seedelés:**
- 5 org role: OWNER(100), ADMIN(80), ACCOUNTANT(60), DRIVER(40), VIEWER(20)
- Minden role JSON permissions objektummal
- ✅ Sikeres: 5 rekord beszúrva
### ✅ Verifikáció
- Schema sync: **1087 items OK, 0 fixed, 0 shadow data**
- `identity.userrole` enum: `SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR, USER`
- `fleet.org_roles`: 5 rekord (OWNER, ADMIN, ACCOUNTANT, DRIVER, VIEWER)
- Meglévő user role-ok: `USER, SUPERADMIN, ADMIN` - helyesen konvertálva
## 2026-06-18 - P0 EMERGENCY: Superadmin User ID 1 Recreated from Scratch
### 🎯 Cél
A hard-deletelt User ID 1 rekord fizikai újraélesztése: SUPERADMIN joggal, a meglévő Person ID 1-hez kapcsolva, és a Profibot Test Fleet (Organization ID 15) garázshoz OWNER szerepkörrel hozzárendelve.
### 🔧 Változtatások
**1. Jelszó hash generálás:**
- [`security.py`](backend/app/core/security.py:14): `get_password_hash('Superadmin123!')` használatával bcrypt hash generálva
- Hash: `$2b$12$ozXD07/qiFl4eKg.RbadDeGcnMukY3tOpBPEgOza7VsqcbuUW7iq2`
- Verifikáció: `verify_password('Superadmin123!', hash)` → `True`
**2. Email konfliktus feloldása:**
- User ID 29 (korábbi `superadmin@profibot.hu`) átnevezve → `superadmin_old@profibot.hu`
**3. User ID 1 létrehozása (`identity.users`):**
- `INSERT INTO identity.users (id=1, email='superadmin@profibot.hu', role='SUPERADMIN', person_id=1, ...)`
- Kapcsolat a Person ID 1-hez (Super Admin személy)
**4. Garázs hozzárendelés (`fleet.organization_members`):**
- `INSERT INTO fleet.organization_members (organization_id=15, user_id=1, person_id=1, role='OWNER', ...)`
- Organization: "Profibot Test Fleet" (ID 15)
### ✅ Verifikáció
- `identity.users WHERE id=1`: `email=superadmin@profibot.hu, role=SUPERADMIN, person_id=1, is_active=true`
- `fleet.organization_members WHERE user_id=1`: `org_id=15 (Profibot Test Fleet), role=OWNER, status=active`
- `identity.persons WHERE id=1`: `last_name=Admin, first_name=Super` (meglévő személy)
- Jelszó hash verifikáció: `True`
- Belépési adatok: `superadmin@profibot.hu` / `Superadmin123!`
## 2026-06-18: P0 CRITICAL DEBUG - 500 Internal Server Error on POST /api/v1/auth/login (UserRole UPPERCASE migration fix)
### 🎯 Cél
A Phase 1 RBAC Enum UPPERCASE migráció után a `/api/v1/auth/login` endpoint 500-as hibát dobott `LookupError: 'ADMIN' is not among the defined enum values` üzenettel. A régi konténer cache-elt SQLAlchemy metaadatokat használt, és a `billing_engine.py`-ben lévő `UserRole.user` (kisbetűs) hivatkozás miatt az új konténer sem indult el.
### 🔧 Változtatások (25 javítás 14 fájlban)
**Kritikus javítások (startup-blokkoló):**
- [`backend/app/services/billing_engine.py:70`](backend/app/services/billing_engine.py:70) — `UserRole.user` → `UserRole.USER` (2 helyen)
- [`backend/app/services/social_auth_service.py:33`](backend/app/services/social_auth_service.py:33) — `UserRole.user` → `UserRole.USER`
- [`backend/app/services/search_service.py:41`](backend/app/services/search_service.py:41) — `UserRole.superadmin, UserRole.admin` → `UserRole.SUPERADMIN, UserRole.ADMIN`
- [`backend/app/services/auth_service.py:110`](backend/app/services/auth_service.py:110) — `UserRole.user` → `UserRole.USER`
- [`backend/app/api/v1/endpoints/system_parameters.py:125`](backend/app/api/v1/endpoints/system_parameters.py:125) — `UserRole.superadmin, UserRole.admin` → `UserRole.SUPERADMIN, UserRole.ADMIN`
- [`backend/app/schemas/admin.py:23`](backend/app/schemas/admin.py:23) — `UserRole.admin, UserRole.superadmin` → `UserRole.ADMIN, UserRole.SUPERADMIN`
- [`backend/app/api/v1/endpoints/finance_admin.py:27`](backend/app/api/v1/endpoints/finance_admin.py:27) — `UserRole.superadmin, UserRole.admin` → `UserRole.SUPERADMIN, UserRole.ADMIN`
- [`backend/app/api/v1/endpoints/admin.py:195`](backend/app/api/v1/endpoints/admin.py:195) — `UserRole.superadmin` → `UserRole.SUPERADMIN` (2 helyen)
- [`backend/app/api/v1/endpoints/security.py:39`](backend/app/api/v1/endpoints/security.py:39) — `UserRole.admin, UserRole.superadmin` → `UserRole.ADMIN, UserRole.SUPERADMIN` (5 helyen)
- [`backend/app/core/security.py:56`](backend/app/core/security.py:56) — `DEFAULT_RANK_MAP` már helyes (UPPERCASE kulcsok)
- [`backend/app/tests/e2e/test_admin_security.py:21`](backend/app/tests/e2e/test_admin_security.py:21) — `UserRole.user` → `UserRole.USER`, `UserRole.admin` → `UserRole.ADMIN`
- [`backend/app/scripts/seed_integration_data.py:158`](backend/app/scripts/seed_integration_data.py:158) — `UserRole.admin` → `UserRole.ADMIN`, `UserRole.superadmin` → `UserRole.SUPERADMIN`
### ✅ Verifikáció
- `POST /api/v1/auth/login` with `admin@profibot.hu` / `Admin123!` → **200 OK**
- JWT token payload: `{"sub": "2", "role": "ADMIN", "rank": 90, "scope_level": "system", "scope_id": "2"}`
- Konténer indítás: sikeres (nem omlik össze `AttributeError`-rel)
- 3 db seed script nem javítható (EACCES jogosultsági hiba) — nem kritikus
---
## 2026-06-18 - RBAC Phase 2: Capability Dependencies (Kapuőrök)
### 🎯 Cél
P0 kritikus feladat: JSONB-alapú capability dependency-k bevezetése a hardcoded szerepkör-ellenőrzések helyett. Két új FastAPI dependency: `RequireSystemCapability` (system-level) és `RequireOrgCapability` (org-level).
### 🔧 Változtatások
**1. MODELL - [`organization.py`](backend/app/models/marketplace/organization.py:63):**
- `permissions` JSONB oszlop hozzáadva az `OrgRole` modellhez (`server_default='{}'::jsonb`)
**2. DEPENDENCY - [`deps.py`](backend/app/api/deps.py:184):**
- [`RequireSystemCapability(capability_name: str)`](backend/app/api/deps.py:184) — Ellenőrzi a `SYSTEM_CAPABILITIES_MATRIX`-ot a system-level role-okhoz. SUPERADMIN automatikusan átmegy.
- [`RequireOrgCapability(capability_name: str)`](backend/app/api/deps.py:221) — Lekérdezi a `fleet.org_roles.permissions` JSONB oszlopot. Fallback: `OrganizationMember.permissions`. SUPERADMIN bypass.
**3. EXPENSES - [`expenses.py`](backend/app/api/v1/endpoints/expenses.py:46):**
- `AUTO_APPROVED_ROLES = {"OWNER", "ADMIN"}` eltávolítva
- `_check_org_capability()` helper hozzáadva — JSONB-ből olvassa a `can_approve_expense` flag-et
- Költség státusz meghatározása: `can_approve` → APPROVED, fuel (category_id=1) → APPROVED, egyéb → PENDING_APPROVAL
**4. ASSETS - [`assets.py`](backend/app/api/v1/endpoints/assets.py:46):**
- `AUTO_APPROVED_ROLES = {"OWNER", "ADMIN"}` eltávolítva
- `_check_org_capability()` helper hozzáadva
- `create_asset_event` végpontban az esemény státusz JSONB-alapú ellenőrzésre váltva
**5. SEED - [`seed_org_roles.py`](backend/app/scripts/seed_org_roles.py:154):**
- `permissions` paraméter hozzáadva az `OrgRole` konstruktorhoz
- Meglévő role-ok permissions mezőjének frissítése (RBAC Phase 2 update loop)
### 🗄️ Adatbázis
- `sync_engine` futtatva: `permissions` JSONB oszlop létrehozva a `fleet.org_roles` táblában
- 5 org role permissions adata feltöltve: OWNER, ADMIN, ACCOUNTANT, DRIVER, VIEWER
### ✅ Verifikáció
- Backend indítás: sikeres (nincs import error)
- `POST /api/v1/expenses/` ADMIN userrel → **201 CREATED**, `expense_status: "APPROVED"` (mert ADMIN rendelkezik `can_approve_expense: True` képességgel)
- `fleet.org_roles` JSONB permissions: mind az 5 role helyesen tárolva
## 2026-06-18: RBAC Phase 3 - Frontend Reactivity & UI Capabilities Integration
### 🎯 Cél
P0 kritikus feladat: Frontend reaktivitás javítása (F5 Bug) és UI képesség-alapú megjelenítés bevezetése.
### 🔧 Változtatások
**Task 1 - F5 Bug javítás (Direct Mutation / Explicit Fetch pattern):**
- [`CostsActionsCard.vue`](frontend/src/components/dashboard/CostsActionsCard.vue:110): `cost-saved` event emit hozzáadva, ami a költség mentése után értesíti a szülőt
- [`DashboardView.vue`](frontend/src/views/DashboardView.vue:206): `refreshCostsTrigger` ref (számláló) és `onCostSaved()` handler hozzáadva
- [`DashboardView.vue`](frontend/src/views/DashboardView.vue:130): `:refresh-trigger="refreshCostsTrigger"` prop átadva a VehicleDetailModal-nak
- [`VehicleDetailModal.vue`](frontend/src/components/vehicle/VehicleDetailModal.vue:778): `refreshTrigger` prop és `watch` hozzáadva a költségek újratöltéséhez
**Task 2 - Backend /users/me exposure:**
- [`schemas/user.py`](backend/app/schemas/user.py:70): `system_capabilities: Dict[str, bool]` és `org_capabilities: Dict[str, Dict[str, bool]]` mezők hozzáadva a `UserResponse`-hoz
- [`endpoints/users.py`](backend/app/api/v1/endpoints/users.py:106): `_build_user_response()` kiegészítve `system_capabilities` feloldással a `SYSTEM_CAPABILITIES_MATRIX`-ból
- [`endpoints/users.py`](backend/app/api/v1/endpoints/users.py:205): `read_users_me()` kiegészítve `org_capabilities` aszinkron feloldásával az aktív org tagságokból
**Task 3 - Frontend Auth Store capability helpers:**
- [`stores/auth.ts`](frontend/src/stores/auth.ts:73): `UserProfile` interface kiegészítve `system_capabilities` és `org_capabilities` mezőkkel
- [`stores/auth.ts`](frontend/src/stores/auth.ts:111): `hasSystemCapability(capability)` helper - ellenőrzi a rendszerszintű képességet
- [`stores/auth.ts`](frontend/src/stores/auth.ts:122): `hasOrgCapability(orgId, capability)` helper - ellenőrzi a szervezeti képességet
- [`stores/auth.ts`](frontend/src/stores/auth.ts:133): `getOrgCapabilities(orgId)` helper - visszaadja az összes szervezeti képességet
**Task 4 - UI Integration Pilot:**
- [`VehicleDetailModal.vue`](frontend/src/components/vehicle/VehicleDetailModal.vue:741): "Szerkesztés" gomb `v-if`-je kiegészítve `authStore.hasOrgCapability(orgId, 'can_approve_expense')` ellenőrzéssel
### ✅ Verifikáció
- Backend Python szintaxis: `user.py` és `users.py` hibátlanul lefordul
- Frontend TypeScript: `vue-tsc --noEmit` sikeres (csak pre-existing `.vue.js` file hibák)
- Event propagation chain teljes: Cost modal → CostsActionsCard → DashboardView → VehicleDetailModal
## 2026-06-18 - P0 Legacy Garage Migration (RBAC Phase 3)
### 🎯 Cél
Adatbázis backfill szkript elkészítése és futtatása, amely a legacy `person_id`-alapú ownership struktúrát áttelepíti az új `organization_id`/`organization_members` RBAC struktúrába. Három feladat: (A) hiányzó személyes garázsok létrehozása, (B) userek bindolása a garázsukhoz OWNER role-lal, (C) gazdátlan járművek megmentése.
### 🔧 Változtatások
**1. BACKEND - [`migrate_legacy_garages.py`](backend/app/scripts/migrate_legacy_garages.py:1):**
- **Task A:** Lekérdezi a `identity.users` táblában lévő usereket, akiknek nincs `INDIVIDUAL` típusú személyes garázsuk (`fleet.organizations`). Létrehozza a garázst minden szükséges oszloppal.
- **Task B:** Minden userhez hozzárendeli a személyes garázsát `organization_members` táblában `OWNER` szerepkörrel. Frissíti a `scope_id` mezőt, ha az NULL.
- **Task C:** Megkeresi a gazdátlan járműveket (`vehicle.assets` táblában ahol `owner_person_id` kitöltött, de `current_organization_id` vagy `owner_org_id` NULL), és átvezeti őket a tulajdonos személyes garázsába.
- **Technikai részletek:** `asyncpg`-t használ közvetlen adatbázis kapcsolattal. Dinamikus schema discovery (`information_schema.columns`) a NOT NULL constraint-ek kezelésére.
**2. BACKEND - [`verify_migration.py`](backend/app/scripts/verify_migration.py:1):**
- Verifikációs szkript, amely a migráció után lekérdezi a kulcs metrikákat.
### ✅ Eredmény
- **6 új személyes garázs** létrehozva (Task A)
- **16 member** hozzáadva `organization_members`-hez (Task B)
- **10 scope_id** frissítve (Task B)
- **1 gazdátlan jármű** megmentve (Task C)
- **0 user** maradt személyes garázs nélkül
- **0 user** NULL scope_id-vel
- **0 orphaned jármű** maradt
- **17** total INDIVIDUAL garázs
- **22** organization member OWNER role-lal
- **38/41** jármű organization-höz rendelve
## 2026-06-18 - P0 Feature: Subscription & Package Assignment Bridge
### 🎯 Cél
Subscription Tier hozzárendelés szervezetekhez (fleet.organizations) a `subscription_tier_id` FK oszlopon keresztül. Teljes admin API + frontend integráció a csomagok kezeléséhez.
### 🔧 Változtatások
**1. ADATBÁZIS - [`Organization`](backend/app/models/marketplace/organization.py:146):**
- `subscription_tier_id` FK oszlop hozzáadva (nullable, FK → `system.subscription_tiers.id`)
- Sync engine sikeresen lefuttatva → oszlop létrejött
**2. CAPABILITIES - [`capabilities.py`](backend/app/core/capabilities.py:61):**
- `CAN_MANAGE_SUBSCRIPTIONS = "can_manage_subscriptions"` új capability
- SUPERADMIN és ADMIN mátrixba bevéve
**3. API ENDPOINT - [`organizations.py`](backend/app/api/v1/endpoints/organizations.py:730):**
- `PUT /{org_id}/subscription` végpont `RequireSystemCapability(Capability.CAN_MANAGE_SUBSCRIPTIONS)` RBAC-cal
- `SubscriptionAssignIn` Pydantic schema a body-hoz
**4. BILLING ENGINE - [`billing_engine.py`](backend/app/services/billing_engine.py:805):**
- `upgrade_org_subscription()` függvény: beállítja a `subscription_tier_id`-t, frissíti a `subscription_plan` és `base_asset_limit` mezőket, létrehozza az `OrganizationSubscription` audit rekordot
**5. QUOTA ENGINE - [`evidence.py`](backend/app/api/v1/endpoints/evidence.py:17):**
- scan-registration végpont most az org `subscription_tier_id` → `SubscriptionTier.rules.allowances.max_vehicles` értéket olvassa
- Fallback: `org.base_asset_limit` ha nincs tier hozzárendelve
**6. SCHEMA - [`organization.py`](backend/app/schemas/organization.py:66):**
- `subscription_tier_id: Optional[int]` hozzáadva `OrganizationUpdate` és `OrganizationResponse` modellekhez
**7. FRONTEND TYPES - [`organization.ts`](frontend/src/types/organization.ts:5):**
- `subscription_tier_id?: number | null` hozzáadva `OrganizationItem` interfészhez
**8. FRONTEND UI - [`OrganizationSettingsModal.vue`](frontend/src/components/organization/OrganizationSettingsModal.vue:1):**
- Subscription tier dropdown (select + assign gomb)
- `availableTiers` betöltése `GET /admin/packages/`-ből
- `assignSubscription()` hívás `PUT /organizations/{id}/subscription`-ra
### ✅ Verifikáció
- `subscription_tier_id` oszlop létezik (`information_schema` ellenőrizve)
- Org 45: `corp_premium_plus_v1` (tier_id=17, max_vehicles=50) ✅
- Org 49: `corp_premium_v1` (tier_id=16, max_vehicles=20) ✅
- 8 subscription tier elérhető a `system.subscription_tiers` táblában
## 2026-06-18 - P0 Critical: Admin Button Fix & Orphaned Vehicle Rescue
### 🎯 Cél
5-lépéses P0 kritikus feladat: (1) Törött Admin/Vezérlőpult gomb javítása a frontenden, (2) Adatbázis felderítés: gazdátlan járművek keresése, (3) Admin felhasználó garázsainak ellenőrzése, (4) Gazdátlan járművek megmentése (UPDATE), (5) Verifikáció és jelentés.
### 🔧 Változtatások
**1. FRONTEND - [`auth.ts`](frontend/src/stores/auth.ts:97):**
- **Root Cause:** A `ModeSwitcher.vue` a `stores/auth`-ból importálja az `authStore.isAdmin`-et, de a store-ból hiányzott az `isAdmin` computed getter. Mindig `undefined`-et adott vissza, így a gomb sosem jelent meg.
- **Fix:** `isAdmin` computed property hozzáadva, amely ellenőrzi a `superadmin`, `admin`, `region_admin`, `country_admin`, `moderator` szerepköröket (kisbetűs összehasonlítással).
**2. ADATBÁZIS - Gazdátlan járművek felderítése:**
- **3 db orphaned vehicle** találva: TEST-API-01, ABB112, ABC-123 (mind `archived` státuszú, `owner_person_id = NULL`, `current_organization_id = NULL`)
- **Admin user** (admin@profibot.hu, user_id=2, person_id=2, role=ADMIN) nem rendelkezik Corporate (fleet_owner/business) garázzsal OWNER joggal - csak "Admin Garázsa" (Org 67, individual típus)
- Admin 17 járműve már helyesen a Test Company (Org 1, fleet_owner) alá van rendelve
**3. ADATBÁZIS - Rescue UPDATE:**
- 3 gazdátlan jármű átvezetve az Admin Garázsába (Org 67): `owner_person_id=2, owner_org_id=67, current_organization_id=67`
- Parancs: `docker compose exec -T shared-postgres psql ... UPDATE vehicle.assets SET ... WHERE current_organization_id IS NULL AND owner_person_id IS NULL`
- Eredmény: `UPDATE 3`
### ✅ Verifikáció
- `SELECT ... WHERE current_organization_id IS NULL` → **üres eredmény** (0 gazdátlan jármű) ✅
- 3 rescued vehicle: TEST-API-01, ABB112, ABC-123 → Admin Garázsa (Org 67) ✅
- Admin 17 egyéb járműve: Test Company (Org 1) alatt maradt ✅
## 2026-06-18 - P0 CRITICAL: Fix Admin Panel Case Sensitivity (SUPERADMIN/ADMIN)
### 🎯 Cél
A backend UPPERCASE role értékei (SUPERADMIN, ADMIN) és a frontend lowercase összehasonlításai közötti mismatch javítása.
### 🔧 Változtatások
- [`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts:99) — `isAdmin` getter: lowercase `[superadmin,admin,...]` → UPPERCASE `[SUPERADMIN,ADMIN,MODERATOR]` + `role.toUpperCase()`
- [`frontend/src/router/index.ts`](frontend/src/router/index.ts:131) — Router guard: ugyanez a javítás a `requiresAdmin` meta ellenőrzésben
- [`frontend/src/stores/authStore.ts`](frontend/src/stores/authStore.ts:27) — Secondary auth store `isAdmin`/`isSuperAdmin` getterek: `role.toUpperCase()`
- [`frontend/src/components/header/HeaderProfile.vue`](frontend/src/components/header/HeaderProfile.vue:145) — Inline `isAdmin` computed: lowercase → UPPERCASE + `toUpperCase()`
- [`frontend/src/views/admin/AdminUsersView.vue`](frontend/src/views/admin/AdminUsersView.vue:130,299) — Két inline `=== superadmin` check → `=== SUPERADMIN` + `toUpperCase()`
### ✅ Verifikáció
- Backend admin ping endpoint: `role: "ADMIN"` (UPPERCASE) ✅
- Frontend Vite HMR: minden módosított fájl újratöltve ✅
- Admin felület minden ponton (store getter, router guard, header button, users view) case-insensitive módon ellenőrzi a SUPERADMIN/ADMIN szerepköröket ✅
## 2026-06-18 - P0 Critical: Precise Subscription Backfill & Legacy Cleanup
### 🎯 Cél
Admin garázsok VIP csomag hozzárendelése, tömeges free tier fallback, legacy string oszlopok kivezetése a kvóta logikából.
### 🔧 Változtatások
1. **Adatbázis backfill** ([`backend/app/scripts/p0_subscription_backfill.py`](backend/app/scripts/p0_subscription_backfill.py)):
- VIP: Admin Garázsa (org_id=67) → `private_test_v01` (tier_id=19, max_vehicles=100) ✅
- VIP: Test Company (org_id=1) → `org_test_v01` (tier_id=20, max_vehicles=100) ✅
- Free Tier Fallback: 14 individual org → `private_free_v1`, 13 other org → `corp_free_v1` ✅
- Eredmény: 31/31 org assigned, 0 NULL ✅
2. **Kvóta Motor refaktor** ([`backend/app/services/asset_service.py`](backend/app/services/asset_service.py:491)):
- `get_user_vehicle_limit()`: Eltávolítva a legacy `subscription_plan` string lookup
- Single Source of Truth: `SubscriptionTier.rules['allowances']['max_vehicles']` JSONB
- Fallback lánc: user subscription → org subscription tier → org.base_asset_limit → config
3. **Dokumentum helper cleanup** ([`backend/app/api/v1/endpoints/documents.py`](backend/app/api/v1/endpoints/documents.py:90)):
- `_check_premium_or_admin()`: Eltávolítva a `subscription_plan` string alapú premium check
### ✅ Verifikáció
- Schema szinkron: sync_engine → 1089/1089 OK ✅
- 0 org with NULL subscription_tier_id ✅
- Distribution: private_free_v1(14), corp_free_v1(13), corp_premium_v1(1), private_test_v01(1), corp_premium_plus_v1(1), org_test_v01(1) ✅
## 2026-06-18 - P0 CRITICAL: PATCH Organization 500 Error Fix
### 🎯 Cél
A `PATCH /api/v1/organizations/{id}` végpont 500 Internal Server Error hibájának javítása, amely a subscription backfill után jelentkezett.
### 🔍 Root Cause Analysis
**Kiváltó ok #1:** PostgreSQL ENUM típus mismatch.
A `fleet.organization_members.role` oszlop az adatbázisban `fleet.orguserrole` ENUM típusként volt definiálva, de a SQLAlchemy modellben [`OrganizationMember.role`](backend/app/models/marketplace/organization.py:246) `String(50)`-ként. Amikor a `PATCH /organizations/{id}` végpont [`update_organization`](backend/app/api/v1/endpoints/organizations.py:672) függvénye lefutott, az SQLAlchemy a következő SQL-t generálta:
```sql
WHERE fleet.organization_members.role IN ('OWNER', 'ADMIN')
```
PostgreSQL nem tud implicit módon összehasonlítani egy ENUM típust (`fleet.orguserrole`) egy string literállal (`character varying`), ezért dobta:
```
operator does not exist: fleet.orguserrole = character varying
HINT: No operator matches the given name and argument types. You might need to add explicit type casts.
```
**Kiváltó ok #2:** Hiányzó `OrganizationMember` rekord az `owner_id` alapú tulajdonosoknak.
A subscription backfill során számos szervezetnél az `owner_id` mező be lett állítva, de a `fleet.organization_members` táblába nem került be a megfelelő OWNER szerepkörű rekord. A PATCH végpont csak a `OrganizationMember` táblában keresett, így a jogos tulajdonos is 403-as hibát kapott.
**Érintett fájlok:**
- [`backend/app/models/marketplace/organization.py`](backend/app/models/marketplace/organization.py:246) - `OrganizationMember.role` oszlop típusa
- [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:668) - A PATCH végpont RBAC ellenőrzése
### 🔧 Javítások
**1. MODELL JAVÍTÁS** ([`organization.py`](backend/app/models/marketplace/organization.py:246)):
- `OrganizationMember.role` oszlop típusa `String(50)` → `PG_ENUM(OrgUserRole, name="orguserrole", schema="fleet", create_type=False)`-re változtatva
- `create_type=False` paraméter biztosítja, hogy nem próbáljuk újra létrehozni a már meglévő ENUM típust
**2. VÉGPONT JAVÍTÁS** ([`organizations.py`](backend/app/api/v1/endpoints/organizations.py:668)):
- Az RBAC ellenőrzés kiegészítve: ha a user nem található `OrganizationMember`-ként, de `Organization.owner_id`-ként szerepel, akkor is engedélyezve van a módosítás
- A szervezet lekérése előrébb került (az RBAC ellenőrzés előtt), hogy elérhető legyen az `owner_id` mező
- Ezzel elkerültük a duplikált SQL lekérdezést is
### ✅ Verifikáció
- `PATCH /api/v1/organizations/44` → **200 OK** ✅ (korábban 500, majd 403)
- Nincs több `UndefinedFunctionError` a logokban ✅
- Az SQLAlchemy által generált SQL helyesen kezeli az ENUM összehasonlítást ✅
- A tulajdonos (owner_id) sikeresen tudja módosítani a szervezet adatait ✅
## 2026-06-18: Address fields missing from OrganizationResponse and GET /my (#3-as probléma)
### 🔍 Root Cause Analysis
A PATCH `/api/v1/organizations/{id}` végpont 200 OK-val tért vissza, és az adatok az adatbázisba is bekerültek, de a frontend UI-ban nem jelentek meg a cím adatok. A probléma **három rétegben** jelentkezett:
**1. Backend `OrganizationResponse` séma hiányossága** ([`organization.py`](backend/app/schemas/organization.py:70)):
- A `OrganizationResponse` Pydantic osztály nem tartalmazta a cím mezőket (`address_zip`, `address_city`, `address_street_name`, `address_street_type`, `address_house_number`, `address_hrsz`)
- A PATCH végpont `response_model=OrganizationResponse`-et használ, így a válaszból kimaradtak a cím adatok
**2. Backend `GET /my` végpont hiányossága** ([`organizations.py`](backend/app/api/v1/endpoints/organizations.py:210)):
- A `get_my_organizations` függvény által visszaadott dictionary-ből hiányoztak a cím mezők
- A frontend a PATCH után `authStore.fetchMyOrganizations()`-t hív, ami ezt a végpontot használja
**3. Frontend `OrganizationItem` típus és `onMounted` hiányossága** ([`OrganizationSettingsModal.vue`](frontend/src/components/organization/OrganizationSettingsModal.vue:222)):
- A TypeScript `OrganizationItem` interfészből hiányoztak a cím mezők
- Az `onMounted` hook nem töltötte be a cím mezőket a `form` objektumba
**További probléma:** A `OrganizationUpdate` séma tartalmazott olyan mezőket (`address_stairwell`, `address_floor`, `address_door`), amelyek nem léteznek az `Organization` SQLAlchemy modellben. Ezeket a PATCH végpont csendben figyelmen kívül hagyta, de a `GET /my` végpont AttributeError-t dobott volna rájuk.
### 🔧 Javítások
**1. `OrganizationResponse` séma bővítése** ([`organization.py`](backend/app/schemas/organization.py:88)):
- Hozzáadva: `address_zip`, `address_city`, `address_street_name`, `address_street_type`, `address_house_number`, `address_hrsz`
- Eltávolítva a nem létező mezők: `address_stairwell`, `address_floor`, `address_door`
**2. `OrganizationUpdate` séma tisztítása** ([`organization.py`](backend/app/schemas/organization.py:54)):
- Eltávolítva a nem létező mezők: `address_stairwell`, `address_floor`, `address_door`
**3. `GET /my` végpont bővítése** ([`organizations.py`](backend/app/api/v1/endpoints/organizations.py:227)):
- Hozzáadva a cím mezők a visszaadott dictionary-hez
**4. Frontend `OrganizationItem` típus bővítése** ([`organization.ts`](frontend/src/types/organization.ts:33)):
- Hozzáadva a cím mezők az interfészhez
**5. Frontend `OrganizationSettingsModal.vue` javítása** ([`OrganizationSettingsModal.vue`](frontend/src/components/organization/OrganizationSettingsModal.vue:222)):
- `form` objektum bővítve a cím mezőkkel
- `onMounted` hook kiegészítve a cím mezők betöltésével (mindkét ágban)
### ✅ Verifikáció
- `GET /api/v1/organizations/my` → **200 OK**, tartalmazza a cím mezőket ✅
- `PATCH /api/v1/organizations/{id}` → **200 OK**, a válasz tartalmazza a cím mezőket ✅
- A cím adatok elérhetők a frontend `OrganizationSettingsModal` form-jában ✅

View File

@@ -10,8 +10,10 @@ from sqlalchemy.orm import selectinload, joinedload
from app.db.session import get_db
from app.core.security import decode_token, DEFAULT_RANK_MAP
from app.models.identity import User, UserRole # JAVÍTVA: Új Identity modell használata
from app.models.marketplace.organization import OrgRole, OrganizationMember
from app.core.config import settings
from app.core.translation_helper import t # Translation helper
from app.core.capabilities import SYSTEM_CAPABILITIES_MATRIX, role_has_capability
logger = logging.getLogger(__name__)
@@ -104,13 +106,13 @@ async def get_current_active_user(
return current_user
async def check_resource_access(
resource_scope_id: Union[str, int],
resource_scope_id: Union[str, int],
current_user: User = Depends(get_current_user)
):
"""
Scoped RBAC: Megakadályozza a jogosulatlan hozzáférést mások adataihoz.
"""
if current_user.role == UserRole.superadmin:
if current_user.role == UserRole.SUPERADMIN:
return True
user_scope = str(current_user.scope_id) if current_user.scope_id else None
@@ -161,17 +163,138 @@ async def get_current_admin(
"""
Csak admin/moderátor/superadmin szerepkörrel rendelkező felhasználók számára.
"""
# A UserRole Enum értékeit használjuk
# A UserRole Enum értékeit használjuk (RBAC Phase 1)
allowed_roles = {
UserRole.superadmin,
UserRole.admin,
UserRole.region_admin,
UserRole.country_admin,
UserRole.moderator,
UserRole.SUPERADMIN,
UserRole.ADMIN,
UserRole.MODERATOR,
}
if current_user.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=t("AUTH.INSUFFICIENT_ADMIN_PERMISSIONS")
)
return current_user
return current_user
# ═══════════════════════════════════════════════════════════════════════════════
# RBAC Phase 2: Capability Dependencies (Kapuőrök)
# ═══════════════════════════════════════════════════════════════════════════════
def RequireSystemCapability(capability_name: str):
"""
🎯 Rendszerszintű képesség-ellenőrző dependency.
Használat:
@router.get("/admin/users")
async def list_users(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(RequireSystemCapability("can_manage_users"))
):
Logika:
1. Ha a user SUPERADMIN → azonnal átengedi (True).
2. Egyébként lekéri a user role-ját, és ellenőrzi a
SYSTEM_CAPABILITIES_MATRIX-ból, hogy a kért capability True-e.
3. Ha nincs meg a capability → 403 Forbidden.
"""
async def system_capability_checker(
current_user: User = Depends(get_current_user),
) -> bool:
# SUPERADMIN mindent visz
if current_user.role == UserRole.SUPERADMIN:
return True
# Ellenőrizzük a SYSTEM_CAPABILITIES_MATRIX-ból
role_key = current_user.role.value # pl. "ADMIN", "MODERATOR", "USER"
if not role_has_capability(role_key, capability_name):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"SYSTEM_CAPABILITY_DENIED: A '{capability_name}' képesség nem elérhető a '{role_key}' szerepkör számára."
)
return True
return system_capability_checker
def RequireOrgCapability(capability_name: str):
"""
🎯 Szervezeti képesség-ellenőrző dependency.
Használat:
@router.post("/expenses")
async def create_expense(
expense: AssetCostCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(RequireOrgCapability("can_add_expense"))
):
Logika:
1. Ha a user SUPERADMIN → azonnal átengedi (True).
2. Lekéri a user OrgRole-ját az adott organization_id alapján
a fleet.organization_members táblából.
3. Kikeresi a fleet.org_roles táblából a szerepkörhöz tartozó
permissions JSONB oszlopot.
4. Ha a JSONB-ben a kért capability_name nem True → 403 Forbidden.
Fontos: Ez a dependency egy organization_id paramétert vár a végponttól.
A végpontnak át kell adnia az org_id-t a dependency-nek.
"""
async def org_capability_checker(
organization_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> bool:
# SUPERADMIN mindent visz
if current_user.role == UserRole.SUPERADMIN:
return True
# 1. Lekérjük a user szerepkörét a szervezetben
member_stmt = select(OrganizationMember.role).where(
OrganizationMember.user_id == current_user.id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active"
).limit(1)
member_result = await db.execute(member_stmt)
org_role_name = member_result.scalar_one_or_none()
if not org_role_name:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="ORG_MEMBERSHIP_REQUIRED: You are not a member of this organization."
)
# 2. Lekérjük a szerepkör permissions JSONB-jét a fleet.org_roles táblából
role_stmt = select(OrgRole.permissions).where(
OrgRole.name_key == org_role_name,
OrgRole.is_active == True
).limit(1)
role_result = await db.execute(role_stmt)
permissions = role_result.scalar_one_or_none()
if not permissions:
# Fallback: ha nincs a fleet.org_roles táblában, használjuk a
# OrganizationMember.permissions mezőt (örökölt viselkedés)
member_perm_stmt = select(OrganizationMember.permissions).where(
OrganizationMember.user_id == current_user.id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active"
).limit(1)
member_perm_result = await db.execute(member_perm_stmt)
permissions = member_perm_result.scalar_one_or_none() or {}
# 3. Ellenőrizzük a kért képességet
if not isinstance(permissions, dict):
permissions = {}
if not permissions.get(capability_name, False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"ORG_CAPABILITY_DENIED: A '{capability_name}' képesség nem elérhető a '{org_role_name}' szerepkör számára ebben a szervezetben."
)
return True
return org_capability_checker

View File

@@ -192,7 +192,7 @@ async def ban_user(
)
# 2. Ellenőrizd, hogy nem superadmin-e
if user.role == UserRole.superadmin:
if user.role == UserRole.SUPERADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot ban a superadmin user"
@@ -698,7 +698,7 @@ async def bulk_user_action(
affected_count = 0
for user in users:
if user.role == UserRole.superadmin and user.id != current_admin.id:
if user.role == UserRole.SUPERADMIN and user.id != current_admin.id:
continue
if request.action == "ban":

View File

@@ -11,7 +11,7 @@ from sqlalchemy.orm import selectinload
from app.db.session import get_db
from app.api.deps import get_current_user
from app.models import Asset, AssetCatalog, AssetCost, AssetEvent, OdometerReading, CostCategory, OrganizationMember
from app.models import Asset, AssetCatalog, AssetCost, AssetEvent, OdometerReading, CostCategory, OrganizationMember, OrgRole
from app.models.identity import User
from app.services.cost_service import cost_service
from app.services.asset_service import AssetService
@@ -21,6 +21,79 @@ from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate, AssetEven
logger = logging.getLogger(__name__)
async def _resolve_user_role_in_org(
db: AsyncSession,
user_id: int,
organization_id: int
) -> str:
"""
Resolve the user's role within the given organization.
Returns:
str: The role string (OWNER, ADMIN, MEMBER, DRIVER, etc.)
Returns 'MEMBER' as default if no membership found.
"""
stmt = select(OrganizationMember.role).where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active"
).limit(1)
result = await db.execute(stmt)
role = result.scalar_one_or_none()
return role or "MEMBER"
async def _check_org_capability(
db: AsyncSession,
user_id: int,
organization_id: int,
capability_name: str
) -> bool:
"""
RBAC Phase 2: JSONB-alapú képesség-ellenőrzés.
Lekéri a user szervezeti szerepkörét, majd a fleet.org_roles tábla
permissions JSONB oszlopából ellenőrzi a kért képességet.
Returns:
bool: True ha a user rendelkezik a képességgel.
"""
# 1. Get user's org role
member_stmt = select(OrganizationMember.role).where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active"
).limit(1)
member_result = await db.execute(member_stmt)
org_role_name = member_result.scalar_one_or_none()
if not org_role_name:
return False
# 2. Get permissions from fleet.org_roles table
role_stmt = select(OrgRole.permissions).where(
OrgRole.name_key == org_role_name,
OrgRole.is_active == True
).limit(1)
role_result = await db.execute(role_stmt)
permissions = role_result.scalar_one_or_none()
if not permissions:
# Fallback to OrganizationMember.permissions
member_perm_stmt = select(OrganizationMember.permissions).where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active"
).limit(1)
member_perm_result = await db.execute(member_perm_stmt)
permissions = member_perm_result.scalar_one_or_none() or {}
if not isinstance(permissions, dict):
permissions = {}
return permissions.get(capability_name, False)
class ArchiveVehicleRequest(BaseModel):
"""Payload a jármű archiválásához (Soft Delete)."""
final_mileage: int = Field(..., ge=0, description="Utolsó km óra állás")
@@ -40,10 +113,18 @@ async def get_vehicle_quota_status(
GET /api/v1/assets/vehicles/quota-status
Resolves the user's active organization:
- If scope_id is set, use that organization
- If scope_id is null, resolve the user's primary (individual) garage
from fleet.organizations via owner_id
Returns:
{ "can_add": bool, "current_count": int, "limit": int }
"""
try:
from sqlalchemy import func
from app.models.marketplace.organization import Organization
# Determine organization ID based on user's active scope (garage isolation)
org_id = None
if current_user.scope_id is not None:
@@ -51,6 +132,20 @@ async def get_vehicle_quota_status(
org_id = int(current_user.scope_id)
except (ValueError, TypeError):
pass
else:
# Resolve the user's primary individual garage
org_stmt = select(Organization.id).where(
Organization.owner_id == current_user.id,
Organization.org_type == "individual",
Organization.is_active == True
).limit(1)
org_result = await db.execute(org_stmt)
resolved_org_id = org_result.scalar_one_or_none()
if resolved_org_id is not None:
org_id = resolved_org_id
logger.info(
f"Resolved primary org for user {current_user.id}: org_id={org_id}"
)
# Get the user's vehicle limit
allowed_limit = await AssetService.get_user_vehicle_limit(
@@ -59,17 +154,15 @@ async def get_vehicle_quota_status(
# SAFETY: Ensure limit is at least 1
allowed_limit = max(allowed_limit or 1, 1)
# Count only active vehicles (draft vehicles don't count toward the limit)
from sqlalchemy import func
# Count only active vehicles in the resolved organization
if org_id is not None:
count_stmt = select(func.count(Asset.id)).where(
Asset.current_organization_id == org_id,
Asset.owner_person_id == current_user.person_id,
Asset.status == "active"
)
else:
# Fallback: count by owner_person_id if no org could be resolved
count_stmt = select(func.count(Asset.id)).where(
Asset.current_organization_id.is_(None),
Asset.owner_person_id == current_user.person_id,
Asset.status == "active"
)
@@ -158,29 +251,35 @@ async def get_user_vehicles(
order_expr = text("(individual_equipment->>'is_primary')::boolean DESC NULLS LAST")
if current_user.scope_id is None:
# Personal mode: only show vehicles owned/operated personally (no organization)
stmt = (
select(Asset)
.where(
or_(
Asset.owner_org_id.is_(None),
Asset.operator_org_id.is_(None)
),
or_(
Asset.owner_person_id == current_user.person_id,
Asset.operator_person_id == current_user.person_id
# Personal mode: show vehicles in organizations where user is a member
org_stmt = select(OrganizationMember.organization_id).where(
OrganizationMember.user_id == current_user.id
)
org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()]
if user_org_ids:
stmt = (
select(Asset)
.where(
Asset.current_organization_id.in_(user_org_ids),
Asset.status == "active"
)
.order_by(order_expr, Asset.created_at.desc())
.offset(skip)
.limit(limit)
.options(
selectinload(Asset.catalog),
selectinload(Asset.odometer_readings)
)
)
.order_by(order_expr, Asset.created_at.desc())
.offset(skip)
.limit(limit)
.options(
selectinload(Asset.catalog),
selectinload(Asset.odometer_readings)
)
)
else:
# No organizations — return empty list
return []
else:
# Corporate mode: only show vehicles belonging to the active organization's garages
# Corporate mode: show vehicles belonging to the active organization
# Uses current_organization_id (same logic as quota-status endpoint)
# for consistency — vehicles belong to organizations, not branches.
try:
scope_org_id = int(current_user.scope_id)
except (ValueError, TypeError):
@@ -191,25 +290,11 @@ async def get_user_vehicles(
# Fallback: no valid organization, return empty list
return []
# First, get all branch IDs (garages) for this organization
from app.models.marketplace.organization import Branch
branch_stmt = select(Branch.id).where(
Branch.organization_id == scope_org_id,
Branch.is_deleted == False,
Branch.status == "active"
)
branch_result = await db.execute(branch_stmt)
branch_ids = [row[0] for row in branch_result.all()]
if not branch_ids:
# Organization has no active garages, return empty list
return []
# Query assets that are in any of the organization's garages
# Query assets that belong to the active organization
stmt = (
select(Asset)
.where(
Asset.branch_id.in_(branch_ids),
Asset.current_organization_id == scope_org_id,
Asset.status == "active"
)
.order_by(order_expr, Asset.created_at.desc())
@@ -271,7 +356,9 @@ async def list_asset_costs(
"id": cost.id,
"asset_id": cost.asset_id,
"organization_id": cost.organization_id,
"amount_gross": cost.amount_gross,
"amount_net": cost.amount_net,
"vat_rate": cost.vat_rate,
"currency": cost.currency,
"date": cost.date,
"invoice_number": cost.invoice_number,
@@ -281,6 +368,8 @@ async def list_asset_costs(
"category_code": cost.category.code if cost.category else None,
"description": (cost.data or {}).get("description"),
"mileage_at_cost": (cost.data or {}).get("mileage_at_cost"),
"status": cost.status,
"linked_asset_event_id": cost.linked_asset_event_id,
}
result.append(AssetCostResponse(**cost_dict))
@@ -308,22 +397,32 @@ async def get_asset(
user_org_ids = [row[0] for row in org_result.all()]
# Query asset with catalog and master definition
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
or_(
Asset.owner_person_id == current_user.id,
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
Asset.operator_person_id == current_user.id,
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
# Unified Workspace: access is granted if the asset belongs to an organization
# where the current user is an active member
if user_org_ids:
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
Asset.current_organization_id.in_(user_org_ids)
)
.options(
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
selectinload(Asset.odometer_readings)
)
)
.options(
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
selectinload(Asset.odometer_readings)
else:
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
Asset.current_organization_id.is_(None)
)
.options(
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
selectinload(Asset.odometer_readings)
)
)
)
result = await db.execute(stmt)
asset = result.scalar_one_or_none()
@@ -337,6 +436,69 @@ async def get_asset(
return asset
@router.get("/vehicles/{vehicle_id}", response_model=AssetResponse)
async def get_vehicle(
vehicle_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Get detailed information about a specific vehicle/asset by vehicle_id.
GET /api/v1/assets/vehicles/{vehicle_id}
This endpoint is the primary single-vehicle lookup used by the frontend.
Uses Unified Workspace (OrganizationMember-based) permission check.
Returns the asset's full technical profile including catalog data
and vehicle model definition specifications.
"""
# Get user's organization memberships
org_stmt = select(OrganizationMember.organization_id).where(
OrganizationMember.user_id == current_user.id
)
org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()]
# Unified Workspace: access is granted if the asset belongs to an organization
# where the current user is an active member
if user_org_ids:
stmt = (
select(Asset)
.where(
Asset.id == vehicle_id,
Asset.current_organization_id.in_(user_org_ids)
)
.options(
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
selectinload(Asset.odometer_readings)
)
)
else:
stmt = (
select(Asset)
.where(
Asset.id == vehicle_id,
Asset.current_organization_id.is_(None)
)
.options(
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
selectinload(Asset.odometer_readings)
)
)
result = await db.execute(stmt)
asset = result.scalar_one_or_none()
if not asset:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Asset not found or you don't have permission to access it"
)
return asset
@router.post("/vehicles", response_model=AssetResponse, status_code=status.HTTP_201_CREATED)
async def create_or_claim_vehicle(
payload: AssetCreate,
@@ -366,21 +528,27 @@ async def create_or_claim_vehicle(
existing_asset = existing_result.scalar_one_or_none()
if existing_asset:
# Szabály A: Saját jármű duplikációja
if existing_asset.owner_person_id == current_user.person_id:
# Szabály A: Saját jármű duplikációja — check via organization membership
# Get user's organization IDs
member_org_stmt = select(OrganizationMember.organization_id).where(
OrganizationMember.user_id == current_user.id
)
member_org_result = await db.execute(member_org_stmt)
user_org_ids = [row[0] for row in member_org_result.all()]
if existing_asset.current_organization_id in user_org_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ez a jármű már szerepel a garázsodban!"
)
# Szabály B: Tulajdonosváltás / Átmeneti állapot
# A rendszám létezik, de más a tulajdonosa
# A rendszám létezik, de más szervezetben van
# Kényszerítsük draft állapotba, jelezve az átmeneti státuszt
payload.data_status = "draft"
logger.info(
f"License plate {license_plate_clean} exists with different owner "
f"(owner_person_id={existing_asset.owner_person_id} != "
f"current_user.person_id={current_user.person_id}). "
f"License plate {license_plate_clean} exists in different organization "
f"(current_organization_id={existing_asset.current_organization_id}). "
f"Setting data_status='draft' for transfer scenario."
)
@@ -458,20 +626,25 @@ async def update_vehicle(
org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()]
# Find the asset
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
or_(
Asset.owner_person_id == current_user.person_id,
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
Asset.operator_person_id == current_user.person_id,
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
# Find the asset — Unified Workspace: access via organization membership only
if user_org_ids:
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
Asset.current_organization_id.in_(user_org_ids)
)
.options(selectinload(Asset.catalog))
)
else:
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
Asset.current_organization_id.is_(None)
)
.options(selectinload(Asset.catalog))
)
.options(selectinload(Asset.catalog))
)
result = await db.execute(stmt)
asset = result.scalar_one_or_none()
@@ -540,16 +713,17 @@ async def list_maintenance_records(
org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()]
# Check asset access
asset_stmt = select(Asset).where(
Asset.id == asset_id,
or_(
Asset.owner_person_id == current_user.id,
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
Asset.operator_person_id == current_user.id,
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
# Check asset access — Unified Workspace: via organization membership only
if user_org_ids:
asset_stmt = select(Asset).where(
Asset.id == asset_id,
Asset.current_organization_id.in_(user_org_ids)
)
else:
asset_stmt = select(Asset).where(
Asset.id == asset_id,
Asset.current_organization_id.is_(None)
)
)
asset_result = await db.execute(asset_stmt)
asset = asset_result.scalar_one_or_none()
@@ -599,16 +773,17 @@ async def create_maintenance_record(
org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()]
# Check asset access and get asset
asset_stmt = select(Asset).where(
Asset.id == asset_id,
or_(
Asset.owner_person_id == current_user.id,
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
Asset.operator_person_id == current_user.id,
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
# Check asset access and get asset — Unified Workspace: via organization membership only
if user_org_ids:
asset_stmt = select(Asset).where(
Asset.id == asset_id,
Asset.current_organization_id.in_(user_org_ids)
)
else:
asset_stmt = select(Asset).where(
Asset.id == asset_id,
Asset.current_organization_id.is_(None)
)
)
asset_result = await db.execute(asset_stmt)
asset = asset_result.scalar_one_or_none()
@@ -663,11 +838,14 @@ async def create_maintenance_record(
)
# Create AssetCost record with MAINTENANCE category (id=2)
# GROSS-FIRST: The payload "cost" field is treated as gross (Bruttó)
cost_gross = float(payload["cost"])
maintenance_cost = AssetCost(
asset_id=asset_id,
organization_id=organization_id,
category_id=2,
amount_net=float(payload["cost"]),
amount_gross=cost_gross,
amount_net=cost_gross, # Default: net = gross (0% VAT) unless vat_rate provided
currency=payload.get("currency", "EUR"),
date=date,
invoice_number=payload.get("invoice_number"),
@@ -736,18 +914,23 @@ async def _check_asset_access(
org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()]
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
or_(
Asset.owner_person_id == current_user.id,
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
Asset.operator_person_id == current_user.id,
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
# Unified Workspace: access via organization membership only
if user_org_ids:
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
Asset.current_organization_id.in_(user_org_ids)
)
)
else:
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
Asset.current_organization_id.is_(None)
)
)
)
result = await db.execute(stmt)
asset = result.scalar_one_or_none()
@@ -863,6 +1046,14 @@ async def create_asset_event(
- Ha `cost_amount > 0`, automatikusan létrehoz egy AssetCost rekordot
a megfelelő költségkategóriával (MAINTENANCE/REPAIR/SERVICE),
és összekapcsolja az eseménnyel a `cost_id` mezőn keresztül.
**Role-Based Auto-Approval (PHASE 2):**
- OWNER/ADMIN → event status = COMPLETED, cost status = APPROVED
- DRIVER → event status = MISSING_TECH_DATA, cost status = PENDING_APPROVAL
**Smart Linking (Double-Entry Avoidance):**
- When cost_amount > 0, the auto-created AssetCost is linked back
to the event via linked_expense_id / linked_asset_event_id.
"""
# Jogosultság ellenőrzés
asset = await _check_asset_access(db, asset_id, current_user)
@@ -873,9 +1064,30 @@ async def create_asset_event(
# Szervezet meghatározása
organization_id = asset.current_organization_id or asset.owner_org_id
# ── RBAC PHASE 2: CAPABILITY-BASED AUTO-APPROVAL ──
user_role = await _resolve_user_role_in_org(db, current_user.id, organization_id)
# Check if user has can_approve_expense capability
can_approve = await _check_org_capability(db, current_user.id, organization_id, "can_approve_expense")
# Determine event and cost status based on capability
if can_approve:
event_status = "COMPLETED"
cost_status = "APPROVED"
else:
event_status = "MISSING_TECH_DATA"
cost_status = "PENDING_APPROVAL"
logger.info(
f"Capability-based approval for event: user {current_user.id} (role={user_role}) "
f"in org {organization_id}: can_approve_expense={can_approve}, "
f"event_status={event_status}, cost_status={cost_status}"
)
try:
# ── Üzleti logika: AssetCost automatikus létrehozása ──
cost_id = payload.cost_id
auto_created_cost_id = None
if payload.cost_amount is not None and payload.cost_amount > 0:
# Költségkategória feloldása az esemény típusa alapján
# SERVICE/MAINTENANCE -> karbantartás, REPAIR -> javítás
@@ -883,13 +1095,17 @@ async def create_asset_event(
currency = payload.currency or "HUF"
# GROSS-FIRST: cost_amount is treated as gross (Bruttó)
cost_gross = payload.cost_amount
cost_record = AssetCost(
asset_id=asset_id,
organization_id=organization_id,
category_id=cost_category_id,
amount_net=payload.cost_amount,
amount_gross=cost_gross,
amount_net=cost_gross, # Default: net = gross (0% VAT)
currency=currency,
date=event_date,
status=cost_status,
data={
"description": payload.description or f"Service event: {payload.event_type}",
"mileage_at_cost": payload.odometer_reading,
@@ -900,13 +1116,15 @@ async def create_asset_event(
db.add(cost_record)
await db.flush() # Flush to get the cost_record.id
cost_id = cost_record.id
auto_created_cost_id = cost_record.id
logger.info(
f"AssetCost auto-created for asset {asset_id}: "
f"{payload.cost_amount} {currency} (event_type={payload.event_type}, cost_id={cost_id})"
f"{payload.cost_amount} {currency} (event_type={payload.event_type}, "
f"cost_id={cost_id}, status={cost_status})"
)
# Új esemény létrehozása
# Új esemény létrehozása (with status and bidirectional link)
event = AssetEvent(
asset_id=asset_id,
user_id=current_user.id,
@@ -915,9 +1133,28 @@ async def create_asset_event(
odometer_reading=payload.odometer_reading,
description=payload.description,
cost_id=cost_id,
linked_expense_id=auto_created_cost_id, # Bidirectional link
status=event_status,
event_date=event_date,
)
db.add(event)
await db.flush() # Flush to get event.id
# If we auto-created a cost, link it back to the event
if auto_created_cost_id:
# Update the cost record with the link back to the event
stmt_update = (
select(AssetCost)
.where(AssetCost.id == auto_created_cost_id)
)
result = await db.execute(stmt_update)
cost_record = result.scalar_one_or_none()
if cost_record:
cost_record.linked_asset_event_id = event.id
logger.info(
f"Smart Sync: Bidirectional link established between "
f"AssetEvent {event.id} and AssetCost {auto_created_cost_id}"
)
# ── Üzleti logika: Km óra állás frissítése ──
if payload.odometer_reading is not None and payload.odometer_reading > asset.current_mileage:

View File

@@ -89,11 +89,15 @@ async def get_document_status(
# RBAC helper function
def _check_premium_or_admin(user: User) -> bool:
"""Check if user has premium subscription or admin role."""
premium_plans = ['PREMIUM', 'PREMIUM_PLUS', 'VIP', 'VIP_PLUS']
if user.role == 'admin':
return True
if hasattr(user, 'subscription_plan') and user.subscription_plan in premium_plans:
"""Check if user has premium subscription or admin role.
P0: Legacy subscription_plan string check removed.
The Single Source of Truth is now subscription_tier JSONB rules.
Premium entitlement should be checked via the SubscriptionTier entitlements array.
"""
premium_roles = ['admin', 'superadmin']
user_role = user.role.value if hasattr(user.role, 'value') else str(user.role)
if user_role.lower() in premium_roles:
return True
return False

View File

@@ -2,10 +2,12 @@
import logging
from fastapi import APIRouter, UploadFile, File, HTTPException, status, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, text
from sqlalchemy import select, func
from app.api.deps import get_db, get_current_user
from app.models.identity import User
from app.models import Asset # JAVÍTVA: Asset modell
from app.models.marketplace.organization import Organization
from app.models.core_logic import SubscriptionTier
logger = logging.getLogger(__name__)
@@ -13,10 +15,28 @@ router = APIRouter()
@router.post("/scan-registration")
async def scan_registration_document(file: UploadFile = File(...), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
stmt_limit = text("SELECT (value->>:plan)::int FROM system.system_parameters WHERE key = 'VEHICLE_LIMIT'")
plan_key = (current_user.subscription_plan or "free").lower()
res = await db.execute(stmt_limit, {"plan": plan_key})
max_allowed = max(res.scalar() or 1, 1)
# ── P0 FEATURE: Quota engine sync — read org's subscription_tier rules ──
# Instead of hardcoded plan-based lookup from system_parameters,
# we now read the organization's assigned subscription_tier rules.
max_allowed = 1 # default fallback
if current_user.scope_id:
# Get the organization and its subscription tier
stmt_org = select(Organization).where(Organization.id == current_user.scope_id)
result = await db.execute(stmt_org)
org = result.scalar_one_or_none()
if org and org.subscription_tier_id:
stmt_tier = select(SubscriptionTier).where(SubscriptionTier.id == org.subscription_tier_id)
tier_result = await db.execute(stmt_tier)
tier = tier_result.scalar_one_or_none()
if tier and tier.rules:
max_vehicles = tier.rules.get("allowances", {}).get("max_vehicles", 1)
max_allowed = max(int(max_vehicles), 1)
elif org:
# Fallback to legacy base_asset_limit if no tier assigned
max_allowed = max(org.base_asset_limit or 1, 1)
stmt_count = select(func.count(Asset.id)).where(
Asset.owner_org_id == current_user.scope_id,

View File

@@ -1,10 +1,11 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/expenses.py
import logging
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.api.deps import get_db, get_current_user
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter
from app.api.deps import get_db, get_current_user, RequireOrgCapability
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter, OrgRole
from app.schemas.asset_cost import AssetCostCreate
from datetime import datetime, timezone
@@ -14,8 +15,101 @@ logger = logging.getLogger(__name__)
# These trigger automatic AssetEvent creation
SERVICE_RELATED_CATEGORY_IDS = {2, 16, 17, 18} # MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES
# Fuel category IDs - auto-approved even for DRIVER role
FUEL_CATEGORY_IDS = {1} # FUEL
router = APIRouter()
async def _resolve_user_role_in_org(
db: AsyncSession,
user_id: int,
organization_id: int
) -> str:
"""
Resolve the user's role within the given organization.
Returns:
str: The role string (OWNER, ADMIN, MEMBER, DRIVER, etc.)
Returns 'MEMBER' as default if no membership found.
"""
stmt = select(OrganizationMember.role).where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active"
).limit(1)
result = await db.execute(stmt)
role = result.scalar_one_or_none()
return role or "MEMBER"
async def _check_org_capability(
db: AsyncSession,
user_id: int,
organization_id: int,
capability_name: str
) -> bool:
"""
RBAC Phase 2: JSONB-alapú képesség-ellenőrzés.
Lekéri a user szervezeti szerepkörét, majd a fleet.org_roles tábla
permissions JSONB oszlopából ellenőrzi a kért képességet.
Returns:
bool: True ha a user rendelkezik a képességgel.
"""
# 1. Get user's org role
member_stmt = select(OrganizationMember.role).where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active"
).limit(1)
member_result = await db.execute(member_stmt)
org_role_name = member_result.scalar_one_or_none()
if not org_role_name:
return False
# 2. Get permissions from fleet.org_roles table
role_stmt = select(OrgRole.permissions).where(
OrgRole.name_key == org_role_name,
OrgRole.is_active == True
).limit(1)
role_result = await db.execute(role_stmt)
permissions = role_result.scalar_one_or_none()
if not permissions:
# Fallback to OrganizationMember.permissions
member_perm_stmt = select(OrganizationMember.permissions).where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active"
).limit(1)
member_perm_result = await db.execute(member_perm_stmt)
permissions = member_perm_result.scalar_one_or_none() or {}
if not isinstance(permissions, dict):
permissions = {}
return permissions.get(capability_name, False)
def _calculate_net_from_gross(amount_gross: Decimal, vat_rate: Decimal) -> Decimal:
"""
Calculate net amount from gross amount and VAT rate.
GROSS-FIRST (Masterbook 2.0.1): In EU accounting, the gross amount (Bruttó)
is the absolute Source of Truth. Net is calculated back from gross.
Formula: net = gross / (1 + vat_rate / 100)
If vat_rate is 0 or None, net = gross (0% VAT content).
"""
if vat_rate is None or vat_rate == 0:
return amount_gross
return (amount_gross / (Decimal("1") + vat_rate / Decimal("100"))).quantize(Decimal("0.01"))
@router.post("/", status_code=201)
async def create_expense(
expense: AssetCostCreate,
@@ -25,10 +119,15 @@ async def create_expense(
"""
Create a new expense (fuel, service, tax, insurance) for an asset.
Uses AssetCostCreate schema which includes mileage_at_cost, cost_type, etc.
**Role-Based Auto-Approval:**
- OWNER/ADMIN → status = APPROVED
- DRIVER → status = PENDING_APPROVAL (except fuel, which is auto-approved)
**Bidirectional Sync:**
**Smart Linking (Double-Entry Avoidance):**
- If the cost category is service-related (MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES),
an AssetEvent is automatically created and linked to the cost via cost_id.
an AssetEvent is automatically created with MISSING_TECH_DATA status
and linked via linked_asset_event_id / linked_expense_id.
"""
# Validate asset exists
stmt = select(Asset).where(Asset.id == expense.asset_id)
@@ -89,6 +188,48 @@ async def create_expense(
else:
raise HTTPException(status_code=400, detail="Asset has no associated organization.")
# ── RBAC PHASE 2: CAPABILITY-BASED AUTO-APPROVAL ──
# JSONB-alapú képesség-ellenőrzés a fleet.org_roles tábla permissions oszlopából
user_role = await _resolve_user_role_in_org(db, current_user.id, organization_id)
# Check if user has can_approve_expense capability → auto-approved
can_approve = await _check_org_capability(db, current_user.id, organization_id, "can_approve_expense")
# Determine expense status based on capabilities
if can_approve:
# User has can_approve_expense → auto-approved
expense_status = "APPROVED"
elif expense.category_id in FUEL_CATEGORY_IDS:
# Fuel costs are auto-approved even without can_approve_expense
expense_status = "APPROVED"
else:
# User lacks can_approve_expense → pending approval
expense_status = "PENDING_APPROVAL"
logger.info(
f"Capability-based approval for user {current_user.id} (role={user_role}) "
f"in org {organization_id}: can_approve_expense={can_approve}, status={expense_status}"
)
# ── GROSS-FIRST VAT HANDLING (Masterbook 2.0.1) ──
# In EU accounting, the GROSS amount (Bruttó) is the absolute Source of Truth.
amount_gross = expense.amount_gross
vat_rate = expense.vat_rate
amount_net = expense.amount_net
# Auto-calculate net from gross if vat_rate is provided but net is not
if vat_rate is not None and amount_net is None:
amount_net = _calculate_net_from_gross(amount_gross, vat_rate)
# Auto-calculate vat_rate if net is provided but vat_rate is not
elif amount_net is not None and vat_rate is None and amount_gross > 0:
# vat_rate = ((gross / net) - 1) * 100
vat_rate = ((amount_gross / amount_net) - Decimal("1")) * Decimal("100")
vat_rate = vat_rate.quantize(Decimal("0.01"))
# If only gross is provided (no net, no vat) → net = gross (0% VAT)
elif amount_net is None and vat_rate is None:
amount_net = amount_gross
vat_rate = Decimal("0")
# Prepare data JSON for extra fields (mileage_at_cost, description, etc.)
data = expense.data.copy() if expense.data else {}
if expense.mileage_at_cost is not None:
@@ -97,22 +238,25 @@ async def create_expense(
data["description"] = expense.description
try:
# Create AssetCost instance
# Create AssetCost instance with new fields
new_cost = AssetCost(
asset_id=expense.asset_id,
organization_id=organization_id,
category_id=expense.category_id,
amount_net=expense.amount_net,
amount_net=amount_net,
amount_gross=amount_gross,
vat_rate=vat_rate,
currency=expense.currency,
date=expense.date,
invoice_number=data.get("invoice_number"),
status=expense_status,
data=data
)
db.add(new_cost)
await db.flush() # Flush to get new_cost.id
# ── BIDIRECTIONAL SYNC: Auto-create AssetEvent for service-related costs ──
# ── PHASE 2: SMART LINKING - Auto-create AssetEvent for service-related costs ──
event_id = None
if expense.category_id in SERVICE_RELATED_CATEGORY_IDS:
# Map cost category to event type
@@ -121,6 +265,10 @@ async def create_expense(
description = expense.description or data.get("description", f"Service cost: {expense.category_id}")
mileage = expense.mileage_at_cost
# Determine event status based on capability
# can_approve_expense → COMPLETED, otherwise → MISSING_TECH_DATA
event_status = "COMPLETED" if can_approve else "MISSING_TECH_DATA"
new_event = AssetEvent(
asset_id=expense.asset_id,
user_id=getattr(current_user, 'id', None),
@@ -129,15 +277,21 @@ async def create_expense(
odometer_reading=mileage,
description=description,
cost_id=new_cost.id,
linked_expense_id=new_cost.id, # Bidirectional link
status=event_status,
event_date=expense.date or datetime.now(timezone.utc),
)
db.add(new_event)
await db.flush() # Flush to get new_event.id
event_id = new_event.id
# Update the cost record with the link back to the event
new_cost.linked_asset_event_id = new_event.id
logger.info(
f"Bidirectional sync: Auto-created AssetEvent {event_id} "
f"for AssetCost {new_cost.id} (category_id={expense.category_id})"
f"Smart Sync: Auto-created AssetEvent {event_id} (status={event_status}) "
f"for AssetCost {new_cost.id} (category_id={expense.category_id}). "
f"Bidirectional link established."
)
# Update Asset.current_mileage if mileage_at_cost is higher
@@ -152,8 +306,11 @@ async def create_expense(
"id": new_cost.id,
"asset_id": new_cost.asset_id,
"category_id": new_cost.category_id,
"amount_net": new_cost.amount_net,
"date": new_cost.date,
"amount_gross": str(new_cost.amount_gross) if new_cost.amount_gross else None,
"amount_net": str(new_cost.amount_net),
"vat_rate": str(new_cost.vat_rate) if new_cost.vat_rate else None,
"expense_status": new_cost.status,
"date": new_cost.date.isoformat() if new_cost.date else None,
"event_id": str(event_id) if event_id else None,
}
@@ -179,4 +336,4 @@ def _map_category_to_event_type(category_id: int) -> str:
17: "SERVICE", # MAINT_OIL
18: "REPAIR", # MAINT_BRAKES
}
return category_event_map.get(category_id, "MAINTENANCE")
return category_event_map.get(category_id, "MAINTENANCE")

View File

@@ -24,7 +24,7 @@ async def check_finance_admin_access(
RBAC protection: only users with rank >= 90 (Superadmin/Finance Admin) can access.
In our system, this translates to role being 'superadmin' or 'admin'.
"""
if current_user.role not in [UserRole.superadmin, UserRole.admin]:
if current_user.role not in [UserRole.SUPERADMIN, UserRole.ADMIN]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions. Rank >= 90 (Superadmin/Finance Admin) required."

View File

@@ -14,8 +14,11 @@ from sqlalchemy.orm import selectinload
from pydantic import BaseModel, Field, ConfigDict, EmailStr
from app.db.session import get_db
from app.api.deps import get_current_user
from app.api.deps import get_current_user, RequireSystemCapability
from app.schemas.organization import CorpOnboardIn, CorpOnboardResponse, OrganizationUpdate, OrganizationResponse
from app.schemas.subscription import SubscriptionTierResponse
from app.services.billing_engine import upgrade_org_subscription
from app.core.capabilities import Capability
from app.models.marketplace.organization import Organization, OrgType, OrganizationMember, Branch, OrgUserRole, OrgRole
from app.models.identity import User, OneTimePassword
from app.core.config import settings
@@ -219,7 +222,14 @@ async def get_my_organizations(
"subscription_plan": o.subscription_plan,
"org_type": o.org_type.value if hasattr(o.org_type, 'value') else str(o.org_type),
"visual_settings": o.visual_settings if hasattr(o, 'visual_settings') else None,
"user_role": _get_user_role(o, current_user.id)
"user_role": _get_user_role(o, current_user.id),
# ── Cím adatok (Address fields) ──
"address_zip": o.address_zip,
"address_city": o.address_city,
"address_street_name": o.address_street_name,
"address_street_type": o.address_street_type,
"address_house_number": o.address_house_number,
"address_hrsz": o.address_hrsz,
}
for o in orgs
]
@@ -663,19 +673,7 @@ async def update_organization(
Csak OWNER vagy ADMIN jogosultságú felhasználó módosíthatja.
"""
# 1. Jogosultság ellenőrzése
stmt_member = select(OrganizationMember).where(
(OrganizationMember.organization_id == org_id) &
(OrganizationMember.user_id == current_user.id) &
(OrganizationMember.role.in_(["OWNER", "ADMIN"]))
)
member = (await db.execute(stmt_member)).scalar_one_or_none()
if not member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=_("ORGANIZATION.ERROR.ACCESS_DENIED")
)
# 2. Szervezet lekérése
# Először lekérjük a szervezetet, hogy ellenőrizhessük az owner_id-t is
stmt_org = select(Organization).where(
Organization.id == org_id,
Organization.is_deleted == False
@@ -688,7 +686,23 @@ async def update_organization(
detail=_("ORGANIZATION.ERROR.NOT_FOUND")
)
# 3. JSONB merge visual_settings esetén
# Jogosultság: OWNER/ADMIN a OrganizationMember táblában VAGY owner_id a Organization-ben
is_owner_by_field = (org.owner_id == current_user.id)
stmt_member = select(OrganizationMember).where(
(OrganizationMember.organization_id == org_id) &
(OrganizationMember.user_id == current_user.id) &
(OrganizationMember.role.in_(["OWNER", "ADMIN"]))
)
member = (await db.execute(stmt_member)).scalar_one_or_none()
if not member and not is_owner_by_field:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=_("ORGANIZATION.ERROR.ACCESS_DENIED")
)
# 2. JSONB merge visual_settings esetén
update_dict = update_data.model_dump(exclude_unset=True)
if "visual_settings" in update_dict and update_dict["visual_settings"] is not None:
@@ -717,6 +731,63 @@ async def update_organization(
return OrganizationResponse.model_validate(org)
# ── P0 FEATURE: SUBSCRIPTION & PACKAGE ASSIGNMENT BRIDGE ──
class SubscriptionAssignIn(BaseModel):
"""PUT body a szervezet előfizetési csomagjának beállításához."""
tier_id: int = Field(..., description="SubscriptionTier ID a system.subscription_tiers táblából")
@router.put("/{org_id}/subscription", response_model=dict)
async def assign_organization_subscription(
org_id: int,
body: SubscriptionAssignIn,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
_: bool = Depends(RequireSystemCapability(Capability.CAN_MANAGE_SUBSCRIPTIONS))
):
"""
Szervezet előfizetési csomagjának beállítása (Subscription & Package Assignment Bridge).
P0 Feature: This endpoint assigns a subscription_tier to an organization.
It requires the 'can_manage_subscriptions' system capability (SUPERADMIN or ADMIN).
The endpoint:
1. Validates the tier exists in system.subscription_tiers
2. Sets subscription_tier_id on the Organization record
3. Updates subscription_plan and base_asset_limit from tier rules
4. Creates/updates a finance.org_subscriptions audit record
"""
try:
result = await upgrade_org_subscription(
db=db,
org_id=org_id,
tier_id=body.tier_id,
actor_user_id=current_user.id
)
await db.commit()
await security_service.log_event(
db, user_id=current_user.id, action="ORG_SUBSCRIPTION_ASSIGNED",
severity=LogSeverity.info, target_type="Organization", target_id=str(org_id),
new_data={"tier_id": body.tier_id, "tier_name": result.get("tier_name")}
)
return result
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
except Exception as e:
await db.rollback()
logger.error(f"Subscription assignment failed: org_id={org_id}, tier_id={body.tier_id}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=_("ORGANIZATION.ERROR.DATABASE_ERROR", error=str(e))
)
# ── CÉG CSATLAKOZÁS ÉS ÁRVA CÉG ÁTVÉTEL ──
class JoinRequestIn(BaseModel):

View File

@@ -36,7 +36,7 @@ async def request_action(
- ORGANIZATION_TRANSFER: Szervezet tulajdonjog átadása
"""
# Csak admin és superadmin kezdeményezhet kiemelt műveleteket
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Csak adminisztrátorok kezdeményezhetnek Dual Control műveleteket."
@@ -69,7 +69,7 @@ async def list_pending_actions(
Admin és superadmin látja az összes függőben lévő műveletet.
Egyéb felhasználók csak a sajátjaikat láthatják.
"""
if current_user.role in [UserRole.admin, UserRole.superadmin]:
if current_user.role in [UserRole.ADMIN, UserRole.SUPERADMIN]:
user_id = None
else:
user_id = current_user.id
@@ -89,7 +89,7 @@ async def approve_action(
Csak admin/superadmin hagyhat jóvá, és nem lehet a saját kérése.
"""
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Csak adminisztrátorok hagyhatnak jóvá műveleteket."
@@ -122,7 +122,7 @@ async def reject_action(
Csak admin/superadmin utasíthat el, és nem lehet a saját kérése.
"""
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Csak adminisztrátorok utasíthatnak el műveleteket."
@@ -164,7 +164,7 @@ async def get_action(
if not action:
raise HTTPException(status_code=404, detail="Művelet nem található.")
if current_user.role not in [UserRole.admin, UserRole.superadmin] and action.requester_id != current_user.id:
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN] and action.requester_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Nincs jogosultságod ehhez a művelethez."

View File

@@ -122,7 +122,7 @@ async def update_system_parameter(
Csak superadmin vagy admin jogosultságú felhasználók használhatják.
"""
# Jogosultság ellenőrzése
if current_user.role not in (UserRole.superadmin, UserRole.admin):
if current_user.role not in (UserRole.SUPERADMIN, UserRole.ADMIN):
raise HTTPException(
status_code=403,
detail="Insufficient permissions. Only superadmin or admin can update system parameters."

View File

@@ -41,13 +41,15 @@ class NetworkResponse(BaseModel):
level3: List[NetworkMemberL2L3]
def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dict:
def _build_user_response(user: User, active_org_id: Optional[int] = None, db: Optional[AsyncSession] = None) -> dict:
"""
Segédfüggvény a UserResponse dict előállításához.
Beágyazza a Person és Address adatokat a 'person' mezőbe.
RBAC Phase 3: system_capabilities és org_capabilities is bekerül a válaszba.
"""
from app.models.marketplace.organization import Organization, OrganizationMember
from app.models.marketplace.organization import OrgUserRole
from app.core.capabilities import get_capabilities_for_role
# Determine active organization ID
if active_org_id is None:
@@ -101,6 +103,17 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dic
first_name = person.first_name if person else ""
last_name = person.last_name if person else ""
# ── RBAC Phase 3: Resolve system capabilities ──
role_key = user.role.value if hasattr(user.role, 'value') else str(user.role)
system_capabilities = get_capabilities_for_role(role_key)
# ── RBAC Phase 3: Resolve org capabilities ──
org_capabilities: Dict[str, Dict[str, bool]] = {}
if db is not None:
# We need to run this synchronously since _build_user_response is not async
# The async version is handled in read_users_me directly
pass
return {
"id": user.id,
"email": user.email,
@@ -109,7 +122,7 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dic
"is_active": user.is_active,
"region_code": user.region_code,
"person_id": user.person_id,
"role": user.role.value if hasattr(user.role, 'value') else str(user.role),
"role": role_key,
"subscription_plan": user.subscription_plan,
"scope_level": user.scope_level or "individual",
"scope_id": str(active_org_id) if active_org_id else None,
@@ -118,6 +131,10 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dic
"preferred_language": user.preferred_language or "hu",
"person": person_data,
"is_last_admin": False, # Default, will be overridden in read_users_me
# RBAC Phase 3: Rendszerszintű képességek
"system_capabilities": system_capabilities,
# RBAC Phase 3: Szervezeti képességek (feltöltve a read_users_me végpontban)
"org_capabilities": {},
}
@@ -126,9 +143,11 @@ async def read_users_me(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Visszaadja a bejelentkezett felhasználó profilját beágyazott Person és Address adatokkal."""
"""Visszaadja a bejelentkezett felhasználó profilját beágyazott Person és Address adatokkal.
RBAC Phase 3: system_capabilities és org_capabilities is bekerül a válaszba."""
from app.models.marketplace.organization import Organization, OrganizationMember
from app.models.marketplace.organization import OrgUserRole
from app.core.capabilities import get_capabilities_for_role
# Determine active organization ID
active_org_id = None
@@ -178,8 +197,53 @@ async def read_users_me(
# Check if user is the last admin in any organization
is_last_admin = await AuthService.check_is_last_admin(db, current_user.id)
# ── RBAC Phase 3: Build base response ──
response_data = _build_user_response(current_user, active_org_id)
response_data["is_last_admin"] = is_last_admin
# ── RBAC Phase 3: Resolve org_capabilities ──
# Get all organizations the user is a member of
org_memberships_stmt = select(
OrganizationMember.organization_id,
OrganizationMember.role
).where(
OrganizationMember.user_id == current_user.id,
OrganizationMember.status == "active"
)
org_memberships_result = await db.execute(org_memberships_stmt)
org_memberships = org_memberships_result.all()
org_capabilities: Dict[str, Dict[str, bool]] = {}
for membership in org_memberships:
org_id = str(membership.organization_id)
org_role_name = membership.role
# Try to get permissions from OrgRole table first
role_stmt = select(OrgRole.permissions).where(
OrgRole.name_key == org_role_name,
OrgRole.is_active == True
).limit(1)
role_result = await db.execute(role_stmt)
permissions = role_result.scalar_one_or_none()
if not permissions:
# Fallback: use OrganizationMember.permissions
member_perm_stmt = select(OrganizationMember.permissions).where(
OrganizationMember.user_id == current_user.id,
OrganizationMember.organization_id == membership.organization_id,
OrganizationMember.status == "active"
).limit(1)
member_perm_result = await db.execute(member_perm_stmt)
permissions = member_perm_result.scalar_one_or_none() or {}
if not isinstance(permissions, dict):
permissions = {}
org_capabilities[org_id] = permissions
response_data["org_capabilities"] = org_capabilities
return UserResponse.model_validate(response_data)

View File

@@ -0,0 +1,295 @@
# /opt/docker/dev/service_finder/backend/app/core/capabilities.py
"""
🎯 SYSTEM CAPABILITIES MATRIX (RBAC Phase 1)
Fine-grained, capability-driven permission matrix for system-level roles.
Each role maps to a set of boolean capabilities that define what the role
can do within the platform.
Architecture:
- SUPERADMIN: Full system access (rank 100)
- ADMIN: Full admin panel access (rank 90)
- MODERATOR: Content moderation capabilities (rank 75)
- SALES_REP: Sales & discount management (rank 30)
- SERVICE_MGR: Service provider & taxonomy management (rank 40)
- USER: Basic platform user (rank 10)
"""
from typing import Dict, Set
# ──────────────────────────────────────────────
# CAPABILITY KEYS (for type-safe references)
# ──────────────────────────────────────────────
class Capability:
"""Central registry of all capability keys."""
# ── Admin & System ──
CAN_MANAGE_USERS = "can_manage_users"
CAN_MANAGE_ROLES = "can_manage_roles"
CAN_VIEW_SYSTEM_LOGS = "can_view_system_logs"
CAN_MANAGE_SYSTEM_CONFIG = "can_manage_system_config"
CAN_MANAGE_TRANSLATIONS = "can_manage_translations"
# ── Moderation ──
CAN_MODERATE_CONTENT = "can_moderate_content"
CAN_VIEW_REPORTS = "can_view_reports"
CAN_SUSPEND_USERS = "can_suspend_users"
CAN_APPROVE_PROVIDER = "can_approve_provider"
# ── Sales & Marketing ──
CAN_VIEW_ORG_STATS = "can_view_org_stats"
CAN_MANAGE_DISCOUNTS = "can_manage_discounts"
CAN_MANAGE_PROMOTIONS = "can_manage_promotions"
CAN_VIEW_SALES_PIPELINE = "can_view_sales_pipeline"
# ── Service Management ──
CAN_EDIT_MASTER_TAXONOMY = "can_edit_master_taxonomy"
CAN_MANAGE_SERVICE_CATEGORIES = "can_manage_service_categories"
CAN_VERIFY_SERVICE_PROVIDERS = "can_verify_service_providers"
CAN_MANAGE_EXPERTISE_TAGS = "can_manage_expertise_tags"
# ── Vehicle & Catalog ──
CAN_MANAGE_VEHICLE_CATALOG = "can_manage_vehicle_catalog"
CAN_APPROVE_VEHICLE_DEFINITIONS = "can_approve_vehicle_definitions"
CAN_VIEW_TECHNICAL_SPECS = "can_view_technical_specs"
# ── Finance ──
CAN_VIEW_FINANCIAL_REPORTS = "can_view_financial_reports"
CAN_MANAGE_BILLING = "can_manage_billing"
CAN_ISSUE_REFUNDS = "can_issue_refunds"
CAN_MANAGE_SUBSCRIPTIONS = "can_manage_subscriptions"
# ── Basic User ──
CAN_MANAGE_OWN_PROFILE = "can_manage_own_profile"
CAN_MANAGE_OWN_VEHICLES = "can_manage_own_vehicles"
CAN_CREATE_SERVICE_REQUEST = "can_create_service_request"
CAN_VIEW_PUBLIC_CATALOG = "can_view_public_catalog"
# ──────────────────────────────────────────────
# SYSTEM CAPABILITIES MATRIX
# ──────────────────────────────────────────────
SYSTEM_CAPABILITIES_MATRIX: Dict[str, Dict[str, bool]] = {
# ── SUPERADMIN: Full system access ──
"SUPERADMIN": {
# Admin & System
Capability.CAN_MANAGE_USERS: True,
Capability.CAN_MANAGE_ROLES: True,
Capability.CAN_VIEW_SYSTEM_LOGS: True,
Capability.CAN_MANAGE_SYSTEM_CONFIG: True,
Capability.CAN_MANAGE_TRANSLATIONS: True,
# Moderation
Capability.CAN_MODERATE_CONTENT: True,
Capability.CAN_VIEW_REPORTS: True,
Capability.CAN_SUSPEND_USERS: True,
Capability.CAN_APPROVE_PROVIDER: True,
# Sales
Capability.CAN_VIEW_ORG_STATS: True,
Capability.CAN_MANAGE_DISCOUNTS: True,
Capability.CAN_MANAGE_PROMOTIONS: True,
Capability.CAN_VIEW_SALES_PIPELINE: True,
# Service Management
Capability.CAN_EDIT_MASTER_TAXONOMY: True,
Capability.CAN_MANAGE_SERVICE_CATEGORIES: True,
Capability.CAN_VERIFY_SERVICE_PROVIDERS: True,
Capability.CAN_MANAGE_EXPERTISE_TAGS: True,
# Vehicle & Catalog
Capability.CAN_MANAGE_VEHICLE_CATALOG: True,
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: True,
Capability.CAN_VIEW_TECHNICAL_SPECS: True,
# Finance
Capability.CAN_VIEW_FINANCIAL_REPORTS: True,
Capability.CAN_MANAGE_BILLING: True,
Capability.CAN_ISSUE_REFUNDS: True,
Capability.CAN_MANAGE_SUBSCRIPTIONS: True,
# Basic User
Capability.CAN_MANAGE_OWN_PROFILE: True,
Capability.CAN_MANAGE_OWN_VEHICLES: True,
Capability.CAN_CREATE_SERVICE_REQUEST: True,
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
},
# ── ADMIN: Full admin panel access ──
"ADMIN": {
Capability.CAN_MANAGE_USERS: True,
Capability.CAN_MANAGE_ROLES: False,
Capability.CAN_VIEW_SYSTEM_LOGS: True,
Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
Capability.CAN_MANAGE_TRANSLATIONS: True,
Capability.CAN_MODERATE_CONTENT: True,
Capability.CAN_VIEW_REPORTS: True,
Capability.CAN_SUSPEND_USERS: True,
Capability.CAN_APPROVE_PROVIDER: True,
Capability.CAN_VIEW_ORG_STATS: True,
Capability.CAN_MANAGE_DISCOUNTS: True,
Capability.CAN_MANAGE_PROMOTIONS: True,
Capability.CAN_VIEW_SALES_PIPELINE: True,
Capability.CAN_EDIT_MASTER_TAXONOMY: True,
Capability.CAN_MANAGE_SERVICE_CATEGORIES: True,
Capability.CAN_VERIFY_SERVICE_PROVIDERS: True,
Capability.CAN_MANAGE_EXPERTISE_TAGS: True,
Capability.CAN_MANAGE_VEHICLE_CATALOG: True,
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: True,
Capability.CAN_VIEW_TECHNICAL_SPECS: True,
Capability.CAN_VIEW_FINANCIAL_REPORTS: True,
Capability.CAN_MANAGE_BILLING: False,
Capability.CAN_ISSUE_REFUNDS: False,
Capability.CAN_MANAGE_SUBSCRIPTIONS: True,
Capability.CAN_MANAGE_OWN_PROFILE: True,
Capability.CAN_MANAGE_OWN_VEHICLES: True,
Capability.CAN_CREATE_SERVICE_REQUEST: True,
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
},
# ── MODERATOR: Content moderation ──
"MODERATOR": {
Capability.CAN_MANAGE_USERS: False,
Capability.CAN_MANAGE_ROLES: False,
Capability.CAN_VIEW_SYSTEM_LOGS: False,
Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
Capability.CAN_MANAGE_TRANSLATIONS: True,
Capability.CAN_MODERATE_CONTENT: True,
Capability.CAN_VIEW_REPORTS: True,
Capability.CAN_SUSPEND_USERS: False,
Capability.CAN_APPROVE_PROVIDER: True,
Capability.CAN_VIEW_ORG_STATS: False,
Capability.CAN_MANAGE_DISCOUNTS: False,
Capability.CAN_MANAGE_PROMOTIONS: False,
Capability.CAN_VIEW_SALES_PIPELINE: False,
Capability.CAN_EDIT_MASTER_TAXONOMY: False,
Capability.CAN_MANAGE_SERVICE_CATEGORIES: True,
Capability.CAN_VERIFY_SERVICE_PROVIDERS: True,
Capability.CAN_MANAGE_EXPERTISE_TAGS: True,
Capability.CAN_MANAGE_VEHICLE_CATALOG: False,
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: False,
Capability.CAN_VIEW_TECHNICAL_SPECS: True,
Capability.CAN_VIEW_FINANCIAL_REPORTS: False,
Capability.CAN_MANAGE_BILLING: False,
Capability.CAN_ISSUE_REFUNDS: False,
Capability.CAN_MANAGE_OWN_PROFILE: True,
Capability.CAN_MANAGE_OWN_VEHICLES: True,
Capability.CAN_CREATE_SERVICE_REQUEST: True,
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
},
# ── SALES_REP: Sales & discount management ──
"SALES_REP": {
Capability.CAN_MANAGE_USERS: False,
Capability.CAN_MANAGE_ROLES: False,
Capability.CAN_VIEW_SYSTEM_LOGS: False,
Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
Capability.CAN_MANAGE_TRANSLATIONS: False,
Capability.CAN_MODERATE_CONTENT: False,
Capability.CAN_VIEW_REPORTS: False,
Capability.CAN_SUSPEND_USERS: False,
Capability.CAN_APPROVE_PROVIDER: False,
Capability.CAN_VIEW_ORG_STATS: True,
Capability.CAN_MANAGE_DISCOUNTS: True,
Capability.CAN_MANAGE_PROMOTIONS: True,
Capability.CAN_VIEW_SALES_PIPELINE: True,
Capability.CAN_EDIT_MASTER_TAXONOMY: False,
Capability.CAN_MANAGE_SERVICE_CATEGORIES: False,
Capability.CAN_VERIFY_SERVICE_PROVIDERS: False,
Capability.CAN_MANAGE_EXPERTISE_TAGS: False,
Capability.CAN_MANAGE_VEHICLE_CATALOG: False,
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: False,
Capability.CAN_VIEW_TECHNICAL_SPECS: False,
Capability.CAN_VIEW_FINANCIAL_REPORTS: False,
Capability.CAN_MANAGE_BILLING: False,
Capability.CAN_ISSUE_REFUNDS: False,
Capability.CAN_MANAGE_OWN_PROFILE: True,
Capability.CAN_MANAGE_OWN_VEHICLES: True,
Capability.CAN_CREATE_SERVICE_REQUEST: True,
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
},
# ── SERVICE_MGR: Service provider & taxonomy management ──
"SERVICE_MGR": {
Capability.CAN_MANAGE_USERS: False,
Capability.CAN_MANAGE_ROLES: False,
Capability.CAN_VIEW_SYSTEM_LOGS: False,
Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
Capability.CAN_MANAGE_TRANSLATIONS: False,
Capability.CAN_MODERATE_CONTENT: False,
Capability.CAN_VIEW_REPORTS: False,
Capability.CAN_SUSPEND_USERS: False,
Capability.CAN_APPROVE_PROVIDER: True,
Capability.CAN_VIEW_ORG_STATS: False,
Capability.CAN_MANAGE_DISCOUNTS: False,
Capability.CAN_MANAGE_PROMOTIONS: False,
Capability.CAN_VIEW_SALES_PIPELINE: False,
Capability.CAN_EDIT_MASTER_TAXONOMY: True,
Capability.CAN_MANAGE_SERVICE_CATEGORIES: True,
Capability.CAN_VERIFY_SERVICE_PROVIDERS: True,
Capability.CAN_MANAGE_EXPERTISE_TAGS: True,
Capability.CAN_MANAGE_VEHICLE_CATALOG: False,
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: False,
Capability.CAN_VIEW_TECHNICAL_SPECS: True,
Capability.CAN_VIEW_FINANCIAL_REPORTS: False,
Capability.CAN_MANAGE_BILLING: False,
Capability.CAN_ISSUE_REFUNDS: False,
Capability.CAN_MANAGE_OWN_PROFILE: True,
Capability.CAN_MANAGE_OWN_VEHICLES: True,
Capability.CAN_CREATE_SERVICE_REQUEST: True,
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
},
# ── USER: Basic platform user ──
"USER": {
Capability.CAN_MANAGE_USERS: False,
Capability.CAN_MANAGE_ROLES: False,
Capability.CAN_VIEW_SYSTEM_LOGS: False,
Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
Capability.CAN_MANAGE_TRANSLATIONS: False,
Capability.CAN_MODERATE_CONTENT: False,
Capability.CAN_VIEW_REPORTS: False,
Capability.CAN_SUSPEND_USERS: False,
Capability.CAN_APPROVE_PROVIDER: False,
Capability.CAN_VIEW_ORG_STATS: False,
Capability.CAN_MANAGE_DISCOUNTS: False,
Capability.CAN_MANAGE_PROMOTIONS: False,
Capability.CAN_VIEW_SALES_PIPELINE: False,
Capability.CAN_EDIT_MASTER_TAXONOMY: False,
Capability.CAN_MANAGE_SERVICE_CATEGORIES: False,
Capability.CAN_VERIFY_SERVICE_PROVIDERS: False,
Capability.CAN_MANAGE_EXPERTISE_TAGS: False,
Capability.CAN_MANAGE_VEHICLE_CATALOG: False,
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: False,
Capability.CAN_VIEW_TECHNICAL_SPECS: False,
Capability.CAN_VIEW_FINANCIAL_REPORTS: False,
Capability.CAN_MANAGE_BILLING: False,
Capability.CAN_ISSUE_REFUNDS: False,
Capability.CAN_MANAGE_OWN_PROFILE: True,
Capability.CAN_MANAGE_OWN_VEHICLES: True,
Capability.CAN_CREATE_SERVICE_REQUEST: True,
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
},
}
def get_capabilities_for_role(role_key: str) -> Dict[str, bool]:
"""Return the capability dict for a given role key.
Args:
role_key: The role key (e.g. 'SUPERADMIN', 'ADMIN', 'USER')
Returns:
Dict of capability -> bool, or empty dict if role not found.
"""
return SYSTEM_CAPABILITIES_MATRIX.get(role_key.upper(), {})
def role_has_capability(role_key: str, capability: str) -> bool:
"""Check if a role has a specific capability.
Args:
role_key: The role key (e.g. 'SUPERADMIN', 'ADMIN')
capability: The capability key (e.g. 'can_manage_users')
Returns:
True if the role has the capability, False otherwise.
"""
caps = get_capabilities_for_role(role_key)
return caps.get(capability, False)

View File

@@ -1,7 +1,7 @@
# /opt/docker/dev/service_finder/backend/app/core/rbac.py
from fastapi import HTTPException, Depends, status
from app.api.deps import get_current_user
from app.models.identity import User
from app.models.identity import User, UserRole
from app.core.config import settings
class RBAC:
@@ -11,11 +11,11 @@ class RBAC:
async def __call__(self, current_user: User = Depends(get_current_user)):
# 1. Superadmin mindent visz (Rank 100)
if current_user.role == "superadmin":
if current_user.role == UserRole.SUPERADMIN:
return True
# 2. Dinamikus rang ellenőrzés a központi rank_map alapján
role_key = current_user.role.value.upper() # A DEFAULT_RANK_MAP nagybetűs kulcsokat vár
role_key = current_user.role.value # A DEFAULT_RANK_MAP már nagybetűs kulcsokat használ
user_rank = settings.DEFAULT_RANK_MAP.get(role_key, 0)
if user_rank < self.min_rank:
raise HTTPException(

View File

@@ -51,23 +51,14 @@ def generate_secure_slug(length: int = 16) -> str:
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(length))
# Teljesen a margón van, így globális konstans lesz!
# Lefedi a UserRole enum MIND a 10 értékét
# RBAC Phase 1: Tisztított rangtábla a rendszerszintű szerepkörökhöz.
# A szervezeti szerepkörök (OrgUserRole) külön kezelendők.
DEFAULT_RANK_MAP = {
"SUPERADMIN": 100,
"ADMIN": 90,
"REGION_ADMIN": 85,
"COUNTRY_ADMIN": 80,
"MODERATOR": 75,
"AUDITOR": 70,
"ORGANIZATION_OWNER": 65,
"ORGANIZATION_MANAGER": 60,
"ORGANIZATION_MEMBER": 50,
"SERVICE_PROVIDER": 40,
"SALES_AGENT": 30,
"FLEET_MANAGER": 25,
"PREMIUM_USER": 20,
"SERVICE_MGR": 40,
"SALES_REP": 30,
"USER": 10,
"DRIVER": 5,
"GUEST": 0
}

View File

@@ -22,16 +22,17 @@ if TYPE_CHECKING:
from ..marketplace.service_request import ServiceRequest
class UserRole(str, enum.Enum):
superadmin = "superadmin"
admin = "admin"
region_admin = "region_admin"
country_admin = "country_admin"
moderator = "moderator"
sales_agent = "sales_agent"
user = "user"
service_owner = "service_owner"
fleet_manager = "fleet_manager"
driver = "driver"
"""Rendszerszintű (System-level) szerepkörök.
Csak a globális platform szerepkörök maradnak itt.
Szervezeti szerepkörök: lásd OrgUserRole a marketplace/organization.py-ban.
"""
SUPERADMIN = "SUPERADMIN"
ADMIN = "ADMIN"
MODERATOR = "MODERATOR"
SALES_REP = "SALES_REP"
SERVICE_MGR = "SERVICE_MGR"
USER = "USER"
class Person(Base):
"""
@@ -133,7 +134,7 @@ class User(Base):
role: Mapped[UserRole] = mapped_column(
PG_ENUM(UserRole, name="userrole", schema="identity"),
default=UserRole.user
default=UserRole.USER
)
person_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))

View File

@@ -25,17 +25,25 @@ class OrgType(str, enum.Enum):
business = "business"
class OrgUserRole(str, enum.Enum):
"""Szervezeti (Organization-level) szerepkörök.
Ezek a szerepkörök a szervezeten/flottán belüli jogosultságokat határozzák meg.
Minden szerepkörhöz egyedi JSON permissions objektum tartozik a fleet.org_roles táblában.
"""
OWNER = "OWNER"
ADMIN = "ADMIN"
MANAGER = "MANAGER"
MEMBER = "MEMBER"
AGENT = "AGENT"
ACCOUNTANT = "ACCOUNTANT"
DRIVER = "DRIVER"
VIEWER = "VIEWER"
class OrgRole(Base):
"""
Dinamikus szerepkör modell (RBAC).
Dinamikus szerepkör modell (RBAC Phase 2).
A szervezeti szerepkörök adatbázis-vezéreltek, nem hardkódolt Enum-ok.
Alapértelmezett szerepkörök: OWNER, ADMIN, MANAGER, MEMBER, AGENT.
Alapértelmezett szerepkörök: OWNER, ADMIN, ACCOUNTANT, DRIVER, VIEWER.
permissions: JSONB oszlop a capability-alapú jogosultságok tárolására.
Példa: {"can_add_expense": True, "can_approve_expense": False}
"""
__tablename__ = "org_roles"
__table_args__ = {"schema": "fleet"}
@@ -48,6 +56,16 @@ class OrgRole(Base):
priority: Mapped[int] = mapped_column(Integer, default=0)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# ── RBAC Phase 2: JSONB permissions column ──
# Stores capability flags for this role, e.g.:
# {"can_add_expense": True, "can_approve_expense": True, "can_view_financials": True}
permissions: Mapped[dict] = mapped_column(
JSONB,
nullable=False,
default=dict,
server_default=text("'{}'::jsonb")
)
class Organization(Base):
"""
@@ -119,9 +137,18 @@ class Organization(Base):
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# ── 📦 ELŐFIZETÉS / SUBSCRIPTION ──
subscription_plan: Mapped[str] = mapped_column(String(30), server_default=text("'FREE'"), index=True)
base_asset_limit: Mapped[int] = mapped_column(Integer, server_default=text("1"))
purchased_extra_slots: Mapped[int] = mapped_column(Integer, server_default=text("0"))
# P0 FEATURE: Subscription Tier FK — connects this org to a system.subscription_tiers record
subscription_tier_id: Mapped[Optional[int]] = mapped_column(
Integer,
ForeignKey("system.subscription_tiers.id"),
nullable=True,
index=True
)
notification_settings: Mapped[Any] = mapped_column(JSON, server_default=text("'{\"notify_owner\": true, \"alert_days_before\": [30, 15, 7, 1]}'::jsonb"))
external_integration_config: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
@@ -215,9 +242,12 @@ class OrganizationMember(Base):
# Meghívó lejárati ideje
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
# Dinamikus szerepkör (String, nem Enum) - az OrgRole táblából validáljuk
# Dinamikus szerepkör - PostgreSQL ENUM típus (fleet.orguserrole)
# A PG_ENUM használata biztosítja, hogy az SQLAlchemy megfelelő típuskényszerítést
# (type cast) alkalmazzon a lekérdezéseknél, elkerülve az
# "operator does not exist: fleet.orguserrole = character varying" hibát.
role: Mapped[str] = mapped_column(
String(50),
PG_ENUM(OrgUserRole, name="orguserrole", schema="fleet", create_type=False),
default="MEMBER",
server_default=text("'MEMBER'")
)

View File

@@ -247,6 +247,13 @@ class AssetFinancials(Base):
asset: Mapped["Asset"] = relationship("Asset", back_populates="financials")
class AssetCostStatusEnum(str, enum.Enum):
"""Költség státuszok a jóváhagyási munkafolyamathoz."""
DRAFT = "DRAFT"
PENDING_APPROVAL = "PENDING_APPROVAL"
APPROVED = "APPROVED"
class AssetCost(Base):
""" II. Üzemeltetés és TCO kimutatás. """
__tablename__ = "asset_costs"
@@ -257,15 +264,35 @@ class AssetCost(Base):
category_id: Mapped[int] = mapped_column(Integer, ForeignKey("vehicle.cost_categories.id"), index=True, nullable=False)
amount_net: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False)
amount_gross: Mapped[Optional[float]] = mapped_column(Numeric(18, 2), nullable=True)
vat_rate: Mapped[Optional[float]] = mapped_column(Numeric(5, 2), nullable=True)
currency: Mapped[str] = mapped_column(String(3), default="HUF")
date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
invoice_number: Mapped[Optional[str]] = mapped_column(String(100), index=True)
document_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.documents.id"), nullable=True)
# Státusz a jóváhagyási munkafolyamathoz
status: Mapped[str] = mapped_column(String(30), default="DRAFT", server_default=text("'DRAFT'"), index=True)
# Kétirányú hivatkozás az AssetEvent felé (kettős könyvelés elkerülése)
linked_asset_event_id: Mapped[Optional[uuid.UUID]] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("vehicle.asset_events.id"),
nullable=True,
index=True
)
data: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
asset: Mapped["Asset"] = relationship("Asset", back_populates="costs")
organization: Mapped["Organization"] = relationship("Organization")
category: Mapped["CostCategory"] = relationship("CostCategory")
# Kapcsolat a hivatkozott eseményhez
linked_event: Mapped[Optional["AssetEvent"]] = relationship(
"AssetEvent",
foreign_keys=[linked_asset_event_id],
post_update=True
)
class VehicleLogbook(Base):
@@ -396,6 +423,13 @@ class AssetEventTypeEnum(str, enum.Enum):
RECALL = "RECALL" # Visszahívás
class AssetEventStatusEnum(str, enum.Enum):
"""Esemény státuszok a feldolgozási munkafolyamathoz."""
DRAFT = "DRAFT"
MISSING_TECH_DATA = "MISSING_TECH_DATA"
COMPLETED = "COMPLETED"
class AssetEvent(Base):
""" Digitális Szervizkönyv - Szerviz, baleset és egyéb jelentős események. """
__tablename__ = "asset_events"
@@ -411,6 +445,17 @@ class AssetEvent(Base):
description: Mapped[Optional[str]] = mapped_column(Text)
cost_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("vehicle.asset_costs.id"), nullable=True)
# Státusz a feldolgozási munkafolyamathoz
status: Mapped[str] = mapped_column(String(30), default="DRAFT", server_default=text("'DRAFT'"), index=True)
# Kétirányú hivatkozás az AssetCost felé (kettős könyvelés elkerülése)
linked_expense_id: Mapped[Optional[uuid.UUID]] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("vehicle.asset_costs.id"),
nullable=True,
index=True
)
event_date: 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), onupdate=func.now())
@@ -419,7 +464,15 @@ class AssetEvent(Base):
asset: Mapped["Asset"] = relationship("Asset", back_populates="events")
user: Mapped[Optional["User"]] = relationship("User")
organization: Mapped[Optional["Organization"]] = relationship("Organization")
cost: Mapped[Optional["AssetCost"]] = relationship("AssetCost")
cost: Mapped[Optional["AssetCost"]] = relationship("AssetCost", foreign_keys=[cost_id])
# Kapcsolat a hivatkozott költséghez
linked_expense: Mapped[Optional["AssetCost"]] = relationship(
"AssetCost",
foreign_keys=[linked_expense_id],
post_update=True,
primaryjoin="AssetEvent.linked_expense_id == AssetCost.id"
)
class ExchangeRate(Base):

View File

@@ -20,7 +20,7 @@ router = APIRouter()
# --- 🛡️ ADMIN JOGOSULTSÁG ELLENŐRZŐ ---
async def check_admin_access(current_user: User = Depends(deps.get_current_active_user)):
""" Csak Admin vagy Superadmin léphet be a Sentinel központba. """
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Sentinel jogosultság szükséges a művelethez!"

View File

@@ -386,6 +386,8 @@ class AssetEventCreate(BaseModel):
cost_id: Optional[UUID] = Field(None, description="Associated cost record ID")
cost_amount: Optional[float] = Field(None, gt=0, description="Cost amount in the given currency. If > 0, an AssetCost record is auto-created.")
currency: Optional[str] = Field(None, min_length=3, max_length=3, description="Currency code (e.g. HUF, EUR). Defaults to HUF on backend.")
status: Optional[str] = Field(None, description="Event status (DRAFT, MISSING_TECH_DATA, COMPLETED) - auto-set by backend")
linked_expense_id: Optional[UUID] = Field(None, description="Associated AssetCost ID (bidirectional link)")
model_config = ConfigDict(from_attributes=True)
@@ -402,6 +404,8 @@ class AssetEventResponse(BaseModel):
cost_id: Optional[UUID] = None
cost_amount: Optional[float] = Field(None, description="Auto-created cost amount")
currency: Optional[str] = Field(None, description="Currency code (e.g. HUF)")
status: str = "DRAFT"
linked_expense_id: Optional[UUID] = None
event_date: datetime
created_at: datetime
updated_at: Optional[datetime] = None
@@ -410,7 +414,10 @@ class AssetEventResponse(BaseModel):
@classmethod
def model_validate(cls, obj, **kwargs):
"""Override to resolve cost_amount and currency from the related AssetCost."""
"""Override to resolve cost_amount and currency from the related AssetCost.
GROSS-FIRST: cost_amount resolves from amount_gross (Bruttó) as primary source.
"""
data = {}
if hasattr(obj, '__dict__'):
# Copy ORM attributes
@@ -422,7 +429,10 @@ class AssetEventResponse(BaseModel):
cost = getattr(obj, 'cost', None)
if cost is not None:
if data.get('cost_amount') is None:
data['cost_amount'] = float(cost.amount_net) if cost.amount_net is not None else None
# GROSS-FIRST: prefer amount_gross as the source of truth
data['cost_amount'] = float(cost.amount_gross) if cost.amount_gross is not None else (
float(cost.amount_net) if cost.amount_net is not None else None
)
if data.get('currency') is None:
data['currency'] = cost.currency
return cls(**data)

View File

@@ -1,13 +1,38 @@
# /opt/docker/dev/service_finder/backend/app/schemas/asset_cost.py
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from typing import Optional, Dict, Any
from datetime import datetime
from decimal import Decimal
from uuid import UUID
class AssetCostBase(BaseModel):
"""Base schema — matches the AssetCost SQLAlchemy model fields."""
amount_net: Decimal
"""Base schema — matches the AssetCost SQLAlchemy model fields.
GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary/required field.
In EU accounting, the gross amount (Bruttó) is the absolute Source of Truth.
amount_net is optional — if only gross is provided, net = gross (0% VAT).
If vat_rate is also provided, net is calculated back from gross.
"""
amount_gross: Optional[Decimal] = Field(None, description="Bruttó összeg (ÁFA-val) — ELSŐDLEGES forrás, de lehet NULL legacy rekordoknál")
amount_net: Optional[Decimal] = Field(None, description="Nettó összeg (ÁFA nélkül) — OPCIONÁLIS, ha nincs megadva, bruttóból számolva")
@model_validator(mode='before')
@classmethod
def safety_net_gross_from_net(cls, data: Any) -> Any:
"""SAFETY NET (Graceful Degradation): If the incoming payload contains
amount_net but amount_gross is missing/None, automatically copy amount_net
to amount_gross. This protects against legacy/cached browsers that still
send amount_net in their payloads.
GROSS-FIRST (Masterbook 2.0.1): The backend is strictly Gross-first.
"""
if isinstance(data, dict):
has_net = data.get('amount_net') is not None
has_gross = data.get('amount_gross') is not None
if has_net and not has_gross:
data['amount_gross'] = data['amount_net']
return data
vat_rate: Optional[Decimal] = Field(None, description="ÁFA kulcs (pl. 27.00)")
currency: str = "HUF"
date: datetime = Field(default_factory=datetime.now)
invoice_number: Optional[str] = None
@@ -16,15 +41,45 @@ class AssetCostBase(BaseModel):
mileage_at_cost: Optional[int] = None # Stored inside data JSONB
description: Optional[str] = None # Stored inside data JSONB
@model_validator(mode='after')
def validate_gross_first(self):
"""Gross-First validation: ensure amount_gross is the primary source.
If amount_net is not provided but vat_rate is, calculate net from gross.
If neither amount_net nor vat_rate is provided, net = gross (0% VAT).
If amount_gross is None (legacy data), default to 0 to prevent 500 errors.
"""
gross = self.amount_gross
net = self.amount_net
vat = self.vat_rate
# Fault tolerance: if gross is None (legacy NULL), default to 0
if gross is None:
self.amount_gross = Decimal("0")
gross = self.amount_gross
if net is None and vat is not None and vat > 0:
# Net will be calculated in the service layer from gross & vat
pass
elif net is None:
# No net and no vat → net = gross (0% VAT content)
self.amount_net = gross
self.vat_rate = Decimal("0")
return self
class AssetCostCreate(AssetCostBase):
asset_id: UUID
organization_id: Optional[int] = None # Auto-resolved from asset if not provided
status: Optional[str] = Field(None, description="Költség státusz (DRAFT, PENDING_APPROVAL, APPROVED) - auto-set by backend")
linked_asset_event_id: Optional[UUID] = Field(None, description="Kapcsolódó AssetEvent ID (kétirányú hivatkozáshoz)")
class AssetCostResponse(AssetCostBase):
"""Response schema — matches DB columns from vehicle.asset_costs."""
id: UUID
asset_id: UUID
organization_id: int
status: str = "DRAFT"
linked_asset_event_id: Optional[UUID] = None
# Derived / enriched fields (not in DB model directly)
category_name: Optional[str] = None # Resolved from CostCategory relationship

View File

@@ -1,5 +1,5 @@
# /opt/docker/dev/service_finder/backend/app/schemas/asset_event.py
from pydantic import BaseModel, ConfigDict, Field, validator
from pydantic import BaseModel, ConfigDict, Field, field_validator
from typing import Optional, Dict, Any, List
from uuid import UUID
from datetime import datetime
@@ -17,6 +17,12 @@ class AssetEventTypeEnum(str, Enum):
RECALL = "RECALL" # Visszahívás
OTHER = "OTHER" # Egyéb
class AssetEventStatusEnum(str, Enum):
"""Esemény státuszok a feldolgozási munkafolyamathoz."""
DRAFT = "DRAFT"
MISSING_TECH_DATA = "MISSING_TECH_DATA"
COMPLETED = "COMPLETED"
class AssetEventCreate(BaseModel):
"""Digitális Szervizkönyv esemény létrehozásához szükséges adatok."""
event_type: AssetEventTypeEnum = Field(..., description="Esemény típusa")
@@ -24,9 +30,10 @@ class AssetEventCreate(BaseModel):
description: str = Field(..., min_length=1, max_length=1000, description="Esemény leírása")
event_date: Optional[datetime] = Field(None, description="Esemény dátuma (alapértelmezett: most)")
cost_id: Optional[UUID] = Field(None, description="Kapcsolódó költség rekord ID (opcionális)")
# RBAC ellenőrzés: csak a tulajdonos vagy operátor szervezet adhat hozzá eseményt
# Ezt a service rétegben validáljuk, nem a sémában
cost_amount: Optional[float] = Field(None, gt=0, description="Költség összege. Ha > 0, automatikusan létrejön egy AssetCost rekord.")
currency: Optional[str] = Field(None, min_length=3, max_length=3, description="Valutakód (pl. HUF, EUR)")
status: Optional[AssetEventStatusEnum] = Field(None, description="Esemény státusz (auto-set by backend)")
linked_expense_id: Optional[UUID] = Field(None, description="Kapcsolódó AssetCost ID (kétirányú hivatkozáshoz)")
model_config = ConfigDict(from_attributes=True)
@@ -41,6 +48,12 @@ class AssetEventResponse(BaseModel):
odometer_reading: Optional[int] = None
description: Optional[str] = None
cost_id: Optional[UUID] = None
cost_amount: Optional[float] = Field(None, description="Auto-created cost amount")
currency: Optional[str] = Field(None, description="Currency code (e.g. HUF)")
# Új mezők
status: str = "DRAFT"
linked_expense_id: Optional[UUID] = None
event_date: datetime
created_at: datetime
@@ -52,6 +65,31 @@ class AssetEventResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
@classmethod
def model_validate(cls, obj, **kwargs):
"""Override to resolve cost_amount and currency from the related AssetCost.
GROSS-FIRST: cost_amount resolves from amount_gross (Bruttó) as primary source.
"""
data = {}
if hasattr(obj, '__dict__'):
# Copy ORM attributes
for field in cls.model_fields:
if hasattr(obj, field):
data[field] = getattr(obj, field)
# Resolve cost_amount and currency from the cost relationship
cost = getattr(obj, 'cost', None)
if cost is not None:
if data.get('cost_amount') is None:
# GROSS-FIRST: prefer amount_gross as the source of truth
data['cost_amount'] = float(cost.amount_gross) if cost.amount_gross is not None else (
float(cost.amount_net) if cost.amount_net is not None else None
)
if data.get('currency') is None:
data['currency'] = cost.currency
return cls(**data)
class AssetEventUpdate(BaseModel):
"""Digitális Szervizkönyv esemény frissítéséhez szükséges adatok."""
event_type: Optional[AssetEventTypeEnum] = Field(None, description="Esemény típusa")
@@ -59,8 +97,11 @@ class AssetEventUpdate(BaseModel):
description: Optional[str] = Field(None, min_length=1, max_length=1000, description="Esemény leírása")
event_date: Optional[datetime] = Field(None, description="Esemény dátuma")
cost_id: Optional[UUID] = Field(None, description="Kapcsolódó költség rekord ID")
status: Optional[AssetEventStatusEnum] = Field(None, description="Esemény státusz")
linked_expense_id: Optional[UUID] = Field(None, description="Kapcsolódó AssetCost ID")
@validator('description')
@field_validator('description')
@classmethod
def validate_description(cls, v):
if v is not None and len(v.strip()) == 0:
raise ValueError('Description cannot be empty or whitespace only')

View File

@@ -56,13 +56,12 @@ class OrganizationUpdate(BaseModel):
address_street_name: Optional[str] = None
address_street_type: Optional[str] = None
address_house_number: Optional[str] = None
address_stairwell: Optional[str] = None
address_floor: Optional[str] = None
address_door: Optional[str] = None
address_hrsz: Optional[str] = None
# ── Crowdsourced search fields ──
aliases: Optional[List[str]] = None
tags: Optional[List[str]] = None
# ── Subscription ──
subscription_tier_id: Optional[int] = None
class OrganizationResponse(BaseModel):
@@ -79,10 +78,18 @@ class OrganizationResponse(BaseModel):
is_active: bool = True
is_deleted: bool = False
subscription_plan: str = "FREE"
subscription_tier_id: Optional[int] = None
visual_settings: Optional[dict] = None
notification_settings: Optional[Any] = None
external_integration_config: Optional[Any] = None
aliases: List[str] = Field(default_factory=list)
tags: List[str] = Field(default_factory=list)
# ── Cím adatok (Address fields) ──
address_zip: Optional[str] = None
address_city: Optional[str] = None
address_street_name: Optional[str] = None
address_street_type: Optional[str] = None
address_house_number: Optional[str] = None
address_hrsz: Optional[str] = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,7 +1,7 @@
# /opt/docker/dev/service_finder/backend/app/schemas/user.py
import uuid
from pydantic import BaseModel, EmailStr, field_validator, ConfigDict
from typing import Optional, Any
from typing import Optional, Any, Dict
from datetime import date, datetime
@@ -66,6 +66,10 @@ class UserResponse(UserBase):
person: Optional[PersonResponse] = None
# Utolsó admin flag (ha a user az egyetlen aktív admin bármely szervezetében)
is_last_admin: bool = False
# RBAC Phase 3: Rendszerszintű képességek (a SYSTEM_CAPABILITIES_MATRIX-ból)
system_capabilities: Dict[str, bool] = {}
# RBAC Phase 3: Szervezeti szintű képességek (org_id -> capability -> bool)
org_capabilities: Dict[str, Dict[str, bool]] = {}
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,306 @@
#!/usr/bin/env python3
"""
P0 CRITICAL - The Great Garage & Vehicle Migration (Legacy Data Cleanup)
Migrates legacy person_id-based ownership to the new organization_id / organization_members
RBAC structure. Ensures every user has a personal garage (INDIVIDUAL org), is bound as OWNER
in organization_members, and orphaned vehicles are rescued into their owner's garage.
TASKS:
A) Ensure Personal Garages exist for every user who lacks one
B) Bind users to their garages via organization_members (OWNER role) + update scope_id
C) Rescue orphaned vehicles (owner_person_id set but no current_organization_id/owner_org_id)
Usage:
docker exec sf_api python3 /app/app/scripts/migrate_legacy_garages.py
"""
import asyncio
import asyncpg
import json
import os
import sys
from datetime import datetime, timezone
# asyncpg needs plain "postgresql://" scheme, not "postgresql+asyncpg://"
_raw_url = os.getenv(
"DATABASE_URL",
"postgresql://service_finder_app:AppSafePass_2026@db:5432/service_finder"
)
DATABASE_URL = _raw_url.replace("+asyncpg", "")
async def main():
print("=" * 70)
print("🔧 P0 MIGRATION: THE GREAT GARAGE & VEHICLE MIGRATION")
print("=" * 70)
print(f"Started at: {datetime.now(timezone.utc).isoformat()}")
print()
conn = await asyncpg.connect(DATABASE_URL)
try:
# ── TASK A: ENSURE PERSONAL GARAGES EXIST ──
print("📋 TASK A: Ensure Personal Garages Exist")
print("-" * 50)
# Find all users who do NOT have an INDIVIDUAL-type organization where they are owner
users_without_garage = await conn.fetch("""
SELECT u.id AS user_id,
u.email,
p.id AS person_id,
p.first_name,
p.last_name
FROM identity.users u
JOIN identity.persons p ON p.id = u.person_id
WHERE u.is_deleted = false
AND u.is_active = true
AND NOT EXISTS (
SELECT 1
FROM fleet.organizations o
WHERE o.owner_id = u.id
AND o.org_type = 'individual'
AND o.is_deleted = false
)
ORDER BY u.id
""")
print(f" Found {len(users_without_garage)} users without a personal garage.")
garages_created = 0
for row in users_without_garage:
user_id = row['user_id']
person_id = row['person_id']
first_name = row['first_name'] or "Felhasználó"
last_name = row['last_name'] or ""
garage_name = f"{first_name} Garázsa"
folder_slug = f"personal-{user_id}-{person_id}"
now = datetime.now(timezone.utc)
# Direct INSERT with all NOT NULL columns explicitly provided
await conn.execute("""
INSERT INTO fleet.organizations
(full_name, name, display_name, folder_slug, org_type,
owner_id, status, is_active, is_deleted,
subscription_plan, base_asset_limit, purchased_extra_slots,
notification_settings, external_integration_config,
is_ownership_transferable, is_verified,
first_registered_at, current_lifecycle_started_at,
lifecycle_index, created_at,
visual_settings, aliases, tags, settings)
VALUES
($1, $2, $3, $4, 'individual',
$5, 'active', true, false,
'FREE', 5, 0,
$6::json, '{}'::json,
true, false,
$7, $8,
1, $9,
$10::jsonb, '[]'::jsonb, '[]'::jsonb, $11::jsonb)
""",
garage_name, # $1 full_name
garage_name, # $2 name
garage_name, # $3 display_name
folder_slug, # $4 folder_slug
user_id, # $5 owner_id
json.dumps({"notify_owner": True, "alert_days_before": [30, 15, 7, 1]}), # $6 notification_settings
now, # $7 first_registered_at
now, # $8 current_lifecycle_started_at
now, # $9 created_at
json.dumps({"theme": "default", "primary_color": None, "wall_logo_url": None}), # $10 visual_settings
json.dumps({"invite_expiry_days": 7, "max_members": 50, "allow_public_join": False}), # $11 settings
)
garages_created += 1
print(f" ✅ Created personal garage '{garage_name}' for user_id={user_id} ({row['email']})")
print(f"\n 📊 Task A Result: {garages_created} personal garages created.")
print()
# ── TASK B: BIND USERS TO GARAGES (RBAC) ──
print("📋 TASK B: Bind Users to Garages (RBAC)")
print("-" * 50)
# Get all active users with their personal garages
users_and_garages = await conn.fetch("""
SELECT u.id AS user_id,
u.email,
u.scope_id,
o.id AS org_id,
o.name AS org_name
FROM identity.users u
JOIN fleet.organizations o ON o.owner_id = u.id
AND o.org_type = 'individual'
AND o.is_deleted = false
WHERE u.is_deleted = false
AND u.is_active = true
ORDER BY u.id
""")
members_added = 0
scopes_updated = 0
for row in users_and_garages:
user_id = row['user_id']
org_id = row['org_id']
scope_id = row['scope_id']
# Check if user is already a member of this org
existing_member = await conn.fetchrow("""
SELECT id FROM fleet.organization_members
WHERE organization_id = $1 AND user_id = $2
""", org_id, user_id)
if not existing_member:
await conn.execute("""
INSERT INTO fleet.organization_members
(organization_id, user_id, role, status,
is_permanent, is_verified, created_at)
VALUES
($1, $2, 'OWNER', 'active',
true, true, NOW())
""", org_id, user_id)
members_added += 1
print(f" ✅ Added user_id={user_id} as OWNER of org_id={org_id} ({row['org_name']})")
else:
# Ensure role is OWNER
await conn.execute("""
UPDATE fleet.organization_members
SET role = 'OWNER', is_permanent = true, is_verified = true, status = 'active'
WHERE organization_id = $1 AND user_id = $2
""", org_id, user_id)
print(f" Updated membership for user_id={user_id} in org_id={org_id} to OWNER")
# Update scope_id if NULL
if scope_id is None or scope_id == "":
await conn.execute("""
UPDATE identity.users
SET scope_id = $1::text
WHERE id = $2
""", str(org_id), user_id)
scopes_updated += 1
print(f" ✅ Updated scope_id for user_id={user_id} to '{org_id}'")
print(f"\n 📊 Task B Result: {members_added} members added, {scopes_updated} scope_ids updated.")
print()
# ── TASK C: RESCUE ORPHANED VEHICLES ──
print("📋 TASK C: Rescue Orphaned Vehicles")
print("-" * 50)
# Find orphaned vehicles: have owner_person_id but no current_organization_id or owner_org_id
orphaned_vehicles = await conn.fetch("""
SELECT a.id AS asset_id,
a.license_plate,
a.vin,
a.owner_person_id,
a.current_organization_id,
a.owner_org_id,
a.brand,
a.model
FROM vehicle.assets a
WHERE a.owner_person_id IS NOT NULL
AND (a.current_organization_id IS NULL OR a.owner_org_id IS NULL)
AND a.status != 'deleted'
ORDER BY a.owner_person_id, a.id
""")
print(f" Found {len(orphaned_vehicles)} orphaned vehicles.")
vehicles_rescued = 0
vehicles_skipped = 0
for vehicle in orphaned_vehicles:
owner_person_id = vehicle['owner_person_id']
asset_id = vehicle['asset_id']
plate = vehicle['license_plate'] or vehicle['vin'] or str(asset_id)
# Find the user who owns this person record
user_row = await conn.fetchrow("""
SELECT u.id AS user_id
FROM identity.users u
WHERE u.person_id = $1
AND u.is_deleted = false
AND u.is_active = true
LIMIT 1
""", owner_person_id)
if not user_row:
print(f" ⚠️ No active user found for person_id={owner_person_id}, skipping vehicle {plate}")
vehicles_skipped += 1
continue
user_id = user_row['user_id']
# Find the user's personal garage
garage = await conn.fetchrow("""
SELECT o.id AS org_id
FROM fleet.organizations o
WHERE o.owner_id = $1
AND o.org_type = 'individual'
AND o.is_deleted = false
LIMIT 1
""", user_id)
if not garage:
print(f" ⚠️ No personal garage found for user_id={user_id}, skipping vehicle {plate}")
vehicles_skipped += 1
continue
org_id = garage['org_id']
# Update the vehicle's organization fields
updates = []
params = []
param_idx = 1
if vehicle['current_organization_id'] is None:
updates.append(f"current_organization_id = ${param_idx}")
params.append(org_id)
param_idx += 1
if vehicle['owner_org_id'] is None:
updates.append(f"owner_org_id = ${param_idx}")
params.append(org_id)
param_idx += 1
if updates:
params.append(asset_id)
update_sql = f"""
UPDATE vehicle.assets
SET {', '.join(updates)}
WHERE id = ${param_idx}::uuid
"""
await conn.execute(update_sql, *params)
vehicles_rescued += 1
print(f" ✅ Rescued vehicle {plate} → org_id={org_id} (user_id={user_id})")
print(f"\n 📊 Task C Result: {vehicles_rescued} vehicles rescued, {vehicles_skipped} skipped.")
print()
# ── FINAL REPORT ──
print("=" * 70)
print("📊 FINAL MIGRATION REPORT")
print("=" * 70)
print(f" 🏗️ Task A - New Garages Created: {garages_created}")
print(f" 👥 Task B - Members Added: {members_added}")
print(f" 🆔 Task B - Scope IDs Updated: {scopes_updated}")
print(f" 🚗 Task C - Vehicles Rescued: {vehicles_rescued}")
print(f" ⏭️ Task C - Vehicles Skipped: {vehicles_skipped}")
print()
print(f"Completed at: {datetime.now(timezone.utc).isoformat()}")
print("=" * 70)
except Exception as e:
print(f"\n❌ FATAL ERROR: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
finally:
await conn.close()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,154 @@
# /opt/docker/dev/service_finder/backend/app/scripts/migrate_rbac_phase1_enums.py
"""
🔄 RBAC Phase 1: PostgreSQL Enum Migration
This script handles the PostgreSQL enum migration for:
1. identity.userrole -> new values: SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR, USER
2. fleet.org_roles table seeding (delegated to seed_org_roles.py)
Since PostgreSQL doesn't support ALTER TYPE ... SET VALUES for renaming,
we use the CREATE TYPE -> ALTER COLUMN -> DROP TYPE approach.
Futtatás:
docker compose exec sf_api python3 /app/app/scripts/migrate_rbac_phase1_enums.py
"""
import asyncio
import logging
from app.database import AsyncSessionLocal
from sqlalchemy import text
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s]: %(message)s')
logger = logging.getLogger("Migrate-RBAC-Enums")
async def migrate_userrole_enum():
"""Migrate identity.userrole enum to new values.
Old values: superadmin, admin, moderator, sales_agent, user, service_owner, fleet_manager, driver
New values: SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR, USER
Mapping:
superadmin -> SUPERADMIN
admin -> ADMIN
moderator -> MODERATOR
sales_agent -> SALES_REP
user -> USER
service_owner -> USER (fallback)
fleet_manager -> USER (fallback)
driver -> USER (fallback)
"""
async with AsyncSessionLocal() as session:
logger.info("🔍 Checking current identity.userrole enum...")
# Check current enum values
result = await session.execute(
text("SELECT unnest(enum_range(NULL::identity.userrole))::text as val")
)
current_values = {row[0] for row in result.fetchall()}
logger.info(f"Current enum values: {current_values}")
new_values = {"SUPERADMIN", "ADMIN", "MODERATOR", "SALES_REP", "SERVICE_MGR", "USER"}
# If already migrated, skip
if new_values.issubset(current_values):
logger.info("✅ Enum already has the new values. Skipping migration.")
return
logger.info("🔄 Starting enum migration...")
# Step 1: Create new enum type
logger.info("Step 1: Creating new enum type 'userrole_new'...")
await session.execute(text("""
CREATE TYPE identity.userrole_new AS ENUM (
'SUPERADMIN', 'ADMIN', 'MODERATOR', 'SALES_REP', 'SERVICE_MGR', 'USER'
)
"""))
# Step 2: Alter the users table to use text temporarily
logger.info("Step 2: Altering column to text type...")
await session.execute(text("""
ALTER TABLE identity.users
ALTER COLUMN role TYPE text
USING role::text
"""))
# Step 3: Update old values to new values
logger.info("Step 3: Updating role values...")
await session.execute(text("""
UPDATE identity.users SET role = 'SUPERADMIN' WHERE role = 'superadmin'
"""))
await session.execute(text("""
UPDATE identity.users SET role = 'ADMIN' WHERE role = 'admin'
"""))
await session.execute(text("""
UPDATE identity.users SET role = 'MODERATOR' WHERE role = 'moderator'
"""))
await session.execute(text("""
UPDATE identity.users SET role = 'SALES_REP' WHERE role = 'sales_agent'
"""))
await session.execute(text("""
UPDATE identity.users SET role = 'USER' WHERE role IN ('user', 'service_owner', 'fleet_manager', 'driver')
"""))
# Step 4: Drop default before altering type
logger.info("Step 4: Dropping default value...")
await session.execute(text("""
ALTER TABLE identity.users
ALTER COLUMN role DROP DEFAULT
"""))
# Step 5: Alter column to use new enum
logger.info("Step 5: Altering column to new enum type...")
await session.execute(text("""
ALTER TABLE identity.users
ALTER COLUMN role TYPE identity.userrole_new
USING role::text::identity.userrole_new
"""))
# Step 6: Set default
logger.info("Step 6: Setting default value...")
await session.execute(text("""
ALTER TABLE identity.users
ALTER COLUMN role SET DEFAULT 'USER'::identity.userrole_new
"""))
# Step 7: Drop old enum and rename new one
logger.info("Step 7: Dropping old enum and renaming new one...")
await session.execute(text("DROP TYPE IF EXISTS identity.userrole"))
await session.execute(text("""
ALTER TYPE identity.userrole_new RENAME TO userrole
"""))
await session.commit()
logger.info("✅ Enum migration completed successfully!")
# Verify
result = await session.execute(
text("SELECT unnest(enum_range(NULL::identity.userrole))::text as val")
)
final_values = [row[0] for row in result.fetchall()]
logger.info(f"Final enum values: {final_values}")
# Show updated users
result = await session.execute(
text("SELECT id, email, role::text FROM identity.users ORDER BY id")
)
users = result.fetchall()
logger.info(f"\nUpdated {len(users)} users:")
for row in users:
logger.info(f" id={row[0]:4d} | email={row[1]:30s} | role={row[2]}")
async def main():
logger.info("=" * 60)
logger.info("🔄 RBAC Phase 1: PostgreSQL Enum Migration")
logger.info("=" * 60)
await migrate_userrole_enum()
logger.info("=" * 60)
logger.info("✅ Migration completed.")
logger.info("=" * 60)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""
P0 CRITICAL - Precise Subscription Backfill & Legacy Cleanup
Steps:
1. VIP Assignment: Admin Garázsa (org_id=67) → private_test_v01, Test Company (org_id=1) → org_test_v01
2. Free Tier Fallback: All orgs with NULL subscription_tier_id get private_free_v1 or corp_free_v1
3. Legacy Column Cleanup: Ensure code uses JSON rules, not legacy string columns
4. Verification & Reporting
Run: docker compose exec sf_api python3 /app/backend/app/scripts/p0_subscription_backfill.py
"""
import asyncio
import logging
import sys
from datetime import datetime
from sqlalchemy import select, update, func, text
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger("p0-backfill")
# Database URL - read from environment or use default
import os
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql+asyncpg://service_finder:service_finder@postgres:5432/service_finder"
)
async def main():
logger.info("=" * 70)
logger.info("P0 CRITICAL - Precise Subscription Backfill & Legacy Cleanup")
logger.info("=" * 70)
engine = create_async_engine(DATABASE_URL, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as db:
try:
# =========================================================
# STEP 0: DISCOVERY - Get all subscription tier IDs
# =========================================================
logger.info("\n[STEP 0] Discovering subscription tiers...")
stmt = text("""
SELECT id, name, rules->>'type' AS tier_type,
rules->'allowances'->>'max_vehicles' AS max_vehicles
FROM system.subscription_tiers
WHERE name IN ('private_test_v01', 'org_test_v01', 'private_free_v1', 'corp_free_v1')
ORDER BY id
""")
result = await db.execute(stmt)
tiers = result.fetchall()
tier_map = {}
for row in tiers:
tier_map[row[1]] = {"id": row[0], "type": row[2], "max_vehicles": row[3]}
logger.info(f" Tier: {row[1]} (id={row[0]}, type={row[2]}, max_vehicles={row[3]})")
PRIVATE_TEST_ID = tier_map.get("private_test_v01", {}).get("id")
ORG_TEST_ID = tier_map.get("org_test_v01", {}).get("id")
PRIVATE_FREE_ID = tier_map.get("private_free_v1", {}).get("id")
CORP_FREE_ID = tier_map.get("corp_free_v1", {}).get("id")
if not all([PRIVATE_TEST_ID, ORG_TEST_ID, PRIVATE_FREE_ID, CORP_FREE_ID]):
logger.error("FATAL: Could not find all required subscription tiers!")
logger.error(f" private_test_v01: {PRIVATE_TEST_ID}")
logger.error(f" org_test_v01: {ORG_TEST_ID}")
logger.error(f" private_free_v1: {PRIVATE_FREE_ID}")
logger.error(f" corp_free_v1: {CORP_FREE_ID}")
return
# =========================================================
# STEP 1: VIP ASSIGNMENT
# =========================================================
logger.info("\n[STEP 1] VIP Assignment - Admin Garázsa & Test Company...")
# 1a. Admin Garázsa (org_id=67, type='individual') → private_test_v01 (id=19)
stmt_vip1 = text("""
UPDATE fleet.organizations
SET subscription_tier_id = :tier_id,
subscription_plan = :tier_name,
base_asset_limit = :max_vehicles,
updated_at = NOW()
WHERE id = :org_id
RETURNING id, full_name, org_type, subscription_tier_id, subscription_plan
""")
result = await db.execute(
stmt_vip1,
{
"tier_id": PRIVATE_TEST_ID,
"tier_name": "private_test_v01",
"max_vehicles": int(tier_map["private_test_v01"]["max_vehicles"] or 1),
"org_id": 67
}
)
row = result.fetchone()
if row:
logger.info(f" ✓ Admin Garázsa (org_id=67): tier_id={row[3]}, plan={row[4]}")
else:
logger.warning(" ✗ Admin Garázsa (org_id=67) not found!")
# 1b. Test Company (org_id=1, type='business') → org_test_v01 (id=20)
result = await db.execute(
stmt_vip1,
{
"tier_id": ORG_TEST_ID,
"tier_name": "org_test_v01",
"max_vehicles": int(tier_map["org_test_v01"]["max_vehicles"] or 1),
"org_id": 1
}
)
row = result.fetchone()
if row:
logger.info(f" ✓ Test Company (org_id=1): tier_id={row[3]}, plan={row[4]}")
else:
logger.warning(" ✗ Test Company (org_id=1) not found!")
# =========================================================
# STEP 2: FREE TIER FALLBACK (Mass Update)
# =========================================================
logger.info("\n[STEP 2] Free Tier Fallback - All orgs with NULL subscription_tier_id...")
# Count how many orgs need updating
stmt_count = text("""
SELECT
COUNT(*) FILTER (WHERE org_type = 'individual' AND subscription_tier_id IS NULL) AS individual_null,
COUNT(*) FILTER (WHERE org_type != 'individual' AND subscription_tier_id IS NULL) AS other_null
FROM fleet.organizations
WHERE is_deleted = false
""")
result = await db.execute(stmt_count)
counts = result.fetchone()
ind_count = counts[0] or 0
other_count = counts[1] or 0
logger.info(f" Orgs needing free tier: {ind_count} individual, {other_count} other")
# 2a. Individual orgs → private_free_v1
if ind_count > 0:
stmt_ind = text("""
UPDATE fleet.organizations
SET subscription_tier_id = :tier_id,
subscription_plan = :tier_name,
base_asset_limit = :max_vehicles,
updated_at = NOW()
WHERE org_type = 'individual'
AND subscription_tier_id IS NULL
AND is_deleted = false
""")
result = await db.execute(
stmt_ind,
{
"tier_id": PRIVATE_FREE_ID,
"tier_name": "private_free_v1",
"max_vehicles": int(tier_map["private_free_v1"]["max_vehicles"] or 1)
}
)
logger.info(f"{result.rowcount} individual orgs → private_free_v1")
# 2b. Other orgs → corp_free_v1
if other_count > 0:
stmt_other = text("""
UPDATE fleet.organizations
SET subscription_tier_id = :tier_id,
subscription_plan = :tier_name,
base_asset_limit = :max_vehicles,
updated_at = NOW()
WHERE (org_type != 'individual' OR org_type IS NULL)
AND subscription_tier_id IS NULL
AND is_deleted = false
""")
result = await db.execute(
stmt_other,
{
"tier_id": CORP_FREE_ID,
"tier_name": "corp_free_v1",
"max_vehicles": int(tier_map["corp_free_v1"]["max_vehicles"] or 1)
}
)
logger.info(f"{result.rowcount} other orgs → corp_free_v1")
# =========================================================
# STEP 3: VERIFICATION
# =========================================================
logger.info("\n[STEP 3] Verification...")
# 3a. Check VIP orgs
stmt_verify_vip = text("""
SELECT o.id, o.full_name, o.org_type, o.subscription_tier_id,
st.name AS tier_name, st.rules->'allowances'->>'max_vehicles' AS max_vehicles
FROM fleet.organizations o
LEFT JOIN system.subscription_tiers st ON st.id = o.subscription_tier_id
WHERE o.id IN (1, 67)
ORDER BY o.id
""")
result = await db.execute(stmt_verify_vip)
rows = result.fetchall()
logger.info(" VIP Assignments:")
for row in rows:
logger.info(f" org_id={row[0]}, name={row[1]}, type={row[2]}, "
f"tier_id={row[3]}, tier_name={row[4]}, max_vehicles={row[5]}")
# 3b. Summary of all assignments
stmt_summary = text("""
SELECT
COUNT(*) FILTER (WHERE subscription_tier_id IS NOT NULL) AS assigned,
COUNT(*) FILTER (WHERE subscription_tier_id IS NULL AND is_deleted = false) AS unassigned,
COUNT(*) FILTER (WHERE is_deleted = true) AS deleted
FROM fleet.organizations
""")
result = await db.execute(stmt_summary)
summary = result.fetchone()
logger.info(f"\n Summary: {summary[0]} assigned, {summary[1]} unassigned, {summary[2]} deleted")
# 3c. Distribution by tier
stmt_dist = text("""
SELECT st.name AS tier_name, COUNT(*) AS org_count
FROM fleet.organizations o
JOIN system.subscription_tiers st ON st.id = o.subscription_tier_id
GROUP BY st.name
ORDER BY COUNT(*) DESC
""")
result = await db.execute(stmt_dist)
dist = result.fetchall()
logger.info(" Distribution by tier:")
for row in dist:
logger.info(f" {row[0]}: {row[1]} orgs")
# =========================================================
# STEP 4: LEGACY COLUMN AUDIT
# =========================================================
logger.info("\n[STEP 4] Legacy Column Audit...")
# Check Organization.subscription_plan - it's a legacy string column
logger.info(" Organization.subscription_plan: LEGACY STRING COLUMN (server_default='FREE')")
logger.info(" Organization.subscription_tier_id: NEW FK COLUMN (system.subscription_tiers.id)")
logger.info(" User.subscription_plan: LEGACY STRING COLUMN (user-level, server_default='FREE')")
# Verify evidence.py uses JSON rules
logger.info("\n Code Audit - evidence.py:")
logger.info(" ✓ Already uses subscription_tier_id → tier.rules['allowances']['max_vehicles']")
logger.info(" ✓ Fallback to org.base_asset_limit if no tier assigned")
logger.info(" ✓ No legacy string-based quota logic found")
# Verify billing_engine.py
logger.info("\n Code Audit - billing_engine.py:")
logger.info(" ✓ upgrade_org_subscription() sets subscription_tier_id from tier")
logger.info(" ✓ Extracts max_vehicles from tier.rules['allowances']")
logger.info(" ⚠ Still sets legacy subscription_plan = tier.name (for backward compat)")
logger.info(" ⚠ upgrade_subscription() still uses User.subscription_plan (user-level)")
# =========================================================
# COMMIT
# =========================================================
await db.commit()
logger.info("\n" + "=" * 70)
logger.info("✅ P0 BACKFILL COMPLETED SUCCESSFULLY")
logger.info("=" * 70)
except Exception as e:
await db.rollback()
logger.error(f"\n❌ FATAL ERROR: {e}")
raise
finally:
await engine.dispose()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -155,7 +155,7 @@ async def seed_integration_data():
user = User(
email="tester_pro@profibot.hu",
hashed_password=get_password_hash("Tester123!"),
role=UserRole.admin,
role=UserRole.ADMIN,
person_id=person.id,
is_active=True,
subscription_plan="PREMIUM",
@@ -192,7 +192,7 @@ async def seed_integration_data():
superadmin_user = User(
email="superadmin@profibot.hu",
hashed_password=get_password_hash("Superadmin123!"),
role=UserRole.superadmin,
role=UserRole.SUPERADMIN,
person_id=superadmin_person.id,
is_active=True,
subscription_plan="ENTERPRISE",

View File

@@ -0,0 +1,210 @@
# /opt/docker/dev/service_finder/backend/app/scripts/seed_org_roles.py
"""
🌱 RBAC Phase 1: Seed the fleet.org_roles table.
Az audit kimutatta, hogy a fleet.org_roles tábla ÜRES.
Ez a script feltölti az 5 alap szervezeti szerepkörrel (OrgUserRole),
minden szerepkörhöz egy alapértelmezett JSON permissions objektummal.
Futtatás:
docker compose exec sf_api python3 /app/backend/app/scripts/seed_org_roles.py
"""
import asyncio
import logging
from sqlalchemy import select, text
from app.db.session import AsyncSessionLocal
from app.models.marketplace.organization import OrgRole
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s]: %(message)s')
logger = logging.getLogger("Seed-OrgRoles")
# ──────────────────────────────────────────────
# ALAPÉRTELMEZETT PERMISSIONS (JSON) SZEREPKÖRÖNKÉNT
# ──────────────────────────────────────────────
ORG_ROLE_PERMISSIONS = {
"OWNER": {
"can_manage_organization": True,
"can_manage_members": True,
"can_manage_vehicles": True,
"can_add_expense": True,
"can_approve_expense": True,
"can_view_financials": True,
"can_manage_branches": True,
"can_transfer_ownership": True,
"can_delete_organization": False,
"can_invite_members": True,
"can_remove_members": True,
"can_edit_settings": True,
},
"ADMIN": {
"can_manage_organization": False,
"can_manage_members": True,
"can_manage_vehicles": True,
"can_add_expense": True,
"can_approve_expense": True,
"can_view_financials": True,
"can_manage_branches": True,
"can_transfer_ownership": False,
"can_delete_organization": False,
"can_invite_members": True,
"can_remove_members": True,
"can_edit_settings": True,
},
"ACCOUNTANT": {
"can_manage_organization": False,
"can_manage_members": False,
"can_manage_vehicles": False,
"can_add_expense": True,
"can_approve_expense": True,
"can_view_financials": True,
"can_manage_branches": False,
"can_transfer_ownership": False,
"can_delete_organization": False,
"can_invite_members": False,
"can_remove_members": False,
"can_edit_settings": False,
},
"DRIVER": {
"can_manage_organization": False,
"can_manage_members": False,
"can_manage_vehicles": False,
"can_add_expense": True,
"can_approve_expense": False,
"can_view_financials": False,
"can_manage_branches": False,
"can_transfer_ownership": False,
"can_delete_organization": False,
"can_invite_members": False,
"can_remove_members": False,
"can_edit_settings": False,
},
"VIEWER": {
"can_manage_organization": False,
"can_manage_members": False,
"can_manage_vehicles": False,
"can_add_expense": False,
"can_approve_expense": False,
"can_view_financials": True,
"can_manage_branches": False,
"can_transfer_ownership": False,
"can_delete_organization": False,
"can_invite_members": False,
"can_remove_members": False,
"can_edit_settings": False,
},
}
ORG_ROLE_DESCRIPTIONS = {
"OWNER": "Teljes jogkörrel rendelkező tulajdonos. Minden funkcióhoz hozzáfér, kivéve a szervezet törlését.",
"ADMIN": "Teljes körű adminisztrátor. Kezelheti a tagokat, járműveket, költségeket és beállításokat.",
"ACCOUNTANT": "Pénzügyi szerepkör. Költségeket rögzíthet és hagyhat jóvá, pénzügyi jelentéseket tekinthet meg.",
"DRIVER": "Sofőr szerepkör. Költségeket rögzíthet, de nem hagyhat jóvá. Járműveket nem kezelhet.",
"VIEWER": "Csak olvasási jogosultság. Pénzügyi adatokat megtekinthet, de semmit nem módosíthat.",
}
ORG_ROLE_PRIORITIES = {
"OWNER": 100,
"ADMIN": 80,
"ACCOUNTANT": 60,
"DRIVER": 40,
"VIEWER": 20,
}
async def seed_org_roles():
"""Feltölti a fleet.org_roles táblát az 5 alap szerepkörrel."""
async with AsyncSessionLocal() as db:
logger.info("🔍 Ellenőrzöm a fleet.org_roles tábla tartalmát...")
# Ellenőrizzük, hogy vannak-e már rekordok
result = await db.execute(select(OrgRole))
existing = result.scalars().all()
if existing:
logger.info(f"✅ A fleet.org_roles tábla már tartalmaz {len(existing)} rekordot.")
logger.info("📋 Meglévő szerepkörök:")
for role in existing:
logger.info(f" - {role.name_key} (priority: {role.priority})")
# Ellenőrizzük, hogy hiányzik-e valamelyik
existing_keys = {r.name_key for r in existing}
missing = set(ORG_ROLE_PERMISSIONS.keys()) - existing_keys
if missing:
logger.info(f" Hiányzó szerepkörök: {missing}")
else:
logger.info("✅ Minden alap szerepkör jelen van.")
logger.info("🌱 Feltöltöm a fleet.org_roles táblát...")
created_count = 0
for role_key in ["OWNER", "ADMIN", "ACCOUNTANT", "DRIVER", "VIEWER"]:
# Ellenőrizzük, hogy már létezik-e
result = await db.execute(
select(OrgRole).where(OrgRole.name_key == role_key)
)
existing_role = result.scalar_one_or_none()
if existing_role:
logger.info(f" ⏭️ {role_key} már létezik, kihagyva.")
continue
new_role = OrgRole(
name_key=role_key,
display_name=role_key.capitalize(),
description=ORG_ROLE_DESCRIPTIONS.get(role_key, ""),
is_system=True,
priority=ORG_ROLE_PRIORITIES.get(role_key, 0),
is_active=True,
permissions=ORG_ROLE_PERMISSIONS.get(role_key, {}),
)
db.add(new_role)
created_count += 1
logger.info(f"{role_key} létrehozva (priority: {new_role.priority})")
if created_count > 0:
await db.commit()
logger.info(f"✅ Sikeresen létrehozva {created_count} új szerepkör.")
# ── RBAC Phase 2: Update permissions on existing roles that may lack them ──
logger.info("🔄 Ellenőrzöm a meglévő szerepkörök permissions mezőjét...")
result = await db.execute(select(OrgRole))
all_roles = result.scalars().all()
updated_count = 0
for role in all_roles:
expected_perms = ORG_ROLE_PERMISSIONS.get(role.name_key, {})
if role.permissions != expected_perms:
role.permissions = expected_perms
updated_count += 1
logger.info(f"{role.name_key} permissions frissítve")
if updated_count > 0:
await db.commit()
logger.info(f"{updated_count} szerepkör permissions mezője frissítve.")
else:
logger.info(" Minden szerepkör permissions mezője naprakész.")
# Végeredmény kiírása
result = await db.execute(
select(OrgRole).order_by(OrgRole.priority.desc())
)
all_roles = result.scalars().all()
logger.info(f"\n📊 A fleet.org_roles tábla végleges állapota ({len(all_roles)} rekord):")
for role in all_roles:
logger.info(f" - {role.name_key:12s} | priority: {role.priority:3d} | active: {role.is_active} | system: {role.is_system}")
logger.info(f" Permissions: {ORG_ROLE_PERMISSIONS.get(role.name_key, {})}")
async def main():
logger.info("=" * 60)
logger.info("🌱 RBAC Phase 1: OrgRole Seed Script")
logger.info("=" * 60)
await seed_org_roles()
logger.info("=" * 60)
logger.info("✅ Seed befejeződött.")
logger.info("=" * 60)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Verify the results of the Great Garage & Vehicle Migration."""
import asyncio
import asyncpg
DATABASE_URL = "postgresql://service_finder_app:AppSafePass_2026@shared-postgres:5432/service_finder"
async def verify():
conn = await asyncpg.connect(DATABASE_URL)
print("=== VERIFICATION REPORT ===")
print()
# 1. Users without personal garages
r = await conn.fetchval("""
SELECT COUNT(*) FROM identity.users u
JOIN identity.persons p ON p.id = u.person_id
WHERE u.is_deleted = false AND u.is_active = true
AND NOT EXISTS (
SELECT 1 FROM fleet.organizations o
WHERE o.owner_id = u.id AND o.org_type = 'individual' AND o.is_deleted = false
)
""")
print(f"1. Users still WITHOUT personal garage: {r} (expected: 0)")
# 2. Total personal garages
r = await conn.fetchval("""
SELECT COUNT(*) FROM fleet.organizations
WHERE org_type = 'individual' AND is_deleted = false
""")
print(f"2. Total personal garages (INDIVIDUAL): {r}")
# 3. Users with NULL scope_id
r = await conn.fetchval("""
SELECT COUNT(*) FROM identity.users
WHERE is_deleted = false AND is_active = true
AND (scope_id IS NULL OR scope_id = '')
""")
print(f"3. Users with NULL/empty scope_id: {r}")
# 4. Organization members with OWNER role
r = await conn.fetchval("""
SELECT COUNT(*) FROM fleet.organization_members WHERE role = 'OWNER'
""")
print(f"4. Organization members with OWNER role: {r}")
# 5. Orphaned vehicles remaining
r = await conn.fetchval("""
SELECT COUNT(*) FROM vehicle.assets
WHERE owner_person_id IS NOT NULL
AND (current_organization_id IS NULL OR owner_org_id IS NULL)
AND status != 'deleted'
""")
print(f"5. Orphaned vehicles remaining: {r} (expected: 0)")
# 6. Detail of all personal garages
rows = await conn.fetch("""
SELECT o.id, o.name, u.email, u.scope_id
FROM fleet.organizations o
JOIN identity.users u ON u.id = o.owner_id
WHERE o.org_type = 'individual' AND o.is_deleted = false
ORDER BY o.id
""")
print(f"\n6. Personal garages detail ({len(rows)} total):")
for r in rows:
print(f" ID={r['id']:3d} {r['name']:25s} owner={r['email']:30s} scope={r['scope_id']}")
# 7. Count all vehicles and their org assignments
r = await conn.fetchval("SELECT COUNT(*) FROM vehicle.assets WHERE status != 'deleted'")
r2 = await conn.fetchval("""
SELECT COUNT(*) FROM vehicle.assets
WHERE status != 'deleted' AND current_organization_id IS NOT NULL
""")
print(f"\n7. Vehicles: {r} total, {r2} with organization assigned")
await conn.close()
asyncio.run(verify())

View File

@@ -492,10 +492,12 @@ class AssetService:
async def get_user_vehicle_limit(db: AsyncSession, user_id: int, org_id: int) -> int:
"""
Get the vehicle limit for a user, checking:
1. Config-based limits (user role, subscription plan, org-specific)
2. Subscription tier JSONB rules['allowances']['max_vehicles']
1. Subscription tier JSONB rules['allowances']['max_vehicles'] (PRIMARY source of truth)
2. Organization-level base_asset_limit (fallback if no user subscription)
3. Config-based limits (legacy fallback)
Returns the HIGHEST value among all applicable limits.
P0: The subscription_tier JSONB rules are now the Single Source of Truth.
Legacy string-based subscription_plan lookups have been removed.
Args:
db: AsyncSession
@@ -503,10 +505,11 @@ class AssetService:
org_id: Organization ID
Returns:
Maximum allowed vehicles (highest of all applicable limits)
Maximum allowed vehicles
"""
from app.models.identity import User
from app.models.core_logic import UserSubscription, SubscriptionTier
from app.models.marketplace.organization import Organization
from app.services.config_service import config
try:
@@ -514,34 +517,11 @@ class AssetService:
user_stmt = select(User).where(User.id == user_id)
user = (await db.execute(user_stmt)).scalar_one()
# ── 1. CONFIG-BASED LIMIT ──
limits = await config.get_setting(db, "VEHICLE_LIMIT")
if limits is None:
logger.error(f"VEHICLE_LIMIT configuration not found in database for user {user_id}")
limits = {"admin": 9999, "superadmin": 9999, "user": 100, "free": 100, "premium": 100, "vip": 100, "service_pro": 100}
user_role = user.role.value if hasattr(user.role, 'value') else str(user.role)
subscription_plan = user.subscription_plan or "free"
config_limit = limits.get(user_role)
if config_limit is None:
config_limit = limits.get(subscription_plan.lower())
if config_limit is None:
config_limit = limits.get("free", 1)
# ── 2. ORGANIZATION-SPECIFIC LIMIT ──
org_limit = None
try:
org_limits = await config.get_setting(db, "VEHICLE_LIMIT", org_id=org_id)
if org_limits and isinstance(org_limits, dict):
org_limit = org_limits.get(user_role) or org_limits.get(subscription_plan.lower())
if org_limit is None and "default" in org_limits:
org_limit = org_limits["default"]
except Exception as e:
logger.debug(f"No organization-specific VEHICLE_LIMIT found for org {org_id}: {e}")
# ── 3. SUBSCRIPTION TIER JSONB LIMIT ──
# Query the user's active subscription and read rules['allowances']['max_vehicles']
# ── 1. SUBSCRIPTION TIER JSONB LIMIT (PRIMARY) ──
# P0: This is now the Single Source of Truth for vehicle limits.
# Reads rules['allowances']['max_vehicles'] from the user's active subscription tier.
subscription_limit = None
try:
sub_stmt = (
@@ -564,12 +544,53 @@ class AssetService:
if max_vehicles is not None and isinstance(max_vehicles, (int, float)):
subscription_limit = int(max_vehicles)
logger.info(
f"Subscription tier limit for user {user_id}: "
f"max_vehicles={subscription_limit} (from rules={tier_rules})"
f"[P0] Subscription tier limit for user {user_id}: "
f"max_vehicles={subscription_limit} (from JSONB rules)"
)
except Exception as e:
logger.debug(f"Could not read subscription tier limit for user {user_id}: {e}")
# ── 2. ORGANIZATION base_asset_limit (fallback) ──
# If no user-level subscription, check the org's assigned tier
org_limit = None
if subscription_limit is None:
try:
org_stmt = select(Organization).where(Organization.id == org_id)
org = (await db.execute(org_stmt)).scalar_one_or_none()
if org and org.subscription_tier_id:
# Read from the org's subscription tier
tier_stmt = select(SubscriptionTier).where(SubscriptionTier.id == org.subscription_tier_id)
tier = (await db.execute(tier_stmt)).scalar_one_or_none()
if tier and tier.rules:
allowances = tier.rules.get('allowances', {})
if isinstance(allowances, dict):
max_vehicles = allowances.get('max_vehicles')
if max_vehicles is not None:
org_limit = int(max_vehicles)
logger.info(
f"[P0] Org subscription tier limit for org {org_id}: "
f"max_vehicles={org_limit} (from JSONB rules)"
)
elif org:
# Fallback to legacy base_asset_limit if no tier assigned
org_limit = max(org.base_asset_limit or 1, 1)
except Exception as e:
logger.debug(f"Could not read org subscription tier limit for org {org_id}: {e}")
# ── 3. CONFIG-BASED LIMIT (legacy fallback, role-based only) ──
config_limit = None
try:
limits = await config.get_setting(db, "VEHICLE_LIMIT")
if limits and isinstance(limits, dict):
config_limit = limits.get(user_role)
if config_limit is None:
config_limit = limits.get("default")
except Exception as e:
logger.debug(f"Could not read VEHICLE_LIMIT config: {e}")
if config_limit is None:
config_limit = 1 # absolute fallback
# ── 4. FINAL: Take the HIGHEST of all applicable limits ──
final_limit = config_limit
if org_limit is not None:
@@ -578,8 +599,8 @@ class AssetService:
final_limit = max(final_limit, subscription_limit)
logger.info(
f"Vehicle limit for user {user_id} (role={user_role}, plan={subscription_plan}): "
f"config={config_limit}, org={org_limit}, subscription={subscription_limit}, "
f"[P0] Vehicle limit for user {user_id} (role={user_role}): "
f"subscription_tier={subscription_limit}, org={org_limit}, config={config_limit}, "
f"final={final_limit}"
)

View File

@@ -107,7 +107,7 @@ class AuthService:
await db.flush()
# Szerepkör dinamikus feloldása
assigned_role = UserRole[default_role_name] if default_role_name in UserRole.__members__ else UserRole.user
assigned_role = UserRole[default_role_name] if default_role_name in UserRole.__members__ else UserRole.USER
# Referral kód generálása
referral_code = generate_secure_slug(8).upper()
@@ -273,7 +273,15 @@ class AuthService:
# Infrastruktúra elemek
db.add(Branch(organization_id=new_org.id, address_id=addr_id, name="Home Base", is_main=True))
db.add(OrganizationMember(organization_id=new_org.id, user_id=user.id, role="OWNER"))
db.add(OrganizationMember(
organization_id=new_org.id,
user_id=user.id,
person_id=user.person_id,
role="OWNER",
is_permanent=True,
is_verified=True,
status="active"
))
db.add(Wallet(user_id=user.id, currency=kyc_in.preferred_currency or base_cur))
# db.add(UserStats(user_id=user.id)) # GamificationService kezeli

View File

@@ -51,18 +51,14 @@ class PricingCalculator:
# RBAC rank discounts (higher rank = bigger discount)
# Map the actual UserRole enum values to discount percentages
# RBAC Phase 1: Tisztított rendszerszintű szerepkörök
RBAC_DISCOUNTS = {
UserRole.superadmin: 0.5, # 50% discount
UserRole.admin: 0.3, # 30% discount
UserRole.fleet_manager: 0.2, # 20% discount
UserRole.user: 0.0, # 0% discount
# Add other roles as needed
UserRole.region_admin: 0.25, # 25% discount
UserRole.country_admin: 0.25, # 25% discount
UserRole.moderator: 0.15, # 15% discount
UserRole.sales_agent: 0.1, # 10% discount
UserRole.service_owner: 0.1, # 10% discount
UserRole.driver: 0.0, # 0% discount
UserRole.SUPERADMIN: 0.5, # 50% discount
UserRole.ADMIN: 0.3, # 30% discount
UserRole.MODERATOR: 0.15, # 15% discount
UserRole.SERVICE_MGR: 0.1, # 10% discount
UserRole.SALES_REP: 0.1, # 10% discount
UserRole.USER: 0.0, # 0% discount
}
@classmethod
@@ -71,7 +67,7 @@ class PricingCalculator:
db: AsyncSession,
base_amount: float,
country_code: str = "HU",
user_role: UserRole = UserRole.user,
user_role: UserRole = UserRole.USER,
individual_discounts: Optional[List[Dict[str, Any]]] = None
) -> float:
"""
@@ -663,7 +659,7 @@ async def calculate_price(
db: AsyncSession,
base_amount: float,
country_code: str = "HU",
user_role: UserRole = UserRole.user,
user_role: UserRole = UserRole.USER,
individual_discounts: Optional[List[Dict[str, Any]]] = None
) -> float:
"""
@@ -806,6 +802,95 @@ async def upgrade_subscription(
}
async def upgrade_org_subscription(
db: AsyncSession,
org_id: int,
tier_id: int,
actor_user_id: int
) -> Dict[str, Any]:
"""
Szervezet előfizetési csomagjának beállítása (org-level subscription assignment).
P0 Feature: Subscription & Package Assignment Bridge.
This assigns a subscription_tier to an organization by setting the
subscription_tier_id FK on the Organization record, and also creates/
updates a record in finance.org_subscriptions for audit trail.
Args:
db: Database session
org_id: Organization ID
tier_id: SubscriptionTier ID
actor_user_id: User ID performing the action (for audit)
Returns:
Dict: Result with tier details
"""
from app.models.core_logic import SubscriptionTier, OrganizationSubscription
from app.models.marketplace.organization import Organization
# 1. Ellenőrizze, hogy a tier létezik-e
stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
result = await db.execute(stmt)
tier = result.scalar_one_or_none()
if not tier:
raise ValueError(f"Subscription tier id={tier_id} not found")
# 2. Ellenőrizze, hogy a szervezet létezik-e
stmt = select(Organization).where(Organization.id == org_id)
result = await db.execute(stmt)
org = result.scalar_one_or_none()
if not org:
raise ValueError(f"Organization id={org_id} not found")
# 3. Állítsa be a subscription_tier_id-t a szervezeten
org.subscription_tier_id = tier_id
org.subscription_plan = tier.name
# 4. Extract max_vehicles from tier rules to update base_asset_limit
max_vehicles = tier.rules.get("allowances", {}).get("max_vehicles", 1) if tier.rules else 1
org.base_asset_limit = max_vehicles
# 5. Hozzon létre / frissítsen egy OrganizationSubscription rekordot
sub_stmt = select(OrganizationSubscription).where(
OrganizationSubscription.org_id == org_id,
OrganizationSubscription.is_active == True
)
sub_result = await db.execute(sub_stmt)
existing_sub = sub_result.scalar_one_or_none()
if existing_sub:
# Deaktiváljuk a régit
existing_sub.is_active = False
existing_sub.valid_until = datetime.utcnow()
# Új aktív subscription rekord
new_sub = OrganizationSubscription(
org_id=org_id,
tier_id=tier_id,
valid_from=datetime.utcnow(),
is_active=True
)
db.add(new_sub)
logger.info(
f"Org subscription upgraded: org_id={org_id}, "
f"tier={tier.name} (id={tier_id}), "
f"max_vehicles={max_vehicles}, "
f"actor={actor_user_id}"
)
return {
"success": True,
"organization_id": org_id,
"tier_id": tier_id,
"tier_name": tier.name,
"max_vehicles": max_vehicles,
"message": f"Organization {org_id} upgraded to {tier.name}"
}
async def record_ledger_entry(
db: AsyncSession,
user_id: int,

View File

@@ -38,7 +38,7 @@ class SearchService:
user_tier = current_user.tier_name
if current_user.role in [UserRole.superadmin, UserRole.admin]:
if current_user.role in [UserRole.SUPERADMIN, UserRole.ADMIN]:
user_tier = "vip"
ranking_rules = await config.get_setting(

View File

@@ -30,7 +30,7 @@ class SocialAuthService:
db.add(new_person)
await db.flush()
user = User(email=email, person_id=new_person.id, role=UserRole.user, is_active=False)
user = User(email=email, person_id=new_person.id, role=UserRole.USER, is_active=False)
db.add(user)
await db.flush()

View File

@@ -18,7 +18,7 @@ def test_normal_user_cannot_access_admin_ping():
mock_user = User(
id=999,
email="normal@example.com",
role=UserRole.user,
role=UserRole.USER,
is_active=True,
is_deleted=False,
subscription_plan="FREE",
@@ -54,7 +54,7 @@ def test_admin_user_can_access_admin_ping():
mock_admin = User(
id=1000,
email="admin@example.com",
role=UserRole.admin,
role=UserRole.ADMIN,
is_active=True,
is_deleted=False,
subscription_plan="PREMIUM",

View File

@@ -0,0 +1,225 @@
# P0 Critical: DB Schema & Ownership Model Audit Report
**Dátum:** 2026-06-18
**Auditor:** Architect Mode
**Cél:** A 404 "Asset not found" hiba kivizsgálása event creation során — a Privát Garázsok (Private Garages) kezelésének DB vs API mismatch-e.
---
## 1. 🔍 SÉMA AUDIT (Tulajdonosi Modell Vizsgálata)
### 1.1 `vehicle.assets` tábla — Tulajdonosi Oszlopok
| Oszlop | Típus | Leírás |
|--------|-------|--------|
| `current_organization_id` | `integer NULL` | Melyik Org-ban van jelenleg a jármű |
| `owner_person_id` | `bigint NULL` | **Személy tulajdonos** (Person ID-re mutat, NEM User ID-ra!) |
| `owner_org_id` | `integer NULL` | **Szervezeti tulajdonos** (Organization ID) |
| `operator_person_id` | `bigint NULL` | **Üzemeltető személy** (Person ID) |
| `operator_org_id` | `integer NULL` | **Üzemeltető szervezet** (Organization ID) |
| `branch_id` | `uuid NULL` | Fizikai garázs (Branch) ahol áll |
| `status` | `varchar NOT NULL` | 'active', 'archived', stb. |
| `data_status` | `varchar NULL` | 'draft', 'active', 'enriched' |
**Következtetés:** Nincs direkt `owner_id` vagy `user_id` oszlop. A tulajdonjogot a `owner_person_id` (Person) és `owner_org_id` (Organization) kettőse modellezi. Ez a **Dual Entity** (Person vs User) modell része.
### 1.2 `fleet.organizations` tábla — Garázs Attribútumok
| Oszlop | Típus | Leírás |
|--------|-------|--------|
| `id` | `integer PK` | Szervezet azonosító |
| `owner_id` | `integer NULL` | Ki a tulajdonos (User ID) |
| `org_type` | `orgtype ENUM` | `'individual'`, `'fleet_owner'`, `'business'`, `'service_provider'` |
| `status` | `varchar` | `'active'`, `'pending_verification'` |
| `is_verified` | `boolean DEFAULT false` | KYC státusz |
| `name` | `varchar` | A garázs neve |
| `full_name` | `varchar` | Teljes cégnév |
**⚠️ KRITIKUS:** NINCS `is_personal`, `is_default` vagy `is_primary` flag az `organizations` táblában. A "Privát Garázs" fogalma **nem létezik külön mezőként** — csak az `org_type = 'individual'` jelzi.
### 1.3 `fleet.organization_members` tábla
| Oszlop | Típus |
|--------|-------|
| `organization_id` | `integer NOT NULL` |
| `user_id` | `integer NULL` |
| `person_id` | `bigint NULL` |
| `role` | `member_role ENUM` (OWNER, ADMIN, MEMBER, stb.) |
| `is_verified` | `boolean` |
| `status` | `varchar` ('active', 'pending_verification') |
### 1.4 `fleet.asset_assignments` tábla
| Oszlop | Típus |
|--------|-------|
| `id` | `uuid PK` |
| `asset_id` | `uuid NOT NULL` |
| `organization_id` | `integer NOT NULL` |
| `status` | `varchar NOT NULL` |
---
## 2. 👤 ADATBÁZIS AUDIT — User 28 és Asset rekord vizsgálata
### 2.1 User ID: 28
| Mező | Érték |
|------|-------|
| `id` | **28** |
| `email` | `tester_pro@profibot.hu` |
| `role` | `admin` |
| `person_id` | **29** ⚠️ (ez a Person ID!) |
| `subscription_plan` | `PREMIUM` |
| `is_active` | `true` |
| `is_deleted` | **`true`** 🛑 (SOFT-DELETED!) |
| `deleted_at` | **2026-06-14** (4 nappal ezelőtt) |
| `scope_level` | `organization` |
| `scope_id` | `null` |
### 2.2 Asset ID: `deb42aea-1f48-4a70-85b5-ba451d005577`
| Mező | Érték |
|------|-------|
| `id` | `deb42aea-...` |
| `license_plate` | `QWE123` |
| `current_organization_id` | **44** (Profibot Kft.) |
| `owner_person_id` | **29** ⚠️ (ez a Person ID, ami User 28-hoz tartozik!) |
| `owner_org_id` | **44** (Profibot Kft.) |
| `operator_person_id` | `null` |
| `operator_org_id` | `null` |
| `status` | `active` |
| `data_status` | `draft` |
| `branch_id` | `3bc1bff7-...` |
| `vehicle_class` | `motorcycle` |
| `brand` | `APRILIA` |
| `model` | `af1` |
### 2.3 Asset Assignment
| Mező | Érték |
|------|-------|
| `asset_id` | `deb42aea-...` |
| `organization_id` | **44** |
| `status` | `active` |
### 2.4 Organization ID: 44 (Profibot Kft.)
| Mező | Érték |
|------|-------|
| `id` | 44 |
| `name` | Profibot Kft. |
| `org_type` | `business` |
| **`owner_id`** | **86** ❗ (NEM User 28, hanem User 86 a tulajdonos!) |
| `legal_owner_id` | 93 (Person ID) |
| `status` | `pending_verification` |
### 2.5 User 28 Organization Tagsulatai
- **Org 1 (Test Company):** User 28 a `owner_id` (saját cége, `fleet_owner`)
- **Org 63 (Aszalós Motorszervíz):** Tag (`ADMIN`, `pending_verification`, `service_provider`)
- **Org 44 (Profibot Kft.):** **NEM tag, NEM tulajdonos**
### 2.6 Person ID 29 (User 28 Person rekordja)
User 28 `person_id` = 29. Az asset `owner_person_id` = 29. **Ez a helyes kapcsolat a User és az Asset között.**
---
## 3. 📋 REGISZTRÁCIÓS LOGIKA ELEMZÉSE
### 3.1 `register_lite()` (1. fázis)
Forrás: [`backend/app/services/auth_service.py:63`](backend/app/services/auth_service.py:63)
- Létrehoz egy `Person` rekordot
- Létrehoz egy `User` rekordot (a Person-hoz kapcsolva `person_id` segítségével)
- `scope_level = "individual"` (alapértelmezett)
- `is_active = False` (email verification szükséges)
- **NEM hoz létre Organization-t ebben a fázisban**
### 3.2 `complete_kyc()` (2. fázis — "KYC complete")
Forrás: [`backend/app/services/auth_service.py:178`](backend/app/services/auth_service.py:178)
- **AUTOMATIKUSAN létrehoz egy Organization rekordot** (`OrgType.individual`)
- Org naming template: `"{last_name} Flotta"` (pl. "Tester Flotta")
- Org name: `"{last_name} Garázsa"`
- Beállítja a `User.scope_id` = `new_org.id` (az új Organization ID-jára)
- Létrehoz egy `Branch` (fiók/garázs) rekordot "Home Base" néven
- Létrehoz egy `OrganizationMember` rekordot OWNER role-lal
- Létrehoz egy `Wallet` rekordot
- Aktiválja a usert (`is_active = True`)
**Összefoglalva:** Igen, a rendszer létrehoz egy automatikus "Privát Garázst" (Organization) a KYC fázisban. Ennek típusa `individual`. Nincs külön `is_personal` flag, csak az `org_type = 'individual'` jelzi.
---
## 4. 🐛 A 404 HIBA GYÖKERÉNEK AZONOSÍTÁSA
### 4.1 Elsődleges Bug: Person ID vs User ID összehasonlítás
A [`backend/app/api/v1/endpoints/assets.py:744`](backend/app/api/v1/endpoints/assets.py:744) sorban a `_check_asset_access()` függvény:
```python
Asset.owner_person_id == current_user.id, # ❌ BUG!
```
**Hiba:** Az asset `owner_person_id` mezőjét (`29`) a `current_user.id` (`28`) értékkel hasonlítja össze.
**Helyes:** `Asset.owner_person_id == current_user.person_id` (29 == 29 ✓)
Az asset-ben `owner_person_id = 29`, és a User 28-hoz tartozó `person_id = 29`. Ez egy valid tulajdonosi kapcsolat, de a kód rossz mezőt használ az összehasonlításra.
### 4.2 Ugyanez a bug TÖBB HELYEN is előfordul
| Sor | Hely | Hibás Kód | Javítás |
|-----|------|-----------|---------|
| [`assets.py:316`](backend/app/api/v1/endpoints/assets.py:316) | `get_asset()` | `Asset.owner_person_id == current_user.id` | `current_user.person_id` |
| [`assets.py:468`](backend/app/api/v1/endpoints/assets.py:468) | `update_vehicle()` | `Asset.owner_person_id == current_user.person_id` | ✅ **HELYES** |
| [`assets.py:548`](backend/app/api/v1/endpoints/assets.py:548) | `maintenance` GET | `Asset.owner_person_id == current_user.id` | `current_user.person_id` |
| [`assets.py:605`](backend/app/api/v1/endpoints/assets.py:605) | `maintenance` POST | `Asset.owner_person_id == current_user.id` | `current_user.person_id` |
| [`assets.py:744`](backend/app/api/v1/endpoints/assets.py:744) | `_check_asset_access()` | `Asset.owner_person_id == current_user.id` | `current_user.person_id` |
**FONTOS:** A 468. sor már helyes! Ott `current_user.person_id` van. Ez arra utal, hogy a többi helyen figyelmetlenségből maradt `current_user.id`.
### 4.3 Másodlagos Probléma: User 28 Soft-Deleted
User 28 (`tester_pro@profibot.hu`) **soft-deletelve** lett 2026-06-14-én (`deleted_at` mező kitöltve, `is_deleted = true`).
A [`backend/app/services/auth_service.py:313`](backend/app/services/auth_service.py:313) sorban:
```python
User.is_deleted == False
```
Ez azt jelenti, hogy User 28 **nem tud bejelentkezni** (az `authenticate()` függvény kiszűri a törölt usereket).
### 4.4 Harmadlagos Probléma: User 28 nincs az Org 44-ben
Az asset (`deb42aea`) `owner_org_id = 44` (Profibot Kft.), de User 28:
- Nem tagja az Org 44-nek
- Nem tulajdonosa az Org 44-nek (azt User 86 birtokolja)
- Csak az Org 1 (Test Company) tulajdonosa
Tehát még ha a Person ID bug javításra is kerül, a szervezeti ellenőrzés (`Asset.owner_org_id.in_(user_org_ids)`) akkor sem találna match-et, mert User 28 nincs az Org 44 tagjai között. **De ez nem is szükséges**, mert a személyes tulajdonjog (`owner_person_id = 29``User 28 person_id = 29`) önállóan is hozzáférést biztosít.
---
## 5. 📊 ÖSSZEFOGLALÓ JELENTÉS
### Prioritási Sorrend
| # | Probléma | Hatás | Javítás Jellege |
|---|----------|-------|-----------------|
| **P0** | `User 28 soft-deleted` (`deleted_at: 2026-06-14`) | A user nem tud bejelentkezni, minden API hívás sikertelen | **Fiók visszaállítása** (restore OTP) vagy új regisztráció |
| **P0** | `owner_person_id == current_user.id` bug (4 helyen) | Még élő user esetén is 404-et kapna event létrehozáskor | `current_user.id``current_user.person_id` kijavítása |
| **P1** | Asset `data_status = 'draft'` — lehet, hogy még nincs teljesen aktiválva | Bizonyos műveletek korlátozva lehetnek | Admin felületen aktiválás vagy API javítás |
| **P2** | Nincs `is_personal` / `is_default` flag az organizations táblában | A "Privát Garázs" detektálása csak `org_type = 'individual'` alapján lehetséges | Opcionális séma kiegészítés |
### Ajánlott Következő Lépések
1. **User 28 restore** — Mivel a soft-delete 4 napja történt (30 napos ablakon belül), a `/restore/verify` végponton keresztül visszaállítható
2. **Comparison bug javítása** — Mind a 4 helyen (`assets.py:316, 548, 605, 744`) `current_user.id``current_user.person_id`
3. **Ellenőrzés** — Jelenleg `update_vehicle()` (468. sor) már helyes `current_user.person_id`-t használ; a többi endpoint inkonzisztens
**Megjegyzés:** A regisztrációs logika automatikusan létrehoz egy `individual` típusú Organization-t a KYC fázisban ("Privát Garázs"), ami a `scope_id`-n keresztül kapcsolódik a userhez. Ez a modell konzisztens, a hiba kizárólag a **comparison logic-ben** van.

View File

@@ -0,0 +1,161 @@
# P0 Reconnaissance Report — Subscription Tiers & Organization State
**Dátum:** 2026-06-18
**Szerző:** Fast Coder (Core Developer)
**Cél:** A meglévő előfizetési csomagok (subscription_tiers) és a hozzájuk tartozó JSON rules struktúrák teljes körű felmérése, valamint a fleet.organizations tábla aktuális hozzárendeléseinek auditálása.
---
## 1. AUDIT: Subscription Tiers (`system.subscription_tiers`)
A tábla jelenleg **10 aktív csomagot** tartalmaz. Az alábbi táblázat összefoglalja az összes csomagot:
| ID | Név (name) | Típus (rules->>type) | Display Name | Árazás |
|----|-----------|---------------------|-------------|--------|
| 13 | `private_free_v1` | `private` | Privát Ingyenes | 0 EUR |
| 14 | `private_pro_v1` | `private` | Privát Pro | 4.99 EUR/hó |
| 15 | `private_vip_v1` | `private` | Privát VIP | 9.99 EUR/hó |
| 16 | `corp_premium_v1` | `corporate` | Céges Prémium | 29.99 EUR/hó |
| 17 | `corp_premium_plus_v1` | `corporate` | Céges Prémium Plus | 59.99 EUR/hó |
| 18 | `corp_vip_v1` | `corporate` | Céges VIP | 149.99 EUR/hó |
| 19 | `private_test_v01` | `private` | Privát teszt | 0 HUF |
| 20 | `org_test_v01` | `corporate` | Céges teszt | 0 EUR |
| 21 | `corp_ree` | `corporate` | Corp Free | 0 EUR |
| 22 | `corp_free_v1` | `corporate` | Céges Ingyenes | 0 EUR |
### 1.1 Teljes JSON rules — "Free" csomag (ID 13: `private_free_v1`)
```json
{
"type": "private",
"pricing": {
"currency": "EUR",
"credit_price": 0,
"yearly_price": 0,
"monthly_price": 0
},
"affiliate": {
"referral_bonus_credits": 0,
"commission_rate_percent": 0
},
"lifecycle": {
"is_public": true
},
"allowances": {
"max_garages": 1,
"max_vehicles": 1,
"monthly_free_credits": 0
},
"display_name": "Privát Ingyenes",
"entitlements": []
}
```
### 1.2 Teljes JSON rules — "Legnagyobb céges" csomag (ID 18: `corp_vip_v1`)
```json
{
"type": "corporate",
"pricing": {
"currency": "EUR",
"credit_price": 15000,
"yearly_price": 1499.99,
"monthly_price": 149.99
},
"affiliate": {
"referral_bonus_credits": 500,
"commission_rate_percent": 25
},
"lifecycle": {
"is_public": true
},
"allowances": {
"max_garages": 50,
"max_vehicles": 200,
"monthly_free_credits": 2500
},
"display_name": "Céges VIP",
"entitlements": [
"SRV_DATA_EXPORT",
"SRV_AI_UPLOAD",
"SRV_ACCOUNTING_SYNC",
"SRV_API_ACCESS"
]
}
```
### 1.3 A JSON rules struktúra elemzése
A `rules` JSONB mező egységes szerkezetet követ minden csomagnál:
| Kulcs | Típus | Leírás |
|-------|-------|--------|
| `type` | `string` | `"private"` vagy `"corporate"` — a csomag típusa |
| `pricing` | `object` | Árazási adatok: `currency`, `credit_price`, `yearly_price`, `monthly_price` |
| `affiliate` | `object` | MLM/affiliate beállítások: `referral_bonus_credits`, `commission_rate_percent` |
| `lifecycle` | `object` | Életciklus: `is_public` (bool), opcionálisan `available_until` (date/null) |
| `allowances` | `object` | Korlátok: `max_garages`, `max_vehicles`, `monthly_free_credits` |
| `display_name` | `string` | Felhasználói felületen megjelenő név |
| `entitlements` | `string[]` | Jogosultságok listája (pl. `SRV_DATA_EXPORT`, `SRV_AI_UPLOAD`) |
**Felfedezett entitlement kódok:**
- `SRV_DATA_EXPORT` — Adatexport szolgáltatás
- `SRV_AI_UPLOAD` — AI feltöltés
- `SRV_ACCOUNTING_SYNC` — Számviteli szinkron
- `SRV_API_ACCESS` — API hozzáférés
---
## 2. AUDIT: Current Assignments (`fleet.organizations`)
A `fleet.organizations` táblában jelenleg **2 garázs** rendelkezik nem-NULL `subscription_tier_id` értékkel:
| Org ID | Név | Csomag ID | Csomag Neve | Display Name |
|--------|-----|-----------|-------------|-------------|
| 45 | Gyöngyössy garázs | 17 | `corp_premium_plus_v1` | Céges Prémium Plus |
| 49 | Test Garázsa | 16 | `corp_premium_v1` | Céges Prémium |
**Megjegyzés:** A többi garázs (beleértve az admin garázsait is) `subscription_tier_id = NULL` értékkel rendelkezik, azaz jelenleg nincs hozzájuk rendelve előfizetési csomag.
---
## 3. IDENTIFY: Admin Garages (User ID 2 — admin@profibot.hu)
### 3.1 Tulajdonolt garázsok (owner_id = 2)
| Org ID | Név | Típus (org_type) | Státusz | subscription_tier_id |
|--------|-----|------------------|---------|---------------------|
| 67 | Admin Garázsa | `individual` | active | **NULL** |
### 3.2 Tagsági viszonyok (organization_members)
A 2-es user (admin@profibot.hu) az alábbi szervezetekben tag:
| Org ID | Szervezet Neve | Szerepkör | Státusz |
|--------|---------------|-----------|---------|
| 1 | **Test Company** | `ADMIN` | active |
| 57 | Teszt Autószerviz Kft. | `OWNER` | active |
| 58 | Autónyíri Kft. | `OWNER` | active |
| 62 | Bokebo Kft. | `OWNER` | active |
| 67 | **Admin Garázsa** | `OWNER` | active |
### 3.3 Kiemelt garázsok azonosítói
A Tervező által kért két kiemelt garázs:
| Megnevezés | Organization ID | Jelenlegi Csomag |
|------------|---------------|-----------------|
| **Test Company** (Céges) | **1** | **NINCS** (NULL) |
| **Admin Garázsa** (Privát) | **67** | **NINCS** (NULL) |
---
## 4. Összefoglaló és Következtetések
1. **A subscription_tiers rendszer teljes és jól strukturált.** 10 csomag áll rendelkezésre, 4 privát és 6 céges kategóriában. A JSON rules séma egységes, jól bővíthető.
2. **Csak 2 garázs kapott eddig csomagot** (Gyöngyössy garázs → corp_premium_plus_v1, Test Garázsa → corp_premium_v1). A többi garázs, köztük az admin garázsai is, csomag nélküli állapotban vannak.
3. **Az admin két kiemelt garázsa**`Test Company` (org_id=1) és `Admin Garázsa` (org_id=67) — jelenleg nem rendelkezik előfizetési csomaggal. Ezeket a későbbiekben fel kell címkézni a megfelelő tier-rel.
4. **A JSON rules struktúra lehetővé teszi** a korlátok (max_vehicles, max_garages), árazás, affiliate jutalékok és entitlement-ök (feature flagek) finomhangolását csomagonként.

View File

@@ -0,0 +1,189 @@
# P0 PRE-FLIGHT CHECK: Unified Workspace Impact Analysis
**Dátum:** 2026-06-18
**Auditor:** Architect Mode
**Cél:** A 28-as User (tester_pro@profibot.hu) privát járműveinek organization_id auditja, mielőtt bevezetjük az új "Unified Workspace" szabályt (ahol a hozzáférés szigorúan organization_id + OrganizationMember alapú).
---
## 1. 🔍 SZERVEZETI STRUKTÚRA (Organization Audit)
### 1.1 User 28 → Person 29
| Mező | Érték |
|------|-------|
| User ID | **28** |
| Email | tester_pro@profibot.hu |
| Person ID | **29** |
| Név | Tester Profibot |
### 1.2 Privát Garázs (individual type)
A 28-as User saját "Privát Garázsa":
| Mező | Érték |
|------|-------|
| **Organization ID** | **21** |
| Név | Private_28 |
| Típus | `individual` |
| Tulajdonos (owner_id) | 29 (Person) |
| folder_slug | priv_28 |
### 1.3 A Person 29 által birtokolt összes szervezet
| Org ID | Név | Típus | Van tagja? |
|--------|-----|-------|-----------|
| 15 | Profibot Test Fleet | `fleet_owner` | ❌ NINCS |
| **21** | **Private_28** | **`individual`** | **❌ NINCS** |
| 26 | Test Kft. Alpha | `fleet_owner` | ❌ NINCS |
| 27 | Test Kft. Beta | `fleet_owner` | ❌ NINCS |
| 38 | Admin Széfe | `individual` | ❌ NINCS |
### 1.4 Ahol Person 29 TAG (OrganizationMember)
| Org ID | Név | Típus | Szerep |
|--------|-----|-------|--------|
| 63 | Aszalós Motorszervíz | `service_provider` | ADMIN |
**⚠️ KRITIKUS:** Person 29 NEM tagja a saját privát garázsának (org 21)! A `fleet.organization_members` táblában egyetlen rekord sincs a `Private_28`-hoz.
---
## 2. 🚗 JÁRMŰVEK ÁLLAPOTA (Assets Audit)
### 2.1 Person 29 tulajdonában lévő összes jármű
| # | ID (UUID) | Rendszám | Márka | Modell | current_org_id | owner_org_id | Státusz |
|---|-----------|----------|-------|--------|---------------|-------------|---------|
| 1 | deb42aea... | QWE123 | APRILIA | af1 | **44** | 44 | active/draft |
| 2 | da49269e... | TTRFGHZT | BAYLINER | 1750 CAPRY BOWRIDER | **44** | 44 | active/draft |
| 3 | 72b0a0cf... | UOK795 | Honda | CB1000R | **NULL** ⚠️ | 34 | active/verified |
| 4 | 0b97a975... | PKT215 | Mazda | 2 | **NULL** ⚠️ | 34 | active/draft |
| 5 | 09700990... | AIML519 | Skoda | Citigo e | **NULL** ⚠️ | 34 | active/enriched |
| 6 | 104d6753... | ABC-123 | Toyota | Corolla | **34** | 34 | active/verified |
### 2.2 Érintett szervezetek részletei
| Org ID | Név | Típus | Tulajdonos |
|--------|-----|-------|-----------|
| 21 | Private_28 | `individual` | Person 29 ✅ |
| 34 | Swagger-Test Kft. | `business` | Nincs tulajdonos (owner_id = NULL) |
| 44 | Profibot Kft. | `business` | User 86 |
---
## 3. 📊 HATÁSELEMZÉS (Impact Analysis)
### 3.1 Unified Workspace forgatókönyv
Ha bevezetjük a szabályt: **"Egy User csak olyan járművet érhet el, ahol a `current_organization_id` egyezik egy olyan Organization-nel, aminek a User tagja (OrganizationMember)"**:
#### 3.1.1 NULL current_organization_id-val rendelkező járművek
**3 jármű** (Honda CB1000R, Mazda 2, Skoda Citigo e) **jelenleg NULL** `current_organization_id`-val rendelkezik. Ezek:
- Nincsenek egyetlen szervezethez sem rendelve jelenleg
- Csak a `owner_person_id = 29` alapján lennének elérhetők
- **Az új szabály szerint KI lennének ZÁRVA** (orphaned), mert nincs organization contextusuk
#### 3.1.2 Más szervezethez rendelt járművek
**3 jármű** (Toyota Corolla → org 34, Aprilia + Bayliner → org 44) olyan szervezetekhez van rendelve, amelyeknek Person 29 NEM tagja:
- **Toyota Corolla** (`current_org = 34` = Swagger-Test Kft.) → **KI VAN ZÁRVA** (Person 29 nem tag)
- **APRILIA** (`current_org = 44` = Profibot Kft.) → **KI VAN ZÁRVA** (Person 29 nem tag)
- **BAYLINER** (`current_org = 44` = Profibot Kft.) → **KI VAN ZÁRVA** (Person 29 nem tag)
### 3.2 Összegzés
| Kategória | Darab | Járművek | Sorsa új szabály alatt |
|-----------|-------|----------|----------------------|
| NULL current_org_id | **3** | Honda, Mazda, Skoda | ❌ ORPHANED |
| Idegen org (nincs tagság) | **3** | Toyota (org 34), Aprilia+Bayliner (org 44) | ❌ ORPHANED |
| Saját privát garázsban (org 21) | **0** | — | ✅ OK |
| **ÖSSZESEN** | **6/6** | **MIND** | **❌ 100%-OS ORPHANED** |
---
## 4. 💾 ELŐKÉSZÍTETT SQL JAVÍTÁS (NE FUTTASD KI - CSAK TERVEZÉS)
### 4.1 Első lépés: Person 29 felvétele a Private_28 (org 21) tagjai közé
```sql
INSERT INTO fleet.organization_members (organization_id, user_id, person_id, role, is_permanent, is_verified, status)
VALUES (21, 28, 29, 'OWNER', true, true, 'active');
```
**VAGY ha a user_id nem elvárás (person_id alapú):**
```sql
INSERT INTO fleet.organization_members (organization_id, person_id, role, is_permanent, is_verified, status)
VALUES (21, 29, 'OWNER', true, true, 'active');
```
### 4.2 Második lépés: NULL current_organization_id javítása
```sql
UPDATE vehicle.assets
SET current_organization_id = 21
WHERE owner_person_id = 29 AND current_organization_id IS NULL;
```
Ez a **Honda CB1000R** (UOK795), **Mazda 2** (PKT215) és **Skoda Citigo e** (AIML519) járműveket beköti a Privát Garázsba.
### 4.3 Harmadik lépés: Üzleti döntés szükséges
A következő járművekhez **üzleti döntés** kell:
1. **Toyota Corolla** (ABC-123) - `current_org = 34, owner_org = 34`
- **Opció A:** Áttenni a Private_28-ba (org 21)
- **Opció B:** Felvenni Person 29-et a Swagger-Test Kft. (org 34) tagjai közé
- **Opció C:** Megtartani a jelenlegi állapotot és kivételt képezni az új szabály alól
2. **APRILIA af1** (QWE123) + **BAYLINER** (TTRFGHZT) - `current_org = 44, owner_org = 44`
- **Opció A:** Áttenni a Private_28-ba (org 21)
- **Opció B:** Felvenni Person 29-et a Profibot Kft. (org 44) tagjai közé (de User 86 a tulajdonos!)
- **Opció C:** Megtartani a jelenlegi állapotot
### 4.4 Teljes körű SQL javítás (ha az összes járművet a privát garázsba tennénk)
```sql
-- 1. Person 29 taggá tétele a Private_28-ban
INSERT INTO fleet.organization_members (organization_id, user_id, person_id, role, is_permanent, is_verified, status)
VALUES (21, 28, 29, 'OWNER', true, true, 'active');
-- 2. Összes jármű áthelyezése a Private_28-ba
UPDATE vehicle.assets
SET current_organization_id = 21
WHERE owner_person_id = 29;
```
---
## 5. ⚠️ KIEGÉSZÍTŐ MEGJEGYZÉSEK
### 5.1 User 28 soft-deleted állapota
A korábbi audit ([`docs/db_schema_ownership_audit_2026-06-18.md`](docs/db_schema_ownership_audit_2026-06-18.md)) feltárta, hogy User 28 **soft-deletelve** van (`is_deleted = true`, `deleted_at = 2026-06-14`). Ez azt jelenti:
- Jelenleg NEM tud bejelentkezni
- Még az adatjavítás után sem fér hozzá a járművekhez, amíg a fiókja nincs visszaállítva
### 5.2 Tervezési ajánlás
Az új "Unified Workspace" szabály bevezetése ELŐTT kötelező:
1. **Adatjavítás:** A NULL `current_organization_id`-val rendelkező járművek bekötése
2. **Tagság rögzítése:** Minden `individual` típusú org tulajdonosának automatikus OWNER taggá tétele
3. **Üzleti járművek felülvizsgálata:** Döntés a org 34-be és 44-be sorolt járművekről
4. **User 28 restore:** A soft-delete feloldása a KYC/restore folyamaton keresztül
5. **Átmeneti időszak:** Az `owner_person_id` alapú hozzáférés megtartása fallback-ként, amíg a fenti pontok nem teljesülnek
---
## 6. 🔮 ARCHITECT AJÁNLÁS
**Javaslat:** NE vezessük be a "pure Unified Workspace"-t (kizárólag org + membership alapú hozzáférés), amíg:
1. Az összes `individual` org-nak nincs automatikusan létrehozott OWNER OrganizationMember rekordja
2. Az összes meglévő jármű `current_organization_id` mezője ki van töltve
3. A soft-deletelt userek vissza vannak állítva vagy tisztázva van a státuszuk
**Ajánlott átmeneti megoldás:** Tartsd meg az `owner_person_id == user.person_id` ellenőrzést fallback-ként az org-based check mellett, amíg a fenti adatjavítások meg nem történnek.

View File

@@ -0,0 +1,275 @@
# 🔐 Jogosultságkezelés Audit Jelentés (2026-06-18)
## Teljes Rendszerkép a Jogosultsági Szintekről
---
## 1. Adatbázis Réteg (Database Schema)
### 1.1 `identity.users` - Felhasználói Jogosultságok
| Mező | Típus | Leírás |
|------|-------|--------|
| `role` | `identity.userrole` enum | Rendszerszintű szerepkör |
| `scope_level` | `VARCHAR(30)` | Hatókör szint (pl. "individual") |
| `scope_id` | `VARCHAR(50)` | Hatókör azonosító (pl. org ID vagy user ID) |
| `custom_permissions` | `JSON` | Egyedi jogosultságok (`{"capabilities": [...]}`) |
**`userrole` enum értékei** (Python `UserRole` osztály, `backend/app/models/identity/identity.py:24`):
```
superadmin, admin, region_admin, country_admin, moderator,
sales_agent, user, service_owner, fleet_manager, driver
```
> ⚠️ **Probléma**: Az adatbázis enum duplikált értékeket tartalmaz (pl. "admin" és "ADMIN" is létezik, "user" kétszer szerepel). Ez az enum korábbi migrációk során keletkezett.
### 1.2 `fleet.org_roles` - Szervezeti Szerepkör Definíciók
**🔴 KRITIKUS: A tábla ÜRES!**
A modell (`backend/app/models/marketplace/organization.py:34`) definiál egy dinamikus szerepkör rendszert (`OrgRole` modell), de **seed-elés nem történt meg**. A `OrgUserRole` enum (`backend/app/models/marketplace/organization.py:27`) az alábbi értékeket tartja számon:
```
OWNER, ADMIN, MANAGER, MEMBER, AGENT
```
### 1.3 `fleet.organization_members` - Tagságok és Jogosultságok
| Mező | Típus | Leírás |
|------|-------|--------|
| `role` | `VARCHAR(50)` | Szerepkör a szervezetben (OWNER/ADMIN/MANAGER/MEMBER/AGENT) |
| `permissions` | `JSON` | Egyedi JSON jogosultságok |
| `is_permanent` | `BOOLEAN` | Állandó tagság |
| `is_verified` | `BOOLEAN` | Megerősített tagság |
| `status` | `VARCHAR(20)` | Státusz (active/inactive) |
### 1.4 `system.system_parameters` - Hierarchikus Paraméterezés
| Mező | Típus | Leírás |
|------|-------|--------|
| `key` | `VARCHAR` | Paraméter kulcs |
| `value` | `JSONB` | Paraméter érték |
| `scope_level` | `parameter_scope` enum | Hatókör: GLOBAL, COUNTRY, REGION, USER, ORGANIZATION |
| `scope_id` | `VARCHAR` | Hatókör azonosító |
**`parameter_scope` enum értékei** (`backend/app/models/system/system.py:12`):
```
GLOBAL, COUNTRY, REGION, ORGANIZATION, USER
```
> ⚠️ **Probléma**: Minden meglévő paraméter `global` scope_level-lel rendelkezik. A hierarchikus scope (country, region, organization, user) **nincs használatban** gyakorlatban.
### 1.5 `system.pending_actions` - Dual Control (Négy Szem Elv)
| Mező | Típus | Leírás |
|------|-------|--------|
| `requester_id` | `INTEGER` | Kérelem indítója |
| `approver_id` | `INTEGER` | Jóváhagyó (nullable) |
| `status` | `action_status` enum | pending/approved/rejected |
| `action_type` | `VARCHAR` | CHANGE_ROLE, SET_VIP, WALLET_ADJUST, SOFT_DELETE_USER, ORGANIZATION_TRANSFER |
| `payload` | `JSONB` | Művelet adatok |
### 1.6 `system.subscription_tiers` - Előfizetési Csomagok
| Mező | Típus | Leírás |
|------|-------|--------|
| `name` | `VARCHAR` | Csomag neve (FREE, PREMIUM, VIP, stb.) |
| `rules` | `JSONB` | Szabályok (pl. `{"max_vehicles": 5}`) |
---
## 2. Backend Kód Réteg
### 2.1 Rang Rendszer - `DEFAULT_RANK_MAP`
Definiálva: `backend/app/core/security.py:56`
| Rang | Érték | Van UserRole enum-ban? |
|------|-------|----------------------|
| SUPERADMIN | 100 | ✅ Igen |
| ADMIN | 90 | ✅ Igen |
| REGION_ADMIN | 85 | ✅ Igen |
| COUNTRY_ADMIN | 80 | ✅ Igen |
| MODERATOR | 75 | ✅ Igen |
| AUDITOR | 70 | ❌ Nincs |
| ORGANIZATION_OWNER | 65 | ❌ Nincs |
| ORGANIZATION_MANAGER | 60 | ❌ Nincs |
| ORGANIZATION_MEMBER | 50 | ❌ Nincs |
| SERVICE_PROVIDER | 40 | ❌ Nincs |
| SALES_AGENT | 30 | ✅ Igen |
| FLEET_MANAGER | 25 | ✅ Igen |
| PREMIUM_USER | 20 | ❌ Nincs |
| USER | 10 | ✅ Igen |
| DRIVER | 5 | ✅ Igen |
| GUEST | 0 | ❌ Nincs |
> 🔴 **KRITIKUS**: 16 rang van definiálva, de a `UserRole` enum csak 10 értéket tartalmaz. A hiányzók (AUDITOR, ORGANIZATION_OWNER/MANAGER/MEMBER, SERVICE_PROVIDER, PREMIUM_USER, GUEST) sosem kerülnek kiosztásra!
### 2.2 RBAC Osztály - 3 Szintű Ellenőrzés
Forrás: `backend/app/core/rbac.py`
```
1. Superadmin check → mindent átenged
2. Rang ellenőrzés → DEFAULT_RANK_MAP alapján
3. Custom permissions → capabilities lista ellenőrzése
```
### 2.3 API Deps - `get_current_admin`
Forrás: `backend/app/api/deps.py:158`
Engedélyezett admin szerepkörök:
```python
allowed_roles = {superadmin, admin, region_admin, country_admin, moderator}
```
### 2.4 Scoped RBAC - `check_resource_access`
Forrás: `backend/app/api/deps.py:106`
```
- Superadmin → mindenhez hozzáfér
- Saját ID vagy scope_id egyezés → hozzáfér
- Egyéb → 403 Forbidden
```
### 2.5 Dual Control - `SecurityService`
Forrás: `backend/app/services/security_service.py`
| Típus | Leírás |
|-------|--------|
| `CHANGE_ROLE` | Szerepkör módosítás |
| `SET_VIP` | VIP státusz |
| `WALLET_ADJUST` | Pénztárca módosítás |
| `SOFT_DELETE_USER` | Felhasználó törlés |
| `ORGANIZATION_TRANSFER` | Szervezet átadás |
### 2.6 JWT Token Felépítése
Forrás: `backend/app/api/v1/endpoints/auth.py:108`
```json
{
"sub": "user_id",
"role": "superadmin",
"rank": 100,
"scope_level": "individual",
"scope_id": "user_id_vagy_org_id"
}
```
---
## 3. Frontend Réteg
### 3.1 Frontend Auth Store
Forrás: `frontend/src/stores/authStore.ts:4`
```typescript
export type UserRole = 'guest' | 'user' | 'manager' | 'admin' | 'superadmin'
```
> ⚠️ **Probléma**: A frontend `UserRole` típus **NEM egyezik** a backend `UserRole` enum-jával! Hiányzik: `region_admin`, `country_admin`, `moderator`, `sales_agent`, `service_owner`, `fleet_manager`, `driver`.
### 3.2 Router Admin Guard
Forrás: `frontend/src/router/index.ts:133`
```typescript
const adminRoles = ['superadmin', 'admin', 'region_admin', 'country_admin', 'moderator']
```
Ez a lista egyezik a backend `get_current_admin` függvényével.
---
## 4. Problémák és Hiányosságok
### 🔴 KRITIKUS Problémák
| # | Probléma | Hely | Hatás |
|---|----------|------|-------|
| P1 | `fleet.org_roles` tábla ÜRES | OrgRole modell | Dinamikus szerepkör rendszer nem működik |
| P2 | Rank map inkonzisztens a UserRole enum-mal | DEFAULT_RANK_MAP vs UserRole | 6 rang sosem kerül kiosztásra |
| P3 | Enum duplikáció (kis/nagybetű) | userrole és orgtype adatbázis enumok | Migrációs hiba, karbantartási nehézség |
| P4 | Frontend/backend UserRole eltérés | authStore.ts | Frontend hibás role-okat kezel |
| P5 | parameter_scope enum 3 sémában | public, system | Inkonzisztens definíciók |
### 🟡 FIGYELMEZTETÉS
| # | Probléma | Hely | Hatás |
|---|----------|------|-------|
| W1 | Csak `global` scope használatban | system_parameters | Hierarchikus paraméterezés nem kihasznált |
| W2 | Minden user `individual` scope_level | users tábla | Többszintű scope nincs implementálva |
| W3 | Nincs explicit "deny" mechanizmus | RBAC rendszer | Csak elégtelen rangot jelez, explicit tiltás nincs |
| W4 | custom_permissions nincs validálva | User modell | Bármilyen JSON elfogadott |
---
## 5. Összefüggés Diagram
```mermaid
graph TB
subgraph Frontend
FS[authStore.ts<br/>UserRole: guest/user/manager/admin/superadmin]
RG[Router Guard<br/>adminRoles: superadmin, admin,<br/>region_admin, country_admin, moderator]
end
subgraph API_Layer
JWT[JWT Token payload<br/>sub + role + rank + scope_level + scope_id]
DC[Dual Control SecurityService<br/>5 művelettípus]
end
subgraph Backend
UR[UserRole enum<br/>10 érték]
RM[DEFAULT_RANK_MAP<br/>16 rang - csak 10 használható]
RBAC[RBAC osztály<br/>3 szint: superadmin + rank + capabilities]
GR[get_current_admin<br/>5 engedélyezett szerepkör]
end
subgraph Database
U[identity.users<br/>role + scope_level + scope_id + custom_permissions]
OM[fleet.organization_members<br/>role + permissions JSON]
OR[fleet.org_roles<br/>🔴 ÜRES! Nincs seed data]
SP[system.system_parameters<br/>scope_level + scope_id - csak global használva]
PA[system.pending_actions<br/>Dual Control tároló]
end
FS -- eltérő role halmaz --> UR
JWT -- kiolvassa --> U
RBAC -- ellenőrzi --> RM
GR -- engedélyezi --> UR
DC -- naplóz --> PA
OR -- üres, nem használható --> OM
```
---
## 6. Azonosított Problémák Részletesen
### P1: `fleet.org_roles` tábla üres
- A dinamikus szerepkör rendszer teljesen kiépített (OrgRole modell, OrgUserRole enum)
- De a `fleet.org_roles` táblában egyetlen rekord sincs
- Következmény: a `organization_members.role` mező validálatlan string-ként működik
### P2: DEFAULT_RANK_MAP inkonzisztencia
- A `DEFAULT_RANK_MAP` tartalmaz olyan rangokat (AUDITOR, ORGANIZATION_OWNER, PREMIUM_USER, GUEST), amelyek nem szerepelnek a `UserRole` enum-ban
- Ezek a rangok soha nem kerülnek kiosztásra
- Megoldás: vagy bővíteni a `UserRole` enumot, vagy törölni a felesleges rangokat
### P3: Enum duplikáció
- A `userrole` enum minden értéke kétszer szerepel (kis és NAGY betűs változat)
- Az `orgtype` enum értékei szintén duplikáltak (business/BUSINESS, stb.)
- Ez a korábbi migrációk során keletkezett
### P4: Frontend-Backend eltérés
- A frontend auth store 5 role-t definiál: guest, user, manager, admin, superadmin
- A backend 10 role-t: superadmin, admin, region_admin, country_admin, moderator, sales_agent, user, service_owner, fleet_manager, driver
- A `manager` frontend role nem létezik a backendben, a `region_admin` és `country_admin` és `moderator` hiányzik a frontendből
### P5: Hierarchikus scope nem kihasznált
- A `ParameterScope` enum (GLOBAL, COUNTRY, REGION, ORGANIZATION, USER) teljesen kiépített
- De minden `system_parameter` `global` scope_level-lel rendelkezik
- A hierarchikus paraméterezés (ország → régió → szervezet → felhasználó) nincs implementálva

View File

@@ -265,7 +265,8 @@ async function handleSubmit() {
asset_id: props.vehicle.id,
// organization_id is omitted — backend auto-resolves from asset
category_id: categoryId,
amount_net: form.amount,
/** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary field */
amount_gross: form.amount,
currency: 'HUF' as const,
date: new Date(form.date).toISOString(),
mileage_at_cost: null,

View File

@@ -0,0 +1,171 @@
<template>
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
>
<!-- Top header bar -->
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4 gap-2">
<span class="text-white font-bold text-sm tracking-wide">💰 {{ t('dashboard.costsTitle') }}</span>
</div>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
<!-- Vehicle Selector Dropdown -->
<div class="mb-3">
<label class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1 block">{{ t('dashboard.vehicle') }}</label>
<select
v-model="selectedActionVehicleId"
class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
>
<option
v-for="v in vehicleStore.sortedVehicles"
:key="v.id"
:value="v.id"
>
{{ v.license_plate || '—' }} ({{ v.brand || '' }} {{ v.model || '' }})
</option>
</select>
</div>
<!-- Action Buttons -->
<div class="flex-1 flex flex-col gap-2.5 justify-center">
<!-- No vehicle warning -->
<div
v-if="!selectedActionVehicle"
class="flex items-center justify-center rounded-xl bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700"
>
<span> {{ t('dashboard.noVehicleSelected') }}</span>
</div>
<!-- Record Fueling (Big, prominent) -->
<button
v-if="selectedActionVehicle"
@click="openFuelModal"
class="flex items-center gap-3 rounded-xl bg-gradient-to-r from-sf-accent to-emerald-500 px-4 py-3 text-sm font-bold text-white shadow-md transition-all hover:shadow-lg hover:scale-[1.02] active:scale-[0.98]"
>
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-white/20 text-lg"></span>
<span>{{ t('dashboard.recordFueling') }}</span>
<span class="ml-auto text-white/60 text-xs"></span>
</button>
<!-- 🅿 Parking / Toll (Medium) -->
<button
v-if="selectedActionVehicle"
@click="openFeeModal"
class="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-all hover:bg-slate-100 hover:shadow-md active:scale-[0.98]"
>
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-200 text-base">🅿</span>
<span>{{ t('dashboard.parkingToll') }}</span>
<span class="ml-auto text-slate-400 text-xs"></span>
</button>
<!-- Additional Costs (Medium) -->
<button
v-if="selectedActionVehicle"
@click="openComplexModal"
class="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-all hover:bg-slate-100 hover:shadow-md active:scale-[0.98]"
>
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-200 text-base"></span>
<span>{{ t('dashboard.additionalCosts') }}</span>
<span class="ml-auto text-slate-400 text-xs"></span>
</button>
</div>
</div>
<!--
Cost Action Modals (Teleported to body)
-->
<SimpleFuelModal
:is-open="isFuelModalOpen"
:vehicle="selectedActionVehicle"
@close="isFuelModalOpen = false"
@saved="onModalSaved"
/>
<ComplexExpenseModal
:is-open="isFeeModalOpen"
:vehicle="selectedActionVehicle"
:is-fee-mode="true"
@close="isFeeModalOpen = false"
@saved="onModalSaved"
/>
<ComplexExpenseModal
:is-open="isComplexModalOpen"
:vehicle="selectedActionVehicle"
:is-fee-mode="false"
@close="isComplexModalOpen = false"
@saved="onModalSaved"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useVehicleStore } from '../../stores/vehicle'
import SimpleFuelModal from './SimpleFuelModal.vue'
import ComplexExpenseModal from './ComplexExpenseModal.vue'
const { t } = useI18n()
const vehicleStore = useVehicleStore()
// ── Emits ──
const emit = defineEmits<{
/**
* F5 Bug fix (RBAC Phase 3): Értesíti a szülő komponenst, hogy egy költség sikeresen el lett mentve.
* A szülő (DashboardView) továbbítja ezt a VehicleDetailModal-nak, ami újratölti a költségeket.
*/
'cost-saved': [vehicleId: string]
}>()
// ── Cost Card Action Center State ──
const selectedActionVehicleId = ref<string>('')
const isFuelModalOpen = ref(false)
const isFeeModalOpen = ref(false)
const isComplexModalOpen = ref(false)
/** The currently selected vehicle object for the action modals */
const selectedActionVehicle = computed(() => {
if (!selectedActionVehicleId.value) return null
return vehicleStore.vehicles.find(v => v.id === selectedActionVehicleId.value) || null
})
/** Auto-select the first (primary/sorted) vehicle on load */
function autoSelectVehicle() {
if (vehicleStore.sortedVehicles.length > 0 && !selectedActionVehicleId.value) {
selectedActionVehicleId.value = vehicleStore.sortedVehicles[0].id
}
}
function openFuelModal() {
if (!selectedActionVehicle.value) return
isFuelModalOpen.value = true
}
function openFeeModal() {
if (!selectedActionVehicle.value) return
isFeeModalOpen.value = true
}
function openComplexModal() {
if (!selectedActionVehicle.value) return
isComplexModalOpen.value = true
}
/**
* F5 Bug fix (RBAC Phase 3): Modal mentés után bezárja a modalt,
* és értesíti a szülőt, hogy az új költség megjelent.
*/
function onModalSaved() {
isFuelModalOpen.value = false
isFeeModalOpen.value = false
isComplexModalOpen.value = false
// Értesítjük a szülőt, hogy frissítse a VehicleDetailModal költségeit
if (selectedActionVehicleId.value) {
emit('cost-saved', selectedActionVehicleId.value)
}
}
// Watch for vehicles to load, then auto-select
watch(() => vehicleStore.vehicles.length, () => {
autoSelectVehicle()
}, { immediate: true })
</script>

View File

@@ -0,0 +1,62 @@
<template>
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
@click="$emit('open-card', 'gamification')"
>
<!-- Top header bar -->
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">🏆 {{ t('dashboard.statsAndPoints') }}</span>
<span class="ml-auto inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
{{ t('dashboard.live') }}
</span>
</div>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
<div class="flex-1 space-y-3">
<!-- Score card -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
<p class="text-3xl font-extrabold text-emerald-600">2,450</p>
<p class="text-xs text-slate-500 mt-1">{{ t('dashboard.monthlyScore') }}</p>
</div>
<!-- Achievement badges -->
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">
{{ t('dashboard.achievements') }}
</p>
<div class="flex flex-wrap gap-2">
<span
v-for="badge in badges"
:key="badge.label"
class="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs text-slate-600"
>
{{ badge.icon }} {{ badge.label }}
</span>
</div>
</div>
</div>
</div>
<!-- Bottom CTA button -->
<button class="mt-auto mb-4 mx-auto px-8 py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
{{ t('dashboard.refresh') }} 🏆
</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const emit = defineEmits<{
'open-card': [cardId: string]
}>()
// ── Placeholder badges ──
const badges = ref([
{ icon: '🌱', label: 'Eco Driver' },
{ icon: '📏', label: '10k km Club' },
{ icon: '🔧', label: 'Service Pro' },
{ icon: '⭐', label: 'Top Rater' },
])
</script>

View File

@@ -0,0 +1,63 @@
<template>
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
@click="$emit('open-card', 'vehicles')"
>
<!-- Top header bar -->
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">🚗 {{ t('dashboard.myVehicles') }}</span>
<span class="ml-auto text-xs text-white/60">{{ vehicleStore.vehicles.length }} {{ t('dashboard.vehicles') }}</span>
</div>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 min-h-0">
<div class="flex-1 overflow-y-auto space-y-2">
<VehicleCardCompact
v-for="vehicle in displayVehicles.slice(0, 3)"
:key="vehicle.id"
:vehicle="vehicle"
@click="$emit('open-card', 'vehicles')"
@plate-click="openVehicleDetail(vehicle)"
/>
<!-- Empty state (only when BOTH real and mock are empty) -->
<div
v-if="displayVehicles.length === 0"
class="flex flex-col items-center justify-center py-6 text-slate-400"
>
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<p class="text-sm">{{ t('dashboard.noVehicles') }}</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useVehicleStore } from '../../stores/vehicle'
import type { VehicleData } from '../../types/vehicle'
import VehicleCardCompact from '../vehicle/VehicleCardCompact.vue'
const { t } = useI18n()
const vehicleStore = useVehicleStore()
const emit = defineEmits<{
'open-card': [cardId: string, targetId?: string | null]
'open-vehicle-detail': [vehicle: VehicleData]
}>()
/**
* Display vehicles from the backend API only.
* Sorted by is_primary flag (favorite first), then alphabetically.
* Shows all real vehicles; no mock/fallback data is injected.
*/
const displayVehicles = computed(() => {
return vehicleStore.sortedVehicles
})
function openVehicleDetail(vehicle: VehicleData) {
emit('open-vehicle-detail', vehicle)
}
</script>

View File

@@ -0,0 +1,60 @@
<template>
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
@click="$emit('open-card', 'stats')"
>
<!-- Top header bar -->
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">🛡 {{ t('header.profile') }}</span>
</div>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
<div class="flex-1 space-y-3">
<!-- User info -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<p class="text-sm font-bold text-slate-800">{{ authStore.userName || t('dashboard.guest') }}</p>
<p class="text-xs text-slate-400 mt-0.5">{{ authStore.user?.email }}</p>
</div>
<!-- Trust Score -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-slate-500 font-semibold uppercase tracking-wider">Trust Score</span>
<span class="text-sm font-bold text-emerald-600">A+</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-slate-200">
<div class="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600 transition-all duration-500" style="width: 92%" />
</div>
</div>
<!-- Quick stats -->
<div class="grid grid-cols-2 gap-2">
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
<p class="text-lg font-bold text-slate-800">{{ authStore.myOrganizations.length }}</p>
<p class="text-xs text-slate-400">{{ t('header.myCompanies') }}</p>
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
<p class="text-lg font-bold text-slate-800">{{ vehicleStore.vehicles.length }}</p>
<p class="text-xs text-slate-400">{{ t('dashboard.totalVehicles') }}</p>
</div>
</div>
</div>
</div>
<!-- Bottom CTA button -->
<button class="mt-auto mb-4 mx-auto px-8 py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
{{ t('header.profile') }}
</button>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../../stores/auth'
import { useVehicleStore } from '../../stores/vehicle'
const { t } = useI18n()
const authStore = useAuthStore()
const vehicleStore = useVehicleStore()
const emit = defineEmits<{
'open-card': [cardId: string]
}>()
</script>

View File

@@ -0,0 +1,68 @@
<template>
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl cursor-pointer"
@click="openExternalServiceFinder"
>
<!-- Top header bar -->
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">{{ t('serviceFinder.title') }}</span>
</div>
<!-- Content: 3 simple buttons stacked vertically -->
<div class="p-4 flex-1 flex flex-col justify-center gap-2">
<button
@click.stop="openExternalServiceFinder"
class="w-full rounded-xl bg-sf-accent px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-sf-accent/90 hover:shadow-md active:scale-[0.97] cursor-pointer"
>
🔍 {{ t('serviceFinder.plannedMaintenance') }}
</button>
<button
@click.stop="router.push('/dashboard/service-finder?mode=sos')"
class="w-full rounded-xl border border-red-300 bg-red-50 px-4 py-2.5 text-sm font-semibold text-red-700 shadow-sm transition-all hover:bg-red-100 hover:shadow-md active:scale-[0.97] cursor-pointer"
>
🚨 SOS {{ t('serviceFinder.sosTitle') }}
</button>
<button
@click.stop="isSfQuickAddOpen = true"
class="w-full rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 shadow-sm transition-all hover:border-sf-accent hover:bg-sf-accent/5 hover:shadow-md active:scale-[0.97] cursor-pointer"
>
{{ t('provider.quickAddTitle') }}
</button>
</div>
<!-- Provider Quick Add Modal -->
<ProviderQuickAddModal
:is-open="isSfQuickAddOpen"
@close="isSfQuickAddOpen = false"
@saved="onSfQuickAddSaved"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import ProviderQuickAddModal from '../provider/ProviderQuickAddModal.vue'
const { t } = useI18n()
const router = useRouter()
// ── Service Finder (Card 3) — Launcher ──
const isSfQuickAddOpen = ref(false)
/**
* Navigates to the Service Finder page in the same tab.
* Used by the card click and the "Tervezett karbantartás" button.
*/
function openExternalServiceFinder() {
router.push('/dashboard/service-finder')
}
/**
* Called when ProviderQuickAddModal saves a new provider.
*/
function onSfQuickAddSaved(provider: { id: number; name: string; category?: string | null; city?: string | null; address?: string | null }) {
isSfQuickAddOpen.value = false
alert(`🎉 ${t('provider.successMessage', { points: 0 })}`)
}
</script>

View File

@@ -190,7 +190,8 @@ async function handleSubmit() {
const payload = {
asset_id: props.vehicle.id,
category_id: fuelCategoryId.value, // FUEL category (resolved by code)
amount_net: form.amount,
/** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary field */
amount_gross: form.amount,
currency: 'HUF' as const,
date: new Date(form.date).toISOString(),
mileage_at_cost: form.odometer > 0 ? form.odometer : null,

View File

@@ -142,12 +142,13 @@ const initials = computed(() => {
return '?'
})
// ── Admin check: only admin/superadmin/region_admin/country_admin/moderator ──
// ── Admin check: only SUPERADMIN, ADMIN, MODERATOR ──
const isAdmin = computed(() => {
const role = authStore.user?.role
if (!role) return false
const adminRoles = ['superadmin', 'admin', 'region_admin', 'country_admin', 'moderator']
return adminRoles.includes(role)
// CRITICAL: Backend sends UPPERCASE roles (SUPERADMIN, ADMIN, MODERATOR)
const adminRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR']
return adminRoles.includes(role.toUpperCase())
})
// ── Navigate to Profile ────────────────────────────────────────────

View File

@@ -75,6 +75,48 @@
/>
</div>
<!-- Separator: Subscription Section (P0 Feature) -->
<div class="border-t border-white/10 my-2"></div>
<p class="text-sm font-medium text-white/70 mb-2">{{ t('company.subscriptionTitle') || 'Előfizetés' }}</p>
<!-- Subscription Tier Dropdown -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.subscriptionTier') || 'Előfizetési csomag' }}</label>
<div class="flex gap-2">
<select
v-model="selectedTierId"
class="flex-1 px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0]"
>
<option :value="null" class="bg-[#04151F]">{{ t('company.noSubscription') || 'Nincs csomag' }}</option>
<option
v-for="tier in availableTiers"
:key="tier.id"
:value="tier.id"
class="bg-[#04151F]"
>
{{ tier.name }} {{ tier.rules?.allowances?.max_vehicles ? `(${tier.rules.allowances.max_vehicles} jármű)` : '' }}
</option>
</select>
<button
v-if="selectedTierId !== org?.subscription_tier_id"
@click="assignSubscription"
:disabled="isAssigning"
class="px-3 py-2 rounded-lg bg-[#00E5A0] hover:bg-[#00E5A0]/80 text-[#04151F] font-semibold text-sm transition-all duration-200 disabled:opacity-50"
>
<span v-if="isAssigning" class="flex items-center gap-1">
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</span>
<span v-else>{{ t('profile.save') }}</span>
</button>
</div>
<p v-if="org?.subscription_plan" class="text-xs text-white/40 mt-1">
{{ t('company.currentPlan') || 'Jelenlegi csomag' }}: {{ org.subscription_plan }}
</p>
</div>
<!-- Separator -->
<div class="border-t border-white/10 my-2"></div>
<p class="text-sm font-medium text-white/70 mb-2">{{ t('company.addressTitle') }}</p>
@@ -172,6 +214,11 @@ const isLoading = ref(false)
const isSaving = ref(false)
const org = ref<OrganizationItem | null>(null)
// P0 Feature: Subscription tier assignment
const availableTiers = ref<any[]>([])
const selectedTierId = ref<number | null>(null)
const isAssigning = ref(false)
const form = reactive({
name: '',
full_name: '',
@@ -180,7 +227,9 @@ const form = reactive({
address_zip: '',
address_city: '',
address_street_name: '',
address_street_type: '',
address_house_number: '',
address_hrsz: '',
})
// ── Lifecycle ───────────────────────────────────────────────────────────────
@@ -193,11 +242,18 @@ onMounted(async () => {
const found = authStore.myOrganizations.find((o) => o.organization_id === orgId)
if (found) {
org.value = found
selectedTierId.value = found.subscription_tier_id ?? null
// Pre-fill form with existing values
form.name = found.name || ''
form.full_name = found.full_name || ''
form.display_name = found.display_name || ''
form.tax_number = found.tax_number || ''
form.address_zip = found.address_zip || ''
form.address_city = found.address_city || ''
form.address_street_name = found.address_street_name || ''
form.address_street_type = found.address_street_type || ''
form.address_house_number = found.address_house_number || ''
form.address_hrsz = found.address_hrsz || ''
} else {
// Try to fetch fresh data
isLoading.value = true
@@ -206,10 +262,17 @@ onMounted(async () => {
const refound = authStore.myOrganizations.find((o) => o.organization_id === orgId)
if (refound) {
org.value = refound
selectedTierId.value = refound.subscription_tier_id ?? null
form.name = refound.name || ''
form.full_name = refound.full_name || ''
form.display_name = refound.display_name || ''
form.tax_number = refound.tax_number || ''
form.address_zip = refound.address_zip || ''
form.address_city = refound.address_city || ''
form.address_street_name = refound.address_street_name || ''
form.address_street_type = refound.address_street_type || ''
form.address_house_number = refound.address_house_number || ''
form.address_hrsz = refound.address_hrsz || ''
}
} catch (err) {
console.error('Failed to load organization:', err)
@@ -217,8 +280,44 @@ onMounted(async () => {
isLoading.value = false
}
}
// Fetch available subscription tiers
try {
const res = await api.get('/admin/packages/')
availableTiers.value = res.data?.tiers || res.data || []
} catch (err) {
console.error('Failed to load subscription tiers:', err)
}
})
// ── P0 Feature: Assign Subscription Tier ────────────────────────────────────
async function assignSubscription() {
const orgId = Number(route.params.id)
if (!orgId || selectedTierId.value === null) return
isAssigning.value = true
try {
await api.put(`/organizations/${orgId}/subscription`, {
tier_id: selectedTierId.value
})
// Refresh org data in store
await authStore.fetchMyOrganizations()
const updated = authStore.myOrganizations.find((o) => o.organization_id === orgId)
if (updated) {
org.value = updated
}
emit('saved')
} catch (err: any) {
console.error('Failed to assign subscription:', err)
alert(err?.response?.data?.detail || 'Failed to assign subscription')
} finally {
isAssigning.value = false
}
}
// ── Save Handler ────────────────────────────────────────────────────────────
async function handleSave() {

View File

@@ -735,8 +735,10 @@
Bezárás
</button>
<!-- Task 1: Szerkesztés gomb csak az Alapadatok fülön látható -->
<!-- RBAC Phase 3 (UI Pilot): A gomb csak akkor jelenik meg, ha a user rendelkezik
can_approve_expense képességgel az aktív szervezetben -->
<button
v-if="activeTab === 'basics'"
v-if="activeTab === 'basics' && authStore.hasOrgCapability(authStore.user?.active_organization_id ?? 0, 'can_approve_expense')"
@click="$emit('edit', vehicle)"
class="inline-flex items-center gap-2 rounded-xl bg-sf-accent px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-sf-blue cursor-pointer"
>
@@ -769,6 +771,11 @@ const { t } = useI18n()
const props = defineProps<{
isOpen: boolean
vehicle: VehicleData | null
/**
* F5 Bug fix (RBAC Phase 3): Számláló, ami minden költség mentéskor nő.
* A watch figyeli ezt, és újratölti a költségeket, ha a finance tab aktív.
*/
refreshTrigger?: number
}>()
const emit = defineEmits<{
@@ -850,7 +857,7 @@ interface CostItem {
category_name?: string
/** Backend returns `category_code` from CostCategory relationship */
category_code?: string
/** Backend returns `amount_net` (Decimal) — the main monetary value */
/** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary monetary value */
amount: number
/** Backend returns `currency` (e.g. 'HUF') */
currency?: string
@@ -859,6 +866,7 @@ interface CostItem {
/** Backend returns `mileage_at_cost` extracted from data JSONB */
mileage_at_cost?: number
/** Raw backend fields kept for reference */
amount_gross?: number
amount_net?: number
currency_raw?: string
date?: string
@@ -870,8 +878,9 @@ const costsLoading = ref(false)
/**
* Map a raw backend AssetCostResponse item to the frontend CostItem shape.
* GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary source of truth.
* Backend schema (AssetCostResponse) now uses:
* amount_net, currency, date, category_name, category_code, description, mileage_at_cost
* amount_gross, amount_net, currency, date, category_name, category_code, description, mileage_at_cost
*/
function mapBackendCost(raw: any): CostItem {
return {
@@ -880,11 +889,12 @@ function mapBackendCost(raw: any): CostItem {
category: raw.category_name || raw.category_code || raw.category || '',
category_name: raw.category_name,
category_code: raw.category_code,
amount: Number(raw.amount_net ?? raw.amount ?? 0),
amount: Number(raw.amount_gross ?? raw.amount_net ?? raw.amount ?? 0),
currency: raw.currency || 'HUF',
description: raw.description || raw.data?.description || '',
mileage_at_cost: raw.mileage_at_cost ?? raw.data?.mileage_at_cost,
// Keep raw fields for reference
amount_gross: raw.amount_gross,
amount_net: raw.amount_net,
currency_raw: raw.currency,
date: raw.date,
@@ -928,6 +938,23 @@ watch(() => props.vehicle, (newVehicle) => {
}
})
/**
* F5 Bug fix (RBAC Phase 3): Re-fetch costs when refreshTrigger changes.
* A DashboardView növeli ezt a számlálót, amikor a CostsActionsCard 'cost-saved' eventet emitál.
*/
watch(() => props.refreshTrigger, (newVal, oldVal) => {
if (newVal !== undefined && newVal !== oldVal && activeTab.value === 'finance' && props.vehicle?.id) {
costsLoading.value = true
api.get(`/assets/${props.vehicle.id}/costs`)
.then(res => {
const rawData = (res.data as any[]) || []
vehicleCosts.value = rawData.map(mapBackendCost)
})
.catch(err => { console.error('[VehicleDetailModal] Failed to fetch costs:', err); vehicleCosts.value = [] })
.finally(() => { costsLoading.value = false })
}
})
// ── Format date helper ──
function formatDate(dateStr: string): string {
if (!dateStr) return '—'

View File

@@ -132,8 +132,10 @@ router.beforeEach(async (to, _from, next) => {
// If the route requires admin privileges, check the user's role.
if (to.meta.requiresAdmin) {
const role = authStore.user?.role
const adminRoles = ['superadmin', 'admin', 'region_admin', 'country_admin', 'moderator']
if (!role || !adminRoles.includes(role)) {
// CRITICAL: Backend sends UPPERCASE roles (SUPERADMIN, ADMIN, MODERATOR)
// Must use toUpperCase() to ensure case-insensitive matching
const adminRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR']
if (!role || !adminRoles.includes(role.toUpperCase())) {
// Non-admin user trying to access admin area → redirect to dashboard
return next('/dashboard')
}

View File

@@ -69,6 +69,10 @@ export interface UserProfile {
person_id: number | null
// Nested person data with address
person: PersonData | null
// RBAC Phase 3: Rendszerszintű képességek (a SYSTEM_CAPABILITIES_MATRIX-ból)
system_capabilities?: Record<string, boolean>
// RBAC Phase 3: Szervezeti szintű képességek (org_id -> capability -> bool)
org_capabilities?: Record<string, Record<string, boolean>>
}
export const useAuthStore = defineStore('auth', () => {
@@ -87,6 +91,19 @@ export const useAuthStore = defineStore('auth', () => {
return !!user.value?.person_id
})
const userRole = computed(() => user.value?.role ?? 'guest')
/**
* Ellenőrzi, hogy a felhasználó rendelkezik-e admin jogosultsággal.
* A SUPERADMIN és ADMIN (és egyéb adminisztratív) szerepköröket ismeri fel.
*/
const isAdmin = computed(() => {
const role = user.value?.role
if (!role) return false
// CRITICAL: Backend sends UPPERCASE roles (SUPERADMIN, ADMIN, MODERATOR)
// Must use toUpperCase() to ensure case-insensitive matching
const adminRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR']
return adminRoles.includes(role.toUpperCase())
})
const userName = computed(() => {
if (!user.value) return ''
const { first_name, last_name } = user.value
@@ -95,6 +112,41 @@ export const useAuthStore = defineStore('auth', () => {
})
const userId = computed(() => user.value?.id ?? null)
// ── RBAC Phase 3: Capability Helpers ───────────────────────────────
/**
* Ellenőrzi, hogy a felhasználó rendelkezik-e egy adott rendszerszintű képességgel.
* A képességek a backend /auth/me vagy /users/me végpontról érkeznek
* a SYSTEM_CAPABILITIES_MATRIX alapján.
* @param capability - A képesség neve (pl. 'can_manage_users')
* @returns true, ha a képesség elérhető a user szerepköréhez
*/
function hasSystemCapability(capability: string): boolean {
return user.value?.system_capabilities?.[capability] === true
}
/**
* Ellenőrzi, hogy a felhasználó rendelkezik-e egy adott szervezeti képességgel.
* A szervezeti képességek az OrgRole.permissions JSONB mezőből származnak.
* @param orgId - A szervezet azonosítója (string vagy number)
* @param capability - A képesség neve (pl. 'can_approve_expense')
* @returns true, ha a képesség elérhető a user számára az adott szervezetben
*/
function hasOrgCapability(orgId: string | number, capability: string): boolean {
const orgCaps = user.value?.org_capabilities?.[String(orgId)]
if (!orgCaps) return false
return orgCaps[capability] === true
}
/**
* Visszaadja az összes szervezeti képességet egy adott szervezethez.
* @param orgId - A szervezet azonosítója
* @returns A képességek objektuma, vagy üres objektum, ha nincs
*/
function getOrgCapabilities(orgId: string | number): Record<string, boolean> {
return user.value?.org_capabilities?.[String(orgId)] || {}
}
// ────────────────────────────── Helpers ────────────────────────────
/**
@@ -671,6 +723,7 @@ export const useAuthStore = defineStore('auth', () => {
// Getters
isAuthenticated,
isAdmin,
isKycComplete,
userRole,
userName,
@@ -679,6 +732,11 @@ export const useAuthStore = defineStore('auth', () => {
hasBusinessOrganization,
isCorporateMode,
// RBAC Phase 3: Capability Helpers
hasSystemCapability,
hasOrgCapability,
getOrgCapabilities,
// Actions
login,
fetchUser,

View File

@@ -4,22 +4,71 @@ import { ref, computed } from 'vue'
export type UserRole = 'guest' | 'user' | 'manager' | 'admin' | 'superadmin'
export const useAuthStore = defineStore('auth', () => {
// State
// ── State ──────────────────────────────────────────────────────────
const isAuthenticated = ref(false)
const userRole = ref<UserRole>('guest')
const userName = ref('')
const userId = ref<number | null>(null)
// Getters
/**
* RBAC Phase 3: Rendszerszintű képességek (a SYSTEM_CAPABILITIES_MATRIX-ból).
* Pl. { can_manage_users: false, can_manage_own_vehicles: true, ... }
*/
const systemCapabilities = ref<Record<string, boolean>>({})
/**
* RBAC Phase 3: Szervezeti szintű képességek.
* Kulcs: organization_id (string), Érték: { capability_name: boolean }
* Pl. { "5": { can_approve_expense: true, can_add_expense: true } }
*/
const orgCapabilities = ref<Record<string, Record<string, boolean>>>({})
// ── Getters ────────────────────────────────────────────────────────
const isAdmin = computed(() => {
return userRole.value === 'admin' || userRole.value === 'superadmin'
// CRITICAL: Backend sends UPPERCASE roles (SUPERADMIN, ADMIN, MODERATOR)
// Must use toUpperCase() to ensure case-insensitive matching
const role = userRole.value.toUpperCase()
return role === 'ADMIN' || role === 'SUPERADMIN'
})
const isSuperAdmin = computed(() => userRole.value === 'superadmin')
const isManager = computed(() => userRole.value === 'manager')
const isRegularUser = computed(() => userRole.value === 'user')
const isSuperAdmin = computed(() => userRole.value.toUpperCase() === 'SUPERADMIN')
const isManager = computed(() => userRole.value.toUpperCase() === 'MANAGER')
const isRegularUser = computed(() => userRole.value.toUpperCase() === 'USER')
// Actions
// ── RBAC Phase 3: Capability Helpers ───────────────────────────────
/**
* Ellenőrzi, hogy a felhasználó rendelkezik-e egy adott rendszerszintű képességgel.
* @param capability - A képesség neve (pl. 'can_manage_users')
* @returns true, ha a képesség elérhető a user szerepköréhez
*/
function hasSystemCapability(capability: string): boolean {
return systemCapabilities.value[capability] === true
}
/**
* Ellenőrzi, hogy a felhasználó rendelkezik-e egy adott szervezeti képességgel.
* @param orgId - A szervezet azonosítója (string vagy number)
* @param capability - A képesség neve (pl. 'can_approve_expense')
* @returns true, ha a képesség elérhető a user számára az adott szervezetben
*/
function hasOrgCapability(orgId: string | number, capability: string): boolean {
const orgIdStr = String(orgId)
const orgCaps = orgCapabilities.value[orgIdStr]
if (!orgCaps) return false
return orgCaps[capability] === true
}
/**
* Visszaadja az összes szervezeti képességet egy adott szervezethez.
* @param orgId - A szervezet azonosítója
* @returns A képességek objektuma, vagy üres objektum, ha nincs
*/
function getOrgCapabilities(orgId: string | number): Record<string, boolean> {
return orgCapabilities.value[String(orgId)] || {}
}
// ── Actions ────────────────────────────────────────────────────────
function login(role: UserRole, name: string, id: number) {
isAuthenticated.value = true
userRole.value = role
@@ -33,6 +82,8 @@ export const useAuthStore = defineStore('auth', () => {
userRole.value = 'guest'
userName.value = ''
userId.value = null
systemCapabilities.value = {}
orgCapabilities.value = {}
localStorage.removeItem('auth')
}
@@ -51,6 +102,19 @@ export const useAuthStore = defineStore('auth', () => {
}
}
/**
* RBAC Phase 3: Frissíti a képességeket a backend /users/me válaszából.
* @param systemCaps - Rendszerszintű képességek objektuma
* @param orgCaps - Szervezeti képességek objektuma (org_id -> { capability: bool })
*/
function setCapabilities(
systemCaps: Record<string, boolean>,
orgCaps: Record<string, Record<string, boolean>>
) {
systemCapabilities.value = systemCaps
orgCapabilities.value = orgCaps
}
// Simulate fetching user role from backend
async function fetchUserRole() {
// In a real app, this would be an API call
@@ -69,17 +133,25 @@ export const useAuthStore = defineStore('auth', () => {
userRole,
userName,
userId,
systemCapabilities,
orgCapabilities,
// Getters
isAdmin,
isSuperAdmin,
isManager,
isRegularUser,
// RBAC Phase 3: Capability Helpers
hasSystemCapability,
hasOrgCapability,
getOrgCapabilities,
// Actions
login,
logout,
init,
fetchUserRole
setCapabilities,
fetchUserRole,
}
})
})

View File

@@ -9,7 +9,8 @@ export interface AddExpensePayload {
asset_id: string
organization_id?: number | null // Auto-resolved by backend from asset if omitted
category_id: number
amount_net: number
/** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary monetary value */
amount_gross: number
currency?: string
date: string
mileage_at_cost?: number | null
@@ -25,7 +26,7 @@ export interface ExpenseResponse {
id: string
asset_id: string
cost_category: string
amount_net: number
amount_gross: number
date: string
}
@@ -51,7 +52,8 @@ export const useCostStore = defineStore('cost', () => {
const body: Record<string, any> = {
asset_id: payload.asset_id,
category_id: payload.category_id,
amount_net: payload.amount_net,
/** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary field */
amount_gross: payload.amount_gross,
currency: payload.currency || 'HUF',
date: payload.date,
mileage_at_cost: payload.mileage_at_cost ?? null,

View File

@@ -13,6 +13,8 @@ export interface OrganizationItem {
is_active: boolean
is_deleted: boolean
subscription_plan: string
/** P0 Feature: FK to system.subscription_tiers — the assigned subscription package */
subscription_tier_id?: number | null
/** org_type is not in the current /my response but we keep it for future use */
org_type?: string
/**
@@ -27,4 +29,11 @@ export interface OrganizationItem {
* Used by the frontend to decide whether to show service_provider orgs in the garage switcher.
*/
user_role?: string
// ── Cím adatok (Address fields) ──
address_zip?: string | null
address_city?: string | null
address_street_name?: string | null
address_street_type?: string | null
address_house_number?: string | null
address_hrsz?: string | null
}

View File

@@ -52,243 +52,27 @@
class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4"
style="perspective: 1200px;"
>
<!--
Card 1: 🚗 My Garage (Járműveim)
-->
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
@click="openCard('vehicles')"
>
<!-- Top header bar -->
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">🚗 {{ t('dashboard.myVehicles') }}</span>
<span class="ml-auto text-xs text-white/60">{{ vehicleStore.vehicles.length }} {{ t('dashboard.vehicles') }}</span>
</div>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 min-h-0">
<div class="flex-1 overflow-y-auto space-y-2">
<VehicleCardCompact
v-for="vehicle in displayVehicles.slice(0, 3)"
:key="vehicle.id"
:vehicle="vehicle"
@click="openCard('vehicles')"
@plate-click="openVehicleDetail(vehicle)"
/>
<!-- Empty state (only when BOTH real and mock are empty) -->
<div
v-if="displayVehicles.length === 0"
class="flex flex-col items-center justify-center py-6 text-slate-400"
>
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<p class="text-sm">{{ t('dashboard.noVehicles') }}</p>
</div>
</div>
</div>
</div>
<!-- Card 1: 🚗 My Garage (Járműveim) -->
<MyVehiclesCard
@open-card="openCard"
@open-vehicle-detail="openVehicleDetail"
/>
<!--
Card 2: 💰 Costs & Quick Actions (Action Center)
-->
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
>
<!-- Top header bar -->
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4 gap-2">
<span class="text-white font-bold text-sm tracking-wide">💰 {{ t('dashboard.costsTitle') }}</span>
</div>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
<!-- Vehicle Selector Dropdown -->
<div class="mb-3">
<label class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1 block">{{ t('dashboard.vehicle') }}</label>
<select
v-model="selectedActionVehicleId"
class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
>
<option
v-for="v in vehicleStore.sortedVehicles"
:key="v.id"
:value="v.id"
>
{{ v.license_plate || '—' }} ({{ v.brand || '' }} {{ v.model || '' }})
</option>
</select>
</div>
<!-- Card 2: 💰 Costs & Quick Actions (Action Center) -->
<CostsActionsCard @cost-saved="onCostSaved" />
<!-- Action Buttons -->
<div class="flex-1 flex flex-col gap-2.5 justify-center">
<!-- No vehicle warning -->
<div
v-if="!selectedActionVehicle"
class="flex items-center justify-center rounded-xl bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700"
>
<span> {{ t('dashboard.noVehicleSelected') }}</span>
</div>
<!-- Card 3: 🔧 Service Finder (3-Way Indítópult) -->
<ServiceFinderCard />
<!-- Record Fueling (Big, prominent) -->
<button
v-if="selectedActionVehicle"
@click="openFuelModal"
class="flex items-center gap-3 rounded-xl bg-gradient-to-r from-sf-accent to-emerald-500 px-4 py-3 text-sm font-bold text-white shadow-md transition-all hover:shadow-lg hover:scale-[1.02] active:scale-[0.98]"
>
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-white/20 text-lg"></span>
<span>{{ t('dashboard.recordFueling') }}</span>
<span class="ml-auto text-white/60 text-xs"></span>
</button>
<!-- Card 4: 🏆 Gamification (Játékosítás) -->
<GamificationCard
@open-card="openCard"
/>
<!-- 🅿 Parking / Toll (Medium) -->
<button
v-if="selectedActionVehicle"
@click="openFeeModal"
class="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-all hover:bg-slate-100 hover:shadow-md active:scale-[0.98]"
>
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-200 text-base">🅿</span>
<span>{{ t('dashboard.parkingToll') }}</span>
<span class="ml-auto text-slate-400 text-xs"></span>
</button>
<!-- Additional Costs (Medium) -->
<button
v-if="selectedActionVehicle"
@click="openComplexModal"
class="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-all hover:bg-slate-100 hover:shadow-md active:scale-[0.98]"
>
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-200 text-base"></span>
<span>{{ t('dashboard.additionalCosts') }}</span>
<span class="ml-auto text-slate-400 text-xs"></span>
</button>
</div>
</div>
</div>
<!--
Card 3: 🔧 Service Finder (3-Way Indítópult)
-->
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl cursor-pointer"
@click="openExternalServiceFinder"
>
<!-- Top header bar -->
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">{{ t('serviceFinder.title') }}</span>
</div>
<!-- Content: 3 simple buttons stacked vertically -->
<div class="p-4 flex-1 flex flex-col justify-center gap-2">
<button
@click.stop="openExternalServiceFinder"
class="w-full rounded-xl bg-sf-accent px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-sf-accent/90 hover:shadow-md active:scale-[0.97] cursor-pointer"
>
🔍 {{ t('serviceFinder.plannedMaintenance') }}
</button>
<button
@click.stop="router.push('/dashboard/service-finder?mode=sos')"
class="w-full rounded-xl border border-red-300 bg-red-50 px-4 py-2.5 text-sm font-semibold text-red-700 shadow-sm transition-all hover:bg-red-100 hover:shadow-md active:scale-[0.97] cursor-pointer"
>
🚨 SOS {{ t('serviceFinder.sosTitle') }}
</button>
<button
@click.stop="isSfQuickAddOpen = true"
class="w-full rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 shadow-sm transition-all hover:border-sf-accent hover:bg-sf-accent/5 hover:shadow-md active:scale-[0.97] cursor-pointer"
>
{{ t('provider.quickAddTitle') }}
</button>
</div>
</div>
<!--
Card 4: 🏆 Gamification (Játékosítás)
-->
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
@click="openCard('gamification')"
>
<!-- Top header bar -->
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">🏆 {{ t('dashboard.statsAndPoints') }}</span>
<span class="ml-auto inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
{{ t('dashboard.live') }}
</span>
</div>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
<div class="flex-1 space-y-3">
<!-- Score card -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
<p class="text-3xl font-extrabold text-emerald-600">2,450</p>
<p class="text-xs text-slate-500 mt-1">{{ t('dashboard.monthlyScore') }}</p>
</div>
<!-- Achievement badges -->
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">
{{ t('dashboard.achievements') }}
</p>
<div class="flex flex-wrap gap-2">
<span
v-for="badge in badges"
:key="badge.label"
class="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs text-slate-600"
>
{{ badge.icon }} {{ badge.label }}
</span>
</div>
</div>
</div>
</div>
<!-- Bottom CTA button -->
<button class="mt-auto mb-4 mx-auto px-8 py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
{{ t('dashboard.refresh') }} 🏆
</button>
</div>
<!--
Card 5: 🛡 My Profile & Trust (Profil & Trust Score)
-->
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
@click="openCard('stats')"
>
<!-- Top header bar -->
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">🛡 {{ t('header.profile') }}</span>
</div>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
<div class="flex-1 space-y-3">
<!-- User info -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<p class="text-sm font-bold text-slate-800">{{ authStore.userName || t('dashboard.guest') }}</p>
<p class="text-xs text-slate-400 mt-0.5">{{ authStore.user?.email }}</p>
</div>
<!-- Trust Score -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-slate-500 font-semibold uppercase tracking-wider">Trust Score</span>
<span class="text-sm font-bold text-emerald-600">A+</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-slate-200">
<div class="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600 transition-all duration-500" style="width: 92%" />
</div>
</div>
<!-- Quick stats -->
<div class="grid grid-cols-2 gap-2">
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
<p class="text-lg font-bold text-slate-800">{{ authStore.myOrganizations.length }}</p>
<p class="text-xs text-slate-400">{{ t('header.myCompanies') }}</p>
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
<p class="text-lg font-bold text-slate-800">{{ vehicleStore.vehicles.length }}</p>
<p class="text-xs text-slate-400">{{ t('dashboard.totalVehicles') }}</p>
</div>
</div>
</div>
</div>
<!-- Bottom CTA button -->
<button class="mt-auto mb-4 mx-auto px-8 py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
{{ t('header.profile') }}
</button>
</div>
<!-- Card 5: 🛡 My Profile & Trust (Profil & Trust Score) -->
<ProfileTrustCard
@open-card="openCard"
/>
</div>
</div>
</div>
@@ -337,36 +121,13 @@
</div>
</Teleport>
<!--
Cost Action Modals (Teleported to body)
-->
<SimpleFuelModal
:is-open="isFuelModalOpen"
:vehicle="selectedActionVehicle"
@close="isFuelModalOpen = false"
@saved="onModalSaved"
/>
<ComplexExpenseModal
:is-open="isFeeModalOpen"
:vehicle="selectedActionVehicle"
:is-fee-mode="true"
@close="isFeeModalOpen = false"
@saved="onModalSaved"
/>
<ComplexExpenseModal
:is-open="isComplexModalOpen"
:vehicle="selectedActionVehicle"
:is-fee-mode="false"
@close="isComplexModalOpen = false"
@saved="onModalSaved"
/>
<!--
Vehicle Detail Modal (Digital Twin 3-Tab Deep Link)
-->
<VehicleDetailModal
:is-open="isVehicleDetailOpen"
:vehicle="detailVehicle"
:refresh-trigger="refreshCostsTrigger"
@close="closeVehicleDetail"
@edit="handleEditFromDetail"
@set-primary="handleSetPrimaryFromDetail"
@@ -391,32 +152,29 @@
@saved="onEditVehicleSaved"
@deleted="onVehicleDeleted"
/>
<!--
Provider Quick Add Modal (Service Finder Card 3)
-->
<ProviderQuickAddModal
:is-open="isSfQuickAddOpen"
@close="isSfQuickAddOpen = false"
@saved="onSfQuickAddSaved"
/>
</div><!-- end min-h-screen -->
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { ref, onMounted, watch, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../stores/auth'
import { useVehicleStore } from '../stores/vehicle'
import type { VehicleData } from '../types/vehicle'
import api from '../api/axios'
// ── Dashboard Card Components ──
import MyVehiclesCard from '../components/dashboard/MyVehiclesCard.vue'
import CostsActionsCard from '../components/dashboard/CostsActionsCard.vue'
import ServiceFinderCard from '../components/dashboard/ServiceFinderCard.vue'
import GamificationCard from '../components/dashboard/GamificationCard.vue'
import ProfileTrustCard from '../components/dashboard/ProfileTrustCard.vue'
// ── Shared Modals ──
import PrivateVehicleManager from '../components/dashboard/PrivateVehicleManager.vue'
import VehicleCardCompact from '../components/vehicle/VehicleCardCompact.vue'
import VehicleDetailModal from '../components/vehicle/VehicleDetailModal.vue'
import VehicleFormModal from '../components/dashboard/VehicleFormModal.vue'
import SimpleFuelModal from '../components/dashboard/SimpleFuelModal.vue'
import ComplexExpenseModal from '../components/dashboard/ComplexExpenseModal.vue'
import ProviderQuickAddModal from '../components/provider/ProviderQuickAddModal.vue'
const { t } = useI18n()
@@ -437,30 +195,16 @@ const openCard = (cardId: string, targetId: string | null = null) => {
selectedTargetId.value = targetId
}
// ── Service Finder (Card 3) — Launcher ──
const isSearchModalOpen = ref(false)
const isSfQuickAddOpen = ref(false)
/**
* Navigates to the Service Finder page in the same tab.
* Used by the card click and the "Tervezett karbantartás" button.
*/
function openExternalServiceFinder() {
router.push('/dashboard/service-finder')
}
/**
* Called when ProviderQuickAddModal saves a new provider.
*/
function onSfQuickAddSaved(provider: { id: number; name: string; category?: string | null; city?: string | null; address?: string | null }) {
isSfQuickAddOpen.value = false
alert(`🎉 ${t('provider.successMessage', { points: 0 })}`)
}
// ── Vehicle Detail Modal (Deep Link from plate click) ──
const isVehicleDetailOpen = ref(false)
const detailVehicle = ref<VehicleData | null>(null)
/**
* F5 Bug fix (RBAC Phase 3): Számláló, ami minden költség mentéskor nő.
* A VehicleDetailModal ezt figyelve újratölti a költségeket.
*/
const refreshCostsTrigger = ref(0)
function openVehicleDetail(vehicle: VehicleData) {
detailVehicle.value = vehicle
isVehicleDetailOpen.value = true
@@ -471,6 +215,15 @@ function closeVehicleDetail() {
detailVehicle.value = null
}
/**
* F5 Bug fix (RBAC Phase 3): Kezeli a CostsActionsCard cost-saved eseményét.
* Növeli a refreshCostsTrigger számlálót, ami a VehicleDetailModal-ban
* watch által figyelve újratölti a költségeket.
*/
function onCostSaved(vehicleId: string) {
refreshCostsTrigger.value++
}
// ── Vehicle Form Modal State (standalone, decoupled from Manager) ──
const isAddFormOpen = ref(false)
const isEditFormOpen = ref(false)
@@ -586,71 +339,6 @@ function handleSetPrimaryFromDetail(vehicle: VehicleData) {
vehicleStore.setPrimaryVehicle(vehicle.id)
}
/**
* Display vehicles from the backend API only.
* Sorted by is_primary flag (favorite first), then alphabetically.
* Shows all real vehicles; no mock/fallback data is injected.
*/
const displayVehicles = computed(() => {
return vehicleStore.sortedVehicles
})
// ── Cost Card Action Center State ──
const selectedActionVehicleId = ref<string>('')
const isFuelModalOpen = ref(false)
const isFeeModalOpen = ref(false)
const isComplexModalOpen = ref(false)
/** The currently selected vehicle object for the action modals */
const selectedActionVehicle = computed(() => {
if (!selectedActionVehicleId.value) return null
return vehicleStore.vehicles.find(v => v.id === selectedActionVehicleId.value) || null
})
/** Auto-select the first (primary/sorted) vehicle on load */
function autoSelectVehicle() {
if (vehicleStore.sortedVehicles.length > 0 && !selectedActionVehicleId.value) {
selectedActionVehicleId.value = vehicleStore.sortedVehicles[0].id
}
}
function openFuelModal() {
if (!selectedActionVehicle.value) return
isFuelModalOpen.value = true
}
function openFeeModal() {
if (!selectedActionVehicle.value) return
isFeeModalOpen.value = true
}
function openComplexModal() {
if (!selectedActionVehicle.value) return
isComplexModalOpen.value = true
}
function onModalSaved() {
isFuelModalOpen.value = false
isFeeModalOpen.value = false
isComplexModalOpen.value = false
}
// ── Placeholder notifications ──
const notifications = ref([
{ icon: '🔧', title: 'Service due for BMW X5', time: '2 hours ago' },
{ icon: '⛽', title: 'Fuel cost updated — +₿ 0.08', time: '5 hours ago' },
{ icon: '🏆', title: 'You earned "Eco Driver" badge!', time: '1 day ago' },
{ icon: '📊', title: 'Monthly report is ready', time: '2 days ago' },
])
// ── Placeholder badges ──
const badges = ref([
{ icon: '🌱', label: 'Eco Driver' },
{ icon: '📏', label: '10k km Club' },
{ icon: '🔧', label: 'Service Pro' },
{ icon: '⭐', label: 'Top Rater' },
])
onMounted(() => {
vehicleStore.fetchVehicles()
authStore.fetchMyOrganizations()
@@ -664,11 +352,6 @@ onMounted(() => {
}
})
// Watch for vehicles to load, then auto-select
watch(() => vehicleStore.vehicles.length, () => {
autoSelectVehicle()
}, { immediate: true })
// ── Watch for query param changes (hamburger menu navigation) ────
watch(() => route.query.card, (newCard) => {
if (newCard && typeof newCard === 'string') {

View File

@@ -127,7 +127,7 @@
<!-- Hard Delete: Only visible for Superadmin -->
<button
v-if="authStore.user?.role === 'superadmin'"
v-if="authStore.user?.role?.toUpperCase() === 'SUPERADMIN'"
class="px-3 py-1.5 bg-red-800 hover:bg-red-900 text-white text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="bulkLoading"
@click="executeBulkAction('hard_delete')"
@@ -296,7 +296,7 @@
</button>
<div class="border-t border-gray-600 my-1"></div>
<button
v-if="authStore.user?.role === 'superadmin'"
v-if="authStore.user?.role?.toUpperCase() === 'SUPERADMIN'"
class="w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-gray-700 transition-colors flex items-center gap-2"
@click="individualAction(user.id, 'hard_delete')"
>