rspace-online/modules/rvnb/mod.ts

1339 lines
44 KiB
TypeScript

/**
* rVnb module — community RV & camper rentals rApp.
*
* Peer-to-peer RV and camper rentals 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 { vnbSchema, vnbDocId } from './schemas';
import type {
VnbDoc, Vehicle, TripWindow, RentalRequest, RentalMessage,
Endorsement, SpaceConfig, EconomyModel, VehicleType, RentalStatus,
MileagePolicy,
} from './schemas';
let _syncServer: SyncServer | null = null;
const routes = new Hono();
// ── Local-first helpers ──
function ensureDoc(space: string): VnbDoc {
const docId = vnbDocId(space);
let doc = _syncServer!.getDoc<VnbDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<VnbDoc>(), 'init vnb', (d) => {
const init = vnbSchema.init();
d.meta = init.meta;
d.meta.spaceSlug = space;
d.config = init.config;
d.vehicles = {};
d.availability = {};
d.rentals = {};
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 vehicleToRow(v: Vehicle) {
return {
id: v.id,
owner_did: v.ownerDid,
owner_name: v.ownerName,
title: v.title,
description: v.description,
type: v.type,
economy: v.economy,
year: v.year,
make: v.make,
model: v.model,
length_feet: v.lengthFeet,
sleeps: v.sleeps,
fuel_type: v.fuelType,
has_generator: v.hasGenerator,
has_solar: v.hasSolar,
has_ac: v.hasAC,
has_heating: v.hasHeating,
has_shower: v.hasShower,
has_toilet: v.hasToilet,
has_kitchen: v.hasKitchen,
pet_friendly: v.petFriendly,
tow_required: v.towRequired,
mileage_policy: v.mileagePolicy,
included_miles: v.includedMiles,
per_mile_rate: v.perMileRate,
suggested_amount: v.suggestedAmount,
currency: v.currency,
sliding_min: v.slidingMin,
sliding_max: v.slidingMax,
exchange_description: v.exchangeDescription,
pickup_location_name: v.pickupLocationName,
pickup_location_lat: v.pickupLocationLat,
pickup_location_lng: v.pickupLocationLng,
dropoff_same_as_pickup: v.dropoffSameAsPickup,
dropoff_location_name: v.dropoffLocationName,
dropoff_location_lat: v.dropoffLocationLat,
dropoff_location_lng: v.dropoffLocationLng,
photos: v.photos,
cover_photo: v.coverPhoto,
trust_threshold: v.trustThreshold,
instant_accept: v.instantAccept,
is_active: v.isActive,
created_at: v.createdAt ? new Date(v.createdAt).toISOString() : null,
updated_at: v.updatedAt ? new Date(v.updatedAt).toISOString() : null,
};
}
function availabilityToRow(a: TripWindow) {
return {
id: a.id,
vehicle_id: a.vehicleId,
start_date: new Date(a.startDate).toISOString().split('T')[0],
end_date: new Date(a.endDate).toISOString().split('T')[0],
status: a.status,
pickup_location_name: a.pickupLocationName,
pickup_lat: a.pickupLat,
pickup_lng: a.pickupLng,
dropoff_location_name: a.dropoffLocationName,
dropoff_lat: a.dropoffLat,
dropoff_lng: a.dropoffLng,
notes: a.notes,
created_at: a.createdAt ? new Date(a.createdAt).toISOString() : null,
};
}
function rentalToRow(r: RentalRequest) {
return {
id: r.id,
vehicle_id: r.vehicleId,
renter_did: r.renterDid,
renter_name: r.renterName,
owner_did: r.ownerDid,
pickup_date: new Date(r.pickupDate).toISOString().split('T')[0],
dropoff_date: new Date(r.dropoffDate).toISOString().split('T')[0],
estimated_miles: r.estimatedMiles,
requested_pickup_location: r.requestedPickupLocation,
requested_pickup_lat: r.requestedPickupLat,
requested_pickup_lng: r.requestedPickupLng,
requested_dropoff_location: r.requestedDropoffLocation,
requested_dropoff_lat: r.requestedDropoffLat,
requested_dropoff_lng: r.requestedDropoffLng,
status: r.status,
messages: r.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: r.offeredAmount,
offered_currency: r.offeredCurrency,
offered_exchange: r.offeredExchange,
requested_at: r.requestedAt ? new Date(r.requestedAt).toISOString() : null,
responded_at: r.respondedAt ? new Date(r.respondedAt).toISOString() : null,
completed_at: r.completedAt ? new Date(r.completedAt).toISOString() : null,
cancelled_at: r.cancelledAt ? new Date(r.cancelledAt).toISOString() : null,
};
}
function endorsementToRow(e: Endorsement) {
return {
id: e.id,
rental_id: e.rentalId,
vehicle_id: e.vehicleId,
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 = vnbDocId(space);
const doc = ensureDoc(space);
if (Object.keys(doc.vehicles).length > 0) return;
_syncServer!.changeDoc<VnbDoc>(docId, 'seed demo data', (d) => {
const now = Date.now();
// ── 4 Demo Vehicles ──
const v1 = crypto.randomUUID();
d.vehicles[v1] = {
id: v1,
ownerDid: 'did:key:z6MkVnbOwner1',
ownerName: 'Dave S.',
title: 'The Shaggin\' Wagon',
description: 'A 2019 Sprinter camper van with a full kitchen, queen bed, and solar setup. We promise it\'s been cleaned since the festival.',
type: 'camper_van',
economy: 'suggested',
year: 2019,
make: 'Mercedes-Benz',
model: 'Sprinter 144',
lengthFeet: 19,
sleeps: 2,
fuelType: 'diesel',
hasGenerator: false,
hasSolar: true,
hasAC: false,
hasHeating: true,
hasShower: true,
hasToilet: false,
hasKitchen: true,
petFriendly: true,
towRequired: false,
mileagePolicy: 'included_miles',
includedMiles: 150,
perMileRate: 0.35,
suggestedAmount: 120,
currency: 'USD',
slidingMin: null,
slidingMax: null,
exchangeDescription: null,
pickupLocationName: 'Portland, OR',
pickupLocationLat: 45.5152,
pickupLocationLng: -122.6784,
dropoffSameAsPickup: true,
dropoffLocationName: null,
dropoffLocationLat: null,
dropoffLocationLng: null,
photos: [],
coverPhoto: null,
trustThreshold: 25,
instantAccept: true,
isActive: true,
createdAt: now - 86400000 * 30,
updatedAt: now - 86400000 * 2,
};
const v2 = crypto.randomUUID();
d.vehicles[v2] = {
id: v2,
ownerDid: 'did:key:z6MkVnbOwner2',
ownerName: 'Rosa M.',
title: 'Dolly',
description: 'A 1987 Airstream travel trailer. She\'s older than your marriage and twice as reliable. Recently restored interior, original exterior. Tow vehicle not included.',
type: 'travel_trailer',
economy: 'sliding_scale',
year: 1987,
make: 'Airstream',
model: 'Excella 31',
lengthFeet: 31,
sleeps: 4,
fuelType: null,
hasGenerator: false,
hasSolar: false,
hasAC: true,
hasHeating: true,
hasShower: true,
hasToilet: true,
hasKitchen: true,
petFriendly: false,
towRequired: true,
mileagePolicy: 'unlimited',
includedMiles: null,
perMileRate: null,
suggestedAmount: 80,
currency: 'USD',
slidingMin: 40,
slidingMax: 120,
exchangeDescription: null,
pickupLocationName: 'Asheville, NC',
pickupLocationLat: 35.5951,
pickupLocationLng: -82.5515,
dropoffSameAsPickup: true,
dropoffLocationName: null,
dropoffLocationLat: null,
dropoffLocationLng: null,
photos: [],
coverPhoto: null,
trustThreshold: 35,
instantAccept: false,
isActive: true,
createdAt: now - 86400000 * 60,
updatedAt: now - 86400000 * 5,
};
const v3 = crypto.randomUUID();
d.vehicles[v3] = {
id: v3,
ownerDid: 'did:key:z6MkVnbOwner3',
ownerName: 'Mike & Jen T.',
title: 'Big Bertha',
description: 'A 2021 Class A motorhome with every amenity you can imagine. Technically a house. With wheels. And a generator. Sleeps 6 if you\'re friendly.',
type: 'motorhome',
economy: 'fixed',
year: 2021,
make: 'Thor',
model: 'Palazzo 33.5',
lengthFeet: 34,
sleeps: 6,
fuelType: 'diesel',
hasGenerator: true,
hasSolar: true,
hasAC: true,
hasHeating: true,
hasShower: true,
hasToilet: true,
hasKitchen: true,
petFriendly: true,
towRequired: false,
mileagePolicy: 'per_mile',
includedMiles: null,
perMileRate: 0.45,
suggestedAmount: 200,
currency: 'USD',
slidingMin: null,
slidingMax: null,
exchangeDescription: null,
pickupLocationName: 'Austin, TX',
pickupLocationLat: 30.2672,
pickupLocationLng: -97.7431,
dropoffSameAsPickup: false,
dropoffLocationName: 'San Antonio, TX',
dropoffLocationLat: 29.4241,
dropoffLocationLng: -98.4936,
photos: [],
coverPhoto: null,
trustThreshold: 50,
instantAccept: false,
isActive: true,
createdAt: now - 86400000 * 15,
updatedAt: now - 86400000 * 1,
};
const v4 = crypto.randomUUID();
d.vehicles[v4] = {
id: v4,
ownerDid: 'did:key:z6MkVnbOwner4',
ownerName: 'Sunny L.',
title: 'The Skool Bus',
description: 'A 2003 converted school bus with handmade everything. Compost toilet, rain catchment, woodburning stove. Still smells faintly of crayons. This is a feature.',
type: 'skoolie',
economy: 'gift',
year: 2003,
make: 'Blue Bird',
model: 'All American RE',
lengthFeet: 40,
sleeps: 4,
fuelType: 'diesel',
hasGenerator: false,
hasSolar: true,
hasAC: false,
hasHeating: true,
hasShower: true,
hasToilet: true,
hasKitchen: true,
petFriendly: true,
towRequired: false,
mileagePolicy: 'unlimited',
includedMiles: null,
perMileRate: null,
suggestedAmount: null,
currency: null,
slidingMin: null,
slidingMax: null,
exchangeDescription: null,
pickupLocationName: 'Taos, NM',
pickupLocationLat: 36.4072,
pickupLocationLng: -105.5731,
dropoffSameAsPickup: true,
dropoffLocationName: null,
dropoffLocationLat: null,
dropoffLocationLng: null,
photos: [],
coverPhoto: null,
trustThreshold: 15,
instantAccept: true,
isActive: true,
createdAt: now - 86400000 * 45,
updatedAt: now - 86400000 * 3,
};
// ── Availability windows ──
const avail = (vehicleId: string, startDays: number, endDays: number, status: 'available' | 'blocked' = 'available') => {
const id = crypto.randomUUID();
d.availability[id] = {
id,
vehicleId,
startDate: daysFromNow(startDays),
endDate: daysFromNow(endDays),
status,
pickupLocationName: null,
pickupLat: null,
pickupLng: null,
dropoffLocationName: null,
dropoffLat: null,
dropoffLng: null,
notes: null,
createdAt: now,
};
};
// Shaggin' Wagon — available next 2 months
avail(v1, 0, 60);
// Dolly — available next month, blocked for a wedding, then open
avail(v2, 0, 25);
avail(v2, 26, 33, 'blocked');
avail(v2, 34, 90);
// Big Bertha — weekends + some weeks
avail(v3, 0, 120);
// The Skool Bus — perpetually available (Sunny is generous)
avail(v4, 0, 180);
// ── Sample rental requests ──
const r1 = crypto.randomUUID();
d.rentals[r1] = {
id: r1,
vehicleId: v1,
renterDid: 'did:key:z6MkVnbRenter1',
renterName: 'Alex K.',
ownerDid: 'did:key:z6MkVnbOwner1',
pickupDate: daysFromNow(7),
dropoffDate: daysFromNow(12),
estimatedMiles: 600,
requestedPickupLocation: 'Portland, OR',
requestedPickupLat: 45.5152,
requestedPickupLng: -122.6784,
requestedDropoffLocation: null,
requestedDropoffLat: null,
requestedDropoffLng: null,
status: 'accepted',
messages: [
{
id: crypto.randomUUID(),
senderDid: 'did:key:z6MkVnbRenter1',
senderName: 'Alex K.',
body: 'Hey Dave! Planning a trip down the Oregon coast. The Shaggin\' Wagon looks perfect. Promise to return it festival-free.',
sentAt: now - 86400000 * 4,
},
{
id: crypto.randomUUID(),
senderDid: 'did:key:z6MkVnbOwner1',
senderName: 'Dave S.',
body: 'Auto-accepted! (Trust score 78.) She\'s all yours. Pro tip: the hot water takes 10 minutes to warm up and the passenger seat only reclines if you jiggle the lever.',
sentAt: now - 86400000 * 4 + 3600000,
},
],
offeredAmount: 120,
offeredCurrency: 'USD',
offeredExchange: null,
requestedAt: now - 86400000 * 4,
respondedAt: now - 86400000 * 4 + 3600000,
completedAt: null,
cancelledAt: null,
};
const r2 = crypto.randomUUID();
d.rentals[r2] = {
id: r2,
vehicleId: v4,
renterDid: 'did:key:z6MkVnbRenter2',
renterName: 'Jordan W.',
ownerDid: 'did:key:z6MkVnbOwner4',
pickupDate: daysFromNow(-10),
dropoffDate: daysFromNow(-3),
estimatedMiles: 400,
requestedPickupLocation: 'Taos, NM',
requestedPickupLat: 36.4072,
requestedPickupLng: -105.5731,
requestedDropoffLocation: null,
requestedDropoffLat: null,
requestedDropoffLng: null,
status: 'completed',
messages: [
{
id: crypto.randomUUID(),
senderDid: 'did:key:z6MkVnbRenter2',
senderName: 'Jordan W.',
body: 'Sunny! Can I take The Skool Bus to a gathering in the Gila? I\'ll bring it back with a full tank and good vibes.',
sentAt: now - 86400000 * 14,
},
{
id: crypto.randomUUID(),
senderDid: 'did:key:z6MkVnbOwner4',
senderName: 'Sunny L.',
body: 'The bus is yours, friend. Fair warning: she doesn\'t go above 55 mph and the left blinker has a mind of its own. The crayons smell is free of charge.',
sentAt: now - 86400000 * 13,
},
],
offeredAmount: null,
offeredCurrency: null,
offeredExchange: null,
requestedAt: now - 86400000 * 14,
respondedAt: now - 86400000 * 13,
completedAt: now - 86400000 * 3,
cancelledAt: null,
};
const r3 = crypto.randomUUID();
d.rentals[r3] = {
id: r3,
vehicleId: v3,
renterDid: 'did:key:z6MkVnbRenter3',
renterName: 'Sam C.',
ownerDid: 'did:key:z6MkVnbOwner3',
pickupDate: daysFromNow(14),
dropoffDate: daysFromNow(21),
estimatedMiles: 1200,
requestedPickupLocation: 'Austin, TX',
requestedPickupLat: 30.2672,
requestedPickupLng: -97.7431,
requestedDropoffLocation: 'San Antonio, TX',
requestedDropoffLat: 29.4241,
requestedDropoffLng: -98.4936,
status: 'pending',
messages: [
{
id: crypto.randomUUID(),
senderDid: 'did:key:z6MkVnbRenter3',
senderName: 'Sam C.',
body: 'Hey Mike & Jen! Big Bertha looks like the perfect family road trip rig. We\'re taking the kids to Big Bend. Happy to pay the fixed rate + mileage.',
sentAt: now - 86400000 * 1,
},
],
offeredAmount: 200,
offeredCurrency: 'USD',
offeredExchange: null,
requestedAt: now - 86400000 * 1,
respondedAt: null,
completedAt: null,
cancelledAt: null,
};
// ── Sample endorsements ──
const e1 = crypto.randomUUID();
d.endorsements[e1] = {
id: e1,
rentalId: r2,
vehicleId: v4,
authorDid: 'did:key:z6MkVnbRenter2',
authorName: 'Jordan W.',
subjectDid: 'did:key:z6MkVnbOwner4',
subjectName: 'Sunny L.',
direction: 'renter_to_owner',
body: 'The Skool Bus is a vibe. Sunny gave us the full tour, shared their secret hot springs map, and the woodburning stove kept us warm at 9,000 feet. Yes, it smells like crayons. Yes, that\'s a feature. 10/10 would crayon again.',
rating: 5,
tags: ['smells_like_adventure', 'felt_like_home', 'good_communication', 'smooth_handoff'],
visibility: 'public',
trustWeight: 0.85,
createdAt: now - 86400000 * 2,
};
const e2 = crypto.randomUUID();
d.endorsements[e2] = {
id: e2,
rentalId: r2,
vehicleId: v4,
authorDid: 'did:key:z6MkVnbOwner4',
authorName: 'Sunny L.',
subjectDid: 'did:key:z6MkVnbRenter2',
subjectName: 'Jordan W.',
direction: 'owner_to_renter',
body: 'Jordan brought the bus back with a full tank, clean interior, and a jar of homemade salsa. The left blinker still has a mind of its own but that\'s not Jordan\'s fault. Welcome back anytime.',
rating: 5,
tags: ['reliable', 'suspiciously_clean', 'smooth_handoff'],
visibility: 'public',
trustWeight: 0.8,
createdAt: now - 86400000 * 2,
};
});
console.log("[rVnb] Demo data seeded: 4 vehicles, 3 rental requests, 2 endorsements");
}
// ── API: Vehicles ──
routes.get("/api/vehicles", 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 vehicles = Object.values(doc.vehicles);
if (type) vehicles = vehicles.filter(v => v.type === type);
if (economy) vehicles = vehicles.filter(v => v.economy === economy);
if (active !== undefined) vehicles = vehicles.filter(v => v.isActive === (active === 'true'));
if (search) {
const term = search.toLowerCase();
vehicles = vehicles.filter(v =>
v.title.toLowerCase().includes(term) ||
v.description.toLowerCase().includes(term) ||
v.pickupLocationName.toLowerCase().includes(term) ||
(v.make || '').toLowerCase().includes(term) ||
(v.model || '').toLowerCase().includes(term)
);
}
vehicles.sort((a, b) => b.createdAt - a.createdAt);
const rows = vehicles.map(vehicleToRow);
return c.json({ count: rows.length, results: rows });
});
routes.post("/api/vehicles", 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 = vnbDocId(dataSpace);
ensureDoc(dataSpace);
const vehicleId = crypto.randomUUID();
const now = Date.now();
_syncServer!.changeDoc<VnbDoc>(docId, `create vehicle ${vehicleId}`, (d) => {
d.vehicles[vehicleId] = {
id: vehicleId,
ownerDid: body.owner_did || '',
ownerName: body.owner_name || 'Anonymous',
title: body.title.trim(),
description: body.description || '',
type: body.type || 'camper_van',
economy: body.economy || d.config.defaultEconomy,
year: body.year ?? null,
make: body.make ?? null,
model: body.model ?? null,
lengthFeet: body.length_feet ?? null,
sleeps: body.sleeps || 2,
fuelType: body.fuel_type ?? null,
hasGenerator: body.has_generator ?? false,
hasSolar: body.has_solar ?? false,
hasAC: body.has_ac ?? false,
hasHeating: body.has_heating ?? false,
hasShower: body.has_shower ?? false,
hasToilet: body.has_toilet ?? false,
hasKitchen: body.has_kitchen ?? false,
petFriendly: body.pet_friendly ?? false,
towRequired: body.tow_required ?? false,
mileagePolicy: body.mileage_policy || 'unlimited',
includedMiles: body.included_miles ?? null,
perMileRate: body.per_mile_rate ?? null,
suggestedAmount: body.suggested_amount ?? null,
currency: body.currency ?? null,
slidingMin: body.sliding_min ?? null,
slidingMax: body.sliding_max ?? null,
exchangeDescription: body.exchange_description ?? null,
pickupLocationName: body.pickup_location_name || '',
pickupLocationLat: body.pickup_location_lat ?? null,
pickupLocationLng: body.pickup_location_lng ?? null,
dropoffSameAsPickup: body.dropoff_same_as_pickup ?? true,
dropoffLocationName: body.dropoff_location_name ?? null,
dropoffLocationLat: body.dropoff_location_lat ?? null,
dropoffLocationLng: body.dropoff_location_lng ?? null,
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<VnbDoc>(docId)!;
return c.json(vehicleToRow(updated.vehicles[vehicleId]), 201);
});
routes.get("/api/vehicles/: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 vehicle = doc.vehicles[id];
if (!vehicle) return c.json({ error: "Vehicle not found" }, 404);
return c.json(vehicleToRow(vehicle));
});
routes.patch("/api/vehicles/: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 = vnbDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.vehicles[id]) return c.json({ error: "Not found" }, 404);
const fieldMap: Record<string, keyof Vehicle> = {
title: 'title', description: 'description', type: 'type', economy: 'economy',
year: 'year', make: 'make', model: 'model', length_feet: 'lengthFeet',
sleeps: 'sleeps', fuel_type: 'fuelType',
has_generator: 'hasGenerator', has_solar: 'hasSolar', has_ac: 'hasAC',
has_heating: 'hasHeating', has_shower: 'hasShower', has_toilet: 'hasToilet',
has_kitchen: 'hasKitchen', pet_friendly: 'petFriendly', tow_required: 'towRequired',
mileage_policy: 'mileagePolicy', included_miles: 'includedMiles', per_mile_rate: 'perMileRate',
suggested_amount: 'suggestedAmount', currency: 'currency',
sliding_min: 'slidingMin', sliding_max: 'slidingMax',
exchange_description: 'exchangeDescription',
pickup_location_name: 'pickupLocationName', pickup_location_lat: 'pickupLocationLat',
pickup_location_lng: 'pickupLocationLng',
dropoff_same_as_pickup: 'dropoffSameAsPickup',
dropoff_location_name: 'dropoffLocationName', dropoff_location_lat: 'dropoffLocationLat',
dropoff_location_lng: 'dropoffLocationLng',
photos: 'photos', cover_photo: 'coverPhoto',
trust_threshold: 'trustThreshold', instant_accept: 'instantAccept',
is_active: 'isActive',
};
const updates: Array<{ field: keyof Vehicle; 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<VnbDoc>(docId, `update vehicle ${id}`, (d) => {
const v = d.vehicles[id];
for (const { field, value } of updates) {
(v as any)[field] = value;
}
v.updatedAt = Date.now();
});
const updated = _syncServer!.getDoc<VnbDoc>(docId)!;
return c.json(vehicleToRow(updated.vehicles[id]));
});
routes.delete("/api/vehicles/:id", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const id = c.req.param("id");
const docId = vnbDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.vehicles[id]) return c.json({ error: "Not found" }, 404);
_syncServer!.changeDoc<VnbDoc>(docId, `delete vehicle ${id}`, (d) => {
delete d.vehicles[id];
// Also clean up availability windows for this vehicle
for (const [aid, aw] of Object.entries(d.availability)) {
if (aw.vehicleId === id) delete d.availability[aid];
}
});
return c.json({ ok: true });
});
// ── API: Availability ──
routes.get("/api/vehicles/:id/availability", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const vehicleId = c.req.param("id");
const { status } = c.req.query();
const doc = ensureDoc(dataSpace);
let windows = Object.values(doc.availability).filter(a => a.vehicleId === vehicleId);
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/vehicles/: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 vehicleId = 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 = vnbDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.vehicles[vehicleId]) return c.json({ error: "Vehicle not found" }, 404);
const awId = crypto.randomUUID();
_syncServer!.changeDoc<VnbDoc>(docId, `add availability ${awId}`, (d) => {
d.availability[awId] = {
id: awId,
vehicleId,
startDate: new Date(body.start_date).getTime(),
endDate: new Date(body.end_date).getTime(),
status: body.status || 'available',
pickupLocationName: body.pickup_location_name ?? null,
pickupLat: body.pickup_lat ?? null,
pickupLng: body.pickup_lng ?? null,
dropoffLocationName: body.dropoff_location_name ?? null,
dropoffLat: body.dropoff_lat ?? null,
dropoffLng: body.dropoff_lng ?? null,
notes: body.notes ?? null,
createdAt: Date.now(),
};
});
const updated = _syncServer!.getDoc<VnbDoc>(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 = vnbDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.availability[id]) return c.json({ error: "Not found" }, 404);
_syncServer!.changeDoc<VnbDoc>(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.pickup_location_name !== undefined) aw.pickupLocationName = body.pickup_location_name;
if (body.pickup_lat !== undefined) aw.pickupLat = body.pickup_lat;
if (body.pickup_lng !== undefined) aw.pickupLng = body.pickup_lng;
if (body.dropoff_location_name !== undefined) aw.dropoffLocationName = body.dropoff_location_name;
if (body.dropoff_lat !== undefined) aw.dropoffLat = body.dropoff_lat;
if (body.dropoff_lng !== undefined) aw.dropoffLng = body.dropoff_lng;
if (body.notes !== undefined) aw.notes = body.notes;
});
const updated = _syncServer!.getDoc<VnbDoc>(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 = vnbDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.availability[id]) return c.json({ error: "Not found" }, 404);
_syncServer!.changeDoc<VnbDoc>(docId, `delete availability ${id}`, (d) => {
delete d.availability[id];
});
return c.json({ ok: true });
});
// ── API: Rentals ──
routes.get("/api/rentals", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const { vehicle_id, status, renter_did, owner_did } = c.req.query();
const doc = ensureDoc(dataSpace);
let rentals = Object.values(doc.rentals);
if (vehicle_id) rentals = rentals.filter(r => r.vehicleId === vehicle_id);
if (status) rentals = rentals.filter(r => r.status === status);
if (renter_did) rentals = rentals.filter(r => r.renterDid === renter_did);
if (owner_did) rentals = rentals.filter(r => r.ownerDid === owner_did);
rentals.sort((a, b) => b.requestedAt - a.requestedAt);
return c.json({ count: rentals.length, results: rentals.map(rentalToRow) });
});
routes.post("/api/rentals", 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.vehicle_id || !body.pickup_date || !body.dropoff_date) {
return c.json({ error: "vehicle_id, pickup_date, and dropoff_date required" }, 400);
}
const docId = vnbDocId(dataSpace);
const doc = ensureDoc(dataSpace);
const vehicle = doc.vehicles[body.vehicle_id];
if (!vehicle) return c.json({ error: "Vehicle not found" }, 404);
const rentalId = crypto.randomUUID();
const now = Date.now();
// Check trust threshold for auto-accept
const renterTrustScore = body.renter_trust_score ?? 0;
const autoAccept = vehicle.instantAccept && vehicle.trustThreshold !== null
&& renterTrustScore >= vehicle.trustThreshold;
const initialMessage: RentalMessage | null = body.message ? {
id: crypto.randomUUID(),
senderDid: body.renter_did || '',
senderName: body.renter_name || 'Renter',
body: body.message,
sentAt: now,
} : null;
_syncServer!.changeDoc<VnbDoc>(docId, `create rental ${rentalId}`, (d) => {
d.rentals[rentalId] = {
id: rentalId,
vehicleId: body.vehicle_id,
renterDid: body.renter_did || '',
renterName: body.renter_name || 'Renter',
ownerDid: vehicle.ownerDid,
pickupDate: new Date(body.pickup_date).getTime(),
dropoffDate: new Date(body.dropoff_date).getTime(),
estimatedMiles: body.estimated_miles ?? null,
requestedPickupLocation: body.requested_pickup_location ?? null,
requestedPickupLat: body.requested_pickup_lat ?? null,
requestedPickupLng: body.requested_pickup_lng ?? null,
requestedDropoffLocation: body.requested_dropoff_location ?? null,
requestedDropoffLat: body.requested_dropoff_lat ?? null,
requestedDropoffLng: body.requested_dropoff_lng ?? null,
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<VnbDoc>(docId)!;
return c.json(rentalToRow(updated.rentals[rentalId]), 201);
});
routes.get("/api/rentals/: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 rental = doc.rentals[id];
if (!rental) return c.json({ error: "Rental not found" }, 404);
return c.json(rentalToRow(rental));
});
// ── Rental status transitions ──
function rentalTransition(statusTarget: RentalStatus, 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 = vnbDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.rentals[id]) return c.json({ error: "Not found" }, 404);
_syncServer!.changeDoc<VnbDoc>(docId, `${statusTarget} rental ${id}`, (d) => {
d.rentals[id].status = statusTarget;
(d.rentals[id] as any)[timestampField] = Date.now();
});
const updated = _syncServer!.getDoc<VnbDoc>(docId)!;
return c.json(rentalToRow(updated.rentals[id]));
};
}
routes.post("/api/rentals/:id/accept", rentalTransition('accepted', 'respondedAt'));
routes.post("/api/rentals/:id/decline", rentalTransition('declined', 'respondedAt'));
routes.post("/api/rentals/:id/cancel", rentalTransition('cancelled', 'cancelledAt'));
routes.post("/api/rentals/:id/complete", rentalTransition('completed', 'completedAt'));
// ── Rental messages ──
routes.post("/api/rentals/: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 = vnbDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.rentals[id]) return c.json({ error: "Rental not found" }, 404);
const msgId = crypto.randomUUID();
_syncServer!.changeDoc<VnbDoc>(docId, `add message to rental ${id}`, (d) => {
d.rentals[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<VnbDoc>(docId)!;
return c.json(rentalToRow(updated.rentals[id]));
});
// ── API: Endorsements ──
routes.get("/api/endorsements", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const { vehicle_id, subject_did, direction } = c.req.query();
const doc = ensureDoc(dataSpace);
let endorsements = Object.values(doc.endorsements);
if (vehicle_id) endorsements = endorsements.filter(e => e.vehicleId === vehicle_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.rental_id || !body.body?.trim()) {
return c.json({ error: "rental_id and body required" }, 400);
}
const docId = vnbDocId(dataSpace);
const doc = ensureDoc(dataSpace);
const rental = doc.rentals[body.rental_id];
if (!rental) return c.json({ error: "Rental not found" }, 404);
if (rental.status !== 'completed' && rental.status !== 'endorsed') {
return c.json({ error: "Can only endorse completed rentals" }, 400);
}
const endorsementId = crypto.randomUUID();
const now = Date.now();
_syncServer!.changeDoc<VnbDoc>(docId, `create endorsement ${endorsementId}`, (d) => {
d.endorsements[endorsementId] = {
id: endorsementId,
rentalId: body.rental_id,
vehicleId: rental.vehicleId,
authorDid: body.author_did || '',
authorName: body.author_name || 'Anonymous',
subjectDid: body.subject_did || '',
subjectName: body.subject_name || '',
direction: body.direction || 'renter_to_owner',
body: body.body.trim(),
rating: body.rating ?? null,
tags: body.tags || [],
visibility: body.visibility || 'public',
trustWeight: body.trust_weight ?? 0.5,
createdAt: now,
};
// Update rental status to endorsed
if (d.rentals[body.rental_id].status === 'completed') {
d.rentals[body.rental_id].status = 'endorsed';
}
});
const updated = _syncServer!.getDoc<VnbDoc>(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 asOwner = endorsements.filter(e => e.direction === 'renter_to_owner');
const asRenter = endorsements.filter(e => e.direction === 'owner_to_renter');
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_owner: {
count: asOwner.length,
avg_rating: avgRating(asOwner),
tags: tagCounts(asOwner),
avg_trust_weight: asOwner.length ? asOwner.reduce((s, e) => s + e.trustWeight, 0) / asOwner.length : 0,
},
as_renter: {
count: asRenter.length,
avg_rating: avgRating(asRenter),
tags: tagCounts(asRenter),
avg_trust_weight: asRenter.length ? asRenter.reduce((s, e) => s + e.trustWeight, 0) / asRenter.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, sleeps, lat, lng, radius, pickup_date, dropoff_date } = c.req.query();
const doc = ensureDoc(dataSpace);
let vehicles = Object.values(doc.vehicles).filter(v => v.isActive);
// Text search
if (q) {
const term = q.toLowerCase();
vehicles = vehicles.filter(v =>
v.title.toLowerCase().includes(term) ||
v.description.toLowerCase().includes(term) ||
v.pickupLocationName.toLowerCase().includes(term) ||
v.ownerName.toLowerCase().includes(term) ||
(v.make || '').toLowerCase().includes(term) ||
(v.model || '').toLowerCase().includes(term)
);
}
if (type) vehicles = vehicles.filter(v => v.type === type);
if (economy) vehicles = vehicles.filter(v => v.economy === economy);
if (sleeps) {
const s = parseInt(sleeps);
vehicles = vehicles.filter(v => v.sleeps >= s);
}
// Location proximity
if (lat && lng && radius) {
const cLat = parseFloat(lat);
const cLng = parseFloat(lng);
const r = parseFloat(radius);
vehicles = vehicles.filter(v => {
if (!v.pickupLocationLat || !v.pickupLocationLng) return false;
const dLat = (v.pickupLocationLat - cLat) * Math.PI / 180;
const dLng = (v.pickupLocationLng - cLng) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(cLat * Math.PI / 180) * Math.cos(v.pickupLocationLat * 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 (pickup_date && dropoff_date) {
const puMs = new Date(pickup_date).getTime();
const doMs = new Date(dropoff_date).getTime();
const availability = Object.values(doc.availability);
vehicles = vehicles.filter(v => {
const windows = availability.filter(a =>
a.vehicleId === v.id && a.status === 'available'
);
return windows.some(w => w.startDate <= puMs && w.endDate >= doMs);
});
}
const rows = vehicles.map(vehicleToRow);
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 vehicles = Object.values(doc.vehicles);
const rentals = Object.values(doc.rentals);
const endorsements = Object.values(doc.endorsements);
return c.json({
vehicles: vehicles.length,
active_vehicles: vehicles.filter(v => v.isActive).length,
rentals: rentals.length,
pending_rentals: rentals.filter(r => r.status === 'pending').length,
completed_rentals: rentals.filter(r => r.status === 'completed' || r.status === 'endorsed').length,
endorsements: endorsements.length,
economy_breakdown: {
gift: vehicles.filter(v => v.economy === 'gift').length,
suggested: vehicles.filter(v => v.economy === 'suggested').length,
fixed: vehicles.filter(v => v.economy === 'fixed').length,
sliding_scale: vehicles.filter(v => v.economy === 'sliding_scale').length,
exchange: vehicles.filter(v => v.economy === 'exchange').length,
},
type_breakdown: {
motorhome: vehicles.filter(v => v.type === 'motorhome').length,
camper_van: vehicles.filter(v => v.type === 'camper_van').length,
travel_trailer: vehicles.filter(v => v.type === 'travel_trailer').length,
truck_camper: vehicles.filter(v => v.type === 'truck_camper').length,
skoolie: vehicles.filter(v => v.type === 'skoolie').length,
other: vehicles.filter(v => v.type === 'other').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 = vnbDocId(dataSpace);
ensureDoc(dataSpace);
_syncServer!.changeDoc<VnbDoc>(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.endorsement_tag_catalog) d.config.endorsementTagCatalog = body.endorsement_tag_catalog;
if (body.require_endorsement !== undefined) d.config.requireEndorsement = body.require_endorsement;
if (body.max_rental_days !== undefined) d.config.maxRentalDays = body.max_rental_days;
});
const updated = _syncServer!.getDoc<VnbDoc>(docId)!;
return c.json(updated.config);
});
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${space} — RV Sharing | rSpace`,
moduleId: "rvnb",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-vnb-view space="${space}"></folk-vnb-view>`,
scripts: `<script type="module" src="/modules/rvnb/folk-vnb-view.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rvnb/rvnb.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">`,
}));
});
// ── Module export ──
export const vnbModule: RSpaceModule = {
id: "rvnb",
name: "rVnb",
icon: "\u{1F690}",
description: "Can't afford a house? Live in a van down by the river. Camper rental, lending & parking.",
scoping: { defaultScope: 'space', userConfigurable: true },
docSchemas: [{ pattern: '{space}:vnb:vehicles', description: 'Vehicles, rentals, endorsements', init: vnbSchema.init }],
routes,
standaloneDomain: "rvnb.online",
landingPage: renderLanding,
seedTemplate: seedDemoIfEmpty,
async onInit(ctx) {
_syncServer = ctx.syncServer;
seedDemoIfEmpty("demo");
},
feeds: [
{
id: "vehicles",
name: "Vehicles",
kind: "data",
description: "RV and camper listings with specs, location, and mileage policy",
filterable: true,
},
{
id: "rentals",
name: "Rentals",
kind: "data",
description: "Rental requests and their status (pending, accepted, completed)",
},
{
id: "endorsements",
name: "Endorsements",
kind: "trust",
description: "Trust-based endorsements from completed trips, feeds rNetwork",
},
{
id: "rental-value",
name: "Rental Value",
kind: "economic",
description: "Economic value of rentals (contributions, exchanges, gifts)",
},
],
acceptsFeeds: ["data", "trust", "economic"],
outputPaths: [
{ path: "vehicles", name: "Vehicles", icon: "\u{1F690}", description: "Community vehicle listings" },
{ path: "rentals", name: "Rentals", icon: "\u{1F697}", description: "Rental requests and history" },
{ path: "endorsements", name: "Endorsements", icon: "\u{2B50}", description: "Trust endorsements from trips" },
],
};