feat: operator settings, guest booking list, card checkout #22
8 changed files with 140 additions and 2 deletions
feat(api): a guest's own bookings on both doors
GET /api/v1/bookings/mine (LNbits account auth; identity = the account's
Nostr pubkey, the same value the booking request carried) and RPC twin
chatelet_booking_list_mine (scoped by the signed sender_pubkey). Rows come
back newest check-in first via the m003 guest index, as guest_booking_dict:
the guest's own contact and counts, minus the Lightning/Nostr plumbing.
Declared ahead of /bookings/{booking_id} so 'mine' is not read as an id.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
commit
8b816d83b0
|
|
@ -67,6 +67,7 @@ def chatelet_start():
|
||||||
handle_availability,
|
handle_availability,
|
||||||
handle_block_create,
|
handle_block_create,
|
||||||
handle_booking_get,
|
handle_booking_get,
|
||||||
|
handle_booking_list_mine,
|
||||||
handle_booking_request,
|
handle_booking_request,
|
||||||
handle_operator_get,
|
handle_operator_get,
|
||||||
handle_operator_update,
|
handle_operator_update,
|
||||||
|
|
@ -95,6 +96,7 @@ def chatelet_start():
|
||||||
register_rpc("chatelet_availability", handle_availability, AUTH_NONE)
|
register_rpc("chatelet_availability", handle_availability, AUTH_NONE)
|
||||||
register_rpc("chatelet_booking_request", handle_booking_request, AUTH_NONE)
|
register_rpc("chatelet_booking_request", handle_booking_request, AUTH_NONE)
|
||||||
register_rpc("chatelet_booking_get", handle_booking_get, AUTH_NONE)
|
register_rpc("chatelet_booking_get", handle_booking_get, AUTH_NONE)
|
||||||
|
register_rpc("chatelet_booking_list_mine", handle_booking_list_mine, AUTH_NONE)
|
||||||
|
|
||||||
# tasks.py stamps extra["booking_id"] on settlement (see on_invoice_paid),
|
# tasks.py stamps extra["booking_id"] on settlement (see on_invoice_paid),
|
||||||
# so override the default link_extra_key ("link") to match.
|
# so override the default link_extra_key ("link") to match.
|
||||||
|
|
|
||||||
12
crud.py
12
crud.py
|
|
@ -139,6 +139,18 @@ async def get_booking_by_payment_hash(payment_hash: str) -> Booking | None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_bookings_for_guest(guest_pubkey: str, limit: int = 200) -> list[Booking]:
|
||||||
|
"""A guest's own stays, newest check-in first (idx_bookings_guest_pubkey)."""
|
||||||
|
return await db.fetchall(
|
||||||
|
"""
|
||||||
|
SELECT * FROM chatelet.bookings WHERE guest_pubkey = :pk
|
||||||
|
ORDER BY check_in DESC LIMIT :limit
|
||||||
|
""",
|
||||||
|
{"pk": guest_pubkey, "limit": limit},
|
||||||
|
Booking,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_bookings_for_room(room_id: str) -> list[Booking]:
|
async def get_bookings_for_room(room_id: str) -> list[Booking]:
|
||||||
return await db.fetchall(
|
return await db.fetchall(
|
||||||
"SELECT * FROM chatelet.bookings WHERE room_id = :rid",
|
"SELECT * FROM chatelet.bookings WHERE room_id = :rid",
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ flow runs over relays with no HTTP:
|
||||||
| `chatelet_availability` | none | is a range free + a quote |
|
| `chatelet_availability` | none | is a range free + a quote |
|
||||||
| `chatelet_booking_request` | none | guest requests a stay (guest id = signed `sender_pubkey`) |
|
| `chatelet_booking_request` | none | guest requests a stay (guest id = signed `sender_pubkey`) |
|
||||||
| `chatelet_booking_get` | none | guest reads back their booking (ownership by `sender_pubkey`) |
|
| `chatelet_booking_get` | none | guest reads back their booking (ownership by `sender_pubkey`) |
|
||||||
|
| `chatelet_booking_list_mine` | none | the caller's own bookings (`sender_pubkey`; HTTP twin `GET /api/v1/bookings/mine` uses the account's pubkey) |
|
||||||
|
|
||||||
Guest identity is the `sender_pubkey` the dispatcher lifts off the signed
|
Guest identity is the `sender_pubkey` the dispatcher lifts off the signed
|
||||||
kind-21000 event — unspoofable, and it means no separate `guest_pubkey` is
|
kind-21000 event — unspoofable, and it means no separate `guest_pubkey` is
|
||||||
|
|
|
||||||
10
models.py
10
models.py
|
|
@ -259,6 +259,16 @@ def public_booking_dict(booking: "Booking") -> dict:
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def guest_booking_dict(booking: "Booking") -> dict:
|
||||||
|
"""A Booking for the guest who owns it (authenticated by pubkey on either
|
||||||
|
door): everything public_booking_dict shows plus their own contact and
|
||||||
|
guest count; the Lightning/Nostr plumbing stays internal."""
|
||||||
|
d = json.loads(booking.json())
|
||||||
|
for k in ("payment_hash", "request_event_id", "reservation_event_id"):
|
||||||
|
d.pop(k, None)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
class AvailabilityQuery(BaseModel):
|
class AvailabilityQuery(BaseModel):
|
||||||
room_id: str
|
room_id: str
|
||||||
check_in: str # YYYY-MM-DD inclusive
|
check_in: str # YYYY-MM-DD inclusive
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ from .models import (
|
||||||
Room,
|
Room,
|
||||||
RoomStatus,
|
RoomStatus,
|
||||||
UnavailableRanges,
|
UnavailableRanges,
|
||||||
|
guest_booking_dict,
|
||||||
public_room_dict,
|
public_room_dict,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -151,6 +152,12 @@ async def public_room_views(rooms: list[Room]) -> list[dict]:
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def list_guest_bookings(guest_pubkey: str) -> list[dict]:
|
||||||
|
"""The caller's own bookings (identity established by the door: LNbits
|
||||||
|
account pubkey over HTTP, signed sender_pubkey over RPC)."""
|
||||||
|
return [guest_booking_dict(b) for b in await crud.get_bookings_for_guest(guest_pubkey)]
|
||||||
|
|
||||||
|
|
||||||
# A guest calendar asks for a year by default; cap the window so a bad client
|
# A guest calendar asks for a year by default; cap the window so a bad client
|
||||||
# can't make us scan and ship an unbounded span.
|
# can't make us scan and ship an unbounded span.
|
||||||
DEFAULT_CALENDAR_DAYS = 365
|
DEFAULT_CALENDAR_DAYS = 365
|
||||||
|
|
|
||||||
89
tests/test_my_bookings.py
Normal file
89
tests/test_my_bookings.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
"""A guest's own bookings, on both doors. HTTP identity is the LNbits account
|
||||||
|
pubkey; RPC identity is the signed sender_pubkey. Neither leaks another
|
||||||
|
guest's rows, and the Lightning/Nostr plumbing stays internal."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from .. import crud, transport_rpcs, views_api
|
||||||
|
from ..models import Booking, BookingStatus, guest_booking_dict
|
||||||
|
|
||||||
|
PK = "ab" * 32
|
||||||
|
|
||||||
|
|
||||||
|
def _booking(i: int, **over) -> Booking:
|
||||||
|
base = dict(
|
||||||
|
id=f"bk{i}",
|
||||||
|
room_id="a",
|
||||||
|
guest_pubkey=PK,
|
||||||
|
guest_contact="me@example.com",
|
||||||
|
check_in=f"2026-10-{10 + i:02d}",
|
||||||
|
check_out=f"2026-10-{12 + i:02d}",
|
||||||
|
nights=2,
|
||||||
|
num_guests=1,
|
||||||
|
currency="EUR",
|
||||||
|
price_fiat=200.0,
|
||||||
|
amount_sat=300000,
|
||||||
|
deposit_sat=300000,
|
||||||
|
status=BookingStatus.confirmed,
|
||||||
|
payment_hash=f"ph{i}",
|
||||||
|
request_event_id="req",
|
||||||
|
reservation_event_id="res",
|
||||||
|
)
|
||||||
|
base.update(over)
|
||||||
|
return Booking(**base) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_store(monkeypatch, rows):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
async def for_guest(pubkey, limit=200):
|
||||||
|
seen["pubkey"] = pubkey
|
||||||
|
return [b for b in rows if b.guest_pubkey == pubkey]
|
||||||
|
|
||||||
|
monkeypatch.setattr(crud, "get_bookings_for_guest", for_guest)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def test_guest_dict_keeps_own_contact_but_hides_plumbing():
|
||||||
|
d = guest_booking_dict(_booking(1))
|
||||||
|
assert d["guest_contact"] == "me@example.com"
|
||||||
|
assert d["guest_pubkey"] == PK
|
||||||
|
for hidden in ("payment_hash", "request_event_id", "reservation_event_id"):
|
||||||
|
assert hidden not in d
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_lists_only_the_callers_rows(monkeypatch):
|
||||||
|
rows = [_booking(1), _booking(2, guest_pubkey="cd" * 32)]
|
||||||
|
seen = _patch_store(monkeypatch, rows)
|
||||||
|
user = SimpleNamespace(id="u1", pubkey=PK)
|
||||||
|
out = asyncio.run(views_api.api_my_bookings(user=user))
|
||||||
|
assert seen["pubkey"] == PK
|
||||||
|
assert [b["id"] for b in out] == ["bk1"]
|
||||||
|
assert "payment_hash" not in out[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_rejects_account_without_pubkey(monkeypatch):
|
||||||
|
_patch_store(monkeypatch, [])
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
asyncio.run(views_api.api_my_bookings(user=SimpleNamespace(id="u1", pubkey=None)))
|
||||||
|
assert e.value.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_rpc_scopes_by_sender_and_requires_it(monkeypatch):
|
||||||
|
rows = [_booking(1)]
|
||||||
|
_patch_store(monkeypatch, rows)
|
||||||
|
req = transport_rpcs.NostrRpcRequest(
|
||||||
|
rpc_name="chatelet_booking_list_mine", request_id="r", body={}, sender_pubkey=PK
|
||||||
|
)
|
||||||
|
out = asyncio.run(transport_rpcs.handle_booking_list_mine(None, req))
|
||||||
|
assert [b["id"] for b in out] == ["bk1"]
|
||||||
|
|
||||||
|
anon = transport_rpcs.NostrRpcRequest(
|
||||||
|
rpc_name="chatelet_booking_list_mine", request_id="r", body={}
|
||||||
|
)
|
||||||
|
with pytest.raises(PermissionError):
|
||||||
|
asyncio.run(transport_rpcs.handle_booking_list_mine(None, anon))
|
||||||
|
|
@ -177,6 +177,13 @@ async def handle_booking_request(auth: None, request: NostrRpcRequest) -> dict:
|
||||||
return _to_dict(quote)
|
return _to_dict(quote)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_booking_list_mine(auth: None, request: NostrRpcRequest) -> list[dict]:
|
||||||
|
"""The caller's own bookings, scoped by the signed sender_pubkey."""
|
||||||
|
if not request.sender_pubkey:
|
||||||
|
raise PermissionError("chatelet: caller identity required")
|
||||||
|
return await services.list_guest_bookings(request.sender_pubkey)
|
||||||
|
|
||||||
|
|
||||||
async def handle_booking_get(auth: None, request: NostrRpcRequest) -> dict:
|
async def handle_booking_get(auth: None, request: NostrRpcRequest) -> dict:
|
||||||
booking = await crud.get_booking(_require_id(request))
|
booking = await crud.get_booking(_require_id(request))
|
||||||
if not booking:
|
if not booking:
|
||||||
|
|
|
||||||
14
views_api.py
14
views_api.py
|
|
@ -8,8 +8,8 @@ the guest-facing surface (availability, booking) is what also rides the RPC.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from lnbits.core.models import WalletTypeInfo
|
from lnbits.core.models import User, WalletTypeInfo
|
||||||
from lnbits.decorators import require_admin_key, require_invoice_key
|
from lnbits.decorators import check_user_exists, require_admin_key, require_invoice_key
|
||||||
|
|
||||||
from . import crud, services
|
from . import crud, services
|
||||||
from .models import (
|
from .models import (
|
||||||
|
|
@ -286,6 +286,16 @@ async def api_request_booking(data: BookingRequestData) -> BookingQuote:
|
||||||
raise _to_http(exc) from exc
|
raise _to_http(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@chatelet_api_router.get("/api/v1/bookings/mine")
|
||||||
|
async def api_my_bookings(user: User = Depends(check_user_exists)) -> list[dict]:
|
||||||
|
"""The signed-in guest's own stays. Identity is the LNbits account's Nostr
|
||||||
|
pubkey — the same value the booking request carried as guest_pubkey.
|
||||||
|
Declared before /bookings/{booking_id} so "mine" is not read as an id."""
|
||||||
|
if not user.pubkey:
|
||||||
|
raise HTTPException(409, "This account has no Nostr pubkey")
|
||||||
|
return await services.list_guest_bookings(user.pubkey)
|
||||||
|
|
||||||
|
|
||||||
@chatelet_api_router.get("/api/v1/bookings/{booking_id}")
|
@chatelet_api_router.get("/api/v1/bookings/{booking_id}")
|
||||||
async def api_get_booking(
|
async def api_get_booking(
|
||||||
booking_id: str, key: WalletTypeInfo = Depends(require_invoice_key)
|
booking_id: str, key: WalletTypeInfo = Depends(require_invoice_key)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue