mirror of
http://88.130.71.182:3000/BlitTech/badoHair_be.git
synced 2026-06-12 23:23:22 +00:00
Initial Commit
This commit is contained in:
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
127
app/services/auth_service.py
Normal file
127
app/services/auth_service.py
Normal file
@@ -0,0 +1,127 @@
|
||||
import asyncpg
|
||||
from supabase import create_client, Client
|
||||
|
||||
from app.config import get_settings
|
||||
from app.exceptions import AppError, UnauthorizedError
|
||||
from app.models.auth import RegisterRequest, LoginRequest, RefreshRequest
|
||||
|
||||
|
||||
def _client() -> Client:
|
||||
s = get_settings()
|
||||
return create_client(s.SUPABASE_URL, s.SUPABASE_ANON_KEY)
|
||||
|
||||
|
||||
def _admin_client() -> Client:
|
||||
s = get_settings()
|
||||
return create_client(s.SUPABASE_URL, s.SUPABASE_SERVICE_ROLE_KEY)
|
||||
|
||||
|
||||
async def register(req: RegisterRequest, db: asyncpg.Connection) -> dict:
|
||||
try:
|
||||
result = _client().auth.sign_up({"email": req.email, "password": req.password})
|
||||
except Exception as e:
|
||||
raise AppError("REGISTRATION_FAILED", str(e), 400)
|
||||
|
||||
if not result.user:
|
||||
raise AppError("REGISTRATION_FAILED", "Could not create account", 400)
|
||||
|
||||
user_id = str(result.user.id)
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO profiles (id, email, full_name, phone, role)
|
||||
VALUES ($1, $2, $3, $4, 'client')
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""",
|
||||
user_id, req.email, req.resolved_name(), req.phone,
|
||||
)
|
||||
|
||||
if result.session:
|
||||
return {
|
||||
"access_token": result.session.access_token,
|
||||
"refresh_token": result.session.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": result.session.expires_in,
|
||||
}
|
||||
|
||||
return {"message": "Account created successfully."}
|
||||
|
||||
|
||||
async def login(req: LoginRequest) -> dict:
|
||||
try:
|
||||
result = _client().auth.sign_in_with_password({"email": req.email, "password": req.password})
|
||||
except Exception:
|
||||
raise UnauthorizedError("Invalid email or password")
|
||||
|
||||
if not result.session:
|
||||
raise UnauthorizedError("Invalid email or password")
|
||||
|
||||
return {
|
||||
"access_token": result.session.access_token,
|
||||
"refresh_token": result.session.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": result.session.expires_in,
|
||||
}
|
||||
|
||||
|
||||
async def refresh(req: RefreshRequest) -> dict:
|
||||
try:
|
||||
result = _client().auth.refresh_session(req.refresh_token)
|
||||
except Exception:
|
||||
raise UnauthorizedError("Invalid or expired refresh token")
|
||||
|
||||
return {
|
||||
"access_token": result.session.access_token,
|
||||
"refresh_token": result.session.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": result.session.expires_in,
|
||||
}
|
||||
|
||||
|
||||
async def forgot_password(email: str):
|
||||
try:
|
||||
_client().auth.reset_password_email(email)
|
||||
except Exception:
|
||||
pass # Always return success to avoid email enumeration
|
||||
|
||||
|
||||
async def update_profile(user_id: str, db: asyncpg.Connection, full_name: str | None, phone: str | None) -> dict:
|
||||
updates = {}
|
||||
if full_name is not None:
|
||||
updates["full_name"] = full_name
|
||||
if phone is not None:
|
||||
updates["phone"] = phone
|
||||
|
||||
if not updates:
|
||||
row = await db.fetchrow("SELECT * FROM profiles WHERE id = $1", user_id)
|
||||
return dict(row)
|
||||
|
||||
set_clauses = [f"{k} = ${i + 2}" for i, k in enumerate(updates)]
|
||||
row = await db.fetchrow(
|
||||
f"UPDATE profiles SET {', '.join(set_clauses)}, updated_at = now() WHERE id = $1 RETURNING *",
|
||||
user_id, *updates.values(),
|
||||
)
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def create_admin_user(email: str, password: str, full_name: str, db: asyncpg.Connection) -> dict:
|
||||
try:
|
||||
result = _admin_client().auth.admin.create_user({
|
||||
"email": email,
|
||||
"password": password,
|
||||
"email_confirm": True,
|
||||
})
|
||||
except Exception as e:
|
||||
raise AppError("USER_CREATE_FAILED", str(e), 400)
|
||||
|
||||
user_id = str(result.user.id)
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO profiles (id, email, full_name, role)
|
||||
VALUES ($1, $2, $3, 'admin')
|
||||
ON CONFLICT (id) DO UPDATE SET role = 'admin'
|
||||
""",
|
||||
user_id, email, full_name,
|
||||
)
|
||||
|
||||
row = await db.fetchrow("SELECT * FROM profiles WHERE id = $1", user_id)
|
||||
return dict(row)
|
||||
272
app/services/booking_service.py
Normal file
272
app/services/booking_service.py
Normal file
@@ -0,0 +1,272 @@
|
||||
from uuid import UUID
|
||||
|
||||
import asyncpg
|
||||
|
||||
from app.exceptions import NotFoundError, SlotUnavailableError, AppError, ValidationError
|
||||
from app.models.bookings import BookingCreate
|
||||
|
||||
|
||||
async def create_booking(
|
||||
db: asyncpg.Connection,
|
||||
data: BookingCreate,
|
||||
user: dict | None = None,
|
||||
) -> dict:
|
||||
# Resolve identity
|
||||
if user:
|
||||
user_id = str(user["id"])
|
||||
guest_name = guest_email = guest_phone = None
|
||||
else:
|
||||
if not data.guest_email or not data.guest_name:
|
||||
raise ValidationError("guest_name and guest_email are required for unauthenticated bookings")
|
||||
user_id = None
|
||||
guest_name = data.guest_name
|
||||
guest_email = data.guest_email
|
||||
guest_phone = data.guest_phone
|
||||
|
||||
async with db.transaction():
|
||||
slot = await db.fetchrow(
|
||||
"""
|
||||
SELECT ts.* FROM time_slots ts
|
||||
WHERE ts.id = $1
|
||||
AND ts.is_blocked = false
|
||||
AND ts.date >= CURRENT_DATE
|
||||
AND NOT EXISTS (SELECT 1 FROM blocked_dates bd WHERE bd.date = ts.date)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM bookings b
|
||||
WHERE b.slot_id = ts.id AND b.status IN ('pending', 'confirmed')
|
||||
)
|
||||
""",
|
||||
str(data.slot_id),
|
||||
)
|
||||
if not slot:
|
||||
raise SlotUnavailableError()
|
||||
|
||||
booking = await db.fetchrow(
|
||||
"""
|
||||
INSERT INTO bookings
|
||||
(user_id, slot_id, service_note, status,
|
||||
guest_name, guest_email, guest_phone)
|
||||
VALUES ($1, $2, $3, 'pending', $4, $5, $6)
|
||||
RETURNING *
|
||||
""",
|
||||
user_id, str(data.slot_id), data.service_note,
|
||||
guest_name, guest_email, guest_phone,
|
||||
)
|
||||
|
||||
return {"booking_id": str(booking["id"])}
|
||||
|
||||
|
||||
async def get_booking(db: asyncpg.Connection, booking_id: str, user_id: str | None = None) -> dict:
|
||||
query = """
|
||||
SELECT b.*,
|
||||
ts.date AS slot_date,
|
||||
ts.start_time AS slot_start,
|
||||
ts.end_time AS slot_end,
|
||||
p.full_name AS profile_name,
|
||||
p.email AS profile_email,
|
||||
p.phone AS profile_phone,
|
||||
s.name AS service_name
|
||||
FROM bookings b
|
||||
JOIN time_slots ts ON ts.id = b.slot_id
|
||||
LEFT JOIN profiles p ON p.id = b.user_id
|
||||
LEFT JOIN services s ON s.id = b.service_id
|
||||
WHERE b.id = $1
|
||||
"""
|
||||
params: list = [booking_id]
|
||||
|
||||
if user_id:
|
||||
query += " AND b.user_id = $2"
|
||||
params.append(user_id)
|
||||
|
||||
row = await db.fetchrow(query, *params)
|
||||
if not row:
|
||||
raise NotFoundError("booking")
|
||||
return _shape(row)
|
||||
|
||||
|
||||
async def list_bookings(
|
||||
db: asyncpg.Connection,
|
||||
page: int,
|
||||
per_page: int,
|
||||
offset: int,
|
||||
user_id: str | None = None,
|
||||
status: str | None = None,
|
||||
from_date=None,
|
||||
to_date=None,
|
||||
) -> tuple[list[dict], int]:
|
||||
conditions: list[str] = []
|
||||
params: list = []
|
||||
|
||||
if user_id:
|
||||
params.append(user_id)
|
||||
conditions.append(f"b.user_id = ${len(params)}")
|
||||
if status:
|
||||
params.append(status)
|
||||
conditions.append(f"b.status = ${len(params)}")
|
||||
if from_date:
|
||||
params.append(from_date)
|
||||
conditions.append(f"ts.date >= ${len(params)}")
|
||||
if to_date:
|
||||
params.append(to_date)
|
||||
conditions.append(f"ts.date <= ${len(params)}")
|
||||
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
total = await db.fetchval(
|
||||
f"SELECT COUNT(*) FROM bookings b JOIN time_slots ts ON ts.id = b.slot_id {where}",
|
||||
*params,
|
||||
)
|
||||
|
||||
params.extend([per_page, offset])
|
||||
rows = await db.fetch(
|
||||
f"""
|
||||
SELECT b.*,
|
||||
ts.date AS slot_date,
|
||||
ts.start_time AS slot_start,
|
||||
ts.end_time AS slot_end,
|
||||
p.full_name AS profile_name,
|
||||
p.email AS profile_email,
|
||||
p.phone AS profile_phone,
|
||||
s.name AS service_name
|
||||
FROM bookings b
|
||||
JOIN time_slots ts ON ts.id = b.slot_id
|
||||
LEFT JOIN profiles p ON p.id = b.user_id
|
||||
LEFT JOIN services s ON s.id = b.service_id
|
||||
{where}
|
||||
ORDER BY ts.date DESC, ts.start_time DESC
|
||||
LIMIT ${len(params) - 1} OFFSET ${len(params)}
|
||||
""",
|
||||
*params,
|
||||
)
|
||||
return [_shape(r) for r in rows], total
|
||||
|
||||
|
||||
async def cancel_booking(db: asyncpg.Connection, booking_id: str, user_id: str):
|
||||
booking = await db.fetchrow(
|
||||
"SELECT * FROM bookings WHERE id = $1 AND user_id = $2",
|
||||
booking_id, user_id,
|
||||
)
|
||||
if not booking:
|
||||
raise NotFoundError("booking")
|
||||
if booking["status"] not in ("pending", "confirmed"):
|
||||
raise AppError("CANNOT_CANCEL", "Only pending or confirmed bookings can be cancelled", 400)
|
||||
|
||||
await db.execute(
|
||||
"UPDATE bookings SET status = 'cancelled', updated_at = now() WHERE id = $1",
|
||||
booking_id,
|
||||
)
|
||||
if booking["stripe_payment_intent_id"] and booking["status"] == "confirmed":
|
||||
await stripe_service.create_refund(booking["stripe_payment_intent_id"])
|
||||
|
||||
|
||||
async def admin_update_booking(
|
||||
db: asyncpg.Connection,
|
||||
booking_id: str,
|
||||
status: str,
|
||||
admin_notes: str | None,
|
||||
actor_id: str,
|
||||
) -> dict:
|
||||
row = await db.fetchrow(
|
||||
"""
|
||||
UPDATE bookings SET status = $2, admin_notes = COALESCE($3, admin_notes), updated_at = now()
|
||||
WHERE id = $1 RETURNING *
|
||||
""",
|
||||
booking_id, status, admin_notes,
|
||||
)
|
||||
if not row:
|
||||
raise NotFoundError("booking")
|
||||
|
||||
booking = dict(row)
|
||||
email = booking.get("guest_email")
|
||||
if not email and booking.get("user_id"):
|
||||
profile = await db.fetchrow("SELECT email FROM profiles WHERE id = $1", str(booking["user_id"]))
|
||||
if profile:
|
||||
email = profile["email"]
|
||||
|
||||
if email:
|
||||
slot = await db.fetchrow("SELECT date, start_time FROM time_slots WHERE id = $1", str(booking["slot_id"]))
|
||||
if slot:
|
||||
from app.services import email_service
|
||||
if status == "confirmed":
|
||||
await email_service.send_booking_confirmed(email, str(slot["date"]), _fmt_time(slot["start_time"]))
|
||||
elif status == "cancelled":
|
||||
await email_service.send_booking_cancelled(email, str(slot["date"]), _fmt_time(slot["start_time"]))
|
||||
|
||||
await _log(db, actor_id, f"booking.{status}", "booking", booking_id)
|
||||
return await get_booking(db, booking_id)
|
||||
|
||||
|
||||
async def admin_delete_booking(db: asyncpg.Connection, booking_id: str):
|
||||
result = await db.execute("DELETE FROM bookings WHERE id = $1", booking_id)
|
||||
if result == "DELETE 0":
|
||||
raise NotFoundError("booking")
|
||||
|
||||
|
||||
async def handle_payment_succeeded(db: asyncpg.Connection, payment_intent_id: str, entity_id: str):
|
||||
booking = await db.fetchrow(
|
||||
"SELECT * FROM bookings WHERE id = $1 AND stripe_payment_intent_id = $2",
|
||||
entity_id, payment_intent_id,
|
||||
)
|
||||
if not booking:
|
||||
return
|
||||
|
||||
await db.execute(
|
||||
"UPDATE bookings SET status = 'confirmed', updated_at = now() WHERE id = $1",
|
||||
entity_id,
|
||||
)
|
||||
|
||||
email = booking["guest_email"]
|
||||
if not email and booking["user_id"]:
|
||||
profile = await db.fetchrow("SELECT email FROM profiles WHERE id = $1", str(booking["user_id"]))
|
||||
if profile:
|
||||
email = profile["email"]
|
||||
|
||||
slot = await db.fetchrow("SELECT date, start_time FROM time_slots WHERE id = $1", str(booking["slot_id"]))
|
||||
if email and slot:
|
||||
from app.services.email_service import send_booking_confirmed
|
||||
await send_booking_confirmed(email, str(slot["date"]), _fmt_time(slot["start_time"]))
|
||||
|
||||
|
||||
async def handle_payment_failed(db: asyncpg.Connection, payment_intent_id: str, entity_id: str):
|
||||
await db.execute(
|
||||
"UPDATE bookings SET status = 'cancelled', updated_at = now() WHERE id = $1 AND status = 'pending'",
|
||||
entity_id,
|
||||
)
|
||||
|
||||
|
||||
async def _get_booking_price(db: asyncpg.Connection) -> float:
|
||||
val = await db.fetchval("SELECT value FROM store_settings WHERE key = 'default_booking_price'")
|
||||
if val is not None:
|
||||
try:
|
||||
return float(val)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
|
||||
def _shape(r) -> dict:
|
||||
d = dict(r)
|
||||
d["slot_start"] = _fmt_time(d.get("slot_start"))
|
||||
d["slot_end"] = _fmt_time(d.get("slot_end"))
|
||||
# Resolve client identity: profile takes priority over guest fields
|
||||
d["client_name"] = d.pop("profile_name", None) or d.get("guest_name")
|
||||
d["client_email"] = d.pop("profile_email", None) or d.get("guest_email")
|
||||
d["client_phone"] = d.pop("profile_phone", None) or d.get("guest_phone")
|
||||
# Clean up internal fields
|
||||
for f in ("guest_name", "guest_email", "guest_phone", "service_name"):
|
||||
d.pop(f, None)
|
||||
return d
|
||||
|
||||
|
||||
def _fmt_time(t) -> str:
|
||||
if t is None:
|
||||
return ""
|
||||
s = str(t)
|
||||
# "HH:MM:SS" → "HH:MM"
|
||||
return s[:5] if len(s) >= 5 else s
|
||||
|
||||
|
||||
async def _log(db, actor_id, action, entity_type, entity_id, metadata=None):
|
||||
await db.execute(
|
||||
"INSERT INTO activity_log (actor_id, action, entity_type, entity_id, metadata) VALUES ($1, $2, $3, $4, $5)",
|
||||
actor_id, action, entity_type, entity_id, metadata,
|
||||
)
|
||||
71
app/services/email_service.py
Normal file
71
app/services/email_service.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import aiosmtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
async def send_email(to: str, subject: str, html_body: str, text_body: str | None = None):
|
||||
settings = get_settings()
|
||||
if not settings.SMTP_HOST:
|
||||
return # Email non configuré, on ignore silencieusement
|
||||
|
||||
message = MIMEMultipart("alternative")
|
||||
message["Subject"] = subject
|
||||
message["From"] = settings.EMAIL_FROM
|
||||
message["To"] = to
|
||||
|
||||
if text_body:
|
||||
message.attach(MIMEText(text_body, "plain"))
|
||||
message.attach(MIMEText(html_body, "html"))
|
||||
|
||||
try:
|
||||
async with aiosmtplib.SMTP(
|
||||
hostname=settings.SMTP_HOST,
|
||||
port=settings.SMTP_PORT,
|
||||
start_tls=True,
|
||||
) as smtp:
|
||||
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
await smtp.send_message(message)
|
||||
except Exception:
|
||||
pass # Ne jamais laisser un échec d'email planter la requête
|
||||
|
||||
|
||||
async def send_booking_confirmed(to: str, booking_date: str, booking_time: str):
|
||||
settings = get_settings()
|
||||
html = f"""
|
||||
<h2>Votre rendez-vous est confirmé !</h2>
|
||||
<p>Bonjour,</p>
|
||||
<p>Votre rendez-vous chez <strong>{settings.BUSINESS_NAME}</strong> est confirmé.</p>
|
||||
<p><strong>Date :</strong> {booking_date}<br>
|
||||
<strong>Heure :</strong> {booking_time}</p>
|
||||
<p>Nous vous contacterons pour finaliser le paiement. À très bientôt !</p>
|
||||
<p>— L'équipe {settings.BUSINESS_NAME}</p>
|
||||
"""
|
||||
await send_email(to, f"Rendez-vous confirmé – {settings.BUSINESS_NAME}", html)
|
||||
|
||||
|
||||
async def send_booking_cancelled(to: str, booking_date: str, booking_time: str):
|
||||
settings = get_settings()
|
||||
html = f"""
|
||||
<h2>Annulation de votre rendez-vous</h2>
|
||||
<p>Bonjour,</p>
|
||||
<p>Votre rendez-vous du {booking_date} à {booking_time} a été annulé.</p>
|
||||
<p>Pour toute question, n'hésitez pas à nous contacter.</p>
|
||||
<p>— L'équipe {settings.BUSINESS_NAME}</p>
|
||||
"""
|
||||
await send_email(to, f"Rendez-vous annulé – {settings.BUSINESS_NAME}", html)
|
||||
|
||||
|
||||
async def send_order_confirmed(to: str, order_id: str, total: float):
|
||||
settings = get_settings()
|
||||
html = f"""
|
||||
<h2>Commande enregistrée !</h2>
|
||||
<p>Bonjour,</p>
|
||||
<p>Merci pour votre commande chez <strong>{settings.BUSINESS_NAME}</strong>.</p>
|
||||
<p><strong>Numéro de commande :</strong> {order_id}<br>
|
||||
<strong>Total :</strong> {total:.2f} €</p>
|
||||
<p>Nous vous contacterons pour confirmer les modalités de livraison et de paiement.</p>
|
||||
<p>— L'équipe {settings.BUSINESS_NAME}</p>
|
||||
"""
|
||||
await send_email(to, f"Commande confirmée – {settings.BUSINESS_NAME}", html)
|
||||
194
app/services/order_service.py
Normal file
194
app/services/order_service.py
Normal file
@@ -0,0 +1,194 @@
|
||||
from uuid import UUID
|
||||
|
||||
import asyncpg
|
||||
|
||||
from app.exceptions import NotFoundError, OutOfStockError
|
||||
from app.models.orders import OrderCreate
|
||||
|
||||
|
||||
async def create_order(db: asyncpg.Connection, user_id: str, data: OrderCreate) -> dict:
|
||||
async with db.transaction():
|
||||
total = 0.0
|
||||
line_items = []
|
||||
|
||||
for item in data.items:
|
||||
product = await db.fetchrow(
|
||||
"SELECT id, name, price, stock_quantity FROM products WHERE id = $1 AND is_hidden = false",
|
||||
str(item.product_id),
|
||||
)
|
||||
if not product:
|
||||
raise NotFoundError("product")
|
||||
if product["stock_quantity"] < item.quantity:
|
||||
raise OutOfStockError(product["name"])
|
||||
|
||||
line_items.append({
|
||||
"product_id": str(item.product_id),
|
||||
"product_name": product["name"],
|
||||
"quantity": item.quantity,
|
||||
"unit_price": float(product["price"]),
|
||||
})
|
||||
total += float(product["price"]) * item.quantity
|
||||
|
||||
await db.execute(
|
||||
"UPDATE products SET stock_quantity = stock_quantity - $2 WHERE id = $1",
|
||||
str(item.product_id), item.quantity,
|
||||
)
|
||||
|
||||
total = round(total, 2)
|
||||
order = await db.fetchrow(
|
||||
"""
|
||||
INSERT INTO orders (user_id, status, total_amount, shipping_address, notes)
|
||||
VALUES ($1, 'pending', $2, $3, $4)
|
||||
RETURNING *
|
||||
""",
|
||||
user_id, total,
|
||||
data.shipping_address, data.notes,
|
||||
)
|
||||
order_id = str(order["id"])
|
||||
|
||||
for li in line_items:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
""",
|
||||
order_id, li["product_id"], li["quantity"], li["unit_price"],
|
||||
)
|
||||
|
||||
return {"order_id": order_id, "amount": total}
|
||||
|
||||
|
||||
async def get_order(db: asyncpg.Connection, order_id: UUID, user_id: str | None = None) -> dict:
|
||||
query = "SELECT o.* FROM orders o WHERE o.id = $1"
|
||||
params: list = [str(order_id)]
|
||||
|
||||
if user_id:
|
||||
query += " AND o.user_id = $2"
|
||||
params.append(user_id)
|
||||
|
||||
row = await db.fetchrow(query, *params)
|
||||
if not row:
|
||||
raise NotFoundError("order")
|
||||
|
||||
items = await db.fetch(
|
||||
"""
|
||||
SELECT oi.id, oi.product_id, p.name as product_name, oi.quantity, oi.unit_price
|
||||
FROM order_items oi
|
||||
JOIN products p ON p.id = oi.product_id
|
||||
WHERE oi.order_id = $1
|
||||
""",
|
||||
str(order_id),
|
||||
)
|
||||
|
||||
result = dict(row)
|
||||
result["items"] = [dict(i) for i in items]
|
||||
return result
|
||||
|
||||
|
||||
async def list_orders(
|
||||
db: asyncpg.Connection,
|
||||
page: int,
|
||||
per_page: int,
|
||||
offset: int,
|
||||
user_id: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> tuple[list[dict], int]:
|
||||
conditions: list[str] = []
|
||||
params: list = []
|
||||
|
||||
if user_id:
|
||||
params.append(user_id)
|
||||
conditions.append(f"o.user_id = ${len(params)}")
|
||||
if status:
|
||||
params.append(status)
|
||||
conditions.append(f"o.status = ${len(params)}")
|
||||
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
total = await db.fetchval(f"SELECT COUNT(*) FROM orders o {where}", *params)
|
||||
|
||||
params.extend([per_page, offset])
|
||||
rows = await db.fetch(
|
||||
f"""
|
||||
SELECT o.id, o.user_id, o.status, o.total_amount,
|
||||
o.shipping_address, o.notes, o.created_at, o.updated_at,
|
||||
p.full_name AS client_name, p.email AS client_email, p.phone AS client_phone
|
||||
FROM orders o
|
||||
LEFT JOIN profiles p ON p.id = o.user_id
|
||||
{where}
|
||||
ORDER BY o.created_at DESC
|
||||
LIMIT ${len(params) - 1} OFFSET ${len(params)}
|
||||
""",
|
||||
*params,
|
||||
)
|
||||
return [dict(r) for r in rows], total
|
||||
|
||||
|
||||
async def update_order_status(db: asyncpg.Connection, order_id: UUID, status: str, actor_id: str) -> dict:
|
||||
row = await db.fetchrow(
|
||||
"UPDATE orders SET status = $2, updated_at = now() WHERE id = $1 RETURNING *",
|
||||
str(order_id), status,
|
||||
)
|
||||
if not row:
|
||||
raise NotFoundError("order")
|
||||
await _log(db, actor_id, "order.status_updated", "order", str(order_id), {"status": status})
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def refund_order(db: asyncpg.Connection, order_id: UUID, actor_id: str, amount: float | None, reason: str | None) -> dict:
|
||||
order = await db.fetchrow("SELECT * FROM orders WHERE id = $1", str(order_id))
|
||||
if not order:
|
||||
raise NotFoundError("order")
|
||||
if not order["stripe_payment_intent_id"]:
|
||||
from app.exceptions import AppError
|
||||
raise AppError("NO_PAYMENT", "No payment found for this order", 400)
|
||||
|
||||
refund = await stripe_service.create_refund(
|
||||
order["stripe_payment_intent_id"],
|
||||
amount=amount,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
new_status = "refunded"
|
||||
row = await db.fetchrow(
|
||||
"UPDATE orders SET status = $2, stripe_refund_id = $3, updated_at = now() WHERE id = $1 RETURNING *",
|
||||
str(order_id), new_status, refund.id,
|
||||
)
|
||||
await _log(db, actor_id, "order.refunded", "order", str(order_id), {"refund_id": refund.id})
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def handle_payment_succeeded(db: asyncpg.Connection, payment_intent_id: str, entity_id: str):
|
||||
row = await db.fetchrow(
|
||||
"UPDATE orders SET status = 'paid', updated_at = now() WHERE id = $1 AND stripe_payment_intent_id = $2 RETURNING *",
|
||||
entity_id, payment_intent_id,
|
||||
)
|
||||
if row:
|
||||
user = await db.fetchrow("SELECT email FROM profiles WHERE id = $1", str(row["user_id"]))
|
||||
if user:
|
||||
from app.services.email_service import send_order_confirmed
|
||||
await send_order_confirmed(user["email"], entity_id, float(row["total_amount"]))
|
||||
|
||||
|
||||
async def handle_payment_failed(db: asyncpg.Connection, payment_intent_id: str, entity_id: str):
|
||||
order = await db.fetchrow("SELECT * FROM orders WHERE id = $1", entity_id)
|
||||
if not order or order["status"] != "pending":
|
||||
return
|
||||
|
||||
await db.execute(
|
||||
"UPDATE orders SET status = 'cancelled', updated_at = now() WHERE id = $1",
|
||||
entity_id,
|
||||
)
|
||||
# Restore stock
|
||||
items = await db.fetch("SELECT product_id, quantity FROM order_items WHERE order_id = $1", entity_id)
|
||||
for item in items:
|
||||
await db.execute(
|
||||
"UPDATE products SET stock_quantity = stock_quantity + $2 WHERE id = $1",
|
||||
str(item["product_id"]), item["quantity"],
|
||||
)
|
||||
|
||||
|
||||
async def _log(db, actor_id, action, entity_type, entity_id, metadata=None):
|
||||
await db.execute(
|
||||
"INSERT INTO activity_log (actor_id, action, entity_type, entity_id, metadata) VALUES ($1, $2, $3, $4, $5)",
|
||||
actor_id, action, entity_type, entity_id, metadata,
|
||||
)
|
||||
159
app/services/product_service.py
Normal file
159
app/services/product_service.py
Normal file
@@ -0,0 +1,159 @@
|
||||
import json
|
||||
from uuid import UUID
|
||||
|
||||
import asyncpg
|
||||
|
||||
from app.exceptions import NotFoundError
|
||||
from app.models.products import ProductCreate, ProductUpdate
|
||||
|
||||
|
||||
async def list_products(
|
||||
db: asyncpg.Connection,
|
||||
page: int,
|
||||
per_page: int,
|
||||
offset: int,
|
||||
include_hidden: bool = False,
|
||||
category: str | None = None,
|
||||
bestseller: bool = False,
|
||||
is_new: bool = False,
|
||||
search: str | None = None,
|
||||
exclude_id: str | None = None,
|
||||
) -> tuple[list[dict], int]:
|
||||
conditions: list[str] = []
|
||||
params: list = []
|
||||
|
||||
if not include_hidden:
|
||||
conditions.append("is_hidden = false")
|
||||
if category:
|
||||
params.append(category)
|
||||
conditions.append(f"category = ${len(params)}")
|
||||
if bestseller:
|
||||
conditions.append("is_bestseller = true")
|
||||
if is_new:
|
||||
conditions.append("is_new = true")
|
||||
if search:
|
||||
params.append(f"%{search}%")
|
||||
conditions.append(f"(name ILIKE ${len(params)} OR description ILIKE ${len(params)})")
|
||||
if exclude_id:
|
||||
params.append(exclude_id)
|
||||
conditions.append(f"id != ${len(params)}")
|
||||
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
total = await db.fetchval(f"SELECT COUNT(*) FROM products {where}", *params)
|
||||
|
||||
params.extend([per_page, offset])
|
||||
rows = await db.fetch(
|
||||
f"""
|
||||
SELECT id, name, description, price, original_price, category,
|
||||
images, colors, lengths, features, stock_quantity,
|
||||
is_featured, is_hidden, is_new, is_bestseller,
|
||||
rating, review_count, created_at, updated_at
|
||||
FROM products {where}
|
||||
ORDER BY is_bestseller DESC, is_new DESC, created_at DESC
|
||||
LIMIT ${len(params) - 1} OFFSET ${len(params)}
|
||||
""",
|
||||
*params,
|
||||
)
|
||||
return [_row(r) for r in rows], total
|
||||
|
||||
|
||||
async def get_product(db: asyncpg.Connection, product_id: str) -> dict:
|
||||
row = await db.fetchrow("SELECT * FROM products WHERE id = $1", product_id)
|
||||
if not row:
|
||||
raise NotFoundError("product")
|
||||
return _row(row)
|
||||
|
||||
|
||||
async def create_product(db: asyncpg.Connection, data: ProductCreate) -> dict:
|
||||
row = await db.fetchrow(
|
||||
"""
|
||||
INSERT INTO products (
|
||||
name, description, price, original_price, category,
|
||||
colors, lengths, features, stock_quantity,
|
||||
is_featured, is_hidden, is_new, is_bestseller, images
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'[]'::jsonb)
|
||||
RETURNING *
|
||||
""",
|
||||
data.name, data.description, data.price, data.original_price, data.category,
|
||||
data.colors, data.lengths, data.features,
|
||||
data.stock_quantity, data.is_featured, data.is_hidden, data.is_new, data.is_bestseller,
|
||||
)
|
||||
return _row(row)
|
||||
|
||||
|
||||
async def update_product(db: asyncpg.Connection, product_id: str, data: ProductUpdate) -> dict:
|
||||
await get_product(db, product_id)
|
||||
updates = {k: v for k, v in data.model_dump().items() if v is not None}
|
||||
if not updates:
|
||||
return await get_product(db, product_id)
|
||||
|
||||
json_fields = {"colors", "lengths", "features"}
|
||||
set_parts = []
|
||||
values = []
|
||||
for i, (k, v) in enumerate(updates.items()):
|
||||
set_parts.append(f"{k} = ${i + 2}")
|
||||
values.append(v)
|
||||
|
||||
row = await db.fetchrow(
|
||||
f"UPDATE products SET {', '.join(set_parts)}, updated_at = now() WHERE id = $1 RETURNING *",
|
||||
product_id, *values,
|
||||
)
|
||||
return _row(row)
|
||||
|
||||
|
||||
async def delete_product(db: asyncpg.Connection, product_id: str):
|
||||
result = await db.execute("DELETE FROM products WHERE id = $1", product_id)
|
||||
if result == "DELETE 0":
|
||||
raise NotFoundError("product")
|
||||
|
||||
|
||||
async def add_image(db: asyncpg.Connection, product_id: str, url: str) -> dict:
|
||||
await get_product(db, product_id)
|
||||
row = await db.fetchrow(
|
||||
"""
|
||||
UPDATE products
|
||||
SET images = images || $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *
|
||||
""",
|
||||
product_id, [url],
|
||||
)
|
||||
return _row(row)
|
||||
|
||||
|
||||
async def remove_image(db: asyncpg.Connection, product_id: str, url: str) -> dict:
|
||||
product = await get_product(db, product_id)
|
||||
images = [img for img in product["_raw_images"] if img != url]
|
||||
row = await db.fetchrow(
|
||||
"UPDATE products SET images = $2, updated_at = now() WHERE id = $1 RETURNING *",
|
||||
product_id, images,
|
||||
)
|
||||
return _row(row)
|
||||
|
||||
|
||||
async def bulk_stock_update(db: asyncpg.Connection, updates: list[dict]):
|
||||
async with db.transaction():
|
||||
for u in updates:
|
||||
await db.execute(
|
||||
"UPDATE products SET stock_quantity = $2, updated_at = now() WHERE id = $1",
|
||||
str(u["id"]), u["stock_quantity"],
|
||||
)
|
||||
|
||||
|
||||
def _row(r) -> dict:
|
||||
d = dict(r)
|
||||
|
||||
# Decode JSONB lists
|
||||
for field in ("images", "colors", "lengths", "features"):
|
||||
val = d.get(field, [])
|
||||
if isinstance(val, str):
|
||||
val = json.loads(val)
|
||||
d[field] = val if isinstance(val, list) else []
|
||||
|
||||
# images stored as plain URL strings; keep a raw copy for internal use
|
||||
d["_raw_images"] = d["images"]
|
||||
|
||||
# Derive convenience `image` field (first URL for product cards)
|
||||
d["image"] = d["images"][0] if d["images"] else ""
|
||||
|
||||
return d
|
||||
175
app/services/slot_service.py
Normal file
175
app/services/slot_service.py
Normal file
@@ -0,0 +1,175 @@
|
||||
from datetime import date, timedelta, datetime, time
|
||||
|
||||
import asyncpg
|
||||
import pytz
|
||||
|
||||
from app.config import get_settings
|
||||
from app.exceptions import NotFoundError, AppError
|
||||
from app.models.bookings import WeeklyScheduleCreate, SlotCreate
|
||||
|
||||
|
||||
def _fmt_time(t) -> str:
|
||||
if t is None:
|
||||
return ""
|
||||
s = str(t)
|
||||
return s[:5]
|
||||
|
||||
|
||||
async def get_schedule(db: asyncpg.Connection) -> list[dict]:
|
||||
rows = await db.fetch("SELECT * FROM weekly_schedule WHERE is_active = true ORDER BY day_of_week, start_time")
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
async def create_schedule_entry(db: asyncpg.Connection, data: WeeklyScheduleCreate) -> dict:
|
||||
row = await db.fetchrow(
|
||||
"""
|
||||
INSERT INTO weekly_schedule (day_of_week, start_time, end_time, slot_duration_minutes)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *
|
||||
""",
|
||||
data.day_of_week, data.start_time, data.end_time, data.slot_duration_minutes,
|
||||
)
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def delete_schedule_entry(db: asyncpg.Connection, schedule_id: str):
|
||||
result = await db.execute("DELETE FROM weekly_schedule WHERE id = $1", schedule_id)
|
||||
if result == "DELETE 0":
|
||||
raise NotFoundError("schedule entry")
|
||||
|
||||
|
||||
async def generate_slots(db: asyncpg.Connection, from_date: date, to_date: date) -> int:
|
||||
if (to_date - from_date).days > 90:
|
||||
raise AppError("RANGE_TOO_LARGE", "Date range cannot exceed 90 days", 400)
|
||||
|
||||
schedule = await db.fetch("SELECT * FROM weekly_schedule WHERE is_active = true")
|
||||
if not schedule:
|
||||
raise AppError("NO_SCHEDULE", "No active weekly schedule configured", 400)
|
||||
|
||||
blocked = await db.fetch(
|
||||
"SELECT date FROM blocked_dates WHERE date BETWEEN $1 AND $2",
|
||||
from_date, to_date,
|
||||
)
|
||||
blocked_set = {r["date"] for r in blocked}
|
||||
|
||||
tz = pytz.timezone(get_settings().BUSINESS_TIMEZONE)
|
||||
created = 0
|
||||
current = from_date
|
||||
|
||||
async with db.transaction():
|
||||
while current <= to_date:
|
||||
if current in blocked_set:
|
||||
current += timedelta(days=1)
|
||||
continue
|
||||
|
||||
day_schedules = [s for s in schedule if s["day_of_week"] == current.weekday()]
|
||||
|
||||
for sched in day_schedules:
|
||||
cursor = datetime.combine(current, sched["start_time"])
|
||||
end_limit = datetime.combine(current, sched["end_time"])
|
||||
duration = timedelta(minutes=sched["slot_duration_minutes"])
|
||||
|
||||
while cursor + duration <= end_limit:
|
||||
slot_end = cursor + duration
|
||||
exists = await db.fetchval(
|
||||
"SELECT 1 FROM time_slots WHERE date = $1 AND start_time = $2",
|
||||
current, cursor.time(),
|
||||
)
|
||||
if not exists:
|
||||
await db.execute(
|
||||
"INSERT INTO time_slots (date, start_time, end_time) VALUES ($1, $2, $3)",
|
||||
current, cursor.time(), slot_end.time(),
|
||||
)
|
||||
created += 1
|
||||
cursor += duration
|
||||
|
||||
current += timedelta(days=1)
|
||||
|
||||
return created
|
||||
|
||||
|
||||
async def create_slot(db: asyncpg.Connection, data: SlotCreate) -> dict:
|
||||
if data.start_time >= data.end_time:
|
||||
raise AppError("INVALID_SLOT", "start_time must be before end_time", 400)
|
||||
row = await db.fetchrow(
|
||||
"INSERT INTO time_slots (date, start_time, end_time) VALUES ($1, $2, $3) RETURNING *",
|
||||
data.date, data.start_time, data.end_time,
|
||||
)
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def delete_slot(db: asyncpg.Connection, slot_id: str):
|
||||
booked = await db.fetchval(
|
||||
"SELECT 1 FROM bookings WHERE slot_id = $1 AND status IN ('pending', 'confirmed')",
|
||||
slot_id,
|
||||
)
|
||||
if booked:
|
||||
raise AppError("SLOT_BOOKED", "Cannot delete a slot with active bookings", 409)
|
||||
result = await db.execute("DELETE FROM time_slots WHERE id = $1", slot_id)
|
||||
if result == "DELETE 0":
|
||||
raise NotFoundError("slot")
|
||||
|
||||
|
||||
async def update_slot(db: asyncpg.Connection, slot_id: str, is_blocked: bool, block_reason: str | None) -> dict:
|
||||
row = await db.fetchrow(
|
||||
"UPDATE time_slots SET is_blocked = $2, block_reason = $3 WHERE id = $1 RETURNING *",
|
||||
slot_id, is_blocked, block_reason,
|
||||
)
|
||||
if not row:
|
||||
raise NotFoundError("slot")
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def list_slots(
|
||||
db: asyncpg.Connection,
|
||||
from_date: date,
|
||||
to_date: date,
|
||||
available_only: bool = False,
|
||||
) -> list[dict]:
|
||||
query = """
|
||||
SELECT
|
||||
ts.id, ts.date, ts.start_time, ts.end_time,
|
||||
ts.is_blocked, ts.block_reason,
|
||||
EXISTS(
|
||||
SELECT 1 FROM bookings b
|
||||
WHERE b.slot_id = ts.id AND b.status IN ('pending', 'confirmed')
|
||||
) AS is_booked
|
||||
FROM time_slots ts
|
||||
WHERE ts.date BETWEEN $1 AND $2
|
||||
"""
|
||||
params: list = [from_date, to_date]
|
||||
|
||||
if available_only:
|
||||
query += " AND ts.is_blocked = false"
|
||||
query += """
|
||||
AND NOT EXISTS(
|
||||
SELECT 1 FROM blocked_dates bd WHERE bd.date = ts.date
|
||||
)
|
||||
AND NOT EXISTS(
|
||||
SELECT 1 FROM bookings b WHERE b.slot_id = ts.id AND b.status IN ('pending', 'confirmed')
|
||||
)
|
||||
AND ts.date >= CURRENT_DATE
|
||||
"""
|
||||
|
||||
query += " ORDER BY ts.date, ts.start_time"
|
||||
rows = await db.fetch(query, *params)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
async def get_blocked_dates(db: asyncpg.Connection) -> list[dict]:
|
||||
rows = await db.fetch("SELECT * FROM blocked_dates ORDER BY date")
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
async def add_blocked_date(db: asyncpg.Connection, date: date, reason: str | None) -> dict:
|
||||
row = await db.fetchrow(
|
||||
"INSERT INTO blocked_dates (date, reason) VALUES ($1, $2) ON CONFLICT (date) DO UPDATE SET reason = $2 RETURNING *",
|
||||
date, reason,
|
||||
)
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def remove_blocked_date(db: asyncpg.Connection, blocked_date_id: str):
|
||||
result = await db.execute("DELETE FROM blocked_dates WHERE id = $1", blocked_date_id)
|
||||
if result == "DELETE 0":
|
||||
raise NotFoundError("blocked date")
|
||||
36
app/services/storage_service.py
Normal file
36
app/services/storage_service.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import io
|
||||
import uuid
|
||||
from supabase import create_client
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
BUCKET = "product-images"
|
||||
|
||||
|
||||
def _client():
|
||||
s = get_settings()
|
||||
return create_client(s.SUPABASE_URL, s.SUPABASE_SERVICE_ROLE_KEY)
|
||||
|
||||
|
||||
async def upload_product_image(product_id: str, file_bytes: bytes, content_type: str) -> dict:
|
||||
ext = "jpg" if "jpeg" in content_type else content_type.split("/")[-1]
|
||||
path = f"{product_id}/{uuid.uuid4()}.{ext}"
|
||||
|
||||
client = _client()
|
||||
result = client.storage.from_(BUCKET).upload(
|
||||
path,
|
||||
file_bytes,
|
||||
{"content-type": content_type, "upsert": False},
|
||||
)
|
||||
|
||||
# supabase-py v2 raises StorageException on failure, but guard against
|
||||
# unexpected response shapes that silently skip the upload
|
||||
if hasattr(result, "error") and result.error:
|
||||
raise RuntimeError(f"Storage upload failed: {result.error}")
|
||||
|
||||
url = client.storage.from_(BUCKET).get_public_url(path)
|
||||
return {"url": url, "storage_path": path}
|
||||
|
||||
|
||||
async def delete_product_image(storage_path: str):
|
||||
_client().storage.from_(BUCKET).remove([storage_path])
|
||||
61
app/services/stripe_service.py
Normal file
61
app/services/stripe_service.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import asyncio
|
||||
import stripe
|
||||
|
||||
from app.config import get_settings
|
||||
from app.exceptions import PaymentError
|
||||
|
||||
_stripe_initialized = False
|
||||
|
||||
|
||||
def _init_stripe():
|
||||
global _stripe_initialized
|
||||
if not _stripe_initialized:
|
||||
stripe.api_key = get_settings().STRIPE_SECRET_KEY
|
||||
_stripe_initialized = True
|
||||
|
||||
|
||||
async def create_payment_intent(
|
||||
amount: float,
|
||||
metadata: dict,
|
||||
description: str = "",
|
||||
) -> stripe.PaymentIntent:
|
||||
_init_stripe()
|
||||
settings = get_settings()
|
||||
try:
|
||||
intent = await asyncio.to_thread(
|
||||
stripe.PaymentIntent.create,
|
||||
amount=int(round(amount * 100)),
|
||||
currency=settings.STRIPE_CURRENCY,
|
||||
metadata=metadata,
|
||||
description=description,
|
||||
automatic_payment_methods={"enabled": True},
|
||||
)
|
||||
return intent
|
||||
except stripe.StripeError as e:
|
||||
raise PaymentError(str(e))
|
||||
|
||||
|
||||
async def create_refund(
|
||||
payment_intent_id: str,
|
||||
amount: float | None = None,
|
||||
reason: str | None = None,
|
||||
) -> stripe.Refund:
|
||||
_init_stripe()
|
||||
try:
|
||||
kwargs: dict = {"payment_intent": payment_intent_id}
|
||||
if amount is not None:
|
||||
kwargs["amount"] = int(round(amount * 100))
|
||||
if reason:
|
||||
kwargs["reason"] = reason
|
||||
return await asyncio.to_thread(stripe.Refund.create, **kwargs)
|
||||
except stripe.StripeError as e:
|
||||
raise PaymentError(str(e))
|
||||
|
||||
|
||||
def verify_webhook(payload: bytes, sig_header: str) -> stripe.Event:
|
||||
_init_stripe()
|
||||
settings = get_settings()
|
||||
try:
|
||||
return stripe.Webhook.construct_event(payload, sig_header, settings.STRIPE_WEBHOOK_SECRET)
|
||||
except stripe.SignatureVerificationError:
|
||||
raise PaymentError("Invalid webhook signature")
|
||||
Reference in New Issue
Block a user