/** * 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(docId); if (!doc) { doc = Automerge.change(Automerge.init(), '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(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(), '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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(docId); if (!doc || !doc.packingItems[packingId]) continue; const body = await c.req.json(); _syncServer!.changeDoc(docId, 'toggle packing item', (d) => { d.packingItems[packingId].packed = body.packed ?? false; }); const updated = _syncServer!.getDoc(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: ``, body: ``, scripts: ``, })); }); // ── 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: ``, scripts: ``, styles: ``, })); }); 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" }, ], };