From f715d728ef30343e1f34defb03078fe5ab8cf2e2 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 20 Jul 2026 00:44:10 +0200 Subject: [PATCH 1/4] 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 Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD --- views_api.py | 163 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 136 insertions(+), 27 deletions(-) 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 -- 2.53.0 From e6973fa1922a966791ed3436e5947f5965db2e4f Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 20 Jul 2026 00:44:10 +0200 Subject: [PATCH 2/4] feat: operator admin UI (rooms, bookings, calendar blocks, settings) Replaces the placeholder page with a real 4-tab admin (Vue 3 + Quasar 2 UMD, no build). Rooms table with create/edit dialog + publish-toggle + delete; per-room bookings view; per-room calendar blocks add/delete; operator settings form (identity, relays, hold, deposit, times, policy). UMD gotchas honored: no self-closing tags, ${ } interpolation, :style for typography, static_url_for(path=...). Follows the spirekeeper admin pattern. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD --- static/js/index.js | 244 +++++++++++++++++++++++++++++++- templates/chatelet/index.html | 259 ++++++++++++++++++++++++++++++++-- 2 files changed, 487 insertions(+), 16 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index e586d16..e1867de 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,15 +1,247 @@ -// Chatelet operator admin page — placeholder. -// Quasar 2 + Vue 3 as UMD globals (no build step). Remember: no -// self-closing tags in UMD templates, and use :style bindings (not