Spaces:
Running
Running
Commit ·
3ee4cc7
1
Parent(s): 5381654
fyp
Browse files- app/i18n/email_translations.py +70 -0
- app/routes/contact_update.py +430 -0
- app/routes/legal.py +948 -0
- app/routes/notifications.py +166 -0
- app/services/email_notification_service.py +152 -0
- app/services/otp_service.py +22 -14
- app/services/push_service.py +197 -0
- main.py +15 -0
- push/louezmoi-5d0f0-firebase-adminsdk-fbsvc-7411d58511.json +13 -0
app/i18n/email_translations.py
CHANGED
|
@@ -22,6 +22,20 @@ EMAIL_TRANSLATIONS = {
|
|
| 22 |
"otp_reset_expiry": "This code will expire in 15 minutes.",
|
| 23 |
"otp_reset_footer": "If you didn't request a password reset, please ignore this email and your password will remain unchanged.",
|
| 24 |
"otp_reset_brand": "Lojiz - Find Your Perfect Home",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
},
|
| 26 |
|
| 27 |
"fr": {
|
|
@@ -42,6 +56,20 @@ EMAIL_TRANSLATIONS = {
|
|
| 42 |
"otp_reset_expiry": "Ce code expirera dans 15 minutes.",
|
| 43 |
"otp_reset_footer": "Si vous n'avez pas demandé de réinitialisation de mot de passe, veuillez ignorer cet e-mail et votre mot de passe restera inchangé.",
|
| 44 |
"otp_reset_brand": "Lojiz - Trouvez votre maison parfaite",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
},
|
| 46 |
|
| 47 |
"es": {
|
|
@@ -62,6 +90,20 @@ EMAIL_TRANSLATIONS = {
|
|
| 62 |
"otp_reset_expiry": "Este código expirará en 15 minutos.",
|
| 63 |
"otp_reset_footer": "Si no solicitaste un restablecimiento de contraseña, por favor ignora este correo y tu contraseña permanecerá sin cambios.",
|
| 64 |
"otp_reset_brand": "Lojiz - Encuentra tu hogar perfecto",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
},
|
| 66 |
|
| 67 |
"ar": {
|
|
@@ -82,6 +124,20 @@ EMAIL_TRANSLATIONS = {
|
|
| 82 |
"otp_reset_expiry": "سينتهي صلاحية هذا الرمز خلال 15 دقيقة.",
|
| 83 |
"otp_reset_footer": "إذا لم تطلب إعادة تعيين كلمة المرور، يرجى تجاهل هذا البريد الإلكتروني وستبقى كلمة مرورك دون تغيير.",
|
| 84 |
"otp_reset_brand": "Lojiz - اعثر على منزلك المثالي",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
},
|
| 86 |
|
| 87 |
"pt": {
|
|
@@ -102,6 +158,20 @@ EMAIL_TRANSLATIONS = {
|
|
| 102 |
"otp_reset_expiry": "Este código expirará em 15 minutos.",
|
| 103 |
"otp_reset_footer": "Se você não solicitou uma redefinição de senha, por favor ignore este e-mail e sua senha permanecerá inalterada.",
|
| 104 |
"otp_reset_brand": "Lojiz - Encontre sua casa perfeita",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
},
|
| 106 |
}
|
| 107 |
|
|
|
|
| 22 |
"otp_reset_expiry": "This code will expire in 15 minutes.",
|
| 23 |
"otp_reset_footer": "If you didn't request a password reset, please ignore this email and your password will remain unchanged.",
|
| 24 |
"otp_reset_brand": "Lojiz - Find Your Perfect Home",
|
| 25 |
+
|
| 26 |
+
# Email Update OTP
|
| 27 |
+
"otp_email_update_subject": "Verify Your New Email Address",
|
| 28 |
+
"otp_email_update_greeting": "Hello!",
|
| 29 |
+
"otp_email_update_message": "You requested to update your email address on Lojiz. Please use the verification code below to confirm this change:",
|
| 30 |
+
"otp_email_update_expiry": "This code will expire in 15 minutes.",
|
| 31 |
+
"otp_email_update_footer": "If you didn't request this change, please ignore this email. Your account email will not be changed.",
|
| 32 |
+
|
| 33 |
+
# Phone Update OTP (sent via email)
|
| 34 |
+
"otp_phone_update_subject": "Verify Your New Phone Number",
|
| 35 |
+
"otp_phone_update_greeting": "Hello!",
|
| 36 |
+
"otp_phone_update_message": "You requested to update your phone number on Lojiz. Please use the verification code below to confirm this change:",
|
| 37 |
+
"otp_phone_update_expiry": "This code will expire in 15 minutes.",
|
| 38 |
+
"otp_phone_update_footer": "If you didn't request this change, please ignore this email. Your phone number will not be changed.",
|
| 39 |
},
|
| 40 |
|
| 41 |
"fr": {
|
|
|
|
| 56 |
"otp_reset_expiry": "Ce code expirera dans 15 minutes.",
|
| 57 |
"otp_reset_footer": "Si vous n'avez pas demandé de réinitialisation de mot de passe, veuillez ignorer cet e-mail et votre mot de passe restera inchangé.",
|
| 58 |
"otp_reset_brand": "Lojiz - Trouvez votre maison parfaite",
|
| 59 |
+
|
| 60 |
+
# Email Update OTP
|
| 61 |
+
"otp_email_update_subject": "Vérifiez votre nouvelle adresse e-mail",
|
| 62 |
+
"otp_email_update_greeting": "Bonjour !",
|
| 63 |
+
"otp_email_update_message": "Vous avez demandé à modifier votre adresse e-mail sur Lojiz. Veuillez utiliser le code ci-dessous pour confirmer ce changement :",
|
| 64 |
+
"otp_email_update_expiry": "Ce code expirera dans 15 minutes.",
|
| 65 |
+
"otp_email_update_footer": "Si vous n'avez pas demandé ce changement, veuillez ignorer cet e-mail.",
|
| 66 |
+
|
| 67 |
+
# Phone Update OTP
|
| 68 |
+
"otp_phone_update_subject": "Vérifiez votre nouveau numéro de téléphone",
|
| 69 |
+
"otp_phone_update_greeting": "Bonjour !",
|
| 70 |
+
"otp_phone_update_message": "Vous avez demandé à modifier votre numéro de téléphone sur Lojiz. Veuillez utiliser le code ci-dessous pour confirmer ce changement :",
|
| 71 |
+
"otp_phone_update_expiry": "Ce code expirera dans 15 minutes.",
|
| 72 |
+
"otp_phone_update_footer": "Si vous n'avez pas demandé ce changement, veuillez ignorer cet e-mail.",
|
| 73 |
},
|
| 74 |
|
| 75 |
"es": {
|
|
|
|
| 90 |
"otp_reset_expiry": "Este código expirará en 15 minutos.",
|
| 91 |
"otp_reset_footer": "Si no solicitaste un restablecimiento de contraseña, por favor ignora este correo y tu contraseña permanecerá sin cambios.",
|
| 92 |
"otp_reset_brand": "Lojiz - Encuentra tu hogar perfecto",
|
| 93 |
+
|
| 94 |
+
# Email Update OTP
|
| 95 |
+
"otp_email_update_subject": "Verifica tu nueva dirección de correo",
|
| 96 |
+
"otp_email_update_greeting": "¡Hola!",
|
| 97 |
+
"otp_email_update_message": "Solicitaste actualizar tu dirección de correo en Lojiz. Usa el código a continuación para confirmar el cambio:",
|
| 98 |
+
"otp_email_update_expiry": "Este código expirará en 15 minutos.",
|
| 99 |
+
"otp_email_update_footer": "Si no solicitaste este cambio, ignora este correo.",
|
| 100 |
+
|
| 101 |
+
# Phone Update OTP
|
| 102 |
+
"otp_phone_update_subject": "Verifica tu nuevo número de teléfono",
|
| 103 |
+
"otp_phone_update_greeting": "¡Hola!",
|
| 104 |
+
"otp_phone_update_message": "Solicitaste actualizar tu número de teléfono en Lojiz. Usa el código a continuación para confirmar el cambio:",
|
| 105 |
+
"otp_phone_update_expiry": "Este código expirará en 15 minutos.",
|
| 106 |
+
"otp_phone_update_footer": "Si no solicitaste este cambio, ignora este correo.",
|
| 107 |
},
|
| 108 |
|
| 109 |
"ar": {
|
|
|
|
| 124 |
"otp_reset_expiry": "سينتهي صلاحية هذا الرمز خلال 15 دقيقة.",
|
| 125 |
"otp_reset_footer": "إذا لم تطلب إعادة تعيين كلمة المرور، يرجى تجاهل هذا البريد الإلكتروني وستبقى كلمة مرورك دون تغيير.",
|
| 126 |
"otp_reset_brand": "Lojiz - اعثر على منزلك المثالي",
|
| 127 |
+
|
| 128 |
+
# Email Update OTP
|
| 129 |
+
"otp_email_update_subject": "تحقق من عنوان بريدك الإلكتروني الجديد",
|
| 130 |
+
"otp_email_update_greeting": "مرحباً!",
|
| 131 |
+
"otp_email_update_message": "لقد طلبت تحديث عنوان بريدك الإلكتروني على Lojiz. يرجى استخدام رمز التحقق أدناه لتأكيد هذا التغيير:",
|
| 132 |
+
"otp_email_update_expiry": "سينتهي صلاحية هذا الرمز خلال 15 دقيقة.",
|
| 133 |
+
"otp_email_update_footer": "إذا ل�� تطلب هذا التغيير، يرجى تجاهل هذا البريد الإلكتروني.",
|
| 134 |
+
|
| 135 |
+
# Phone Update OTP
|
| 136 |
+
"otp_phone_update_subject": "تحقق من رقم هاتفك الجديد",
|
| 137 |
+
"otp_phone_update_greeting": "مرحباً!",
|
| 138 |
+
"otp_phone_update_message": "لقد طلبت تحديث رقم هاتفك على Lojiz. يرجى استخدام رمز التحقق أدناه لتأكيد هذا التغيير:",
|
| 139 |
+
"otp_phone_update_expiry": "سينتهي صلاحية هذا الرمز خلال 15 دقيقة.",
|
| 140 |
+
"otp_phone_update_footer": "إذا لم تطلب هذا التغيير، يرجى تجاهل هذا البريد الإلكتروني.",
|
| 141 |
},
|
| 142 |
|
| 143 |
"pt": {
|
|
|
|
| 158 |
"otp_reset_expiry": "Este código expirará em 15 minutos.",
|
| 159 |
"otp_reset_footer": "Se você não solicitou uma redefinição de senha, por favor ignore este e-mail e sua senha permanecerá inalterada.",
|
| 160 |
"otp_reset_brand": "Lojiz - Encontre sua casa perfeita",
|
| 161 |
+
|
| 162 |
+
# Email Update OTP
|
| 163 |
+
"otp_email_update_subject": "Verifique seu novo endereço de e-mail",
|
| 164 |
+
"otp_email_update_greeting": "Olá!",
|
| 165 |
+
"otp_email_update_message": "Você solicitou a atualização do seu endereço de e-mail no Lojiz. Use o código abaixo para confirmar a alteração:",
|
| 166 |
+
"otp_email_update_expiry": "Este código expirará em 15 minutos.",
|
| 167 |
+
"otp_email_update_footer": "Se você não solicitou esta alteração, ignore este e-mail.",
|
| 168 |
+
|
| 169 |
+
# Phone Update OTP
|
| 170 |
+
"otp_phone_update_subject": "Verifique seu novo número de telefone",
|
| 171 |
+
"otp_phone_update_greeting": "Olá!",
|
| 172 |
+
"otp_phone_update_message": "Você solicitou a atualização do seu número de telefone no Lojiz. Use o código abaixo para confirmar a alteração:",
|
| 173 |
+
"otp_phone_update_expiry": "Este código expirará em 15 minutos.",
|
| 174 |
+
"otp_phone_update_footer": "Se você não solicitou esta alteração, ignore este e-mail.",
|
| 175 |
},
|
| 176 |
}
|
| 177 |
|
app/routes/contact_update.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# app/routes/contact_update.py - Update Email & Phone with OTP
|
| 3 |
+
# ============================================================
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from fastapi import APIRouter, HTTPException, status, Depends, Request
|
| 7 |
+
from pydantic import BaseModel, EmailStr, Field
|
| 8 |
+
from typing import Optional
|
| 9 |
+
from app.middleware import limiter, AUTH_RATE_LIMIT
|
| 10 |
+
from app.guards.jwt_guard import get_current_user
|
| 11 |
+
from app.services.otp_service import otp_service
|
| 12 |
+
from app.database import get_db
|
| 13 |
+
from app.core.rate_limiter import get_rate_limiter, RateLimitExceeded
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
|
| 16 |
+
router = APIRouter(tags=["Contact Update"])
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ============================================================
|
| 21 |
+
# DTOs
|
| 22 |
+
# ============================================================
|
| 23 |
+
|
| 24 |
+
class SendEmailOtpDto(BaseModel):
|
| 25 |
+
"""Request OTP to update email"""
|
| 26 |
+
new_email: EmailStr
|
| 27 |
+
language: str = Field(default="en", pattern="^(en|fr|es|ar|pt)$")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class VerifyEmailOtpDto(BaseModel):
|
| 31 |
+
"""Verify OTP and update email"""
|
| 32 |
+
new_email: EmailStr
|
| 33 |
+
code: str = Field(..., min_length=6, max_length=6)
|
| 34 |
+
language: str = Field(default="en", pattern="^(en|fr|es|ar|pt)$")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class SendPhoneOtpDto(BaseModel):
|
| 38 |
+
"""Request OTP to update phone"""
|
| 39 |
+
new_phone: str = Field(..., min_length=5, max_length=20)
|
| 40 |
+
language: str = Field(default="en", pattern="^(en|fr|es|ar|pt)$")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class VerifyPhoneOtpDto(BaseModel):
|
| 44 |
+
"""Verify OTP and update phone"""
|
| 45 |
+
new_phone: str = Field(..., min_length=5, max_length=20)
|
| 46 |
+
code: str = Field(..., min_length=6, max_length=6)
|
| 47 |
+
language: str = Field(default="en", pattern="^(en|fr|es|ar|pt)$")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ============================================================
|
| 51 |
+
# RATE LIMITING HELPER
|
| 52 |
+
# ============================================================
|
| 53 |
+
|
| 54 |
+
async def check_contact_rate_limit(identifier: str, operation: str, request: Request):
|
| 55 |
+
"""Check rate limit for contact update operations"""
|
| 56 |
+
rate_limiter = get_rate_limiter()
|
| 57 |
+
allowed, info = await rate_limiter.is_allowed(
|
| 58 |
+
user_id=identifier,
|
| 59 |
+
operation=operation,
|
| 60 |
+
ip_address=request.client.host if request.client else None,
|
| 61 |
+
)
|
| 62 |
+
if not allowed:
|
| 63 |
+
raise HTTPException(
|
| 64 |
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
| 65 |
+
detail=f"Too many {operation} attempts. Please try again later.",
|
| 66 |
+
headers={"Retry-After": str(info.get("user", {}).get("reset_in", 60))},
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# ============================================================
|
| 71 |
+
# EMAIL UPDATE ENDPOINTS
|
| 72 |
+
# ============================================================
|
| 73 |
+
|
| 74 |
+
@router.post("/email/send-otp", status_code=status.HTTP_200_OK)
|
| 75 |
+
@limiter.limit(AUTH_RATE_LIMIT)
|
| 76 |
+
async def send_email_update_otp(
|
| 77 |
+
dto: SendEmailOtpDto,
|
| 78 |
+
request: Request,
|
| 79 |
+
current_user: dict = Depends(get_current_user),
|
| 80 |
+
):
|
| 81 |
+
"""
|
| 82 |
+
Step 1: Send OTP to new email address.
|
| 83 |
+
Requires authentication. Checks that the new email is not already in use.
|
| 84 |
+
"""
|
| 85 |
+
user_id = current_user.get("user_id")
|
| 86 |
+
new_email = dto.new_email.lower()
|
| 87 |
+
|
| 88 |
+
logger.info(f"Email update OTP requested by user {user_id} for {new_email}")
|
| 89 |
+
|
| 90 |
+
db = await get_db()
|
| 91 |
+
users_collection = db["users"]
|
| 92 |
+
|
| 93 |
+
# Check if new email is same as current
|
| 94 |
+
current_email = current_user.get("email", "")
|
| 95 |
+
if current_email and current_email.lower() == new_email:
|
| 96 |
+
raise HTTPException(
|
| 97 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 98 |
+
detail="New email is the same as your current email.",
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
# Check if email already taken by another user
|
| 102 |
+
existing = await users_collection.find_one({"email": new_email})
|
| 103 |
+
if existing:
|
| 104 |
+
raise HTTPException(
|
| 105 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 106 |
+
detail="This email is already registered to another account.",
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
# Send OTP to the NEW email
|
| 110 |
+
try:
|
| 111 |
+
await otp_service.send_otp(new_email, "email_update", dto.language)
|
| 112 |
+
except Exception as e:
|
| 113 |
+
logger.error(f"Failed to send email update OTP: {e}")
|
| 114 |
+
raise HTTPException(
|
| 115 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 116 |
+
detail="Failed to send verification email. Please try again.",
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
return {
|
| 120 |
+
"success": True,
|
| 121 |
+
"message": "Verification code sent to your new email address.",
|
| 122 |
+
"data": {"email": new_email},
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
@router.post("/email/verify-otp", status_code=status.HTTP_200_OK)
|
| 127 |
+
async def verify_email_update_otp(
|
| 128 |
+
dto: VerifyEmailOtpDto,
|
| 129 |
+
request: Request,
|
| 130 |
+
current_user: dict = Depends(get_current_user),
|
| 131 |
+
):
|
| 132 |
+
"""
|
| 133 |
+
Step 2: Verify OTP and update email.
|
| 134 |
+
On success, updates the user's email in the database.
|
| 135 |
+
"""
|
| 136 |
+
user_id = current_user.get("user_id")
|
| 137 |
+
new_email = dto.new_email.lower()
|
| 138 |
+
|
| 139 |
+
await check_contact_rate_limit(user_id, "verify_email_otp", request)
|
| 140 |
+
|
| 141 |
+
logger.info(f"Email update OTP verification by user {user_id} for {new_email}")
|
| 142 |
+
|
| 143 |
+
# Verify OTP
|
| 144 |
+
await otp_service.verify_otp(new_email, dto.code, "email_update", dto.language)
|
| 145 |
+
|
| 146 |
+
db = await get_db()
|
| 147 |
+
users_collection = db["users"]
|
| 148 |
+
|
| 149 |
+
# Double-check email not taken (race condition guard)
|
| 150 |
+
existing = await users_collection.find_one({"email": new_email})
|
| 151 |
+
if existing and str(existing["_id"]) != user_id:
|
| 152 |
+
raise HTTPException(
|
| 153 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 154 |
+
detail="This email was just registered by another account.",
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
# Update user email
|
| 158 |
+
from bson import ObjectId
|
| 159 |
+
await users_collection.update_one(
|
| 160 |
+
{"_id": ObjectId(user_id)},
|
| 161 |
+
{
|
| 162 |
+
"$set": {
|
| 163 |
+
"email": new_email,
|
| 164 |
+
"isEmailVerified": True,
|
| 165 |
+
"updatedAt": datetime.utcnow(),
|
| 166 |
+
}
|
| 167 |
+
},
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
# Clean up OTP
|
| 171 |
+
await otp_service.delete_otp(new_email, "email_update")
|
| 172 |
+
|
| 173 |
+
logger.info(f"Email updated successfully for user {user_id}: {new_email}")
|
| 174 |
+
|
| 175 |
+
return {
|
| 176 |
+
"success": True,
|
| 177 |
+
"message": "Email updated successfully!",
|
| 178 |
+
"data": {"email": new_email},
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
@router.post("/email/resend-otp", status_code=status.HTTP_200_OK)
|
| 183 |
+
@limiter.limit(AUTH_RATE_LIMIT)
|
| 184 |
+
async def resend_email_update_otp(
|
| 185 |
+
dto: SendEmailOtpDto,
|
| 186 |
+
request: Request,
|
| 187 |
+
current_user: dict = Depends(get_current_user),
|
| 188 |
+
):
|
| 189 |
+
"""Resend OTP for email update."""
|
| 190 |
+
new_email = dto.new_email.lower()
|
| 191 |
+
|
| 192 |
+
# Check if previous OTP has expired
|
| 193 |
+
is_expired = await otp_service.is_otp_expired(new_email, "email_update")
|
| 194 |
+
if not is_expired:
|
| 195 |
+
from app.i18n.error_translations import get_error_message
|
| 196 |
+
raise HTTPException(
|
| 197 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 198 |
+
detail=get_error_message(dto.language, "otp_still_valid"),
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
try:
|
| 202 |
+
await otp_service.send_otp(new_email, "email_update", dto.language)
|
| 203 |
+
except Exception as e:
|
| 204 |
+
logger.error(f"Failed to resend email update OTP: {e}")
|
| 205 |
+
raise HTTPException(
|
| 206 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 207 |
+
detail="Failed to resend verification email.",
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
return {
|
| 211 |
+
"success": True,
|
| 212 |
+
"message": "New verification code sent to your email.",
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
# ============================================================
|
| 217 |
+
# PHONE UPDATE ENDPOINTS
|
| 218 |
+
# ============================================================
|
| 219 |
+
|
| 220 |
+
@router.post("/phone/send-otp", status_code=status.HTTP_200_OK)
|
| 221 |
+
@limiter.limit(AUTH_RATE_LIMIT)
|
| 222 |
+
async def send_phone_update_otp(
|
| 223 |
+
dto: SendPhoneOtpDto,
|
| 224 |
+
request: Request,
|
| 225 |
+
current_user: dict = Depends(get_current_user),
|
| 226 |
+
):
|
| 227 |
+
"""
|
| 228 |
+
Step 1: Send OTP to new phone number.
|
| 229 |
+
Requires authentication. Checks that the new phone is not already in use.
|
| 230 |
+
|
| 231 |
+
NOTE: SMS delivery depends on SMS provider being configured.
|
| 232 |
+
Currently sends via email if phone user has an email on file.
|
| 233 |
+
"""
|
| 234 |
+
user_id = current_user.get("user_id")
|
| 235 |
+
new_phone = dto.new_phone.strip()
|
| 236 |
+
|
| 237 |
+
logger.info(f"Phone update OTP requested by user {user_id} for {new_phone}")
|
| 238 |
+
|
| 239 |
+
db = await get_db()
|
| 240 |
+
users_collection = db["users"]
|
| 241 |
+
|
| 242 |
+
# Check if same as current
|
| 243 |
+
current_phone = current_user.get("phone", "")
|
| 244 |
+
if current_phone and current_phone == new_phone:
|
| 245 |
+
raise HTTPException(
|
| 246 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 247 |
+
detail="New phone number is the same as your current number.",
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
# Check if phone already taken
|
| 251 |
+
existing = await users_collection.find_one({"phone": new_phone})
|
| 252 |
+
if existing:
|
| 253 |
+
raise HTTPException(
|
| 254 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 255 |
+
detail="This phone number is already registered to another account.",
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
# For phone OTP, we send to the user's email since SMS is not implemented
|
| 259 |
+
# The OTP is keyed by the new phone number for verification
|
| 260 |
+
from bson import ObjectId
|
| 261 |
+
user = await users_collection.find_one({"_id": ObjectId(user_id)})
|
| 262 |
+
user_email = user.get("email") if user else None
|
| 263 |
+
|
| 264 |
+
if not user_email:
|
| 265 |
+
raise HTTPException(
|
| 266 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 267 |
+
detail="You need a verified email on your account to update your phone number.",
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
# Generate OTP keyed to new phone, but send via email
|
| 271 |
+
otp_code = await otp_service.generate_otp(new_phone, "phone_update")
|
| 272 |
+
try:
|
| 273 |
+
await otp_service._send_otp_via_email(user_email, otp_code, "phone_update", dto.language)
|
| 274 |
+
except Exception as e:
|
| 275 |
+
logger.error(f"Failed to send phone update OTP: {e}")
|
| 276 |
+
raise HTTPException(
|
| 277 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 278 |
+
detail="Failed to send verification code. Please try again.",
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
return {
|
| 282 |
+
"success": True,
|
| 283 |
+
"message": "Verification code sent to your email address.",
|
| 284 |
+
"data": {"phone": new_phone},
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
@router.post("/phone/verify-otp", status_code=status.HTTP_200_OK)
|
| 289 |
+
async def verify_phone_update_otp(
|
| 290 |
+
dto: VerifyPhoneOtpDto,
|
| 291 |
+
request: Request,
|
| 292 |
+
current_user: dict = Depends(get_current_user),
|
| 293 |
+
):
|
| 294 |
+
"""
|
| 295 |
+
Step 2: Verify OTP and update phone number.
|
| 296 |
+
"""
|
| 297 |
+
user_id = current_user.get("user_id")
|
| 298 |
+
new_phone = dto.new_phone.strip()
|
| 299 |
+
|
| 300 |
+
await check_contact_rate_limit(user_id, "verify_phone_otp", request)
|
| 301 |
+
|
| 302 |
+
logger.info(f"Phone update OTP verification by user {user_id} for {new_phone}")
|
| 303 |
+
|
| 304 |
+
# Verify OTP
|
| 305 |
+
await otp_service.verify_otp(new_phone, dto.code, "phone_update", dto.language)
|
| 306 |
+
|
| 307 |
+
db = await get_db()
|
| 308 |
+
users_collection = db["users"]
|
| 309 |
+
|
| 310 |
+
# Double-check phone not taken
|
| 311 |
+
existing = await users_collection.find_one({"phone": new_phone})
|
| 312 |
+
if existing and str(existing["_id"]) != user_id:
|
| 313 |
+
raise HTTPException(
|
| 314 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 315 |
+
detail="This phone number was just registered by another account.",
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
+
# Update user phone
|
| 319 |
+
from bson import ObjectId
|
| 320 |
+
await users_collection.update_one(
|
| 321 |
+
{"_id": ObjectId(user_id)},
|
| 322 |
+
{
|
| 323 |
+
"$set": {
|
| 324 |
+
"phone": new_phone,
|
| 325 |
+
"isPhoneVerified": True,
|
| 326 |
+
"updatedAt": datetime.utcnow(),
|
| 327 |
+
}
|
| 328 |
+
},
|
| 329 |
+
)
|
| 330 |
+
|
| 331 |
+
# Clean up OTP
|
| 332 |
+
await otp_service.delete_otp(new_phone, "phone_update")
|
| 333 |
+
|
| 334 |
+
logger.info(f"Phone updated successfully for user {user_id}: {new_phone}")
|
| 335 |
+
|
| 336 |
+
return {
|
| 337 |
+
"success": True,
|
| 338 |
+
"message": "Phone number updated successfully!",
|
| 339 |
+
"data": {"phone": new_phone},
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
@router.post("/phone/resend-otp", status_code=status.HTTP_200_OK)
|
| 344 |
+
@limiter.limit(AUTH_RATE_LIMIT)
|
| 345 |
+
async def resend_phone_update_otp(
|
| 346 |
+
dto: SendPhoneOtpDto,
|
| 347 |
+
request: Request,
|
| 348 |
+
current_user: dict = Depends(get_current_user),
|
| 349 |
+
):
|
| 350 |
+
"""Resend OTP for phone update (sent via email)."""
|
| 351 |
+
new_phone = dto.new_phone.strip()
|
| 352 |
+
|
| 353 |
+
is_expired = await otp_service.is_otp_expired(new_phone, "phone_update")
|
| 354 |
+
if not is_expired:
|
| 355 |
+
from app.i18n.error_translations import get_error_message
|
| 356 |
+
raise HTTPException(
|
| 357 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 358 |
+
detail=get_error_message(dto.language, "otp_still_valid"),
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
user_id = current_user.get("user_id")
|
| 362 |
+
db = await get_db()
|
| 363 |
+
users_collection = db["users"]
|
| 364 |
+
from bson import ObjectId
|
| 365 |
+
user = await users_collection.find_one({"_id": ObjectId(user_id)})
|
| 366 |
+
user_email = user.get("email") if user else None
|
| 367 |
+
|
| 368 |
+
if not user_email:
|
| 369 |
+
raise HTTPException(
|
| 370 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 371 |
+
detail="You need a verified email to update your phone number.",
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
otp_code = await otp_service.generate_otp(new_phone, "phone_update")
|
| 375 |
+
try:
|
| 376 |
+
await otp_service._send_otp_via_email(user_email, otp_code, "phone_update", dto.language)
|
| 377 |
+
except Exception as e:
|
| 378 |
+
logger.error(f"Failed to resend phone update OTP: {e}")
|
| 379 |
+
raise HTTPException(
|
| 380 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 381 |
+
detail="Failed to resend verification code.",
|
| 382 |
+
)
|
| 383 |
+
|
| 384 |
+
return {
|
| 385 |
+
"success": True,
|
| 386 |
+
"message": "New verification code sent to your email.",
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
# ============================================================
|
| 391 |
+
# ACCOUNT DELETION
|
| 392 |
+
# ============================================================
|
| 393 |
+
|
| 394 |
+
@router.delete("/account", status_code=status.HTTP_200_OK)
|
| 395 |
+
async def delete_account(
|
| 396 |
+
request: Request,
|
| 397 |
+
current_user: dict = Depends(get_current_user),
|
| 398 |
+
):
|
| 399 |
+
"""
|
| 400 |
+
Permanently delete user account and all associated data.
|
| 401 |
+
Requires authentication. This action is irreversible.
|
| 402 |
+
"""
|
| 403 |
+
user_id = current_user.get("user_id")
|
| 404 |
+
logger.warning(f"Account deletion requested by user {user_id}")
|
| 405 |
+
|
| 406 |
+
from bson import ObjectId
|
| 407 |
+
db = await get_db()
|
| 408 |
+
oid = ObjectId(user_id)
|
| 409 |
+
|
| 410 |
+
# Delete user's data across collections
|
| 411 |
+
await db["users"].delete_one({"_id": oid})
|
| 412 |
+
await db["listings"].delete_many({"landlord_id": user_id})
|
| 413 |
+
await db["otps"].delete_many({"identifier": current_user.get("email", "")})
|
| 414 |
+
await db["conversations"].delete_many({
|
| 415 |
+
"$or": [{"user_id": user_id}, {"landlord_id": user_id}]
|
| 416 |
+
})
|
| 417 |
+
await db["bookings"].delete_many({
|
| 418 |
+
"$or": [{"renter_id": user_id}, {"landlord_id": user_id}]
|
| 419 |
+
})
|
| 420 |
+
await db["reviews"].delete_many({
|
| 421 |
+
"$or": [{"reviewer_id": user_id}, {"landlord_id": user_id}]
|
| 422 |
+
})
|
| 423 |
+
await db["wishlists"].delete_many({"user_id": user_id})
|
| 424 |
+
|
| 425 |
+
logger.info(f"Account and associated data deleted for user {user_id}")
|
| 426 |
+
|
| 427 |
+
return {
|
| 428 |
+
"success": True,
|
| 429 |
+
"message": "Your account has been permanently deleted.",
|
| 430 |
+
}
|
app/routes/legal.py
ADDED
|
@@ -0,0 +1,948 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# app/routes/legal.py – Localized legal HTML pages
|
| 3 |
+
# Serves Privacy Policy & Terms of Service as styled HTML
|
| 4 |
+
# ============================================================
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Query
|
| 7 |
+
from fastapi.responses import HTMLResponse
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
# ── Brand colors ────────────────────────────────────────────
|
| 13 |
+
GRADIENT_START = "#764BA2"
|
| 14 |
+
GRADIENT_END = "#FF305A"
|
| 15 |
+
BRAND_COLOR = "#FF305A"
|
| 16 |
+
|
| 17 |
+
SUPPORTED_LANGS = {"en", "fr", "es", "ar", "pt"}
|
| 18 |
+
LAST_UPDATED = "2026-03-01"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _base_template(title: str, content: str, lang: str) -> str:
|
| 22 |
+
"""Wrap content in a branded, responsive HTML shell."""
|
| 23 |
+
is_rtl = lang == "ar"
|
| 24 |
+
dir_attr = ' dir="rtl"' if is_rtl else ""
|
| 25 |
+
year = datetime.now().year
|
| 26 |
+
|
| 27 |
+
return f"""<!DOCTYPE html>
|
| 28 |
+
<html lang="{lang}"{dir_attr}>
|
| 29 |
+
<head>
|
| 30 |
+
<meta charset="UTF-8">
|
| 31 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
| 32 |
+
<title>{title}</title>
|
| 33 |
+
<style>
|
| 34 |
+
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
| 35 |
+
body {{
|
| 36 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', sans-serif;
|
| 37 |
+
background: #f8f8fc;
|
| 38 |
+
color: #2c2c2e;
|
| 39 |
+
line-height: 1.7;
|
| 40 |
+
-webkit-font-smoothing: antialiased;
|
| 41 |
+
}}
|
| 42 |
+
.header {{
|
| 43 |
+
background: linear-gradient(135deg, {GRADIENT_START}, {GRADIENT_END});
|
| 44 |
+
padding: 48px 24px 36px;
|
| 45 |
+
text-align: center;
|
| 46 |
+
}}
|
| 47 |
+
.header h1 {{
|
| 48 |
+
color: #fff;
|
| 49 |
+
font-size: 24px;
|
| 50 |
+
font-weight: 700;
|
| 51 |
+
letter-spacing: -0.3px;
|
| 52 |
+
margin-bottom: 8px;
|
| 53 |
+
}}
|
| 54 |
+
.header .updated {{
|
| 55 |
+
color: rgba(255,255,255,0.75);
|
| 56 |
+
font-size: 13px;
|
| 57 |
+
font-weight: 400;
|
| 58 |
+
}}
|
| 59 |
+
.content {{
|
| 60 |
+
max-width: 680px;
|
| 61 |
+
margin: -20px auto 0;
|
| 62 |
+
background: #fff;
|
| 63 |
+
border-radius: 20px 20px 0 0;
|
| 64 |
+
padding: 32px 24px 40px;
|
| 65 |
+
min-height: 60vh;
|
| 66 |
+
}}
|
| 67 |
+
h2 {{
|
| 68 |
+
font-size: 18px;
|
| 69 |
+
font-weight: 700;
|
| 70 |
+
color: {GRADIENT_START};
|
| 71 |
+
margin: 28px 0 12px;
|
| 72 |
+
letter-spacing: -0.2px;
|
| 73 |
+
}}
|
| 74 |
+
h2:first-child {{ margin-top: 0; }}
|
| 75 |
+
h3 {{
|
| 76 |
+
font-size: 15px;
|
| 77 |
+
font-weight: 600;
|
| 78 |
+
color: #1a1a2e;
|
| 79 |
+
margin: 20px 0 8px;
|
| 80 |
+
}}
|
| 81 |
+
p {{
|
| 82 |
+
font-size: 14px;
|
| 83 |
+
color: #4a4a4e;
|
| 84 |
+
margin-bottom: 14px;
|
| 85 |
+
}}
|
| 86 |
+
ul {{
|
| 87 |
+
padding-{"right" if is_rtl else "left"}: 20px;
|
| 88 |
+
margin-bottom: 14px;
|
| 89 |
+
}}
|
| 90 |
+
li {{
|
| 91 |
+
font-size: 14px;
|
| 92 |
+
color: #4a4a4e;
|
| 93 |
+
margin-bottom: 8px;
|
| 94 |
+
line-height: 1.6;
|
| 95 |
+
}}
|
| 96 |
+
.highlight {{
|
| 97 |
+
background: linear-gradient(135deg, rgba(118,75,162,0.08), rgba(255,48,90,0.06));
|
| 98 |
+
border-{"right" if is_rtl else "left"}: 3px solid {BRAND_COLOR};
|
| 99 |
+
padding: 14px 16px;
|
| 100 |
+
border-radius: 0 12px 12px 0;
|
| 101 |
+
margin: 16px 0;
|
| 102 |
+
font-size: 13.5px;
|
| 103 |
+
color: #3a3a3c;
|
| 104 |
+
}}
|
| 105 |
+
.contact-box {{
|
| 106 |
+
background: linear-gradient(135deg, {GRADIENT_START}, {GRADIENT_END});
|
| 107 |
+
border-radius: 16px;
|
| 108 |
+
padding: 24px;
|
| 109 |
+
margin-top: 32px;
|
| 110 |
+
text-align: center;
|
| 111 |
+
}}
|
| 112 |
+
.contact-box p {{
|
| 113 |
+
color: rgba(255,255,255,0.9);
|
| 114 |
+
font-size: 14px;
|
| 115 |
+
margin-bottom: 8px;
|
| 116 |
+
}}
|
| 117 |
+
.contact-box a {{
|
| 118 |
+
color: #fff;
|
| 119 |
+
font-weight: 600;
|
| 120 |
+
text-decoration: none;
|
| 121 |
+
border-bottom: 1px solid rgba(255,255,255,0.4);
|
| 122 |
+
}}
|
| 123 |
+
.footer {{
|
| 124 |
+
text-align: center;
|
| 125 |
+
padding: 24px;
|
| 126 |
+
font-size: 12px;
|
| 127 |
+
color: #8e8e93;
|
| 128 |
+
}}
|
| 129 |
+
@media (max-width: 480px) {{
|
| 130 |
+
.header {{ padding: 40px 20px 28px; }}
|
| 131 |
+
.header h1 {{ font-size: 21px; }}
|
| 132 |
+
.content {{ padding: 24px 18px 32px; }}
|
| 133 |
+
h2 {{ font-size: 16px; }}
|
| 134 |
+
}}
|
| 135 |
+
</style>
|
| 136 |
+
</head>
|
| 137 |
+
<body>
|
| 138 |
+
{content}
|
| 139 |
+
<div class="footer">© {year} Lojiz. All rights reserved.</div>
|
| 140 |
+
</body>
|
| 141 |
+
</html>"""
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# ══════════════════════════════════════════════════════════════
|
| 145 |
+
# PRIVACY POLICY
|
| 146 |
+
# ══════════════════════════════════════════════════════════════
|
| 147 |
+
|
| 148 |
+
PRIVACY_CONTENT = {
|
| 149 |
+
"en": {
|
| 150 |
+
"title": "Privacy Policy",
|
| 151 |
+
"updated": "Last updated",
|
| 152 |
+
"sections": """
|
| 153 |
+
<div class="header">
|
| 154 |
+
<h1>Privacy Policy</h1>
|
| 155 |
+
<div class="updated">Last updated: {date}</div>
|
| 156 |
+
</div>
|
| 157 |
+
<div class="content">
|
| 158 |
+
<h2>1. Information We Collect</h2>
|
| 159 |
+
<p>At Lojiz, we collect information to provide you with the best property rental experience. This includes:</p>
|
| 160 |
+
<ul>
|
| 161 |
+
<li><strong>Account Information:</strong> Name, email address, phone number, and profile picture when you create an account.</li>
|
| 162 |
+
<li><strong>Property Data:</strong> Listings you create, search history, saved properties, and booking details.</li>
|
| 163 |
+
<li><strong>Usage Data:</strong> How you interact with our app, including pages viewed, features used, and time spent.</li>
|
| 164 |
+
<li><strong>Device Information:</strong> Device type, operating system, and app version for optimization.</li>
|
| 165 |
+
<li><strong>Location Data:</strong> With your permission, we collect location to show nearby properties.</li>
|
| 166 |
+
</ul>
|
| 167 |
+
|
| 168 |
+
<h2>2. How We Use Your Information</h2>
|
| 169 |
+
<p>We use your information to:</p>
|
| 170 |
+
<ul>
|
| 171 |
+
<li>Provide and improve our property rental platform</li>
|
| 172 |
+
<li>Facilitate communication between renters and landlords</li>
|
| 173 |
+
<li>Power AIDA, our AI assistant, to help you find properties</li>
|
| 174 |
+
<li>Send relevant notifications about listings and bookings</li>
|
| 175 |
+
<li>Process payments securely through Stripe</li>
|
| 176 |
+
<li>Prevent fraud and ensure platform safety</li>
|
| 177 |
+
</ul>
|
| 178 |
+
|
| 179 |
+
<div class="highlight">We never sell your personal data to third parties. Your information is used solely to enhance your Lojiz experience.</div>
|
| 180 |
+
|
| 181 |
+
<h2>3. AI Assistant (AIDA)</h2>
|
| 182 |
+
<p>AIDA uses your search preferences and conversation history to provide personalized property recommendations. Conversations are processed securely and are not shared with other users or third parties.</p>
|
| 183 |
+
|
| 184 |
+
<h2>4. Data Sharing</h2>
|
| 185 |
+
<p>We may share limited information with:</p>
|
| 186 |
+
<ul>
|
| 187 |
+
<li><strong>Landlords/Renters:</strong> Contact details when you initiate a booking or inquiry.</li>
|
| 188 |
+
<li><strong>Payment Processors:</strong> Stripe processes payments; we don't store full card details.</li>
|
| 189 |
+
<li><strong>Service Providers:</strong> Hosting, analytics, and email services that help us operate.</li>
|
| 190 |
+
<li><strong>Legal Requirements:</strong> When required by law or to protect rights and safety.</li>
|
| 191 |
+
</ul>
|
| 192 |
+
|
| 193 |
+
<h2>5. Data Security</h2>
|
| 194 |
+
<p>We protect your data with:</p>
|
| 195 |
+
<ul>
|
| 196 |
+
<li>End-to-end encryption for messages</li>
|
| 197 |
+
<li>Secure JWT authentication</li>
|
| 198 |
+
<li>Regular security audits</li>
|
| 199 |
+
<li>Encrypted data storage</li>
|
| 200 |
+
</ul>
|
| 201 |
+
|
| 202 |
+
<h2>6. Your Rights</h2>
|
| 203 |
+
<p>You have the right to:</p>
|
| 204 |
+
<ul>
|
| 205 |
+
<li>Access and download your personal data</li>
|
| 206 |
+
<li>Correct inaccurate information</li>
|
| 207 |
+
<li>Delete your account and associated data</li>
|
| 208 |
+
<li>Opt out of analytics and marketing communications</li>
|
| 209 |
+
<li>Withdraw consent for location tracking at any time</li>
|
| 210 |
+
</ul>
|
| 211 |
+
|
| 212 |
+
<h2>7. Data Retention</h2>
|
| 213 |
+
<p>We retain your data for as long as your account is active. After account deletion, we remove personal data within 30 days, except where legal obligations require longer retention.</p>
|
| 214 |
+
|
| 215 |
+
<h2>8. Children's Privacy</h2>
|
| 216 |
+
<p>Lojiz is not intended for users under 18. We do not knowingly collect data from minors.</p>
|
| 217 |
+
|
| 218 |
+
<h2>9. Changes to This Policy</h2>
|
| 219 |
+
<p>We may update this policy periodically. Significant changes will be notified via the app or email.</p>
|
| 220 |
+
|
| 221 |
+
<div class="contact-box">
|
| 222 |
+
<p>Questions about your privacy?</p>
|
| 223 |
+
<a href="mailto:privacy@lojiz.com">privacy@lojiz.com</a>
|
| 224 |
+
</div>
|
| 225 |
+
</div>"""
|
| 226 |
+
},
|
| 227 |
+
"fr": {
|
| 228 |
+
"title": "Politique de Confidentialité",
|
| 229 |
+
"updated": "Dernière mise à jour",
|
| 230 |
+
"sections": """
|
| 231 |
+
<div class="header">
|
| 232 |
+
<h1>Politique de Confidentialité</h1>
|
| 233 |
+
<div class="updated">Dernière mise à jour : {date}</div>
|
| 234 |
+
</div>
|
| 235 |
+
<div class="content">
|
| 236 |
+
<h2>1. Informations que nous collectons</h2>
|
| 237 |
+
<p>Chez Lojiz, nous collectons des informations pour vous offrir la meilleure expérience de location. Cela inclut :</p>
|
| 238 |
+
<ul>
|
| 239 |
+
<li><strong>Informations de compte :</strong> Nom, adresse e-mail, numéro de téléphone et photo de profil lors de la création de votre compte.</li>
|
| 240 |
+
<li><strong>Données immobilières :</strong> Annonces créées, historique de recherche, propriétés sauvegardées et détails de réservation.</li>
|
| 241 |
+
<li><strong>Données d'utilisation :</strong> Comment vous interagissez avec notre application, pages consultées et fonctionnalités utilisées.</li>
|
| 242 |
+
<li><strong>Informations sur l'appareil :</strong> Type d'appareil, système d'exploitation et version de l'application.</li>
|
| 243 |
+
<li><strong>Données de localisation :</strong> Avec votre permission, pour afficher les propriétés à proximité.</li>
|
| 244 |
+
</ul>
|
| 245 |
+
|
| 246 |
+
<h2>2. Comment nous utilisons vos informations</h2>
|
| 247 |
+
<p>Nous utilisons vos informations pour :</p>
|
| 248 |
+
<ul>
|
| 249 |
+
<li>Fournir et améliorer notre plateforme de location</li>
|
| 250 |
+
<li>Faciliter la communication entre locataires et propriétaires</li>
|
| 251 |
+
<li>Alimenter AIDA, notre assistante IA, pour vous aider à trouver des propriétés</li>
|
| 252 |
+
<li>Envoyer des notifications pertinentes sur les annonces et réservations</li>
|
| 253 |
+
<li>Traiter les paiements de manière sécurisée via Stripe</li>
|
| 254 |
+
<li>Prévenir la fraude et assurer la sécurité de la plateforme</li>
|
| 255 |
+
</ul>
|
| 256 |
+
|
| 257 |
+
<div class="highlight">Nous ne vendons jamais vos données personnelles à des tiers. Vos informations sont utilisées uniquement pour améliorer votre expérience Lojiz.</div>
|
| 258 |
+
|
| 259 |
+
<h2>3. Assistante IA (AIDA)</h2>
|
| 260 |
+
<p>AIDA utilise vos préférences de recherche et l'historique de vos conversations pour fournir des recommandations personnalisées. Les conversations sont traitées de manière sécurisée et ne sont pas partagées.</p>
|
| 261 |
+
|
| 262 |
+
<h2>4. Partage des données</h2>
|
| 263 |
+
<p>Nous pouvons partager des informations limitées avec :</p>
|
| 264 |
+
<ul>
|
| 265 |
+
<li><strong>Propriétaires/Locataires :</strong> Coordonnées lors d'une réservation ou demande.</li>
|
| 266 |
+
<li><strong>Processeurs de paiement :</strong> Stripe traite les paiements ; nous ne stockons pas les détails complets de carte.</li>
|
| 267 |
+
<li><strong>Prestataires de services :</strong> Hébergement, analytique et services e-mail.</li>
|
| 268 |
+
<li><strong>Obligations légales :</strong> Lorsque la loi l'exige.</li>
|
| 269 |
+
</ul>
|
| 270 |
+
|
| 271 |
+
<h2>5. Sécurité des données</h2>
|
| 272 |
+
<p>Nous protégeons vos données avec :</p>
|
| 273 |
+
<ul>
|
| 274 |
+
<li>Chiffrement de bout en bout pour les messages</li>
|
| 275 |
+
<li>Authentification JWT sécurisée</li>
|
| 276 |
+
<li>Audits de sécurité réguliers</li>
|
| 277 |
+
<li>Stockage de données chiffré</li>
|
| 278 |
+
</ul>
|
| 279 |
+
|
| 280 |
+
<h2>6. Vos droits</h2>
|
| 281 |
+
<p>Vous avez le droit de :</p>
|
| 282 |
+
<ul>
|
| 283 |
+
<li>Accéder et télécharger vos données personnelles</li>
|
| 284 |
+
<li>Corriger les informations inexactes</li>
|
| 285 |
+
<li>Supprimer votre compte et les données associées</li>
|
| 286 |
+
<li>Refuser l'analytique et les communications marketing</li>
|
| 287 |
+
<li>Retirer votre consentement pour le suivi de localisation</li>
|
| 288 |
+
</ul>
|
| 289 |
+
|
| 290 |
+
<h2>7. Conservation des données</h2>
|
| 291 |
+
<p>Nous conservons vos données tant que votre compte est actif. Après suppression, nous effaçons les données personnelles sous 30 jours, sauf obligation légale.</p>
|
| 292 |
+
|
| 293 |
+
<h2>8. Confidentialité des enfants</h2>
|
| 294 |
+
<p>Lojiz n'est pas destiné aux utilisateurs de moins de 18 ans. Nous ne collectons pas sciemment de données de mineurs.</p>
|
| 295 |
+
|
| 296 |
+
<h2>9. Modifications de cette politique</h2>
|
| 297 |
+
<p>Nous pouvons mettre à jour cette politique périodiquement. Les changements importants seront notifiés via l'application ou par e-mail.</p>
|
| 298 |
+
|
| 299 |
+
<div class="contact-box">
|
| 300 |
+
<p>Questions sur votre confidentialité ?</p>
|
| 301 |
+
<a href="mailto:privacy@lojiz.com">privacy@lojiz.com</a>
|
| 302 |
+
</div>
|
| 303 |
+
</div>"""
|
| 304 |
+
},
|
| 305 |
+
"es": {
|
| 306 |
+
"title": "Política de Privacidad",
|
| 307 |
+
"updated": "Última actualización",
|
| 308 |
+
"sections": """
|
| 309 |
+
<div class="header">
|
| 310 |
+
<h1>Política de Privacidad</h1>
|
| 311 |
+
<div class="updated">Última actualización: {date}</div>
|
| 312 |
+
</div>
|
| 313 |
+
<div class="content">
|
| 314 |
+
<h2>1. Información que recopilamos</h2>
|
| 315 |
+
<p>En Lojiz, recopilamos información para brindarte la mejor experiencia de alquiler. Esto incluye:</p>
|
| 316 |
+
<ul>
|
| 317 |
+
<li><strong>Información de cuenta:</strong> Nombre, correo electrónico, número de teléfono y foto de perfil al crear tu cuenta.</li>
|
| 318 |
+
<li><strong>Datos de propiedades:</strong> Anuncios creados, historial de búsqueda, propiedades guardadas y detalles de reservas.</li>
|
| 319 |
+
<li><strong>Datos de uso:</strong> Cómo interactúas con nuestra aplicación, páginas visitadas y funciones utilizadas.</li>
|
| 320 |
+
<li><strong>Información del dispositivo:</strong> Tipo de dispositivo, sistema operativo y versión de la aplicación.</li>
|
| 321 |
+
<li><strong>Datos de ubicación:</strong> Con tu permiso, para mostrar propiedades cercanas.</li>
|
| 322 |
+
</ul>
|
| 323 |
+
|
| 324 |
+
<h2>2. Cómo usamos tu información</h2>
|
| 325 |
+
<p>Usamos tu información para:</p>
|
| 326 |
+
<ul>
|
| 327 |
+
<li>Proporcionar y mejorar nuestra plataforma de alquiler</li>
|
| 328 |
+
<li>Facilitar la comunicación entre inquilinos y propietarios</li>
|
| 329 |
+
<li>Potenciar AIDA, nuestra asistente de IA, para ayudarte a encontrar propiedades</li>
|
| 330 |
+
<li>Enviar notificaciones relevantes sobre anuncios y reservas</li>
|
| 331 |
+
<li>Procesar pagos de forma segura a través de Stripe</li>
|
| 332 |
+
<li>Prevenir fraudes y garantizar la seguridad de la plataforma</li>
|
| 333 |
+
</ul>
|
| 334 |
+
|
| 335 |
+
<div class="highlight">Nunca vendemos tus datos personales a terceros. Tu información se utiliza únicamente para mejorar tu experiencia en Lojiz.</div>
|
| 336 |
+
|
| 337 |
+
<h2>3. Asistente de IA (AIDA)</h2>
|
| 338 |
+
<p>AIDA utiliza tus preferencias de búsqueda y el historial de conversaciones para ofrecer recomendaciones personalizadas. Las conversaciones se procesan de forma segura y no se comparten.</p>
|
| 339 |
+
|
| 340 |
+
<h2>4. Compartir datos</h2>
|
| 341 |
+
<ul>
|
| 342 |
+
<li><strong>Propietarios/Inquilinos:</strong> Datos de contacto al iniciar una reserva o consulta.</li>
|
| 343 |
+
<li><strong>Procesadores de pago:</strong> Stripe procesa los pagos; no almacenamos datos completos de tarjetas.</li>
|
| 344 |
+
<li><strong>Proveedores de servicios:</strong> Alojamiento, analítica y servicios de correo electrónico.</li>
|
| 345 |
+
<li><strong>Requisitos legales:</strong> Cuando lo exija la ley.</li>
|
| 346 |
+
</ul>
|
| 347 |
+
|
| 348 |
+
<h2>5. Seguridad de datos</h2>
|
| 349 |
+
<ul>
|
| 350 |
+
<li>Cifrado de extremo a extremo para mensajes</li>
|
| 351 |
+
<li>Autenticación JWT segura</li>
|
| 352 |
+
<li>Auditorías de seguridad regulares</li>
|
| 353 |
+
<li>Almacenamiento de datos cifrado</li>
|
| 354 |
+
</ul>
|
| 355 |
+
|
| 356 |
+
<h2>6. Tus derechos</h2>
|
| 357 |
+
<ul>
|
| 358 |
+
<li>Acceder y descargar tus datos personales</li>
|
| 359 |
+
<li>Corregir información inexacta</li>
|
| 360 |
+
<li>Eliminar tu cuenta y datos asociados</li>
|
| 361 |
+
<li>Rechazar analíticas y comunicaciones de marketing</li>
|
| 362 |
+
<li>Retirar el consentimiento para el seguimiento de ubicación</li>
|
| 363 |
+
</ul>
|
| 364 |
+
|
| 365 |
+
<h2>7. Retención de datos</h2>
|
| 366 |
+
<p>Conservamos tus datos mientras tu cuenta esté activa. Después de la eliminación, borramos los datos personales en 30 días, salvo obligación legal.</p>
|
| 367 |
+
|
| 368 |
+
<h2>8. Privacidad de menores</h2>
|
| 369 |
+
<p>Lojiz no está destinado a usuarios menores de 18 años.</p>
|
| 370 |
+
|
| 371 |
+
<h2>9. Cambios en esta política</h2>
|
| 372 |
+
<p>Podemos actualizar esta política periódicamente. Los cambios importantes se notificarán a través de la aplicación o correo electrónico.</p>
|
| 373 |
+
|
| 374 |
+
<div class="contact-box">
|
| 375 |
+
<p>¿Preguntas sobre tu privacidad?</p>
|
| 376 |
+
<a href="mailto:privacy@lojiz.com">privacy@lojiz.com</a>
|
| 377 |
+
</div>
|
| 378 |
+
</div>"""
|
| 379 |
+
},
|
| 380 |
+
"ar": {
|
| 381 |
+
"title": "سياسة الخصوصية",
|
| 382 |
+
"updated": "آخر تحديث",
|
| 383 |
+
"sections": """
|
| 384 |
+
<div class="header">
|
| 385 |
+
<h1>سياسة الخصوصية</h1>
|
| 386 |
+
<div class="updated">آخر تحديث: {date}</div>
|
| 387 |
+
</div>
|
| 388 |
+
<div class="content">
|
| 389 |
+
<h2>1. المعلومات التي نجمعها</h2>
|
| 390 |
+
<p>في Lojiz، نجمع المعلومات لتقديم أفضل تجربة تأجير لك. يشمل ذلك:</p>
|
| 391 |
+
<ul>
|
| 392 |
+
<li><strong>معلومات الحساب:</strong> الاسم والبريد الإلكتروني ورقم الهاتف وصورة الملف الشخصي عند إنشاء حسابك.</li>
|
| 393 |
+
<li><strong>بيانات العقارات:</strong> الإعلانات المنشأة وسجل البحث والعقارات المحفوظة وتفاصيل الحجز.</li>
|
| 394 |
+
<li><strong>بيانات الاستخدام:</strong> كيفية تفاعلك مع تطبيقنا والصفحات المعروضة والميزات المستخدمة.</li>
|
| 395 |
+
<li><strong>معلومات الجهاز:</strong> نوع الجهاز ونظام التشغيل وإصدار التطبيق.</li>
|
| 396 |
+
<li><strong>بيانات الموقع:</strong> بإذنك، لعرض العقارات القريبة.</li>
|
| 397 |
+
</ul>
|
| 398 |
+
|
| 399 |
+
<h2>2. كيف نستخدم معلوماتك</h2>
|
| 400 |
+
<ul>
|
| 401 |
+
<li>توفير وتحسين منصة التأجير</li>
|
| 402 |
+
<li>تسهيل التواصل بين المستأجرين والملاك</li>
|
| 403 |
+
<li>تشغيل AIDA، مساعدتنا الذكية، لمساعدتك في العثور على عقارات</li>
|
| 404 |
+
<li>إرسال إشعارات ذات صلة حول الإعلانات والحجوزات</li>
|
| 405 |
+
<li>معالجة المدفوعات بأمان عبر Stripe</li>
|
| 406 |
+
<li>منع الاحتيال وضمان سلامة المنصة</li>
|
| 407 |
+
</ul>
|
| 408 |
+
|
| 409 |
+
<div class="highlight">نحن لا نبيع بياناتك الشخصية أبداً لأطراف ثالثة. تُستخدم معلوماتك فقط لتحسين تجربتك في Lojiz.</div>
|
| 410 |
+
|
| 411 |
+
<h2>3. المساعدة الذكية (AIDA)</h2>
|
| 412 |
+
<p>تستخدم AIDA تفضيلات البحث وسجل المحادثات لتقديم توصيات مخصصة. تتم معالجة المحادثات بشكل آمن ولا تتم مشاركتها.</p>
|
| 413 |
+
|
| 414 |
+
<h2>4. مشاركة البيانات</h2>
|
| 415 |
+
<ul>
|
| 416 |
+
<li><strong>الملاك/المستأجرون:</strong> بيانات الاتصال عند بدء حجز أو استفسار.</li>
|
| 417 |
+
<li><strong>معالجو الدفع:</strong> يعالج Stripe المدفوعات؛ لا نخزن تفاصيل البطاقة الكاملة.</li>
|
| 418 |
+
<li><strong>مقدمو الخدمات:</strong> الاستضافة والتحليلات وخدمات البريد الإلكتروني.</li>
|
| 419 |
+
<li><strong>المتطلبات القانونية:</strong> عندما يتطلب القانون ذلك.</li>
|
| 420 |
+
</ul>
|
| 421 |
+
|
| 422 |
+
<h2>5. أمان البيانات</h2>
|
| 423 |
+
<ul>
|
| 424 |
+
<li>تشفير من طرف إلى طرف للرسائل</li>
|
| 425 |
+
<li>مصادقة JWT آمنة</li>
|
| 426 |
+
<li>تدقيقات أمنية منتظمة</li>
|
| 427 |
+
<li>تخزين بيانات مشفر</li>
|
| 428 |
+
</ul>
|
| 429 |
+
|
| 430 |
+
<h2>6. حقوقك</h2>
|
| 431 |
+
<ul>
|
| 432 |
+
<li>الوصول إلى بياناتك الشخصية وتحميلها</li>
|
| 433 |
+
<li>تصحيح المعلومات غير الدقيقة</li>
|
| 434 |
+
<li>حذف حسابك والبيانات المرتبطة</li>
|
| 435 |
+
<li>رفض التحليلات والاتصالات التسويقية</li>
|
| 436 |
+
<li>سحب الموافقة على تتبع الموقع</li>
|
| 437 |
+
</ul>
|
| 438 |
+
|
| 439 |
+
<h2>7. الاحتفاظ بالبيانات</h2>
|
| 440 |
+
<p>نحتفظ ببياناتك طالما حسابك نشط. بعد الحذف، نزيل البيانات الشخصية خلال 30 يوماً، ما لم تتطلب الالتزامات القانونية خلاف ذلك.</p>
|
| 441 |
+
|
| 442 |
+
<h2>8. خصوصية الأطفال</h2>
|
| 443 |
+
<p>Lojiz غير مخصص للمستخدمين دون 18 عاماً.</p>
|
| 444 |
+
|
| 445 |
+
<h2>9. التغييرات على هذه السياسة</h2>
|
| 446 |
+
<p>قد نحدّث هذه السياسة بشكل دوري. سيتم إخطارك بالتغييرات المهمة عبر التطبيق أو البريد الإلكتروني.</p>
|
| 447 |
+
|
| 448 |
+
<div class="contact-box">
|
| 449 |
+
<p>أسئلة حول خصوصيتك؟</p>
|
| 450 |
+
<a href="mailto:privacy@lojiz.com">privacy@lojiz.com</a>
|
| 451 |
+
</div>
|
| 452 |
+
</div>"""
|
| 453 |
+
},
|
| 454 |
+
"pt": {
|
| 455 |
+
"title": "Política de Privacidade",
|
| 456 |
+
"updated": "Última atualização",
|
| 457 |
+
"sections": """
|
| 458 |
+
<div class="header">
|
| 459 |
+
<h1>Política de Privacidade</h1>
|
| 460 |
+
<div class="updated">Última atualização: {date}</div>
|
| 461 |
+
</div>
|
| 462 |
+
<div class="content">
|
| 463 |
+
<h2>1. Informações que coletamos</h2>
|
| 464 |
+
<p>Na Lojiz, coletamos informações para oferecer a melhor experiência de aluguel. Isso inclui:</p>
|
| 465 |
+
<ul>
|
| 466 |
+
<li><strong>Informações da conta:</strong> Nome, e-mail, número de telefone e foto de perfil ao criar sua conta.</li>
|
| 467 |
+
<li><strong>Dados de imóveis:</strong> Anúncios criados, histórico de busca, imóveis salvos e detalhes de reservas.</li>
|
| 468 |
+
<li><strong>Dados de uso:</strong> Como você interage com nosso aplicativo, páginas visitadas e recursos utilizados.</li>
|
| 469 |
+
<li><strong>Informações do dispositivo:</strong> Tipo de dispositivo, sistema operacional e versão do aplicativo.</li>
|
| 470 |
+
<li><strong>Dados de localização:</strong> Com sua permissão, para mostrar imóveis próximos.</li>
|
| 471 |
+
</ul>
|
| 472 |
+
|
| 473 |
+
<h2>2. Como usamos suas informações</h2>
|
| 474 |
+
<ul>
|
| 475 |
+
<li>Fornecer e melhorar nossa plataforma de aluguel</li>
|
| 476 |
+
<li>Facilitar a comunicação entre inquilinos e proprietários</li>
|
| 477 |
+
<li>Alimentar a AIDA, nossa assistente de IA, para ajudá-lo a encontrar imóveis</li>
|
| 478 |
+
<li>Enviar notificações relevantes sobre anúncios e reservas</li>
|
| 479 |
+
<li>Processar pagamentos com segurança via Stripe</li>
|
| 480 |
+
<li>Prevenir fraudes e garantir a segurança da plataforma</li>
|
| 481 |
+
</ul>
|
| 482 |
+
|
| 483 |
+
<div class="highlight">Nunca vendemos seus dados pessoais a terceiros. Suas informações são usadas exclusivamente para melhorar sua experiência na Lojiz.</div>
|
| 484 |
+
|
| 485 |
+
<h2>3. Assistente de IA (AIDA)</h2>
|
| 486 |
+
<p>A AIDA usa suas preferências de busca e histórico de conversas para fornecer recomendações personalizadas. As conversas são processadas com segurança e não são compartilhadas.</p>
|
| 487 |
+
|
| 488 |
+
<h2>4. Compartilhamento de dados</h2>
|
| 489 |
+
<ul>
|
| 490 |
+
<li><strong>Proprietários/Inquilinos:</strong> Dados de contato ao iniciar uma reserva ou consulta.</li>
|
| 491 |
+
<li><strong>Processadores de pagamento:</strong> O Stripe processa pagamentos; não armazenamos dados completos do cartão.</li>
|
| 492 |
+
<li><strong>Provedores de serviço:</strong> Hospedagem, análises e serviços de e-mail.</li>
|
| 493 |
+
<li><strong>Requisitos legais:</strong> Quando exigido por lei.</li>
|
| 494 |
+
</ul>
|
| 495 |
+
|
| 496 |
+
<h2>5. Segurança dos dados</h2>
|
| 497 |
+
<ul>
|
| 498 |
+
<li>Criptografia de ponta a ponta para mensagens</li>
|
| 499 |
+
<li>Autenticação JWT segura</li>
|
| 500 |
+
<li>Auditorias de segurança regulares</li>
|
| 501 |
+
<li>Armazenamento de dados criptografado</li>
|
| 502 |
+
</ul>
|
| 503 |
+
|
| 504 |
+
<h2>6. Seus direitos</h2>
|
| 505 |
+
<ul>
|
| 506 |
+
<li>Acessar e baixar seus dados pessoais</li>
|
| 507 |
+
<li>Corrigir informações imprecisas</li>
|
| 508 |
+
<li>Excluir sua conta e dados associados</li>
|
| 509 |
+
<li>Recusar análises e comunicações de marketing</li>
|
| 510 |
+
<li>Retirar o consentimento para rastreamento de localização</li>
|
| 511 |
+
</ul>
|
| 512 |
+
|
| 513 |
+
<h2>7. Retenção de dados</h2>
|
| 514 |
+
<p>Mantemos seus dados enquanto sua conta estiver ativa. Após a exclusão, removemos os dados pessoais em 30 dias, exceto quando obrigações legais exigirem retenção mais longa.</p>
|
| 515 |
+
|
| 516 |
+
<h2>8. Privacidade de menores</h2>
|
| 517 |
+
<p>A Lojiz não é destinada a usuários menores de 18 anos.</p>
|
| 518 |
+
|
| 519 |
+
<h2>9. Alterações nesta política</h2>
|
| 520 |
+
<p>Podemos atualizar esta política periodicamente. Mudanças significativas serão notificadas pelo aplicativo ou e-mail.</p>
|
| 521 |
+
|
| 522 |
+
<div class="contact-box">
|
| 523 |
+
<p>Dúvidas sobre sua privacidade?</p>
|
| 524 |
+
<a href="mailto:privacy@lojiz.com">privacy@lojiz.com</a>
|
| 525 |
+
</div>
|
| 526 |
+
</div>"""
|
| 527 |
+
},
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
# ══════════════════════════════════════════════════════════════
|
| 532 |
+
# TERMS OF SERVICE
|
| 533 |
+
# ══════════════════════════════════════════════════════════════
|
| 534 |
+
|
| 535 |
+
TERMS_CONTENT = {
|
| 536 |
+
"en": {
|
| 537 |
+
"title": "Terms of Service",
|
| 538 |
+
"sections": """
|
| 539 |
+
<div class="header">
|
| 540 |
+
<h1>Terms of Service</h1>
|
| 541 |
+
<div class="updated">Last updated: {date}</div>
|
| 542 |
+
</div>
|
| 543 |
+
<div class="content">
|
| 544 |
+
<h2>1. Acceptance of Terms</h2>
|
| 545 |
+
<p>By accessing or using Lojiz, you agree to be bound by these Terms of Service. If you do not agree, please do not use our platform.</p>
|
| 546 |
+
|
| 547 |
+
<h2>2. Eligibility</h2>
|
| 548 |
+
<p>You must be at least 18 years old to use Lojiz. By creating an account, you represent that you meet this requirement.</p>
|
| 549 |
+
|
| 550 |
+
<h2>3. Account Responsibilities</h2>
|
| 551 |
+
<ul>
|
| 552 |
+
<li>You are responsible for maintaining the confidentiality of your account credentials.</li>
|
| 553 |
+
<li>You must provide accurate and current information.</li>
|
| 554 |
+
<li>You are responsible for all activities under your account.</li>
|
| 555 |
+
<li>Notify us immediately of any unauthorized access.</li>
|
| 556 |
+
</ul>
|
| 557 |
+
|
| 558 |
+
<h2>4. Property Listings</h2>
|
| 559 |
+
<h3>For Landlords:</h3>
|
| 560 |
+
<ul>
|
| 561 |
+
<li>You must have legal authority to list the property.</li>
|
| 562 |
+
<li>Listings must be accurate, truthful, and up-to-date.</li>
|
| 563 |
+
<li>Photos must represent the actual property.</li>
|
| 564 |
+
<li>Pricing must be transparent with no hidden fees.</li>
|
| 565 |
+
</ul>
|
| 566 |
+
<h3>For Renters:</h3>
|
| 567 |
+
<ul>
|
| 568 |
+
<li>Use the platform respectfully and in good faith.</li>
|
| 569 |
+
<li>Do not submit fraudulent bookings or inquiries.</li>
|
| 570 |
+
<li>Respect property rules set by landlords.</li>
|
| 571 |
+
</ul>
|
| 572 |
+
|
| 573 |
+
<div class="highlight">Lojiz acts as a platform connecting landlords and renters. We do not own, manage, or control any listed properties.</div>
|
| 574 |
+
|
| 575 |
+
<h2>5. AI Assistant (AIDA)</h2>
|
| 576 |
+
<p>AIDA provides property recommendations and assistance. While we strive for accuracy, AIDA's suggestions are informational and do not constitute professional real estate advice. Always verify property details independently.</p>
|
| 577 |
+
|
| 578 |
+
<h2>6. Payments & Fees</h2>
|
| 579 |
+
<ul>
|
| 580 |
+
<li>Payments are processed securely through Stripe.</li>
|
| 581 |
+
<li>Service fees may apply to bookings.</li>
|
| 582 |
+
<li>Cancellation and refund policies are specified per listing.</li>
|
| 583 |
+
<li>Landlords receive payouts according to their Stripe configuration.</li>
|
| 584 |
+
</ul>
|
| 585 |
+
|
| 586 |
+
<h2>7. Prohibited Conduct</h2>
|
| 587 |
+
<p>You may not:</p>
|
| 588 |
+
<ul>
|
| 589 |
+
<li>Post false, misleading, or discriminatory content</li>
|
| 590 |
+
<li>Harass, threaten, or abuse other users</li>
|
| 591 |
+
<li>Circumvent the platform to avoid fees</li>
|
| 592 |
+
<li>Use automated tools to scrape or manipulate the platform</li>
|
| 593 |
+
<li>Violate any applicable laws or regulations</li>
|
| 594 |
+
</ul>
|
| 595 |
+
|
| 596 |
+
<h2>8. Content Ownership</h2>
|
| 597 |
+
<p>You retain ownership of content you post (photos, descriptions). By posting, you grant Lojiz a non-exclusive license to display and promote your listings on the platform.</p>
|
| 598 |
+
|
| 599 |
+
<h2>9. Limitation of Liability</h2>
|
| 600 |
+
<p>Lojiz is provided "as is". We are not liable for:</p>
|
| 601 |
+
<ul>
|
| 602 |
+
<li>Property conditions or landlord/renter behavior</li>
|
| 603 |
+
<li>Disputes between users</li>
|
| 604 |
+
<li>Service interruptions or data loss</li>
|
| 605 |
+
<li>Third-party service failures</li>
|
| 606 |
+
</ul>
|
| 607 |
+
|
| 608 |
+
<h2>10. Account Termination</h2>
|
| 609 |
+
<p>We may suspend or terminate accounts that violate these terms. You may delete your account at any time through the app settings.</p>
|
| 610 |
+
|
| 611 |
+
<h2>11. Dispute Resolution</h2>
|
| 612 |
+
<p>Disputes will be resolved through good-faith negotiation first. If unresolved, disputes will be settled through binding arbitration.</p>
|
| 613 |
+
|
| 614 |
+
<h2>12. Governing Law</h2>
|
| 615 |
+
<p>These terms are governed by applicable laws. Any legal proceedings shall be conducted in the appropriate jurisdiction.</p>
|
| 616 |
+
|
| 617 |
+
<h2>13. Changes to Terms</h2>
|
| 618 |
+
<p>We may modify these terms at any time. Continued use after changes constitutes acceptance. Material changes will be notified via the app.</p>
|
| 619 |
+
|
| 620 |
+
<div class="contact-box">
|
| 621 |
+
<p>Questions about these terms?</p>
|
| 622 |
+
<a href="mailto:legal@lojiz.com">legal@lojiz.com</a>
|
| 623 |
+
</div>
|
| 624 |
+
</div>"""
|
| 625 |
+
},
|
| 626 |
+
"fr": {
|
| 627 |
+
"title": "Conditions d'Utilisation",
|
| 628 |
+
"sections": """
|
| 629 |
+
<div class="header">
|
| 630 |
+
<h1>Conditions d'Utilisation</h1>
|
| 631 |
+
<div class="updated">Dernière mise à jour : {date}</div>
|
| 632 |
+
</div>
|
| 633 |
+
<div class="content">
|
| 634 |
+
<h2>1. Acceptation des conditions</h2>
|
| 635 |
+
<p>En accédant ou en utilisant Lojiz, vous acceptez d'être lié par ces conditions. Si vous n'êtes pas d'accord, veuillez ne pas utiliser notre plateforme.</p>
|
| 636 |
+
|
| 637 |
+
<h2>2. Éligibilité</h2>
|
| 638 |
+
<p>Vous devez avoir au moins 18 ans pour utiliser Lojiz. En créant un compte, vous déclarez remplir cette condition.</p>
|
| 639 |
+
|
| 640 |
+
<h2>3. Responsabilités du compte</h2>
|
| 641 |
+
<ul>
|
| 642 |
+
<li>Vous êtes responsable de la confidentialité de vos identifiants.</li>
|
| 643 |
+
<li>Vous devez fournir des informations exactes et à jour.</li>
|
| 644 |
+
<li>Vous êtes responsable de toutes les activités sous votre compte.</li>
|
| 645 |
+
<li>Signalez immédiatement tout accès non autorisé.</li>
|
| 646 |
+
</ul>
|
| 647 |
+
|
| 648 |
+
<h2>4. Annonces immobilières</h2>
|
| 649 |
+
<h3>Pour les propriétaires :</h3>
|
| 650 |
+
<ul>
|
| 651 |
+
<li>Vous devez avoir l'autorité légale pour lister la propriété.</li>
|
| 652 |
+
<li>Les annonces doivent être exactes, véridiques et à jour.</li>
|
| 653 |
+
<li>Les photos doivent représenter la propriété réelle.</li>
|
| 654 |
+
<li>Les prix doivent être transparents sans frais cachés.</li>
|
| 655 |
+
</ul>
|
| 656 |
+
<h3>Pour les locataires :</h3>
|
| 657 |
+
<ul>
|
| 658 |
+
<li>Utilisez la plateforme respectueusement et de bonne foi.</li>
|
| 659 |
+
<li>Ne soumettez pas de réservations ou demandes frauduleuses.</li>
|
| 660 |
+
<li>Respectez les règles de propriété définies par les propriétaires.</li>
|
| 661 |
+
</ul>
|
| 662 |
+
|
| 663 |
+
<div class="highlight">Lojiz agit comme une plateforme connectant propriétaires et locataires. Nous ne possédons, ne gérons ni ne contrôlons les propriétés listées.</div>
|
| 664 |
+
|
| 665 |
+
<h2>5. Assistante IA (AIDA)</h2>
|
| 666 |
+
<p>AIDA fournit des recommandations et une assistance immobilière. Ses suggestions sont informatives et ne constituent pas un conseil immobilier professionnel.</p>
|
| 667 |
+
|
| 668 |
+
<h2>6. Paiements et frais</h2>
|
| 669 |
+
<ul>
|
| 670 |
+
<li>Les paiements sont traités de manière sécurisée via Stripe.</li>
|
| 671 |
+
<li>Des frais de service peuvent s'appliquer aux réservations.</li>
|
| 672 |
+
<li>Les politiques d'annulation et de remboursement sont spécifiées par annonce.</li>
|
| 673 |
+
<li>Les propriétaires reçoivent les paiements selon leur configuration Stripe.</li>
|
| 674 |
+
</ul>
|
| 675 |
+
|
| 676 |
+
<h2>7. Conduite interdite</h2>
|
| 677 |
+
<ul>
|
| 678 |
+
<li>Publier du contenu faux, trompeur ou discriminatoire</li>
|
| 679 |
+
<li>Harceler, menacer ou abuser d'autres utilisateurs</li>
|
| 680 |
+
<li>Contourner la plateforme pour éviter les frais</li>
|
| 681 |
+
<li>Utiliser des outils automatisés pour extraire ou manipuler la plateforme</li>
|
| 682 |
+
<li>Violer les lois ou réglementations applicables</li>
|
| 683 |
+
</ul>
|
| 684 |
+
|
| 685 |
+
<h2>8. Propriété du contenu</h2>
|
| 686 |
+
<p>Vous conservez la propriété du contenu que vous publiez. En publiant, vous accordez à Lojiz une licence non exclusive pour afficher et promouvoir vos annonces.</p>
|
| 687 |
+
|
| 688 |
+
<h2>9. Limitation de responsabilité</h2>
|
| 689 |
+
<p>Lojiz est fourni « tel quel ». Nous ne sommes pas responsables des conditions des propriétés, des litiges entre utilisateurs ou des interruptions de service.</p>
|
| 690 |
+
|
| 691 |
+
<h2>10. Résiliation de compte</h2>
|
| 692 |
+
<p>Nous pouvons suspendre ou résilier les comptes en violation. Vous pouvez supprimer votre compte à tout moment via les paramètres.</p>
|
| 693 |
+
|
| 694 |
+
<h2>11. Résolution des litiges</h2>
|
| 695 |
+
<p>Les litiges seront résolus par négociation de bonne foi d'abord, puis par arbitrage contraignant si nécessaire.</p>
|
| 696 |
+
|
| 697 |
+
<h2>12. Modifications des conditions</h2>
|
| 698 |
+
<p>Nous pouvons modifier ces conditions à tout moment. L'utilisation continue après les modifications constitue une acceptation.</p>
|
| 699 |
+
|
| 700 |
+
<div class="contact-box">
|
| 701 |
+
<p>Questions sur ces conditions ?</p>
|
| 702 |
+
<a href="mailto:legal@lojiz.com">legal@lojiz.com</a>
|
| 703 |
+
</div>
|
| 704 |
+
</div>"""
|
| 705 |
+
},
|
| 706 |
+
"es": {
|
| 707 |
+
"title": "Términos de Servicio",
|
| 708 |
+
"sections": """
|
| 709 |
+
<div class="header">
|
| 710 |
+
<h1>Términos de Servicio</h1>
|
| 711 |
+
<div class="updated">Última actualización: {date}</div>
|
| 712 |
+
</div>
|
| 713 |
+
<div class="content">
|
| 714 |
+
<h2>1. Aceptación de los términos</h2>
|
| 715 |
+
<p>Al acceder o usar Lojiz, aceptas estar sujeto a estos Términos de Servicio. Si no estás de acuerdo, no uses nuestra plataforma.</p>
|
| 716 |
+
|
| 717 |
+
<h2>2. Elegibilidad</h2>
|
| 718 |
+
<p>Debes tener al menos 18 años para usar Lojiz.</p>
|
| 719 |
+
|
| 720 |
+
<h2>3. Responsabilidades de la cuenta</h2>
|
| 721 |
+
<ul>
|
| 722 |
+
<li>Eres responsable de la confidencialidad de tus credenciales.</li>
|
| 723 |
+
<li>Debes proporcionar información precisa y actual.</li>
|
| 724 |
+
<li>Eres responsable de todas las actividades bajo tu cuenta.</li>
|
| 725 |
+
<li>Notifícanos inmediatamente de cualquier acceso no autorizado.</li>
|
| 726 |
+
</ul>
|
| 727 |
+
|
| 728 |
+
<h2>4. Anuncios de propiedades</h2>
|
| 729 |
+
<h3>Para propietarios:</h3>
|
| 730 |
+
<ul>
|
| 731 |
+
<li>Debes tener autoridad legal para listar la propiedad.</li>
|
| 732 |
+
<li>Los anuncios deben ser precisos, veraces y actualizados.</li>
|
| 733 |
+
<li>Las fotos deben representar la propiedad real.</li>
|
| 734 |
+
<li>Los precios deben ser transparentes sin cargos ocultos.</li>
|
| 735 |
+
</ul>
|
| 736 |
+
<h3>Para inquilinos:</h3>
|
| 737 |
+
<ul>
|
| 738 |
+
<li>Usa la plataforma respetuosamente y de buena fe.</li>
|
| 739 |
+
<li>No envíes reservas o consultas fraudulentas.</li>
|
| 740 |
+
</ul>
|
| 741 |
+
|
| 742 |
+
<div class="highlight">Lojiz actúa como plataforma que conecta propietarios e inquilinos. No poseemos, gestionamos ni controlamos las propiedades listadas.</div>
|
| 743 |
+
|
| 744 |
+
<h2>5. Asistente de IA (AIDA)</h2>
|
| 745 |
+
<p>AIDA proporciona recomendaciones e información. Sus sugerencias no constituyen asesoramiento inmobiliario profesional.</p>
|
| 746 |
+
|
| 747 |
+
<h2>6. Pagos y tarifas</h2>
|
| 748 |
+
<ul>
|
| 749 |
+
<li>Los pagos se procesan de forma segura a través de Stripe.</li>
|
| 750 |
+
<li>Pueden aplicarse tarifas de servicio a las reservas.</li>
|
| 751 |
+
<li>Las políticas de cancelación se especifican por anuncio.</li>
|
| 752 |
+
</ul>
|
| 753 |
+
|
| 754 |
+
<h2>7. Conducta prohibida</h2>
|
| 755 |
+
<ul>
|
| 756 |
+
<li>Publicar contenido falso, engañoso o discriminatorio</li>
|
| 757 |
+
<li>Acosar, amenazar o abusar de otros usuarios</li>
|
| 758 |
+
<li>Eludir la plataforma para evitar tarifas</li>
|
| 759 |
+
<li>Violar leyes o regulaciones aplicables</li>
|
| 760 |
+
</ul>
|
| 761 |
+
|
| 762 |
+
<h2>8. Propiedad del contenido</h2>
|
| 763 |
+
<p>Conservas la propiedad del contenido que publicas. Al publicar, otorgas a Lojiz una licencia no exclusiva para mostrar tus anuncios.</p>
|
| 764 |
+
|
| 765 |
+
<h2>9. Limitación de responsabilidad</h2>
|
| 766 |
+
<p>Lojiz se proporciona "tal cual". No somos responsables de las condiciones de las propiedades ni de disputas entre usuarios.</p>
|
| 767 |
+
|
| 768 |
+
<h2>10. Terminación de cuenta</h2>
|
| 769 |
+
<p>Podemos suspender cuentas que violen estos términos. Puedes eliminar tu cuenta en cualquier momento.</p>
|
| 770 |
+
|
| 771 |
+
<h2>11. Cambios en los términos</h2>
|
| 772 |
+
<p>Podemos modificar estos términos en cualquier momento. El uso continuado constituye aceptación.</p>
|
| 773 |
+
|
| 774 |
+
<div class="contact-box">
|
| 775 |
+
<p>¿Preguntas sobre estos términos?</p>
|
| 776 |
+
<a href="mailto:legal@lojiz.com">legal@lojiz.com</a>
|
| 777 |
+
</div>
|
| 778 |
+
</div>"""
|
| 779 |
+
},
|
| 780 |
+
"ar": {
|
| 781 |
+
"title": "شروط الخدمة",
|
| 782 |
+
"sections": """
|
| 783 |
+
<div class="header">
|
| 784 |
+
<h1>شروط الخدمة</h1>
|
| 785 |
+
<div class="updated">آخر تحديث: {date}</div>
|
| 786 |
+
</div>
|
| 787 |
+
<div class="content">
|
| 788 |
+
<h2>1. قبول الشروط</h2>
|
| 789 |
+
<p>باستخدام Lojiz، فإنك تواف�� على الالتزام بشروط الخدمة هذه. إذا لم توافق، يرجى عدم استخدام منصتنا.</p>
|
| 790 |
+
|
| 791 |
+
<h2>2. الأهلية</h2>
|
| 792 |
+
<p>يجب أن يكون عمرك 18 عاماً على الأقل لاستخدام Lojiz.</p>
|
| 793 |
+
|
| 794 |
+
<h2>3. مسؤوليات الحساب</h2>
|
| 795 |
+
<ul>
|
| 796 |
+
<li>أنت مسؤول عن الحفاظ على سرية بيانات اعتماد حسابك.</li>
|
| 797 |
+
<li>يجب تقديم معلومات دقيقة وحديثة.</li>
|
| 798 |
+
<li>أنت مسؤول عن جميع الأنشطة تحت حسابك.</li>
|
| 799 |
+
<li>أبلغنا فوراً عن أي وصول غير مصرح به.</li>
|
| 800 |
+
</ul>
|
| 801 |
+
|
| 802 |
+
<h2>4. إعلانات العقارات</h2>
|
| 803 |
+
<h3>للملاك:</h3>
|
| 804 |
+
<ul>
|
| 805 |
+
<li>يجب أن تملك السلطة القانونية لإدراج العقار.</li>
|
| 806 |
+
<li>يجب أن تكون الإعلانات دقيقة وصادقة ومحدثة.</li>
|
| 807 |
+
<li>يجب أن تمثل الصور العقار الفعلي.</li>
|
| 808 |
+
<li>يجب أن تكون الأسعار شفافة بدون رسوم مخفية.</li>
|
| 809 |
+
</ul>
|
| 810 |
+
<h3>للمستأجرين:</h3>
|
| 811 |
+
<ul>
|
| 812 |
+
<li>استخدم المنصة باحترام وحسن نية.</li>
|
| 813 |
+
<li>لا تقدم حجوزات أو استفسارات احتيالية.</li>
|
| 814 |
+
</ul>
|
| 815 |
+
|
| 816 |
+
<div class="highlight">Lojiz هي منصة تربط الملاك بالمستأجرين. نحن لا نملك أو ندير أو نتحكم في أي عقارات مدرجة.</div>
|
| 817 |
+
|
| 818 |
+
<h2>5. المساعدة الذكية (AIDA)</h2>
|
| 819 |
+
<p>تقدم AIDA توصيات ومعلومات. اقتراحاتها إرشادية ولا تشكل نصيحة عقارية مهنية.</p>
|
| 820 |
+
|
| 821 |
+
<h2>6. المدفوعات والرسوم</h2>
|
| 822 |
+
<ul>
|
| 823 |
+
<li>تتم معالجة المدفوعات بأمان عبر Stripe.</li>
|
| 824 |
+
<li>قد تنطبق رسوم خدمة على الحجوزات.</li>
|
| 825 |
+
<li>سياسات الإلغاء والاسترداد محددة لكل إعلان.</li>
|
| 826 |
+
</ul>
|
| 827 |
+
|
| 828 |
+
<h2>7. السلوك المحظور</h2>
|
| 829 |
+
<ul>
|
| 830 |
+
<li>نشر محتوى كاذب أو مضلل أو تمييزي</li>
|
| 831 |
+
<li>مضايقة أو تهديد أو إساءة معاملة المستخدمين الآخرين</li>
|
| 832 |
+
<li>التحايل على المنصة لتجنب الرسوم</li>
|
| 833 |
+
<li>انتهاك القوانين أو اللوائح المعمول بها</li>
|
| 834 |
+
</ul>
|
| 835 |
+
|
| 836 |
+
<h2>8. ملكية المحتوى</h2>
|
| 837 |
+
<p>تحتفظ بملكية المحتوى الذي تنشره. بالنشر، تمنح Lojiz ترخيصاً غير حصري لعرض إعلاناتك.</p>
|
| 838 |
+
|
| 839 |
+
<h2>9. تحديد المسؤولية</h2>
|
| 840 |
+
<p>يتم توفير Lojiz "كما هي". لسنا مسؤولين عن حالة العقارات أو النزاعات بين المستخدمين.</p>
|
| 841 |
+
|
| 842 |
+
<h2>10. إنهاء الحساب</h2>
|
| 843 |
+
<p>يمكننا تعليق الحسابات المخالفة. يمكنك حذف حسابك في أي وقت من الإعدادات.</p>
|
| 844 |
+
|
| 845 |
+
<h2>11. التغييرات على الشروط</h2>
|
| 846 |
+
<p>يمكننا تعديل هذه الشروط في أي وقت. الاستمرار في الاستخدام يعني القبول.</p>
|
| 847 |
+
|
| 848 |
+
<div class="contact-box">
|
| 849 |
+
<p>أسئلة حول هذه الشروط؟</p>
|
| 850 |
+
<a href="mailto:legal@lojiz.com">legal@lojiz.com</a>
|
| 851 |
+
</div>
|
| 852 |
+
</div>"""
|
| 853 |
+
},
|
| 854 |
+
"pt": {
|
| 855 |
+
"title": "Termos de Serviço",
|
| 856 |
+
"sections": """
|
| 857 |
+
<div class="header">
|
| 858 |
+
<h1>Termos de Serviço</h1>
|
| 859 |
+
<div class="updated">Última atualização: {date}</div>
|
| 860 |
+
</div>
|
| 861 |
+
<div class="content">
|
| 862 |
+
<h2>1. Aceitação dos termos</h2>
|
| 863 |
+
<p>Ao acessar ou usar a Lojiz, você concorda em estar vinculado a estes Termos de Serviço. Se não concordar, não use nossa plataforma.</p>
|
| 864 |
+
|
| 865 |
+
<h2>2. Elegibilidade</h2>
|
| 866 |
+
<p>Você deve ter pelo menos 18 anos para usar a Lojiz.</p>
|
| 867 |
+
|
| 868 |
+
<h2>3. Responsabilidades da conta</h2>
|
| 869 |
+
<ul>
|
| 870 |
+
<li>Você é responsável pela confidencialidade de suas credenciais.</li>
|
| 871 |
+
<li>Deve fornecer informações precisas e atuais.</li>
|
| 872 |
+
<li>É responsável por todas as atividades em sua conta.</li>
|
| 873 |
+
<li>Notifique-nos imediatamente sobre qualquer acesso não autorizado.</li>
|
| 874 |
+
</ul>
|
| 875 |
+
|
| 876 |
+
<h2>4. Anúncios de imóveis</h2>
|
| 877 |
+
<h3>Para proprietários:</h3>
|
| 878 |
+
<ul>
|
| 879 |
+
<li>Você deve ter autoridade legal para listar o imóvel.</li>
|
| 880 |
+
<li>Os anúncios devem ser precisos, verdadeiros e atualizados.</li>
|
| 881 |
+
<li>As fotos devem representar o imóvel real.</li>
|
| 882 |
+
<li>Os preços devem ser transparentes sem taxas ocultas.</li>
|
| 883 |
+
</ul>
|
| 884 |
+
<h3>Para inquilinos:</h3>
|
| 885 |
+
<ul>
|
| 886 |
+
<li>Use a plataforma com respeito e boa-fé.</li>
|
| 887 |
+
<li>Não envie reservas ou consultas fraudulentas.</li>
|
| 888 |
+
</ul>
|
| 889 |
+
|
| 890 |
+
<div class="highlight">A Lojiz atua como plataforma conectando proprietários e inquilinos. Não possuímos, gerenciamos ou controlamos os imóveis listados.</div>
|
| 891 |
+
|
| 892 |
+
<h2>5. Assistente de IA (AIDA)</h2>
|
| 893 |
+
<p>A AIDA fornece recomendações e informações. Suas sugestões não constituem consultoria imobiliária profissional.</p>
|
| 894 |
+
|
| 895 |
+
<h2>6. Pagamentos e taxas</h2>
|
| 896 |
+
<ul>
|
| 897 |
+
<li>Pagamentos processados com segurança via Stripe.</li>
|
| 898 |
+
<li>Taxas de serviço podem ser aplicadas às reservas.</li>
|
| 899 |
+
<li>Políticas de cancelamento especificadas por anúncio.</li>
|
| 900 |
+
</ul>
|
| 901 |
+
|
| 902 |
+
<h2>7. Conduta proibida</h2>
|
| 903 |
+
<ul>
|
| 904 |
+
<li>Publicar conteúdo falso, enganoso ou discriminatório</li>
|
| 905 |
+
<li>Assediar, ameaçar ou abusar de outros usuários</li>
|
| 906 |
+
<li>Contornar a plataforma para evitar taxas</li>
|
| 907 |
+
<li>Violar leis ou regulamentos aplicáveis</li>
|
| 908 |
+
</ul>
|
| 909 |
+
|
| 910 |
+
<h2>8. Propriedade do conteúdo</h2>
|
| 911 |
+
<p>Você mantém a propriedade do conteúdo publicado. Ao publicar, concede à Lojiz uma licença não exclusiva para exibir seus anúncios.</p>
|
| 912 |
+
|
| 913 |
+
<h2>9. Limitação de responsabilidade</h2>
|
| 914 |
+
<p>A Lojiz é fornecida "como está". Não somos responsáveis pelas condições dos imóveis ou disputas entre usuários.</p>
|
| 915 |
+
|
| 916 |
+
<h2>10. Encerramento de conta</h2>
|
| 917 |
+
<p>Podemos suspender contas que violem estes termos. Você pode excluir sua conta a qualquer momento.</p>
|
| 918 |
+
|
| 919 |
+
<h2>11. Alterações nos termos</h2>
|
| 920 |
+
<p>Podemos modificar estes termos a qualquer momento. O uso continuado constitui aceitação.</p>
|
| 921 |
+
|
| 922 |
+
<div class="contact-box">
|
| 923 |
+
<p>Dúvidas sobre estes termos?</p>
|
| 924 |
+
<a href="mailto:legal@lojiz.com">legal@lojiz.com</a>
|
| 925 |
+
</div>
|
| 926 |
+
</div>"""
|
| 927 |
+
},
|
| 928 |
+
}
|
| 929 |
+
|
| 930 |
+
|
| 931 |
+
# ══════════════════════════════════════════════════════════════
|
| 932 |
+
# ENDPOINTS
|
| 933 |
+
# ══════════════════════════════════════════════════════════════
|
| 934 |
+
|
| 935 |
+
@router.get("/privacy-policy", response_class=HTMLResponse)
|
| 936 |
+
async def privacy_policy(lang: str = Query("en", regex="^(en|fr|es|ar|pt)$")):
|
| 937 |
+
"""Serve localized Privacy Policy as styled HTML."""
|
| 938 |
+
content = PRIVACY_CONTENT.get(lang, PRIVACY_CONTENT["en"])
|
| 939 |
+
html_body = content["sections"].replace("{date}", LAST_UPDATED)
|
| 940 |
+
return HTMLResponse(_base_template(content["title"], html_body, lang))
|
| 941 |
+
|
| 942 |
+
|
| 943 |
+
@router.get("/terms-of-service", response_class=HTMLResponse)
|
| 944 |
+
async def terms_of_service(lang: str = Query("en", regex="^(en|fr|es|ar|pt)$")):
|
| 945 |
+
"""Serve localized Terms of Service as styled HTML."""
|
| 946 |
+
content = TERMS_CONTENT.get(lang, TERMS_CONTENT["en"])
|
| 947 |
+
html_body = content["sections"].replace("{date}", LAST_UPDATED)
|
| 948 |
+
return HTMLResponse(_base_template(content["title"], html_body, lang))
|
app/routes/notifications.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# app/routes/notifications.py – Notification preferences + FCM
|
| 3 |
+
# ============================================================
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from fastapi import APIRouter, HTTPException, status, Depends, Request
|
| 7 |
+
from pydantic import BaseModel, Field
|
| 8 |
+
from typing import Optional
|
| 9 |
+
from app.guards.jwt_guard import get_current_user
|
| 10 |
+
from app.database import get_db
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
from bson import ObjectId
|
| 13 |
+
|
| 14 |
+
router = APIRouter(tags=["Notifications"])
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# ============================================================
|
| 19 |
+
# DTOs
|
| 20 |
+
# ============================================================
|
| 21 |
+
|
| 22 |
+
class NotificationPrefsDto(BaseModel):
|
| 23 |
+
"""Notification preference toggles"""
|
| 24 |
+
master_enabled: bool = True
|
| 25 |
+
messages: bool = True
|
| 26 |
+
property_alerts: bool = True
|
| 27 |
+
booking_updates: bool = True
|
| 28 |
+
promotions: bool = False
|
| 29 |
+
push_enabled: bool = False
|
| 30 |
+
email_enabled: bool = False
|
| 31 |
+
voice_notes_enabled: bool = False
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class FCMTokenDto(BaseModel):
|
| 35 |
+
"""Register or update FCM device token"""
|
| 36 |
+
token: str = Field(..., min_length=10, max_length=500)
|
| 37 |
+
platform: str = Field(..., pattern="^(android|ios)$")
|
| 38 |
+
device_id: Optional[str] = None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# ============================================================
|
| 42 |
+
# NOTIFICATION PREFERENCES
|
| 43 |
+
# ============================================================
|
| 44 |
+
|
| 45 |
+
@router.get("/preferences", status_code=status.HTTP_200_OK)
|
| 46 |
+
async def get_notification_preferences(
|
| 47 |
+
request: Request,
|
| 48 |
+
current_user: dict = Depends(get_current_user),
|
| 49 |
+
):
|
| 50 |
+
"""Fetch user's notification preferences."""
|
| 51 |
+
user_id = current_user.get("user_id")
|
| 52 |
+
|
| 53 |
+
db = await get_db()
|
| 54 |
+
prefs = await db["notification_prefs"].find_one({"user_id": user_id})
|
| 55 |
+
|
| 56 |
+
if not prefs:
|
| 57 |
+
# Return defaults if no prefs saved yet
|
| 58 |
+
return {
|
| 59 |
+
"success": True,
|
| 60 |
+
"data": NotificationPrefsDto().model_dump(),
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
# Remove mongo fields
|
| 64 |
+
prefs.pop("_id", None)
|
| 65 |
+
prefs.pop("user_id", None)
|
| 66 |
+
prefs.pop("updatedAt", None)
|
| 67 |
+
|
| 68 |
+
return {
|
| 69 |
+
"success": True,
|
| 70 |
+
"data": prefs,
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@router.put("/preferences", status_code=status.HTTP_200_OK)
|
| 75 |
+
async def update_notification_preferences(
|
| 76 |
+
dto: NotificationPrefsDto,
|
| 77 |
+
request: Request,
|
| 78 |
+
current_user: dict = Depends(get_current_user),
|
| 79 |
+
):
|
| 80 |
+
"""Update user's notification preferences. Synced across devices."""
|
| 81 |
+
user_id = current_user.get("user_id")
|
| 82 |
+
|
| 83 |
+
db = await get_db()
|
| 84 |
+
prefs_data = {
|
| 85 |
+
**dto.model_dump(),
|
| 86 |
+
"user_id": user_id,
|
| 87 |
+
"updatedAt": datetime.utcnow(),
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
await db["notification_prefs"].update_one(
|
| 91 |
+
{"user_id": user_id},
|
| 92 |
+
{"$set": prefs_data},
|
| 93 |
+
upsert=True,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
logger.info(f"Notification prefs updated for user {user_id}")
|
| 97 |
+
|
| 98 |
+
return {
|
| 99 |
+
"success": True,
|
| 100 |
+
"message": "Notification preferences saved.",
|
| 101 |
+
"data": dto.model_dump(),
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ============================================================
|
| 106 |
+
# FCM TOKEN REGISTRATION
|
| 107 |
+
# ============================================================
|
| 108 |
+
|
| 109 |
+
@router.post("/fcm-token", status_code=status.HTTP_200_OK)
|
| 110 |
+
async def register_fcm_token(
|
| 111 |
+
dto: FCMTokenDto,
|
| 112 |
+
request: Request,
|
| 113 |
+
current_user: dict = Depends(get_current_user),
|
| 114 |
+
):
|
| 115 |
+
"""
|
| 116 |
+
Register or update FCM device token for push notifications.
|
| 117 |
+
Each user can have multiple tokens (one per device).
|
| 118 |
+
"""
|
| 119 |
+
user_id = current_user.get("user_id")
|
| 120 |
+
|
| 121 |
+
db = await get_db()
|
| 122 |
+
token_data = {
|
| 123 |
+
"user_id": user_id,
|
| 124 |
+
"token": dto.token,
|
| 125 |
+
"platform": dto.platform,
|
| 126 |
+
"device_id": dto.device_id,
|
| 127 |
+
"active": True,
|
| 128 |
+
"updatedAt": datetime.utcnow(),
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
# Upsert by token (same device updates, new device inserts)
|
| 132 |
+
await db["fcm_tokens"].update_one(
|
| 133 |
+
{"token": dto.token},
|
| 134 |
+
{"$set": token_data},
|
| 135 |
+
upsert=True,
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
logger.info(f"FCM token registered for user {user_id} ({dto.platform})")
|
| 139 |
+
|
| 140 |
+
return {
|
| 141 |
+
"success": True,
|
| 142 |
+
"message": "Push notification token registered.",
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@router.delete("/fcm-token", status_code=status.HTTP_200_OK)
|
| 147 |
+
async def unregister_fcm_token(
|
| 148 |
+
dto: FCMTokenDto,
|
| 149 |
+
request: Request,
|
| 150 |
+
current_user: dict = Depends(get_current_user),
|
| 151 |
+
):
|
| 152 |
+
"""Unregister FCM token (e.g., on logout)."""
|
| 153 |
+
user_id = current_user.get("user_id")
|
| 154 |
+
|
| 155 |
+
db = await get_db()
|
| 156 |
+
await db["fcm_tokens"].delete_one({
|
| 157 |
+
"user_id": user_id,
|
| 158 |
+
"token": dto.token,
|
| 159 |
+
})
|
| 160 |
+
|
| 161 |
+
logger.info(f"FCM token unregistered for user {user_id}")
|
| 162 |
+
|
| 163 |
+
return {
|
| 164 |
+
"success": True,
|
| 165 |
+
"message": "Push notification token removed.",
|
| 166 |
+
}
|
app/services/email_notification_service.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# app/services/email_notification_service.py
|
| 3 |
+
# Email notifications for bookings & property alerts only
|
| 4 |
+
# Uses Resend API (same as OTP emails)
|
| 5 |
+
# ============================================================
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
from typing import Optional
|
| 9 |
+
from app.database import get_db
|
| 10 |
+
from app.config import settings
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
# Categories that support email notifications
|
| 15 |
+
# Messages are excluded — email is only for bookings & alerts
|
| 16 |
+
EMAIL_ALLOWED_CATEGORIES = {"booking_updates", "property_alerts", "promotions"}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class EmailNotificationService:
|
| 20 |
+
"""Send email notifications via Resend for bookings and alerts."""
|
| 21 |
+
|
| 22 |
+
@staticmethod
|
| 23 |
+
async def should_send_email(user_id: str, category: str) -> bool:
|
| 24 |
+
"""Check if user has email notifications enabled for this category."""
|
| 25 |
+
# Never send email for messages
|
| 26 |
+
if category not in EMAIL_ALLOWED_CATEGORIES:
|
| 27 |
+
return False
|
| 28 |
+
|
| 29 |
+
db = await get_db()
|
| 30 |
+
prefs = await db["notification_prefs"].find_one({"user_id": user_id})
|
| 31 |
+
|
| 32 |
+
if not prefs:
|
| 33 |
+
return False # Default: email off
|
| 34 |
+
|
| 35 |
+
if not prefs.get("master_enabled", True):
|
| 36 |
+
return False
|
| 37 |
+
|
| 38 |
+
if not prefs.get("email_enabled", False):
|
| 39 |
+
return False
|
| 40 |
+
|
| 41 |
+
# Check category-specific toggle
|
| 42 |
+
return prefs.get(category, True)
|
| 43 |
+
|
| 44 |
+
@staticmethod
|
| 45 |
+
async def get_user_email(user_id: str) -> Optional[str]:
|
| 46 |
+
"""Get user's email address from the database."""
|
| 47 |
+
db = await get_db()
|
| 48 |
+
user = await db["users"].find_one({"_id": user_id})
|
| 49 |
+
if not user:
|
| 50 |
+
from bson import ObjectId
|
| 51 |
+
user = await db["users"].find_one({"_id": ObjectId(user_id)})
|
| 52 |
+
return user.get("email") if user else None
|
| 53 |
+
|
| 54 |
+
@staticmethod
|
| 55 |
+
async def send_email(
|
| 56 |
+
user_id: str,
|
| 57 |
+
subject: str,
|
| 58 |
+
body_html: str,
|
| 59 |
+
category: str = "booking_updates",
|
| 60 |
+
) -> bool:
|
| 61 |
+
"""
|
| 62 |
+
Send an email notification to a user.
|
| 63 |
+
|
| 64 |
+
Only sends if:
|
| 65 |
+
- Category is allowed (bookings, property alerts, promotions)
|
| 66 |
+
- User has master + email toggles enabled
|
| 67 |
+
- User has the specific category enabled
|
| 68 |
+
"""
|
| 69 |
+
if not await EmailNotificationService.should_send_email(user_id, category):
|
| 70 |
+
logger.debug(f"Email skipped (prefs) for user {user_id}, category={category}")
|
| 71 |
+
return False
|
| 72 |
+
|
| 73 |
+
email = await EmailNotificationService.get_user_email(user_id)
|
| 74 |
+
if not email:
|
| 75 |
+
logger.debug(f"Email skipped (no email) for user {user_id}")
|
| 76 |
+
return False
|
| 77 |
+
|
| 78 |
+
try:
|
| 79 |
+
import resend
|
| 80 |
+
resend.api_key = settings.RESEND_API_KEY
|
| 81 |
+
|
| 82 |
+
params = {
|
| 83 |
+
"from": f"{settings.RESEND_FROM_NAME} <{settings.RESEND_FROM_EMAIL}>",
|
| 84 |
+
"to": email,
|
| 85 |
+
"subject": subject,
|
| 86 |
+
"html": _wrap_email_template(subject, body_html),
|
| 87 |
+
}
|
| 88 |
+
resp = resend.Emails.send(params)
|
| 89 |
+
if resp.get("error"):
|
| 90 |
+
raise Exception(resp["error"].get("message", "Send failed"))
|
| 91 |
+
|
| 92 |
+
logger.info(f"Email notification sent to {email} ({category})")
|
| 93 |
+
return True
|
| 94 |
+
|
| 95 |
+
except ImportError:
|
| 96 |
+
logger.warning("resend package not installed")
|
| 97 |
+
return False
|
| 98 |
+
except Exception as e:
|
| 99 |
+
logger.error(f"Email notification failed for {email}: {e}")
|
| 100 |
+
return False
|
| 101 |
+
|
| 102 |
+
@staticmethod
|
| 103 |
+
async def notify_user(
|
| 104 |
+
user_id: str,
|
| 105 |
+
title: str,
|
| 106 |
+
body: str,
|
| 107 |
+
category: str = "booking_updates",
|
| 108 |
+
) -> bool:
|
| 109 |
+
"""
|
| 110 |
+
High-level: send a simple email notification.
|
| 111 |
+
Builds a clean HTML email from title + body text.
|
| 112 |
+
"""
|
| 113 |
+
body_html = f"<p style='font-size:16px;color:#333;line-height:1.6;'>{body}</p>"
|
| 114 |
+
return await EmailNotificationService.send_email(
|
| 115 |
+
user_id=user_id,
|
| 116 |
+
subject=title,
|
| 117 |
+
body_html=body_html,
|
| 118 |
+
category=category,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _wrap_email_template(title: str, content_html: str) -> str:
|
| 123 |
+
"""Wrap content in Lojiz branded email template."""
|
| 124 |
+
return f"""<!DOCTYPE html>
|
| 125 |
+
<html>
|
| 126 |
+
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"></head>
|
| 127 |
+
<body style="margin:0;padding:0;background:#f5f5fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
| 128 |
+
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5fa;padding:32px 16px;">
|
| 129 |
+
<tr><td align="center">
|
| 130 |
+
<table width="100%" style="max-width:520px;background:#ffffff;border-radius:16px;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,0.08);">
|
| 131 |
+
<!-- Header gradient -->
|
| 132 |
+
<tr><td style="background:linear-gradient(135deg,#764BA2,#FF305A);padding:28px 24px;text-align:center;">
|
| 133 |
+
<h1 style="margin:0;color:#ffffff;font-size:22px;font-weight:700;letter-spacing:-0.3px;">Lojiz</h1>
|
| 134 |
+
</td></tr>
|
| 135 |
+
<!-- Content -->
|
| 136 |
+
<tr><td style="padding:28px 24px;">
|
| 137 |
+
<h2 style="margin:0 0 16px;font-size:18px;font-weight:600;color:#1a1025;">{title}</h2>
|
| 138 |
+
{content_html}
|
| 139 |
+
</td></tr>
|
| 140 |
+
<!-- Footer -->
|
| 141 |
+
<tr><td style="padding:20px 24px;border-top:1px solid #eee;text-align:center;">
|
| 142 |
+
<p style="margin:0;font-size:12px;color:#999;">© 2026 Lojiz. All rights reserved.</p>
|
| 143 |
+
<p style="margin:8px 0 0;font-size:11px;color:#bbb;">You received this because email notifications are enabled in your Lojiz settings.</p>
|
| 144 |
+
</td></tr>
|
| 145 |
+
</table>
|
| 146 |
+
</td></tr>
|
| 147 |
+
</table>
|
| 148 |
+
</body>
|
| 149 |
+
</html>"""
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
email_notification_service = EmailNotificationService()
|
app/services/otp_service.py
CHANGED
|
@@ -105,8 +105,14 @@ class OTPService:
|
|
| 105 |
try:
|
| 106 |
template = self._generate_email_template(otp, purpose, language)
|
| 107 |
|
| 108 |
-
# Get localized subject
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
subject = get_translation(language, subject_key)
|
| 111 |
|
| 112 |
params = {
|
|
@@ -144,17 +150,19 @@ class OTPService:
|
|
| 144 |
current_year = datetime.now().year
|
| 145 |
expiry_minutes = settings.OTP_EXPIRY_MINUTES
|
| 146 |
|
| 147 |
-
# Get localized strings
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
|
|
|
|
|
|
| 158 |
|
| 159 |
# Check if RTL language (Arabic)
|
| 160 |
is_rtl = language == "ar"
|
|
@@ -200,7 +208,7 @@ class OTPService:
|
|
| 200 |
<p class="intro">{intro_text}</p>
|
| 201 |
<div class="otp-box">
|
| 202 |
<div class="otp-code">{otp}</div>
|
| 203 |
-
<div class="otp-expiry">{get_translation(language, "
|
| 204 |
</div>
|
| 205 |
<p class="warning">{footer_text}</p>
|
| 206 |
</div>
|
|
|
|
| 105 |
try:
|
| 106 |
template = self._generate_email_template(otp, purpose, language)
|
| 107 |
|
| 108 |
+
# Get localized subject based on purpose
|
| 109 |
+
subject_key_map = {
|
| 110 |
+
"signup": "otp_verify_subject",
|
| 111 |
+
"password_reset": "otp_reset_subject",
|
| 112 |
+
"email_update": "otp_email_update_subject",
|
| 113 |
+
"phone_update": "otp_phone_update_subject",
|
| 114 |
+
}
|
| 115 |
+
subject_key = subject_key_map.get(purpose, "otp_verify_subject")
|
| 116 |
subject = get_translation(language, subject_key)
|
| 117 |
|
| 118 |
params = {
|
|
|
|
| 150 |
current_year = datetime.now().year
|
| 151 |
expiry_minutes = settings.OTP_EXPIRY_MINUTES
|
| 152 |
|
| 153 |
+
# Get localized strings based on purpose
|
| 154 |
+
purpose_key_map = {
|
| 155 |
+
"signup": "otp_verify",
|
| 156 |
+
"password_reset": "otp_reset",
|
| 157 |
+
"email_update": "otp_email_update",
|
| 158 |
+
"phone_update": "otp_phone_update",
|
| 159 |
+
}
|
| 160 |
+
key_prefix = purpose_key_map.get(purpose, "otp_verify")
|
| 161 |
+
|
| 162 |
+
subject_line = get_translation(language, f"{key_prefix}_subject")
|
| 163 |
+
greeting = get_translation(language, f"{key_prefix}_greeting")
|
| 164 |
+
intro_text = get_translation(language, f"{key_prefix}_message")
|
| 165 |
+
footer_text = get_translation(language, f"{key_prefix}_footer")
|
| 166 |
|
| 167 |
# Check if RTL language (Arabic)
|
| 168 |
is_rtl = language == "ar"
|
|
|
|
| 208 |
<p class="intro">{intro_text}</p>
|
| 209 |
<div class="otp-box">
|
| 210 |
<div class="otp-code">{otp}</div>
|
| 211 |
+
<div class="otp-expiry">{get_translation(language, f"{key_prefix}_expiry")}</div>
|
| 212 |
</div>
|
| 213 |
<p class="warning">{footer_text}</p>
|
| 214 |
</div>
|
app/services/push_service.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# app/services/push_service.py – FCM Push Notification Service
|
| 3 |
+
# ============================================================
|
| 4 |
+
import os
|
| 5 |
+
import logging
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Optional
|
| 8 |
+
from app.database import get_db
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
# Try to initialize Firebase Admin
|
| 13 |
+
_firebase_app = None
|
| 14 |
+
try:
|
| 15 |
+
import firebase_admin
|
| 16 |
+
from firebase_admin import credentials, messaging
|
| 17 |
+
|
| 18 |
+
if not firebase_admin._apps:
|
| 19 |
+
# 1) Try explicit path from env
|
| 20 |
+
sa_path = os.getenv("FIREBASE_SERVICE_ACCOUNT_PATH", "")
|
| 21 |
+
if sa_path:
|
| 22 |
+
# Resolve relative to project root (where main.py lives)
|
| 23 |
+
full_path = Path(__file__).resolve().parents[2] / sa_path
|
| 24 |
+
if full_path.exists():
|
| 25 |
+
cred = credentials.Certificate(str(full_path))
|
| 26 |
+
_firebase_app = firebase_admin.initialize_app(cred)
|
| 27 |
+
logger.info(f"Firebase Admin SDK initialized from {full_path}")
|
| 28 |
+
else:
|
| 29 |
+
logger.warning(f"Firebase key not found at {full_path}")
|
| 30 |
+
# 2) Fallback: GOOGLE_APPLICATION_CREDENTIALS env var
|
| 31 |
+
if not _firebase_app and os.getenv("GOOGLE_APPLICATION_CREDENTIALS"):
|
| 32 |
+
cred = credentials.ApplicationDefault()
|
| 33 |
+
_firebase_app = firebase_admin.initialize_app(cred)
|
| 34 |
+
logger.info("Firebase Admin SDK initialized via GOOGLE_APPLICATION_CREDENTIALS")
|
| 35 |
+
|
| 36 |
+
if not _firebase_app:
|
| 37 |
+
logger.warning("No Firebase credentials found. Push notifications disabled.")
|
| 38 |
+
except ImportError:
|
| 39 |
+
logger.warning("firebase-admin not installed. Run: pip install firebase-admin")
|
| 40 |
+
except Exception as e:
|
| 41 |
+
logger.warning(f"Firebase Admin SDK init failed: {e}")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class PushService:
|
| 45 |
+
"""Send push notifications via Firebase Cloud Messaging."""
|
| 46 |
+
|
| 47 |
+
@staticmethod
|
| 48 |
+
async def get_user_tokens(user_id: str) -> list[str]:
|
| 49 |
+
"""Get all active FCM tokens for a user."""
|
| 50 |
+
db = await get_db()
|
| 51 |
+
cursor = db["fcm_tokens"].find({
|
| 52 |
+
"user_id": user_id,
|
| 53 |
+
"active": True,
|
| 54 |
+
})
|
| 55 |
+
tokens = []
|
| 56 |
+
async for doc in cursor:
|
| 57 |
+
tokens.append(doc["token"])
|
| 58 |
+
return tokens
|
| 59 |
+
|
| 60 |
+
@staticmethod
|
| 61 |
+
async def should_notify(user_id: str, category: str) -> bool:
|
| 62 |
+
"""
|
| 63 |
+
Check if user has enabled notifications for this category.
|
| 64 |
+
Categories: messages, property_alerts, booking_updates, promotions
|
| 65 |
+
"""
|
| 66 |
+
db = await get_db()
|
| 67 |
+
prefs = await db["notification_prefs"].find_one({"user_id": user_id})
|
| 68 |
+
|
| 69 |
+
if not prefs:
|
| 70 |
+
defaults = {
|
| 71 |
+
"messages": True,
|
| 72 |
+
"property_alerts": True,
|
| 73 |
+
"booking_updates": True,
|
| 74 |
+
"promotions": False,
|
| 75 |
+
}
|
| 76 |
+
return defaults.get(category, True)
|
| 77 |
+
|
| 78 |
+
if not prefs.get("master_enabled", True):
|
| 79 |
+
return False
|
| 80 |
+
if not prefs.get("push_enabled", False):
|
| 81 |
+
return False
|
| 82 |
+
|
| 83 |
+
return prefs.get(category, True)
|
| 84 |
+
|
| 85 |
+
@staticmethod
|
| 86 |
+
async def send_to_user(
|
| 87 |
+
user_id: str,
|
| 88 |
+
title: str,
|
| 89 |
+
body: str,
|
| 90 |
+
category: str = "messages",
|
| 91 |
+
data: Optional[dict] = None,
|
| 92 |
+
) -> int:
|
| 93 |
+
"""
|
| 94 |
+
Send push notification to all of a user's devices.
|
| 95 |
+
Returns number of devices successfully notified.
|
| 96 |
+
"""
|
| 97 |
+
if not _firebase_app:
|
| 98 |
+
logger.debug(f"Push skipped (Firebase not configured): {title}")
|
| 99 |
+
return 0
|
| 100 |
+
|
| 101 |
+
if not await PushService.should_notify(user_id, category):
|
| 102 |
+
logger.debug(f"Push skipped (user opted out of {category}): {user_id}")
|
| 103 |
+
return 0
|
| 104 |
+
|
| 105 |
+
tokens = await PushService.get_user_tokens(user_id)
|
| 106 |
+
if not tokens:
|
| 107 |
+
return 0
|
| 108 |
+
|
| 109 |
+
from firebase_admin import messaging
|
| 110 |
+
|
| 111 |
+
notification = messaging.Notification(title=title, body=body)
|
| 112 |
+
|
| 113 |
+
android_config = messaging.AndroidConfig(
|
| 114 |
+
priority="high",
|
| 115 |
+
notification=messaging.AndroidNotification(
|
| 116 |
+
sound="default",
|
| 117 |
+
channel_id="lojiz_default",
|
| 118 |
+
),
|
| 119 |
+
)
|
| 120 |
+
apns_config = messaging.APNSConfig(
|
| 121 |
+
payload=messaging.APNSPayload(
|
| 122 |
+
aps=messaging.Aps(sound="default", badge=1),
|
| 123 |
+
),
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
success_count = 0
|
| 127 |
+
stale_tokens = []
|
| 128 |
+
|
| 129 |
+
for token in tokens:
|
| 130 |
+
message = messaging.Message(
|
| 131 |
+
notification=notification,
|
| 132 |
+
android=android_config,
|
| 133 |
+
apns=apns_config,
|
| 134 |
+
token=token,
|
| 135 |
+
data=data or {},
|
| 136 |
+
)
|
| 137 |
+
try:
|
| 138 |
+
messaging.send(message)
|
| 139 |
+
success_count += 1
|
| 140 |
+
except messaging.UnregisteredError:
|
| 141 |
+
stale_tokens.append(token)
|
| 142 |
+
except Exception as e:
|
| 143 |
+
logger.error(f"FCM send failed for token {token[:20]}...: {e}")
|
| 144 |
+
|
| 145 |
+
# Clean up stale tokens
|
| 146 |
+
if stale_tokens:
|
| 147 |
+
db = await get_db()
|
| 148 |
+
await db["fcm_tokens"].delete_many({"token": {"$in": stale_tokens}})
|
| 149 |
+
logger.info(f"Cleaned {len(stale_tokens)} stale FCM tokens for user {user_id}")
|
| 150 |
+
|
| 151 |
+
logger.info(f"Push sent to {success_count}/{len(tokens)} devices for user {user_id}")
|
| 152 |
+
return success_count
|
| 153 |
+
|
| 154 |
+
@staticmethod
|
| 155 |
+
async def send_to_many(
|
| 156 |
+
user_ids: list[str],
|
| 157 |
+
title: str,
|
| 158 |
+
body: str,
|
| 159 |
+
category: str = "messages",
|
| 160 |
+
data: Optional[dict] = None,
|
| 161 |
+
) -> int:
|
| 162 |
+
"""Send push notification to multiple users."""
|
| 163 |
+
total = 0
|
| 164 |
+
for user_id in user_ids:
|
| 165 |
+
total += await PushService.send_to_user(user_id, title, body, category, data)
|
| 166 |
+
return total
|
| 167 |
+
|
| 168 |
+
@staticmethod
|
| 169 |
+
async def notify_user(
|
| 170 |
+
user_id: str,
|
| 171 |
+
title: str,
|
| 172 |
+
body: str,
|
| 173 |
+
category: str = "messages",
|
| 174 |
+
data: Optional[dict] = None,
|
| 175 |
+
) -> dict:
|
| 176 |
+
"""
|
| 177 |
+
Unified notification: sends push + email (for allowed categories).
|
| 178 |
+
Email is only sent for booking_updates, property_alerts, promotions.
|
| 179 |
+
Messages never trigger email.
|
| 180 |
+
"""
|
| 181 |
+
push_count = await PushService.send_to_user(
|
| 182 |
+
user_id, title, body, category, data,
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
email_sent = False
|
| 186 |
+
try:
|
| 187 |
+
from app.services.email_notification_service import email_notification_service
|
| 188 |
+
email_sent = await email_notification_service.notify_user(
|
| 189 |
+
user_id, title, body, category,
|
| 190 |
+
)
|
| 191 |
+
except Exception as e:
|
| 192 |
+
logger.error(f"Email notification error for user {user_id}: {e}")
|
| 193 |
+
|
| 194 |
+
return {"push_devices": push_count, "email_sent": email_sent}
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
push_service = PushService()
|
main.py
CHANGED
|
@@ -358,10 +358,25 @@ app.include_router(wishlist_router, prefix="/api/wishlist", tags=["Wishlist"])
|
|
| 358 |
app.include_router(booking_router, prefix="/api/bookings", tags=["Bookings"])
|
| 359 |
app.include_router(stripe_connect.router, prefix="/api/stripe", tags=["Stripe Connect"])
|
| 360 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
from app.routes.quota import router as quota_router
|
| 362 |
app.include_router(quota_router, prefix="/api/quota", tags=["Quota"])
|
| 363 |
logger.info("✅ Quota router registered at /api/quota")
|
| 364 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
logger.info("✅ Conversations router registered at /api/conversations")
|
| 366 |
logger.info("✅ Wishlist router registered at /api/wishlist")
|
| 367 |
logger.info("✅ Bookings router registered at /api/bookings")
|
|
|
|
| 358 |
app.include_router(booking_router, prefix="/api/bookings", tags=["Bookings"])
|
| 359 |
app.include_router(stripe_connect.router, prefix="/api/stripe", tags=["Stripe Connect"])
|
| 360 |
|
| 361 |
+
# Contact Update (Email & Phone)
|
| 362 |
+
from app.routes.contact_update import router as contact_update_router
|
| 363 |
+
app.include_router(contact_update_router, prefix="/api/contact", tags=["Contact Update"])
|
| 364 |
+
logger.info("✅ Contact Update router registered at /api/contact")
|
| 365 |
+
|
| 366 |
from app.routes.quota import router as quota_router
|
| 367 |
app.include_router(quota_router, prefix="/api/quota", tags=["Quota"])
|
| 368 |
logger.info("✅ Quota router registered at /api/quota")
|
| 369 |
|
| 370 |
+
# Legal Pages (Privacy Policy & Terms of Service)
|
| 371 |
+
from app.routes.legal import router as legal_router
|
| 372 |
+
app.include_router(legal_router, prefix="/api/legal", tags=["Legal"])
|
| 373 |
+
logger.info("✅ Legal pages router registered at /api/legal")
|
| 374 |
+
|
| 375 |
+
# Notification Preferences + FCM
|
| 376 |
+
from app.routes.notifications import router as notifications_router
|
| 377 |
+
app.include_router(notifications_router, prefix="/api/notifications", tags=["Notifications"])
|
| 378 |
+
logger.info("✅ Notifications router registered at /api/notifications")
|
| 379 |
+
|
| 380 |
logger.info("✅ Conversations router registered at /api/conversations")
|
| 381 |
logger.info("✅ Wishlist router registered at /api/wishlist")
|
| 382 |
logger.info("✅ Bookings router registered at /api/bookings")
|
push/louezmoi-5d0f0-firebase-adminsdk-fbsvc-7411d58511.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"type": "service_account",
|
| 3 |
+
"project_id": "louezmoi-5d0f0",
|
| 4 |
+
"private_key_id": "7411d5851127bbf9c774a497cab76dee887d1b01",
|
| 5 |
+
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDMQkVUUz9uDYPG\nD5Ch3PBUEgWDb5DTmIftsKWVa4jFQQIfkXcBFw7VWRjIUK5opbEJq/iJnsCIEkiy\nN7wu/LdTed6a/xoY3RNPUPHuDG/cJd1tThdZK10aunz460yGG1mjlKLPryqmp5WY\nhpnofmS2gFMymGCp4Kb0CtqRDgBVCAkJd5NXPpkU3iGnPo9hUENjtvwMhRK/FzLn\nJyDmSyoq7yR5Xdd1uZJa7VKzyNoqxOL9PUQSB/lI0Y8UKSdiKSBNSw5MAcapX7TJ\n/gQLSRqEUxMbEgMYT0aScDY5gl0fUBg02ASIgepTu11eIGfm6IHK0lfl4aU2MtMn\nWsKMjNLzAgMBAAECggEAAax1VviLp4Iy1zefbsQZsAapxubuH927F2aAJNZJu5wJ\nwWp2uCqRM4xUWKfYlgDtj2+6TJ5hY3wK3nL4dsYZVnTELy7x7sw48UhvgwzVCc/B\nnbirFk71p1dp2LxM1zh4Jo/8OYls5wqufh50OmR2vMwWYm6/NAUkORGxssiTlh7D\n+WZeep48XI/izUGLasEd1nq3mdi1YyktN1cRCd8BAARDDmELUZZOg3eH+TrM8rAv\nomyvBRBKAkeqcTGstEoxTtCF1ecFV5aaXV/Lx43QqYEKg5b8I/sVoEF/iqCFKIwf\ng2dmgN3F2ho7EWc6m73kZ8Yj8j95hV5/IsyBa9fvEQKBgQD45FUbnr6pGpkHRXmp\nmAI4amKxBWSLivbMjay8F9EBaB/ugJLRmxbmssxgpd2mt9H1YJz57/1GTTIESfj9\nlnpUOkk1luNZNUbRaraP4kr+xs2j/ZbxXECl7fDEFbfzovzxyH/K+CGravbo8NRL\nfUUXo76ZMgU/iGcVou3fl59W+wKBgQDSF59tPSojFVM7NUTQqwRYZUfcKNOqEQ6U\n0m5LkenGEdIJVMCt1gv6bIRe3HKBZgN1qdKcP+2Voa1/5Fop0AXAoSa4kHrLjd3M\nBg4sfCK2heLc7/RJDP4hMgZ0drYD6VwtzK8IVZt+X26QQFgGBUm4fois4PLpzjRg\n5Grf7hWSaQKBgQCC6V6Wm4W/Xn0VJQbcHFc2o0Zrw+FhwGMVroMnoANQERuSkFAp\nLcD68RqszzFpqpAl135ZvFBktN/U/6+Wepq3SL0PEeFdH7ezGMUTozw9DAZH9m95\nXkOmwyN7j8zkulr/Na157c+P4UAjv+B6IFbCG196JqQ6CZ3m9H7Xt2GZFwKBgA+M\nM/4vkpi6EUjGbmtK3K12AVVnqBJj5MJauFLbX4OK+hI6tW+n8Yb81Ob4RXkSBZuF\nj9KEYJ68MkzOp2WEwYg55NWvy4aY3xskL5RXM3JyghF7qIZncb8fJp8j0eCEOpAh\nH6rsOu+lPFdsi5cWeRgwNQkmNJPc8L9i6A3eXrZRAoGAAjJuAGNUO+YiMq2LjiHJ\nEGFjbdiPKiKpItkWsqf/OkJ8uEOaYEvYsjV+x53p0f09hyTTgBM+QV9lKWqcBp35\nZlDGZmd+TNT1rgGadh5smCe7Vgzh4jzjZwA3GvYky3lBk+iKI9HeOlidlHTPJCwm\nh1js/XYyzwagdUkpqtIBrtw=\n-----END PRIVATE KEY-----\n",
|
| 6 |
+
"client_email": "firebase-adminsdk-fbsvc@louezmoi-5d0f0.iam.gserviceaccount.com",
|
| 7 |
+
"client_id": "109525111802797881931",
|
| 8 |
+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
| 9 |
+
"token_uri": "https://oauth2.googleapis.com/token",
|
| 10 |
+
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
| 11 |
+
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40louezmoi-5d0f0.iam.gserviceaccount.com",
|
| 12 |
+
"universe_domain": "googleapis.com"
|
| 13 |
+
}
|