diff --git a/views.py b/views.py new file mode 100644 index 0000000..276b456 --- /dev/null +++ b/views.py @@ -0,0 +1,22 @@ +"""Frontend routes — serves the operator admin page. SKETCH: single index +template; the guest-facing booking UI is expected to live in the webapp +(standalone app pattern), talking to this extension over Nostr/REST.""" + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +from lnbits.core.models import User +from lnbits.decorators import check_user_exists +from lnbits.helpers import template_renderer + +chatelet_generic_router = APIRouter() + + +def chatelet_renderer(): + return template_renderer(["chatelet/templates"]) + + +@chatelet_generic_router.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return chatelet_renderer().TemplateResponse( + "chatelet/index.html", {"request": request, "user": user.json()} + ) diff --git a/views_api.py b/views_api.py new file mode 100644 index 0000000..fb6ccda --- /dev/null +++ b/views_api.py @@ -0,0 +1,184 @@ +"""Chatelet REST API. + +The REST surface and the Nostr surface (nostr/service.py) are two doors +into the SAME booking flow — both funnel through crud + the helpers here so +availability arbitration and quoting live in exactly one place. Per the +aiolabs long-term direction (webapp<->lnbits over Nostr), REST is the +transitional door; don't grow booking logic that only the HTTP path knows. + +SKETCH: handlers show the intended flow and auth scopes; the pricing/FX and +invoice-creation calls are marked TODO where they'd touch core LNbits. +""" + +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, HTTPException +from lnbits.core.models import WalletTypeInfo +from lnbits.decorators import require_admin_key, require_invoice_key +from lnbits.helpers import urlsafe_short_hash + +from . import crud +from .models import ( + AvailabilityQuery, + AvailabilityResult, + Booking, + BookingRequestData, + BookingStatus, + CreateBlockData, + CreateRoomData, + Room, +) +from .nostr import service as nostr + +chatelet_api_router = APIRouter() + + +# --- rooms (operator; admin-key scoped to own wallet) ---------------------- + + +@chatelet_api_router.post("/api/v1/rooms", status_code=201) +async def api_create_room( + data: CreateRoomData, key: WalletTypeInfo = Depends(require_admin_key) +) -> Room: + data.wallet = data.wallet or key.wallet.id + return await crud.create_room(data) + + +@chatelet_api_router.get("/api/v1/rooms") +async def api_list_rooms() -> list[Room]: + return await crud.get_rooms() + + +@chatelet_api_router.post("/api/v1/rooms/{room_id}/publish") +async def api_publish_room( + room_id: str, key: WalletTypeInfo = Depends(require_admin_key) +) -> Room: + room = await crud.get_room(room_id) + if not room: + raise HTTPException(404, "Room not found") + room.status = room.status.active + room.listing_event_id = await nostr.publish_listing(room) or room.listing_event_id + return await crud.update_room(room) + + +# --- availability (public read) -------------------------------------------- + + +@chatelet_api_router.post("/api/v1/availability") +async def api_check_availability(q: AvailabilityQuery) -> AvailabilityResult: + room = await crud.get_room(q.room_id) + if not room: + raise HTTPException(404, "Room not found") + nights = crud.nights_between(q.check_in, q.check_out) + if nights < 1: + raise HTTPException(400, "check_out must be after check_in") + available = await crud.is_available(q.room_id, q.check_in, q.check_out) + quote_sat = quote_fiat = None + if available: + quote_fiat = round(room.price_amount * nights, 2) + quote_sat = await _to_sats(quote_fiat, room.price_currency) + return AvailabilityResult( + room_id=q.room_id, + check_in=q.check_in, + check_out=q.check_out, + available=available, + nights=nights, + quote_sat=quote_sat, + quote_fiat=quote_fiat, + currency=room.price_currency, + ) + + +# --- booking (public write; guest-initiated) ------------------------------- + + +@chatelet_api_router.post("/api/v1/bookings", status_code=201) +async def api_request_booking(data: BookingRequestData) -> Booking: + """Guest requests a stay. Check-then-hold is the lock: if available we + write a `held` booking with the canonical amount_sat and an expiry, then + (TODO) create the deposit invoice. The Nostr path calls the same steps.""" + room = await crud.get_room(data.room_id) + if not room or room.status != room.status.active: + raise HTTPException(404, "Room not available") + + nights = crud.nights_between(data.check_in, data.check_out) + if nights < room.min_nights: + raise HTTPException(400, f"Minimum stay is {room.min_nights} night(s)") + if data.num_guests > room.max_guests: + raise HTTPException(400, f"Max {room.max_guests} guests") + + # TODO(concurrency): wrap is_available + create_booking in a per-room + # lock / DB transaction so two requests can't both pass the read. + if not await crud.is_available(data.room_id, data.check_in, data.check_out): + raise HTTPException(409, "Those dates are no longer available") + + settings = await crud.get_or_create_settings() + price_fiat = round(room.price_amount * nights, 2) + amount_sat = await _to_sats(price_fiat, room.price_currency) # canonical + deposit_sat = amount_sat * settings.deposit_percent // 100 + + booking = Booking( + id=urlsafe_short_hash()[:10], + room_id=room.id, + guest_pubkey=data.guest_pubkey, + guest_contact=data.guest_contact, + check_in=data.check_in, + check_out=data.check_out, + nights=nights, + num_guests=data.num_guests, + currency=room.price_currency, + price_fiat=price_fiat, + amount_sat=amount_sat, + deposit_sat=deposit_sat, + status=BookingStatus.held, + expires_at=datetime.now(timezone.utc) + + timedelta(minutes=settings.default_hold_minutes), + ) + await crud.create_booking(booking) + + # TODO(payment): create_invoice(wallet_id=room.wallet, amount=deposit_sat, + # extra={"tag": "chatelet", "booking_id": booking.id}); store payment_hash; + # set status=awaiting_payment; DM the bolt11 to the guest (NIP-17). + return booking + + +@chatelet_api_router.get("/api/v1/bookings/{booking_id}") +async def api_get_booking( + booking_id: str, key: WalletTypeInfo = Depends(require_invoice_key) +) -> Booking: + booking = await crud.get_booking(booking_id) + if not booking: + raise HTTPException(404, "Booking not found") + return booking + + +# --- blocks (operator) ----------------------------------------------------- + + +@chatelet_api_router.post("/api/v1/blocks", status_code=201) +async def api_create_block( + data: CreateBlockData, key: WalletTypeInfo = Depends(require_admin_key) +): + block = await crud.create_block(data) + room = await crud.get_room(data.room_id) + if room: + await nostr.publish_block_calendar( + room, block.start_date, block.end_date, block.id + ) + return block + + +# --- helpers --------------------------------------------------------------- + + +async def _to_sats(amount: float, currency: str) -> int: + """Fiat/currency -> sats. Canonical conversion happens HERE, once, at + quote/hold time; the result is stored as amount_sat and never recomputed + downstream. + + TODO(fx): if currency in ('sat','sats') return int(amount); else call + lnbits.utils.exchange_rates.fiat_amount_as_satoshis(amount, currency). + """ + if currency.lower() in ("sat", "sats"): + return int(amount) + raise NotImplementedError("wire lnbits fiat_amount_as_satoshis")