1258 lines
42 KiB
TypeScript
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" },
|
|
],
|
|
};
|