/** * 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(docId); if (!doc) { doc = Automerge.change(Automerge.init(), '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(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(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(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 = { 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(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(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(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(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(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(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(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(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(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(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(docId, `${statusTarget} rental ${id}`, (d) => { d.rentals[id].status = statusTarget; (d.rentals[id] as any)[timestampField] = Date.now(); }); const updated = _syncServer!.getDoc(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(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(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(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(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 = {}; 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(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(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: ``, scripts: ``, styles: ` `, })); }); // ── 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" }, ], };