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=sendgrid
|
||||
EMAILS_FROM_EMAIL=info@profibot.hu
|
||||
EMAILS_FROM_NAME='Profibot Service Finder'
|
||||
EMAILS_FROM_NAME='Service Finder'
|
||||
|
||||
# SendGrid beállítások
|
||||
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.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -6,46 +6,50 @@ from sqlalchemy import select
|
||||
|
||||
from app.db.session import get_db
|
||||
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")
|
||||
|
||||
async def get_current_user(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
token: str = Depends(reusable_oauth2),
|
||||
) -> 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)
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token azonosítási hiba."
|
||||
)
|
||||
|
||||
# Felhasználó keresése az adatbázisban
|
||||
res = await db.execute(select(User).where(User.id == int(user_id)))
|
||||
user = res.scalar_one_or_none()
|
||||
# Felhasználó lekérése az adatbázisból
|
||||
result = await db.execute(select(User).where(User.id == int(user_id)))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Felhasználó nem található."
|
||||
detail="A felhasználó nem található."
|
||||
)
|
||||
|
||||
if user.is_deleted:
|
||||
raise HTTPException(
|
||||
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,
|
||||
# mert a Step 2 (KYC) kitöltéséhez be kell tudnia lépni inaktívként is!
|
||||
# Megjegyzés: is_active ellenőrzést szándékosan nem teszünk itt,
|
||||
# hogy a KYC folyamatot (Step 2) be tudja fejezni a még nem aktív user is.
|
||||
|
||||
return user
|
||||
Binary file not shown.
@@ -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"])
|
||||
Binary file not shown.
Binary file not shown.
@@ -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."}
|
||||
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
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
if not hashed_password: return False
|
||||
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
|
||||
"""Összehasonlítja a sima szöveges jelszót a hash-elt változattal."""
|
||||
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:
|
||||
"""Létrehozza a jelszó hash-elt változatát."""
|
||||
salt = bcrypt.gensalt()
|
||||
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:
|
||||
"""Létrehozza a JWT access tokent."""
|
||||
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})
|
||||
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]]:
|
||||
"""JWT token visszafejtése és ellenőrzése."""
|
||||
"""JWT token visszafejtése és validálása."""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
return payload
|
||||
|
||||
@@ -2,11 +2,9 @@ import os
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
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
|
||||
|
||||
# 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)
|
||||
|
||||
app = FastAPI(
|
||||
@@ -16,24 +14,21 @@ app = FastAPI(
|
||||
docs_url="/docs"
|
||||
)
|
||||
|
||||
# 2. PONTOS CORS BEÁLLÍTÁS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://192.168.100.10:3001", # Frontend portja
|
||||
"http://192.168.100.10:3001",
|
||||
"http://localhost:3001",
|
||||
"https://dev.profibot.hu" # NPM proxy esetén
|
||||
"https://dev.profibot.hu"
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
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")
|
||||
|
||||
# 4. ROUTER BEKÖTÉSE
|
||||
# CSAK EZT AZ EGYET KELL BEKÖTNI:
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
|
||||
@app.get("/")
|
||||
@@ -41,5 +36,5 @@ async def root():
|
||||
return {
|
||||
"status": "online",
|
||||
"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.dialects.postgresql import UUID
|
||||
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):
|
||||
admin = "admin"
|
||||
@@ -25,12 +25,14 @@ class Person(Base):
|
||||
mothers_name = Column(String, nullable=True)
|
||||
birth_place = Column(String, 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"))
|
||||
medical_emergency = 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)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -55,7 +57,6 @@ class User(Base):
|
||||
|
||||
person = relationship("Person", back_populates="users")
|
||||
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")
|
||||
|
||||
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)
|
||||
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)
|
||||
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())
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
is_used = Column(Boolean, default=False)
|
||||
Binary file not shown.
@@ -29,7 +29,6 @@ class UserKYCComplete(BaseModel):
|
||||
birth_place: str
|
||||
birth_date: date
|
||||
mothers_name: str
|
||||
# Rugalmas okmánytár, pl: {"id_card": {"number": "123", "expiry_date": "2030-01-01"}}
|
||||
identity_docs: Dict[str, DocumentDetail]
|
||||
ice_contact: ICEContact
|
||||
|
||||
@@ -37,7 +36,14 @@ class UserKYCComplete(BaseModel):
|
||||
class PasswordResetRequest(BaseModel):
|
||||
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):
|
||||
access_token: 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
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
# SQLAlchemy importok
|
||||
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.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.services.email_manager import email_manager
|
||||
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:
|
||||
@staticmethod
|
||||
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:
|
||||
# 1. Person alap létrehozása
|
||||
new_person = Person(
|
||||
@@ -25,7 +34,7 @@ class AuthService:
|
||||
db.add(new_person)
|
||||
await db.flush()
|
||||
|
||||
# 2. User technikai fiók
|
||||
# 2. User fiók
|
||||
new_user = User(
|
||||
email=user_in.email,
|
||||
hashed_password=get_password_hash(user_in.password),
|
||||
@@ -37,157 +46,64 @@ class AuthService:
|
||||
db.add(new_user)
|
||||
await db.flush()
|
||||
|
||||
# 3. Kormányozható Token generálása
|
||||
expire_hours = getattr(settings, "REGISTRATION_TOKEN_EXPIRE_HOURS", 48)
|
||||
# --- DINAMIKUS TOKEN LEJÁRAT ---
|
||||
reg_hours = await config.get_setting(
|
||||
"auth_registration_hours",
|
||||
region_code=user_in.region_code,
|
||||
default=48
|
||||
)
|
||||
|
||||
token_val = uuid.uuid4()
|
||||
new_token = VerificationToken(
|
||||
token=token_val,
|
||||
user_id=new_user.id,
|
||||
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)
|
||||
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}"
|
||||
try:
|
||||
await email_manager.send_email(
|
||||
recipient=user_in.email,
|
||||
template_key="registration",
|
||||
variables={
|
||||
"first_name": user_in.first_name,
|
||||
"link": verification_link
|
||||
}
|
||||
)
|
||||
except Exception as email_err:
|
||||
print(f"CRITICAL: Email failed: {str(email_err)}")
|
||||
await email_manager.send_email(
|
||||
recipient=user_in.email,
|
||||
template_key="registration",
|
||||
variables={"first_name": user_in.first_name, "link": verification_link}
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(new_user)
|
||||
return new_user
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Registration Error: {str(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
|
||||
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)
|
||||
res = await db.execute(stmt)
|
||||
user = res.scalar_one_or_none()
|
||||
|
||||
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()
|
||||
new_token = VerificationToken(
|
||||
token=token_val,
|
||||
user_id=user.id,
|
||||
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)
|
||||
|
||||
@@ -195,9 +111,11 @@ class AuthService:
|
||||
await email_manager.send_email(
|
||||
recipient=email,
|
||||
template_key="password_reset",
|
||||
variables={"link": reset_link},
|
||||
user_id=user.id
|
||||
variables={"link": reset_link}
|
||||
)
|
||||
await db.commit()
|
||||
return True
|
||||
return False
|
||||
return "success"
|
||||
|
||||
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 app.db.session import SessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ConfigService:
|
||||
@staticmethod
|
||||
def __init__(self):
|
||||
self._cache: Dict[str, Any] = {}
|
||||
|
||||
async def get_setting(
|
||||
self,
|
||||
key: str,
|
||||
org_id: Optional[int] = None,
|
||||
region_code: Optional[str] = None,
|
||||
tier_id: Optional[int] = None,
|
||||
default: Any = None
|
||||
) -> 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("""
|
||||
SELECT value_json
|
||||
FROM data.system_settings
|
||||
@@ -28,14 +39,25 @@ class ConfigService:
|
||||
LIMIT 1
|
||||
""")
|
||||
|
||||
async with SessionLocal() as db:
|
||||
result = await db.execute(query, {
|
||||
"key": key,
|
||||
"org_id": org_id,
|
||||
"tier_id": tier_id,
|
||||
"region_code": region_code
|
||||
})
|
||||
row = result.fetchone()
|
||||
return row[0] if row else default
|
||||
try:
|
||||
async with SessionLocal() as db:
|
||||
result = await db.execute(query, {
|
||||
"key": key,
|
||||
"org_id": org_id,
|
||||
"tier_id": tier_id,
|
||||
"region_code": region_code
|
||||
})
|
||||
row = result.fetchone()
|
||||
val = row[0] if row else default
|
||||
|
||||
# 2. Mentés cache-be
|
||||
self._cache[cache_key] = val
|
||||
return val
|
||||
except Exception as e:
|
||||
logger.error(f"ConfigService Error: {e}")
|
||||
return default
|
||||
|
||||
def clear_cache(self):
|
||||
self._cache = {}
|
||||
|
||||
config = ConfigService()
|
||||
@@ -1,35 +1,40 @@
|
||||
import os
|
||||
import smtplib
|
||||
import logging
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
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:
|
||||
@staticmethod
|
||||
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)
|
||||
body = locale_manager.get(f"email.{template_key}_body", lang=lang, **variables)
|
||||
button_text = locale_manager.get(f"email.{template_key}_button", lang=lang)
|
||||
footer = locale_manager.get(f"email.{template_key}_footer", lang=lang)
|
||||
|
||||
# Egységes HTML váz gombbal
|
||||
return f"""
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; border: 1px solid #ddd; padding: 20px;">
|
||||
<h2>{greeting}</h2>
|
||||
<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: 30px; border-radius: 10px;">
|
||||
<h2 style="color: #2c3e50;">{greeting}</h2>
|
||||
<p>{body}</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<div style="text-align: center; margin: 40px 0;">
|
||||
<a href="{variables.get('link', '#')}"
|
||||
style="background-color: #3498db; color: white; padding: 12px 25px; text-decoration: none; border-radius: 5px; font-weight: bold;">
|
||||
{button_text}
|
||||
style="background-color: #3498db; color: white; padding: 15px 30px; text-decoration: none; border-radius: 5px; font-weight: bold; font-size: 16px;">
|
||||
{button_text}
|
||||
</a>
|
||||
</div>
|
||||
<p style="font-size: 0.9em; color: #666;">{variables.get('link')}</p>
|
||||
<hr style="border: 0; border-top: 1px solid #eee;">
|
||||
<p style="font-size: 0.8em; color: #999;">{footer}</p>
|
||||
<p style="font-size: 0.85em; color: #777; word-break: break-all;">
|
||||
Ha a gomb nem működik, másolja be ezt a linket a böngészőjébe:<br>
|
||||
{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>
|
||||
</body>
|
||||
</html>
|
||||
@@ -37,16 +42,20 @@ class EmailManager:
|
||||
|
||||
@staticmethod
|
||||
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)
|
||||
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:
|
||||
try:
|
||||
from sendgrid import SendGridAPIClient
|
||||
from sendgrid.helpers.mail import Mail
|
||||
|
||||
message = Mail(
|
||||
from_email=(settings.EMAILS_FROM_EMAIL, settings.EMAILS_FROM_NAME),
|
||||
to_emails=recipient,
|
||||
@@ -54,23 +63,27 @@ class EmailManager:
|
||||
html_content=html
|
||||
)
|
||||
sg = SendGridAPIClient(settings.SENDGRID_API_KEY)
|
||||
sg.send(message)
|
||||
return {"status": "success"}
|
||||
except Exception as e:
|
||||
print(f"SendGrid Error: {e}")
|
||||
response = sg.send(message)
|
||||
|
||||
# SMTP Fallback
|
||||
# ... (az eredeti SMTP kódod ide jön változatlanul)
|
||||
# 2) SMTP fallback
|
||||
logger.info(f"SendGrid Status: {response.status_code} for {recipient}")
|
||||
if response.status_code >= 400:
|
||||
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:
|
||||
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:
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = f"{settings.EMAILS_FROM_NAME} <{settings.EMAILS_FROM_EMAIL}>"
|
||||
msg["To"] = recipient
|
||||
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:
|
||||
if settings.SMTP_USE_TLS:
|
||||
@@ -78,8 +91,10 @@ class EmailManager:
|
||||
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
server.send_message(msg)
|
||||
|
||||
logger.info(f"Email sikeresen kiküldve (SMTP) ide: {recipient}")
|
||||
return {"status": "success", "provider": "smtp"}
|
||||
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()
|
||||
@@ -1,40 +1,26 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, text
|
||||
from app.models.gamification import UserStats, PointsLedger
|
||||
from sqlalchemy import select
|
||||
from app.models.identity import User
|
||||
|
||||
class GamificationService:
|
||||
@staticmethod
|
||||
async def award_points(db: AsyncSession, user_id: int, points: int, reason: str):
|
||||
"""Pontok jóváírása és a UserStats frissítése"""
|
||||
|
||||
# 1. Bejegyzés a naplóba (Mezőnevek szinkronizálva a modellel)
|
||||
"""Pontok jóváírása (SQL szinkronizált points mezővel)."""
|
||||
new_entry = PointsLedger(
|
||||
user_id=user_id,
|
||||
points_change=points,
|
||||
points=points, # Javítva: points_change helyett points
|
||||
reason=reason
|
||||
)
|
||||
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))
|
||||
stats = result.scalar_one_or_none()
|
||||
|
||||
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)
|
||||
|
||||
# 3. Pontok hozzáadása
|
||||
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()
|
||||
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)
|
||||
- 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
|
||||
- **Lifecycle:** draft_user -> draft_bot -> community_verified -> official.
|
||||
- **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.
|
||||
- `GET /api/v1/auth/invite/verify/{token}`: Token validálása.
|
||||
- `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.
|
||||
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.
|
||||
|
||||
## 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)
|
||||
- **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.
|
||||
|
||||
## 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).
|
||||
- **Healthcheck:** Docker `healthcheck` minden konténeren.
|
||||
- **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ó):**
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
|
||||
## 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