rspace-online/modules/rbnb/mod.ts

1258 lines
42 KiB
TypeScript

/**
* rBnb module — community hospitality rApp.
*
* Trust-based space sharing and couch surfing within community networks.
* No platform extraction — leverages rNetwork trust graph, supports gift economy
* as a first-class option, keeps all data local-first via Automerge CRDTs.
*
* All persistence uses Automerge documents via SyncServer —
* no PostgreSQL dependency.
*/
import { Hono } from "hono";
import * as Automerge from "@automerge/automerge";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
import { renderLanding } from "./landing";
import type { SyncServer } from '../../server/local-first/sync-server';
import { bnbSchema, bnbDocId } from './schemas';
import type {
BnbDoc, Listing, AvailabilityWindow, StayRequest, StayMessage,
Endorsement, SpaceConfig, EconomyModel, ListingType, StayStatus,
} from './schemas';
let _syncServer: SyncServer | null = null;
const routes = new Hono();
// ── Local-first helpers ──
function ensureDoc(space: string): BnbDoc {
const docId = bnbDocId(space);
let doc = _syncServer!.getDoc<BnbDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<BnbDoc>(), 'init bnb', (d) => {
const init = bnbSchema.init();
d.meta = init.meta;
d.meta.spaceSlug = space;
d.config = init.config;
d.listings = {};
d.availability = {};
d.stays = {};
d.endorsements = {};
});
_syncServer!.setDoc(docId, doc);
}
return doc;
}
function daysFromNow(days: number): number {
const d = new Date();
d.setDate(d.getDate() + days);
d.setHours(0, 0, 0, 0);
return d.getTime();
}
// ── JSON response helpers ──
function listingToRow(l: Listing) {
return {
id: l.id,
host_did: l.hostDid,
host_name: l.hostName,
title: l.title,
description: l.description,
type: l.type,
economy: l.economy,
suggested_amount: l.suggestedAmount,
currency: l.currency,
sliding_min: l.slidingMin,
sliding_max: l.slidingMax,
exchange_description: l.exchangeDescription,
location_name: l.locationName,
location_lat: l.locationLat,
location_lng: l.locationLng,
location_granularity: l.locationGranularity,
guest_capacity: l.guestCapacity,
bedroom_count: l.bedroomCount,
bed_count: l.bedCount,
bathroom_count: l.bathroomCount,
amenities: l.amenities,
house_rules: l.houseRules,
photos: l.photos,
cover_photo: l.coverPhoto,
trust_threshold: l.trustThreshold,
instant_accept: l.instantAccept,
is_active: l.isActive,
created_at: l.createdAt ? new Date(l.createdAt).toISOString() : null,
updated_at: l.updatedAt ? new Date(l.updatedAt).toISOString() : null,
};
}
function availabilityToRow(a: AvailabilityWindow) {
return {
id: a.id,
listing_id: a.listingId,
start_date: new Date(a.startDate).toISOString().split('T')[0],
end_date: new Date(a.endDate).toISOString().split('T')[0],
status: a.status,
notes: a.notes,
created_at: a.createdAt ? new Date(a.createdAt).toISOString() : null,
};
}
function stayToRow(s: StayRequest) {
return {
id: s.id,
listing_id: s.listingId,
guest_did: s.guestDid,
guest_name: s.guestName,
host_did: s.hostDid,
check_in: new Date(s.checkIn).toISOString().split('T')[0],
check_out: new Date(s.checkOut).toISOString().split('T')[0],
guest_count: s.guestCount,
status: s.status,
messages: s.messages.map(m => ({
id: m.id,
sender_did: m.senderDid,
sender_name: m.senderName,
body: m.body,
sent_at: new Date(m.sentAt).toISOString(),
})),
offered_amount: s.offeredAmount,
offered_currency: s.offeredCurrency,
offered_exchange: s.offeredExchange,
requested_at: s.requestedAt ? new Date(s.requestedAt).toISOString() : null,
responded_at: s.respondedAt ? new Date(s.respondedAt).toISOString() : null,
completed_at: s.completedAt ? new Date(s.completedAt).toISOString() : null,
cancelled_at: s.cancelledAt ? new Date(s.cancelledAt).toISOString() : null,
};
}
function endorsementToRow(e: Endorsement) {
return {
id: e.id,
stay_id: e.stayId,
listing_id: e.listingId,
author_did: e.authorDid,
author_name: e.authorName,
subject_did: e.subjectDid,
subject_name: e.subjectName,
direction: e.direction,
body: e.body,
rating: e.rating,
tags: e.tags,
visibility: e.visibility,
trust_weight: e.trustWeight,
created_at: e.createdAt ? new Date(e.createdAt).toISOString() : null,
};
}
// ── Seed demo data ──
function seedDemoIfEmpty(space: string) {
const docId = bnbDocId(space);
const doc = ensureDoc(space);
if (Object.keys(doc.listings).length > 0) return;
_syncServer!.changeDoc<BnbDoc>(docId, 'seed demo data', (d) => {
const now = Date.now();
// ── 6 Demo Listings ──
const listing1 = crypto.randomUUID();
d.listings[listing1] = {
id: listing1,
hostDid: 'did:key:z6MkDemoHost1',
hostName: 'Marta K.',
title: 'Cozy Couch in Kreuzberg Co-op',
description: 'A comfortable pull-out couch in our community living room. Shared kitchen, rooftop garden access. We host weekly dinners on Thursdays — guests welcome to join!',
type: 'couch',
economy: 'gift',
suggestedAmount: null,
currency: null,
slidingMin: null,
slidingMax: null,
exchangeDescription: null,
locationName: 'Kreuzberg, Berlin',
locationLat: 52.4934,
locationLng: 13.4014,
locationGranularity: 'neighborhood',
guestCapacity: 1,
bedroomCount: null,
bedCount: 1,
bathroomCount: 1,
amenities: ['wifi', 'kitchen', 'laundry', 'garden', 'linens'],
houseRules: ['shoes_off', 'quiet_hours', 'no_smoking'],
photos: [],
coverPhoto: null,
trustThreshold: 20,
instantAccept: true,
isActive: true,
createdAt: now - 86400000 * 45,
updatedAt: now - 86400000 * 3,
};
const listing2 = crypto.randomUUID();
d.listings[listing2] = {
id: listing2,
hostDid: 'did:key:z6MkDemoHost2',
hostName: 'Tomás R.',
title: 'Permaculture Farm Stay — Help & Learn',
description: 'Private room in the farmhouse. Join morning chores in exchange for your stay: feeding chickens, harvesting vegetables, composting. Meals included from the farm kitchen.',
type: 'room',
economy: 'exchange',
suggestedAmount: null,
currency: null,
slidingMin: null,
slidingMax: null,
exchangeDescription: '3-4 hours of farm work per day (morning shift). All meals from the farm kitchen included.',
locationName: 'Alentejo, Portugal',
locationLat: 38.5667,
locationLng: -7.9,
locationGranularity: 'region',
guestCapacity: 2,
bedroomCount: 1,
bedCount: 1,
bathroomCount: 1,
amenities: ['kitchen', 'garden', 'parking', 'linens', 'towels', 'hot_water'],
houseRules: ['no_smoking', 'clean_up_after', 'no_parties'],
photos: [],
coverPhoto: null,
trustThreshold: 15,
instantAccept: false,
isActive: true,
createdAt: now - 86400000 * 60,
updatedAt: now - 86400000 * 5,
};
const listing3 = crypto.randomUUID();
d.listings[listing3] = {
id: listing3,
hostDid: 'did:key:z6MkDemoHost3',
hostName: 'Anika S.',
title: 'Lakeside Tent Site in Mecklenburg',
description: 'Pitch your tent by the lake on our community land. Composting toilet, outdoor shower (solar-heated), fire pit, and kayak available. Pure quiet — no cars, no lights, just stars.',
type: 'tent_site',
economy: 'suggested',
suggestedAmount: 10,
currency: 'EUR',
slidingMin: null,
slidingMax: null,
exchangeDescription: null,
locationName: 'Mecklenburg Lake District, Germany',
locationLat: 53.45,
locationLng: 12.7,
locationGranularity: 'region',
guestCapacity: 4,
bedroomCount: null,
bedCount: null,
bathroomCount: 1,
amenities: ['parking', 'garden', 'pets_welcome'],
houseRules: ['no_parties', 'clean_up_after'],
photos: [],
coverPhoto: null,
trustThreshold: 10,
instantAccept: true,
isActive: true,
createdAt: now - 86400000 * 30,
updatedAt: now - 86400000 * 2,
};
const listing4 = crypto.randomUUID();
d.listings[listing4] = {
id: listing4,
hostDid: 'did:key:z6MkDemoHost4',
hostName: 'Lucia V.',
title: 'Artist Loft in Neukölln — Sliding Scale',
description: 'Bright, spacious loft in a converted factory. Art supplies and a shared studio space available. Great for creative retreats. Pay what feels right.',
type: 'loft',
economy: 'sliding_scale',
suggestedAmount: 35,
currency: 'EUR',
slidingMin: 15,
slidingMax: 60,
exchangeDescription: null,
locationName: 'Neukölln, Berlin',
locationLat: 52.4811,
locationLng: 13.4346,
locationGranularity: 'neighborhood',
guestCapacity: 2,
bedroomCount: 1,
bedCount: 1,
bathroomCount: 1,
amenities: ['wifi', 'kitchen', 'workspace', 'heating', 'hot_water', 'linens', 'towels'],
houseRules: ['shoes_off', 'no_smoking', 'quiet_hours'],
photos: [],
coverPhoto: null,
trustThreshold: 25,
instantAccept: false,
isActive: true,
createdAt: now - 86400000 * 20,
updatedAt: now - 86400000 * 1,
};
const listing5 = crypto.randomUUID();
d.listings[listing5] = {
id: listing5,
hostDid: 'did:key:z6MkDemoHost5',
hostName: 'Commons Hub Berlin',
title: 'Commons Hub Guest Room — Gift Economy',
description: 'A dedicated guest room in our co-working and community hub. Access to shared kitchen, meeting rooms, library, and rooftop terrace. Visitors are part of the community for the duration of their stay.',
type: 'room',
economy: 'gift',
suggestedAmount: null,
currency: null,
slidingMin: null,
slidingMax: null,
exchangeDescription: null,
locationName: 'Mitte, Berlin',
locationLat: 52.52,
locationLng: 13.405,
locationGranularity: 'neighborhood',
guestCapacity: 2,
bedroomCount: 1,
bedCount: 2,
bathroomCount: 1,
amenities: ['wifi', 'kitchen', 'workspace', 'laundry', 'heating', 'hot_water', 'linens', 'towels', 'wheelchair_accessible'],
houseRules: ['no_smoking', 'quiet_hours', 'clean_up_after', 'shoes_off'],
photos: [],
coverPhoto: null,
trustThreshold: 30,
instantAccept: true,
isActive: true,
createdAt: now - 86400000 * 90,
updatedAt: now - 86400000 * 7,
};
const listing6 = crypto.randomUUID();
d.listings[listing6] = {
id: listing6,
hostDid: 'did:key:z6MkDemoHost6',
hostName: 'Jakob W.',
title: 'Off-Grid Cabin in the Black Forest',
description: 'A small wooden cabin heated by wood stove. No electricity, no internet — just forest, silence, and a creek. Solar lantern and firewood provided. Ideal for digital detox.',
type: 'cabin',
economy: 'fixed',
suggestedAmount: 45,
currency: 'EUR',
slidingMin: null,
slidingMax: null,
exchangeDescription: null,
locationName: 'Black Forest, Germany',
locationLat: 48.0,
locationLng: 8.2,
locationGranularity: 'region',
guestCapacity: 2,
bedroomCount: 1,
bedCount: 1,
bathroomCount: null,
amenities: ['parking', 'pets_welcome', 'heating'],
houseRules: ['no_smoking', 'clean_up_after', 'check_in_by_10pm'],
photos: [],
coverPhoto: null,
trustThreshold: 40,
instantAccept: false,
isActive: true,
createdAt: now - 86400000 * 15,
updatedAt: now - 86400000 * 1,
};
// ── Availability windows ──
const avail = (listingId: string, startDays: number, endDays: number, status: 'available' | 'blocked' = 'available') => {
const id = crypto.randomUUID();
d.availability[id] = {
id,
listingId,
startDate: daysFromNow(startDays),
endDate: daysFromNow(endDays),
status,
notes: null,
createdAt: now,
};
};
// Listing 1 (Kreuzberg couch) — available next 2 months
avail(listing1, 0, 60);
// Listing 2 (Farm) — available next month, then blocked
avail(listing2, 0, 30);
avail(listing2, 31, 45, 'blocked');
avail(listing2, 46, 90);
// Listing 3 (Tent site) — seasonal, available May-September feel
avail(listing3, 0, 120);
// Listing 4 (Artist loft) — some gaps
avail(listing4, 3, 20);
avail(listing4, 25, 50);
// Listing 5 (Commons hub) — available year-round
avail(listing5, 0, 180);
// Listing 6 (Cabin) — weekends only for next month, then open
avail(listing6, 5, 7);
avail(listing6, 12, 14);
avail(listing6, 19, 21);
avail(listing6, 26, 28);
avail(listing6, 30, 90);
// ── Sample stay requests ──
const stay1 = crypto.randomUUID();
d.stays[stay1] = {
id: stay1,
listingId: listing1,
guestDid: 'did:key:z6MkDemoGuest1',
guestName: 'Ravi P.',
hostDid: 'did:key:z6MkDemoHost1',
checkIn: daysFromNow(5),
checkOut: daysFromNow(8),
guestCount: 1,
status: 'accepted',
messages: [
{
id: crypto.randomUUID(),
senderDid: 'did:key:z6MkDemoGuest1',
senderName: 'Ravi P.',
body: 'Hi Marta! I\'m visiting Berlin for a commons conference and would love to crash at your place. I\'m quiet, clean, and happy to cook!',
sentAt: now - 86400000 * 3,
},
{
id: crypto.randomUUID(),
senderDid: 'did:key:z6MkDemoHost1',
senderName: 'Marta K.',
body: 'Welcome Ravi! You\'re auto-accepted (trust score 72). Thursday dinner is lasagna night — you\'re invited. Key code will be sent day-of.',
sentAt: now - 86400000 * 3 + 3600000,
},
],
offeredAmount: null,
offeredCurrency: null,
offeredExchange: null,
requestedAt: now - 86400000 * 3,
respondedAt: now - 86400000 * 3 + 3600000,
completedAt: null,
cancelledAt: null,
};
const stay2 = crypto.randomUUID();
d.stays[stay2] = {
id: stay2,
listingId: listing2,
guestDid: 'did:key:z6MkDemoGuest2',
guestName: 'Elena M.',
hostDid: 'did:key:z6MkDemoHost2',
checkIn: daysFromNow(-14),
checkOut: daysFromNow(-7),
guestCount: 1,
status: 'completed',
messages: [
{
id: crypto.randomUUID(),
senderDid: 'did:key:z6MkDemoGuest2',
senderName: 'Elena M.',
body: 'I\'d love to learn permaculture! I have experience with composting and seed saving.',
sentAt: now - 86400000 * 21,
},
{
id: crypto.randomUUID(),
senderDid: 'did:key:z6MkDemoHost2',
senderName: 'Tomás R.',
body: 'Perfect! We\'re starting a new compost system this week. Come join us.',
sentAt: now - 86400000 * 20,
},
],
offeredAmount: null,
offeredCurrency: null,
offeredExchange: 'Help with composting and seed saving',
requestedAt: now - 86400000 * 21,
respondedAt: now - 86400000 * 20,
completedAt: now - 86400000 * 7,
cancelledAt: null,
};
const stay3 = crypto.randomUUID();
d.stays[stay3] = {
id: stay3,
listingId: listing4,
guestDid: 'did:key:z6MkDemoGuest3',
guestName: 'Kai L.',
hostDid: 'did:key:z6MkDemoHost4',
checkIn: daysFromNow(10),
checkOut: daysFromNow(14),
guestCount: 1,
status: 'pending',
messages: [
{
id: crypto.randomUUID(),
senderDid: 'did:key:z6MkDemoGuest3',
senderName: 'Kai L.',
body: 'Hi Lucia, I\'m a muralist and would love to stay in your loft for a few days while working on a commission nearby. Happy to pay sliding scale.',
sentAt: now - 86400000 * 1,
},
],
offeredAmount: 30,
offeredCurrency: 'EUR',
offeredExchange: null,
requestedAt: now - 86400000 * 1,
respondedAt: null,
completedAt: null,
cancelledAt: null,
};
// ── Sample endorsements ──
const endorsement1 = crypto.randomUUID();
d.endorsements[endorsement1] = {
id: endorsement1,
stayId: stay2,
listingId: listing2,
authorDid: 'did:key:z6MkDemoGuest2',
authorName: 'Elena M.',
subjectDid: 'did:key:z6MkDemoHost2',
subjectName: 'Tomás R.',
direction: 'guest_to_host',
body: 'An incredible experience. Tomás is a generous teacher and the farm is a paradise. I learned so much about permaculture in one week. The food was amazing — all from the garden.',
rating: 5,
tags: ['welcoming', 'generous', 'great_conversation', 'helpful'],
visibility: 'public',
trustWeight: 0.85,
createdAt: now - 86400000 * 6,
};
const endorsement2 = crypto.randomUUID();
d.endorsements[endorsement2] = {
id: endorsement2,
stayId: stay2,
listingId: listing2,
authorDid: 'did:key:z6MkDemoHost2',
authorName: 'Tomás R.',
subjectDid: 'did:key:z6MkDemoGuest2',
subjectName: 'Elena M.',
direction: 'host_to_guest',
body: 'Elena was a wonderful helper. She arrived with energy and curiosity, respected the land, and left the compost system in better shape than she found it. Welcome back anytime.',
rating: 5,
tags: ['respectful', 'helpful', 'clean', 'great_conversation'],
visibility: 'public',
trustWeight: 0.8,
createdAt: now - 86400000 * 6,
};
});
console.log("[rBnb] Demo data seeded: 6 listings, 3 stay requests, 2 endorsements");
}
// ── API: Listings ──
routes.get("/api/listings", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const { type, economy, active, search } = c.req.query();
const doc = ensureDoc(dataSpace);
let listings = Object.values(doc.listings);
if (type) listings = listings.filter(l => l.type === type);
if (economy) listings = listings.filter(l => l.economy === economy);
if (active !== undefined) listings = listings.filter(l => l.isActive === (active === 'true'));
if (search) {
const term = search.toLowerCase();
listings = listings.filter(l =>
l.title.toLowerCase().includes(term) ||
l.description.toLowerCase().includes(term) ||
l.locationName.toLowerCase().includes(term)
);
}
listings.sort((a, b) => b.createdAt - a.createdAt);
const rows = listings.map(listingToRow);
return c.json({ count: rows.length, results: rows });
});
routes.post("/api/listings", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const body = await c.req.json();
if (!body.title?.trim()) return c.json({ error: "Title required" }, 400);
const docId = bnbDocId(dataSpace);
ensureDoc(dataSpace);
const listingId = crypto.randomUUID();
const now = Date.now();
_syncServer!.changeDoc<BnbDoc>(docId, `create listing ${listingId}`, (d) => {
d.listings[listingId] = {
id: listingId,
hostDid: body.host_did || '',
hostName: body.host_name || 'Anonymous',
title: body.title.trim(),
description: body.description || '',
type: body.type || 'room',
economy: body.economy || d.config.defaultEconomy,
suggestedAmount: body.suggested_amount ?? null,
currency: body.currency ?? null,
slidingMin: body.sliding_min ?? null,
slidingMax: body.sliding_max ?? null,
exchangeDescription: body.exchange_description ?? null,
locationName: body.location_name || '',
locationLat: body.location_lat ?? null,
locationLng: body.location_lng ?? null,
locationGranularity: body.location_granularity ?? null,
guestCapacity: body.guest_capacity || 1,
bedroomCount: body.bedroom_count ?? null,
bedCount: body.bed_count ?? null,
bathroomCount: body.bathroom_count ?? null,
amenities: body.amenities || [],
houseRules: body.house_rules || [],
photos: body.photos || [],
coverPhoto: body.cover_photo ?? null,
trustThreshold: body.trust_threshold ?? d.config.defaultTrustThreshold,
instantAccept: body.instant_accept ?? false,
isActive: true,
createdAt: now,
updatedAt: now,
};
});
const updated = _syncServer!.getDoc<BnbDoc>(docId)!;
return c.json(listingToRow(updated.listings[listingId]), 201);
});
routes.get("/api/listings/:id", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const id = c.req.param("id");
const doc = ensureDoc(dataSpace);
const listing = doc.listings[id];
if (!listing) return c.json({ error: "Listing not found" }, 404);
return c.json(listingToRow(listing));
});
routes.patch("/api/listings/:id", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const id = c.req.param("id");
const body = await c.req.json();
const docId = bnbDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.listings[id]) return c.json({ error: "Not found" }, 404);
const fieldMap: Record<string, keyof Listing> = {
title: 'title', description: 'description', type: 'type', economy: 'economy',
suggested_amount: 'suggestedAmount', currency: 'currency',
sliding_min: 'slidingMin', sliding_max: 'slidingMax',
exchange_description: 'exchangeDescription',
location_name: 'locationName', location_lat: 'locationLat', location_lng: 'locationLng',
guest_capacity: 'guestCapacity', bedroom_count: 'bedroomCount',
bed_count: 'bedCount', bathroom_count: 'bathroomCount',
amenities: 'amenities', house_rules: 'houseRules',
photos: 'photos', cover_photo: 'coverPhoto',
trust_threshold: 'trustThreshold', instant_accept: 'instantAccept',
is_active: 'isActive',
};
const updates: Array<{ field: keyof Listing; value: any }> = [];
for (const [bodyKey, docField] of Object.entries(fieldMap)) {
if (body[bodyKey] !== undefined) {
updates.push({ field: docField, value: body[bodyKey] });
}
}
if (updates.length === 0) return c.json({ error: "No fields" }, 400);
_syncServer!.changeDoc<BnbDoc>(docId, `update listing ${id}`, (d) => {
const l = d.listings[id];
for (const { field, value } of updates) {
(l as any)[field] = value;
}
l.updatedAt = Date.now();
});
const updated = _syncServer!.getDoc<BnbDoc>(docId)!;
return c.json(listingToRow(updated.listings[id]));
});
routes.delete("/api/listings/:id", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const id = c.req.param("id");
const docId = bnbDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.listings[id]) return c.json({ error: "Not found" }, 404);
_syncServer!.changeDoc<BnbDoc>(docId, `delete listing ${id}`, (d) => {
delete d.listings[id];
// Also clean up availability windows for this listing
for (const [aid, aw] of Object.entries(d.availability)) {
if (aw.listingId === id) delete d.availability[aid];
}
});
return c.json({ ok: true });
});
// ── API: Availability ──
routes.get("/api/listings/:id/availability", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const listingId = c.req.param("id");
const { status } = c.req.query();
const doc = ensureDoc(dataSpace);
let windows = Object.values(doc.availability).filter(a => a.listingId === listingId);
if (status) windows = windows.filter(a => a.status === status);
windows.sort((a, b) => a.startDate - b.startDate);
return c.json({ count: windows.length, results: windows.map(availabilityToRow) });
});
routes.post("/api/listings/:id/availability", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const listingId = c.req.param("id");
const body = await c.req.json();
if (!body.start_date || !body.end_date) return c.json({ error: "start_date and end_date required" }, 400);
const docId = bnbDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.listings[listingId]) return c.json({ error: "Listing not found" }, 404);
const awId = crypto.randomUUID();
_syncServer!.changeDoc<BnbDoc>(docId, `add availability ${awId}`, (d) => {
d.availability[awId] = {
id: awId,
listingId,
startDate: new Date(body.start_date).getTime(),
endDate: new Date(body.end_date).getTime(),
status: body.status || 'available',
notes: body.notes ?? null,
createdAt: Date.now(),
};
});
const updated = _syncServer!.getDoc<BnbDoc>(docId)!;
return c.json(availabilityToRow(updated.availability[awId]), 201);
});
routes.patch("/api/availability/:id", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const id = c.req.param("id");
const body = await c.req.json();
const docId = bnbDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.availability[id]) return c.json({ error: "Not found" }, 404);
_syncServer!.changeDoc<BnbDoc>(docId, `update availability ${id}`, (d) => {
const aw = d.availability[id];
if (body.start_date) aw.startDate = new Date(body.start_date).getTime();
if (body.end_date) aw.endDate = new Date(body.end_date).getTime();
if (body.status) aw.status = body.status;
if (body.notes !== undefined) aw.notes = body.notes;
});
const updated = _syncServer!.getDoc<BnbDoc>(docId)!;
return c.json(availabilityToRow(updated.availability[id]));
});
routes.delete("/api/availability/:id", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const id = c.req.param("id");
const docId = bnbDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.availability[id]) return c.json({ error: "Not found" }, 404);
_syncServer!.changeDoc<BnbDoc>(docId, `delete availability ${id}`, (d) => {
delete d.availability[id];
});
return c.json({ ok: true });
});
// ── API: Stay Requests ──
routes.get("/api/stays", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const { listing_id, status, guest_did, host_did } = c.req.query();
const doc = ensureDoc(dataSpace);
let stays = Object.values(doc.stays);
if (listing_id) stays = stays.filter(s => s.listingId === listing_id);
if (status) stays = stays.filter(s => s.status === status);
if (guest_did) stays = stays.filter(s => s.guestDid === guest_did);
if (host_did) stays = stays.filter(s => s.hostDid === host_did);
stays.sort((a, b) => b.requestedAt - a.requestedAt);
return c.json({ count: stays.length, results: stays.map(stayToRow) });
});
routes.post("/api/stays", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const body = await c.req.json();
if (!body.listing_id || !body.check_in || !body.check_out) {
return c.json({ error: "listing_id, check_in, and check_out required" }, 400);
}
const docId = bnbDocId(dataSpace);
const doc = ensureDoc(dataSpace);
const listing = doc.listings[body.listing_id];
if (!listing) return c.json({ error: "Listing not found" }, 404);
const stayId = crypto.randomUUID();
const now = Date.now();
// Check trust threshold for auto-accept
const guestTrustScore = body.guest_trust_score ?? 0;
const autoAccept = listing.instantAccept && listing.trustThreshold !== null
&& guestTrustScore >= listing.trustThreshold;
const initialMessage: StayMessage | null = body.message ? {
id: crypto.randomUUID(),
senderDid: body.guest_did || '',
senderName: body.guest_name || 'Guest',
body: body.message,
sentAt: now,
} : null;
_syncServer!.changeDoc<BnbDoc>(docId, `create stay ${stayId}`, (d) => {
d.stays[stayId] = {
id: stayId,
listingId: body.listing_id,
guestDid: body.guest_did || '',
guestName: body.guest_name || 'Guest',
hostDid: listing.hostDid,
checkIn: new Date(body.check_in).getTime(),
checkOut: new Date(body.check_out).getTime(),
guestCount: body.guest_count || 1,
status: autoAccept ? 'accepted' : 'pending',
messages: initialMessage ? [initialMessage] : [],
offeredAmount: body.offered_amount ?? null,
offeredCurrency: body.offered_currency ?? null,
offeredExchange: body.offered_exchange ?? null,
requestedAt: now,
respondedAt: autoAccept ? now : null,
completedAt: null,
cancelledAt: null,
};
});
const updated = _syncServer!.getDoc<BnbDoc>(docId)!;
return c.json(stayToRow(updated.stays[stayId]), 201);
});
routes.get("/api/stays/:id", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const id = c.req.param("id");
const doc = ensureDoc(dataSpace);
const stay = doc.stays[id];
if (!stay) return c.json({ error: "Stay not found" }, 404);
return c.json(stayToRow(stay));
});
// ── Stay status transitions ──
function stayTransition(statusTarget: StayStatus, timestampField: 'respondedAt' | 'completedAt' | 'cancelledAt') {
return async (c: any) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const id = c.req.param("id");
const docId = bnbDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.stays[id]) return c.json({ error: "Not found" }, 404);
_syncServer!.changeDoc<BnbDoc>(docId, `${statusTarget} stay ${id}`, (d) => {
d.stays[id].status = statusTarget;
(d.stays[id] as any)[timestampField] = Date.now();
});
const updated = _syncServer!.getDoc<BnbDoc>(docId)!;
return c.json(stayToRow(updated.stays[id]));
};
}
routes.post("/api/stays/:id/accept", stayTransition('accepted', 'respondedAt'));
routes.post("/api/stays/:id/decline", stayTransition('declined', 'respondedAt'));
routes.post("/api/stays/:id/cancel", stayTransition('cancelled', 'cancelledAt'));
routes.post("/api/stays/:id/complete", stayTransition('completed', 'completedAt'));
// ── Stay messages ──
routes.post("/api/stays/:id/messages", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const id = c.req.param("id");
const body = await c.req.json();
if (!body.body?.trim()) return c.json({ error: "Message body required" }, 400);
const docId = bnbDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.stays[id]) return c.json({ error: "Stay not found" }, 404);
const msgId = crypto.randomUUID();
_syncServer!.changeDoc<BnbDoc>(docId, `add message to stay ${id}`, (d) => {
d.stays[id].messages.push({
id: msgId,
senderDid: body.sender_did || '',
senderName: body.sender_name || 'Anonymous',
body: body.body.trim(),
sentAt: Date.now(),
});
});
const updated = _syncServer!.getDoc<BnbDoc>(docId)!;
return c.json(stayToRow(updated.stays[id]));
});
// ── API: Endorsements ──
routes.get("/api/endorsements", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const { listing_id, subject_did, direction } = c.req.query();
const doc = ensureDoc(dataSpace);
let endorsements = Object.values(doc.endorsements);
if (listing_id) endorsements = endorsements.filter(e => e.listingId === listing_id);
if (subject_did) endorsements = endorsements.filter(e => e.subjectDid === subject_did);
if (direction) endorsements = endorsements.filter(e => e.direction === direction);
endorsements.sort((a, b) => b.createdAt - a.createdAt);
return c.json({ count: endorsements.length, results: endorsements.map(endorsementToRow) });
});
routes.post("/api/endorsements", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const body = await c.req.json();
if (!body.stay_id || !body.body?.trim()) {
return c.json({ error: "stay_id and body required" }, 400);
}
const docId = bnbDocId(dataSpace);
const doc = ensureDoc(dataSpace);
const stay = doc.stays[body.stay_id];
if (!stay) return c.json({ error: "Stay not found" }, 404);
if (stay.status !== 'completed' && stay.status !== 'endorsed') {
return c.json({ error: "Can only endorse completed stays" }, 400);
}
const endorsementId = crypto.randomUUID();
const now = Date.now();
_syncServer!.changeDoc<BnbDoc>(docId, `create endorsement ${endorsementId}`, (d) => {
d.endorsements[endorsementId] = {
id: endorsementId,
stayId: body.stay_id,
listingId: stay.listingId,
authorDid: body.author_did || '',
authorName: body.author_name || 'Anonymous',
subjectDid: body.subject_did || '',
subjectName: body.subject_name || '',
direction: body.direction || 'guest_to_host',
body: body.body.trim(),
rating: body.rating ?? null,
tags: body.tags || [],
visibility: body.visibility || 'public',
trustWeight: body.trust_weight ?? 0.5,
createdAt: now,
};
// Update stay status to endorsed
if (d.stays[body.stay_id].status === 'completed') {
d.stays[body.stay_id].status = 'endorsed';
}
});
const updated = _syncServer!.getDoc<BnbDoc>(docId)!;
return c.json(endorsementToRow(updated.endorsements[endorsementId]), 201);
});
routes.get("/api/endorsements/summary/:did", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const did = c.req.param("did");
const doc = ensureDoc(dataSpace);
const endorsements = Object.values(doc.endorsements).filter(e => e.subjectDid === did);
const asHost = endorsements.filter(e => e.direction === 'guest_to_host');
const asGuest = endorsements.filter(e => e.direction === 'host_to_guest');
const avgRating = (list: Endorsement[]) => {
const rated = list.filter(e => e.rating !== null);
return rated.length ? rated.reduce((sum, e) => sum + e.rating!, 0) / rated.length : null;
};
const tagCounts = (list: Endorsement[]) => {
const counts: Record<string, number> = {};
for (const e of list) {
for (const t of e.tags) counts[t] = (counts[t] || 0) + 1;
}
return counts;
};
return c.json({
did,
total_endorsements: endorsements.length,
as_host: {
count: asHost.length,
avg_rating: avgRating(asHost),
tags: tagCounts(asHost),
avg_trust_weight: asHost.length ? asHost.reduce((s, e) => s + e.trustWeight, 0) / asHost.length : 0,
},
as_guest: {
count: asGuest.length,
avg_rating: avgRating(asGuest),
tags: tagCounts(asGuest),
avg_trust_weight: asGuest.length ? asGuest.reduce((s, e) => s + e.trustWeight, 0) / asGuest.length : 0,
},
});
});
// ── API: Search ──
routes.get("/api/search", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const { q, type, economy, guests, lat, lng, radius, check_in, check_out } = c.req.query();
const doc = ensureDoc(dataSpace);
let listings = Object.values(doc.listings).filter(l => l.isActive);
// Text search
if (q) {
const term = q.toLowerCase();
listings = listings.filter(l =>
l.title.toLowerCase().includes(term) ||
l.description.toLowerCase().includes(term) ||
l.locationName.toLowerCase().includes(term) ||
l.hostName.toLowerCase().includes(term)
);
}
// Type filter
if (type) listings = listings.filter(l => l.type === type);
// Economy filter
if (economy) listings = listings.filter(l => l.economy === economy);
// Guest count
if (guests) {
const g = parseInt(guests);
listings = listings.filter(l => l.guestCapacity >= g);
}
// Location proximity (simple distance filter)
if (lat && lng && radius) {
const cLat = parseFloat(lat);
const cLng = parseFloat(lng);
const r = parseFloat(radius); // km
listings = listings.filter(l => {
if (!l.locationLat || !l.locationLng) return false;
// Haversine approximation
const dLat = (l.locationLat - cLat) * Math.PI / 180;
const dLng = (l.locationLng - cLng) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(cLat * Math.PI / 180) * Math.cos(l.locationLat * Math.PI / 180) *
Math.sin(dLng / 2) ** 2;
const dist = 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return dist <= r;
});
}
// Date availability check
if (check_in && check_out) {
const ciMs = new Date(check_in).getTime();
const coMs = new Date(check_out).getTime();
const availability = Object.values(doc.availability);
listings = listings.filter(l => {
const windows = availability.filter(a =>
a.listingId === l.id && a.status === 'available'
);
// At least one available window must cover the requested range
return windows.some(w => w.startDate <= ciMs && w.endDate >= coMs);
});
}
const rows = listings.map(listingToRow);
return c.json({ count: rows.length, results: rows });
});
// ── API: Stats ──
routes.get("/api/stats", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const doc = ensureDoc(dataSpace);
const listings = Object.values(doc.listings);
const stays = Object.values(doc.stays);
const endorsements = Object.values(doc.endorsements);
return c.json({
listings: listings.length,
active_listings: listings.filter(l => l.isActive).length,
stays: stays.length,
pending_stays: stays.filter(s => s.status === 'pending').length,
completed_stays: stays.filter(s => s.status === 'completed' || s.status === 'endorsed').length,
endorsements: endorsements.length,
economy_breakdown: {
gift: listings.filter(l => l.economy === 'gift').length,
suggested: listings.filter(l => l.economy === 'suggested').length,
fixed: listings.filter(l => l.economy === 'fixed').length,
sliding_scale: listings.filter(l => l.economy === 'sliding_scale').length,
exchange: listings.filter(l => l.economy === 'exchange').length,
},
});
});
// ── API: Config ──
routes.get("/api/config", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const doc = ensureDoc(dataSpace);
return c.json(doc.config);
});
routes.patch("/api/config", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const body = await c.req.json();
const docId = bnbDocId(dataSpace);
ensureDoc(dataSpace);
_syncServer!.changeDoc<BnbDoc>(docId, 'update config', (d) => {
if (body.default_economy) d.config.defaultEconomy = body.default_economy;
if (body.default_trust_threshold !== undefined) d.config.defaultTrustThreshold = body.default_trust_threshold;
if (body.amenity_catalog) d.config.amenityCatalog = body.amenity_catalog;
if (body.house_rule_catalog) d.config.houseRuleCatalog = body.house_rule_catalog;
if (body.endorsement_tag_catalog) d.config.endorsementTagCatalog = body.endorsement_tag_catalog;
if (body.require_endorsement !== undefined) d.config.requireEndorsement = body.require_endorsement;
if (body.max_stay_days !== undefined) d.config.maxStayDays = body.max_stay_days;
});
const updated = _syncServer!.getDoc<BnbDoc>(docId)!;
return c.json(updated.config);
});
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${space} — Hospitality | rSpace`,
moduleId: "rbnb",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-bnb-view space="${space}"></folk-bnb-view>`,
scripts: `<script type="module" src="/modules/rbnb/folk-bnb-view.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rbnb/bnb.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">`,
}));
});
// ── Module export ──
export const bnbModule: RSpaceModule = {
id: "rbnb",
name: "rBnb",
icon: "\u{1F3E0}",
description: "Community hospitality — trust-based space sharing and couch surfing",
scoping: { defaultScope: 'space', userConfigurable: true },
docSchemas: [{ pattern: '{space}:bnb:listings', description: 'Listings, stays, endorsements', init: bnbSchema.init }],
routes,
standaloneDomain: "rbnb.online",
landingPage: renderLanding,
seedTemplate: seedDemoIfEmpty,
async onInit(ctx) {
_syncServer = ctx.syncServer;
seedDemoIfEmpty("demo");
},
feeds: [
{
id: "listings",
name: "Listings",
kind: "data",
description: "Hospitality listings with location, capacity, and economy model",
filterable: true,
},
{
id: "stays",
name: "Stays",
kind: "data",
description: "Stay requests and their status (pending, accepted, completed)",
},
{
id: "endorsements",
name: "Endorsements",
kind: "trust",
description: "Trust-based endorsements from completed stays, feeds rNetwork",
},
{
id: "hospitality-value",
name: "Hospitality Value",
kind: "economic",
description: "Economic value of stays (contributions, exchanges, gifts)",
},
],
acceptsFeeds: ["data", "trust", "economic"],
outputPaths: [
{ path: "listings", name: "Listings", icon: "\u{1F3E0}", description: "Community hospitality listings" },
{ path: "stays", name: "Stays", icon: "\u{1F6CC}", description: "Stay requests and history" },
{ path: "endorsements", name: "Endorsements", icon: "\u{2B50}", description: "Trust endorsements from stays" },
],
};