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