RABAC felépítése megtörtént ill hirdetési portál alapok lerakva

This commit is contained in:
Roo
2026-06-19 06:06:34 +00:00
parent fe3c32597d
commit 9ba2d9180d
30 changed files with 4336 additions and 76 deletions

View File

@@ -0,0 +1,962 @@
# P0 Architecture Plan — Subscription Stacking & Extensible Ad Engine
**Dátum:** 2026-06-18
**Szerző:** Fast Coder (Core Developer)
**Státusz:** Tervezési fázis — Jóváhagyásra vár
**Cél:** Teljes architekturális terv a Subscription Time Stacking (napok halmozása) és a bővíthető Native Ad Engine (kampányok, kreatívok, elhelyezések, analitika) megvalósításához.
---
## 1. SUBSCRIPTION STACKING MATH
### 1.1 Problémafelvetés
Jelenleg a [`upgrade_org_subscription()`](backend/app/services/billing_engine.py:805) függvény minden híváskor **felülírja** a `valid_from` mezőt `datetime.utcnow()`-ra, és **nem állítja be** a `valid_until` mezőt. Ez azt jelenti, hogy:
- Ha egy szervezet ma megveszi a 30 napos csomagot, majd 10 nap múlva **hozzáad** még 30 napot, a hátralévő 20 nap **elveszik**.
- A `valid_until` mező minden `OrganizationSubscription` és `UserSubscription` rekordban `NULL` marad.
### 1.2 Megoldás: `duration_days` a JSONB `rules`-ban
Minden `subscription_tiers.rules` JSONB blokkba bevezetésre kerül egy új `duration_days` mező, amely fix napokban határozza meg a csomag időtartamát. Ez elkerüli a naptári anomáliákat (pl. február 28., hónap vége).
#### 1.2.1 JSONB `rules` bővítés
```json
{
"type": "private",
"display_name": "Privát Pro",
"duration_days": 30,
"pricing": {
"monthly_price": 4.99,
"yearly_price": 49.99,
"currency": "EUR",
"credit_price": 500
},
"allowances": {
"max_vehicles": 3,
"max_garages": 1,
"monthly_free_credits": 50
},
"entitlements": ["SRV_DATA_EXPORT"],
"lifecycle": { "is_public": true }
}
```
**Tervezett `duration_days` értékek csomagonként:**
| Csomag | `duration_days` | Megjegyzés |
|--------|----------------|------------|
| `private_free_v1` | `null` (korlátlan) | Ingyenes csomag, nem jár le |
| `private_pro_v1` | `30` | Havi |
| `private_vip_v1` | `30` | Havi |
| `corp_premium_v1` | `30` | Havi |
| `corp_premium_plus_v1` | `30` | Havi |
| `corp_vip_v1` | `30` | Havi |
| `private_test_v01` | `365` | Teszt — 1 év |
| `org_test_v01` | `365` | Teszt — 1 év |
| `corp_free_v1` | `null` (korlátlan) | Ingyenes céges |
#### 1.2.2 Pydantic Schema Bővítés
A [`SubscriptionRulesModel`](backend/app/schemas/subscription.py:55) kiegészítése:
```python
class SubscriptionRulesModel(BaseModel):
type: str = Field(..., pattern=r"^(private|corporate)$")
display_name: Optional[str] = None
duration_days: Optional[int] = Field(
default=None,
ge=1,
description="Csomag időtartama napokban. null = korlátlan (pl. ingyenes csomag)"
)
pricing: Optional[PricingModel] = None
allowances: Optional[AllowancesModel] = None
entitlements: List[str] = []
affiliate: Optional[AffiliateModel] = None
lifecycle: Optional[LifecycleModel] = None
ad_policy: Optional[AdPolicyModel] = None # Lásd 2. fejezet
```
### 1.3 Stacking Logika: `upgrade_org_subscription()` Átdolgozása
A [`upgrade_org_subscription()`](backend/app/services/billing_engine.py:805) függvény új logikája:
```python
async def upgrade_org_subscription(
db: AsyncSession,
org_id: int,
tier_id: int,
actor_user_id: int
) -> Dict[str, Any]:
"""
Szervezet előfizetésének beállítása időhalmozással (Stacking).
Stacking logika:
1. Ha az aktuális előfizetés MÉG ÉRVÉNYES (valid_until > now):
- Az új valid_until = régi valid_until + duration_days nap
- Az időhalmozás (stacking) megtörténik
2. Ha az aktuális előfizetés LEJÁRT (valid_until < now) vagy NINCS:
- Az új valid_until = now + duration_days nap
- Friss kezdés
3. Ha duration_days = null (korlátlan csomag):
- valid_until = null (soha nem jár le)
"""
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. Olvassuk ki a duration_days-t a tier rules-ból
rules = tier.rules or {}
duration_days = rules.get("duration_days")
# 4. Számítsuk ki az új valid_until-t (STACKING LOGIKA)
now = datetime.utcnow()
new_valid_until = None # Alapértelmezett: korlátlan
if duration_days is not None:
# Korlátozott időtartamú csomag
# Keressük meg az aktuális aktív előfizetést
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 and existing_sub.valid_until and existing_sub.valid_until > now:
# STACKING: Még érvényes előfizetéshez hozzáadjuk az új napokat
remaining_days = (existing_sub.valid_until - now).days
logger.info(
f"Subscription stacking for org_id={org_id}: "
f"remaining={remaining_days}d, adding={duration_days}d"
)
new_valid_until = existing_sub.valid_until + timedelta(days=duration_days)
else:
# Nincs érvényes előfizetés — friss kezdés
new_valid_until = now + timedelta(days=duration_days)
# 5. Állítsa be a subscription_tier_id-t a szervezeten
org.subscription_tier_id = tier_id
org.subscription_plan = tier.name
# 6. Extract max_vehicles from tier rules
max_vehicles = rules.get("allowances", {}).get("max_vehicles", 1)
org.base_asset_limit = max_vehicles
# 7. Deaktiváljuk a régi előfizetést
if existing_sub:
existing_sub.is_active = False
existing_sub.valid_until = now # Meddig volt érvényben
# 8. Új aktív subscription rekord
new_sub = OrganizationSubscription(
org_id=org_id,
tier_id=tier_id,
valid_from=now,
valid_until=new_valid_until,
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"valid_from={now}, valid_until={new_valid_until}, "
f"max_vehicles={max_vehicles}, actor={actor_user_id}"
)
return {
"success": True,
"organization_id": org_id,
"tier_id": tier_id,
"tier_name": tier.name,
"max_vehicles": max_vehicles,
"valid_from": now.isoformat(),
"valid_until": new_valid_until.isoformat() if new_valid_until else None,
"message": f"Organization {org_id} upgraded to {tier.name}"
}
```
### 1.4 Stacking Matematikai Összefoglaló
```
Képlet:
IF existing_sub.valid_until > NOW:
new_valid_until = existing_sub.valid_until + duration_days
ELSE:
new_valid_until = NOW + duration_days
Példa:
- Március 1.: 30 napos csomag vásárlás → valid_until = március 31.
- Március 15.: Újabb 30 nap hozzáadása → valid_until = március 31. + 30 nap = április 30.
- Eredmény: 45 nap maradt (március 15-től április 30-ig) ahelyett, hogy csak 30 nap lenne.
```
### 1.5 `upgrade_subscription()` (User-level) Átdolgozása
A [`upgrade_subscription()`](backend/app/services/billing_engine.py:722) függvény hasonló stacking logikát kap a `UserSubscription` rekordokhoz:
```python
async def upgrade_subscription(
db: AsyncSession,
user_id: int,
target_package: str
) -> Dict[str, Any]:
"""
Felhasználói előfizetés frissítése időhalmozással.
Ugyanaz a stacking logika, mint az org változatban.
"""
from app.models.core_logic import SubscriptionTier, UserSubscription
from app.models.identity import User
# 1. Ellenőrizze, hogy a cél csomag létezik-e
stmt = select(SubscriptionTier).where(SubscriptionTier.name == target_package)
result = await db.execute(stmt)
tier = result.scalar_one_or_none()
if not tier:
raise ValueError(f"Subscription tier '{target_package}' not found")
rules = tier.rules or {}
duration_days = rules.get("duration_days")
now = datetime.utcnow()
# 2. Stacking logika a UserSubscription rekordokon
new_valid_until = None
if duration_days is not None:
sub_stmt = select(UserSubscription).where(
UserSubscription.user_id == user_id,
UserSubscription.is_active == True
)
sub_result = await db.execute(sub_stmt)
existing_sub = sub_result.scalar_one_or_none()
if existing_sub and existing_sub.valid_until and existing_sub.valid_until > now:
new_valid_until = existing_sub.valid_until + timedelta(days=duration_days)
else:
new_valid_until = now + timedelta(days=duration_days)
# 3. Ár kiszámítása és levonás (meglévő logika)
price = rules.get("price", 0.0)
# ... (ár számítás, wallet levonás)
# 4. Frissítse a felhasználó adatait
user_stmt = select(User).where(User.id == user_id)
user_result = await db.execute(user_stmt)
user = user_result.scalar_one()
user.subscription_plan = target_package
user.subscription_expires_at = new_valid_until # Már nem 30 nap fix!
# 5. UserSubscription rekord frissítése
if existing_sub:
existing_sub.is_active = False
existing_sub.valid_until = now
new_sub = UserSubscription(
user_id=user_id,
tier_id=tier.id,
valid_from=now,
valid_until=new_valid_until,
is_active=True
)
db.add(new_sub)
return {
"success": True,
"new_plan": target_package,
"price_paid": price,
"valid_from": now.isoformat(),
"valid_until": new_valid_until.isoformat() if new_valid_until else None
}
```
---
## 2. EXTENSIBLE AD ENGINE SCHEMA
### 2.1 Új Adatbázis Séma: `marketing` Schema
A Native Ad Engine egy új `marketing` adatbázis sémában kap helyet, elkülönítve a core üzleti logikától. Ez biztosítja a jövőbeli bővíthetőséget (pl. kattintások, konverziók, A/B tesztelés).
#### 2.1.1 SQLAlchemy Modellek
```python
# backend/app/models/marketing.py
"""
📢 Native Ad Engine — Marketing Schema Models
Schema: marketing
Táblák:
- campaigns: Hirdetési kampányok
- creatives: Kreatív anyagok (belső/külső)
- placements: Elhelyezési zónák
- campaign_placements: Kampány ↔ Elhelyezés kapcsolótábla
- ad_impressions: Megjelenítések naplózása
- ad_clicks: Kattintások naplózása (jövőbeli bővítés)
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
String, Integer, ForeignKey, Boolean, DateTime,
Numeric, Text, Enum as SAEnum
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.sql import func
import enum
import uuid
from app.database import Base
class CampaignStatus(enum.Enum):
DRAFT = "draft"
SCHEDULED = "scheduled"
ACTIVE = "active"
PAUSED = "paused"
COMPLETED = "completed"
CANCELLED = "cancelled"
class CreativeType(enum.Enum):
IMAGE = "image" # Belső kép
HTML_SNIPPET = "html_snippet" # Külső ad kód (pl. Google Ads)
VIDEO = "video" # Videó (jövőbeli)
CAROUSEL = "carousel" # Több képből álló (jövőbeli)
class Campaign(Base):
"""
Hirdetési kampány.
Egy kampány több kreatívot és több elhelyezést is célozhat.
A kampányoknak van prioritása és státusza.
"""
__tablename__ = "campaigns"
__table_args__ = {"schema": "marketing"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
uuid: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
server_default=func.gen_random_uuid(),
unique=True,
comment="Publikus azonosító (UUID) a frontend számára"
)
name: Mapped[str] = mapped_column(
String(200), nullable=False,
comment="Kampány neve (pl. '2026 Q3 Flotta Akció')"
)
description: Mapped[Optional[str]] = mapped_column(
Text, comment="Belső leírás a kampányról"
)
# ── Időbeli korlátok ──
start_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False,
comment="Kampány kezdete"
)
end_date: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
comment="Kampány vége (None = nincs lejárat)"
)
# ── Prioritás és státusz ──
priority: Mapped[int] = mapped_column(
Integer, server_default=text("0"),
comment="Prioritás (magasabb = fontosabb). 0 = legalacsonyabb"
)
status: Mapped[CampaignStatus] = mapped_column(
SAEnum(CampaignStatus, name="campaign_status", schema="marketing"),
server_default=text("'draft'"),
comment="Kampány státusza"
)
# ── Célzás ──
targeting_rules: Mapped[Optional[dict]] = mapped_column(
JSONB, server_default=text("'{}'::jsonb"),
comment="Célzási szabályok JSONB (pl. {'country_codes': ['HU', 'GB'], 'min_tier': 'premium'})"
)
# ── Korlátok ──
daily_impression_limit: Mapped[Optional[int]] = mapped_column(
Integer, comment="Napi max megjelenítés (None = korlátlan)"
)
total_impression_limit: Mapped[Optional[int]] = mapped_column(
Integer, comment="Összes max megjelenítés (None = korlátlan)"
)
# ── Meta ──
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()
)
created_by: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("identity.users.id"), nullable=True,
comment="Kampány létrehozója (admin user ID)"
)
# ── Kapcsolatok ──
creatives: Mapped[list["Creative"]] = relationship(
"Creative", back_populates="campaign", cascade="all, delete-orphan"
)
placements: Mapped[list["Placement"]] = relationship(
"Placement", secondary="marketing.campaign_placements",
back_populates="campaigns"
)
class Creative(Base):
"""
Kreatív anyag (hirdetés).
Két fő típus:
- IMAGE: Belső kép + target_url (saját rendszerben tárolt kép)
- HTML_SNIPPET: Külső ad kód (pl. Google Ads, Taboola snippet)
"""
__tablename__ = "creatives"
__table_args__ = {"schema": "marketing"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
campaign_id: Mapped[int] = mapped_column(
Integer, ForeignKey("marketing.campaigns.id", ondelete="CASCADE"),
nullable=False
)
# ── Típus ──
creative_type: Mapped[CreativeType] = mapped_column(
SAEnum(CreativeType, name="creative_type", schema="marketing"),
nullable=False,
comment="Kreatív típusa: image (belső) vagy html_snippet (külső)"
)
# ── IMAGE típushoz ──
image_url: Mapped[Optional[str]] = mapped_column(
String(500),
comment="Kép URL (IMAGE típusnál). Lehet relatív (storage) vagy abszolút"
)
alt_text: Mapped[Optional[str]] = mapped_column(
String(200),
comment="Kép alternatív szövege (akadálymentesítés)"
)
# ── HTML_SNIPPET típushoz ──
html_snippet: Mapped[Optional[str]] = mapped_column(
Text,
comment="HTML kód külső hirdetési hálózatoktól (pl. Google Ads tag)"
)
# ── Közös mezők ──
title: Mapped[Optional[str]] = mapped_column(
String(200),
comment="Hirdetés címe (megjeleníthető a kép alatt)"
)
description: Mapped[Optional[str]] = mapped_column(
Text,
comment="Hirdetés leírása"
)
target_url: Mapped[Optional[str]] = mapped_column(
String(1000),
comment="Cél URL (hova vezet a kattintás)"
)
payload: Mapped[Optional[dict]] = mapped_column(
JSONB, server_default=text("'{}'::jsonb"),
comment="Extra adatok (pl. tracking params, utm_source)"
)
# ── Súlyozás ──
weight: Mapped[int] = mapped_column(
Integer, server_default=text("1"),
comment="Súly a rotációban (magasabb = gyakrabban jelenik meg)"
)
# ── Kapcsolatok ──
campaign: Mapped["Campaign"] = relationship("Campaign", back_populates="creatives")
class Placement(Base):
"""
Elhelyezési zóna a frontenden.
Példák:
- 'dashboard_sidebar'
- 'vehicle_detail_below'
- 'service_search_top'
- 'footer_banner'
"""
__tablename__ = "placements"
__table_args__ = {"schema": "marketing"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(
String(100), unique=True, nullable=False,
comment="Zóna neve (pl. 'dashboard_sidebar')"
)
display_name: Mapped[Optional[str]] = mapped_column(
String(200),
comment="Megjelenítési név az admin felületen"
)
description: Mapped[Optional[str]] = mapped_column(
Text, comment="Leírás a zóna elhelyezkedéséről"
)
# ── Korlátok ──
max_creatives: Mapped[int] = mapped_column(
Integer, server_default=text("1"),
comment="Maximum hány kreatív jelenhet meg egyszerre ebben a zónában"
)
# ── Tier szűrés ──
allowed_subscription_tiers: Mapped[Optional[dict]] = mapped_column(
JSONB, server_default=text("'{}'::jsonb"),
comment="Engedélyezett előfizetési tier-ek JSONB tömbként (pl. ['free', 'premium'])"
)
# ── Meta ──
is_active: Mapped[bool] = mapped_column(
Boolean, server_default=text("true")
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
# ── Kapcsolatok ──
campaigns: Mapped[list["Campaign"]] = relationship(
"Campaign", secondary="marketing.campaign_placements",
back_populates="placements"
)
class CampaignPlacement(Base):
"""
Kapcsolótábla: Kampány ↔ Elhelyezés (M:N kapcsolat).
Lehetővé teszi, hogy egy kampány több zónában is megjelenjen,
és egy zónában több kampány is fusson.
"""
__tablename__ = "campaign_placements"
__table_args__ = {"schema": "marketing"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
campaign_id: Mapped[int] = mapped_column(
Integer, ForeignKey("marketing.campaigns.id", ondelete="CASCADE"),
nullable=False
)
placement_id: Mapped[int] = mapped_column(
Integer, ForeignKey("marketing.placements.id", ondelete="CASCADE"),
nullable=False
)
# ── Override mezők (felülírhatják a kampány szintű beállításokat) ──
priority_override: Mapped[Optional[int]] = mapped_column(
Integer,
comment="Prioritás felülírása ebben a zónában"
)
daily_impression_limit_override: Mapped[Optional[int]] = mapped_column(
Integer,
comment="Napi limit felülírása ebben a zónában"
)
class AdImpression(Base):
"""
Hirdetés megjelenítések naplózása.
Minden egyes megjelenítés rögzítésre kerül a kampány
és kreatív szintű analitikához.
"""
__tablename__ = "ad_impressions"
__table_args__ = {"schema": "marketing"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
campaign_id: Mapped[int] = mapped_column(
Integer, ForeignKey("marketing.campaigns.id"), nullable=False,
index=True
)
creative_id: Mapped[int] = mapped_column(
Integer, ForeignKey("marketing.creatives.id"), nullable=False,
index=True
)
placement_id: Mapped[int] = mapped_column(
Integer, ForeignKey("marketing.placements.id"), nullable=False
)
user_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("identity.users.id"), nullable=True,
comment="Felhasználó aki látta (None = nem bejelentkezett)"
)
# ── Meta ──
seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(),
index=True
)
ip_address: Mapped[Optional[str]] = mapped_column(
String(45), comment="IP cím (anonimizálható)"
)
user_agent: Mapped[Optional[str]] = mapped_column(
Text, comment="User agent string"
)
session_id: Mapped[Optional[str]] = mapped_column(
String(100), index=True,
comment="Munkamenet azonosító (deduplikációhoz)"
)
# ── Kapcsolatok ──
campaign: Mapped["Campaign"] = relationship()
creative: Mapped["Creative"] = relationship()
placement: Mapped["Placement"] = relationship()
class AdClick(Base):
"""
Hirdetés kattintások naplózása (jövőbeli bővítés).
Elkülönítve az impression táblától a jobb skálázhatóság érdekében.
"""
__tablename__ = "ad_clicks"
__table_args__ = {"schema": "marketing"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
impression_id: Mapped[int] = mapped_column(
Integer, ForeignKey("marketing.ad_impressions.id"), nullable=False,
comment="Melyik megjelenítéshez tartozik a kattintás"
)
clicked_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
target_url: Mapped[Optional[str]] = mapped_column(
String(1000),
comment="Cél URL a kattintáskor (tracking paraméterekkel)"
)
```
### 2.2 Adatbázis Séma Diagram (Text)
```
marketing.campaigns
├── id (PK, SERIAL)
├── uuid (UUID, UNIQUE)
├── name (VARCHAR 200)
├── description (TEXT)
├── start_date (TIMESTAMPTZ)
├── end_date (TIMESTAMPTZ, nullable)
├── priority (INTEGER, default=0)
├── status (ENUM: draft/scheduled/active/paused/completed/cancelled)
├── targeting_rules (JSONB)
├── daily_impression_limit (INTEGER, nullable)
├── total_impression_limit (INTEGER, nullable)
├── created_at (TIMESTAMPTZ)
├── updated_at (TIMESTAMPTZ, nullable)
└── created_by (FK → identity.users.id, nullable)
marketing.creatives
├── id (PK, SERIAL)
├── campaign_id (FK → marketing.campaigns.id, CASCADE)
├── creative_type (ENUM: image/html_snippet/video/carousel)
├── image_url (VARCHAR 500, nullable)
├── alt_text (VARCHAR 200, nullable)
├── html_snippet (TEXT, nullable)
├── title (VARCHAR 200, nullable)
├── description (TEXT, nullable)
├── target_url (VARCHAR 1000, nullable)
├── payload (JSONB)
├── weight (INTEGER, default=1)
└── campaign (relationship → Campaign)
marketing.placements
├── id (PK, SERIAL)
├── name (VARCHAR 100, UNIQUE)
├── display_name (VARCHAR 200, nullable)
├── description (TEXT, nullable)
├── max_creatives (INTEGER, default=1)
├── allowed_subscription_tiers (JSONB)
├── is_active (BOOLEAN, default=true)
├── created_at (TIMESTAMPTZ)
└── campaigns (M:N → campaigns via campaign_placements)
marketing.campaign_placements
├── id (PK, SERIAL)
├── campaign_id (FK → marketing.campaigns.id, CASCADE)
├── placement_id (FK → marketing.placements.id, CASCADE)
├── priority_override (INTEGER, nullable)
└── daily_impression_limit_override (INTEGER, nullable)
marketing.ad_impressions
├── id (PK, SERIAL)
├── campaign_id (FK → marketing.campaigns.id, INDEX)
├── creative_id (FK → marketing.creatives.id, INDEX)
├── placement_id (FK → marketing.placements.id)
├── user_id (FK → identity.users.id, nullable)
├── seen_at (TIMESTAMPTZ, INDEX)
├── ip_address (VARCHAR 45, nullable)
├── user_agent (TEXT, nullable)
└── session_id (VARCHAR 100, INDEX)
marketing.ad_clicks (jövőbeli)
├── id (PK, SERIAL)
├── impression_id (FK → marketing.ad_impressions.id)
├── clicked_at (TIMESTAMPTZ)
└── target_url (VARCHAR 1000, nullable)
```
### 2.3 Hirdetés Kiválasztási Logika (Prioritás + Fallback)
Amikor a backend kiválasztja a megfelelő hirdetést egy adott zónához, az alábbi algoritmust követi:
```
1. SZŰRÉS: Aktív kampányok a zónában
SELECT c.* FROM campaigns c
JOIN campaign_placements cp ON cp.campaign_id = c.id
WHERE cp.placement_id = :placement_id
AND c.status = 'active'
AND c.start_date <= NOW()
AND (c.end_date IS NULL OR c.end_date >= NOW())
2. SZŰRÉS: Felhasználó tier-jének megfelelő kampányok
- Ellenőrzi a placement.allowed_subscription_tiers JSONB tömböt
- Csak azokat a kampányokat tartja meg, ahol a user tier szerepel a listában
3. SZŰRÉS: Napi/össz limit ellenőrzés
- Ellenőrzi a campaign.daily_impression_limit-t
- Ellenőrzi a campaign.total_impression_limit-t
- Kiszűri a limitet elért kampányokat
4. SORREND: Prioritás szerinti rendezés
- Elsődleges: priority (magasabb = előrébb)
- Másodlagos: random (egyenlő prioritás esetén rotáció)
5. KIVÁLASZTÁS: A legmagasabb prioritású kampány kreatívjai közül
- Súlyozott random választás a creative.weight alapján
- Ha nincs megfelelő kampány → FALLBACK: üres placeholder vagy default ad
6. MEGJELENÍTÉS: AdImpression naplózása
- Rögzíti a campaign_id, creative_id, placement_id, user_id adatokat
```
#### 2.3.1 Python Pseudo-kód
```python
class AdSelectionService:
"""
Hirdetés kiválasztó szolgáltatás.
A megfelelő hirdetés kiválasztása egy adott zónához,
figyelembe véve a kampány prioritását, a felhasználó tier-jét
és a napi/össz megjelenítési limiteket.
"""
@staticmethod
async def select_ad_for_placement(
db: AsyncSession,
placement_name: str,
user_tier: str = "free",
user_id: Optional[int] = None
) -> Optional[dict]:
"""
Kiválasztja a legmegfelelőbb hirdetést egy adott zónához.
Args:
db: Database session
placement_name: Zóna neve (pl. 'dashboard_sidebar')
user_tier: Felhasználó tier szintje
user_id: Felhasználó ID (naplózáshoz)
Returns:
Optional[dict]: Kiválasztott kreatív adatai vagy None
"""
now = datetime.utcnow()
# 1. Keressük meg a zónát
placement = await db.execute(
select(Placement).where(
Placement.name == placement_name,
Placement.is_active == True
)
)
placement = placement.scalar_one_or_none()
if not placement:
return None
# 2. Ellenőrizzük, hogy a user tier engedélyezett-e ebben a zónában
allowed_tiers = placement.allowed_subscription_tiers or []
if allowed_tiers and user_tier not in allowed_tiers:
return None
# 3. Aktív kampányok lekérdezése ebben a zónában
# Rendezés: priority DESC, majd random
campaigns_query = (
select(Campaign)
.join(CampaignPlacement, CampaignPlacement.campaign_id == Campaign.id)
.where(
CampaignPlacement.placement_id == placement.id,
Campaign.status == CampaignStatus.ACTIVE,
Campaign.start_date <= now,
or_(Campaign.end_date.is_(None), Campaign.end_date >= now),
)
.order_by(Campaign.priority.desc())
)
result = await db.execute(campaigns_query)
campaigns = result.scalars().all()
if not campaigns:
return None # Fallback: nincs aktív kampány
# 4. Válasszuk ki a legmagasabb prioritású kampányt
# (több azonos prioritás esetén random)
top_priority = campaigns[0].priority
top_campaigns = [c for c in campaigns if c.priority == top_priority]
selected_campaign = random.choice(top_campaigns)
# 5. Válasszunk kreatívot a kampányból (súlyozott random)
creatives = selected_campaign.creatives
if not creatives:
return None
# Súlyozott random választás
total_weight = sum(c.weight for c in creatives)
if total_weight == 0:
selected_creative = random.choice(creatives)
else:
r = random.randint(1, total_weight)
cumulative = 0
selected_creative = creatives[0]
for c in creatives:
cumulative += c.weight
if r <= cumulative:
selected_creative = c
break
# 6. Naplózzuk a megjelenítést
impression = AdImpression(
campaign_id=selected_campaign.id,
creative_id=selected_creative.id,
placement_id=placement.id,
user_id=user_id,
seen_at=now,
)
db.add(impression)
await db.flush()
# 7. Visszatérés a kreatív adataival
result_data = {
"creative_id": selected_creative.id,
"campaign_id": selected_campaign.id,
"creative_type": selected_creative.creative_type.value,
"title": selected_creative.title,
"description": selected_creative.description,
"target_url": selected_creative.target_url,
"impression_id": impression.id,
}
if selected_creative.creative_type == CreativeType.IMAGE:
result_data["image_url"] = selected_creative.image_url
result_data["alt_text"] = selected_creative.alt_text
elif selected_creative.creative_type == CreativeType.HTML_SNIPPET:
result_data["html_snippet"] = selected_creative.html_snippet
return result_data
### 2.4 Admin API Végpontok (Tervezet)
Az Ad Engine kezeléséhez az alábbi admin REST végpontok szükségesek:
| Metódus | Végpont | Leírás |
|---------|---------|--------|
| `GET` | `/api/v1/admin/marketing/campaigns` | Kampányok listázása |
| `POST` | `/api/v1/admin/marketing/campaigns` | Új kampány létrehozása |
| `GET` | `/api/v1/admin/marketing/campaigns/{id}` | Kampány részletei |
| `PATCH` | `/api/v1/admin/marketing/campaigns/{id}` | Kampány módosítása |
| `DELETE` | `/api/v1/admin/marketing/campaigns/{id}` | Kampány törlése (soft-delete) |
| `GET` | `/api/v1/admin/marketing/creatives` | Kreatívok listázása |
| `POST` | `/api/v1/admin/marketing/creatives` | Új kreatív létrehozása |
| `GET` | `/api/v1/admin/marketing/placements` | Elhelyezési zónák listázása |
| `POST` | `/api/v1/admin/marketing/placements` | Új zóna létrehozása |
| `GET` | `/api/v1/admin/marketing/analytics/impressions` | Megjelenítési statisztikák |
| `GET` | `/api/v1/admin/marketing/analytics/summary` | Összesített analitika |
### 2.5 Publikus API Végpont (Frontend számára)
| Metódus | Végpont | Leírás |
|---------|---------|--------|
| `GET` | `/api/v1/marketing/ad/{placement_name}` | Hirdetés lekérése egy adott zónához |
Ez a végpont meghívja az `AdSelectionService.select_ad_for_placement()` függvényt, és visszaadja a kiválasztott kreatív adatait a frontend számára.
---
## 3. IMPLEMENTÁCIÓS ÜTEMTERV
### Fázis 1: Backend Alapok (Subscription Stacking)
| # | Feladat | Fájl | Leírás |
|---|---------|------|--------|
| 1.1 | `duration_days` hozzáadása a Pydantic sémához | [`subscription.py`](backend/app/schemas/subscription.py:55) | `SubscriptionRulesModel` bővítése `duration_days` mezővel |
| 1.2 | `upgrade_org_subscription()` stacking logika | [`billing_engine.py:805`](backend/app/services/billing_engine.py:805) | Teljes átírás: duration_days olvasás, valid_until számítás stackinggel |
| 1.3 | `upgrade_subscription()` stacking logika | [`billing_engine.py:722`](backend/app/services/billing_engine.py:722) | User-level stacking implementálása |
| 1.4 | `duration_days` hozzáadása a seed csomagokhoz | [`seed_packages.py`](backend/app/scripts/seed_packages.py) | Mind a 6 csomag rules kiegészítése `duration_days`-szel |
| 1.5 | Seed szkript futtatása | — | `docker compose exec sf_api python3 /app/backend/app/scripts/seed_packages.py` |
### Fázis 2: Ad Engine Modellek és Séma
| # | Feladat | Fájl | Leírás |
|---|---------|------|--------|
| 2.1 | `marketing` schema modellek | [`models/marketing.py`](backend/app/models/) | Campaign, Creative, Placement, CampaignPlacement, AdImpression, AdClick |
| 2.2 | Sync engine futtatása | — | `docker compose exec sf_api python3 /app/backend/app/scripts/sync_engine.py` |
| 2.3 | `AdPolicyModel` Pydantic séma | [`subscription.py`](backend/app/schemas/subscription.py) | `ad_policy` mező hozzáadása a `SubscriptionRulesModel`-hez |
| 2.4 | `AdPolicyService` létrehozása | [`services/ad_policy_service.py`](backend/app/services/) | Új service a hirdetési politika lekérdezéséhez |
| 2.5 | `AdSelectionService` létrehozása | [`services/ad_selection_service.py`](backend/app/services/) | Hirdetés kiválasztó algoritmus |
### Fázis 3: Admin API Végpontok
| # | Feladat | Fájl | Leírás |
|---|---------|------|--------|
| 3.1 | Admin marketing router | [`endpoints/admin_marketing.py`](backend/app/api/v1/endpoints/) | CRUD végpontok kampányokhoz, kreatívokhoz, elhelyezésekhez |
| 3.2 | Publikus ad végpont | [`endpoints/marketing.py`](backend/app/api/v1/endpoints/) | `GET /api/v1/marketing/ad/{placement_name}` |
| 3.3 | Router regisztráció | [`api.py`](backend/app/api/v1/api.py) | Új router-ek hozzáadása az API-hoz |
### Fázis 4: Seed Adatok és Tesztelés
| # | Feladat | Fájl | Leírás |
|---|---------|------|--------|
| 4.1 | `ad_policy` hozzáadása a seed csomagokhoz | [`seed_packages.py`](backend/app/scripts/seed_packages.py) | Mind a 6 csomag rules kiegészítése az `ad_policy` blokkal |
| 4.2 | Seed marketing adatok | [`seed_marketing.py`](backend/app/scripts/) | Teszt kampányok, kreatívok, elhelyezések létrehozása |
| 4.3 | Tesztelés | — | API tesztek a stacking és ad selection logikára |
---
## 4. FÜGGŐSÉGEK ÉS KOCKÁZATOK
| Függőség | Hatás | Megoldás |
|----------|-------|----------|
| A `valid_until` mező jelenleg NULL minden `org_subscriptions` rekordban | A stacking logika nem működik a meglévő rekordokon | Backfill szkript: `UPDATE finance.org_subscriptions SET valid_until = valid_from + INTERVAL '30 days' WHERE valid_until IS NULL AND is_active = true` |
| A `duration_days` mező hiányzik a meglévő seed csomagokból | Az új stacking logika nem tudja kiszámolni az időtartamot | A `seed_packages.py` újrafuttatása a `duration_days` mezővel |
| Az új `marketing` séma táblái nem léteznek | Az Ad Engine nem működik | A `sync_engine.py` futtatása a modellek létrehozása után |
| A frontend jelenleg nem kérdez le hirdetéseket | A hirdetési zónák üresek maradnak | A frontend `AdService` létrehozása a Vue alkalmazásban |
---
## 5. JÓVÁHAGYÁS
A fenti terv jóváhagyása után kezdődhet meg a kódolás az alábbi sorrendben:
1. **Subscription Stacking:** `duration_days` séma → `upgrade_org_subscription()` → `upgrade_subscription()` → seed frissítés
2. **Ad Engine Modellek:** `marketing` séma → sync engine → Pydantic sémák
3. **Ad Engine Service:** `AdPolicyService` → `AdSelectionService`
4. **Admin API:** CRUD végpontok → publikus ad végpont
5. **Seed Adatok:** `ad_policy` → teszt kampányok

View File

@@ -0,0 +1,428 @@
# P0 Architecture Plan — Subscription UI, Expiration Timers & Smart Ad Policy
**Dátum:** 2026-06-18
**Szerző:** Fast Coder (Core Developer)
**Státusz:** Tervezési fázis — Jóváhagyásra vár
**Cél:** Teljes körű architekturális terv a Subscription UI (Frontend), Expiration Timers (Backend) és a dinamikus, tier-alapú Smart Ad Policy rendszerhez.
---
## 1. AUDIT: Backend Modellek — Előfizetés Időzítők és Állapot
### 1.1 Jelenlegi Adatmodell Áttekintés
A rendszer három rétegben tárolja az előfizetési információkat:
#### A) `system.subscription_tiers` — Csomagdefiníciók
- **Modell:** [`SubscriptionTier`](backend/app/models/core_logic.py:12)
- **Kulcs mezők:** `id`, `name` (unique), `rules` (JSONB), `is_custom`
- **JSONB `rules` struktúra:** Lásd a [`seed_packages.py`](backend/app/scripts/seed_packages.py:37) definícióit
- **Jelenlegi csomagok:** 10 db (4 private + 6 corporate)
#### B) `finance.org_subscriptions` — Szervezeti előfizetés rekordok
- **Modell:** [`OrganizationSubscription`](backend/app/models/core_logic.py:25)
- **Kulcs mezők:**
- `org_id` → FK `fleet.organizations.id`
- `tier_id` → FK `system.subscription_tiers.id`
- `valid_from` (DateTime, server_default=now)
- **`valid_until`** (DateTime, nullable) — **EZ A LEJÁRATI IDŐ**
- `is_active` (Boolean, default=True)
#### C) `finance.user_subscriptions` — Felhasználói előfizetés rekordok
- **Modell:** [`UserSubscription`](backend/app/models/core_logic.py:44)
- **Kulcs mezők:** `user_id`, `tier_id`, `valid_from`, **`valid_until`**, `is_active`, `created_at`, `updated_at`
#### D) `fleet.organizations` — Szervezet (denormalizált subscription adatok)
- **Modell:** [`Organization`](backend/app/models/marketplace/organization.py:70)
- **Kulcs mezők:**
- `subscription_plan` (String, legacy — server_default='FREE')
- `subscription_tier_id` (FK → `system.subscription_tiers.id`, nullable)
- `base_asset_limit` (Integer, server_default=1)
- `purchased_extra_slots` (Integer, server_default=0)
### 1.2 Lejárati Idő (valid_until) Jelenlegi Állapota
| Tábla | Oszlop | Típus | Alapértelmezés | Jelenlegi Használat |
|-------|--------|-------|----------------|---------------------|
| `finance.org_subscriptions` | `valid_until` | `DateTime(timezone=True)` | `NULL` | **Nincs beállítva** a [`upgrade_org_subscription()`](backend/app/services/billing_engine.py:805) függvényben — csak `valid_from` kerül beállításra |
| `finance.user_subscriptions` | `valid_until` | `DateTime(timezone=True)` | `NULL` | **Nincs beállítva** a felhasználói előfizetés létrehozáskor |
| `identity.users` | `subscription_expires_at` | (a kódban használva) | — | A [`billing_engine.py:792`](backend/app/services/billing_engine.py:792) beállítja: `user.subscription_expires_at = datetime.utcnow() + timedelta(days=30)` |
**Következtetés:** A `valid_until` mező jelenleg **nem töltődik be** a szervezeti előfizetés hozzárendelésekor. Ez egy hiányosság, amit pótolni kell.
### 1.3 Tervezett Módosítás: `GET /api/v1/organizations/my` Subscription Adatokkal
A [`GET /my`](backend/app/api/v1/endpoints/organizations.py:180) végpont jelenleg **nem adja vissza** az előfizetési adatokat. Tervezett bővítés:
```python
# Jelenlegi válasz (hiányos):
{
"organization_id": 1,
"subscription_plan": "FREE",
"subscription_tier_id": None,
# ... nincs subscription info
}
# Tervezett bővítés:
{
"organization_id": 1,
"subscription_plan": "corp_premium_v1",
"subscription_tier_id": 16,
"subscription": {
"tier_id": 16,
"tier_name": "corp_premium_v1",
"display_name": "Céges Prémium",
"valid_from": "2026-06-01T00:00:00Z",
"valid_until": "2026-07-01T00:00:00Z", # LEJÁRAT
"is_active": True,
"allowances": {
"max_vehicles": 20,
"max_garages": 3,
"monthly_free_credits": 300
},
"pricing": {
"monthly_price": 29.99,
"yearly_price": 299.99,
"currency": "EUR"
}
}
}
```
**Implementációs terv:**
1. A [`GET /my`](backend/app/api/v1/endpoints/organizations.py:180) végpontban minden Organization rekordhoz töltsük be a hozzá tartozó aktív `OrganizationSubscription` rekordot (JOIN vagy selectinload).
2. Ha van aktív subscription, adjuk vissza a `valid_until`, `tier.rules` (allowances, pricing, display_name) adatokat.
3. Ha nincs, a subscription blokk legyen `null`.
---
## 2. SMART AD POLICY — JSONB Architektúra
### 2.1 Tervezési Döntés
A Tervező elvetette az egyszerű `hide_ads` boolean megközelítést. Ehelyett egy **3 szintű, tier-alapú hirdetési politika** kerül bevezetésre, amely a `system.subscription_tiers.rules` JSONB oszlopba ágyazva él.
### 2.2 Az `ad_policy` JSON Struktúra
```json
{
"ad_policy": {
"external_display": {
"enabled": true | false,
"networks": ["google_ads", "taboola", "outbrain"],
"max_per_page": 3,
"placement": ["sidebar", "between_cards", "footer"]
},
"internal_upsell": {
"enabled": true | false,
"show_banners": true | false,
"banner_type": "soft" | "aggressive",
"upsell_target_tier": "private_pro_v1",
"frequency": "once_per_session" | "every_n_actions"
},
"partner_offers": {
"enabled": true | false,
"show_sponsored_services": true | false,
"show_coupons": true | false,
"max_offers_per_page": 2
}
}
}
```
### 2.3 Példa: "Free" Csomag (`private_free_v1`)
```json
{
"type": "private",
"display_name": "Privát Ingyenes",
"pricing": { "monthly_price": 0, "yearly_price": 0, "currency": "EUR", "credit_price": 0 },
"allowances": { "max_vehicles": 1, "max_garages": 1, "monthly_free_credits": 0 },
"entitlements": [],
"lifecycle": { "is_public": true },
"ad_policy": {
"external_display": {
"enabled": true,
"networks": ["google_ads"],
"max_per_page": 3,
"placement": ["sidebar", "between_cards", "footer"]
},
"internal_upsell": {
"enabled": true,
"show_banners": true,
"banner_type": "aggressive",
"upsell_target_tier": "private_pro_v1",
"frequency": "every_n_actions"
},
"partner_offers": {
"enabled": true,
"show_sponsored_services": true,
"show_coupons": true,
"max_offers_per_page": 2
}
}
}
```
### 2.4 Példa: "Premium" Csomag (`corp_premium_v1`)
```json
{
"type": "corporate",
"display_name": "Céges Prémium",
"pricing": { "monthly_price": 29.99, "yearly_price": 299.99, "currency": "EUR", "credit_price": 3000 },
"allowances": { "max_vehicles": 20, "max_garages": 3, "monthly_free_credits": 300 },
"entitlements": ["SRV_DATA_EXPORT", "SRV_AI_UPLOAD"],
"lifecycle": { "is_public": true },
"ad_policy": {
"external_display": {
"enabled": false,
"networks": [],
"max_per_page": 0,
"placement": []
},
"internal_upsell": {
"enabled": false,
"show_banners": false,
"banner_type": "soft",
"upsell_target_tier": "corp_premium_plus_v1",
"frequency": "once_per_session"
},
"partner_offers": {
"enabled": true,
"show_sponsored_services": true,
"show_coupons": false,
"max_offers_per_page": 1
}
}
}
```
### 2.5 Backend Service Terv: `AdPolicyService`
Létrehozandó fájl: [`backend/app/services/ad_policy_service.py`](backend/app/services/)
```python
class AdPolicyService:
"""
Szolgáltatás a hirdetési politika lekérdezésére a felhasználó/szervezet
aktuális előfizetési csomagja alapján.
"""
@staticmethod
async def get_ad_policy(db: AsyncSession, org_id: int) -> dict:
"""
Visszaadja a szervezet aktuális ad_policy-ját.
Ha nincs tier hozzárendelve, a 'private_free_v1' alapértelmezett policy-t adja.
"""
pass
@staticmethod
def should_show_external_ads(policy: dict) -> bool:
"""Ellenőrzi, hogy a külső hirdetések engedélyezettek-e."""
return policy.get("external_display", {}).get("enabled", False)
@staticmethod
def should_show_upsell_banner(policy: dict) -> bool:
"""Ellenőrzi, hogy a belső upsell banner megjelenjen-e."""
return policy.get("internal_upsell", {}).get("enabled", False)
```
### 2.6 Pydantic Schema Bővítés
A [`SubscriptionRulesModel`](backend/app/schemas/subscription.py:55) kiegészítése az `ad_policy` mezővel:
```python
class AdPolicyExternalDisplay(BaseModel):
enabled: bool = False
networks: List[str] = []
max_per_page: int = 0
placement: List[str] = []
class AdPolicyInternalUpsell(BaseModel):
enabled: bool = False
show_banners: bool = False
banner_type: str = "soft"
upsell_target_tier: Optional[str] = None
frequency: str = "once_per_session"
class AdPolicyPartnerOffers(BaseModel):
enabled: bool = False
show_sponsored_services: bool = False
show_coupons: bool = False
max_offers_per_page: int = 0
class AdPolicyModel(BaseModel):
external_display: AdPolicyExternalDisplay = Field(default_factory=AdPolicyExternalDisplay)
internal_upsell: AdPolicyInternalUpsell = Field(default_factory=AdPolicyInternalUpsell)
partner_offers: AdPolicyPartnerOffers = Field(default_factory=AdPolicyPartnerOffers)
```
---
## 3. UI Routing & Catalog — Frontend Terv
### 3.1 Jelenlegi Navigációs Struktúra
A frontend két fő layoutot használ:
1. **`PrivateLayout.vue`** ([`frontend/src/layouts/PrivateLayout.vue`](frontend/src/layouts/PrivateLayout.vue)) — Hamburger menü: Dashboard, Járművek, Költségek, Szerviz kereső
2. **`OrganizationLayout.vue`** ([`frontend/src/layouts/OrganizationLayout.vue`](frontend/src/layouts/OrganizationLayout.vue)) — Hamburger menü: Company Data
**Hiányzó elem:** Egyik layoutban sincs "Előfizetésem & Csomagok" menüpont.
### 3.2 Tervezett UI Változtatások
#### A) Új menüpont: "Előfizetésem & Csomagok"
**Helye a `PrivateLayout.vue` hamburger menüben** (a Szerviz kereső után, a lista végén):
```html
<!-- Előfizetésem & Csomagok -->
<button
@click="navigateToSubscription"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
<svg class="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
{{ t('menu.subscription') }}
</button>
```
**Helye az `OrganizationLayout.vue` hamburger menüben** (a Company Data után):
```html
<button
@click="navigateToSubscription"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
<!-- Wallet/Subscription icon -->
<svg class="w-4 h-4 text-white/40" ...>
{{ t('menu.subscription') }}
</button>
```
#### B) Új Route: `/subscription`
A [`router/index.ts`](frontend/src/router/index.ts)-ben új route hozzáadása:
```typescript
{
path: '/subscription',
name: 'subscription',
component: () => import('../views/SubscriptionView.vue'),
meta: { requiresAuth: true }
}
```
#### C) Új View: `SubscriptionView.vue`
Létrehozandó: [`frontend/src/views/SubscriptionView.vue`](frontend/src/views/)
Funkciók:
- Megjeleníti az aktuális csomag adatait (név, lejárati idő, limitek)
- Lejárati idő visszaszámláló (countdown timer)
- "Csomag váltása" gomb → megnyitja a csomagkatalógust
- Hirdetési politika státuszának megjelenítése (pl. "A Te csomagodban nincsenek külső hirdetések")
### 3.3 `GET /api/v1/subscriptions/public` — Publikus Csomagkatalógus API
**Tervezett új végpont:**
```
GET /api/v1/subscriptions/public?type=private|corporate
```
**Szűrési logika:**
- Ha a garázs `org_type == 'individual'` → csak `private_` prefixű csomagokat ad vissza
- Ha a garázs `org_type != 'individual'` (business, fleet_owner, stb.) → csak `corp_` prefixű csomagokat ad vissza
- Minden esetben csak azokat a csomagokat, ahol `rules.lifecycle.is_public == true`
**Tervezett implementáció:**
```python
@router.get("/public", response_model=SubscriptionTierListResponse)
async def get_public_subscriptions(
org_type: Optional[str] = Query(None, description="Szűrés típus szerint: private vagy corporate"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Publikus előfizetési csomagok listázása.
- Ha org_type='individual', csak private_ csomagok
- Ha org_type='business' (vagy más), csak corp_ csomagok
- Csak az is_public=true csomagok
"""
stmt = select(SubscriptionTier)
# Szűrés típus szerint
if org_type and org_type.lower() == 'individual':
stmt = stmt.where(SubscriptionTier.rules["type"].as_string() == "private")
elif org_type:
stmt = stmt.where(SubscriptionTier.rules["type"].as_string() == "corporate")
# Csak publikus csomagok
stmt = stmt.where(
~SubscriptionTier.rules["lifecycle"]["is_public"].as_string().in_(["false"])
)
result = await db.execute(stmt.order_by(SubscriptionTier.id))
tiers = result.scalars().all()
return SubscriptionTierListResponse(
total=len(tiers),
tiers=[SubscriptionTierResponse.model_validate(t) for t in tiers]
)
```
---
## 4. Implementációs Ütemterv
### Fázis 1: Backend Alapok (Elsőbbséges)
| # | Feladat | Fájl | Leírás |
|---|---------|------|--------|
| 1.1 | `valid_until` beállítása | [`billing_engine.py:869`](backend/app/services/billing_engine.py:869) | A `upgrade_org_subscription()` függvényben állítsuk be a `valid_until = now + 30 nap` értéket |
| 1.2 | `GET /my` subscription adatokkal | [`organizations.py:180`](backend/app/api/v1/endpoints/organizations.py:180) | Bővítsük ki a választ subscription blokkal (tier adatok + valid_until) |
| 1.3 | `AdPolicyModel` Pydantic séma | [`subscription.py`](backend/app/schemas/subscription.py) | Add hozzá az `ad_policy` mezőt a `SubscriptionRulesModel`-hez |
| 1.4 | `AdPolicyService` létrehozása | [`services/ad_policy_service.py`](backend/app/services/) | Új service a hirdetési politika lekérdezéséhez |
| 1.5 | `GET /api/v1/subscriptions/public` | Új végpont | Publikus csomagkatalógus API org_type alapú szűréssel |
### Fázis 2: Frontend UI
| # | Feladat | Fájl | Leírás |
|---|---------|------|--------|
| 2.1 | "Előfizetésem" menüpont | [`PrivateLayout.vue`](frontend/src/layouts/PrivateLayout.vue) | Új hamburger menüpont |
| 2.2 | "Előfizetésem" menüpont | [`OrganizationLayout.vue`](frontend/src/layouts/OrganizationLayout.vue) | Új hamburger menüpont |
| 2.3 | `/subscription` route | [`router/index.ts`](frontend/src/router/index.ts) | Új route regisztrálása |
| 2.4 | `SubscriptionView.vue` | [`views/SubscriptionView.vue`](frontend/src/views/) | Új nézet: aktuális csomag + countdown + csomagváltás |
| 2.5 | i18n kulcsok | [`locales/hu.json`](backend/static/locales/hu.json), [`locales/en.json`](backend/static/locales/en.json) | `menu.subscription` és kapcsolódó fordítások |
### Fázis 3: Seed Adatbázis Frissítés
| # | Feladat | Fájl | Leírás |
|---|---------|------|--------|
| 3.1 | `ad_policy` hozzáadása a seed csomagokhoz | [`seed_packages.py`](backend/app/scripts/seed_packages.py) | Mind a 6 csomag rules kiegészítése az `ad_policy` blokkal |
| 3.2 | Seed szkript futtatása | — | `docker compose exec sf_api python3 /app/backend/app/scripts/seed_packages.py` |
---
## 5. Függőségek és Kockázatok
| Függőség | Hatás | Megoldás |
|----------|-------|----------|
| A `valid_until` mező jelenleg NULL minden `org_subscriptions` rekordban | A lejárati idő visszaszámláló nem működik | A `upgrade_org_subscription()` bővítése + backfill szkript a meglévő rekordokhoz |
| A frontend auth store (`auth.ts`) jelenleg nem tárol subscription adatokat | A UI nem tudja megjeleníteni a csomag infót | A `GET /my` válasz bővítése után a store automatikusan frissül |
| A meglévő seed csomagok nem tartalmaznak `ad_policy`-t | A Smart Ad Policy nem működik a régi csomagokon | A `seed_packages.py` frissítése és újrafuttatása |
---
## 6. Jóváhagyás
A fenti terv jóváhagyása után kezdődhet meg a kódolás az alábbi sorrendben:
1. **Backend:** `valid_until` javítás → `GET /my` bővítés → `AdPolicyModel``AdPolicyService``GET /public`
2. **Seed:** `ad_policy` hozzáadása a csomagokhoz
3. **Frontend:** Menüpontok → Route → SubscriptionView → i18n