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