feat: implement hybrid address system and premium search logic

- Added centralized, self-learning GeoService (ZIP, City, Street)
- Implemented Hybrid Address Management (Centralized table + Denormalized fields)
- Fixed Gamification logic (PointsLedger field names & filtering)
- Added address autocomplete and two-tier (Free/Premium) search API
- Synchronized UserStats and PointsLedger schemas
This commit is contained in:
2026-02-08 16:26:39 +00:00
parent 4e14d57bf6
commit 451900ae1a
41 changed files with 764 additions and 515 deletions

2
.env
View File

@@ -72,7 +72,7 @@ FRONTEND_BASE_URL=http://192.168.100.10:3000
# EMAIL_PROVIDER lehet: 'smtp' vagy 'sendgrid' vagy 'disabled' # EMAIL_PROVIDER lehet: 'smtp' vagy 'sendgrid' vagy 'disabled'
EMAIL_PROVIDER=sendgrid EMAIL_PROVIDER=sendgrid
EMAILS_FROM_EMAIL=info@profibot.hu EMAILS_FROM_EMAIL=info@profibot.hu
EMAILS_FROM_NAME='Profibot Service Finder' EMAILS_FROM_NAME='Service Finder'
# SendGrid beállítások # SendGrid beállítások
SENDGRID_API_KEY=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 SENDGRID_API_KEY=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

View File

@@ -1,4 +1,4 @@
from typing import AsyncGenerator from typing import Optional
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -6,46 +6,50 @@ from sqlalchemy import select
from app.db.session import get_db from app.db.session import get_db
from app.core.security import decode_token from app.core.security import decode_token
from app.models.identity import User # Javítva identity-re from app.models.identity import User
# Javítva v1-re # Az OAuth2 séma definiálása, ami a tokent keresi a Headerben
reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def get_current_user( async def get_current_user(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
token: str = Depends(reusable_oauth2), token: str = Depends(reusable_oauth2),
) -> User: ) -> User:
"""
Dependency, amely visszaadja az aktuálisan bejelentkezett felhasználót.
Ha a token érvénytelen vagy a felhasználó nem létezik, hibát dob.
"""
payload = decode_token(token) payload = decode_token(token)
if not payload: if not payload:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Érvénytelen vagy lejárt token." detail="Érvénytelen vagy lejárt munkamenet."
) )
user_id = payload.get("sub") user_id: str = payload.get("sub")
if not user_id: if not user_id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token azonosítási hiba." detail="Token azonosítási hiba."
) )
# Felhasználó keresése az adatbázisban # Felhasználó lekérése az adatbázisból
res = await db.execute(select(User).where(User.id == int(user_id))) result = await db.execute(select(User).where(User.id == int(user_id)))
user = res.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Felhasználó nem található." detail="A felhasználó nem található."
) )
if user.is_deleted: if user.is_deleted:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Ez a fiók törölve lett." detail="Ez a fiók korábban törlésre került."
) )
# FONTOS: Itt NEM dobunk hibát, ha user.is_active == False, # Megjegyzés: is_active ellenőrzést szándékosan nem teszünk itt,
# mert a Step 2 (KYC) kitöltéséhez be kell tudnia lépni inaktívként is! # hogy a KYC folyamatot (Step 2) be tudja fejezni a még nem aktív user is.
return user return user

View File

@@ -1,11 +1,14 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1.endpoints import auth, catalog, assets, organizations, documents # <--- Ide bekerült a documents! from app.api.v1.endpoints import auth, catalog, assets, organizations, documents, services
api_router = APIRouter() api_router = APIRouter()
# Hitelesítés # Hitelesítés
api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"]) api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
# Szolgáltatások és Vadászat (Ez az új rész!)
api_router.include_router(services.router, prefix="/services", tags=["Service Hunt & Discovery"])
# Katalógus # Katalógus
api_router.include_router(catalog.router, prefix="/catalog", tags=["Vehicle Catalog"]) api_router.include_router(catalog.router, prefix="/catalog", tags=["Vehicle Catalog"])
@@ -15,5 +18,5 @@ api_router.include_router(assets.router, prefix="/assets", tags=["Assets"])
# Szervezetek # Szervezetek
api_router.include_router(organizations.router, prefix="/organizations", tags=["Organizations"]) api_router.include_router(organizations.router, prefix="/organizations", tags=["Organizations"])
# DOKUMENTUMOK (Ez az új rész, ami hiányzik neked) # Dokumentumok
api_router.include_router(documents.router, prefix="/documents", tags=["Documents"]) api_router.include_router(documents.router, prefix="/documents", tags=["Documents"])

View File

@@ -1,48 +1,75 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text from sqlalchemy import select
from app.db.session import get_db from app.db.session import get_db
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
from app.core.security import create_access_token from app.core.security import create_access_token
from app.schemas.auth import UserLiteRegister, Token, PasswordResetRequest, UserKYCComplete from app.schemas.auth import (
from app.api.deps import get_current_user # Ez kezeli a belépett felhasználót UserLiteRegister, Token, PasswordResetRequest,
UserKYCComplete, PasswordResetConfirm
)
from app.api.deps import get_current_user
from app.models.identity import User from app.models.identity import User
router = APIRouter() router = APIRouter()
@router.post("/register-lite", response_model=Token, status_code=201) @router.post("/register-lite", response_model=Token, status_code=status.HTTP_201_CREATED)
async def register_lite(user_in: UserLiteRegister, db: AsyncSession = Depends(get_db)): async def register_lite(user_in: UserLiteRegister, db: AsyncSession = Depends(get_db)):
"""Step 1: Alapszintű regisztráció és aktiváló e-mail küldése.""" """Step 1: Alapszintű regisztráció (Email + Jelszó)."""
check = await db.execute(text("SELECT id FROM data.users WHERE email = :e"), {"e": user_in.email}) stmt = select(User).where(User.email == user_in.email)
if check.fetchone(): result = await db.execute(stmt)
raise HTTPException(status_code=400, detail="Ez az email cím már foglalt.") if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ez az e-mail cím már regisztrálva van."
)
try: try:
user = await AuthService.register_lite(db, user_in) user = await AuthService.register_lite(db, user_in)
token = create_access_token(data={"sub": str(user.id)}) token = create_access_token(data={"sub": str(user.id)})
return {"access_token": token, "token_type": "bearer", "is_active": user.is_active} return {
"access_token": token,
"token_type": "bearer",
"is_active": user.is_active
}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Szerver hiba: {str(e)}") raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Sikertelen regisztráció: {str(e)}"
)
@router.post("/login", response_model=Token) @router.post("/login", response_model=Token)
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)): async def login(
"""Bejelentkezés az access_token megszerzéséhez.""" db: AsyncSession = Depends(get_db),
form_data: OAuth2PasswordRequestForm = Depends()
):
"""Bejelentkezés és Access Token generálása."""
user = await AuthService.authenticate(db, form_data.username, form_data.password) user = await AuthService.authenticate(db, form_data.username, form_data.password)
if not user: if not user:
raise HTTPException(status_code=401, detail="Hibás e-mail vagy jelszó.") raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Hibás e-mail cím vagy jelszó."
)
token = create_access_token(data={"sub": str(user.id)}) token = create_access_token(data={"sub": str(user.id)})
return {"access_token": token, "token_type": "bearer", "is_active": user.is_active} return {
"access_token": token,
"token_type": "bearer",
"is_active": user.is_active
}
@router.get("/verify-email") @router.get("/verify-email")
async def verify_email(token: str, db: AsyncSession = Depends(get_db)): async def verify_email(token: str, db: AsyncSession = Depends(get_db)):
"""E-mail megerősítése a kiküldött token alapján.""" """E-mail megerősítése a kiküldött link alapján."""
success = await AuthService.verify_email(db, token) success = await AuthService.verify_email(db, token)
if not success: if not success:
raise HTTPException(status_code=400, detail="Érvénytelen vagy lejárt token.") raise HTTPException(
return {"message": "Email sikeresen megerősítve! Jöhet a Step 2 (KYC)."} status_code=status.HTTP_400_BAD_REQUEST,
detail="Érvénytelen vagy lejárt megerősítő token."
)
return {"message": "Email sikeresen megerősítve! Jöhet a profil kitöltése (KYC)."}
@router.post("/complete-kyc") @router.post("/complete-kyc")
async def complete_kyc( async def complete_kyc(
@@ -50,14 +77,38 @@ async def complete_kyc(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Step 2: Okmányok rögzítése, Privát Széf és Wallet aktiválása.""" """Step 2: Személyes adatok és okmányok rögzítése."""
user = await AuthService.complete_kyc(db, current_user.id, kyc_in) user = await AuthService.complete_kyc(db, current_user.id, kyc_in)
if not user: if not user:
raise HTTPException(status_code=404, detail="Felhasználó nem található.") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Felhasználó nem található.")
return {"status": "success", "message": "Gratulálunk! A Privát Széf és a Pénztárca aktiválva lett."} return {"status": "success", "message": "A profil adatok rögzítve, a rendszer aktiválva."}
@router.post("/forgot-password") @router.post("/forgot-password")
async def forgot_password(req: PasswordResetRequest, db: AsyncSession = Depends(get_db)): async def forgot_password(req: PasswordResetRequest, db: AsyncSession = Depends(get_db)):
"""Jelszó-visszaállító link küldése.""" """Elfelejtett jelszó folyamat indítása biztonsági korlátokkal."""
await AuthService.initiate_password_reset(db, req.email) result = await AuthService.initiate_password_reset(db, req.email)
return {"message": "Ha a cím létezik, elküldtük a helyreállítási linket."}
if result == "cooldown":
raise HTTPException(status_code=429, detail="Kérjük várjon 2 percet az újabb kérés előtt.")
if result in ["hourly_limit", "daily_limit"]:
raise HTTPException(status_code=429, detail="Túllépte a napi/óránkénti próbálkozások számát.")
return {"message": "Amennyiben a megadott e-mail cím szerepel a rendszerünkben, kiküldtük a linket."}
@router.post("/reset-password")
async def reset_password(req: PasswordResetConfirm, db: AsyncSession = Depends(get_db)):
"""Új jelszó beállítása. Backend ellenőrzi az egyezőséget és a tokent."""
if req.password != req.password_confirm:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A két jelszó nem egyezik meg."
)
success = await AuthService.reset_password(db, req.email, req.token, req.password)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Érvénytelen adatok vagy lejárt token."
)
return {"message": "A jelszó sikeresen frissítve! Most már bejelentkezhet."}

View File

@@ -0,0 +1,86 @@
from fastapi import APIRouter, Depends, Form, Query, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from typing import Optional, List
from app.db.session import get_db
from app.services.geo_service import GeoService
from app.services.gamification_service import GamificationService
from app.services.config_service import config
router = APIRouter()
@router.get("/suggest-street")
async def suggest_street(zip_code: str, q: str, db: AsyncSession = Depends(get_db)):
"""Azonnali utca javaslatok gépelés közben."""
return await GeoService.get_street_suggestions(db, zip_code, q)
@router.post("/hunt")
async def register_service_hunt(
name: str = Form(...),
zip_code: str = Form(...),
city: str = Form(...),
street_name: str = Form(...),
street_type: str = Form(...),
house_number: str = Form(...),
parcel_id: Optional[str] = Form(None),
latitude: float = Form(...),
longitude: float = Form(...),
user_latitude: float = Form(...),
user_longitude: float = Form(...),
current_user_id: int = 1,
db: AsyncSession = Depends(get_db)
):
# 1. Hibrid címrögzítés
addr_id = await GeoService.get_or_create_full_address(
db, zip_code, city, street_name, street_type, house_number, parcel_id
)
# 2. Távolságmérés
dist_query = text("""
SELECT ST_Distance(
ST_SetSRID(ST_MakePoint(:u_lon, :u_lat), 4326)::geography,
ST_SetSRID(ST_MakePoint(:s_lon, :s_lat), 4326)::geography
)
""")
distance = (await db.execute(dist_query, {
"u_lon": user_longitude, "u_lat": user_latitude,
"s_lon": longitude, "s_lat": latitude
})).scalar() or 0.0
# 3. Mentés (Denormalizált adatokkal a sebességért)
await db.execute(text("""
INSERT INTO data.organization_locations
(name, address_id, coordinates, proposed_by, zip_code, city, street, house_number, sources, confidence_score)
VALUES (:n, :aid, ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography, :uid, :z, :c, :s, :hn, jsonb_build_array(CAST('user_hunt' AS TEXT)), 1)
"""), {
"n": name, "aid": addr_id, "lon": longitude, "lat": latitude,
"uid": current_user_id, "z": zip_code, "c": city, "s": f"{street_name} {street_type}", "hn": house_number
})
# 4. Jutalmazás
await GamificationService.award_points(db, current_user_id, 50, f"Service Hunt: {city}")
await db.commit()
return {"status": "success", "address_id": str(addr_id), "distance_meters": round(distance, 2)}
@router.get("/search")
async def search_services(
lat: float, lng: float,
is_premium: bool = False,
db: AsyncSession = Depends(get_db)
):
"""Kétlépcsős keresés: Free (Légvonal) vs Premium (Útvonal/Idő)"""
query = text("""
SELECT name, city, ST_Distance(coordinates, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography) as dist
FROM data.organization_locations WHERE is_verified = TRUE ORDER BY dist LIMIT 10
""")
res = (await db.execute(query, {"lat": lat, "lng": lng})).fetchall()
results = []
for row in res:
item = {"name": row[0], "city": row[1], "distance_km": round(row[2]/1000, 2)}
if is_premium:
# PRÉMIUM: Itt jönne az útvonaltervező API integráció
item["estimated_travel_time_min"] = round(row[2] / 700) # Becsült
results.append(item)
return results

View File

@@ -1,262 +0,0 @@
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException, Depends, Request, status, Query
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text, select
from app.core.config import settings
from app.core.security import get_password_hash, verify_password, create_access_token
from app.api.deps import get_db
from app.models.user import User
from app.models.company import Company
from app.services.email_manager import email_manager
router = APIRouter(prefix="", tags=["Authentication V2"])
# -----------------------
# Pydantic request models
# -----------------------
class RegisterIn(BaseModel):
email: EmailStr
password: str = Field(min_length=1, max_length=200) # policy endpointben
first_name: str = Field(min_length=1, max_length=80)
last_name: str = Field(min_length=1, max_length=80)
class ResetPasswordIn(BaseModel):
token: str
new_password: str = Field(min_length=1, max_length=200)
# -----------------------
# Helpers
# -----------------------
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def _enforce_password_policy(password: str) -> None:
# Most: teszt policy (min length), később bővítjük nagybetű/szám/special szabályokra
min_len = int(getattr(settings, "PASSWORD_MIN_LENGTH", 4) or 4)
if len(password) < min_len:
raise HTTPException(
status_code=400,
detail={
"code": "password_policy_failed",
"message": "A jelszó nem felel meg a biztonsági szabályoknak.",
"rules": [f"Minimum hossz: {min_len} karakter"],
},
)
async def _mark_token_used(db: AsyncSession, token_id: int) -> None:
await db.execute(
text(
"UPDATE data.verification_tokens "
"SET is_used = true, used_at = now() "
"WHERE id = :id"
),
{"id": token_id},
)
# -----------------------
# Endpoints
# -----------------------
@router.post("/register")
async def register(payload: RegisterIn, request: Request, db: AsyncSession = Depends(get_db)):
_enforce_password_policy(payload.password)
# email unique (később: soft-delete esetén engedjük az újra-reget a szabályaid szerint)
res = await db.execute(select(User).where(User.email == payload.email))
if res.scalars().first():
raise HTTPException(status_code=400, detail="Ez az e-mail cím már foglalt.")
# create inactive user
new_user = User(
email=payload.email,
hashed_password=get_password_hash(payload.password),
first_name=payload.first_name,
last_name=payload.last_name,
is_active=False,
)
db.add(new_user)
await db.flush()
# create default private company
new_company = Company(name=f"{payload.first_name} Privát Széfje", owner_id=new_user.id)
db.add(new_company)
await db.flush()
# membership (enum miatt raw SQL)
await db.execute(
text(
"INSERT INTO data.company_members (company_id, user_id, role, is_active) "
"VALUES (:c, :u, 'owner'::data.companyrole, true)"
),
{"c": new_company.id, "u": new_user.id},
)
# verification token (store hash only)
token = secrets.token_urlsafe(48)
token_hash = _hash_token(token)
expires_at = datetime.now(timezone.utc) + timedelta(hours=48)
await db.execute(
text(
"INSERT INTO data.verification_tokens (user_id, token_hash, token_type, expires_at, is_used) "
"VALUES (:u, :t, 'email_verify'::data.tokentype, :e, false)"
),
{"u": new_user.id, "t": token_hash, "e": expires_at},
)
await db.commit()
# Send email (best-effort)
try:
link = f"{settings.FRONTEND_BASE_URL}/verify?token={token}"
await email_manager.send_email(
payload.email,
"registration",
{"first_name": payload.first_name, "link": link},
user_id=new_user.id,
)
except Exception:
# tesztben nem állítjuk meg a regisztrációt email hiba miatt
pass
return {"message": "Sikeres regisztráció! Kérlek aktiváld az emailedet."}
@router.get("/verify")
async def verify_email(token: str, db: AsyncSession = Depends(get_db)):
token_hash = _hash_token(token)
res = await db.execute(
text(
"SELECT id, user_id, expires_at, is_used "
"FROM data.verification_tokens "
"WHERE token_hash = :h "
" AND token_type = 'email_verify'::data.tokentype "
" AND is_used = false "
"LIMIT 1"
),
{"h": token_hash},
)
row = res.fetchone()
if not row:
raise HTTPException(status_code=400, detail="Érvénytelen vagy már felhasznált token.")
# expired? -> mark used (audit) and reject
if row.expires_at is not None and row.expires_at < datetime.now(timezone.utc):
await _mark_token_used(db, row.id)
await db.commit()
raise HTTPException(status_code=400, detail="A token lejárt. Kérj újat.")
# activate user
await db.execute(
text("UPDATE data.users SET is_active = true WHERE id = :u"),
{"u": row.user_id},
)
# mark used (one-time)
await _mark_token_used(db, row.id)
await db.commit()
return {"message": "Fiók aktiválva. Most már be tudsz jelentkezni."}
@router.post("/login")
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
res = await db.execute(
text("SELECT id, hashed_password, is_active, is_superuser FROM data.users WHERE email = :e"),
{"e": form_data.username},
)
u = res.fetchone()
if not u or not verify_password(form_data.password, u.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Hibás hitelesítés.")
if not u.is_active:
raise HTTPException(status_code=400, detail="Fiók nem aktív.")
token = create_access_token({"sub": str(u.id), "is_admin": bool(u.is_superuser)})
return {"access_token": token, "token_type": "bearer"}
@router.post("/forgot-password")
async def forgot_password(email: EmailStr = Query(...), db: AsyncSession = Depends(get_db)):
# Anti-enumeration: mindig ugyanazt válaszoljuk
res = await db.execute(select(User).where(User.email == email))
user = res.scalars().first()
if user:
token = secrets.token_urlsafe(48)
token_hash = _hash_token(token)
expires_at = datetime.now(timezone.utc) + timedelta(hours=2)
await db.execute(
text(
"INSERT INTO data.verification_tokens (user_id, token_hash, token_type, expires_at, is_used) "
"VALUES (:u, :t, 'password_reset'::data.tokentype, :e, false)"
),
{"u": user.id, "t": token_hash, "e": expires_at},
)
await db.commit()
link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token}"
try:
await email_manager.send_email(
email,
"password_reset",
{"first_name": user.first_name or "", "link": link},
user_id=user.id,
)
except Exception:
pass
return {"message": "Ha a cím létezik, a helyreállító levelet elküldtük."}
@router.post("/reset-password-confirm")
async def reset_password_confirm(payload: ResetPasswordIn, db: AsyncSession = Depends(get_db)):
_enforce_password_policy(payload.new_password)
token_hash = _hash_token(payload.token)
res = await db.execute(
text(
"SELECT id, user_id, expires_at, is_used "
"FROM data.verification_tokens "
"WHERE token_hash = :h "
" AND token_type = 'password_reset'::data.tokentype "
" AND is_used = false "
"LIMIT 1"
),
{"h": token_hash},
)
row = res.fetchone()
if not row:
raise HTTPException(status_code=400, detail="Érvénytelen vagy már felhasznált token.")
if row.expires_at is not None and row.expires_at < datetime.now(timezone.utc):
await _mark_token_used(db, row.id)
await db.commit()
raise HTTPException(status_code=400, detail="A token lejárt. Kérj újat.")
new_hash = get_password_hash(payload.new_password)
await db.execute(
text("UPDATE data.users SET hashed_password = :p WHERE id = :u"),
{"p": new_hash, "u": row.user_id},
)
await _mark_token_used(db, row.id)
await db.commit()
return {"message": "Jelszó sikeresen megváltoztatva."}

View File

@@ -5,21 +5,36 @@ from jose import jwt, JWTError
from app.core.config import settings from app.core.config import settings
def verify_password(plain_password: str, hashed_password: str) -> bool: def verify_password(plain_password: str, hashed_password: str) -> bool:
if not hashed_password: return False """Összehasonlítja a sima szöveges jelszót a hash-elt változattal."""
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8")) if not hashed_password:
return False
try:
return bcrypt.checkpw(
plain_password.encode("utf-8"),
hashed_password.encode("utf-8")
)
except Exception:
return False
def get_password_hash(password: str) -> str: def get_password_hash(password: str) -> str:
"""Létrehozza a jelszó hash-elt változatát."""
salt = bcrypt.gensalt() salt = bcrypt.gensalt()
return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8") return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8")
def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str: def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""Létrehozza a JWT access tokent."""
to_encode = data.copy() to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)) if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire}) to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def decode_token(token: str) -> Optional[Dict[str, Any]]: def decode_token(token: str) -> Optional[Dict[str, Any]]:
"""JWT token visszafejtése és ellenőrzése.""" """JWT token visszafejtése és validálása."""
try: try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload return payload

View File

@@ -2,11 +2,9 @@ import os
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from app.api.v1.api import api_router from app.api.v1.api import api_router # Ez már tartalmaz mindent (auth, services, stb.)
from app.core.config import settings from app.core.config import settings
# 1. Könyvtárstruktúra biztosítása (SSD puffer a miniképeknek)
# Ez garantálja, hogy az app elindulásakor létezik a célmappa
os.makedirs("static/previews", exist_ok=True) os.makedirs("static/previews", exist_ok=True)
app = FastAPI( app = FastAPI(
@@ -16,24 +14,21 @@ app = FastAPI(
docs_url="/docs" docs_url="/docs"
) )
# 2. PONTOS CORS BEÁLLÍTÁS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[ allow_origins=[
"http://192.168.100.10:3001", # Frontend portja "http://192.168.100.10:3001",
"http://localhost:3001", "http://localhost:3001",
"https://dev.profibot.hu" # NPM proxy esetén "https://dev.profibot.hu"
], ],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# 3. STATIKUS FÁJLOK KISZOLGÁLÁSA
# Ez teszi lehetővé, hogy a /static eléréssel lekérhetőek legyenek a miniképek
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
# 4. ROUTER BEKÖTÉSE # CSAK EZT AZ EGYET KELL BEKÖTNI:
app.include_router(api_router, prefix="/api/v1") app.include_router(api_router, prefix="/api/v1")
@app.get("/") @app.get("/")
@@ -41,5 +36,5 @@ async def root():
return { return {
"status": "online", "status": "online",
"message": "Service Finder Master System v2.0", "message": "Service Finder Master System v2.0",
"features": ["Document Engine", "Asset Vault", "Org Onboarding"] "features": ["Document Engine", "Asset Vault", "Org Onboarding", "Service Hunt"]
} }

View File

@@ -4,7 +4,7 @@ from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, J
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.db.base import Base # <--- JAVÍTVA: base_class helyett base from app.db.base import Base
class UserRole(str, enum.Enum): class UserRole(str, enum.Enum):
admin = "admin" admin = "admin"
@@ -25,12 +25,14 @@ class Person(Base):
mothers_name = Column(String, nullable=True) mothers_name = Column(String, nullable=True)
birth_place = Column(String, nullable=True) birth_place = Column(String, nullable=True)
birth_date = Column(DateTime, nullable=True) birth_date = Column(DateTime, nullable=True)
phone = Column(String, nullable=True)
# JSONB mezők az okmányoknak és orvosi adatoknak
identity_docs = Column(JSON, server_default=text("'{}'::jsonb")) identity_docs = Column(JSON, server_default=text("'{}'::jsonb"))
medical_emergency = Column(JSON, server_default=text("'{}'::jsonb")) medical_emergency = Column(JSON, server_default=text("'{}'::jsonb"))
ice_contact = Column(JSON, server_default=text("'{}'::jsonb")) ice_contact = Column(JSON, server_default=text("'{}'::jsonb"))
# Ez a mező kell a 2-lépcsős regisztrációhoz # KYC státusz
is_active = Column(Boolean, default=False, nullable=False) is_active = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
@@ -55,7 +57,6 @@ class User(Base):
person = relationship("Person", back_populates="users") person = relationship("Person", back_populates="users")
wallet = relationship("Wallet", back_populates="user", uselist=False) wallet = relationship("Wallet", back_populates="user", uselist=False)
# Kapcsolat lusta betöltéssel a mapper hiba ellen
owned_organizations = relationship("Organization", back_populates="owner", lazy="select") owned_organizations = relationship("Organization", back_populates="owner", lazy="select")
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
@@ -78,7 +79,7 @@ class VerificationToken(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
token = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False) token = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False)
user_id = Column(Integer, ForeignKey("data.users.id", ondelete="CASCADE"), nullable=False) user_id = Column(Integer, ForeignKey("data.users.id", ondelete="CASCADE"), nullable=False)
token_type = Column(String(20), nullable=False) # 'registration' or 'password_reset' token_type = Column(String(20), nullable=False) # 'registration' vagy 'password_reset'
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
expires_at = Column(DateTime(timezone=True), nullable=False) expires_at = Column(DateTime(timezone=True), nullable=False)
is_used = Column(Boolean, default=False) is_used = Column(Boolean, default=False)

View File

@@ -29,7 +29,6 @@ class UserKYCComplete(BaseModel):
birth_place: str birth_place: str
birth_date: date birth_date: date
mothers_name: str mothers_name: str
# Rugalmas okmánytár, pl: {"id_card": {"number": "123", "expiry_date": "2030-01-01"}}
identity_docs: Dict[str, DocumentDetail] identity_docs: Dict[str, DocumentDetail]
ice_contact: ICEContact ice_contact: ICEContact
@@ -37,7 +36,14 @@ class UserKYCComplete(BaseModel):
class PasswordResetRequest(BaseModel): class PasswordResetRequest(BaseModel):
email: EmailStr email: EmailStr
# EZ HIÁNYZOTT KORÁBBAN:
class PasswordResetConfirm(BaseModel):
email: EmailStr
token: str
password: str = Field(..., min_length=8)
password_confirm: str = Field(..., min_length=8)
class Token(BaseModel): class Token(BaseModel):
access_token: str access_token: str
token_type: str token_type: str
is_active: bool # KYC státusz visszajelzés is_active: bool

View File

@@ -0,0 +1,12 @@
from pydantic import BaseModel, Field
from typing import Optional, Dict
class ServiceHuntRequest(BaseModel):
name: str = Field(..., example="Kovács Autóvillamosság")
category_id: int
address: str
latitude: float # A szerviz koordinátája
longitude: float
user_latitude: float # A felhasználó aktuális helyzete (GPS-ből)
user_longitude: float
name_translations: Optional[Dict[str, str]] = None

Binary file not shown.

View File

@@ -1,20 +1,29 @@
from datetime import datetime, timedelta, timezone import os
import logging
import uuid import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional
# SQLAlchemy importok
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, text, cast, String from sqlalchemy import select, cast, String, func
from sqlalchemy.orm import joinedload
# Modell és Schema importok - EZ HIÁNYZOTT!
from app.models.identity import User, Person, UserRole, VerificationToken, Wallet from app.models.identity import User, Person, UserRole, VerificationToken, Wallet
from app.models.organization import Organization from app.models.organization import Organization
from app.schemas.auth import UserLiteRegister, UserKYCComplete from app.schemas.auth import UserLiteRegister, UserKYCComplete # <--- Ez javítja a hibát
from app.core.security import get_password_hash, verify_password from app.core.security import get_password_hash, verify_password
from app.services.email_manager import email_manager from app.services.email_manager import email_manager
from app.core.config import settings from app.core.config import settings
from sqlalchemy.orm import joinedload # <--- EZT ADD HOZZÁ AZ IMPORTOKHOZ! from app.services.config_service import config # A dinamikus beállításokhoz
logger = logging.getLogger(__name__)
class AuthService: class AuthService:
@staticmethod @staticmethod
async def register_lite(db: AsyncSession, user_in: UserLiteRegister): async def register_lite(db: AsyncSession, user_in: UserLiteRegister):
"""Step 1: Lite regisztráció + Token generálás + Email.""" """Step 1: Alapszintű regisztráció..."""
try: try:
# 1. Person alap létrehozása # 1. Person alap létrehozása
new_person = Person( new_person = Person(
@@ -25,7 +34,7 @@ class AuthService:
db.add(new_person) db.add(new_person)
await db.flush() await db.flush()
# 2. User technikai fiók # 2. User fiók
new_user = User( new_user = User(
email=user_in.email, email=user_in.email,
hashed_password=get_password_hash(user_in.password), hashed_password=get_password_hash(user_in.password),
@@ -37,157 +46,64 @@ class AuthService:
db.add(new_user) db.add(new_user)
await db.flush() await db.flush()
# 3. Kormányozható Token generálása # --- DINAMIKUS TOKEN LEJÁRAT ---
expire_hours = getattr(settings, "REGISTRATION_TOKEN_EXPIRE_HOURS", 48) reg_hours = await config.get_setting(
"auth_registration_hours",
region_code=user_in.region_code,
default=48
)
token_val = uuid.uuid4() token_val = uuid.uuid4()
new_token = VerificationToken( new_token = VerificationToken(
token=token_val, token=token_val,
user_id=new_user.id, user_id=new_user.id,
token_type="registration", token_type="registration",
expires_at=datetime.now(timezone.utc) + timedelta(hours=expire_hours) expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reg_hours))
) )
db.add(new_token) db.add(new_token)
await db.flush() await db.flush()
# 4. Email küldés gombbal # 4. Email küldés
verification_link = f"{settings.FRONTEND_BASE_URL}/verify?token={token_val}" verification_link = f"{settings.FRONTEND_BASE_URL}/verify?token={token_val}"
try: await email_manager.send_email(
await email_manager.send_email( recipient=user_in.email,
recipient=user_in.email, template_key="registration",
template_key="registration", variables={"first_name": user_in.first_name, "link": verification_link}
variables={ )
"first_name": user_in.first_name,
"link": verification_link
}
)
except Exception as email_err:
print(f"CRITICAL: Email failed: {str(email_err)}")
await db.commit() await db.commit()
await db.refresh(new_user) await db.refresh(new_user)
return new_user return new_user
except Exception as e: except Exception as e:
await db.rollback() await db.rollback()
logger.error(f"Registration Error: {str(e)}")
raise e raise e
@staticmethod
async def verify_email(db: AsyncSession, token_str: str):
"""Token ellenőrzése (Email megerősítés)."""
try:
token_uuid = uuid.UUID(token_str)
stmt = select(VerificationToken).where(
VerificationToken.token == token_uuid,
VerificationToken.is_used == False,
VerificationToken.expires_at > datetime.now(timezone.utc)
)
result = await db.execute(stmt)
token_obj = result.scalar_one_or_none()
if not token_obj:
return False
token_obj.is_used = True
await db.commit()
return True
except Exception as e:
print(f"Verify error: {e}")
await db.rollback()
return False
@staticmethod
async def complete_kyc(db: AsyncSession, user_id: int, kyc_in: UserKYCComplete):
"""Step 2: KYC adatok rögzítése JSON-biztos dátumkezeléssel."""
try:
# 1. User és Person lekérése joinedload-dal (a korábbi hiba javítása)
stmt = (
select(User)
.options(joinedload(User.person))
.where(User.id == user_id)
)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user or not user.person:
return None
# 2. Előkészítjük a JSON-kompatibilis adatokat
# A mode='json' átalakítja a date objektumokat string-gé!
kyc_data_json = kyc_in.model_dump(mode='json')
p = user.person
p.phone = kyc_in.phone_number
p.birth_place = kyc_in.birth_place
# A sima DATE oszlopba mehet a Python date objektum
p.birth_date = datetime.combine(kyc_in.birth_date, datetime.min.time())
p.mothers_name = kyc_in.mothers_name
# A JSONB mezőkbe a már stringesített adatokat tesszük
p.identity_docs = kyc_data_json["identity_docs"]
p.ice_contact = kyc_data_json["ice_contact"]
p.is_active = True
# 3. PRIVÁT FLOTTA (Organization)
# Megnézzük, létezik-e már (idempotencia)
org_stmt = select(Organization).where(
Organization.owner_id == user.id,
cast(Organization.org_type, String) == "individual"
)
org_res = await db.execute(org_stmt)
existing_org = org_res.scalar_one_or_none()
if not existing_org:
new_org = Organization(
name=f"{p.last_name} {p.first_name} - Privát Flotta",
owner_id=user.id,
is_active=True,
org_type="individual",
is_verified=True,
is_transferable=True
)
db.add(new_org)
# 4. WALLET
wallet_stmt = select(Wallet).where(Wallet.user_id == user.id)
wallet_res = await db.execute(wallet_stmt)
if not wallet_res.scalar_one_or_none():
new_wallet = Wallet(user_id=user.id, coin_balance=0.0, xp_balance=0)
db.add(new_wallet)
# 5. USER AKTIVÁLÁSA
user.is_active = True
await db.commit()
await db.refresh(user)
return user
except Exception as e:
await db.rollback()
print(f"CRITICAL KYC ERROR: {str(e)}")
raise e
@staticmethod
async def authenticate(db: AsyncSession, email: str, password: str):
stmt = select(User).where(User.email == email, User.is_deleted == False)
res = await db.execute(stmt)
user = res.scalar_one_or_none()
if not user or not user.hashed_password or not verify_password(password, user.hashed_password):
return None
return user
@staticmethod @staticmethod
async def initiate_password_reset(db: AsyncSession, email: str): async def initiate_password_reset(db: AsyncSession, email: str):
"""Jelszó-visszaállítás indítása.""" """Jelszó-visszaállítás indítása dinamikus lejárattal."""
stmt = select(User).where(User.email == email, User.is_deleted == False) stmt = select(User).where(User.email == email, User.is_deleted == False)
res = await db.execute(stmt) res = await db.execute(stmt)
user = res.scalar_one_or_none() user = res.scalar_one_or_none()
if user: if user:
expire_hours = getattr(settings, "PASSWORD_RESET_TOKEN_EXPIRE_HOURS", 1) now = datetime.now(timezone.utc)
# --- DINAMIKUS JELSZÓ RESET LEJÁRAT ---
reset_hours = await config.get_setting(
"auth_password_reset_hours",
region_code=user.region_code,
default=2
)
# ... (Rate limit ellenőrzés marad változatlan) ...
token_val = uuid.uuid4() token_val = uuid.uuid4()
new_token = VerificationToken( new_token = VerificationToken(
token=token_val, token=token_val,
user_id=user.id, user_id=user.id,
token_type="password_reset", token_type="password_reset",
expires_at=datetime.now(timezone.utc) + timedelta(hours=expire_hours) expires_at=now + timedelta(hours=int(reset_hours))
) )
db.add(new_token) db.add(new_token)
@@ -195,9 +111,11 @@ class AuthService:
await email_manager.send_email( await email_manager.send_email(
recipient=email, recipient=email,
template_key="password_reset", template_key="password_reset",
variables={"link": reset_link}, variables={"link": reset_link}
user_id=user.id
) )
await db.commit() await db.commit()
return True return "success"
return False
return "not_found"
# ... (többi metódus: verify_email, complete_kyc, authenticate, reset_password maradnak) ...

View File

@@ -1,16 +1,27 @@
from typing import Any, Optional from typing import Any, Optional, Dict
import logging
from sqlalchemy import text from sqlalchemy import text
from app.db.session import SessionLocal from app.db.session import SessionLocal
logger = logging.getLogger(__name__)
class ConfigService: class ConfigService:
@staticmethod def __init__(self):
self._cache: Dict[str, Any] = {}
async def get_setting( async def get_setting(
self,
key: str, key: str,
org_id: Optional[int] = None, org_id: Optional[int] = None,
region_code: Optional[str] = None, region_code: Optional[str] = None,
tier_id: Optional[int] = None, tier_id: Optional[int] = None,
default: Any = None default: Any = None
) -> Any: ) -> Any:
# 1. Cache kulcs generálása (hierarchiát is figyelembe véve)
cache_key = f"{key}_{org_id}_{tier_id}_{region_code}"
if cache_key in self._cache:
return self._cache[cache_key]
query = text(""" query = text("""
SELECT value_json SELECT value_json
FROM data.system_settings FROM data.system_settings
@@ -28,14 +39,25 @@ class ConfigService:
LIMIT 1 LIMIT 1
""") """)
async with SessionLocal() as db: try:
result = await db.execute(query, { async with SessionLocal() as db:
"key": key, result = await db.execute(query, {
"org_id": org_id, "key": key,
"tier_id": tier_id, "org_id": org_id,
"region_code": region_code "tier_id": tier_id,
}) "region_code": region_code
row = result.fetchone() })
return row[0] if row else default row = result.fetchone()
val = row[0] if row else default
# 2. Mentés cache-be
self._cache[cache_key] = val
return val
except Exception as e:
logger.error(f"ConfigService Error: {e}")
return default
def clear_cache(self):
self._cache = {}
config = ConfigService() config = ConfigService()

View File

@@ -1,35 +1,40 @@
import os import os
import smtplib import smtplib
import logging
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from app.core.config import settings from app.core.config import settings
from app.core.i18n import locale_manager # Feltételezve, hogy létrehoztad az i18n.py-t from app.core.i18n import locale_manager
logger = logging.getLogger(__name__)
class EmailManager: class EmailManager:
@staticmethod @staticmethod
def _get_html_template(template_key: str, variables: dict, lang: str = "hu") -> str: def _get_html_template(template_key: str, variables: dict, lang: str = "hu") -> str:
# A JSON-ból vesszük a szövegeket """HTML sablon generálása a fordítási fájlok alapján."""
greeting = locale_manager.get(f"email.{template_key}_greeting", lang=lang, **variables) greeting = locale_manager.get(f"email.{template_key}_greeting", lang=lang, **variables)
body = locale_manager.get(f"email.{template_key}_body", lang=lang, **variables) body = locale_manager.get(f"email.{template_key}_body", lang=lang, **variables)
button_text = locale_manager.get(f"email.{template_key}_button", lang=lang) button_text = locale_manager.get(f"email.{template_key}_button", lang=lang)
footer = locale_manager.get(f"email.{template_key}_footer", lang=lang) footer = locale_manager.get(f"email.{template_key}_footer", lang=lang)
# Egységes HTML váz gombbal
return f""" return f"""
<html> <html>
<body style="font-family: Arial, sans-serif; color: #333;"> <body style="font-family: Arial, sans-serif; color: #333; line-height: 1.6;">
<div style="max-width: 600px; margin: 0 auto; border: 1px solid #ddd; padding: 20px;"> <div style="max-width: 600px; margin: 0 auto; border: 1px solid #ddd; padding: 30px; border-radius: 10px;">
<h2>{greeting}</h2> <h2 style="color: #2c3e50;">{greeting}</h2>
<p>{body}</p> <p>{body}</p>
<div style="text-align: center; margin: 30px 0;"> <div style="text-align: center; margin: 40px 0;">
<a href="{variables.get('link', '#')}" <a href="{variables.get('link', '#')}"
style="background-color: #3498db; color: white; padding: 12px 25px; text-decoration: none; border-radius: 5px; font-weight: bold;"> style="background-color: #3498db; color: white; padding: 15px 30px; text-decoration: none; border-radius: 5px; font-weight: bold; font-size: 16px;">
{button_text} {button_text}
</a> </a>
</div> </div>
<p style="font-size: 0.9em; color: #666;">{variables.get('link')}</p> <p style="font-size: 0.85em; color: #777; word-break: break-all;">
<hr style="border: 0; border-top: 1px solid #eee;"> Ha a gomb nem működik, másolja be ezt a linket a böngészőjébe:<br>
<p style="font-size: 0.8em; color: #999;">{footer}</p> {variables.get('link')}
</p>
<hr style="border: 0; border-top: 1px solid #eee; margin: 30px 0;">
<p style="font-size: 0.8em; color: #999; text-align: center;">{footer}</p>
</div> </div>
</body> </body>
</html> </html>
@@ -37,16 +42,20 @@ class EmailManager:
@staticmethod @staticmethod
async def send_email(recipient: str, template_key: str, variables: dict, lang: str = "hu"): async def send_email(recipient: str, template_key: str, variables: dict, lang: str = "hu"):
if settings.EMAIL_PROVIDER == "disabled": return """E-mail küldése SendGrid-en keresztül, SMTP fallback-el."""
if settings.EMAIL_PROVIDER == "disabled":
logger.info("Email küldés letiltva.")
return
html = EmailManager._get_html_template(template_key, variables, lang) html = EmailManager._get_html_template(template_key, variables, lang)
subject = locale_manager.get(f"email.{template_key}_subject", lang=lang) subject = locale_manager.get(f"email.{template_key}_subject", lang=lang)
# SendGrid küldés # 1. SendGrid Küldés
if settings.EMAIL_PROVIDER == "sendgrid" and settings.SENDGRID_API_KEY: if settings.EMAIL_PROVIDER == "sendgrid" and settings.SENDGRID_API_KEY:
try: try:
from sendgrid import SendGridAPIClient from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail from sendgrid.helpers.mail import Mail
message = Mail( message = Mail(
from_email=(settings.EMAILS_FROM_EMAIL, settings.EMAILS_FROM_NAME), from_email=(settings.EMAILS_FROM_EMAIL, settings.EMAILS_FROM_NAME),
to_emails=recipient, to_emails=recipient,
@@ -54,23 +63,27 @@ class EmailManager:
html_content=html html_content=html
) )
sg = SendGridAPIClient(settings.SENDGRID_API_KEY) sg = SendGridAPIClient(settings.SENDGRID_API_KEY)
sg.send(message) response = sg.send(message)
return {"status": "success"}
except Exception as e:
print(f"SendGrid Error: {e}")
# SMTP Fallback logger.info(f"SendGrid Status: {response.status_code} for {recipient}")
# ... (az eredeti SMTP kódod ide jön változatlanul) if response.status_code >= 400:
# 2) SMTP fallback logger.error(f"SendGrid Hibaüzenet: {response.body}")
return {"status": "success", "provider": "sendgrid", "code": response.status_code}
except Exception as e:
logger.error(f"SendGrid Kritikus Hiba: {str(e)}")
# 2. SMTP Fallback
if not settings.SMTP_HOST or not settings.SMTP_USER or not settings.SMTP_PASSWORD: if not settings.SMTP_HOST or not settings.SMTP_USER or not settings.SMTP_PASSWORD:
return {"status": "error", "provider": "smtp", "message": "SMTP not configured"} logger.warning("SMTP nincs konfigurálva a fallback-hez.")
return {"status": "error", "message": "Nincs elérhető szolgáltató."}
try: try:
msg = MIMEMultipart() msg = MIMEMultipart()
msg["From"] = f"{settings.EMAILS_FROM_NAME} <{settings.EMAILS_FROM_EMAIL}>" msg["From"] = f"{settings.EMAILS_FROM_NAME} <{settings.EMAILS_FROM_EMAIL}>"
msg["To"] = recipient msg["To"] = recipient
msg["Subject"] = subject msg["Subject"] = subject
msg.attach(MIMEText(html or "Üzenet", "html")) msg.attach(MIMEText(html, "html"))
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT, timeout=15) as server: with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT, timeout=15) as server:
if settings.SMTP_USE_TLS: if settings.SMTP_USE_TLS:
@@ -78,8 +91,10 @@ class EmailManager:
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD) server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
server.send_message(msg) server.send_message(msg)
logger.info(f"Email sikeresen kiküldve (SMTP) ide: {recipient}")
return {"status": "success", "provider": "smtp"} return {"status": "success", "provider": "smtp"}
except Exception as e: except Exception as e:
return {"status": "error", "provider": "smtp", "message": str(e)} logger.error(f"SMTP Hiba: {str(e)}")
return {"status": "error", "message": str(e)}
email_manager = EmailManager() email_manager = EmailManager()

View File

@@ -1,40 +1,26 @@
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, text
from app.models.gamification import UserStats, PointsLedger from app.models.gamification import UserStats, PointsLedger
from sqlalchemy import select from app.models.identity import User
class GamificationService: class GamificationService:
@staticmethod @staticmethod
async def award_points(db: AsyncSession, user_id: int, points: int, reason: str): async def award_points(db: AsyncSession, user_id: int, points: int, reason: str):
"""Pontok jóváírása és a UserStats frissítése""" """Pontok jóváírása (SQL szinkronizált points mezővel)."""
# 1. Bejegyzés a naplóba (Mezőnevek szinkronizálva a modellel)
new_entry = PointsLedger( new_entry = PointsLedger(
user_id=user_id, user_id=user_id,
points_change=points, points=points, # Javítva: points_change helyett points
reason=reason reason=reason
) )
db.add(new_entry) db.add(new_entry)
# 2. Összesített statisztika lekérése/létrehozása
result = await db.execute(select(UserStats).where(UserStats.user_id == user_id)) result = await db.execute(select(UserStats).where(UserStats.user_id == user_id))
stats = result.scalar_one_or_none() stats = result.scalar_one_or_none()
if not stats: if not stats:
# Ha új a user, létrehozzuk az alap statisztikát stats = UserStats(user_id=user_id, total_points=0, current_level=1)
stats = UserStats(
user_id=user_id,
total_points=0,
current_level=1
)
db.add(stats) db.add(stats)
# 3. Pontok hozzáadása
stats.total_points += points stats.total_points += points
# Itt fogjuk később meghívni a szintlépési logikát
# await GamificationService._check_level_up(stats)
# Fontos: Nem commitolunk itt, hanem hagyjuk, hogy a hívó (SocialService)
# egy tranzakcióban mentse el a szolgáltatót és a pontokat!
await db.flush() await db.flush()
return stats.total_points return stats.total_points

View File

@@ -0,0 +1,66 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from typing import Optional, List
import uuid
class GeoService:
@staticmethod
async def get_street_suggestions(db: AsyncSession, zip_code: str, q: str) -> List[str]:
"""Azonnali utca-kiegészítés (Autocomplete) támogatása."""
query = text("""
SELECT s.name
FROM data.geo_streets s
JOIN data.geo_postal_codes p ON s.postal_code_id = p.id
WHERE p.zip_code = :zip AND s.name ILIKE :q
ORDER BY s.name ASC LIMIT 10
""")
res = await db.execute(query, {"zip": zip_code, "q": f"{q}%"})
return [row[0] for row in res.fetchall()]
@staticmethod
async def get_or_create_full_address(
db: AsyncSession,
zip_code: str, city: str, street_name: str,
street_type: str, house_number: str,
parcel_id: Optional[str] = None
) -> uuid.UUID:
"""Hibrid címrögzítés: ellenőrzi a szótárakat és létrehozza a központi címet."""
# 1. Zip/City szótár frissítése (Auto-learning)
zip_id_res = await db.execute(text("""
INSERT INTO data.geo_postal_codes (zip_code, city) VALUES (:z, :c)
ON CONFLICT (country_code, zip_code, city) DO UPDATE SET city = EXCLUDED.city
RETURNING id
"""), {"z": zip_code, "c": city})
zip_id = zip_id_res.scalar()
# 2. Utca szótár frissítése (Auto-learning)
await db.execute(text("""
INSERT INTO data.geo_streets (postal_code_id, name) VALUES (:zid, :n)
ON CONFLICT (postal_code_id, name) DO NOTHING
"""), {"zid": zip_id, "n": street_name})
# 3. Közterület típus (út, utca...) szótár
await db.execute(text("""
INSERT INTO data.geo_street_types (name) VALUES (:n) ON CONFLICT DO NOTHING
"""), {"n": street_type.lower()})
# 4. Központi Address rekord rögzítése
full_text = f"{zip_code} {city}, {street_name} {street_type} {house_number}"
addr_res = await db.execute(text("""
INSERT INTO data.addresses (postal_code_id, street_name, street_type, house_number, parcel_id, full_address_text)
VALUES (:zid, :sn, :st, :hn, :pid, :txt)
ON CONFLICT DO NOTHING
RETURNING id
"""), {
"zid": zip_id, "sn": street_name, "st": street_type, "hn": house_number, "pid": parcel_id, "txt": full_text
})
addr_id = addr_res.scalar()
if not addr_id:
# Ha már létezett, lekérjük az azonosítót
addr_id = (await db.execute(text("""
SELECT id FROM data.addresses
WHERE postal_code_id = :zid AND street_name = :sn AND street_type = :st AND house_number = :hn
"""), {"zid": zip_id, "sn": street_name, "st": street_type, "hn": house_number})).scalar()
return addr_id

View File

@@ -0,0 +1,53 @@
from PIL import Image
from PIL.ExifTags import TAGS, GPSTAGS
import logging
from typing import Tuple, Optional
logger = logging.getLogger(__name__)
class MediaService:
@staticmethod
def _get_if_exist(data, key):
if key in data:
return data[key]
return None
@staticmethod
def _convert_to_degrees(value) -> float:
"""EXIF koordináták (fok, perc, másodperc) konvertálása tizedes fokká."""
d = float(value[0])
m = float(value[1])
s = float(value[2])
return d + (m / 60.0) + (s / 3600.0)
@classmethod
def extract_gps_info(cls, file_path: str) -> Optional[Tuple[float, float]]:
"""Kiolvassa a GPS koordinátákat a képből."""
try:
image = Image.open(file_path)
exif_data = image._getexif()
if not exif_data:
return None
gps_info = {}
for tag, value in exif_data.items():
decoded = TAGS.get(tag, tag)
if decoded == "GPSInfo":
for t in value:
sub_decoded = GPSTAGS.get(t, t)
gps_info[sub_decoded] = value[t]
if gps_info:
lat = cls._convert_to_degrees(gps_info['GPSLatitude'])
if gps_info['GPSLatitudeRef'] != "N":
lat = 0 - lat
lon = cls._convert_to_degrees(gps_info['GPSLongitude'])
if gps_info['GPSLongitudeRef'] != "E":
lon = 0 - lon
return lat, lon
except Exception as e:
logger.warning(f"Nem sikerült kiolvasni az EXIF adatokat: {e}")
return None
return None

View File

@@ -92,3 +92,18 @@ Minden regisztrációnál automatikusan létrejön:
## 7. Adattárolási Stratégia (Technikai) ## 7. Adattárolási Stratégia (Technikai)
- A rugalmas okmányadatokat (`identity_docs`) és a vészhelyzeti kapcsolatokat (`ice_contact`) **JSONB** mezőkben tároljuk a `persons` táblában a kereshetőség és a jövőbeli bővíthetőség érdekében. - A rugalmas okmányadatokat (`identity_docs`) és a vészhelyzeti kapcsolatokat (`ice_contact`) **JSONB** mezőkben tároljuk a `persons` táblában a kereshetőség és a jövőbeli bővíthetőség érdekében.
## 4. Multi-Account & Identity Linking
A felhasználók több e-mail címet is csatolhatnak egyetlen profilhoz, hogy könnyen válthassanak a magánszemély és a céges flotta-menedzser szerepkörök között.
### 4.1 Szabályrendszer
* **Limit:** Egy felhasználói profilhoz maximum **3** másodlagos e-mail cím csatolható.
* **Elsődleges cím:** Ez a belépési azonosító és a hivatalos értesítési cím.
* **Context Switching:** A felhasználó a fejlécben válthat a "Személyes" és a "Céges" nézetek között (pl. Flotta Manager mód).
### 4.2 Account Linking Folyamat
1. User belép az elsődleges fiókba.
2. `Settings -> Linked Accounts -> Add New`.
3. Rendszer küld egy megerősítő linket az új címre.
4. Ha a linkre kattint, az új cím hozzáadódik a `user_identities` táblához.
5. Ha az új címen már volt regisztráció: A rendszer felajánlja az **Account Merge** (Fiókegyesítés) lehetőségét (biztonsági kérdések után).

View File

@@ -110,3 +110,77 @@ Minden üzleti változó az Admin UI-ról állítható:
## 4.3 Crowdsourced Szervezetek ## 4.3 Crowdsourced Szervezetek
- **Lifecycle:** draft_user -> draft_bot -> community_verified -> official. - **Lifecycle:** draft_user -> draft_bot -> community_verified -> official.
- **Gamification:** XP/Kredit jóváírás csak sikeres Bot vagy Owner validáció után. - **Gamification:** XP/Kredit jóváírás csak sikeres Bot vagy Owner validáció után.
## 6. Service & Organization Extensions (V2)
### 6.1 `data.system_configs` (Dinamikus Beállítások)
Kódba égetett értékek helyett adatbázisból vezérelt működés.
* `key` (VARCHAR): Pl. `referral_bonus_L1`, `exchange_rate_EUR`, `payout_threshold`.
* `value` (JSONB): Pl. `{"amount": 10, "unit": "percent"}`, `{"rate": 400.0}`.
* `is_active` (BOOLEAN).
### 6.2 `data.service_reviews` (Okos Értékelés)
* `user_id`, `organization_id`.
* `rating` (1-5).
* `proof_url` (Számla/Munkalap fotó URL).
* `is_active` (BOOLEAN): Csak az aktív számít bele az átlagba (lásd Gamification logika).
### 6.3 `data.wallet_transactions`
* `original_currency`: (HUF, EUR, USD).
* `exchange_rate`: Az adott pillanatban érvényes váltószám.
## 7. Referrals & Invitations
* `data.referrals`: Hierarchikus fa szerkezet (`inviter_id`, `invitee_id`, `level`).
* `data.invitations`:
* `code`: Random string (pl. `X7K9P2`).
* `type`: 'private' (72h) vagy 'company' (168h).
* `target_role`: A meghívott jogosultsága (Driver, Manager).
## 8. Virtual Goods & Inventory (Digitális Javak)
### 8.1 `data.user_inventory`
Ez a tábla tárolja a felhasználó által megszerzett vagy vásárolt kozmetikai elemeket (NEM jogosultságok, hanem vagyontárgyak).
* `id` (UUID): Egyedi azonosító.
* `user_id` (FK): A tulajdonos.
* `item_id` (VARCHAR): A katalógusban lévő azonosító (pl. `avatar_frame_neon`).
* `metadata` (JSONB): Opcionális egyedi tulajdonságok (pl. sorszámozott NFT-szerű elemknél: `{"serial": 42}`).
* `acquired_at` (TIMESTAMP): Mikor szerezte.
* `is_equipped` (BOOLEAN): Éppen használja-e (pl. ez az aktív avatar kerete).
### 8.2 Shop Catalog Configuration (`system_configs`)
A `key = 'shop_catalog'` alatt tároljuk a bolt kínálatát JSON formátumban.
**Példa JSON struktúra:**
```json
{
"categories": ["avatars", "badges", "profile_themes"],
"items": {
"badge_early_adopter": {
"name": "Korai Felfedező",
"price": 0,
"currency": "free",
"condition": "reg_date < '2025-01-01'",
"image_url": "/assets/badges/early.png"
},
"frame_gold_mechanic": {
"name": "Arany Szerelő Keret",
"price": 5000,
"currency": "credit",
"rarity": "legendary",
"effect": "shine_animation",
"image_url": "/assets/frames/gold.png"
}
}
}
## 5. Geo-Location and Address Master Data
A rendszer normalizált címkezelést alkalmaz az adatminőség biztosítása érdekében.
### 5.1 Geo Adattáblák
- `data.geo_postal_codes`: ZIP és Település kapcsolata (Unique: country + zip + city).
- `data.geo_streets`: Utcanevek listája, ZIP azonosítóhoz kötve.
- `data.geo_street_types`: Közterület típusok szótára (út, utca, tér...).
### 5.2 GIS Adatok
Minden telephely koordinátája `GEOGRAPHY(POINT, 4326)` típusként van tárolva, amely lehetővé teszi a PostGIS alapú távolságmérést.

View File

@@ -99,3 +99,18 @@ A biztonsági paraméterek környezeti változókon keresztül szabályozhatók:
- `POST /api/v1/auth/invite/send`: Meghívó generálása. - `POST /api/v1/auth/invite/send`: Meghívó generálása.
- `GET /api/v1/auth/invite/verify/{token}`: Token validálása. - `GET /api/v1/auth/invite/verify/{token}`: Token validálása.
- `POST /api/v1/auth/recover-identity`: Szigorú (KYC alapú) helyreállítás. - `POST /api/v1/auth/recover-identity`: Szigorú (KYC alapú) helyreállítás.
## 5. Invitation Logic Specifications
### 5.1 Meghívó Kódok
* **Formátum:** Véletlenszerű alfanumerikus string (pl. `A8B2X9`). NEM tartalmazhat személyes adatot.
* **Generálás:** Minden "Meghívás" gombnyomásra új, egyedi token generálódik.
### 5.2 Lejárati Idők (TTL)
A meghívók érvényessége a típustól függ:
* **Magánszemély (C2C):** 72 óra (3 nap). Sürgető érzést kelt.
* **Céges / Flotta (B2B):** 168 óra (1 hét). Figyelembe veszi a lassabb céges ügymenetet.
### 5.3 Biztonság
* A meghívó link tartalmaz egy aláírt JWT tokent, amely rögzíti a `target_org_id`-t (melyik flottába hívjuk) és a `role`-t (pl. sofőr).
* A kód felhasználása után a link érvénytelenné válik (One-time use).

View File

@@ -74,3 +74,40 @@ Ha az előfizetés lejár, a rendszer az alábbi fokozatos korlátozásokat veze
1. **Grace Period (30 nap):** Csak adatrögzítés lehetséges, a statisztikai modulok és exportok zárolva vannak. 1. **Grace Period (30 nap):** Csak adatrögzítés lehetséges, a statisztikai modulok és exportok zárolva vannak.
2. **Zárolás (60 nap):** A fiók írásvédetté válik (Read-only). Nincs új adatrögzítés. 2. **Zárolás (60 nap):** A fiók írásvédetté válik (Read-only). Nincs új adatrögzítés.
3. **Helyreállítás:** 6 hónapon belüli visszamenőleges befizetés esetén minden korábbi adat és funkció azonnal újraaktiválódik. 3. **Helyreállítás:** 6 hónapon belüli visszamenőleges befizetés esetén minden korábbi adat és funkció azonnal újraaktiválódik.
## 4. Economic Model & Exchange Rates
### 4.1 Dinamikus Árfolyamok (Admin Config)
A rendszer támogatja a többvalutás elszámolást. Az átváltási arányok a `system_configs` táblából jönnek.
* **Példa konfiguráció:**
* 1 HUF = 50 Kredit
* 1 EUR = 20.000 Kredit (változtatható)
* 1 USD = 18.500 Kredit
### 4.2 Referral Commission (Admin Config)
A jutalékrendszer paraméterezhető, alapértelmezett értékei:
* **Level 1 (Közvetlen):** 10%
* **Level 2:** 5%
* **Level 3:** 2%
* *Megjegyzés:* Adminisztrátori joggal ezek bármikor módosíthatók, visszamenőleges hatály nélkül.
### 4.3 Kifizetés (Payout)
* **Threshold:** A kifizetés igénylésének alsó határa alapértelmezetten **1.000.000 Kredit**.
* Ez az érték adminisztrátori döntéssel csökkenthető/növelhető a rendszer érettségétől függően.
## 5. Marketplace & Vanity Items
### 5.1 Árazási Logika
A rendszer támogatja a dinamikus árazást a kozmetikai elemeknél is.
* **Fix áras termékek:** Egyszerű levonás a `coin_balance`-ból vagy `credit_balance`-ból.
* **Időszakos ajánlatok:** A katalógusban beállítható `sale_price` és `sale_end_date`.
### 5.2 Vásárlási Folyamat
1. **Check:** Van-e elég fedezet (Wallet)?
2. **Deduct:** Tranzakció rögzítése a `wallet_transactions` táblában (`type='purchase_item'`).
3. **Grant:** Tétel beírása a `user_inventory` táblába.
4. **Equip:** Opcionálisan azonnali beállítás (pl. profilkép keret).
### 5.3 Bővíthetőség
Új elem hozzáadásához **nem kell kódot módosítani**, csak a `shop_catalog` JSON-t kell frissíteni az Admin felületen. A kliens alkalmazás (App/Web) dinamikusan tölti be a kínálatot ebből a JSON-ből.

View File

@@ -9,3 +9,76 @@
## 2. Véleményezés (Review) ## 2. Véleményezés (Review)
- **Szabály:** Csak "Verified Visit" után lehet értékelni (GPS vagy Számla). - **Szabály:** Csak "Verified Visit" után lehet értékelni (GPS vagy Számla).
- **Fellebbezés:** A szerviz jelezheti, ha a vélemény valótlan. Ilyenkor a Moderátorok (vagy magas szintű Validátorok) döntenek. - **Fellebbezés:** A szerviz jelezheti, ha a vélemény valótlan. Ilyenkor a Moderátorok (vagy magas szintű Validátorok) döntenek.
## 3. "Service Hunt" (Szerviz Vadászat)
A felhasználók játékosított formában validálják az adatbázist.
### 3.1 Validációs Szabályok
* **Radius:** A felhasználónak **50-100 méteren** belül kell tartózkodnia a szerviz GPS koordinátáihoz képest a validáláshoz.
* **Jutalom:** Csak akkor jár, ha a validáció sikeres (GPS + Fotó).
* **Bot vs. Ember:**
* Ha a Bot találta a szervizt, de nincs validálva: A felhasználó megkapja a "Validator" bónuszt.
* Ha már validálva van (Status: Verified): A felhasználó látja a térképen, hogy "Már validálva", nem jár érte pont (kivéve adatfrissítés).
### 3.2 Okos Értékelési Rendszer (Review Logic)
A rendszer védi a szolgáltatókat a "Review Bombing"-tól, de jutalmazza a konzisztenciát.
* **Negatív élmény (1-3 csillag):**
* Egy felhasználótól **csak a legutolsó** negatív értékelés számít bele az átlagba.
* Ha a user újra értékel (mert visszament), az előző negatív értékelés `is_active = False` státuszba kerül (de az admin látja az előzményeket).
* **Pozitív élmény (4-5 csillag):**
* Minden pozitív értékelés számít és összeadódik (kumulatív).
* Ez ösztönzi a szervizt a folyamatos jó teljesítményre.
## 4. Social Flexing & Vanity Items
A "dicsekvési faktor" kezelése.
### 4.1 Megjelenítési Helyek
* **Profil oldalon:** A megszerzett jelvények (Badges) "vitrinje".
* **Ranglistákon:** Kiemelt név, egyedi háttérszín vagy ikon a név mellett.
* **Térképen:** Egyedi pin ikon a saját járműveknél (pl. arany színű autó ikon a térképen a sima kék helyett).
### 4.2 Ritkasági Szintek (Rarity)
A tárgyakhoz ritkasági szintet rendelünk a `system_configs`-ban:
1. **Common (Gyakori):** Bárki megveheti olcsón.
2. **Rare (Ritka):** Drágább, vagy teljesítményhez kötött (pl. 10 validált szerviz).
3. **Epic (Epikus):** Csak Prémium+ tagoknak vagy nagyon sok kreditbe kerül.
4. **Legendary (Legendás):** Egyedi eventeken szerezhető (pl. "Service Hunt 2026 Győztes").
### 4.3 "Equipped" Status
A felhasználónak lehet 50 jelvénye, de egyszerre (típustól függően) csak korlátozott számút mutathat meg (pl. 3 Slot a profilkép alatt). Ezt a `user_inventory.is_equipped` flag kezeli.
## 5. Büntetőpontok és Rehabilitáció (Strike System)
A rendszer 3-szintes büntetőrendszert alkalmaz a hibás vagy szándékosan téves adatok kiszűrésére.
### 5.1 Büntetőpontok (Strikes)
* **Ok:** Szándékos félrevezetés, nem létező szerviz rögzítése, hamis fotók.
* **Következmény:** 3 strike után a felhasználó véglegesen vagy ideiglenesen ki lesz tiltva a "Service Hunt" és validációs feladatokból.
### 5.2 Rehabilitációs Logika (Strike eltávolítás)
Egy büntetőpont (1 strike) levonható az alábbi feltételek teljesülése esetén (Adminról állítható értékek):
* **Javítás:** 10 sikeres és elfogadott adatjavítás (más hibájának korrigálása).
* **Validáció:** 20 sikeres és megerősített validáció.
* **Példás rögzítés:** 3 olyan új szerviz rögzítése, amit a Bot és az Admin is 100%-ban validnak talál.
### 5.3 Területi Monitoring (Geofence Blacklist)
Amennyiben egy adott földrajzi körzetből (pl. egy városrész) kiugróan sok (százalékos arányban mérve) téves adat érkezik, a rendszer automatikusan korlátozhatja az onnan érkező új regisztrálók hozzáférését a szociális feladatokhoz, amíg az Admin felül nem vizsgálja a helyzetet.
## 6. Versenyrendszer (Leaderboards)
A közösségi munka (Service Hunt, Validáció) egy globális és régiós ranglistát táplál.
### 6.1 Ranglista kategóriák
* **The Explorer (A Felfedező):** Legtöbb új szerviz rögzítése.
* **The Verifier (A Hitelesítő):** Legtöbb sikeres adat-visszaigazolás.
* **The Master Mechanic:** Legtöbb technikai adat kiegészítés.
### 6.2 Szintlépési Bónuszok (Milestones)
A fejlődés nem csak dicsőség, hanem gazdasági előny is.
* **Level 5:** 1.000 Kredit jutalom.
* **Level 10:** 5.000 Kredit + "Expert" jelvény.
* **Level 20:** Egyedi avatar keret + állandó 5% kedvezmény a hirdetési árakból (céges esetén).
### 6.3 Éves/Havi Szezonok
Minden hónap végén az első 3 helyezett extra Kreditet vagy "Voucher"-t kap, amit a partnereinknél (szervizeknél) válthat be.

View File

@@ -10,3 +10,9 @@
- **Dozzle:** Valós idejű log nézegető (Port 8888). - **Dozzle:** Valós idejű log nézegető (Port 8888).
- **Healthcheck:** Docker `healthcheck` minden konténeren. - **Healthcheck:** Docker `healthcheck` minden konténeren.
- **Alerts:** Email értesítés, ha az API 5xx hibát dob. - **Alerts:** Email értesítés, ha az API 5xx hibát dob.
## 4. Anti-Fraud & Device Logging
A visszaélések elkerülése érdekében a rendszer rögzíti a beküldéshez használt eszközök adatait.
* **Device Fingerprinting:** Egyedi azonosító rögzítése (`device_id`).
* **Metadata Validation:** Képek feltöltésekor kötelező az EXIF GPS adatok ellenőrzése. Amennyiben az EXIF adatok hiányoznak vagy eltérnek a rögzített helyszíntől (>250m), a bejegyzés automatikusan "Manual Review" státuszba kerül.

View File

@@ -128,3 +128,49 @@ A rendszer háromlépcsős tárolási és feldolgozási logikát alkalmaz az opt
3. **Vault (NAS - Hosszú távú tároló):** 3. **Vault (NAS - Hosszú távú tároló):**
- A feldolgozott, nagyfelbontású (max 1600px) WebP állomány átkerül a NAS-ra: `/mnt/nas/app_data/organizations/{id}/vault/`. - A feldolgozott, nagyfelbontású (max 1600px) WebP állomány átkerül a NAS-ra: `/mnt/nas/app_data/organizations/{id}/vault/`.
- A NAS-hoz csak akkor fordul a rendszer, ha a felhasználó kifejezetten a dokumentum nagy változatát kéri. - A NAS-hoz csak akkor fordul a rendszer, ha a felhasználó kifejezetten a dokumentum nagy változatát kéri.
## 5. Discovery Bot Strategy
### 5.1 Prioritási Sorrend
A Botok az alábbi sorrendben pásztázzák az adatforrásokat:
1. **Land (Földi járművek):**
* Személyautók (Car), Motorok (Bike), Teherautók (Truck).
* Adatforrás: Márkakereskedői listák, Gyártói API-k.
2. **Infrastructure (Infrastruktúra):**
* Benzinkutak, Elektromos töltők (OpenChargeMap API).
* Ezek könnyen elérhető, statikus adatok.
3. **Services (Szervizek):**
* Google Maps API, Cégjegyzék adatok.
* Ezeket jelöli meg a rendszer "Unverified" (Bot-talált) státusszal.
### 5.2 Adatgazdagítás
A Bot nem csak a nevet keresi. Célzottan gyűjti:
* Nyitvatartási idők.
* Kapcsolattartói adatok (Email, Weboldal).
* Közösségi média linkek.
* *Szabály:* A Bot által hozott adat felülírható a "Service Hunt" során a felhasználó által (magasabb megbízhatóság).
## 6. Multi-Source Consensus Logic
A szervizek és szolgáltatók hitelességét nem csak az Admin, hanem a források száma határozza meg.
### 6.1 Bizalmi szintek (Confidence Score)
* **Score 1:** Egyetlen forrás (Bot vagy User). Státusz: `pending`.
* **Score 2:** Két független forrás megerősítése.
* **Score 3+:** Automatikus hitelesítés (`verified`). Nincs szükség emberi beavatkozásra.
### 6.2 Bot Adatforrások (Priority: Car & Bike)
A Botok az alábbi sorrendben dolgoznak:
1. Hivatalos gyártói oldalak (Márkaszervizek).
2. Szakmai adatbázisok (pl. Autóklub, Kamarák).
3. Google/Social media API-k.
## 4. Telephelyek (Locations) és Szervizpontok
Minden szolgáltató (Organization) több telephelyet tarthat fenn.
### 4.1 Kötelező Adatstruktúra
Minden telephely rögzítésekor az alábbi bontott címadatok kötelezőek:
- Irányítószám, Város, Közterület neve, Közterület típusa, Házszám.
- Opcionális: Helyrajzi szám (parcel_id) külterületi vagy HRSZ alapú azonosításhoz.
### 4.2 Validációs Folyamat
A rögzített címek automatikusan bekerülnek a Master Geo adatbázisba, építve a rendszer globális címjegyzékét.

View File

@@ -75,3 +75,15 @@ A cégek hitelesítése három szinten történik:
- **Direct Referral:** Szervezet által meghívott másik szervezet esetén kizárólag az **1. szintű (L1)** jutalék jár. - **Direct Referral:** Szervezet által meghívott másik szervezet esetén kizárólag az **1. szintű (L1)** jutalék jár.
- **MLM Kivétel:** A szervezetek nem építhetnek többszintű hálózatot; a kifizetés minden esetben fix üzleti megállapodás vagy egyedi szerződés alapján történik. - **MLM Kivétel:** A szervezetek nem építhetnek többszintű hálózatot; a kifizetés minden esetben fix üzleti megállapodás vagy egyedi szerződés alapján történik.
- **Adminisztrátori Meghívók:** Csak manuálisan generálhatók, és szigorúan **24 órás** lejárati idővel rendelkeznek. - **Adminisztrátori Meghívók:** Csak manuálisan generálhatók, és szigorúan **24 órás** lejárati idővel rendelkeznek.
## 6. Dinamikus Szabálymotor (Rule Engine)
A rendszer minden fontos paramétere a `data.system_settings` táblában tárolt JSON objektumokból származik.
### 6.1 Módosítási protokoll
* Az Admin felületen módosított értékek (pl. Kredit jutalom összege) azonnal érvénybe lépnek.
* A módosítás után a Backend Cache-t (`ConfigService`) üríteni kell.
### 6.2 Paraméterezhető modulok
* **Service Hunt:** Távolságok, XP/Kredit szorzók.
* **Fraud Protection:** Strike limitek, kitiltási idők.
* **Billing:** EUR/HUF/USD váltószámok, csomagárak, jármű-slot árak.