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

View File

@@ -1,11 +1,14 @@
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()
# Hitelesítés
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
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
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"])

View File

@@ -1,48 +1,75 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from sqlalchemy import select
from app.db.session import get_db
from app.services.auth_service import AuthService
from app.core.security import create_access_token
from app.schemas.auth import UserLiteRegister, Token, PasswordResetRequest, UserKYCComplete
from app.api.deps import get_current_user # Ez kezeli a belépett felhasználót
from app.schemas.auth import (
UserLiteRegister, Token, PasswordResetRequest,
UserKYCComplete, PasswordResetConfirm
)
from app.api.deps import get_current_user
from app.models.identity import User
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)):
"""Step 1: Alapszintű regisztráció és aktiváló e-mail küldése."""
check = await db.execute(text("SELECT id FROM data.users WHERE email = :e"), {"e": user_in.email})
if check.fetchone():
raise HTTPException(status_code=400, detail="Ez az email cím már foglalt.")
"""Step 1: Alapszintű regisztráció (Email + Jelszó)."""
stmt = select(User).where(User.email == user_in.email)
result = await db.execute(stmt)
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:
user = await AuthService.register_lite(db, user_in)
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:
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)
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
"""Bejelentkezés az access_token megszerzéséhez."""
async def login(
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)
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)})
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")
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)
if not success:
raise HTTPException(status_code=400, detail="Érvénytelen vagy lejárt token.")
return {"message": "Email sikeresen megerősítve! Jöhet a Step 2 (KYC)."}
raise HTTPException(
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")
async def complete_kyc(
@@ -50,14 +77,38 @@ async def complete_kyc(
db: AsyncSession = Depends(get_db),
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)
if not user:
raise HTTPException(status_code=404, 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."}
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Felhasználó nem található.")
return {"status": "success", "message": "A profil adatok rögzítve, a rendszer aktiválva."}
@router.post("/forgot-password")
async def forgot_password(req: PasswordResetRequest, db: AsyncSession = Depends(get_db)):
"""Jelszó-visszaállító link küldése."""
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."}
"""Elfelejtett jelszó folyamat indítása biztonsági korlátokkal."""
result = await AuthService.initiate_password_reset(db, req.email)
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