Initial Commit

This commit is contained in:
belviskhoremk
2026-05-12 00:34:21 +00:00
commit d2dc43b16f
57 changed files with 6056 additions and 0 deletions

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