Initial commit

This commit is contained in:
belviskhoremk
2026-03-06 22:57:58 +00:00
commit c4d836a0f9
60 changed files with 5423 additions and 0 deletions

0
app/services/__init__.py Normal file
View File

View 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"}

View 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"),
}

View 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"}

View 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)

View 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

View 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

View 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"}

View 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}"

View 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

View 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"}