rspace-online/modules/rtrips/mod.ts

487 lines
16 KiB
TypeScript

/**
* Trips module — collaborative trip planner.
*
* Plan trips with destinations, itinerary, bookings, expenses,
* and packing lists. Collaborative with role-based access.
*
* Data layer: Automerge documents via SyncServer.
* One document per trip: {space}:trips:trips:{tripId}
*/
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 {
tripSchema, tripDocId,
type TripDoc, type TripMeta, type Destination, type ItineraryItem,
type Booking, type Expense, type PackingItem,
} from './schemas';
let _syncServer: SyncServer | null = null;
const OSRM_URL = process.env.OSRM_URL || "http://osrm-backend:5000";
// ── Helpers ──
/** Generate a short random ID (collision-safe enough for sub-collections). */
function newId(): string {
return crypto.randomUUID().slice(0, 12);
}
/** Ensure a trip document exists; create it lazily if not. */
function ensureDoc(space: string, tripId: string): TripDoc {
const docId = tripDocId(space, tripId);
let doc = _syncServer!.getDoc<TripDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<TripDoc>(), 'init', (d) => {
const init = tripSchema.init();
d.meta = init.meta;
d.meta.spaceSlug = space;
d.trip = init.trip;
d.trip.id = tripId;
d.destinations = {};
d.itinerary = {};
d.bookings = {};
d.expenses = {};
d.packingItems = {};
});
_syncServer!.setDoc(docId, doc);
}
return doc;
}
/** List all trip doc IDs for a given space. */
function listTripDocIds(space: string): string[] {
const prefix = `${space}:trips:trips:`;
return _syncServer!.listDocs().filter((id) => id.startsWith(prefix));
}
const routes = new Hono();
// ── API: Trips ──
// GET /api/trips — list trips
routes.get("/api/trips", async (c) => {
const space = c.req.param("space") || "demo";
const docIds = listTripDocIds(space);
const rows = docIds.map((docId) => {
const doc = _syncServer!.getDoc<TripDoc>(docId);
if (!doc) return null;
const t = doc.trip;
const destinations = Object.values(doc.destinations);
const expenses = Object.values(doc.expenses);
const totalSpent = expenses.reduce((sum, e) => sum + (e.amount || 0), 0);
return {
...t,
destination_count: destinations.length,
expense_count: expenses.length,
total_spent: totalSpent,
};
}).filter(Boolean);
// Sort by createdAt descending (newest first)
rows.sort((a, b) => (b!.createdAt ?? 0) - (a!.createdAt ?? 0));
return c.json(rows);
});
// POST /api/trips — create trip
routes.post("/api/trips", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const body = await c.req.json();
const { title, description, start_date, end_date, budget_total, budget_currency } = body;
if (!title?.trim()) return c.json({ error: "Title required" }, 400);
const space = c.req.param("space") || "demo";
const tripId = newId();
const slug = title.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
const now = Date.now();
const docId = tripDocId(space, tripId);
let doc = Automerge.change(Automerge.init<TripDoc>(), 'create trip', (d) => {
const init = tripSchema.init();
d.meta = init.meta;
d.meta.spaceSlug = space;
d.meta.createdAt = now;
d.trip = {
id: tripId,
title: title.trim(),
slug,
description: description || '',
startDate: start_date || null,
endDate: end_date || null,
budgetTotal: budget_total ?? null,
budgetCurrency: budget_currency || 'USD',
status: 'planning',
createdBy: claims.sub,
createdAt: now,
updatedAt: now,
};
d.destinations = {};
d.itinerary = {};
d.bookings = {};
d.expenses = {};
d.packingItems = {};
});
_syncServer!.setDoc(docId, doc);
return c.json(doc.trip, 201);
});
// GET /api/trips/:id — trip detail with all sub-resources
routes.get("/api/trips/:id", async (c) => {
const space = c.req.param("space") || "demo";
const tripId = c.req.param("id");
const docId = tripDocId(space, tripId);
const doc = _syncServer!.getDoc<TripDoc>(docId);
if (!doc) return c.json({ error: "Trip not found" }, 404);
const destinations = Object.values(doc.destinations).sort((a, b) => a.sortOrder - b.sortOrder);
const itinerary = Object.values(doc.itinerary).sort((a, b) => {
const dateCmp = (a.date || '').localeCompare(b.date || '');
return dateCmp !== 0 ? dateCmp : a.sortOrder - b.sortOrder;
});
const bookings = Object.values(doc.bookings).sort((a, b) => (a.startDate || '').localeCompare(b.startDate || ''));
const expenses = Object.values(doc.expenses).sort((a, b) => (b.date || '').localeCompare(a.date || ''));
const packing = Object.values(doc.packingItems).sort((a, b) => {
const catCmp = (a.category || '').localeCompare(b.category || '');
return catCmp !== 0 ? catCmp : a.sortOrder - b.sortOrder;
});
return c.json({ ...doc.trip, destinations, itinerary, bookings, expenses, packing });
});
// PUT /api/trips/:id — update trip
routes.put("/api/trips/:id", async (c) => {
const space = c.req.param("space") || "demo";
const tripId = c.req.param("id");
const docId = tripDocId(space, tripId);
const doc = _syncServer!.getDoc<TripDoc>(docId);
if (!doc) return c.json({ error: "Not found" }, 404);
const body = await c.req.json();
const { title, description, start_date, end_date, budget_total, budget_currency, status } = body;
const hasFields = [title, description, start_date, end_date, budget_total, budget_currency, status]
.some((v) => v !== undefined);
if (!hasFields) return c.json({ error: "No fields" }, 400);
_syncServer!.changeDoc<TripDoc>(docId, 'update trip', (d) => {
if (title !== undefined) d.trip.title = title;
if (description !== undefined) d.trip.description = description;
if (start_date !== undefined) d.trip.startDate = start_date;
if (end_date !== undefined) d.trip.endDate = end_date;
if (budget_total !== undefined) d.trip.budgetTotal = budget_total;
if (budget_currency !== undefined) d.trip.budgetCurrency = budget_currency;
if (status !== undefined) d.trip.status = status;
d.trip.updatedAt = Date.now();
});
const updated = _syncServer!.getDoc<TripDoc>(docId);
return c.json(updated!.trip);
});
// ── API: Destinations ──
routes.post("/api/trips/:id/destinations", 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 tripId = c.req.param("id");
ensureDoc(space, tripId);
const docId = tripDocId(space, tripId);
const body = await c.req.json();
const destId = newId();
const now = Date.now();
_syncServer!.changeDoc<TripDoc>(docId, 'add destination', (d) => {
d.destinations[destId] = {
id: destId,
tripId,
name: body.name,
country: body.country || null,
lat: body.lat ?? null,
lng: body.lng ?? null,
arrivalDate: body.arrival_date || null,
departureDate: body.departure_date || null,
notes: body.notes || '',
sortOrder: body.sort_order ?? 0,
createdAt: now,
};
});
const updated = _syncServer!.getDoc<TripDoc>(docId);
return c.json(updated!.destinations[destId], 201);
});
// ── API: Itinerary ──
routes.post("/api/trips/:id/itinerary", 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 tripId = c.req.param("id");
ensureDoc(space, tripId);
const docId = tripDocId(space, tripId);
const body = await c.req.json();
const itemId = newId();
const now = Date.now();
_syncServer!.changeDoc<TripDoc>(docId, 'add itinerary item', (d) => {
d.itinerary[itemId] = {
id: itemId,
tripId,
destinationId: body.destination_id || null,
title: body.title,
category: body.category || 'ACTIVITY',
date: body.date || null,
startTime: body.start_time || null,
endTime: body.end_time || null,
notes: body.notes || '',
sortOrder: body.sort_order ?? 0,
createdAt: now,
};
});
const updated = _syncServer!.getDoc<TripDoc>(docId);
return c.json(updated!.itinerary[itemId], 201);
});
// ── API: Bookings ──
routes.post("/api/trips/:id/bookings", 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 tripId = c.req.param("id");
ensureDoc(space, tripId);
const docId = tripDocId(space, tripId);
const body = await c.req.json();
const bookingId = newId();
const now = Date.now();
_syncServer!.changeDoc<TripDoc>(docId, 'add booking', (d) => {
d.bookings[bookingId] = {
id: bookingId,
tripId,
type: body.type || 'OTHER',
provider: body.provider || null,
confirmationNumber: body.confirmation_number || null,
cost: body.cost ?? null,
currency: body.currency || 'USD',
startDate: body.start_date || null,
endDate: body.end_date || null,
status: null,
notes: body.notes || '',
createdAt: now,
};
});
const updated = _syncServer!.getDoc<TripDoc>(docId);
return c.json(updated!.bookings[bookingId], 201);
});
// ── API: Expenses ──
routes.post("/api/trips/:id/expenses", 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 tripId = c.req.param("id");
ensureDoc(space, tripId);
const docId = tripDocId(space, tripId);
const body = await c.req.json();
const expenseId = newId();
const now = Date.now();
_syncServer!.changeDoc<TripDoc>(docId, 'add expense', (d) => {
d.expenses[expenseId] = {
id: expenseId,
tripId,
paidBy: null,
description: body.description,
amount: body.amount,
currency: body.currency || 'USD',
category: body.category || 'OTHER',
date: body.date || null,
splitType: body.split_type || 'EQUAL',
createdAt: now,
};
});
const updated = _syncServer!.getDoc<TripDoc>(docId);
return c.json(updated!.expenses[expenseId], 201);
});
// ── API: Packing ──
routes.get("/api/trips/:id/packing", async (c) => {
const space = c.req.param("space") || "demo";
const tripId = c.req.param("id");
const docId = tripDocId(space, tripId);
const doc = _syncServer!.getDoc<TripDoc>(docId);
if (!doc) return c.json([]);
const rows = Object.values(doc.packingItems).sort((a, b) => {
const catCmp = (a.category || '').localeCompare(b.category || '');
return catCmp !== 0 ? catCmp : a.sortOrder - b.sortOrder;
});
return c.json(rows);
});
routes.post("/api/trips/:id/packing", 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 tripId = c.req.param("id");
ensureDoc(space, tripId);
const docId = tripDocId(space, tripId);
const body = await c.req.json();
const itemId = newId();
const now = Date.now();
_syncServer!.changeDoc<TripDoc>(docId, 'add packing item', (d) => {
d.packingItems[itemId] = {
id: itemId,
tripId,
addedBy: null,
name: body.name,
category: body.category || 'GENERAL',
packed: false,
quantity: body.quantity || 1,
sortOrder: body.sort_order ?? 0,
createdAt: now,
};
});
const updated = _syncServer!.getDoc<TripDoc>(docId);
return c.json(updated!.packingItems[itemId], 201);
});
routes.patch("/api/packing/:id", async (c) => {
const space = c.req.param("space") || "demo";
const packingId = c.req.param("id");
// Find the trip doc containing this packing item
const docIds = listTripDocIds(space);
for (const docId of docIds) {
const doc = _syncServer!.getDoc<TripDoc>(docId);
if (!doc || !doc.packingItems[packingId]) continue;
const body = await c.req.json();
_syncServer!.changeDoc<TripDoc>(docId, 'toggle packing item', (d) => {
d.packingItems[packingId].packed = body.packed ?? false;
});
const updated = _syncServer!.getDoc<TripDoc>(docId);
return c.json(updated!.packingItems[packingId]);
}
return c.json({ error: "Not found" }, 404);
});
// ── OSRM proxy for route planner ──
routes.post("/api/route", async (c) => {
const body = await c.req.json<{ start: { lng: number; lat: number }; end: { lng: number; lat: number } }>();
const { start, end } = body;
if (!start || !end) return c.json({ error: "start and end are required" }, 400);
const url = `${OSRM_URL}/route/v1/driving/${start.lng},${start.lat};${end.lng},${end.lat}?geometries=geojson&overview=full`;
try {
const res = await fetch(url);
const data = await res.json();
return c.json(data, res.status as any);
} catch (e) {
return c.json({ error: "OSRM backend unavailable", detail: String(e) }, 502);
}
});
// ── Route planner page ──
routes.get("/routes", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${space} — Route Planner | rTrips`,
moduleId: "rtrips",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
styles: `<link rel="stylesheet" href="/modules/rtrips/route-planner.css">`,
body: `<folk-route-planner></folk-route-planner>`,
scripts: `<script type="module" src="/modules/rtrips/folk-route-planner.js"></script>`,
}));
});
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${space} — Trips | rSpace`,
moduleId: "rtrips",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-trips-planner space="${space}"></folk-trips-planner>`,
scripts: `<script type="module" src="/modules/rtrips/folk-trips-planner.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rtrips/trips.css">`,
}));
});
export const tripsModule: RSpaceModule = {
id: "rtrips",
name: "rTrips",
icon: "✈️",
description: "Collaborative trip planner with itinerary, bookings, and expense splitting",
scoping: { defaultScope: 'global', userConfigurable: true },
docSchemas: [{ pattern: '{space}:trips:trips:{tripId}', description: 'Trip with destinations and itinerary', init: tripSchema.init }],
routes,
landingPage: renderLanding,
async onInit(ctx) {
_syncServer = ctx.syncServer;
},
standaloneDomain: "rtrips.online",
feeds: [
{
id: "trip-expenses",
name: "Trip Expenses",
kind: "economic",
description: "Expense tracking with split amounts per traveler",
filterable: true,
},
{
id: "itinerary",
name: "Itinerary",
kind: "data",
description: "Destinations, activities, and bookings timeline",
emits: ["folk-itinerary", "folk-destination", "folk-booking"],
},
],
acceptsFeeds: ["economic", "data"],
outputPaths: [
{ path: "itineraries", name: "Itineraries", icon: "🗓️", description: "Trip itineraries with bookings and activities" },
],
};