mirror of
http://88.130.71.182:3000/BlitTech/badoHair_be.git
synced 2026-06-13 09:00:42 +00:00
276 lines
9.2 KiB
Python
276 lines
9.2 KiB
Python
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:
|
|
try:
|
|
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"]))
|
|
except Exception:
|
|
pass # Email failure must not block the status update
|
|
|
|
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,
|
|
)
|