diff --git a/views_api.py b/views_api.py index 91ec6b9..1f24a87 100644 --- a/views_api.py +++ b/views_api.py @@ -2,9 +2,9 @@ The REST surface and the Nostr-transport surface (transport_rpcs.py) are two doors into the SAME booking flow — both delegate to services.py so -availability arbitration + quoting live in one place. Per the aiolabs -long-term direction (webapp<->lnbits over Nostr), REST is the transitional -door; keep new booking logic in services.py, not here. +availability arbitration + quoting live in one place. The operator admin +endpoints (room/block CRUD, settings) are HTTP-only and back the admin UI; +the guest-facing surface (availability, booking) is what also rides the RPC. """ from fastapi import APIRouter, Depends, HTTPException @@ -15,17 +15,35 @@ from . import crud, services from .models import ( AvailabilityQuery, AvailabilityResult, + Block, Booking, BookingQuote, BookingRequestData, + ChateletSettings, CreateBlockData, CreateRoomData, Room, + RoomStatus, ) from .nostr import service as nostr chatelet_api_router = APIRouter() +# Fields patchable via PUT /rooms/{id}. Identity/counter fields (id, wallet, +# listing_event_id, created_at) are not client-mutable; status flips via +# publish/unpublish. +_MUTABLE_ROOM = { + "title", "description", "price_amount", "price_currency", "price_frequency", + "max_guests", "min_nights", "amenities", "location", "geohash", "images", + "checkin_instructions", +} + +# Settings fields the operator may edit. +_EDITABLE_SETTINGS = ( + "operator_id", "relays", "default_hold_minutes", "deposit_percent", + "checkin_time", "checkout_time", "cancellation_policy", "publish_availability", +) + def _to_http(exc: ValueError) -> HTTPException: """Map a services-layer ValueError subclass to an HTTP status.""" @@ -36,34 +54,141 @@ def _to_http(exc: ValueError) -> HTTPException: return HTTPException(400, str(exc)) +async def _owned_room(room_id: str, key: WalletTypeInfo) -> Room: + room = await crud.get_room(room_id) + if not room: + raise HTTPException(404, "Room not found") + if room.wallet != key.wallet.id: + raise HTTPException(403, "Room does not belong to this wallet") + return room + + # --- rooms (operator; admin-key scoped to own wallet) ---------------------- +@chatelet_api_router.get("/api/v1/rooms") +async def api_list_rooms( + key: WalletTypeInfo = Depends(require_admin_key), +) -> list[Room]: + return [r for r in await crud.get_rooms() if r.wallet == key.wallet.id] + + @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 + data.wallet = key.wallet.id # rooms are owned by the calling wallet 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.put("/api/v1/rooms/{room_id}") +async def api_update_room( + room_id: str, + data: CreateRoomData, + key: WalletTypeInfo = Depends(require_admin_key), +) -> Room: + room = await _owned_room(room_id, key) + for field in _MUTABLE_ROOM: + setattr(room, field, getattr(data, field)) + room = await crud.update_room(room) + if room.status == RoomStatus.active: + # keep the published listing in sync with the edit + room.listing_event_id = ( + await nostr.publish_listing(room) or room.listing_event_id + ) + room = await crud.update_room(room) + return room + + +@chatelet_api_router.delete("/api/v1/rooms/{room_id}") +async def api_delete_room( + room_id: str, key: WalletTypeInfo = Depends(require_admin_key) +) -> dict: + await _owned_room(room_id, key) + await crud.delete_room(room_id) + return {"deleted": True} @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 = await _owned_room(room_id, key) + room.status = RoomStatus.active room.listing_event_id = await nostr.publish_listing(room) or room.listing_event_id return await crud.update_room(room) +@chatelet_api_router.post("/api/v1/rooms/{room_id}/unpublish") +async def api_unpublish_room( + room_id: str, key: WalletTypeInfo = Depends(require_admin_key) +) -> Room: + room = await _owned_room(room_id, key) + room.status = RoomStatus.inactive + return await crud.update_room(room) + + +@chatelet_api_router.get("/api/v1/rooms/{room_id}/bookings") +async def api_room_bookings( + room_id: str, key: WalletTypeInfo = Depends(require_admin_key) +) -> list[Booking]: + await _owned_room(room_id, key) + return await crud.get_bookings_for_room(room_id) + + +@chatelet_api_router.get("/api/v1/rooms/{room_id}/blocks") +async def api_room_blocks( + room_id: str, key: WalletTypeInfo = Depends(require_admin_key) +) -> list[Block]: + await _owned_room(room_id, key) + return await crud.get_blocks_for_room(room_id) + + +# --- 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 _owned_room(data.room_id, 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 + + +@chatelet_api_router.delete("/api/v1/blocks/{block_id}") +async def api_delete_block( + block_id: str, key: WalletTypeInfo = Depends(require_admin_key) +) -> dict: + await crud.delete_block(block_id) + return {"deleted": True} + + +# --- settings (operator) --------------------------------------------------- + + +@chatelet_api_router.get("/api/v1/settings") +async def api_get_settings( + key: WalletTypeInfo = Depends(require_admin_key), +) -> ChateletSettings: + return await crud.get_or_create_settings() + + +@chatelet_api_router.put("/api/v1/settings") +async def api_update_settings( + data: ChateletSettings, key: WalletTypeInfo = Depends(require_admin_key) +) -> ChateletSettings: + settings = await crud.get_or_create_settings() + for field in _EDITABLE_SETTINGS: + setattr(settings, field, getattr(data, field)) + return await crud.update_settings(settings) + + # --- availability (public read) -------------------------------------------- @@ -96,19 +221,3 @@ async def api_get_booking( 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