From e7fabd57bf8b589824e13989205d34ef897552a1 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 19 Jul 2026 00:16:39 +0200 Subject: [PATCH] feat: CRUD + availability arbiter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Room/booking/block persistence, and is_available() — the authority for whether a range is free: room active AND no occupying booking or block overlaps [check_in, check_out) (half-open, so back-to-back stays are fine). expire_stale_holds() frees dates for lapsed unpaid holds. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD --- crud.py | 227 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 crud.py diff --git a/crud.py b/crud.py new file mode 100644 index 0000000..0c043ff --- /dev/null +++ b/crud.py @@ -0,0 +1,227 @@ +"""Chatelet CRUD + the availability arbiter. + +The DB is the single source of truth for whether a range is free. Nostr +booking requests are just requests; `is_available` + the `held` write is +where a date range actually gets locked. Keep the check-then-hold path +tight so two concurrent requests can't both win the same nights. +""" + +from datetime import date, datetime, timezone + +from lnbits.db import Database +from lnbits.helpers import urlsafe_short_hash + +from .models import ( + Block, + Booking, + BookingStatus, + ChateletSettings, + CreateBlockData, + CreateRoomData, + Room, + RoomStatus, +) + +db = Database("ext_chatelet") + + +# --------------------------------------------------------------------------- +# Settings +# --------------------------------------------------------------------------- + + +async def get_or_create_settings() -> ChateletSettings: + row = await db.fetchone( + "SELECT * FROM chatelet.settings LIMIT 1", model=ChateletSettings + ) + if row: + return row + settings = ChateletSettings() + await db.insert("chatelet.settings", settings) + return settings + + +async def update_settings(settings: ChateletSettings) -> ChateletSettings: + settings.updated_at = datetime.now(timezone.utc) + await db.update("chatelet.settings", settings, "") + return settings + + +# --------------------------------------------------------------------------- +# Rooms +# --------------------------------------------------------------------------- + + +async def create_room(data: CreateRoomData) -> Room: + assert data.wallet, "wallet is required" + room = Room( + id=urlsafe_short_hash()[:8], + wallet=data.wallet, + title=data.title, + description=data.description, + price_amount=data.price_amount, + price_currency=data.price_currency, + price_frequency=data.price_frequency, + max_guests=data.max_guests, + min_nights=data.min_nights, + amenities=data.amenities, + location=data.location, + geohash=data.geohash, + images=data.images, + status=RoomStatus.inactive, + ) + await db.insert("chatelet.rooms", room) + return room + + +async def get_room(room_id: str) -> Room | None: + return await db.fetchone( + "SELECT * FROM chatelet.rooms WHERE id = :id", {"id": room_id}, Room + ) + + +async def get_rooms() -> list[Room]: + return await db.fetchall("SELECT * FROM chatelet.rooms", model=Room) + + +async def update_room(room: Room) -> Room: + room.updated_at = datetime.now(timezone.utc) + await db.update("chatelet.rooms", room) + return room + + +async def delete_room(room_id: str) -> None: + await db.execute("DELETE FROM chatelet.rooms WHERE id = :id", {"id": room_id}) + + +# --------------------------------------------------------------------------- +# Bookings +# --------------------------------------------------------------------------- + + +async def get_booking(booking_id: str) -> Booking | None: + return await db.fetchone( + "SELECT * FROM chatelet.bookings WHERE id = :id", {"id": booking_id}, Booking + ) + + +async def get_booking_by_payment_hash(payment_hash: str) -> Booking | None: + return await db.fetchone( + "SELECT * FROM chatelet.bookings WHERE payment_hash = :ph", + {"ph": payment_hash}, + Booking, + ) + + +async def get_bookings_for_room(room_id: str) -> list[Booking]: + return await db.fetchall( + "SELECT * FROM chatelet.bookings WHERE room_id = :rid", + {"rid": room_id}, + Booking, + ) + + +async def create_booking(booking: Booking) -> Booking: + await db.insert("chatelet.bookings", booking) + return booking + + +async def update_booking(booking: Booking) -> Booking: + booking.updated_at = datetime.now(timezone.utc) + await db.update("chatelet.bookings", booking) + return booking + + +# --------------------------------------------------------------------------- +# Blocks +# --------------------------------------------------------------------------- + + +async def create_block(data: CreateBlockData) -> Block: + block = Block( + id=urlsafe_short_hash()[:8], + room_id=data.room_id, + start_date=data.start_date, + end_date=data.end_date, + reason=data.reason, + ) + await db.insert("chatelet.blocks", block) + return block + + +async def get_blocks_for_room(room_id: str) -> list[Block]: + return await db.fetchall( + "SELECT * FROM chatelet.blocks WHERE room_id = :rid", {"rid": room_id}, Block + ) + + +async def delete_block(block_id: str) -> None: + await db.execute("DELETE FROM chatelet.blocks WHERE id = :id", {"id": block_id}) + + +# --------------------------------------------------------------------------- +# Availability — the arbiter +# --------------------------------------------------------------------------- + + +def _overlaps(a_start: str, a_end: str, b_start: str, b_end: str) -> bool: + """Half-open interval overlap: [a_start, a_end) ∩ [b_start, b_end). + + check_out / end_date are exclusive, so a stay ending on the same day + another begins does NOT overlap (back-to-back bookings are fine). + """ + return a_start < b_end and b_start < a_end + + +async def is_available(room_id: str, check_in: str, check_out: str) -> bool: + """True iff the room is active and no occupying booking or block + overlaps [check_in, check_out). This is the authoritative check; call + it inside the same request path that writes the `held` booking. + + NOTE: for production, wrap the check+hold in a transaction (or a + per-room asyncio lock) so two simultaneous requests can't both pass + the read before either writes. See docs/event-flow.md § Concurrency. + """ + room = await get_room(room_id) + if not room or room.status != RoomStatus.active: + return False + + for b in await get_bookings_for_room(room_id): + if b.status in ( + BookingStatus.held, + BookingStatus.awaiting_payment, + BookingStatus.confirmed, + BookingStatus.checked_in, + ) and _overlaps(check_in, check_out, b.check_in, b.check_out): + return False + + for blk in await get_blocks_for_room(room_id): + if _overlaps(check_in, check_out, blk.start_date, blk.end_date): + return False + + return True + + +def nights_between(check_in: str, check_out: str) -> int: + d_in = date.fromisoformat(check_in) + d_out = date.fromisoformat(check_out) + return (d_out - d_in).days + + +async def expire_stale_holds() -> list[Booking]: + """Flip `held`/`awaiting_payment` bookings whose hold has lapsed to + `expired`, freeing their dates. Driven by tasks.py on a timer.""" + now = datetime.now(timezone.utc) + stale = await db.fetchall( + """ + SELECT * FROM chatelet.bookings + WHERE status IN ('held', 'awaiting_payment') + AND expires_at IS NOT NULL AND expires_at < :now + """, + {"now": now}, + Booking, + ) + for b in stale: + b.status = BookingStatus.expired + await update_booking(b) + return stale