Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.

9 changed files with 47 additions and 886 deletions

View file

@ -1,6 +1,6 @@
{ {
"id": "chatelet", "id": "chatelet",
"version": "0.3.0", "version": "0.1.0",
"name": "Chatelet", "name": "Chatelet",
"repo": "https://git.atitlan.io/aiolabs/chatelet", "repo": "https://git.atitlan.io/aiolabs/chatelet",
"short_description": "Nostr-native room rentals (Airbnb-style) for LNbits", "short_description": "Nostr-native room rentals (Airbnb-style) for LNbits",

View file

@ -17,7 +17,6 @@ Design notes carried into the field definitions:
arbiter of "is this range open" Nostr events are requests, not locks. arbiter of "is this range open" Nostr events are requests, not locks.
""" """
import json
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import Enum from enum import Enum
@ -195,17 +194,6 @@ class Block(BaseModel):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def public_room_dict(room: Room) -> 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."""
d = json.loads(room.json())
d.pop("wallet", None)
d.pop("checkin_instructions", 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

View file

@ -1,247 +1,15 @@
// Chatelet operator admin — LNbits page shell (Vue 3 + Quasar 2 UMD, no build). // Chatelet operator admin page — placeholder.
// Gotchas honored: no self-closing tags in the template, `${ }` interpolation, // Quasar 2 + Vue 3 as UMD globals (no build step). Remember: no
// `:style` bindings for typography. See workspace CLAUDE.md. // self-closing tags in UMD templates, and use :style bindings (not <style>
// blocks) for per-element typography overrides on LNbits pages.
const API = '/chatelet/api/v1'
window.app = Vue.createApp({ window.app = Vue.createApp({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [window.windowMixin],
delimiters: ['${', '}'],
data() { data() {
return { return {
tab: 'rooms',
rooms: [], rooms: [],
roomsLoading: false,
roomDialog: {show: false, data: {}},
currencyOptions: ['sat', 'EUR', 'USD', 'GBP', 'CHF'],
frequencyOptions: ['night', 'week', 'month'],
bookingsRoomId: null,
bookings: [], bookings: [],
blocksRoomId: null,
blocks: [],
blockForm: {start_date: '', end_date: '', reason: ''},
settings: {},
settingsLoading: false,
roomsColumns: [
{name: 'title', label: 'Room', field: 'title', align: 'left'},
{
name: 'price', label: 'Price', align: 'left',
field: row => `${row.price_amount} ${row.price_currency} / ${row.price_frequency}`
},
{name: 'max_guests', label: 'Max guests', field: 'max_guests', align: 'center'},
{name: 'status', label: 'Status', field: 'status', align: 'center'},
{name: 'actions', label: '', field: 'id', align: 'right'}
],
bookingsColumns: [
{name: 'check_in', label: 'Check-in', field: 'check_in', align: 'left'},
{name: 'check_out', label: 'Check-out', field: 'check_out', align: 'left'},
{name: 'nights', label: 'Nights', field: 'nights', align: 'center'},
{name: 'num_guests', label: 'Guests', field: 'num_guests', align: 'center'},
{name: 'status', label: 'Status', field: 'status', align: 'left'},
{name: 'amount_sat', label: 'Sats', field: 'amount_sat', align: 'right'},
{
name: 'guest', label: 'Guest', align: 'left',
field: row => (row.guest_pubkey || '').slice(0, 12) + '…'
}
],
blocksColumns: [
{name: 'start_date', label: 'From', field: 'start_date', align: 'left'},
{name: 'end_date', label: 'To (exclusive)', field: 'end_date', align: 'left'},
{name: 'reason', label: 'Reason', field: 'reason', align: 'left'},
{name: 'actions', label: '', field: 'id', align: 'right'}
]
} }
}, },
// TODO: wire GET /chatelet/api/v1/rooms, room CRUD, calendar, bookings.
computed: {
adminkey() {
const w = this.g.user.wallets[0]
return w && w.adminkey
},
roomOptions() {
return this.rooms.map(r => ({label: r.title, value: r.id}))
}
},
methods: {
_err(err, fallback) {
const msg = (err && err.response && err.response.data && err.response.data.detail)
|| (err && err.message) || fallback
Quasar.Notify.create({type: 'negative', message: msg, timeout: 5000})
},
statusColor(status) {
return {
active: 'green', inactive: 'grey',
held: 'orange', awaiting_payment: 'amber', confirmed: 'green',
checked_in: 'teal', completed: 'blue-grey',
cancelled: 'red', declined: 'red', expired: 'grey'
}[status] || 'grey'
},
// --- rooms ---
async getRooms() {
this.roomsLoading = true
try {
const {data} = await LNbits.api.request('GET', `${API}/rooms`, this.adminkey)
this.rooms = data
} catch (err) {
this._err(err, 'Could not load rooms')
} finally {
this.roomsLoading = false
}
},
newRoom() {
this.roomDialog = {
show: true,
data: {
price_currency: 'sat', price_frequency: 'night',
max_guests: 2, min_nights: 1, amenities: [], images: []
}
}
},
editRoom(room) {
this.roomDialog = {show: true, data: JSON.parse(JSON.stringify(room))}
},
async saveRoom() {
const d = this.roomDialog.data
const body = {
title: d.title,
description: d.description || '',
price_amount: Number(d.price_amount),
price_currency: d.price_currency,
price_frequency: d.price_frequency || 'night',
max_guests: Number(d.max_guests),
min_nights: Number(d.min_nights),
amenities: d.amenities || [],
location: d.location || '',
geohash: d.geohash || '',
images: d.images || [],
checkin_instructions: d.checkin_instructions || ''
}
try {
if (d.id) {
await LNbits.api.request('PUT', `${API}/rooms/${d.id}`, this.adminkey, body)
} else {
await LNbits.api.request('POST', `${API}/rooms`, this.adminkey, body)
}
this.roomDialog.show = false
Quasar.Notify.create({type: 'positive', message: 'Room saved'})
this.getRooms()
} catch (err) {
this._err(err, 'Could not save room')
}
},
deleteRoom(room) {
LNbits.utils
.confirmDialog(`Delete room "${room.title}"? Its bookings + blocks go too.`)
.onOk(async () => {
try {
await LNbits.api.request('DELETE', `${API}/rooms/${room.id}`, this.adminkey)
this.getRooms()
} catch (err) {
this._err(err, 'Could not delete room')
}
})
},
async setPublished(room, on) {
const path = on ? 'publish' : 'unpublish'
try {
await LNbits.api.request('POST', `${API}/rooms/${room.id}/${path}`, this.adminkey)
Quasar.Notify.create({
type: 'positive',
message: on ? 'Published to relays' : 'Unpublished'
})
this.getRooms()
} catch (err) {
this._err(err, 'Could not change publish state')
}
},
// --- bookings ---
async loadBookings() {
if (!this.bookingsRoomId) return
try {
const {data} = await LNbits.api.request(
'GET', `${API}/rooms/${this.bookingsRoomId}/bookings`, this.adminkey
)
this.bookings = data
} catch (err) {
this._err(err, 'Could not load bookings')
}
},
// --- blocks ---
async loadBlocks() {
if (!this.blocksRoomId) return
try {
const {data} = await LNbits.api.request(
'GET', `${API}/rooms/${this.blocksRoomId}/blocks`, this.adminkey
)
this.blocks = data
} catch (err) {
this._err(err, 'Could not load blocks')
}
},
async addBlock() {
if (!this.blocksRoomId) return
const body = {
room_id: this.blocksRoomId,
start_date: this.blockForm.start_date,
end_date: this.blockForm.end_date,
reason: this.blockForm.reason || ''
}
try {
await LNbits.api.request('POST', `${API}/blocks`, this.adminkey, body)
this.blockForm = {start_date: '', end_date: '', reason: ''}
Quasar.Notify.create({type: 'positive', message: 'Dates blocked'})
this.loadBlocks()
} catch (err) {
this._err(err, 'Could not add block')
}
},
async deleteBlock(block) {
try {
await LNbits.api.request('DELETE', `${API}/blocks/${block.id}`, this.adminkey)
this.loadBlocks()
} catch (err) {
this._err(err, 'Could not delete block')
}
},
// --- settings ---
async getSettings() {
this.settingsLoading = true
try {
const {data} = await LNbits.api.request('GET', `${API}/settings`, this.adminkey)
this.settings = data
} catch (err) {
this._err(err, 'Could not load settings')
} finally {
this.settingsLoading = false
}
},
async saveSettings() {
try {
const {data} = await LNbits.api.request(
'PUT', `${API}/settings`, this.adminkey, this.settings
)
this.settings = data
Quasar.Notify.create({type: 'positive', message: 'Settings saved'})
} catch (err) {
this._err(err, 'Could not save settings')
}
}
},
created() {
this.getRooms()
this.getSettings()
}
}) })

View file

@ -1,257 +1,18 @@
{% extends "base.html" %} {% extends "base.html" %} {% from "macros.jinja" import window_vars with context %}
{% from "macros.jinja" import window_vars with context %}
{% block scripts %}
{{ window_vars(user) }}
<script src="{{ static_url_for('chatelet/static', path='js/index.js') }}"></script>
{% endblock %}
{% block page %} {% block page %}
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12"> <div class="col-12">
<div class="row items-center q-mb-md">
<div class="col">
<h4 class="q-my-none">Chatelet — Room Rentals</h4>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
Operator admin. The guest booking UI lives in the webapp.
</p>
</div>
</div>
<q-card> <q-card>
<q-tabs <q-card-section>
v-model="tab" align="left" <h5 class="text-subtitle1 q-my-none">Chatelet — Room Rentals</h5>
active-color="primary" indicator-color="primary" class="text-grey"> <p class="text-caption">
<q-tab name="rooms" icon="hotel" label="Rooms"></q-tab> Nostr-native room rentals. Operator admin UI goes here (rooms,
<q-tab name="bookings" icon="event_available" label="Bookings"></q-tab> calendar, bookings). Guest booking UI lives in the webapp.
<q-tab name="blocks" icon="event_busy" label="Calendar blocks"></q-tab> </p>
<q-tab name="settings" icon="settings" label="Settings"></q-tab> </q-card-section>
</q-tabs>
<q-separator></q-separator>
<q-tab-panels v-model="tab" animated>
<!-- ROOMS -->
<q-tab-panel name="rooms">
<div class="row items-center q-mb-md">
<div class="col"><div class="text-h6">Rooms</div></div>
<div class="col-auto">
<q-btn color="primary" icon="add" label="Add room" @click="newRoom"></q-btn>
</div>
</div>
<q-table
:rows="rooms" :columns="roomsColumns" row-key="id"
:loading="roomsLoading" flat :pagination="{rowsPerPage: 0}"
no-data-label="No rooms yet — add one to get started">
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-chip :color="statusColor(props.value)" text-color="white" dense square>
${ props.value }
</q-chip>
</q-td>
</template>
<template v-slot:body-cell-actions="props">
<q-td :props="props" class="text-right">
<q-btn
v-if="props.row.status !== 'active'"
flat dense round icon="cloud_upload" color="green"
@click="setPublished(props.row, true)">
<q-tooltip>Publish to relays</q-tooltip>
</q-btn>
<q-btn
v-else flat dense round icon="cloud_off" color="grey"
@click="setPublished(props.row, false)">
<q-tooltip>Unpublish</q-tooltip>
</q-btn>
<q-btn flat dense round icon="edit" color="primary"
@click="editRoom(props.row)">
<q-tooltip>Edit</q-tooltip>
</q-btn>
<q-btn flat dense round icon="delete" color="negative"
@click="deleteRoom(props.row)">
<q-tooltip>Delete</q-tooltip>
</q-btn>
</q-td>
</template>
</q-table>
</q-tab-panel>
<!-- BOOKINGS -->
<q-tab-panel name="bookings">
<div class="row items-center q-mb-md q-gutter-sm">
<q-select
style="min-width: 260px" outlined dense
v-model="bookingsRoomId" :options="roomOptions"
emit-value map-options label="Room"
@update:model-value="loadBookings"></q-select>
<q-btn flat dense icon="refresh" @click="loadBookings"
:disable="!bookingsRoomId">
<q-tooltip>Refresh</q-tooltip>
</q-btn>
</div>
<q-table
:rows="bookings" :columns="bookingsColumns" row-key="id" flat
:pagination="{rowsPerPage: 0}"
no-data-label="Pick a room to see its bookings">
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-chip :color="statusColor(props.value)" text-color="white" dense square>
${ props.value }
</q-chip>
</q-td>
</template>
</q-table>
</q-tab-panel>
<!-- CALENDAR BLOCKS -->
<q-tab-panel name="blocks">
<div class="row items-center q-mb-md q-gutter-sm">
<q-select
style="min-width: 260px" outlined dense
v-model="blocksRoomId" :options="roomOptions"
emit-value map-options label="Room"
@update:model-value="loadBlocks"></q-select>
<q-btn flat dense icon="refresh" @click="loadBlocks"
:disable="!blocksRoomId">
<q-tooltip>Refresh</q-tooltip>
</q-btn>
</div>
<div v-if="blocksRoomId" class="row q-col-gutter-sm q-mb-md items-end">
<div class="col-auto">
<q-input outlined dense type="date" v-model="blockForm.start_date"
label="From"></q-input>
</div>
<div class="col-auto">
<q-input outlined dense type="date" v-model="blockForm.end_date"
label="To (exclusive)"></q-input>
</div>
<div class="col">
<q-input outlined dense v-model="blockForm.reason"
label="Reason (optional)"></q-input>
</div>
<div class="col-auto">
<q-btn color="primary" icon="add" label="Block" @click="addBlock"
:disable="!blockForm.start_date || !blockForm.end_date"></q-btn>
</div>
</div>
<q-table
:rows="blocks" :columns="blocksColumns" row-key="id" flat
:pagination="{rowsPerPage: 0}"
no-data-label="No manual blocks for this room">
<template v-slot:body-cell-actions="props">
<q-td :props="props" class="text-right">
<q-btn flat dense round icon="delete" color="negative"
@click="deleteBlock(props.row)"></q-btn>
</q-td>
</template>
</q-table>
</q-tab-panel>
<!-- SETTINGS -->
<q-tab-panel name="settings">
<div class="text-h6 q-mb-md">Operator settings</div>
<div class="row q-col-gutter-md" style="max-width: 760px">
<div class="col-12">
<q-input outlined dense v-model="settings.operator_id"
label="Operator account id"
hint="LNbits account whose Nostr signer publishes listings + check-in DMs (bunker-backed for the encrypted flows)"></q-input>
</div>
<div class="col-12">
<q-select outlined dense v-model="settings.relays" label="Relays"
use-chips multiple use-input hide-dropdown-icon
new-value-mode="add-unique"
hint="Relays where listings + availability are published"></q-select>
</div>
<div class="col-6 col-md-3">
<q-input outlined dense type="number" label="Hold minutes"
v-model.number="settings.default_hold_minutes"></q-input>
</div>
<div class="col-6 col-md-3">
<q-input outlined dense type="number" label="Deposit %"
v-model.number="settings.deposit_percent"></q-input>
</div>
<div class="col-6 col-md-3">
<q-input outlined dense label="Check-in time"
v-model="settings.checkin_time"></q-input>
</div>
<div class="col-6 col-md-3">
<q-input outlined dense label="Check-out time"
v-model="settings.checkout_time"></q-input>
</div>
<div class="col-12">
<q-input outlined dense type="textarea" autogrow
label="Cancellation policy"
v-model="settings.cancellation_policy"></q-input>
</div>
<div class="col-12">
<q-toggle v-model="settings.publish_availability"
label="Publish blocked dates to a public calendar (kind:31923)"></q-toggle>
</div>
<div class="col-12">
<q-btn color="primary" label="Save settings" @click="saveSettings"
:loading="settingsLoading"></q-btn>
</div>
</div>
</q-tab-panel>
</q-tab-panels>
</q-card> </q-card>
</div> </div>
</div> </div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<!-- ROOM CREATE / EDIT DIALOG --> <script src="{{ static_url_for('chatelet/static', 'js/index.js') }}"></script>
<q-dialog v-model="roomDialog.show">
<q-card style="min-width: 480px; max-width: 640px">
<q-card-section>
<div class="text-h6">${ roomDialog.data.id ? 'Edit room' : 'New room' }</div>
</q-card-section>
<q-card-section class="q-gutter-sm scroll" style="max-height: 70vh">
<q-input outlined dense v-model="roomDialog.data.title" label="Title *"></q-input>
<q-input outlined dense type="textarea" autogrow
v-model="roomDialog.data.description" label="Description (markdown)"></q-input>
<div class="row q-col-gutter-sm">
<div class="col-4">
<q-input outlined dense type="number" label="Price *"
v-model.number="roomDialog.data.price_amount"></q-input>
</div>
<div class="col-4">
<q-select outlined dense label="Currency" :options="currencyOptions"
v-model="roomDialog.data.price_currency"></q-select>
</div>
<div class="col-4">
<q-select outlined dense label="Per" :options="frequencyOptions"
v-model="roomDialog.data.price_frequency"></q-select>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col-6">
<q-input outlined dense type="number" label="Max guests"
v-model.number="roomDialog.data.max_guests"></q-input>
</div>
<div class="col-6">
<q-input outlined dense type="number" label="Min nights"
v-model.number="roomDialog.data.min_nights"></q-input>
</div>
</div>
<q-input outlined dense v-model="roomDialog.data.location" label="Location"></q-input>
<q-input outlined dense v-model="roomDialog.data.geohash"
label="Geohash (optional)"></q-input>
<q-select outlined dense v-model="roomDialog.data.amenities" label="Amenities"
use-chips multiple use-input hide-dropdown-icon
new-value-mode="add-unique"></q-select>
<q-select outlined dense v-model="roomDialog.data.images" label="Image URLs"
use-chips multiple use-input hide-dropdown-icon
new-value-mode="add-unique"></q-select>
<q-input outlined dense type="textarea" autogrow
v-model="roomDialog.data.checkin_instructions"
label="Check-in instructions (private — sent only in the encrypted DM)"></q-input>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="primary" label="Save" @click="saveRoom"
:disable="!roomDialog.data.title || roomDialog.data.price_amount == null"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
{% endblock %} {% endblock %}

View file

@ -1,93 +0,0 @@
"""Operator admin endpoint logic (backs the admin UI). Calls the view
functions directly with a fake auth key (bypassing FastAPI's Depends) and
monkeypatched crud no live server."""
import asyncio
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from .. import crud, views_api
from ..models import ChateletSettings, CreateRoomData, RoomStatus
from .conftest import make_room
def _key(wallet_id: str = "w1"):
return SimpleNamespace(wallet=SimpleNamespace(id=wallet_id, user="u1"))
def test_owned_room_rejects_other_wallet(monkeypatch):
async def gr(_):
return make_room(wallet="w1")
monkeypatch.setattr(crud, "get_room", gr)
with pytest.raises(HTTPException) as e:
asyncio.run(views_api._owned_room("room1", _key("w2")))
assert e.value.status_code == 403
def test_owned_room_404_when_missing(monkeypatch):
async def gr(_):
return None
monkeypatch.setattr(crud, "get_room", gr)
with pytest.raises(HTTPException) as e:
asyncio.run(views_api._owned_room("nope", _key()))
assert e.value.status_code == 404
def test_list_rooms_filters_to_calling_wallet(monkeypatch):
async def gr():
return [make_room("a", wallet="w1"), make_room("b", wallet="w2")]
monkeypatch.setattr(crud, "get_rooms", gr)
out = asyncio.run(views_api.api_list_rooms(key=_key("w1")))
assert [r.id for r in out] == ["a"]
def test_update_room_applies_mutable_fields_only(monkeypatch):
room = make_room("room1", wallet="w1", status=RoomStatus.inactive)
async def gr(_):
return room
async def ur(r):
return r
monkeypatch.setattr(crud, "get_room", gr)
monkeypatch.setattr(crud, "update_room", ur)
data = CreateRoomData(
wallet="attacker", title="New Title", price_amount=200,
price_currency="USD", max_guests=5, checkin_instructions="gate 7",
)
out = asyncio.run(views_api.api_update_room("room1", data, key=_key("w1")))
assert out.title == "New Title"
assert out.price_amount == 200
assert out.checkin_instructions == "gate 7"
assert out.wallet == "w1" # wallet is NOT client-mutable
assert out.id == "room1" # id unchanged
def test_update_settings_merges_editable_fields(monkeypatch):
existing = ChateletSettings(operator_id=None, deposit_percent=100)
async def gs():
return existing
async def us(s):
return s
monkeypatch.setattr(crud, "get_or_create_settings", gs)
monkeypatch.setattr(crud, "update_settings", us)
incoming = ChateletSettings(
operator_id="op1", deposit_percent=50, default_hold_minutes=45,
relays=["wss://relay.example"],
)
out = asyncio.run(views_api.api_update_settings(incoming, key=_key()))
assert out.operator_id == "op1"
assert out.deposit_percent == 50
assert out.default_hold_minutes == 45
assert out.relays == ["wss://relay.example"]

View file

@ -1,69 +0,0 @@
"""Real-DB migration test (#14).
Runs the full migration chain against a fresh temp SQLite via the lnbits
`Database`, then round-trips through `crud` to prove the resulting schema.
Guards the class of bug from #13 (a migration statement that parses in Python
but is invalid SQL on SQLite the default backend). The rest of the suite
monkeypatches `crud`, so migrations are otherwise never actually executed.
Isolation: `Database` binds its sqlite path + engine at construction from
`settings.lnbits_data_folder`, and `crud.db` is built at import time. So we
point settings at `tmp_path`, build a fresh `ext_chatelet` DB, and swap it
into `crud` for the test import-order-independent, no live server.
"""
import asyncio
import re
from lnbits.db import Database
from lnbits.settings import settings
from .. import crud, migrations
from ..models import Booking, BookingStatus, CreateRoomData, RoomStatus
def test_full_migration_chain_applies_and_schema_round_trips(monkeypatch, tmp_path):
# Fresh, isolated ext DB in tmp_path; route crud at it for the test.
monkeypatch.setattr(settings, "lnbits_data_folder", str(tmp_path))
test_db = Database("ext_chatelet")
monkeypatch.setattr(crud, "db", test_db)
async def run():
# Apply every m0NN migration in order against the empty DB. If any
# statement is invalid on SQLite (the #13 bug), this raises here.
migfns = sorted(
(n, f) for n, f in vars(migrations).items() if re.match(r"m\d+_", n)
)
assert migfns, "no m0NN migrations discovered"
async with test_db.connect() as conn:
for _name, fn in migfns:
await fn(conn)
# Round-trip through crud (now bound to test_db) to prove the schema.
room = await crud.create_room(
CreateRoomData(
wallet="w1", title="Keep", price_amount=90, price_currency="EUR"
)
)
got = await crud.get_room(room.id)
assert got is not None
assert got.checkin_instructions == "" # m002 column exists, default ''
got.status = RoomStatus.active
await crud.update_room(got)
booking = Booking(
id="bk1", room_id=room.id, guest_pubkey="g",
check_in="2026-08-01", check_out="2026-08-04", nights=3, num_guests=1,
currency="EUR", price_fiat=270.0, amount_sat=450000, deposit_sat=450000,
status=BookingStatus.held,
)
await crud.create_booking(booking)
gb = await crud.get_booking("bk1")
assert gb is not None and gb.amount_sat == 450000 # big_int round-trips
# Availability computed against real rows (not monkeypatched): the held
# booking blocks its own dates; a non-overlapping range is free.
assert await crud.is_available(room.id, "2026-08-01", "2026-08-04") is False
assert await crud.is_available(room.id, "2026-08-10", "2026-08-12") is True
asyncio.run(run())

View file

@ -1,60 +0,0 @@
"""Public guest discovery endpoints + the operator-private field strip
(privacy: check-in instructions must never reach a guest)."""
import asyncio
import pytest
from fastapi import HTTPException
from .. import crud, views_api
from ..models import RoomStatus, public_room_dict
from .conftest import make_room
def test_public_room_dict_strips_private_fields():
room = make_room(wallet="w1")
room.checkin_instructions = "gate code 4213, door on the left"
d = public_room_dict(room)
assert "wallet" not in d # operator-internal
assert "checkin_instructions" not in d # private, DM-only
assert d["title"] == "Tower Room" # public fields survive
assert d["price_amount"] == 100
def test_public_rooms_lists_active_only_and_stripped(monkeypatch):
active = make_room("a", status=RoomStatus.active)
active.checkin_instructions = "secret"
inactive = make_room("b", status=RoomStatus.inactive)
async def gr():
return [active, inactive]
monkeypatch.setattr(crud, "get_rooms", gr)
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]
def test_public_room_404_when_inactive(monkeypatch):
async def gr(_):
return make_room(status=RoomStatus.inactive)
monkeypatch.setattr(crud, "get_room", gr)
with pytest.raises(HTTPException) as e:
asyncio.run(views_api.api_public_room("x"))
assert e.value.status_code == 404
def test_public_room_returns_stripped_when_active(monkeypatch):
room = make_room("a", status=RoomStatus.active)
room.checkin_instructions = "gate"
async def gr(_):
return room
monkeypatch.setattr(crud, "get_room", gr)
out = asyncio.run(views_api.api_public_room("a"))
assert out["id"] == "a"
assert "checkin_instructions" not in out
assert "wallet" not in out

View file

@ -196,9 +196,6 @@ def _to_dict(obj) -> dict:
def _public_room(room) -> dict: def _public_room(room) -> dict:
# Shared with the HTTP door; strips wallet id AND checkin_instructions d = _to_dict(room)
# (the latter was leaking to guests before — added after this file's d.pop("wallet", None) # wallet id is operator-internal, not for guests
# original public dict). return d
from .models import public_room_dict
return public_room_dict(room)

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. The operator admin availability arbitration + quoting live in one place. Per the aiolabs
endpoints (room/block CRUD, settings) are HTTP-only and back the admin UI; long-term direction (webapp<->lnbits over Nostr), REST is the transitional
the guest-facing surface (availability, booking) is what also rides the RPC. door; keep new booking logic in services.py, not here.
""" """
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
@ -15,36 +15,17 @@ 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,
public_room_dict,
) )
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."""
@ -55,162 +36,34 @@ 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 = key.wallet.id # rooms are owned by the calling wallet data.wallet = data.wallet or key.wallet.id
return await crud.create_room(data) return await crud.create_room(data)
@chatelet_api_router.put("/api/v1/rooms/{room_id}") @chatelet_api_router.get("/api/v1/rooms")
async def api_update_room( async def api_list_rooms() -> list[Room]:
room_id: str, return await crud.get_rooms()
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 _owned_room(room_id, key) room = await crud.get_room(room_id)
room.status = RoomStatus.active if not room:
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)
# --- 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
]
@chatelet_api_router.get("/api/v1/public/rooms/{room_id}")
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)
# --- availability (public read) -------------------------------------------- # --- availability (public read) --------------------------------------------
@ -243,3 +96,19 @@ 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