mirror of
http://88.130.71.182:3000/BlitTech/deals24togo_be.git
synced 2026-06-12 23:33:21 +00:00
Initial commit
This commit is contained in:
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
134
app/services/agency_service.py
Normal file
134
app/services/agency_service.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Agency CRUD service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.core.exceptions import ForbiddenException, NotFoundException
|
||||
from app.core.supabase import get_supabase_admin
|
||||
|
||||
|
||||
class AgencyService:
|
||||
def __init__(self):
|
||||
self.db = get_supabase_admin()
|
||||
|
||||
def get_agency(self, agency_id: str) -> dict:
|
||||
result = self.db.table("agencies").select("*").eq("id", agency_id).execute()
|
||||
if not result.data:
|
||||
raise NotFoundException("Agency not found")
|
||||
return result.data[0]
|
||||
|
||||
def get_agency_by_user(self, user_id: str) -> dict:
|
||||
result = self.db.table("agencies").select("*").eq("user_id", user_id).execute()
|
||||
if not result.data:
|
||||
raise NotFoundException("Agency not found for this user")
|
||||
return result.data[0]
|
||||
|
||||
def create_agency(self, user_id: str, data: dict) -> dict:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
agency_data = {
|
||||
"user_id": user_id,
|
||||
**data,
|
||||
"verified": False,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
result = self.db.table("agencies").insert(agency_data).execute()
|
||||
if not result.data:
|
||||
raise Exception("Failed to create agency")
|
||||
return result.data[0]
|
||||
|
||||
def update_agency(self, agency_id: str, user_id: str, user_role: str, data: dict) -> dict:
|
||||
# Check ownership or admin
|
||||
agency = self.get_agency(agency_id)
|
||||
if user_role != "admin" and agency["user_id"] != user_id:
|
||||
raise ForbiddenException("Not authorized to update this agency")
|
||||
|
||||
update_data = {k: v for k, v in data.items() if v is not None}
|
||||
if not update_data:
|
||||
return agency
|
||||
|
||||
update_data["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
result = (
|
||||
self.db.table("agencies")
|
||||
.update(update_data)
|
||||
.eq("id", agency_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise NotFoundException("Agency not found")
|
||||
return result.data[0]
|
||||
|
||||
def list_agencies(
|
||||
self,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
verified_only: bool = False,
|
||||
) -> dict:
|
||||
query = self.db.table("agencies").select("*", count="exact")
|
||||
if verified_only:
|
||||
query = query.eq("verified", True)
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
result = (
|
||||
query.order("created_at", desc=True)
|
||||
.range(offset, offset + page_size - 1)
|
||||
.execute()
|
||||
)
|
||||
|
||||
return {
|
||||
"agencies": result.data,
|
||||
"total": result.count or 0,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
def verify_agency(self, agency_id: str, requester_role: str) -> dict:
|
||||
if requester_role != "admin":
|
||||
raise ForbiddenException("Only admins can verify agencies")
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
result = (
|
||||
self.db.table("agencies")
|
||||
.update({"verified": True, "updated_at": now})
|
||||
.eq("id", agency_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise NotFoundException("Agency not found")
|
||||
|
||||
# Also verify the associated user
|
||||
agency = result.data[0]
|
||||
self.db.table("users").update(
|
||||
{"verified": True, "updated_at": now}
|
||||
).eq("id", agency["user_id"]).execute()
|
||||
|
||||
return result.data[0]
|
||||
|
||||
def revoke_verification(self, agency_id: str, requester_role: str) -> dict:
|
||||
if requester_role != "admin":
|
||||
raise ForbiddenException("Only admins can revoke agency verification")
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
result = (
|
||||
self.db.table("agencies")
|
||||
.update({"verified": False, "updated_at": now})
|
||||
.eq("id", agency_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise NotFoundException("Agency not found")
|
||||
return result.data[0]
|
||||
|
||||
def delete_agency(self, agency_id: str) -> dict:
|
||||
# Fetch first to get user_id for cascade deletion
|
||||
agency_result = self.db.table("agencies").select("user_id").eq("id", agency_id).execute()
|
||||
if not agency_result.data:
|
||||
raise NotFoundException("Agency not found")
|
||||
user_id = agency_result.data[0]["user_id"]
|
||||
|
||||
# Delete messages belonging to this agency (avoids orphaned rows)
|
||||
self.db.table("messages").delete().eq("agency_id", agency_id).execute()
|
||||
self.db.table("agencies").delete().eq("id", agency_id).execute()
|
||||
# Delete the associated user account to avoid orphaned records
|
||||
self.db.table("users").delete().eq("id", user_id).execute()
|
||||
return {"message": "Agency deleted"}
|
||||
183
app/services/auth_service.py
Normal file
183
app/services/auth_service.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""Authentication service — register, login, refresh, password reset."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from gotrue.errors import AuthApiError
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import (
|
||||
BadRequestException,
|
||||
UnauthorizedException,
|
||||
)
|
||||
from app.core.supabase import get_supabase_admin, get_supabase_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthService:
|
||||
# ── Register ─────────────────────────────────────────
|
||||
|
||||
def register(self, email: str, password: str, name: str, role: str = "visitor") -> dict:
|
||||
client = get_supabase_client()
|
||||
try:
|
||||
response = client.auth.sign_up({"email": email, "password": password})
|
||||
except AuthApiError as exc:
|
||||
raise BadRequestException(str(exc))
|
||||
|
||||
auth_user = response.user
|
||||
if not auth_user:
|
||||
raise BadRequestException("Registration failed — no user returned")
|
||||
|
||||
db = get_supabase_admin()
|
||||
user_data = {
|
||||
"id": auth_user.id,
|
||||
"email": email.lower(),
|
||||
"name": name,
|
||||
"role": role,
|
||||
"verified": False,
|
||||
}
|
||||
result = db.table("users").insert(user_data).execute()
|
||||
if not result.data:
|
||||
raise BadRequestException("Failed to create user profile")
|
||||
|
||||
user = result.data[0]
|
||||
|
||||
if role == "agency":
|
||||
agency_data = {
|
||||
"user_id": auth_user.id,
|
||||
"name": name,
|
||||
"description": "",
|
||||
"address": "",
|
||||
"phone": "",
|
||||
"email": email.lower(),
|
||||
"verified": False,
|
||||
}
|
||||
db.table("agencies").insert(agency_data).execute()
|
||||
|
||||
return {
|
||||
"message": "Registration successful. Please check your email to verify your account.",
|
||||
"user": self._sanitize_user(user),
|
||||
}
|
||||
|
||||
# ── Login ────────────────────────────────────────────
|
||||
|
||||
def login(self, email: str, password: str) -> dict:
|
||||
client = get_supabase_client()
|
||||
try:
|
||||
response = client.auth.sign_in_with_password({"email": email, "password": password})
|
||||
except AuthApiError:
|
||||
raise UnauthorizedException("Invalid email or password")
|
||||
|
||||
session = response.session
|
||||
auth_user = response.user
|
||||
|
||||
db = get_supabase_admin()
|
||||
result = db.table("users").select("*").eq("id", auth_user.id).execute()
|
||||
if not result.data:
|
||||
raise UnauthorizedException("User profile not found")
|
||||
|
||||
user = result.data[0]
|
||||
|
||||
# Sync verified flag if Supabase has confirmed the email
|
||||
if auth_user.email_confirmed_at and not user.get("verified"):
|
||||
db.table("users").update({"verified": True}).eq("id", auth_user.id).execute()
|
||||
user["verified"] = True
|
||||
|
||||
return {
|
||||
"access_token": session.access_token,
|
||||
"refresh_token": session.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"user": self._sanitize_user(user),
|
||||
}
|
||||
|
||||
# ── Refresh ──────────────────────────────────────────
|
||||
|
||||
def refresh(self, refresh_token: str) -> dict:
|
||||
client = get_supabase_client()
|
||||
try:
|
||||
response = client.auth.refresh_session(refresh_token)
|
||||
except AuthApiError:
|
||||
raise UnauthorizedException("Invalid or expired refresh token")
|
||||
|
||||
session = response.session
|
||||
auth_user = response.user
|
||||
|
||||
db = get_supabase_admin()
|
||||
result = db.table("users").select("*").eq("id", auth_user.id).execute()
|
||||
if not result.data:
|
||||
raise UnauthorizedException("User profile not found")
|
||||
|
||||
user = result.data[0]
|
||||
return {
|
||||
"access_token": session.access_token,
|
||||
"refresh_token": session.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"user": self._sanitize_user(user),
|
||||
}
|
||||
|
||||
# ── Password reset ───────────────────────────────────
|
||||
|
||||
def request_password_reset(self, email: str) -> str:
|
||||
settings = get_settings()
|
||||
redirect_to = f"{settings.FRONTEND_URL}/reset-password"
|
||||
try:
|
||||
get_supabase_client().auth.reset_password_for_email(
|
||||
email, {"redirect_to": redirect_to}
|
||||
)
|
||||
except AuthApiError as exc:
|
||||
logger.warning("Password reset request error for %s: %s", email, exc)
|
||||
return "If an account with that email exists, a reset link has been sent."
|
||||
|
||||
def reset_password(self, user_id: str, new_password: str) -> dict:
|
||||
try:
|
||||
get_supabase_admin().auth.admin.update_user_by_id(
|
||||
user_id, {"password": new_password}
|
||||
)
|
||||
except AuthApiError as exc:
|
||||
raise BadRequestException(str(exc))
|
||||
return {"message": "Password has been reset successfully"}
|
||||
|
||||
# ── Change password ──────────────────────────────────
|
||||
|
||||
def change_password(
|
||||
self, user_id: str, email: str, current_password: str, new_password: str
|
||||
) -> dict:
|
||||
# Re-authenticate to verify the current password
|
||||
client = get_supabase_client()
|
||||
try:
|
||||
client.auth.sign_in_with_password({"email": email, "password": current_password})
|
||||
except AuthApiError:
|
||||
raise BadRequestException("Current password is incorrect")
|
||||
|
||||
try:
|
||||
get_supabase_admin().auth.admin.update_user_by_id(
|
||||
user_id, {"password": new_password}
|
||||
)
|
||||
except AuthApiError as exc:
|
||||
raise BadRequestException(str(exc))
|
||||
|
||||
return {"message": "Password changed successfully"}
|
||||
|
||||
# ── Resend verification ──────────────────────────────
|
||||
|
||||
def resend_verification(self, email: str) -> dict:
|
||||
try:
|
||||
get_supabase_client().auth.resend({"type": "signup", "email": email})
|
||||
except AuthApiError as exc:
|
||||
logger.warning("Failed to resend verification to %s: %s", email, exc)
|
||||
return {"message": "Verification email sent"}
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_user(user: dict) -> dict:
|
||||
return {
|
||||
"id": user["id"],
|
||||
"email": user["email"],
|
||||
"name": user["name"],
|
||||
"role": user["role"],
|
||||
"verified": user["verified"],
|
||||
"created_at": user.get("created_at"),
|
||||
}
|
||||
108
app/services/category_service.py
Normal file
108
app/services/category_service.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Category CRUD service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.core.exceptions import ConflictException, NotFoundException
|
||||
from app.core.supabase import get_supabase_admin
|
||||
|
||||
|
||||
class CategoryService:
|
||||
def __init__(self):
|
||||
self.db = get_supabase_admin()
|
||||
|
||||
def get_category(self, category_id: str) -> dict:
|
||||
result = self.db.table("categories").select("*").eq("id", category_id).execute()
|
||||
if not result.data:
|
||||
raise NotFoundException("Category not found")
|
||||
return result.data[0]
|
||||
|
||||
def get_category_by_slug(self, slug: str) -> dict:
|
||||
result = self.db.table("categories").select("*").eq("slug", slug).execute()
|
||||
if not result.data:
|
||||
raise NotFoundException("Category not found")
|
||||
return result.data[0]
|
||||
|
||||
def list_categories(self) -> dict:
|
||||
result = (
|
||||
self.db.table("categories")
|
||||
.select("*", count="exact")
|
||||
.order("name")
|
||||
.execute()
|
||||
)
|
||||
# Count listings per category in one extra query
|
||||
listings_result = self.db.table("listings").select("category_id").execute()
|
||||
counts: dict[str, int] = {}
|
||||
for row in listings_result.data or []:
|
||||
cid = row.get("category_id")
|
||||
if cid:
|
||||
counts[cid] = counts.get(cid, 0) + 1
|
||||
categories = result.data or []
|
||||
for cat in categories:
|
||||
cat["listing_count"] = counts.get(cat["id"], 0)
|
||||
return {"categories": categories, "total": result.count or 0}
|
||||
|
||||
def create_category(self, data: dict) -> dict:
|
||||
# Check slug uniqueness
|
||||
existing = (
|
||||
self.db.table("categories")
|
||||
.select("id")
|
||||
.eq("slug", data["slug"])
|
||||
.execute()
|
||||
)
|
||||
if existing.data:
|
||||
raise ConflictException("A category with this slug already exists")
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
cat_data = {**data, "created_at": now, "updated_at": now}
|
||||
result = self.db.table("categories").insert(cat_data).execute()
|
||||
if not result.data:
|
||||
raise Exception("Failed to create category")
|
||||
return result.data[0]
|
||||
|
||||
def update_category(self, category_id: str, data: dict) -> dict:
|
||||
update_data = {k: v for k, v in data.items() if v is not None}
|
||||
if not update_data:
|
||||
return self.get_category(category_id)
|
||||
|
||||
# Check slug uniqueness if changing
|
||||
if "slug" in update_data:
|
||||
existing = (
|
||||
self.db.table("categories")
|
||||
.select("id")
|
||||
.eq("slug", update_data["slug"])
|
||||
.neq("id", category_id)
|
||||
.execute()
|
||||
)
|
||||
if existing.data:
|
||||
raise ConflictException("A category with this slug already exists")
|
||||
|
||||
update_data["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
result = (
|
||||
self.db.table("categories")
|
||||
.update(update_data)
|
||||
.eq("id", category_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise NotFoundException("Category not found")
|
||||
return result.data[0]
|
||||
|
||||
def delete_category(self, category_id: str) -> dict:
|
||||
# Check if any listings reference this category
|
||||
listings = (
|
||||
self.db.table("listings")
|
||||
.select("id", count="exact")
|
||||
.eq("category_id", category_id)
|
||||
.execute()
|
||||
)
|
||||
if listings.data:
|
||||
raise ConflictException(
|
||||
f"Cannot delete category: {listings.count} listings reference it"
|
||||
)
|
||||
|
||||
result = self.db.table("categories").delete().eq("id", category_id).execute()
|
||||
if not result.data:
|
||||
raise NotFoundException("Category not found")
|
||||
return {"message": "Category deleted"}
|
||||
141
app/services/email_service.py
Normal file
141
app/services/email_service.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Email sending service via SMTP.
|
||||
|
||||
If SMTP credentials are not configured (SMTP_HOST / SMTP_USER empty),
|
||||
emails are logged to console instead — useful for local development.
|
||||
|
||||
Supabase SMTP settings can be found in:
|
||||
Project Settings → Auth → SMTP Settings (enable custom SMTP)
|
||||
|
||||
Or use any external provider (SendGrid, Mailgun, Brevo, etc.) and put
|
||||
the credentials in the .env file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailService:
|
||||
def __init__(self):
|
||||
self.settings = get_settings()
|
||||
|
||||
def _is_configured(self) -> bool:
|
||||
return bool(self.settings.SMTP_HOST and self.settings.SMTP_USER)
|
||||
|
||||
def _send(self, to: str, subject: str, html_body: str, text_body: str) -> None:
|
||||
s = self.settings
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = s.EMAIL_FROM
|
||||
msg["To"] = to
|
||||
msg.attach(MIMEText(text_body, "plain", "utf-8"))
|
||||
msg.attach(MIMEText(html_body, "html", "utf-8"))
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
try:
|
||||
if s.SMTP_PORT == 465:
|
||||
with smtplib.SMTP_SSL(s.SMTP_HOST, s.SMTP_PORT, context=ctx) as srv:
|
||||
srv.login(s.SMTP_USER, s.SMTP_PASSWORD)
|
||||
srv.sendmail(s.EMAIL_FROM, to, msg.as_string())
|
||||
else:
|
||||
with smtplib.SMTP(s.SMTP_HOST, s.SMTP_PORT) as srv:
|
||||
srv.ehlo()
|
||||
srv.starttls(context=ctx)
|
||||
srv.login(s.SMTP_USER, s.SMTP_PASSWORD)
|
||||
srv.sendmail(s.EMAIL_FROM, to, msg.as_string())
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send email to %s: %s", to, exc)
|
||||
raise
|
||||
|
||||
# ── Public send methods ───────────────────────────────────
|
||||
|
||||
def send_password_reset_email(self, to_email: str, reset_url: str) -> None:
|
||||
subject = f"Reset your {self.settings.APP_NAME} password"
|
||||
html = f"""
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:24px;color:#1e293b">
|
||||
<h2 style="color:#0f4c75">Reset Your Password</h2>
|
||||
<p>You requested a password reset for your {self.settings.APP_NAME} account.</p>
|
||||
<p>Click the button below. This link expires in <strong>1 hour</strong>.</p>
|
||||
<p style="margin:24px 0">
|
||||
<a href="{reset_url}"
|
||||
style="background:#0ea5b5;color:#fff;padding:12px 24px;border-radius:6px;
|
||||
text-decoration:none;font-weight:bold">
|
||||
Reset Password
|
||||
</a>
|
||||
</p>
|
||||
<p style="color:#64748b;font-size:13px">
|
||||
If you did not request this, you can safely ignore this email.
|
||||
</p>
|
||||
<p style="color:#94a3b8;font-size:12px">Or paste this link: {reset_url}</p>
|
||||
</div>
|
||||
"""
|
||||
text = f"Reset your password at: {reset_url}\n\nThis link expires in 1 hour."
|
||||
|
||||
if self._is_configured():
|
||||
self._send(to_email, subject, html, text)
|
||||
else:
|
||||
logger.info("[DEV] Password reset email → %s URL: %s", to_email, reset_url)
|
||||
|
||||
def send_verification_email(self, to_email: str, verify_url: str, name: str) -> None:
|
||||
subject = f"Verify your {self.settings.APP_NAME} email address"
|
||||
html = f"""
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:24px;color:#1e293b">
|
||||
<h2 style="color:#0f4c75">Welcome, {name}!</h2>
|
||||
<p>Thanks for signing up. Please verify your email address to activate your account.</p>
|
||||
<p style="margin:24px 0">
|
||||
<a href="{verify_url}"
|
||||
style="background:#0ea5b5;color:#fff;padding:12px 24px;border-radius:6px;
|
||||
text-decoration:none;font-weight:bold">
|
||||
Verify Email
|
||||
</a>
|
||||
</p>
|
||||
<p style="color:#64748b;font-size:13px">
|
||||
This link expires in 24 hours. If you did not sign up, ignore this email.
|
||||
</p>
|
||||
<p style="color:#94a3b8;font-size:12px">Or paste this link: {verify_url}</p>
|
||||
</div>
|
||||
"""
|
||||
text = f"Hi {name},\n\nVerify your {self.settings.APP_NAME} account: {verify_url}\n\nExpires in 24 hours."
|
||||
|
||||
if self._is_configured():
|
||||
self._send(to_email, subject, html, text)
|
||||
else:
|
||||
logger.info("[DEV] Verification email → %s URL: %s", to_email, verify_url)
|
||||
|
||||
def send_new_message_notification(
|
||||
self, to_email: str, agency_name: str, sender_name: str, listing_title: str, dashboard_url: str
|
||||
) -> None:
|
||||
subject = f"New message from {sender_name} — {self.settings.APP_NAME}"
|
||||
html = f"""
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:24px;color:#1e293b">
|
||||
<h2 style="color:#0f4c75">New Message Received</h2>
|
||||
<p>Hi <strong>{agency_name}</strong>,</p>
|
||||
<p><strong>{sender_name}</strong> sent you a message about your listing
|
||||
<em>"{listing_title}"</em>.</p>
|
||||
<p style="margin:24px 0">
|
||||
<a href="{dashboard_url}"
|
||||
style="background:#0ea5b5;color:#fff;padding:12px 24px;border-radius:6px;
|
||||
text-decoration:none;font-weight:bold">
|
||||
View Message
|
||||
</a>
|
||||
</p>
|
||||
<p style="color:#94a3b8;font-size:12px">Or visit: {dashboard_url}</p>
|
||||
</div>
|
||||
"""
|
||||
text = (
|
||||
f"Hi {agency_name},\n\n"
|
||||
f"{sender_name} sent a message about \"{listing_title}\".\n\n"
|
||||
f"View it at: {dashboard_url}"
|
||||
)
|
||||
if self._is_configured():
|
||||
self._send(to_email, subject, html, text)
|
||||
else:
|
||||
logger.info("[DEV] New message notification → %s from: %s", to_email, sender_name)
|
||||
79
app/services/favorite_service.py
Normal file
79
app/services/favorite_service.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Favorites / wishlist service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.core.exceptions import ConflictException, NotFoundException
|
||||
from app.core.supabase import get_supabase_admin
|
||||
|
||||
|
||||
class FavoriteService:
|
||||
def __init__(self):
|
||||
self.db = get_supabase_admin()
|
||||
|
||||
def add_favorite(self, user_id: str, listing_id: str) -> dict:
|
||||
# Check listing exists
|
||||
listing = self.db.table("listings").select("id").eq("id", listing_id).execute()
|
||||
if not listing.data:
|
||||
raise NotFoundException("Listing not found")
|
||||
|
||||
# Check if already favorited
|
||||
existing = (
|
||||
self.db.table("favorites")
|
||||
.select("id")
|
||||
.eq("user_id", user_id)
|
||||
.eq("listing_id", listing_id)
|
||||
.execute()
|
||||
)
|
||||
if existing.data:
|
||||
raise ConflictException("Listing already in favorites")
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
result = (
|
||||
self.db.table("favorites")
|
||||
.insert({"user_id": user_id, "listing_id": listing_id, "created_at": now})
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise Exception("Failed to add favorite")
|
||||
return result.data[0]
|
||||
|
||||
def remove_favorite(self, user_id: str, listing_id: str) -> dict:
|
||||
result = (
|
||||
self.db.table("favorites")
|
||||
.delete()
|
||||
.eq("user_id", user_id)
|
||||
.eq("listing_id", listing_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise NotFoundException("Favorite not found")
|
||||
return {"message": "Removed from favorites"}
|
||||
|
||||
def list_favorites(self, user_id: str) -> dict:
|
||||
result = (
|
||||
self.db.table("favorites")
|
||||
.select(
|
||||
"*, listings("
|
||||
"id, title, description, price, images, location, status, "
|
||||
"agency_id, category_id, listing_type, condition, negotiable, "
|
||||
"views_count, created_at, updated_at"
|
||||
")",
|
||||
count="exact",
|
||||
)
|
||||
.eq("user_id", user_id)
|
||||
.order("created_at", desc=True)
|
||||
.execute()
|
||||
)
|
||||
return {"favorites": result.data, "total": result.count or 0}
|
||||
|
||||
def is_favorited(self, user_id: str, listing_id: str) -> bool:
|
||||
result = (
|
||||
self.db.table("favorites")
|
||||
.select("id")
|
||||
.eq("user_id", user_id)
|
||||
.eq("listing_id", listing_id)
|
||||
.execute()
|
||||
)
|
||||
return len(result.data) > 0
|
||||
260
app/services/listing_service.py
Normal file
260
app/services/listing_service.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""Listing CRUD + search service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.core.exceptions import ForbiddenException, NotFoundException
|
||||
from app.core.supabase import get_supabase_admin
|
||||
|
||||
|
||||
class ListingService:
|
||||
def __init__(self):
|
||||
self.db = get_supabase_admin()
|
||||
|
||||
# ── Single ───────────────────────────────────────────
|
||||
|
||||
def get_listing(self, listing_id: str) -> dict:
|
||||
result = (
|
||||
self.db.table("listings")
|
||||
.select("*, agencies(name), categories(name)")
|
||||
.eq("id", listing_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise NotFoundException("Listing not found")
|
||||
return self._flatten(result.data[0])
|
||||
|
||||
def increment_views(self, listing_id: str) -> None:
|
||||
"""Atomically increment views_count via a read-free update expression.
|
||||
Runs as a background task so the API response is not delayed."""
|
||||
try:
|
||||
# Fetch current count and increment — best-effort, race is acceptable for a counter
|
||||
result = self.db.table("listings").select("views_count").eq("id", listing_id).execute()
|
||||
if result.data:
|
||||
new_count = (result.data[0].get("views_count") or 0) + 1
|
||||
self.db.table("listings").update({"views_count": new_count}).eq("id", listing_id).execute()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── List / Search ────────────────────────────────────
|
||||
|
||||
def list_listings(
|
||||
self,
|
||||
search: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
min_price: Optional[float] = None,
|
||||
max_price: Optional[float] = None,
|
||||
location: Optional[str] = None,
|
||||
listing_type: Optional[str] = None,
|
||||
condition: Optional[str] = None,
|
||||
status: Optional[str] = "approved",
|
||||
sort_by: str = "newest",
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
agency_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
query = self.db.table("listings").select(
|
||||
"*, agencies(name), categories(name, slug)", count="exact"
|
||||
)
|
||||
|
||||
# ── Filters ──
|
||||
if status:
|
||||
query = query.eq("status", status)
|
||||
if agency_id:
|
||||
query = query.eq("agency_id", agency_id)
|
||||
if category:
|
||||
# category might be a slug — resolve from categories table
|
||||
cat_result = (
|
||||
self.db.table("categories")
|
||||
.select("id")
|
||||
.eq("slug", category)
|
||||
.execute()
|
||||
)
|
||||
if cat_result.data:
|
||||
query = query.eq("category_id", cat_result.data[0]["id"])
|
||||
else:
|
||||
# Try direct ID match
|
||||
query = query.eq("category_id", category)
|
||||
if min_price is not None:
|
||||
query = query.gte("price", min_price)
|
||||
if max_price is not None:
|
||||
query = query.lte("price", max_price)
|
||||
if location:
|
||||
query = query.ilike("location", f"%{location}%")
|
||||
if listing_type:
|
||||
query = query.eq("listing_type", listing_type)
|
||||
if condition:
|
||||
query = query.eq("condition", condition)
|
||||
if search:
|
||||
query = query.or_(
|
||||
f"title.ilike.%{search}%,description.ilike.%{search}%"
|
||||
)
|
||||
|
||||
# ── Sort ──
|
||||
sort_map = {
|
||||
"newest": ("created_at", True),
|
||||
"oldest": ("created_at", False),
|
||||
"price_asc": ("price", False),
|
||||
"price_desc": ("price", True),
|
||||
"popular": ("views_count", True),
|
||||
}
|
||||
col, desc = sort_map.get(sort_by, ("created_at", True))
|
||||
query = query.order(col, desc=desc)
|
||||
|
||||
# ── Pagination ──
|
||||
offset = (page - 1) * page_size
|
||||
result = query.range(offset, offset + page_size - 1).execute()
|
||||
|
||||
listings = [self._flatten(l) for l in result.data]
|
||||
return {
|
||||
"listings": listings,
|
||||
"total": result.count or 0,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
# ── Create ───────────────────────────────────────────
|
||||
|
||||
def create_listing(self, agency_id: str, data: dict) -> dict:
|
||||
# Subscription guard: agency must have an active subscription
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
sub_result = (
|
||||
self.db.table("subscriptions")
|
||||
.select("id")
|
||||
.eq("agency_id", agency_id)
|
||||
.eq("status", "active")
|
||||
.gt("ends_at", now_iso)
|
||||
.limit(1)
|
||||
.execute()
|
||||
)
|
||||
if not sub_result.data:
|
||||
raise ForbiddenException("Active subscription required to post listings")
|
||||
|
||||
now = now_iso
|
||||
listing_data = {
|
||||
"agency_id": agency_id,
|
||||
**data,
|
||||
"status": "pending",
|
||||
"views_count": 0,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
result = self.db.table("listings").insert(listing_data).execute()
|
||||
if not result.data:
|
||||
raise Exception("Failed to create listing")
|
||||
return result.data[0]
|
||||
|
||||
# ── Update ───────────────────────────────────────────
|
||||
|
||||
def update_listing(
|
||||
self, listing_id: str, user_id: str, user_role: str, data: dict
|
||||
) -> dict:
|
||||
listing = self._get_raw(listing_id)
|
||||
# Check ownership
|
||||
if user_role != "admin":
|
||||
agency = (
|
||||
self.db.table("agencies")
|
||||
.select("id, user_id")
|
||||
.eq("id", listing["agency_id"])
|
||||
.execute()
|
||||
)
|
||||
if not agency.data or agency.data[0]["user_id"] != user_id:
|
||||
raise ForbiddenException("Not authorized to update this listing")
|
||||
|
||||
update_data = {k: v for k, v in data.items() if v is not None}
|
||||
if not update_data:
|
||||
return listing
|
||||
|
||||
# Reset to pending if agency edits an approved/rejected listing
|
||||
if user_role != "admin" and listing["status"] in ("approved", "rejected"):
|
||||
update_data["status"] = "pending"
|
||||
|
||||
update_data["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
result = (
|
||||
self.db.table("listings")
|
||||
.update(update_data)
|
||||
.eq("id", listing_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise NotFoundException("Listing not found")
|
||||
return result.data[0]
|
||||
|
||||
# ── Status (admin) ───────────────────────────────────
|
||||
|
||||
def update_status(
|
||||
self, listing_id: str, status: str, rejection_reason: Optional[str] = None
|
||||
) -> dict:
|
||||
update_data: dict = {
|
||||
"status": status,
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
if status == "rejected" and rejection_reason:
|
||||
update_data["rejection_reason"] = rejection_reason
|
||||
elif status == "approved":
|
||||
update_data["rejection_reason"] = None
|
||||
|
||||
result = (
|
||||
self.db.table("listings")
|
||||
.update(update_data)
|
||||
.eq("id", listing_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise NotFoundException("Listing not found")
|
||||
return result.data[0]
|
||||
|
||||
# ── Delete ───────────────────────────────────────────
|
||||
|
||||
def delete_listing(self, listing_id: str, user_id: str, user_role: str) -> dict:
|
||||
listing = self._get_raw(listing_id)
|
||||
if user_role != "admin":
|
||||
agency = (
|
||||
self.db.table("agencies")
|
||||
.select("id, user_id")
|
||||
.eq("id", listing["agency_id"])
|
||||
.execute()
|
||||
)
|
||||
if not agency.data or agency.data[0]["user_id"] != user_id:
|
||||
raise ForbiddenException("Not authorized to delete this listing")
|
||||
|
||||
self.db.table("listings").delete().eq("id", listing_id).execute()
|
||||
return {"message": "Listing deleted"}
|
||||
|
||||
# ── Stats ────────────────────────────────────────────
|
||||
|
||||
def get_stats(self, agency_id: Optional[str] = None) -> dict:
|
||||
query = self.db.table("listings").select("status")
|
||||
if agency_id:
|
||||
query = query.eq("agency_id", agency_id)
|
||||
result = query.execute()
|
||||
|
||||
statuses: dict[str, int] = {"pending": 0, "approved": 0, "rejected": 0, "sold": 0}
|
||||
for row in result.data:
|
||||
s = row.get("status")
|
||||
if s in statuses:
|
||||
statuses[s] += 1
|
||||
|
||||
total = sum(statuses.values())
|
||||
return {"total": total, **statuses}
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────
|
||||
|
||||
def _get_raw(self, listing_id: str) -> dict:
|
||||
result = self.db.table("listings").select("*").eq("id", listing_id).execute()
|
||||
if not result.data:
|
||||
raise NotFoundException("Listing not found")
|
||||
return result.data[0]
|
||||
|
||||
@staticmethod
|
||||
def _flatten(listing: dict) -> dict:
|
||||
"""Flatten joined agency/category names."""
|
||||
agencies = listing.pop("agencies", None)
|
||||
categories = listing.pop("categories", None)
|
||||
if agencies and isinstance(agencies, dict):
|
||||
listing["agency_name"] = agencies.get("name")
|
||||
if categories and isinstance(categories, dict):
|
||||
listing["category_name"] = categories.get("name")
|
||||
return listing
|
||||
159
app/services/message_service.py
Normal file
159
app/services/message_service.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Message / contact service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import ForbiddenException, NotFoundException
|
||||
from app.core.supabase import get_supabase_admin
|
||||
from app.services.email_service import EmailService
|
||||
|
||||
|
||||
class MessageService:
|
||||
def __init__(self):
|
||||
self.db = get_supabase_admin()
|
||||
|
||||
def send_message(self, data: dict) -> dict:
|
||||
# Resolve agency_id and listing title
|
||||
listing_row = (
|
||||
self.db.table("listings")
|
||||
.select("agency_id, title")
|
||||
.eq("id", data["listing_id"])
|
||||
.execute()
|
||||
)
|
||||
if not listing_row.data:
|
||||
raise NotFoundException("Listing not found")
|
||||
|
||||
agency_id = listing_row.data[0]["agency_id"]
|
||||
listing_title = listing_row.data[0].get("title", "")
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
msg_data = {
|
||||
**data,
|
||||
"agency_id": agency_id,
|
||||
"read": False,
|
||||
"created_at": now,
|
||||
}
|
||||
result = self.db.table("messages").insert(msg_data).execute()
|
||||
if not result.data:
|
||||
raise Exception("Failed to send message")
|
||||
|
||||
# Notify agency via email (non-blocking)
|
||||
try:
|
||||
agency_row = (
|
||||
self.db.table("agencies")
|
||||
.select("name, email")
|
||||
.eq("id", agency_id)
|
||||
.execute()
|
||||
)
|
||||
if agency_row.data:
|
||||
agency = agency_row.data[0]
|
||||
settings = get_settings()
|
||||
dashboard_url = f"{settings.FRONTEND_URL}/agency/dashboard"
|
||||
EmailService().send_new_message_notification(
|
||||
to_email=agency["email"],
|
||||
agency_name=agency["name"],
|
||||
sender_name=data.get("name", "Someone"),
|
||||
listing_title=listing_title,
|
||||
dashboard_url=dashboard_url,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result.data[0]
|
||||
|
||||
def list_messages(
|
||||
self,
|
||||
agency_id: str,
|
||||
user_id: str,
|
||||
user_role: str,
|
||||
read_filter: Optional[bool] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> dict:
|
||||
# Verify ownership
|
||||
if user_role != "admin":
|
||||
agency = (
|
||||
self.db.table("agencies")
|
||||
.select("user_id")
|
||||
.eq("id", agency_id)
|
||||
.execute()
|
||||
)
|
||||
if not agency.data or agency.data[0]["user_id"] != user_id:
|
||||
raise ForbiddenException("Not authorized")
|
||||
|
||||
query = (
|
||||
self.db.table("messages")
|
||||
.select("*, listings(title)", count="exact")
|
||||
.eq("agency_id", agency_id)
|
||||
)
|
||||
if read_filter is not None:
|
||||
query = query.eq("read", read_filter)
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
result = (
|
||||
query.order("created_at", desc=True)
|
||||
.range(offset, offset + page_size - 1)
|
||||
.execute()
|
||||
)
|
||||
|
||||
messages = []
|
||||
for m in result.data:
|
||||
listings_data = m.pop("listings", None)
|
||||
if listings_data and isinstance(listings_data, dict):
|
||||
m["listing_title"] = listings_data.get("title")
|
||||
messages.append(m)
|
||||
|
||||
return {
|
||||
"messages": messages,
|
||||
"total": result.count or 0,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
def mark_read(self, message_id: str, user_id: str, user_role: str, read: bool = True) -> dict:
|
||||
msg = self.db.table("messages").select("*, agencies(user_id)").eq("id", message_id).execute()
|
||||
if not msg.data:
|
||||
raise NotFoundException("Message not found")
|
||||
|
||||
message = msg.data[0]
|
||||
agencies_data = message.get("agencies")
|
||||
if user_role != "admin":
|
||||
if not agencies_data or agencies_data.get("user_id") != user_id:
|
||||
raise ForbiddenException("Not authorized")
|
||||
|
||||
result = (
|
||||
self.db.table("messages")
|
||||
.update({"read": read})
|
||||
.eq("id", message_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise NotFoundException("Message not found")
|
||||
return result.data[0]
|
||||
|
||||
def get_unread_count(self, agency_id: str) -> int:
|
||||
result = (
|
||||
self.db.table("messages")
|
||||
.select("id", count="exact")
|
||||
.eq("agency_id", agency_id)
|
||||
.eq("read", False)
|
||||
.execute()
|
||||
)
|
||||
return result.count or 0
|
||||
|
||||
def delete_message(self, message_id: str, user_id: str, user_role: str) -> dict:
|
||||
msg = self.db.table("messages").select("*, agencies(user_id)").eq("id", message_id).execute()
|
||||
if not msg.data:
|
||||
raise NotFoundException("Message not found")
|
||||
|
||||
message = msg.data[0]
|
||||
agencies_data = message.get("agencies")
|
||||
if user_role != "admin":
|
||||
if not agencies_data or agencies_data.get("user_id") != user_id:
|
||||
raise ForbiddenException("Not authorized")
|
||||
|
||||
self.db.table("messages").delete().eq("id", message_id).execute()
|
||||
return {"message": "Message deleted"}
|
||||
350
app/services/payment_service.py
Normal file
350
app/services/payment_service.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""Payment service — CinetPay integration for subscriptions and purchases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import BadRequestException, ForbiddenException, NotFoundException
|
||||
from app.core.supabase import get_supabase_admin
|
||||
|
||||
CINETPAY_INIT_URL = "https://api-checkout.cinetpay.com/v2/payment"
|
||||
CINETPAY_CHECK_URL = "https://api-checkout.cinetpay.com/v2/payment/check"
|
||||
|
||||
|
||||
class PaymentService:
|
||||
def __init__(self):
|
||||
self.db = get_supabase_admin()
|
||||
self.settings = get_settings()
|
||||
|
||||
# ── Public methods ────────────────────────────────────
|
||||
|
||||
def initiate(
|
||||
self,
|
||||
user_id: str,
|
||||
payment_type: str,
|
||||
plan: Optional[str] = None,
|
||||
listing_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Create a pending payment record and return CinetPay redirect URL."""
|
||||
settings = self.settings
|
||||
|
||||
if payment_type == "subscription":
|
||||
if plan not in ("monthly", "yearly"):
|
||||
raise BadRequestException("plan must be 'monthly' or 'yearly'")
|
||||
amount = (
|
||||
settings.SUBSCRIPTION_MONTHLY_AMOUNT
|
||||
if plan == "monthly"
|
||||
else settings.SUBSCRIPTION_YEARLY_AMOUNT
|
||||
)
|
||||
# Resolve agency_id from user
|
||||
agency_result = (
|
||||
self.db.table("agencies").select("id").eq("user_id", user_id).execute()
|
||||
)
|
||||
if not agency_result.data:
|
||||
raise ForbiddenException("Agency profile not found")
|
||||
agency_id = agency_result.data[0]["id"]
|
||||
transaction_id = self._build_transaction_id("SUB", agency_id)
|
||||
description = f"Abonnement {plan} - Deals24Togo"
|
||||
metadata = {"plan": plan, "agency_id": agency_id}
|
||||
|
||||
elif payment_type == "purchase":
|
||||
if not listing_id:
|
||||
raise BadRequestException("listing_id is required for purchase")
|
||||
listing_result = (
|
||||
self.db.table("listings")
|
||||
.select("id, title, price, status")
|
||||
.eq("id", listing_id)
|
||||
.execute()
|
||||
)
|
||||
if not listing_result.data:
|
||||
raise NotFoundException("Listing not found")
|
||||
listing = listing_result.data[0]
|
||||
if listing["status"] != "approved":
|
||||
raise BadRequestException("Listing is not available for purchase")
|
||||
amount = float(listing["price"])
|
||||
transaction_id = self._build_transaction_id("PUR", listing_id)
|
||||
description = f"Achat - {listing['title'][:40]}"
|
||||
metadata = {"listing_id": listing_id, "listing_title": listing["title"]}
|
||||
|
||||
else:
|
||||
raise BadRequestException("type must be 'subscription' or 'purchase'")
|
||||
|
||||
# Insert pending payment row
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
payment_row = {
|
||||
"transaction_id": transaction_id,
|
||||
"type": payment_type,
|
||||
"payer_id": user_id,
|
||||
"amount": amount,
|
||||
"currency": "XOF",
|
||||
"status": "pending",
|
||||
"metadata": metadata,
|
||||
"created_at": now,
|
||||
}
|
||||
insert_result = self.db.table("payments").insert(payment_row).execute()
|
||||
if not insert_result.data:
|
||||
raise Exception("Failed to create payment record")
|
||||
|
||||
# Call CinetPay
|
||||
notify_url = f"{settings.BACKEND_PUBLIC_URL}/api/v1/payments/webhook"
|
||||
return_url = f"{settings.FRONTEND_URL}/payment-return?transaction_id={transaction_id}"
|
||||
|
||||
payload = {
|
||||
"apikey": settings.CINETPAY_API_KEY,
|
||||
"site_id": settings.CINETPAY_SITE_ID,
|
||||
"transaction_id": transaction_id,
|
||||
"amount": int(amount),
|
||||
"currency": "XOF",
|
||||
"description": description,
|
||||
"notify_url": notify_url,
|
||||
"return_url": return_url,
|
||||
"channels": "ALL",
|
||||
}
|
||||
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.post(CINETPAY_INIT_URL, json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
if data.get("code") != "201" and data.get("code") != "00":
|
||||
raise Exception(f"CinetPay error: {data.get('message', 'Unknown error')}")
|
||||
|
||||
payment_url = data["data"]["payment_url"]
|
||||
return {"payment_url": payment_url, "transaction_id": transaction_id}
|
||||
|
||||
def handle_webhook(self, form_data: dict) -> None:
|
||||
"""Process CinetPay webhook notification."""
|
||||
settings = self.settings
|
||||
transaction_id = form_data.get("cpm_trans_id")
|
||||
site_id = form_data.get("cpm_site_id")
|
||||
|
||||
if not transaction_id:
|
||||
return
|
||||
|
||||
# Validate site_id
|
||||
if site_id and site_id != str(settings.CINETPAY_SITE_ID):
|
||||
return
|
||||
|
||||
# Idempotency: check if already completed
|
||||
result = (
|
||||
self.db.table("payments")
|
||||
.select("*")
|
||||
.eq("transaction_id", transaction_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
return
|
||||
|
||||
payment = result.data[0]
|
||||
if payment["status"] == "completed":
|
||||
return # Already processed
|
||||
|
||||
# Verify with CinetPay
|
||||
verify_payload = {
|
||||
"apikey": settings.CINETPAY_API_KEY,
|
||||
"site_id": settings.CINETPAY_SITE_ID,
|
||||
"transaction_id": transaction_id,
|
||||
}
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.post(CINETPAY_CHECK_URL, json=verify_payload)
|
||||
resp.raise_for_status()
|
||||
verify_data = resp.json()
|
||||
|
||||
if verify_data.get("code") != "00":
|
||||
return
|
||||
|
||||
cp_status = verify_data["data"].get("status")
|
||||
payment_method = verify_data["data"].get("payment_method")
|
||||
operator_id = verify_data["data"].get("operator_id")
|
||||
|
||||
if cp_status == "ACCEPTED":
|
||||
# Update payment to completed
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
self.db.table("payments").update(
|
||||
{
|
||||
"status": "completed",
|
||||
"payment_method": payment_method,
|
||||
"operator_id": operator_id,
|
||||
"paid_at": now,
|
||||
}
|
||||
).eq("transaction_id", transaction_id).execute()
|
||||
|
||||
# Re-fetch updated payment
|
||||
updated = (
|
||||
self.db.table("payments")
|
||||
.select("*")
|
||||
.eq("transaction_id", transaction_id)
|
||||
.execute()
|
||||
)
|
||||
if updated.data:
|
||||
payment = updated.data[0]
|
||||
if payment["type"] == "subscription":
|
||||
self._activate_subscription(payment)
|
||||
elif payment["type"] == "purchase":
|
||||
self._complete_purchase(payment)
|
||||
|
||||
elif cp_status in ("REFUSED",):
|
||||
self.db.table("payments").update({"status": "failed"}).eq(
|
||||
"transaction_id", transaction_id
|
||||
).execute()
|
||||
|
||||
def get_receipt(self, transaction_id: str, user_id: str) -> dict:
|
||||
"""Return enriched receipt data for a transaction."""
|
||||
result = (
|
||||
self.db.table("payments")
|
||||
.select("*")
|
||||
.eq("transaction_id", transaction_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise NotFoundException("Payment not found")
|
||||
|
||||
payment = result.data[0]
|
||||
if payment["payer_id"] != user_id:
|
||||
raise ForbiddenException("Not authorized to view this payment")
|
||||
|
||||
# Enrich with payer info
|
||||
payer_name = None
|
||||
payer_email = None
|
||||
user_result = (
|
||||
self.db.table("users")
|
||||
.select("name, email")
|
||||
.eq("id", user_id)
|
||||
.execute()
|
||||
)
|
||||
if user_result.data:
|
||||
payer_name = user_result.data[0].get("name")
|
||||
payer_email = user_result.data[0].get("email")
|
||||
|
||||
# Enrich with plan label or listing title
|
||||
metadata = payment.get("metadata") or {}
|
||||
plan_label = None
|
||||
listing_title = None
|
||||
|
||||
if payment["type"] == "subscription":
|
||||
plan = metadata.get("plan")
|
||||
plan_label = "Mensuel (1 mois)" if plan == "monthly" else "Annuel (12 mois)"
|
||||
elif payment["type"] == "purchase":
|
||||
listing_title = metadata.get("listing_title")
|
||||
if not listing_title:
|
||||
lid = metadata.get("listing_id")
|
||||
if lid:
|
||||
lr = (
|
||||
self.db.table("listings")
|
||||
.select("title")
|
||||
.eq("id", lid)
|
||||
.execute()
|
||||
)
|
||||
if lr.data:
|
||||
listing_title = lr.data[0]["title"]
|
||||
|
||||
return {
|
||||
**payment,
|
||||
"payer_name": payer_name,
|
||||
"payer_email": payer_email,
|
||||
"plan_label": plan_label,
|
||||
"listing_title": listing_title,
|
||||
}
|
||||
|
||||
def get_my_payments(self, user_id: str) -> list:
|
||||
"""List all payments for a user."""
|
||||
result = (
|
||||
self.db.table("payments")
|
||||
.select("*")
|
||||
.eq("payer_id", user_id)
|
||||
.order("created_at", desc=True)
|
||||
.execute()
|
||||
)
|
||||
return result.data or []
|
||||
|
||||
def get_subscription_status(self, user_id: str) -> dict:
|
||||
"""Return the current subscription status for the agency user."""
|
||||
agency_result = (
|
||||
self.db.table("agencies").select("id").eq("user_id", user_id).execute()
|
||||
)
|
||||
if not agency_result.data:
|
||||
raise NotFoundException("Agency not found")
|
||||
|
||||
agency_id = agency_result.data[0]["id"]
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
result = (
|
||||
self.db.table("subscriptions")
|
||||
.select("*")
|
||||
.eq("agency_id", agency_id)
|
||||
.eq("status", "active")
|
||||
.gt("ends_at", now)
|
||||
.order("ends_at", desc=True)
|
||||
.limit(1)
|
||||
.execute()
|
||||
)
|
||||
|
||||
if result.data:
|
||||
return {"has_active_subscription": True, "subscription": result.data[0]}
|
||||
return {"has_active_subscription": False, "subscription": None}
|
||||
|
||||
# ── Private helpers ───────────────────────────────────
|
||||
|
||||
def _activate_subscription(self, payment: dict) -> None:
|
||||
"""Create or extend subscription after successful payment."""
|
||||
metadata = payment.get("metadata") or {}
|
||||
agency_id = metadata.get("agency_id")
|
||||
plan = metadata.get("plan")
|
||||
|
||||
if not agency_id or not plan:
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
days = 30 if plan == "monthly" else 365
|
||||
ends_at = (now + timedelta(days=days)).isoformat()
|
||||
starts_at = now.isoformat()
|
||||
|
||||
self.db.table("subscriptions").insert(
|
||||
{
|
||||
"agency_id": agency_id,
|
||||
"plan": plan,
|
||||
"status": "active",
|
||||
"starts_at": starts_at,
|
||||
"ends_at": ends_at,
|
||||
"payment_id": payment["id"],
|
||||
"created_at": starts_at,
|
||||
}
|
||||
).execute()
|
||||
|
||||
def _complete_purchase(self, payment: dict) -> None:
|
||||
"""Record purchase and mark listing as sold."""
|
||||
metadata = payment.get("metadata") or {}
|
||||
listing_id = metadata.get("listing_id")
|
||||
|
||||
if not listing_id:
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Insert purchase record
|
||||
self.db.table("purchases").insert(
|
||||
{
|
||||
"listing_id": listing_id,
|
||||
"buyer_id": payment.get("payer_id"),
|
||||
"amount": payment["amount"],
|
||||
"payment_id": payment["id"],
|
||||
"created_at": now,
|
||||
}
|
||||
).execute()
|
||||
|
||||
# Mark listing as sold
|
||||
self.db.table("listings").update(
|
||||
{"status": "sold", "updated_at": now}
|
||||
).eq("id", listing_id).execute()
|
||||
|
||||
@staticmethod
|
||||
def _build_transaction_id(prefix: str, resource_id: str) -> str:
|
||||
"""Build a ≤50-char alphanumeric transaction ID."""
|
||||
short_id = resource_id[:8].replace("-", "")
|
||||
ts = int(time.time())
|
||||
return f"{prefix}{short_id}{ts}"
|
||||
94
app/services/upload_service.py
Normal file
94
app/services/upload_service.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""File upload service using Supabase Storage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import BadRequestException
|
||||
from app.core.supabase import get_supabase_admin
|
||||
|
||||
ALLOWED_CONTENT_TYPES = {
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
}
|
||||
|
||||
MAX_DIMENSION = 2048
|
||||
|
||||
|
||||
class UploadService:
|
||||
def __init__(self):
|
||||
self.db = get_supabase_admin()
|
||||
self.settings = get_settings()
|
||||
self.bucket = self.settings.SUPABASE_STORAGE_BUCKET
|
||||
|
||||
def upload_image(
|
||||
self,
|
||||
file_bytes: bytes,
|
||||
content_type: str,
|
||||
folder: str = "images",
|
||||
max_size_mb: Optional[int] = None,
|
||||
) -> str:
|
||||
max_bytes = (max_size_mb or self.settings.MAX_UPLOAD_SIZE_MB) * 1024 * 1024
|
||||
|
||||
if content_type not in ALLOWED_CONTENT_TYPES:
|
||||
raise BadRequestException(
|
||||
f"Invalid file type. Allowed: {', '.join(ALLOWED_CONTENT_TYPES)}"
|
||||
)
|
||||
|
||||
if len(file_bytes) > max_bytes:
|
||||
raise BadRequestException(
|
||||
f"File too large. Max: {max_size_mb or self.settings.MAX_UPLOAD_SIZE_MB}MB"
|
||||
)
|
||||
|
||||
# Validate and optionally resize
|
||||
try:
|
||||
img = Image.open(BytesIO(file_bytes))
|
||||
img.verify()
|
||||
img = Image.open(BytesIO(file_bytes)) # Re-open after verify
|
||||
|
||||
# Resize if too large
|
||||
if max(img.size) > MAX_DIMENSION:
|
||||
img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.LANCZOS)
|
||||
buffer = BytesIO()
|
||||
fmt = "JPEG" if content_type == "image/jpeg" else "PNG"
|
||||
img.save(buffer, format=fmt, quality=85)
|
||||
file_bytes = buffer.getvalue()
|
||||
except Exception:
|
||||
raise BadRequestException("Invalid image file")
|
||||
|
||||
ext = content_type.split("/")[-1]
|
||||
if ext == "jpeg":
|
||||
ext = "jpg"
|
||||
filename = f"{folder}/{uuid.uuid4().hex}.{ext}"
|
||||
|
||||
self.db.storage.from_(self.bucket).upload(
|
||||
path=filename,
|
||||
file=file_bytes,
|
||||
file_options={"content-type": content_type},
|
||||
)
|
||||
|
||||
# Return public URL
|
||||
public_url = self.db.storage.from_(self.bucket).get_public_url(filename)
|
||||
return public_url
|
||||
|
||||
def delete_image(self, file_url: str) -> bool:
|
||||
"""Extract path from URL and delete from storage."""
|
||||
try:
|
||||
bucket_prefix = f"/storage/v1/object/public/{self.bucket}/"
|
||||
if bucket_prefix in file_url:
|
||||
path = file_url.split(bucket_prefix)[-1]
|
||||
else:
|
||||
# Try extracting from full URL
|
||||
path = file_url.split(f"{self.bucket}/")[-1]
|
||||
|
||||
self.db.storage.from_(self.bucket).remove([path])
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
110
app/services/user_service.py
Normal file
110
app/services/user_service.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""User CRUD service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.core.exceptions import ConflictException, ForbiddenException, NotFoundException
|
||||
from app.core.supabase import get_supabase_admin
|
||||
|
||||
|
||||
class UserService:
|
||||
def __init__(self):
|
||||
self.db = get_supabase_admin()
|
||||
|
||||
def get_user(self, user_id: str) -> dict:
|
||||
result = self.db.table("users").select("*").eq("id", user_id).execute()
|
||||
if not result.data:
|
||||
raise NotFoundException("User not found")
|
||||
user = result.data[0]
|
||||
user.pop("password_hash", None)
|
||||
return user
|
||||
|
||||
def update_user(self, user_id: str, data: dict) -> dict:
|
||||
# Remove None values
|
||||
update_data = {k: v for k, v in data.items() if v is not None}
|
||||
if not update_data:
|
||||
return self.get_user(user_id)
|
||||
|
||||
# Check email uniqueness if changing email
|
||||
if "email" in update_data:
|
||||
existing = (
|
||||
self.db.table("users")
|
||||
.select("id")
|
||||
.eq("email", update_data["email"].lower())
|
||||
.neq("id", user_id)
|
||||
.execute()
|
||||
)
|
||||
if existing.data:
|
||||
raise ConflictException("A user with this email already exists")
|
||||
update_data["email"] = update_data["email"].lower()
|
||||
|
||||
update_data["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
result = (
|
||||
self.db.table("users")
|
||||
.update(update_data)
|
||||
.eq("id", user_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise NotFoundException("User not found")
|
||||
user = result.data[0]
|
||||
user.pop("password_hash", None)
|
||||
return user
|
||||
|
||||
def list_users(
|
||||
self,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
role: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
) -> dict:
|
||||
query = self.db.table("users").select("*", count="exact")
|
||||
if role:
|
||||
query = query.eq("role", role)
|
||||
if search:
|
||||
query = query.or_(f"name.ilike.%{search}%,email.ilike.%{search}%")
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
result = (
|
||||
query.order("created_at", desc=True)
|
||||
.range(offset, offset + page_size - 1)
|
||||
.execute()
|
||||
)
|
||||
|
||||
users = []
|
||||
for u in result.data:
|
||||
u.pop("password_hash", None)
|
||||
users.append(u)
|
||||
|
||||
return {
|
||||
"users": users,
|
||||
"total": result.count or 0,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
def verify_user(self, user_id: str) -> dict:
|
||||
result = (
|
||||
self.db.table("users")
|
||||
.update({"verified": True, "updated_at": datetime.now(timezone.utc).isoformat()})
|
||||
.eq("id", user_id)
|
||||
.execute()
|
||||
)
|
||||
if not result.data:
|
||||
raise NotFoundException("User not found")
|
||||
user = result.data[0]
|
||||
user.pop("password_hash", None)
|
||||
return user
|
||||
|
||||
def delete_user(self, user_id: str, requester_id: str, requester_role: str) -> dict:
|
||||
if requester_role != "admin" and requester_id != user_id:
|
||||
raise ForbiddenException("Cannot delete other users")
|
||||
if requester_role == "admin" and requester_id == user_id:
|
||||
raise ForbiddenException("Admins cannot delete their own account")
|
||||
|
||||
result = self.db.table("users").delete().eq("id", user_id).execute()
|
||||
if not result.data:
|
||||
raise NotFoundException("User not found")
|
||||
return {"message": "User deleted"}
|
||||
Reference in New Issue
Block a user