feat: operator admin UI + backing endpoints (v0.2.0) #16

Merged
padreug merged 4 commits from feat/admin-ui into main 2026-07-19 22:46:12 +00:00
Showing only changes of commit f715d728ef - Show all commits

feat: operator admin REST endpoints (rooms/blocks/bookings/settings)

Adds the HTTP surface the admin UI needs, all admin-key + ownership-scoped:
- rooms: list (filtered to caller's wallet), update (PUT), delete, publish /
  unpublish (with ownership checks; publish/unpublish flip status + sync the
  relay listing).
- per-room: GET bookings, GET blocks.
- blocks: create (ownership-checked) + delete.
- settings: GET + PUT (merges only the editable fields).

_owned_room centralizes the 404/403 ownership guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
Padreug 2026-07-20 00:44:10 +02:00

View file

@ -2,9 +2,9 @@
The REST surface and the Nostr-transport surface (transport_rpcs.py) are two 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 doors into the SAME booking flow both delegate to services.py so
availability arbitration + quoting live in one place. Per the aiolabs availability arbitration + quoting live in one place. The operator admin
long-term direction (webapp<->lnbits over Nostr), REST is the transitional endpoints (room/block CRUD, settings) are HTTP-only and back the admin UI;
door; keep new booking logic in services.py, not here. the guest-facing surface (availability, booking) is what also rides the RPC.
""" """
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
@ -15,17 +15,35 @@ from . import crud, services
from .models import ( from .models import (
AvailabilityQuery, AvailabilityQuery,
AvailabilityResult, AvailabilityResult,
Block,
Booking, Booking,
BookingQuote, BookingQuote,
BookingRequestData, BookingRequestData,
ChateletSettings,
CreateBlockData, CreateBlockData,
CreateRoomData, CreateRoomData,
Room, Room,
RoomStatus,
) )
from .nostr import service as nostr from .nostr import service as nostr
chatelet_api_router = APIRouter() 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: def _to_http(exc: ValueError) -> HTTPException:
"""Map a services-layer ValueError subclass to an HTTP status.""" """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)) 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) ---------------------- # --- 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) @chatelet_api_router.post("/api/v1/rooms", status_code=201)
async def api_create_room( async def api_create_room(
data: CreateRoomData, key: WalletTypeInfo = Depends(require_admin_key) data: CreateRoomData, key: WalletTypeInfo = Depends(require_admin_key)
) -> Room: ) -> 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) return await crud.create_room(data)
@chatelet_api_router.get("/api/v1/rooms") @chatelet_api_router.put("/api/v1/rooms/{room_id}")
async def api_list_rooms() -> list[Room]: async def api_update_room(
return await crud.get_rooms() 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") @chatelet_api_router.post("/api/v1/rooms/{room_id}/publish")
async def api_publish_room( async def api_publish_room(
room_id: str, key: WalletTypeInfo = Depends(require_admin_key) room_id: str, key: WalletTypeInfo = Depends(require_admin_key)
) -> Room: ) -> Room:
room = await crud.get_room(room_id) room = await _owned_room(room_id, key)
if not room: room.status = RoomStatus.active
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 room.listing_event_id = await nostr.publish_listing(room) or room.listing_event_id
return await crud.update_room(room) 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) -------------------------------------------- # --- availability (public read) --------------------------------------------
@ -96,19 +221,3 @@ async def api_get_booking(
if not booking: if not booking:
raise HTTPException(404, "Booking not found") raise HTTPException(404, "Booking not found")
return booking 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