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) - 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=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é) - 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.db.session import get_db
from app.core.security import decode_token, DEFAULT_RANK_MAP 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.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.config import settings
from app.core.translation_helper import t # Translation helper from app.core.translation_helper import t # Translation helper
from app.core.capabilities import SYSTEM_CAPABILITIES_MATRIX, role_has_capability
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -110,7 +112,7 @@ async def check_resource_access(
""" """
Scoped RBAC: Megakadályozza a jogosulatlan hozzáférést mások adataihoz. 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 return True
user_scope = str(current_user.scope_id) if current_user.scope_id else None user_scope = str(current_user.scope_id) if current_user.scope_id else None
@@ -161,13 +163,11 @@ async def get_current_admin(
""" """
Csak admin/moderátor/superadmin szerepkörrel rendelkező felhasználók számára. 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 = { allowed_roles = {
UserRole.superadmin, UserRole.SUPERADMIN,
UserRole.admin, UserRole.ADMIN,
UserRole.region_admin, UserRole.MODERATOR,
UserRole.country_admin,
UserRole.moderator,
} }
if current_user.role not in allowed_roles: if current_user.role not in allowed_roles:
raise HTTPException( raise HTTPException(
@@ -175,3 +175,126 @@ async def get_current_admin(
detail=t("AUTH.INSUFFICIENT_ADMIN_PERMISSIONS") 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 # 2. Ellenőrizd, hogy nem superadmin-e
if user.role == UserRole.superadmin: if user.role == UserRole.SUPERADMIN:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot ban a superadmin user" detail="Cannot ban a superadmin user"
@@ -698,7 +698,7 @@ async def bulk_user_action(
affected_count = 0 affected_count = 0
for user in users: 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 continue
if request.action == "ban": if request.action == "ban":

View File

@@ -11,7 +11,7 @@ from sqlalchemy.orm import selectinload
from app.db.session import get_db from app.db.session import get_db
from app.api.deps import get_current_user 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.models.identity import User
from app.services.cost_service import cost_service from app.services.cost_service import cost_service
from app.services.asset_service import AssetService from app.services.asset_service import AssetService
@@ -21,6 +21,79 @@ from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate, AssetEven
logger = logging.getLogger(__name__) 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): class ArchiveVehicleRequest(BaseModel):
"""Payload a jármű archiválásához (Soft Delete).""" """Payload a jármű archiválásához (Soft Delete)."""
final_mileage: int = Field(..., ge=0, description="Utolsó km óra állás") 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 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: Returns:
{ "can_add": bool, "current_count": int, "limit": int } { "can_add": bool, "current_count": int, "limit": int }
""" """
try: try:
from sqlalchemy import func
from app.models.marketplace.organization import Organization
# Determine organization ID based on user's active scope (garage isolation) # Determine organization ID based on user's active scope (garage isolation)
org_id = None org_id = None
if current_user.scope_id is not 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) org_id = int(current_user.scope_id)
except (ValueError, TypeError): except (ValueError, TypeError):
pass 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 # Get the user's vehicle limit
allowed_limit = await AssetService.get_user_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 # SAFETY: Ensure limit is at least 1
allowed_limit = max(allowed_limit or 1, 1) allowed_limit = max(allowed_limit or 1, 1)
# Count only active vehicles (draft vehicles don't count toward the limit) # Count only active vehicles in the resolved organization
from sqlalchemy import func
if org_id is not None: if org_id is not None:
count_stmt = select(func.count(Asset.id)).where( count_stmt = select(func.count(Asset.id)).where(
Asset.current_organization_id == org_id, Asset.current_organization_id == org_id,
Asset.owner_person_id == current_user.person_id,
Asset.status == "active" Asset.status == "active"
) )
else: else:
# Fallback: count by owner_person_id if no org could be resolved
count_stmt = select(func.count(Asset.id)).where( count_stmt = select(func.count(Asset.id)).where(
Asset.current_organization_id.is_(None),
Asset.owner_person_id == current_user.person_id, Asset.owner_person_id == current_user.person_id,
Asset.status == "active" Asset.status == "active"
) )
@@ -158,29 +251,35 @@ async def get_user_vehicles(
order_expr = text("(individual_equipment->>'is_primary')::boolean DESC NULLS LAST") order_expr = text("(individual_equipment->>'is_primary')::boolean DESC NULLS LAST")
if current_user.scope_id is None: if current_user.scope_id is None:
# Personal mode: only show vehicles owned/operated personally (no organization) # Personal mode: show vehicles in organizations where user is a member
stmt = ( org_stmt = select(OrganizationMember.organization_id).where(
select(Asset) OrganizationMember.user_id == current_user.id
.where( )
or_( org_result = await db.execute(org_stmt)
Asset.owner_org_id.is_(None), user_org_ids = [row[0] for row in org_result.all()]
Asset.operator_org_id.is_(None)
), if user_org_ids:
or_( stmt = (
Asset.owner_person_id == current_user.person_id, select(Asset)
Asset.operator_person_id == current_user.person_id .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()) else:
.offset(skip) # No organizations — return empty list
.limit(limit) return []
.options(
selectinload(Asset.catalog),
selectinload(Asset.odometer_readings)
)
)
else: 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: try:
scope_org_id = int(current_user.scope_id) scope_org_id = int(current_user.scope_id)
except (ValueError, TypeError): except (ValueError, TypeError):
@@ -191,25 +290,11 @@ async def get_user_vehicles(
# Fallback: no valid organization, return empty list # Fallback: no valid organization, return empty list
return [] return []
# First, get all branch IDs (garages) for this organization # Query assets that belong to the active 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
stmt = ( stmt = (
select(Asset) select(Asset)
.where( .where(
Asset.branch_id.in_(branch_ids), Asset.current_organization_id == scope_org_id,
Asset.status == "active" Asset.status == "active"
) )
.order_by(order_expr, Asset.created_at.desc()) .order_by(order_expr, Asset.created_at.desc())
@@ -271,7 +356,9 @@ async def list_asset_costs(
"id": cost.id, "id": cost.id,
"asset_id": cost.asset_id, "asset_id": cost.asset_id,
"organization_id": cost.organization_id, "organization_id": cost.organization_id,
"amount_gross": cost.amount_gross,
"amount_net": cost.amount_net, "amount_net": cost.amount_net,
"vat_rate": cost.vat_rate,
"currency": cost.currency, "currency": cost.currency,
"date": cost.date, "date": cost.date,
"invoice_number": cost.invoice_number, "invoice_number": cost.invoice_number,
@@ -281,6 +368,8 @@ async def list_asset_costs(
"category_code": cost.category.code if cost.category else None, "category_code": cost.category.code if cost.category else None,
"description": (cost.data or {}).get("description"), "description": (cost.data or {}).get("description"),
"mileage_at_cost": (cost.data or {}).get("mileage_at_cost"), "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)) result.append(AssetCostResponse(**cost_dict))
@@ -308,22 +397,95 @@ async def get_asset(
user_org_ids = [row[0] for row in org_result.all()] user_org_ids = [row[0] for row in org_result.all()]
# Query asset with catalog and master definition # Query asset with catalog and master definition
stmt = ( # Unified Workspace: access is granted if the asset belongs to an organization
select(Asset) # where the current user is an active member
.where( if user_org_ids:
Asset.id == asset_id, stmt = (
or_( select(Asset)
Asset.owner_person_id == current_user.id, .where(
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False, Asset.id == asset_id,
Asset.operator_person_id == current_user.id, Asset.current_organization_id.in_(user_org_ids)
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False )
.options(
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
selectinload(Asset.odometer_readings)
) )
) )
.options( else:
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition), stmt = (
selectinload(Asset.odometer_readings) 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()
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.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) result = await db.execute(stmt)
asset = result.scalar_one_or_none() asset = result.scalar_one_or_none()
@@ -366,21 +528,27 @@ async def create_or_claim_vehicle(
existing_asset = existing_result.scalar_one_or_none() existing_asset = existing_result.scalar_one_or_none()
if existing_asset: if existing_asset:
# Szabály A: Saját jármű duplikációja # Szabály A: Saját jármű duplikációja — check via organization membership
if existing_asset.owner_person_id == current_user.person_id: # 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( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Ez a jármű már szerepel a garázsodban!" detail="Ez a jármű már szerepel a garázsodban!"
) )
# Szabály B: Tulajdonosváltás / Átmeneti állapot # 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 # Kényszerítsük draft állapotba, jelezve az átmeneti státuszt
payload.data_status = "draft" payload.data_status = "draft"
logger.info( logger.info(
f"License plate {license_plate_clean} exists with different owner " f"License plate {license_plate_clean} exists in different organization "
f"(owner_person_id={existing_asset.owner_person_id} != " f"(current_organization_id={existing_asset.current_organization_id}). "
f"current_user.person_id={current_user.person_id}). "
f"Setting data_status='draft' for transfer scenario." f"Setting data_status='draft' for transfer scenario."
) )
@@ -458,20 +626,25 @@ async def update_vehicle(
org_result = await db.execute(org_stmt) org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()] user_org_ids = [row[0] for row in org_result.all()]
# Find the asset # Find the asset — Unified Workspace: access via organization membership only
stmt = ( if user_org_ids:
select(Asset) stmt = (
.where( select(Asset)
Asset.id == asset_id, .where(
or_( Asset.id == asset_id,
Asset.owner_person_id == current_user.person_id, Asset.current_organization_id.in_(user_org_ids)
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
) )
.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) result = await db.execute(stmt)
asset = result.scalar_one_or_none() asset = result.scalar_one_or_none()
@@ -540,16 +713,17 @@ async def list_maintenance_records(
org_result = await db.execute(org_stmt) org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()] user_org_ids = [row[0] for row in org_result.all()]
# Check asset access # Check asset access — Unified Workspace: via organization membership only
asset_stmt = select(Asset).where( if user_org_ids:
Asset.id == asset_id, asset_stmt = select(Asset).where(
or_( Asset.id == asset_id,
Asset.owner_person_id == current_user.id, Asset.current_organization_id.in_(user_org_ids)
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False, )
Asset.operator_person_id == current_user.id, else:
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False asset_stmt = select(Asset).where(
Asset.id == asset_id,
Asset.current_organization_id.is_(None)
) )
)
asset_result = await db.execute(asset_stmt) asset_result = await db.execute(asset_stmt)
asset = asset_result.scalar_one_or_none() asset = asset_result.scalar_one_or_none()
@@ -599,16 +773,17 @@ async def create_maintenance_record(
org_result = await db.execute(org_stmt) org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()] user_org_ids = [row[0] for row in org_result.all()]
# Check asset access and get asset # Check asset access and get asset — Unified Workspace: via organization membership only
asset_stmt = select(Asset).where( if user_org_ids:
Asset.id == asset_id, asset_stmt = select(Asset).where(
or_( Asset.id == asset_id,
Asset.owner_person_id == current_user.id, Asset.current_organization_id.in_(user_org_ids)
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False, )
Asset.operator_person_id == current_user.id, else:
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False asset_stmt = select(Asset).where(
Asset.id == asset_id,
Asset.current_organization_id.is_(None)
) )
)
asset_result = await db.execute(asset_stmt) asset_result = await db.execute(asset_stmt)
asset = asset_result.scalar_one_or_none() asset = asset_result.scalar_one_or_none()
@@ -663,11 +838,14 @@ async def create_maintenance_record(
) )
# Create AssetCost record with MAINTENANCE category (id=2) # 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( maintenance_cost = AssetCost(
asset_id=asset_id, asset_id=asset_id,
organization_id=organization_id, organization_id=organization_id,
category_id=2, 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"), currency=payload.get("currency", "EUR"),
date=date, date=date,
invoice_number=payload.get("invoice_number"), invoice_number=payload.get("invoice_number"),
@@ -736,18 +914,23 @@ async def _check_asset_access(
org_result = await db.execute(org_stmt) org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()] user_org_ids = [row[0] for row in org_result.all()]
stmt = ( # Unified Workspace: access via organization membership only
select(Asset) if user_org_ids:
.where( stmt = (
Asset.id == asset_id, select(Asset)
or_( .where(
Asset.owner_person_id == current_user.id, Asset.id == asset_id,
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False, Asset.current_organization_id.in_(user_org_ids)
Asset.operator_person_id == current_user.id, )
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False )
else:
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
Asset.current_organization_id.is_(None)
) )
) )
)
result = await db.execute(stmt) result = await db.execute(stmt)
asset = result.scalar_one_or_none() 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 - Ha `cost_amount > 0`, automatikusan létrehoz egy AssetCost rekordot
a megfelelő költségkategóriával (MAINTENANCE/REPAIR/SERVICE), a megfelelő költségkategóriával (MAINTENANCE/REPAIR/SERVICE),
és összekapcsolja az eseménnyel a `cost_id` mezőn keresztül. é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 # Jogosultság ellenőrzés
asset = await _check_asset_access(db, asset_id, current_user) asset = await _check_asset_access(db, asset_id, current_user)
@@ -873,9 +1064,30 @@ async def create_asset_event(
# Szervezet meghatározása # Szervezet meghatározása
organization_id = asset.current_organization_id or asset.owner_org_id 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: try:
# ── Üzleti logika: AssetCost automatikus létrehozása ── # ── Üzleti logika: AssetCost automatikus létrehozása ──
cost_id = payload.cost_id cost_id = payload.cost_id
auto_created_cost_id = None
if payload.cost_amount is not None and payload.cost_amount > 0: 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 # Költségkategória feloldása az esemény típusa alapján
# SERVICE/MAINTENANCE -> karbantartás, REPAIR -> javítás # SERVICE/MAINTENANCE -> karbantartás, REPAIR -> javítás
@@ -883,13 +1095,17 @@ async def create_asset_event(
currency = payload.currency or "HUF" currency = payload.currency or "HUF"
# GROSS-FIRST: cost_amount is treated as gross (Bruttó)
cost_gross = payload.cost_amount
cost_record = AssetCost( cost_record = AssetCost(
asset_id=asset_id, asset_id=asset_id,
organization_id=organization_id, organization_id=organization_id,
category_id=cost_category_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, currency=currency,
date=event_date, date=event_date,
status=cost_status,
data={ data={
"description": payload.description or f"Service event: {payload.event_type}", "description": payload.description or f"Service event: {payload.event_type}",
"mileage_at_cost": payload.odometer_reading, "mileage_at_cost": payload.odometer_reading,
@@ -900,13 +1116,15 @@ async def create_asset_event(
db.add(cost_record) db.add(cost_record)
await db.flush() # Flush to get the cost_record.id await db.flush() # Flush to get the cost_record.id
cost_id = cost_record.id cost_id = cost_record.id
auto_created_cost_id = cost_record.id
logger.info( logger.info(
f"AssetCost auto-created for asset {asset_id}: " 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( event = AssetEvent(
asset_id=asset_id, asset_id=asset_id,
user_id=current_user.id, user_id=current_user.id,
@@ -915,9 +1133,28 @@ async def create_asset_event(
odometer_reading=payload.odometer_reading, odometer_reading=payload.odometer_reading,
description=payload.description, description=payload.description,
cost_id=cost_id, cost_id=cost_id,
linked_expense_id=auto_created_cost_id, # Bidirectional link
status=event_status,
event_date=event_date, event_date=event_date,
) )
db.add(event) 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 ── # ── Üzleti logika: Km óra állás frissítése ──
if payload.odometer_reading is not None and payload.odometer_reading > asset.current_mileage: 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 # RBAC helper function
def _check_premium_or_admin(user: User) -> bool: def _check_premium_or_admin(user: User) -> bool:
"""Check if user has premium subscription or admin role.""" """Check if user has premium subscription or admin role.
premium_plans = ['PREMIUM', 'PREMIUM_PLUS', 'VIP', 'VIP_PLUS']
if user.role == 'admin': P0: Legacy subscription_plan string check removed.
return True The Single Source of Truth is now subscription_tier JSONB rules.
if hasattr(user, 'subscription_plan') and user.subscription_plan in premium_plans: 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 True
return False return False

View File

@@ -2,10 +2,12 @@
import logging import logging
from fastapi import APIRouter, UploadFile, File, HTTPException, status, Depends from fastapi import APIRouter, UploadFile, File, HTTPException, status, Depends
from sqlalchemy.ext.asyncio import AsyncSession 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.api.deps import get_db, get_current_user
from app.models.identity import User from app.models.identity import User
from app.models import Asset # JAVÍTVA: Asset modell 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__) logger = logging.getLogger(__name__)
@@ -13,10 +15,28 @@ router = APIRouter()
@router.post("/scan-registration") @router.post("/scan-registration")
async def scan_registration_document(file: UploadFile = File(...), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): 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'") # ── P0 FEATURE: Quota engine sync — read org's subscription_tier rules ──
plan_key = (current_user.subscription_plan or "free").lower() # Instead of hardcoded plan-based lookup from system_parameters,
res = await db.execute(stmt_limit, {"plan": plan_key}) # we now read the organization's assigned subscription_tier rules.
max_allowed = max(res.scalar() or 1, 1) 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( stmt_count = select(func.count(Asset.id)).where(
Asset.owner_org_id == current_user.scope_id, 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 # /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/expenses.py
import logging import logging
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
from app.api.deps import get_db, get_current_user from app.api.deps import get_db, get_current_user, RequireOrgCapability
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter, OrgRole
from app.schemas.asset_cost import AssetCostCreate from app.schemas.asset_cost import AssetCostCreate
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -14,8 +15,101 @@ logger = logging.getLogger(__name__)
# These trigger automatic AssetEvent creation # These trigger automatic AssetEvent creation
SERVICE_RELATED_CATEGORY_IDS = {2, 16, 17, 18} # MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES 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() 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) @router.post("/", status_code=201)
async def create_expense( async def create_expense(
expense: AssetCostCreate, expense: AssetCostCreate,
@@ -26,9 +120,14 @@ async def create_expense(
Create a new expense (fuel, service, tax, insurance) for an asset. Create a new expense (fuel, service, tax, insurance) for an asset.
Uses AssetCostCreate schema which includes mileage_at_cost, cost_type, etc. Uses AssetCostCreate schema which includes mileage_at_cost, cost_type, etc.
**Bidirectional Sync:** **Role-Based Auto-Approval:**
- OWNER/ADMIN → status = APPROVED
- DRIVER → status = PENDING_APPROVAL (except fuel, which is auto-approved)
**Smart Linking (Double-Entry Avoidance):**
- If the cost category is service-related (MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES), - 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 # Validate asset exists
stmt = select(Asset).where(Asset.id == expense.asset_id) stmt = select(Asset).where(Asset.id == expense.asset_id)
@@ -89,6 +188,48 @@ async def create_expense(
else: else:
raise HTTPException(status_code=400, detail="Asset has no associated organization.") 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.) # Prepare data JSON for extra fields (mileage_at_cost, description, etc.)
data = expense.data.copy() if expense.data else {} data = expense.data.copy() if expense.data else {}
if expense.mileage_at_cost is not None: if expense.mileage_at_cost is not None:
@@ -97,22 +238,25 @@ async def create_expense(
data["description"] = expense.description data["description"] = expense.description
try: try:
# Create AssetCost instance # Create AssetCost instance with new fields
new_cost = AssetCost( new_cost = AssetCost(
asset_id=expense.asset_id, asset_id=expense.asset_id,
organization_id=organization_id, organization_id=organization_id,
category_id=expense.category_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, currency=expense.currency,
date=expense.date, date=expense.date,
invoice_number=data.get("invoice_number"), invoice_number=data.get("invoice_number"),
status=expense_status,
data=data data=data
) )
db.add(new_cost) db.add(new_cost)
await db.flush() # Flush to get new_cost.id 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 event_id = None
if expense.category_id in SERVICE_RELATED_CATEGORY_IDS: if expense.category_id in SERVICE_RELATED_CATEGORY_IDS:
# Map cost category to event type # 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}") description = expense.description or data.get("description", f"Service cost: {expense.category_id}")
mileage = expense.mileage_at_cost 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( new_event = AssetEvent(
asset_id=expense.asset_id, asset_id=expense.asset_id,
user_id=getattr(current_user, 'id', None), user_id=getattr(current_user, 'id', None),
@@ -129,15 +277,21 @@ async def create_expense(
odometer_reading=mileage, odometer_reading=mileage,
description=description, description=description,
cost_id=new_cost.id, 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), event_date=expense.date or datetime.now(timezone.utc),
) )
db.add(new_event) db.add(new_event)
await db.flush() # Flush to get new_event.id await db.flush() # Flush to get new_event.id
event_id = 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( logger.info(
f"Bidirectional sync: Auto-created AssetEvent {event_id} " f"Smart Sync: Auto-created AssetEvent {event_id} (status={event_status}) "
f"for AssetCost {new_cost.id} (category_id={expense.category_id})" 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 # Update Asset.current_mileage if mileage_at_cost is higher
@@ -152,8 +306,11 @@ async def create_expense(
"id": new_cost.id, "id": new_cost.id,
"asset_id": new_cost.asset_id, "asset_id": new_cost.asset_id,
"category_id": new_cost.category_id, "category_id": new_cost.category_id,
"amount_net": new_cost.amount_net, "amount_gross": str(new_cost.amount_gross) if new_cost.amount_gross else None,
"date": new_cost.date, "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, "event_id": str(event_id) if event_id else None,
} }

View File

@@ -24,7 +24,7 @@ async def check_finance_admin_access(
RBAC protection: only users with rank >= 90 (Superadmin/Finance Admin) can access. RBAC protection: only users with rank >= 90 (Superadmin/Finance Admin) can access.
In our system, this translates to role being 'superadmin' or 'admin'. 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions. Rank >= 90 (Superadmin/Finance Admin) required." 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 pydantic import BaseModel, Field, ConfigDict, EmailStr
from app.db.session import get_db 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.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.marketplace.organization import Organization, OrgType, OrganizationMember, Branch, OrgUserRole, OrgRole
from app.models.identity import User, OneTimePassword from app.models.identity import User, OneTimePassword
from app.core.config import settings from app.core.config import settings
@@ -219,7 +222,14 @@ async def get_my_organizations(
"subscription_plan": o.subscription_plan, "subscription_plan": o.subscription_plan,
"org_type": o.org_type.value if hasattr(o.org_type, 'value') else str(o.org_type), "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, "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 for o in orgs
] ]
@@ -663,19 +673,7 @@ async def update_organization(
Csak OWNER vagy ADMIN jogosultságú felhasználó módosíthatja. Csak OWNER vagy ADMIN jogosultságú felhasználó módosíthatja.
""" """
# 1. Jogosultság ellenőrzése # 1. Jogosultság ellenőrzése
stmt_member = select(OrganizationMember).where( # Először lekérjük a szervezetet, hogy ellenőrizhessük az owner_id-t is
(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
stmt_org = select(Organization).where( stmt_org = select(Organization).where(
Organization.id == org_id, Organization.id == org_id,
Organization.is_deleted == False Organization.is_deleted == False
@@ -688,7 +686,23 @@ async def update_organization(
detail=_("ORGANIZATION.ERROR.NOT_FOUND") 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) update_dict = update_data.model_dump(exclude_unset=True)
if "visual_settings" in update_dict and update_dict["visual_settings"] is not None: 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) 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 ── # ── CÉG CSATLAKOZÁS ÉS ÁRVA CÉG ÁTVÉTEL ──
class JoinRequestIn(BaseModel): class JoinRequestIn(BaseModel):

View File

@@ -36,7 +36,7 @@ async def request_action(
- ORGANIZATION_TRANSFER: Szervezet tulajdonjog átadása - ORGANIZATION_TRANSFER: Szervezet tulajdonjog átadása
""" """
# Csak admin és superadmin kezdeményezhet kiemelt műveleteket # 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Csak adminisztrátorok kezdeményezhetnek Dual Control műveleteket." 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. 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. 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 user_id = None
else: else:
user_id = current_user.id 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. 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Csak adminisztrátorok hagyhatnak jóvá műveleteket." 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. 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Csak adminisztrátorok utasíthatnak el műveleteket." detail="Csak adminisztrátorok utasíthatnak el műveleteket."
@@ -164,7 +164,7 @@ async def get_action(
if not action: if not action:
raise HTTPException(status_code=404, detail="Művelet nem található.") 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Nincs jogosultságod ehhez a művelethez." 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. Csak superadmin vagy admin jogosultságú felhasználók használhatják.
""" """
# Jogosultság ellenőrzése # 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( raise HTTPException(
status_code=403, status_code=403,
detail="Insufficient permissions. Only superadmin or admin can update system parameters." detail="Insufficient permissions. Only superadmin or admin can update system parameters."

View File

@@ -41,13 +41,15 @@ class NetworkResponse(BaseModel):
level3: List[NetworkMemberL2L3] 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. Segédfüggvény a UserResponse dict előállításához.
Beágyazza a Person és Address adatokat a 'person' mezőbe. 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 Organization, OrganizationMember
from app.models.marketplace.organization import OrgUserRole from app.models.marketplace.organization import OrgUserRole
from app.core.capabilities import get_capabilities_for_role
# Determine active organization ID # Determine active organization ID
if active_org_id is None: 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 "" first_name = person.first_name if person else ""
last_name = person.last_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 { return {
"id": user.id, "id": user.id,
"email": user.email, "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, "is_active": user.is_active,
"region_code": user.region_code, "region_code": user.region_code,
"person_id": user.person_id, "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, "subscription_plan": user.subscription_plan,
"scope_level": user.scope_level or "individual", "scope_level": user.scope_level or "individual",
"scope_id": str(active_org_id) if active_org_id else None, "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", "preferred_language": user.preferred_language or "hu",
"person": person_data, "person": person_data,
"is_last_admin": False, # Default, will be overridden in read_users_me "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), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), 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 Organization, OrganizationMember
from app.models.marketplace.organization import OrgUserRole from app.models.marketplace.organization import OrgUserRole
from app.core.capabilities import get_capabilities_for_role
# Determine active organization ID # Determine active organization ID
active_org_id = None active_org_id = None
@@ -178,8 +197,53 @@ async def read_users_me(
# Check if user is the last admin in any organization # Check if user is the last admin in any organization
is_last_admin = await AuthService.check_is_last_admin(db, current_user.id) 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 = _build_user_response(current_user, active_org_id)
response_data["is_last_admin"] = is_last_admin 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) 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 # /opt/docker/dev/service_finder/backend/app/core/rbac.py
from fastapi import HTTPException, Depends, status from fastapi import HTTPException, Depends, status
from app.api.deps import get_current_user 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 from app.core.config import settings
class RBAC: class RBAC:
@@ -11,11 +11,11 @@ class RBAC:
async def __call__(self, current_user: User = Depends(get_current_user)): async def __call__(self, current_user: User = Depends(get_current_user)):
# 1. Superadmin mindent visz (Rank 100) # 1. Superadmin mindent visz (Rank 100)
if current_user.role == "superadmin": if current_user.role == UserRole.SUPERADMIN:
return True return True
# 2. Dinamikus rang ellenőrzés a központi rank_map alapján # 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) user_rank = settings.DEFAULT_RANK_MAP.get(role_key, 0)
if user_rank < self.min_rank: if user_rank < self.min_rank:
raise HTTPException( raise HTTPException(

View File

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

View File

@@ -22,16 +22,17 @@ if TYPE_CHECKING:
from ..marketplace.service_request import ServiceRequest from ..marketplace.service_request import ServiceRequest
class UserRole(str, enum.Enum): class UserRole(str, enum.Enum):
superadmin = "superadmin" """Rendszerszintű (System-level) szerepkörök.
admin = "admin"
region_admin = "region_admin" Csak a globális platform szerepkörök maradnak itt.
country_admin = "country_admin" Szervezeti szerepkörök: lásd OrgUserRole a marketplace/organization.py-ban.
moderator = "moderator" """
sales_agent = "sales_agent" SUPERADMIN = "SUPERADMIN"
user = "user" ADMIN = "ADMIN"
service_owner = "service_owner" MODERATOR = "MODERATOR"
fleet_manager = "fleet_manager" SALES_REP = "SALES_REP"
driver = "driver" SERVICE_MGR = "SERVICE_MGR"
USER = "USER"
class Person(Base): class Person(Base):
""" """
@@ -133,7 +134,7 @@ class User(Base):
role: Mapped[UserRole] = mapped_column( role: Mapped[UserRole] = mapped_column(
PG_ENUM(UserRole, name="userrole", schema="identity"), 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")) 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" business = "business"
class OrgUserRole(str, enum.Enum): 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" OWNER = "OWNER"
ADMIN = "ADMIN" ADMIN = "ADMIN"
MANAGER = "MANAGER" ACCOUNTANT = "ACCOUNTANT"
MEMBER = "MEMBER" DRIVER = "DRIVER"
AGENT = "AGENT" VIEWER = "VIEWER"
class OrgRole(Base): 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. 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" __tablename__ = "org_roles"
__table_args__ = {"schema": "fleet"} __table_args__ = {"schema": "fleet"}
@@ -49,6 +57,16 @@ class OrgRole(Base):
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# ── 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): class Organization(Base):
""" """
Szervezet entitás (MB 2.0). Szervezet entitás (MB 2.0).
@@ -119,10 +137,19 @@ class Organization(Base):
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False) is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True) 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) 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")) base_asset_limit: Mapped[int] = mapped_column(Integer, server_default=text("1"))
purchased_extra_slots: Mapped[int] = mapped_column(Integer, server_default=text("0")) 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")) 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")) external_integration_config: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
@@ -215,9 +242,12 @@ class OrganizationMember(Base):
# Meghívó lejárati ideje # Meghívó lejárati ideje
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) 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( role: Mapped[str] = mapped_column(
String(50), PG_ENUM(OrgUserRole, name="orguserrole", schema="fleet", create_type=False),
default="MEMBER", default="MEMBER",
server_default=text("'MEMBER'") server_default=text("'MEMBER'")
) )

View File

@@ -247,6 +247,13 @@ class AssetFinancials(Base):
asset: Mapped["Asset"] = relationship("Asset", back_populates="financials") 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): class AssetCost(Base):
""" II. Üzemeltetés és TCO kimutatás. """ """ II. Üzemeltetés és TCO kimutatás. """
__tablename__ = "asset_costs" __tablename__ = "asset_costs"
@@ -257,16 +264,36 @@ class AssetCost(Base):
category_id: Mapped[int] = mapped_column(Integer, ForeignKey("vehicle.cost_categories.id"), index=True, nullable=False) 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_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") currency: Mapped[str] = mapped_column(String(3), default="HUF")
date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
invoice_number: Mapped[Optional[str]] = mapped_column(String(100), index=True) 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) 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")) data: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
asset: Mapped["Asset"] = relationship("Asset", back_populates="costs") asset: Mapped["Asset"] = relationship("Asset", back_populates="costs")
organization: Mapped["Organization"] = relationship("Organization") organization: Mapped["Organization"] = relationship("Organization")
category: Mapped["CostCategory"] = relationship("CostCategory") 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): class VehicleLogbook(Base):
""" Útnyilvántartás (NAV, Kiküldetés, Munkábajárás). """ """ Útnyilvántartás (NAV, Kiküldetés, Munkábajárás). """
@@ -396,6 +423,13 @@ class AssetEventTypeEnum(str, enum.Enum):
RECALL = "RECALL" # Visszahívás 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): class AssetEvent(Base):
""" Digitális Szervizkönyv - Szerviz, baleset és egyéb jelentős események. """ """ Digitális Szervizkönyv - Szerviz, baleset és egyéb jelentős események. """
__tablename__ = "asset_events" __tablename__ = "asset_events"
@@ -411,6 +445,17 @@ class AssetEvent(Base):
description: Mapped[Optional[str]] = mapped_column(Text) 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) 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()) 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()) 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()) 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") asset: Mapped["Asset"] = relationship("Asset", back_populates="events")
user: Mapped[Optional["User"]] = relationship("User") user: Mapped[Optional["User"]] = relationship("User")
organization: Mapped[Optional["Organization"]] = relationship("Organization") 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): class ExchangeRate(Base):

View File

@@ -20,7 +20,7 @@ router = APIRouter()
# --- 🛡️ ADMIN JOGOSULTSÁG ELLENŐRZŐ --- # --- 🛡️ ADMIN JOGOSULTSÁG ELLENŐRZŐ ---
async def check_admin_access(current_user: User = Depends(deps.get_current_active_user)): 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. """ """ 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Sentinel jogosultság szükséges a művelethez!" 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_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.") 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.") 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) model_config = ConfigDict(from_attributes=True)
@@ -402,6 +404,8 @@ class AssetEventResponse(BaseModel):
cost_id: Optional[UUID] = None cost_id: Optional[UUID] = None
cost_amount: Optional[float] = Field(None, description="Auto-created cost amount") cost_amount: Optional[float] = Field(None, description="Auto-created cost amount")
currency: Optional[str] = Field(None, description="Currency code (e.g. HUF)") currency: Optional[str] = Field(None, description="Currency code (e.g. HUF)")
status: str = "DRAFT"
linked_expense_id: Optional[UUID] = None
event_date: datetime event_date: datetime
created_at: datetime created_at: datetime
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
@@ -410,7 +414,10 @@ class AssetEventResponse(BaseModel):
@classmethod @classmethod
def model_validate(cls, obj, **kwargs): 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 = {} data = {}
if hasattr(obj, '__dict__'): if hasattr(obj, '__dict__'):
# Copy ORM attributes # Copy ORM attributes
@@ -422,7 +429,10 @@ class AssetEventResponse(BaseModel):
cost = getattr(obj, 'cost', None) cost = getattr(obj, 'cost', None)
if cost is not None: if cost is not None:
if data.get('cost_amount') is 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: if data.get('currency') is None:
data['currency'] = cost.currency data['currency'] = cost.currency
return cls(**data) return cls(**data)

View File

@@ -1,13 +1,38 @@
# /opt/docker/dev/service_finder/backend/app/schemas/asset_cost.py # /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 typing import Optional, Dict, Any
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
from uuid import UUID from uuid import UUID
class AssetCostBase(BaseModel): class AssetCostBase(BaseModel):
"""Base schema — matches the AssetCost SQLAlchemy model fields.""" """Base schema — matches the AssetCost SQLAlchemy model fields.
amount_net: Decimal
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" currency: str = "HUF"
date: datetime = Field(default_factory=datetime.now) date: datetime = Field(default_factory=datetime.now)
invoice_number: Optional[str] = None invoice_number: Optional[str] = None
@@ -16,15 +41,45 @@ class AssetCostBase(BaseModel):
mileage_at_cost: Optional[int] = None # Stored inside data JSONB mileage_at_cost: Optional[int] = None # Stored inside data JSONB
description: Optional[str] = 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): class AssetCostCreate(AssetCostBase):
asset_id: UUID asset_id: UUID
organization_id: Optional[int] = None # Auto-resolved from asset if not provided 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): class AssetCostResponse(AssetCostBase):
"""Response schema — matches DB columns from vehicle.asset_costs.""" """Response schema — matches DB columns from vehicle.asset_costs."""
id: UUID id: UUID
asset_id: UUID asset_id: UUID
organization_id: int organization_id: int
status: str = "DRAFT"
linked_asset_event_id: Optional[UUID] = None
# Derived / enriched fields (not in DB model directly) # Derived / enriched fields (not in DB model directly)
category_name: Optional[str] = None # Resolved from CostCategory relationship 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 # /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 typing import Optional, Dict, Any, List
from uuid import UUID from uuid import UUID
from datetime import datetime from datetime import datetime
@@ -17,6 +17,12 @@ class AssetEventTypeEnum(str, Enum):
RECALL = "RECALL" # Visszahívás RECALL = "RECALL" # Visszahívás
OTHER = "OTHER" # Egyéb 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): class AssetEventCreate(BaseModel):
"""Digitális Szervizkönyv esemény létrehozásához szükséges adatok.""" """Digitális Szervizkönyv esemény létrehozásához szükséges adatok."""
event_type: AssetEventTypeEnum = Field(..., description="Esemény típusa") 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") 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)") 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)") cost_id: Optional[UUID] = Field(None, description="Kapcsolódó költség rekord ID (opcionális)")
cost_amount: Optional[float] = Field(None, gt=0, description="Költség összege. Ha > 0, automatikusan létrejön egy AssetCost rekord.")
# RBAC ellenőrzés: csak a tulajdonos vagy operátor szervezet adhat hozzá eseményt currency: Optional[str] = Field(None, min_length=3, max_length=3, description="Valutakód (pl. HUF, EUR)")
# Ezt a service rétegben validáljuk, nem a sémában 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) model_config = ConfigDict(from_attributes=True)
@@ -41,6 +48,12 @@ class AssetEventResponse(BaseModel):
odometer_reading: Optional[int] = None odometer_reading: Optional[int] = None
description: Optional[str] = None description: Optional[str] = None
cost_id: Optional[UUID] = 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 event_date: datetime
created_at: datetime created_at: datetime
@@ -52,6 +65,31 @@ class AssetEventResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) 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): class AssetEventUpdate(BaseModel):
"""Digitális Szervizkönyv esemény frissítéséhez szükséges adatok.""" """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") 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") 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") 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") 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): def validate_description(cls, v):
if v is not None and len(v.strip()) == 0: if v is not None and len(v.strip()) == 0:
raise ValueError('Description cannot be empty or whitespace only') 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_name: Optional[str] = None
address_street_type: Optional[str] = None address_street_type: Optional[str] = None
address_house_number: 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 address_hrsz: Optional[str] = None
# ── Crowdsourced search fields ── # ── Crowdsourced search fields ──
aliases: Optional[List[str]] = None aliases: Optional[List[str]] = None
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
# ── Subscription ──
subscription_tier_id: Optional[int] = None
class OrganizationResponse(BaseModel): class OrganizationResponse(BaseModel):
@@ -79,10 +78,18 @@ class OrganizationResponse(BaseModel):
is_active: bool = True is_active: bool = True
is_deleted: bool = False is_deleted: bool = False
subscription_plan: str = "FREE" subscription_plan: str = "FREE"
subscription_tier_id: Optional[int] = None
visual_settings: Optional[dict] = None visual_settings: Optional[dict] = None
notification_settings: Optional[Any] = None notification_settings: Optional[Any] = None
external_integration_config: Optional[Any] = None external_integration_config: Optional[Any] = None
aliases: List[str] = Field(default_factory=list) aliases: List[str] = Field(default_factory=list)
tags: 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) model_config = ConfigDict(from_attributes=True)

View File

@@ -1,7 +1,7 @@
# /opt/docker/dev/service_finder/backend/app/schemas/user.py # /opt/docker/dev/service_finder/backend/app/schemas/user.py
import uuid import uuid
from pydantic import BaseModel, EmailStr, field_validator, ConfigDict from pydantic import BaseModel, EmailStr, field_validator, ConfigDict
from typing import Optional, Any from typing import Optional, Any, Dict
from datetime import date, datetime from datetime import date, datetime
@@ -66,6 +66,10 @@ class UserResponse(UserBase):
person: Optional[PersonResponse] = None person: Optional[PersonResponse] = None
# Utolsó admin flag (ha a user az egyetlen aktív admin bármely szervezetében) # Utolsó admin flag (ha a user az egyetlen aktív admin bármely szervezetében)
is_last_admin: bool = False 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) 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( user = User(
email="tester_pro@profibot.hu", email="tester_pro@profibot.hu",
hashed_password=get_password_hash("Tester123!"), hashed_password=get_password_hash("Tester123!"),
role=UserRole.admin, role=UserRole.ADMIN,
person_id=person.id, person_id=person.id,
is_active=True, is_active=True,
subscription_plan="PREMIUM", subscription_plan="PREMIUM",
@@ -192,7 +192,7 @@ async def seed_integration_data():
superadmin_user = User( superadmin_user = User(
email="superadmin@profibot.hu", email="superadmin@profibot.hu",
hashed_password=get_password_hash("Superadmin123!"), hashed_password=get_password_hash("Superadmin123!"),
role=UserRole.superadmin, role=UserRole.SUPERADMIN,
person_id=superadmin_person.id, person_id=superadmin_person.id,
is_active=True, is_active=True,
subscription_plan="ENTERPRISE", 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: async def get_user_vehicle_limit(db: AsyncSession, user_id: int, org_id: int) -> int:
""" """
Get the vehicle limit for a user, checking: Get the vehicle limit for a user, checking:
1. Config-based limits (user role, subscription plan, org-specific) 1. Subscription tier JSONB rules['allowances']['max_vehicles'] (PRIMARY source of truth)
2. Subscription tier JSONB rules['allowances']['max_vehicles'] 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: Args:
db: AsyncSession db: AsyncSession
@@ -503,10 +505,11 @@ class AssetService:
org_id: Organization ID org_id: Organization ID
Returns: Returns:
Maximum allowed vehicles (highest of all applicable limits) Maximum allowed vehicles
""" """
from app.models.identity import User from app.models.identity import User
from app.models.core_logic import UserSubscription, SubscriptionTier from app.models.core_logic import UserSubscription, SubscriptionTier
from app.models.marketplace.organization import Organization
from app.services.config_service import config from app.services.config_service import config
try: try:
@@ -514,34 +517,11 @@ class AssetService:
user_stmt = select(User).where(User.id == user_id) user_stmt = select(User).where(User.id == user_id)
user = (await db.execute(user_stmt)).scalar_one() 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) 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) # ── 1. SUBSCRIPTION TIER JSONB LIMIT (PRIMARY) ──
if config_limit is None: # P0: This is now the Single Source of Truth for vehicle limits.
config_limit = limits.get(subscription_plan.lower()) # Reads rules['allowances']['max_vehicles'] from the user's active subscription tier.
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']
subscription_limit = None subscription_limit = None
try: try:
sub_stmt = ( sub_stmt = (
@@ -564,12 +544,53 @@ class AssetService:
if max_vehicles is not None and isinstance(max_vehicles, (int, float)): if max_vehicles is not None and isinstance(max_vehicles, (int, float)):
subscription_limit = int(max_vehicles) subscription_limit = int(max_vehicles)
logger.info( logger.info(
f"Subscription tier limit for user {user_id}: " f"[P0] Subscription tier limit for user {user_id}: "
f"max_vehicles={subscription_limit} (from rules={tier_rules})" f"max_vehicles={subscription_limit} (from JSONB rules)"
) )
except Exception as e: except Exception as e:
logger.debug(f"Could not read subscription tier limit for user {user_id}: {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 ── # ── 4. FINAL: Take the HIGHEST of all applicable limits ──
final_limit = config_limit final_limit = config_limit
if org_limit is not None: if org_limit is not None:
@@ -578,8 +599,8 @@ class AssetService:
final_limit = max(final_limit, subscription_limit) final_limit = max(final_limit, subscription_limit)
logger.info( logger.info(
f"Vehicle limit for user {user_id} (role={user_role}, plan={subscription_plan}): " f"[P0] Vehicle limit for user {user_id} (role={user_role}): "
f"config={config_limit}, org={org_limit}, subscription={subscription_limit}, " f"subscription_tier={subscription_limit}, org={org_limit}, config={config_limit}, "
f"final={final_limit}" f"final={final_limit}"
) )

View File

@@ -107,7 +107,7 @@ class AuthService:
await db.flush() await db.flush()
# Szerepkör dinamikus feloldása # 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 kód generálása
referral_code = generate_secure_slug(8).upper() referral_code = generate_secure_slug(8).upper()
@@ -273,7 +273,15 @@ class AuthService:
# Infrastruktúra elemek # Infrastruktúra elemek
db.add(Branch(organization_id=new_org.id, address_id=addr_id, name="Home Base", is_main=True)) 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(Wallet(user_id=user.id, currency=kyc_in.preferred_currency or base_cur))
# db.add(UserStats(user_id=user.id)) # GamificationService kezeli # db.add(UserStats(user_id=user.id)) # GamificationService kezeli

View File

@@ -51,18 +51,14 @@ class PricingCalculator:
# RBAC rank discounts (higher rank = bigger discount) # RBAC rank discounts (higher rank = bigger discount)
# Map the actual UserRole enum values to discount percentages # Map the actual UserRole enum values to discount percentages
# RBAC Phase 1: Tisztított rendszerszintű szerepkörök
RBAC_DISCOUNTS = { RBAC_DISCOUNTS = {
UserRole.superadmin: 0.5, # 50% discount UserRole.SUPERADMIN: 0.5, # 50% discount
UserRole.admin: 0.3, # 30% discount UserRole.ADMIN: 0.3, # 30% discount
UserRole.fleet_manager: 0.2, # 20% discount UserRole.MODERATOR: 0.15, # 15% discount
UserRole.user: 0.0, # 0% discount UserRole.SERVICE_MGR: 0.1, # 10% discount
# Add other roles as needed UserRole.SALES_REP: 0.1, # 10% discount
UserRole.region_admin: 0.25, # 25% discount UserRole.USER: 0.0, # 0% 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
} }
@classmethod @classmethod
@@ -71,7 +67,7 @@ class PricingCalculator:
db: AsyncSession, db: AsyncSession,
base_amount: float, base_amount: float,
country_code: str = "HU", country_code: str = "HU",
user_role: UserRole = UserRole.user, user_role: UserRole = UserRole.USER,
individual_discounts: Optional[List[Dict[str, Any]]] = None individual_discounts: Optional[List[Dict[str, Any]]] = None
) -> float: ) -> float:
""" """
@@ -663,7 +659,7 @@ async def calculate_price(
db: AsyncSession, db: AsyncSession,
base_amount: float, base_amount: float,
country_code: str = "HU", country_code: str = "HU",
user_role: UserRole = UserRole.user, user_role: UserRole = UserRole.USER,
individual_discounts: Optional[List[Dict[str, Any]]] = None individual_discounts: Optional[List[Dict[str, Any]]] = None
) -> float: ) -> 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( async def record_ledger_entry(
db: AsyncSession, db: AsyncSession,
user_id: int, user_id: int,

View File

@@ -38,7 +38,7 @@ class SearchService:
user_tier = current_user.tier_name 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" user_tier = "vip"
ranking_rules = await config.get_setting( ranking_rules = await config.get_setting(

View File

@@ -30,7 +30,7 @@ class SocialAuthService:
db.add(new_person) db.add(new_person)
await db.flush() 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) db.add(user)
await db.flush() await db.flush()

View File

@@ -18,7 +18,7 @@ def test_normal_user_cannot_access_admin_ping():
mock_user = User( mock_user = User(
id=999, id=999,
email="normal@example.com", email="normal@example.com",
role=UserRole.user, role=UserRole.USER,
is_active=True, is_active=True,
is_deleted=False, is_deleted=False,
subscription_plan="FREE", subscription_plan="FREE",
@@ -54,7 +54,7 @@ def test_admin_user_can_access_admin_ping():
mock_admin = User( mock_admin = User(
id=1000, id=1000,
email="admin@example.com", email="admin@example.com",
role=UserRole.admin, role=UserRole.ADMIN,
is_active=True, is_active=True,
is_deleted=False, is_deleted=False,
subscription_plan="PREMIUM", 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, asset_id: props.vehicle.id,
// organization_id is omitted — backend auto-resolves from asset // organization_id is omitted — backend auto-resolves from asset
category_id: categoryId, 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, currency: 'HUF' as const,
date: new Date(form.date).toISOString(), date: new Date(form.date).toISOString(),
mileage_at_cost: null, 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 = { const payload = {
asset_id: props.vehicle.id, asset_id: props.vehicle.id,
category_id: fuelCategoryId.value, // FUEL category (resolved by code) 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, currency: 'HUF' as const,
date: new Date(form.date).toISOString(), date: new Date(form.date).toISOString(),
mileage_at_cost: form.odometer > 0 ? form.odometer : null, mileage_at_cost: form.odometer > 0 ? form.odometer : null,

View File

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

View File

@@ -75,6 +75,48 @@
/> />
</div> </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 --> <!-- Separator -->
<div class="border-t border-white/10 my-2"></div> <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> <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 isSaving = ref(false)
const org = ref<OrganizationItem | null>(null) 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({ const form = reactive({
name: '', name: '',
full_name: '', full_name: '',
@@ -180,7 +227,9 @@ const form = reactive({
address_zip: '', address_zip: '',
address_city: '', address_city: '',
address_street_name: '', address_street_name: '',
address_street_type: '',
address_house_number: '', address_house_number: '',
address_hrsz: '',
}) })
// ── Lifecycle ─────────────────────────────────────────────────────────────── // ── Lifecycle ───────────────────────────────────────────────────────────────
@@ -193,11 +242,18 @@ onMounted(async () => {
const found = authStore.myOrganizations.find((o) => o.organization_id === orgId) const found = authStore.myOrganizations.find((o) => o.organization_id === orgId)
if (found) { if (found) {
org.value = found org.value = found
selectedTierId.value = found.subscription_tier_id ?? null
// Pre-fill form with existing values // Pre-fill form with existing values
form.name = found.name || '' form.name = found.name || ''
form.full_name = found.full_name || '' form.full_name = found.full_name || ''
form.display_name = found.display_name || '' form.display_name = found.display_name || ''
form.tax_number = found.tax_number || '' 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 { } else {
// Try to fetch fresh data // Try to fetch fresh data
isLoading.value = true isLoading.value = true
@@ -206,10 +262,17 @@ onMounted(async () => {
const refound = authStore.myOrganizations.find((o) => o.organization_id === orgId) const refound = authStore.myOrganizations.find((o) => o.organization_id === orgId)
if (refound) { if (refound) {
org.value = refound org.value = refound
selectedTierId.value = refound.subscription_tier_id ?? null
form.name = refound.name || '' form.name = refound.name || ''
form.full_name = refound.full_name || '' form.full_name = refound.full_name || ''
form.display_name = refound.display_name || '' form.display_name = refound.display_name || ''
form.tax_number = refound.tax_number || '' 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) { } catch (err) {
console.error('Failed to load organization:', err) console.error('Failed to load organization:', err)
@@ -217,8 +280,44 @@ onMounted(async () => {
isLoading.value = false 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 ──────────────────────────────────────────────────────────── // ── Save Handler ────────────────────────────────────────────────────────────
async function handleSave() { async function handleSave() {

View File

@@ -735,8 +735,10 @@
Bezárás Bezárás
</button> </button>
<!-- Task 1: Szerkesztés gomb csak az Alapadatok fülön látható --> <!-- 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 <button
v-if="activeTab === 'basics'" v-if="activeTab === 'basics' && authStore.hasOrgCapability(authStore.user?.active_organization_id ?? 0, 'can_approve_expense')"
@click="$emit('edit', vehicle)" @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" 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<{ const props = defineProps<{
isOpen: boolean isOpen: boolean
vehicle: VehicleData | null 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<{ const emit = defineEmits<{
@@ -850,7 +857,7 @@ interface CostItem {
category_name?: string category_name?: string
/** Backend returns `category_code` from CostCategory relationship */ /** Backend returns `category_code` from CostCategory relationship */
category_code?: string 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 amount: number
/** Backend returns `currency` (e.g. 'HUF') */ /** Backend returns `currency` (e.g. 'HUF') */
currency?: string currency?: string
@@ -859,6 +866,7 @@ interface CostItem {
/** Backend returns `mileage_at_cost` extracted from data JSONB */ /** Backend returns `mileage_at_cost` extracted from data JSONB */
mileage_at_cost?: number mileage_at_cost?: number
/** Raw backend fields kept for reference */ /** Raw backend fields kept for reference */
amount_gross?: number
amount_net?: number amount_net?: number
currency_raw?: string currency_raw?: string
date?: string date?: string
@@ -870,8 +878,9 @@ const costsLoading = ref(false)
/** /**
* Map a raw backend AssetCostResponse item to the frontend CostItem shape. * 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: * 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 { function mapBackendCost(raw: any): CostItem {
return { return {
@@ -880,11 +889,12 @@ function mapBackendCost(raw: any): CostItem {
category: raw.category_name || raw.category_code || raw.category || '', category: raw.category_name || raw.category_code || raw.category || '',
category_name: raw.category_name, category_name: raw.category_name,
category_code: raw.category_code, 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', currency: raw.currency || 'HUF',
description: raw.description || raw.data?.description || '', description: raw.description || raw.data?.description || '',
mileage_at_cost: raw.mileage_at_cost ?? raw.data?.mileage_at_cost, mileage_at_cost: raw.mileage_at_cost ?? raw.data?.mileage_at_cost,
// Keep raw fields for reference // Keep raw fields for reference
amount_gross: raw.amount_gross,
amount_net: raw.amount_net, amount_net: raw.amount_net,
currency_raw: raw.currency, currency_raw: raw.currency,
date: raw.date, 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 ── // ── Format date helper ──
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
if (!dateStr) return '—' 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 the route requires admin privileges, check the user's role.
if (to.meta.requiresAdmin) { if (to.meta.requiresAdmin) {
const role = authStore.user?.role const role = authStore.user?.role
const adminRoles = ['superadmin', 'admin', 'region_admin', 'country_admin', 'moderator'] // CRITICAL: Backend sends UPPERCASE roles (SUPERADMIN, ADMIN, MODERATOR)
if (!role || !adminRoles.includes(role)) { // 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 // Non-admin user trying to access admin area → redirect to dashboard
return next('/dashboard') return next('/dashboard')
} }

View File

@@ -69,6 +69,10 @@ export interface UserProfile {
person_id: number | null person_id: number | null
// Nested person data with address // Nested person data with address
person: PersonData | null 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', () => { export const useAuthStore = defineStore('auth', () => {
@@ -87,6 +91,19 @@ export const useAuthStore = defineStore('auth', () => {
return !!user.value?.person_id return !!user.value?.person_id
}) })
const userRole = computed(() => user.value?.role ?? 'guest') 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(() => { const userName = computed(() => {
if (!user.value) return '' if (!user.value) return ''
const { first_name, last_name } = user.value const { first_name, last_name } = user.value
@@ -95,6 +112,41 @@ export const useAuthStore = defineStore('auth', () => {
}) })
const userId = computed(() => user.value?.id ?? null) 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 ──────────────────────────── // ────────────────────────────── Helpers ────────────────────────────
/** /**
@@ -671,6 +723,7 @@ export const useAuthStore = defineStore('auth', () => {
// Getters // Getters
isAuthenticated, isAuthenticated,
isAdmin,
isKycComplete, isKycComplete,
userRole, userRole,
userName, userName,
@@ -679,6 +732,11 @@ export const useAuthStore = defineStore('auth', () => {
hasBusinessOrganization, hasBusinessOrganization,
isCorporateMode, isCorporateMode,
// RBAC Phase 3: Capability Helpers
hasSystemCapability,
hasOrgCapability,
getOrgCapabilities,
// Actions // Actions
login, login,
fetchUser, fetchUser,

View File

@@ -4,22 +4,71 @@ import { ref, computed } from 'vue'
export type UserRole = 'guest' | 'user' | 'manager' | 'admin' | 'superadmin' export type UserRole = 'guest' | 'user' | 'manager' | 'admin' | 'superadmin'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
// State // ── State ──────────────────────────────────────────────────────────
const isAuthenticated = ref(false) const isAuthenticated = ref(false)
const userRole = ref<UserRole>('guest') const userRole = ref<UserRole>('guest')
const userName = ref('') const userName = ref('')
const userId = ref<number | null>(null) 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(() => { 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 isSuperAdmin = computed(() => userRole.value.toUpperCase() === 'SUPERADMIN')
const isManager = computed(() => userRole.value === 'manager') const isManager = computed(() => userRole.value.toUpperCase() === 'MANAGER')
const isRegularUser = computed(() => userRole.value === 'user') 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) { function login(role: UserRole, name: string, id: number) {
isAuthenticated.value = true isAuthenticated.value = true
userRole.value = role userRole.value = role
@@ -33,6 +82,8 @@ export const useAuthStore = defineStore('auth', () => {
userRole.value = 'guest' userRole.value = 'guest'
userName.value = '' userName.value = ''
userId.value = null userId.value = null
systemCapabilities.value = {}
orgCapabilities.value = {}
localStorage.removeItem('auth') 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 // Simulate fetching user role from backend
async function fetchUserRole() { async function fetchUserRole() {
// In a real app, this would be an API call // In a real app, this would be an API call
@@ -69,6 +133,8 @@ export const useAuthStore = defineStore('auth', () => {
userRole, userRole,
userName, userName,
userId, userId,
systemCapabilities,
orgCapabilities,
// Getters // Getters
isAdmin, isAdmin,
@@ -76,10 +142,16 @@ export const useAuthStore = defineStore('auth', () => {
isManager, isManager,
isRegularUser, isRegularUser,
// RBAC Phase 3: Capability Helpers
hasSystemCapability,
hasOrgCapability,
getOrgCapabilities,
// Actions // Actions
login, login,
logout, logout,
init, init,
fetchUserRole setCapabilities,
fetchUserRole,
} }
}) })

View File

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

View File

@@ -13,6 +13,8 @@ export interface OrganizationItem {
is_active: boolean is_active: boolean
is_deleted: boolean is_deleted: boolean
subscription_plan: string 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 is not in the current /my response but we keep it for future use */
org_type?: string 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. * Used by the frontend to decide whether to show service_provider orgs in the garage switcher.
*/ */
user_role?: string 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" class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4"
style="perspective: 1200px;" style="perspective: 1200px;"
> >
<!-- <!-- Card 1: 🚗 My Garage (Járműveim) -->
Card 1: 🚗 My Garage (Járműveim) <MyVehiclesCard
--> @open-card="openCard"
<div @open-vehicle-detail="openVehicleDetail"
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 2: 💰 Costs & Quick Actions (Action Center) -->
Card 2: 💰 Costs & Quick Actions (Action Center) <CostsActionsCard @cost-saved="onCostSaved" />
-->
<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 --> <!-- Card 3: 🔧 Service Finder (3-Way Indítópult) -->
<div class="flex-1 flex flex-col gap-2.5 justify-center"> <ServiceFinderCard />
<!-- 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) --> <!-- Card 4: 🏆 Gamification (Játékosítás) -->
<button <GamificationCard
v-if="selectedActionVehicle" @open-card="openCard"
@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) --> <!-- Card 5: 🛡 My Profile & Trust (Profil & Trust Score) -->
<button <ProfileTrustCard
v-if="selectedActionVehicle" @open-card="openCard"
@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>
</div> </div>
</div> </div>
</div> </div>
@@ -337,36 +121,13 @@
</div> </div>
</Teleport> </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) Vehicle Detail Modal (Digital Twin 3-Tab Deep Link)
--> -->
<VehicleDetailModal <VehicleDetailModal
:is-open="isVehicleDetailOpen" :is-open="isVehicleDetailOpen"
:vehicle="detailVehicle" :vehicle="detailVehicle"
:refresh-trigger="refreshCostsTrigger"
@close="closeVehicleDetail" @close="closeVehicleDetail"
@edit="handleEditFromDetail" @edit="handleEditFromDetail"
@set-primary="handleSetPrimaryFromDetail" @set-primary="handleSetPrimaryFromDetail"
@@ -391,32 +152,29 @@
@saved="onEditVehicleSaved" @saved="onEditVehicleSaved"
@deleted="onVehicleDeleted" @deleted="onVehicleDeleted"
/> />
<!--
Provider Quick Add Modal (Service Finder Card 3)
-->
<ProviderQuickAddModal
:is-open="isSfQuickAddOpen"
@close="isSfQuickAddOpen = false"
@saved="onSfQuickAddSaved"
/>
</div><!-- end min-h-screen --> </div><!-- end min-h-screen -->
</template> </template>
<script setup lang="ts"> <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 { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { useVehicleStore } from '../stores/vehicle' import { useVehicleStore } from '../stores/vehicle'
import type { VehicleData } from '../types/vehicle' import type { VehicleData } from '../types/vehicle'
import api from '../api/axios' 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 PrivateVehicleManager from '../components/dashboard/PrivateVehicleManager.vue'
import VehicleCardCompact from '../components/vehicle/VehicleCardCompact.vue'
import VehicleDetailModal from '../components/vehicle/VehicleDetailModal.vue' import VehicleDetailModal from '../components/vehicle/VehicleDetailModal.vue'
import VehicleFormModal from '../components/dashboard/VehicleFormModal.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() const { t } = useI18n()
@@ -437,30 +195,16 @@ const openCard = (cardId: string, targetId: string | null = null) => {
selectedTargetId.value = targetId 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) ── // ── Vehicle Detail Modal (Deep Link from plate click) ──
const isVehicleDetailOpen = ref(false) const isVehicleDetailOpen = ref(false)
const detailVehicle = ref<VehicleData | null>(null) 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) { function openVehicleDetail(vehicle: VehicleData) {
detailVehicle.value = vehicle detailVehicle.value = vehicle
isVehicleDetailOpen.value = true isVehicleDetailOpen.value = true
@@ -471,6 +215,15 @@ function closeVehicleDetail() {
detailVehicle.value = null 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) ── // ── Vehicle Form Modal State (standalone, decoupled from Manager) ──
const isAddFormOpen = ref(false) const isAddFormOpen = ref(false)
const isEditFormOpen = ref(false) const isEditFormOpen = ref(false)
@@ -586,71 +339,6 @@ function handleSetPrimaryFromDetail(vehicle: VehicleData) {
vehicleStore.setPrimaryVehicle(vehicle.id) 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(() => { onMounted(() => {
vehicleStore.fetchVehicles() vehicleStore.fetchVehicles()
authStore.fetchMyOrganizations() 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 for query param changes (hamburger menu navigation) ────
watch(() => route.query.card, (newCard) => { watch(() => route.query.card, (newCard) => {
if (newCard && typeof newCard === 'string') { if (newCard && typeof newCard === 'string') {

View File

@@ -127,7 +127,7 @@
<!-- Hard Delete: Only visible for Superadmin --> <!-- Hard Delete: Only visible for Superadmin -->
<button <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" 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" :disabled="bulkLoading"
@click="executeBulkAction('hard_delete')" @click="executeBulkAction('hard_delete')"
@@ -296,7 +296,7 @@
</button> </button>
<div class="border-t border-gray-600 my-1"></div> <div class="border-t border-gray-600 my-1"></div>
<button <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" 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')" @click="individualAction(user.id, 'hard_delete')"
> >