diff --git a/__init__.py b/__init__.py index b2e1139..61fe782 100644 --- a/__init__.py +++ b/__init__.py @@ -68,6 +68,8 @@ def chatelet_start(): handle_block_create, handle_booking_get, handle_booking_request, + handle_operator_get, + handle_operator_update, handle_room_create, handle_room_get, handle_room_list, @@ -84,6 +86,8 @@ def chatelet_start(): register_rpc("chatelet_room_publish", handle_room_publish, AUTH_WALLET) register_rpc("chatelet_block_create", handle_block_create, AUTH_WALLET) register_rpc("chatelet_room_list_mine", handle_room_list_mine, AUTH_ACCOUNT) + register_rpc("chatelet_operator_get", handle_operator_get, AUTH_WALLET) + register_rpc("chatelet_operator_update", handle_operator_update, AUTH_WALLET) # public (discovery + guest booking) register_rpc("chatelet_room_list", handle_room_list, AUTH_NONE) register_rpc("chatelet_room_get", handle_room_get, AUTH_NONE) diff --git a/crud.py b/crud.py index 765d703..46e7ebf 100644 --- a/crud.py +++ b/crud.py @@ -19,6 +19,7 @@ from .models import ( ChateletSettings, CreateBlockData, CreateRoomData, + OperatorSettings, Room, RoomStatus, ) @@ -48,6 +49,30 @@ async def update_settings(settings: ChateletSettings) -> ChateletSettings: return settings +# --------------------------------------------------------------------------- +# Operator settings (per LNbits user — multi-tenant) +# --------------------------------------------------------------------------- + + +async def get_or_create_operator_settings(user_id: str) -> OperatorSettings: + row = await db.fetchone( + "SELECT * FROM chatelet.operator_settings WHERE user_id = :uid", + {"uid": user_id}, + OperatorSettings, + ) + if row: + return row + ops = OperatorSettings(user_id=user_id) + await db.insert("chatelet.operator_settings", ops) + return ops + + +async def update_operator_settings(ops: OperatorSettings) -> OperatorSettings: + ops.updated_at = datetime.now(timezone.utc) + await db.update("chatelet.operator_settings", ops, "WHERE user_id = :user_id") + return ops + + # --------------------------------------------------------------------------- # Rooms # --------------------------------------------------------------------------- diff --git a/docs/data-model.md b/docs/data-model.md index b49b732..e842fc5 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -53,6 +53,19 @@ replaces the same addressable event. Holds price (`amount`/`currency`/ published reservation object. - **`expires_at`** — hold expiry (set while `held`/`awaiting_payment`). +### `operator_settings` — per LNbits user (multi-tenant, m003) + +Every LNbits user may host rooms; what they decide for *all their rooms* lives +here, keyed by user id and created on first read: + +| Field | Meaning | +|---|---| +| `accept_fiat` | operator wants card payments. Only *offered* when LNbits core also has a fiat provider for this user (`settings.get_fiat_providers_for_user`) and the room is fiat-priced — chatelet never stores provider credentials (lnbits#67 plugs per-user Stripe creds into that same call) | +| `checkin_time`, `checkout_time`, `cancellation_policy` | house rules — shown to guests (`house_rules` on the public room view, `checkin_time`/`checkout_time` tags on the kind:30402 listing) and put in the check-in DM | + +Rooms resolve their operator via `get_wallet(room.wallet).user`. The old +house-rule columns on `settings` are kept for old rows but no longer read. + ### `blocks` — manual owner unavailability Maintenance, personal use, off-season. Half-open `[start_date, end_date)`. diff --git a/docs/event-flow.md b/docs/event-flow.md index 8524ab4..124472f 100644 --- a/docs/event-flow.md +++ b/docs/event-flow.md @@ -25,7 +25,8 @@ flow runs over relays with no HTTP: | `chatelet_room_create` / `_update` / `_publish` | wallet | operator room CRUD (ownership-checked) | | `chatelet_block_create` | wallet | operator blocks a range | | `chatelet_room_list_mine` | account | operator's rooms across their wallets | -| `chatelet_room_list` / `_get` | none | public discovery (active rooms, wallet id stripped) | +| `chatelet_operator_get` / `_update` | wallet | the caller's per-operator settings (house rules, card acceptance) | +| `chatelet_room_list` / `_get` | none | public discovery (active rooms, wallet id stripped, owner's `house_rules` + `payment_methods` attached) | | `chatelet_room_unavailable` | none | merged occupied/blocked spans over a window — the guest calendar feed (HTTP twin: `GET /api/v1/public/rooms/{id}/unavailable`) | | `chatelet_availability` | none | is a range free + a quote | | `chatelet_booking_request` | none | guest requests a stay (guest id = signed `sender_pubkey`) | diff --git a/migrations.py b/migrations.py index 4a6cb37..26fbc9a 100644 --- a/migrations.py +++ b/migrations.py @@ -123,3 +123,27 @@ async def m002_room_checkin_instructions(db): "ALTER TABLE chatelet.rooms ADD COLUMN checkin_instructions TEXT " "NOT NULL DEFAULT '';" ) + + +async def m003_operator_settings_and_guest_index(db): + """Chatelet is multi-tenant: every LNbits user may host rooms. What an + operator decides for *all their rooms* — house rules and whether they + take card payments — lives here, keyed by LNbits user id, created lazily. + The single `chatelet.settings` row keeps only instance-wide knobs; its + old house-rule columns stay in place but are no longer read. + + Also indexes bookings by guest so a guest can list their own stays.""" + await db.execute(f""" + CREATE TABLE chatelet.operator_settings ( + user_id TEXT PRIMARY KEY, + accept_fiat BOOLEAN NOT NULL DEFAULT false, + checkin_time TEXT NOT NULL DEFAULT '15:00', + checkout_time TEXT NOT NULL DEFAULT '11:00', + cancellation_policy TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """) + await db.execute( + "CREATE INDEX chatelet.idx_bookings_guest_pubkey ON bookings (guest_pubkey);" + ) diff --git a/models.py b/models.py index 346b4e0..d243cca 100644 --- a/models.py +++ b/models.py @@ -65,6 +65,28 @@ OCCUPYING_STATUSES = { # --------------------------------------------------------------------------- +# Per-operator (LNbits user) choices that apply to all of that user's rooms. +HOUSE_RULE_FIELDS = ("checkin_time", "checkout_time", "cancellation_policy") + + +class UpdateOperatorSettings(BaseModel): + accept_fiat: bool = False + checkin_time: str = "15:00" + checkout_time: str = "11:00" + cancellation_policy: str = "" # shown to guests + in the check-in DM + + +class OperatorSettings(UpdateOperatorSettings): + """One row per operator user, created on first read. `accept_fiat` is the + operator's *wish*; whether card is actually offered also depends on LNbits + core having a fiat provider for that user (services.payment_methods_for_room) + — chatelet never holds provider credentials.""" + + user_id: str + created_at: datetime = Field(default_factory=_now) + updated_at: datetime = Field(default_factory=_now) + + class ChateletSettings(BaseModel): # LNbits account whose Nostr signer publishes listings/receipts on the # castle's behalf. Resolved via lnbits.core.signers.resolve_signer so @@ -74,9 +96,11 @@ class ChateletSettings(BaseModel): relays: list[str] = Field(default_factory=list) # where we publish/subscribe default_hold_minutes: int = 30 # how long a `held` booking survives unpaid deposit_percent: int = 100 # 100 = full prepay; <100 = deposit + balance + # Legacy (pre-m003): house rules are per operator now — see OperatorSettings. + # Columns kept so old rows load; nothing reads them. checkin_time: str = "15:00" checkout_time: str = "11:00" - cancellation_policy: str = "" # free-form markdown, surfaced in listings/DMs + cancellation_policy: str = "" publish_availability: bool = True # mirror blocked dates to a public NIP-52 calendar created_at: datetime = Field(default_factory=_now) updated_at: datetime = Field(default_factory=_now) @@ -195,14 +219,24 @@ class Block(BaseModel): # --------------------------------------------------------------------------- -def public_room_dict(room: Room) -> dict: +def public_room_dict( + room: Room, + *, + house_rules: dict | None = None, + payment_methods: list[str] | None = None, +) -> dict: """A Room as public JSON for guests — strips operator-private fields: the wallet id, and the check-in instructions (address/gate code, delivered only in the encrypted post-payment DM). Shared by the HTTP and Nostr-RPC guest - doors so neither can leak them.""" + doors so neither can leak them. `house_rules` / `payment_methods` come from + the owner's OperatorSettings (services.public_room_view resolves them).""" d = json.loads(room.json()) d.pop("wallet", None) d.pop("checkin_instructions", None) + if house_rules is not None: + d["house_rules"] = house_rules + if payment_methods is not None: + d["payment_methods"] = payment_methods return d diff --git a/nostr/events.py b/nostr/events.py index b0c9d8e..b558f0a 100644 --- a/nostr/events.py +++ b/nostr/events.py @@ -18,15 +18,30 @@ from .kinds import ( ) -def build_listing_event(room: Room) -> dict: +def build_listing_event( + room: Room, + *, + payment_methods: list[str] | None = None, + house_rules: dict | None = None, +) -> dict: """NIP-99 kind:30402 classified listing for a room. Public, signed by - the operator's identity. `d` == room.id so re-publishing replaces.""" + the operator's identity. `d` == room.id so re-publishing replaces. + + `payment_methods` (comma-joined, like events' tickets_payment_methods) and + the check-in/out times ride as tags so a generic Nostr client can render + the right pay buttons and house rules without speaking our RPC.""" tags = [ ["d", room.id], ["title", room.title], ["price", str(room.price_amount), room.price_currency, room.price_frequency], ["status", "active"], ] + if payment_methods: + tags.append(["payment_methods", ",".join(payment_methods)]) + if house_rules: + for key in ("checkin_time", "checkout_time"): + if house_rules.get(key): + tags.append([key, str(house_rules[key])]) if room.location: tags.append(["location", room.location]) if room.geohash: diff --git a/nostr/service.py b/nostr/service.py index fc17c75..c1c1a51 100644 --- a/nostr/service.py +++ b/nostr/service.py @@ -160,8 +160,17 @@ def _checkin_message(booking, room, settings) -> str: async def publish_listing(room: Room) -> str | None: - """Publish/refresh a room's NIP-99 kind:30402 listing (public).""" - return await _sign_and_publish(events.build_listing_event(room)) + """Publish/refresh a room's NIP-99 kind:30402 listing (public), carrying + the owner's rails + house rules so the event matches the API view.""" + owner = await services.room_owner_id(room) + ops = await crud.get_or_create_operator_settings(owner) + return await _sign_and_publish( + events.build_listing_event( + room, + payment_methods=services.payment_methods_for_room(room, owner, ops), + house_rules=services.house_rules_dict(ops), + ) + ) async def publish_reservation(booking: Booking) -> str | None: diff --git a/services.py b/services.py index ea6e4f9..0502a7f 100644 --- a/services.py +++ b/services.py @@ -16,21 +16,27 @@ import asyncio from collections import defaultdict from datetime import date, datetime, timedelta, timezone +from lnbits.core.crud.wallets import get_wallet from lnbits.core.services import create_invoice from lnbits.exceptions import InvoiceError from lnbits.helpers import urlsafe_short_hash +from lnbits.settings import settings as lnbits_settings from lnbits.utils.exchange_rates import fiat_amount_as_satoshis from . import crud from .models import ( + HOUSE_RULE_FIELDS, AvailabilityResult, Booking, BookingQuote, BookingRequestData, BookingStatus, DateRange, + OperatorSettings, + Room, RoomStatus, UnavailableRanges, + public_room_dict, ) # Per-room lock serializing the availability read + the `held` write, so two @@ -61,6 +67,90 @@ async def to_sats(amount: float, currency: str) -> int: return await fiat_amount_as_satoshis(amount, currency) +# --------------------------------------------------------------------------- +# Operators + the public room view +# --------------------------------------------------------------------------- + +LIGHTNING = "lightning" +FIAT = "fiat" + + +def is_fiat_currency(currency: str) -> bool: + return currency.lower() not in ("sat", "sats") + + +def fiat_providers_for_user(user_id: str) -> list[str]: + """Fiat providers LNbits core will let this user charge with. The one + place chatelet consults core about card payments (lnbits#67's per-user + Stripe creds land behind this call); module-level so tests can patch it — + the pydantic settings object refuses monkeypatched methods.""" + return lnbits_settings.get_fiat_providers_for_user(user_id) + + +async def room_owner_id(room: Room) -> str: + """The LNbits user who operates a room (rooms belong to wallets).""" + wallet = await get_wallet(room.wallet) + if not wallet: + raise NotFound("Room's wallet not found") + return wallet.user + + +def payment_methods_for_room( + room: Room, owner_id: str, ops: OperatorSettings +) -> list[str]: + """Rails a guest may pay this room with. Card needs three things: the + operator opted in, LNbits core has a fiat provider for *that user* (the + single seam lnbits#67's per-user Stripe creds will plug into — chatelet + never sees credentials), and a fiat-denominated price (core cannot bill + a sat amount through a fiat provider).""" + rails = [LIGHTNING] + if ( + ops.accept_fiat + and is_fiat_currency(room.price_currency) + and fiat_providers_for_user(owner_id) + ): + rails.append(FIAT) + return rails + + +def house_rules_dict(ops: OperatorSettings) -> dict: + return {k: getattr(ops, k) for k in HOUSE_RULE_FIELDS} + + +async def public_room_view(room: Room) -> dict: + """public_room_dict + the owner's house rules and rails. Used by both + guest doors so a room looks the same over HTTP and RPC.""" + owner = await room_owner_id(room) + ops = await crud.get_or_create_operator_settings(owner) + return public_room_dict( + room, + house_rules=house_rules_dict(ops), + payment_methods=payment_methods_for_room(room, owner, ops), + ) + + +async def public_room_views(rooms: list[Room]) -> list[dict]: + """Batch form: one owner/settings lookup per distinct wallet.""" + owners: dict[str, str] = {} + ops_by_owner: dict[str, OperatorSettings] = {} + out = [] + for room in rooms: + if room.wallet not in owners: + owners[room.wallet] = await room_owner_id(room) + owner = owners[room.wallet] + if owner not in ops_by_owner: + ops_by_owner[owner] = await crud.get_or_create_operator_settings(owner) + ops = ops_by_owner[owner] + out.append( + public_room_dict( + room, + house_rules=house_rules_dict(ops), + payment_methods=payment_methods_for_room(room, owner, ops), + ) + ) + return out + + # 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. DEFAULT_CALENDAR_DAYS = 365 diff --git a/static/js/index.js b/static/js/index.js index e1867de..ac32887 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -29,6 +29,9 @@ window.app = Vue.createApp({ settings: {}, settingsLoading: false, + operator: {}, + operatorLoading: false, + roomsColumns: [ {name: 'title', label: 'Room', field: 'title', align: 'left'}, { @@ -237,11 +240,41 @@ window.app = Vue.createApp({ } catch (err) { this._err(err, 'Could not save settings') } + }, + + // --- per-operator settings (house rules, card acceptance) --- + async getOperator() { + this.operatorLoading = true + try { + const {data} = await LNbits.api.request('GET', `${API}/operator`, this.adminkey) + this.operator = data + } catch (err) { + this._err(err, 'Could not load your settings') + } finally { + this.operatorLoading = false + } + }, + async saveOperator() { + this.operatorLoading = true + try { + const {accept_fiat, checkin_time, checkout_time, cancellation_policy} = this.operator + const {data} = await LNbits.api.request( + 'PUT', `${API}/operator`, this.adminkey, + {accept_fiat, checkin_time, checkout_time, cancellation_policy} + ) + this.operator = data + Quasar.Notify.create({type: 'positive', message: 'Your settings saved'}) + } catch (err) { + this._err(err, 'Could not save your settings') + } finally { + this.operatorLoading = false + } } }, created() { this.getRooms() this.getSettings() + this.getOperator() } }) diff --git a/tasks.py b/tasks.py index 3532299..3529182 100644 --- a/tasks.py +++ b/tasks.py @@ -12,7 +12,7 @@ from lnbits.core.models import Payment from lnbits.tasks import register_invoice_listener from loguru import logger -from . import crud +from . import crud, services from .models import BookingStatus from .nostr import service as nostr @@ -47,9 +47,11 @@ async def on_invoice_paid(payment: Payment): # Best-effort: a publish failure must not undo a confirmed, paid booking. try: room = await crud.get_room(booking.room_id) - settings = await crud.get_or_create_settings() if room: - await nostr.send_checkin_dm(booking, room, settings) + # House rules are the room owner's, not the instance's. + owner = await services.room_owner_id(room) + ops = await crud.get_or_create_operator_settings(owner) + await nostr.send_checkin_dm(booking, room, ops) except Exception as exc: # noqa: BLE001 logger.warning(f"chatelet: check-in DM failed for {booking.id} (continuing): {exc}") diff --git a/templates/chatelet/index.html b/templates/chatelet/index.html index cb4b15f..ede4635 100644 --- a/templates/chatelet/index.html +++ b/templates/chatelet/index.html @@ -171,19 +171,6 @@ -
- -
-
- -
-
- -
@@ -193,6 +180,48 @@ :loading="settingsLoading">
+ + + + +
Your rooms: house rules & payments
+
+ Shown to guests on each of your rooms and sent in the check-in message. +
+
+
+ +
+
+ +
+
+ +
+
+ +
+ + Via {{ (operator.available_fiat_providers || []).join(', ') }}. Guests can pay + a fiat-priced room by card; sat-priced rooms stay Lightning-only. + + + No fiat payment provider is enabled for your account — ask the + LNbits admin to enable one (e.g. Stripe) before turning this on. + +
+
+
+ +
+
diff --git a/tests/conftest.py b/tests/conftest.py index 3fe5037..c96c016 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -86,3 +86,23 @@ def make_request( check_out=check_out, num_guests=num_guests, ) + + +def patch_owner(monkeypatch, *, user_id="u1", accept_fiat=False, providers=()): + """Route the public room view away from the core DB: rooms belong to + `user_id`, whose operator settings are fresh defaults (+ accept_fiat) and + who has `providers` enabled in LNbits core.""" + from types import SimpleNamespace + + from .. import crud, services + from ..models import OperatorSettings + + async def get_wallet(_wallet_id): + return SimpleNamespace(user=user_id) + + async def ops(uid): + return OperatorSettings(user_id=uid, accept_fiat=accept_fiat) + + monkeypatch.setattr(services, "get_wallet", get_wallet) + monkeypatch.setattr(crud, "get_or_create_operator_settings", ops) + monkeypatch.setattr(services, "fiat_providers_for_user", lambda _uid: list(providers)) diff --git a/tests/test_operator_settings.py b/tests/test_operator_settings.py new file mode 100644 index 0000000..6df0e25 --- /dev/null +++ b/tests/test_operator_settings.py @@ -0,0 +1,136 @@ +"""Per-operator settings (multi-tenant): what a guest sees on a room and how +the rails are derived. Chatelet never decides *whether* a user may charge +card — it asks LNbits core per owner — only whether the operator wants to.""" + +import asyncio +from types import SimpleNamespace + +from .. import crud, services, transport_rpcs, views_api +from ..models import OperatorSettings, RoomStatus, UpdateOperatorSettings +from ..nostr import events +from .conftest import make_room, patch_owner + + +def _key(wallet_id="w1", user="u1"): + return SimpleNamespace(wallet=SimpleNamespace(id=wallet_id, user=user)) + + +def test_rails_need_flag_provider_and_fiat_price(monkeypatch): + room = make_room(price=100.0, currency="EUR") + on = OperatorSettings(user_id="u1", accept_fiat=True) + off = OperatorSettings(user_id="u1", accept_fiat=False) + lightning_only = ["lightning"] + + monkeypatch.setattr(services, "fiat_providers_for_user", lambda _u: ["stripe"]) + both = ["lightning", "fiat"] + assert services.payment_methods_for_room(room, "u1", on) == both + assert services.payment_methods_for_room(room, "u1", off) == lightning_only + sat_room = make_room(price=1000.0, currency="sat") + assert services.payment_methods_for_room(sat_room, "u1", on) == lightning_only + monkeypatch.setattr(services, "fiat_providers_for_user", lambda _u: []) + assert services.payment_methods_for_room(room, "u1", on) == lightning_only + + +def test_public_room_view_attaches_rules_and_rails(monkeypatch): + patch_owner(monkeypatch, accept_fiat=True, providers=["stripe"]) + room = make_room("a", status=RoomStatus.active, currency="EUR") + out = asyncio.run(services.public_room_view(room)) + assert out["payment_methods"] == ["lightning", "fiat"] + assert out["house_rules"] == { + "checkin_time": "15:00", + "checkout_time": "11:00", + "cancellation_policy": "", + } + assert "wallet" not in out and "checkin_instructions" not in out + + +def test_batch_view_looks_owner_up_once_per_wallet(monkeypatch): + calls: list[str] = [] + + async def get_wallet(wallet_id): + calls.append(wallet_id) + return SimpleNamespace(user="u1") + + async def ops(uid): + return OperatorSettings(user_id=uid) + + monkeypatch.setattr(services, "get_wallet", get_wallet) + monkeypatch.setattr(crud, "get_or_create_operator_settings", ops) + monkeypatch.setattr(services, "fiat_providers_for_user", lambda _u: []) + rooms = [ + make_room("a", wallet="w1"), + make_room("b", wallet="w1"), + make_room("c", wallet="w2"), + ] + out = asyncio.run(services.public_room_views(rooms)) + assert [r["id"] for r in out] == ["a", "b", "c"] + assert sorted(calls) == ["w1", "w2"] + + +def test_listing_event_carries_rails_and_times(): + room = make_room("a") + ev = events.build_listing_event( + room, + payment_methods=["lightning", "fiat"], + house_rules={ + "checkin_time": "16:00", + "checkout_time": "10:00", + "cancellation_policy": "x", + }, + ) + wanted = ("payment_methods", "checkin_time", "checkout_time") + tags = {t[0]: t[1:] for t in ev["tags"] if t[0] in wanted} + assert tags == { + "payment_methods": ["lightning,fiat"], + "checkin_time": ["16:00"], + "checkout_time": ["10:00"], + } + # long-form policy text stays off the listing tags + assert not any(t[0] == "cancellation_policy" for t in ev["tags"]) + + +def test_operator_endpoints_scope_to_calling_user(monkeypatch): + store: dict[str, OperatorSettings] = {} + + async def get_or_create(uid): + return store.setdefault(uid, OperatorSettings(user_id=uid)) + + async def update(ops): + store[ops.user_id] = ops + return ops + + async def no_rooms(): + return [] + + monkeypatch.setattr(crud, "get_or_create_operator_settings", get_or_create) + monkeypatch.setattr(crud, "update_operator_settings", update) + monkeypatch.setattr(crud, "get_rooms", no_rooms) + monkeypatch.setattr( + services, "fiat_providers_for_user", lambda u: ["stripe"] if u == "u1" else [] + ) + + first = asyncio.run(views_api.api_get_operator_settings(key=_key(user="u1"))) + assert first["user_id"] == "u1" and first["accept_fiat"] is False + assert first["available_fiat_providers"] == ["stripe"] + + updated = asyncio.run( + views_api.api_update_operator_settings( + UpdateOperatorSettings(accept_fiat=True, checkin_time="16:00"), + key=_key(user="u1"), + ) + ) + assert updated["accept_fiat"] is True and updated["checkin_time"] == "16:00" + + other = asyncio.run(views_api.api_get_operator_settings(key=_key(user="u2"))) + assert other["accept_fiat"] is False and other["available_fiat_providers"] == [] + + # RPC twin reads the same row for the same wallet user + rpc = asyncio.run( + transport_rpcs.handle_operator_get( + _key(user="u1"), + transport_rpcs.NostrRpcRequest( + rpc_name="chatelet_operator_get", request_id="r", body={} + ), + ) + ) + assert rpc["accept_fiat"] is True and rpc["checkin_time"] == "16:00" diff --git a/tests/test_public_endpoints.py b/tests/test_public_endpoints.py index 8150b9a..752e7a1 100644 --- a/tests/test_public_endpoints.py +++ b/tests/test_public_endpoints.py @@ -2,6 +2,7 @@ (privacy: check-in instructions must never reach a guest).""" import asyncio +from typing import Any import pytest from fastapi import HTTPException @@ -14,7 +15,7 @@ from ..models import ( public_booking_dict, public_room_dict, ) -from .conftest import make_room +from .conftest import make_room, patch_owner def test_public_room_dict_strips_private_fields(): @@ -36,10 +37,13 @@ def test_public_rooms_lists_active_only_and_stripped(monkeypatch): return [active, inactive] monkeypatch.setattr(crud, "get_rooms", gr) + patch_owner(monkeypatch) out = asyncio.run(views_api.api_public_rooms()) assert [r["id"] for r in out] == ["a"] # inactive hidden from guests assert "checkin_instructions" not in out[0] assert "wallet" not in out[0] + assert out[0]["house_rules"]["checkin_time"] == "15:00" + assert out[0]["payment_methods"] == ["lightning"] def test_public_room_404_when_inactive(monkeypatch): @@ -60,14 +64,15 @@ def test_public_room_returns_stripped_when_active(monkeypatch): return room monkeypatch.setattr(crud, "get_room", gr) + patch_owner(monkeypatch) out = asyncio.run(views_api.api_public_room("a")) assert out["id"] == "a" assert "checkin_instructions" not in out assert "wallet" not in out -def _booking(**overrides) -> Booking: - base = { +def _booking(**overrides: Any) -> Booking: + base: dict[str, Any] = { "id": "bk_1234567", "room_id": "a", "guest_pubkey": "ab" * 32, diff --git a/transport_rpcs.py b/transport_rpcs.py index 71ee687..44785da 100644 --- a/transport_rpcs.py +++ b/transport_rpcs.py @@ -30,7 +30,13 @@ from lnbits.core.models.wallets import WalletTypeInfo from lnbits.core.services.nostr_transport.models import NostrRpcRequest from . import crud, services -from .models import BookingRequestData, CreateBlockData, CreateRoomData, RoomStatus +from .models import ( + BookingRequestData, + CreateBlockData, + CreateRoomData, + RoomStatus, + UpdateOperatorSettings, +) # Fields a client may patch on a room via chatelet_room_update. Identity / # counter fields (id, wallet, listing_event_id, created_at) are not mutable; @@ -99,20 +105,36 @@ async def handle_block_create(auth: WalletTypeInfo, request: NostrRpcRequest) -> return _to_dict(block) +async def handle_operator_get(auth: WalletTypeInfo, request: NostrRpcRequest) -> dict: + ops = await crud.get_or_create_operator_settings(auth.wallet.user) + return _to_dict(ops) + + +async def handle_operator_update( + auth: WalletTypeInfo, request: NostrRpcRequest +) -> dict: + ops = await crud.get_or_create_operator_settings(auth.wallet.user) + for k, v in (request.body or {}).items(): + if k in UpdateOperatorSettings.__fields__: + setattr(ops, k, v) + return _to_dict(await crud.update_operator_settings(ops)) + + # --- public: discovery + booking (AUTH_NONE) ------------------------------- async def handle_room_list(auth: None, request: NostrRpcRequest) -> list[dict]: - """Active rooms only, wallet id stripped (public discovery).""" - rooms = await crud.get_rooms() - return [_public_room(r) for r in rooms if r.status == RoomStatus.active] + """Active rooms only, wallet id stripped, owner's house rules + rails + attached (public discovery) — same view as the HTTP door.""" + rooms = [r for r in await crud.get_rooms() if r.status == RoomStatus.active] + return await services.public_room_views(rooms) async def handle_room_get(auth: None, request: NostrRpcRequest) -> dict: room = await crud.get_room(_require_id(request)) if not room or room.status != RoomStatus.active: raise ValueError("Room not available") - return _public_room(room) + return await services.public_room_view(room) async def handle_room_unavailable(auth: None, request: NostrRpcRequest) -> dict: @@ -204,11 +226,3 @@ async def _require_owned_room(room_id: str, wallet_id: str): def _to_dict(obj) -> dict: return json.loads(obj.json()) - -def _public_room(room) -> dict: - # Shared with the HTTP door; strips wallet id AND checkin_instructions - # (the latter was leaking to guests before — added after this file's - # original public dict). - from .models import public_room_dict - - return public_room_dict(room) diff --git a/views_api.py b/views_api.py index 2e306fd..2f7ee3e 100644 --- a/views_api.py +++ b/views_api.py @@ -22,11 +22,12 @@ from .models import ( ChateletSettings, CreateBlockData, CreateRoomData, + OperatorSettings, Room, RoomStatus, UnavailableRanges, + UpdateOperatorSettings, public_booking_dict, - public_room_dict, ) from .nostr import service as nostr @@ -192,17 +193,51 @@ async def api_update_settings( return await crud.update_settings(settings) +# --- operator settings (per LNbits user; admin key → wallet → user) ---------- + + +def _with_providers(ops: OperatorSettings) -> dict: + """The row plus what core would actually let this user charge with, so + the admin UI can explain a card toggle that has no provider behind it.""" + d = ops.dict() + d["available_fiat_providers"] = services.fiat_providers_for_user(ops.user_id) + return d + + +@chatelet_api_router.get("/api/v1/operator") +async def api_get_operator_settings( + key: WalletTypeInfo = Depends(require_admin_key), +) -> dict: + ops = await crud.get_or_create_operator_settings(key.wallet.user) + return _with_providers(ops) + + +@chatelet_api_router.put("/api/v1/operator") +async def api_update_operator_settings( + data: UpdateOperatorSettings, key: WalletTypeInfo = Depends(require_admin_key) +) -> dict: + ops = await crud.get_or_create_operator_settings(key.wallet.user) + for field in UpdateOperatorSettings.__fields__: + setattr(ops, field, getattr(data, field)) + ops = await crud.update_operator_settings(ops) + # Rails/house rules ride on the public listing — refresh the owner's rooms. + for room in await crud.get_rooms(): + if room.status == RoomStatus.active: + owner = await services.room_owner_id(room) + if owner == ops.user_id: + await nostr.publish_listing(room) + return _with_providers(ops) + + # --- public guest discovery (no auth) -------------------------------------- @chatelet_api_router.get("/api/v1/public/rooms") async def api_public_rooms() -> list[dict]: - """Active rooms for guest browsing — operator-private fields stripped.""" - return [ - public_room_dict(r) - for r in await crud.get_rooms() - if r.status == RoomStatus.active - ] + """Active rooms for guests — wallet + check-in instructions stripped, + owner's house rules + accepted rails attached.""" + rooms = [r for r in await crud.get_rooms() if r.status == RoomStatus.active] + return await services.public_room_views(rooms) @chatelet_api_router.get("/api/v1/public/rooms/{room_id}") @@ -210,7 +245,7 @@ async def api_public_room(room_id: str) -> dict: room = await crud.get_room(room_id) if not room or room.status != RoomStatus.active: raise HTTPException(404, "Room not available") - return public_room_dict(room) + return await services.public_room_view(room) @chatelet_api_router.get("/api/v1/public/rooms/{room_id}/unavailable")