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:
2
.env
2
.env
@@ -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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
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
Binary file not shown.
@@ -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"])
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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."}
|
||||||
86
backend/app/api/v1/endpoints/services.py
Normal file
86
backend/app/api/v1/endpoints/services.py
Normal 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
|
||||||
Binary file not shown.
@@ -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."}
|
|
||||||
Binary file not shown.
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
BIN
backend/app/models/__pycache__/gamification.cpython-312.pyc
Normal file
BIN
backend/app/models/__pycache__/gamification.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||||
Binary file not shown.
@@ -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
|
||||||
12
backend/app/schemas/service_hunt.py
Normal file
12
backend/app/schemas/service_hunt.py
Normal 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.
BIN
backend/app/services/__pycache__/config_service.cpython-312.pyc
Executable file → Normal file
BIN
backend/app/services/__pycache__/config_service.cpython-312.pyc
Executable file → Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend/app/services/__pycache__/geo_service.cpython-312.pyc
Normal file
BIN
backend/app/services/__pycache__/geo_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/services/__pycache__/media_service.cpython-312.pyc
Normal file
BIN
backend/app/services/__pycache__/media_service.cpython-312.pyc
Normal file
Binary file not shown.
@@ -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={
|
variables={"first_name": user_in.first_name, "link": verification_link}
|
||||||
"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) ...
|
||||||
@@ -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,6 +39,7 @@ class ConfigService:
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
try:
|
||||||
async with SessionLocal() as db:
|
async with SessionLocal() as db:
|
||||||
result = await db.execute(query, {
|
result = await db.execute(query, {
|
||||||
"key": key,
|
"key": key,
|
||||||
@@ -36,6 +48,16 @@ class ConfigService:
|
|||||||
"region_code": region_code
|
"region_code": region_code
|
||||||
})
|
})
|
||||||
row = result.fetchone()
|
row = result.fetchone()
|
||||||
return row[0] if row else default
|
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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
66
backend/app/services/geo_service.py
Normal file
66
backend/app/services/geo_service.py
Normal 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
|
||||||
53
backend/app/services/media_service.py
Normal file
53
backend/app/services/media_service.py
Normal 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
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user