/** * Trips module — collaborative trip planner. * * Plan trips with destinations, itinerary, bookings, expenses, * and packing lists. Collaborative with role-based access. */ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { sql } from "../../shared/db/pool"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; const routes = new Hono(); // ── DB initialization ── const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8"); async function initDB() { try { await sql.unsafe(SCHEMA_SQL); console.log("[Trips] DB schema initialized"); } catch (e) { console.error("[Trips] DB init error:", e); } } initDB(); // ── API: Trips ── // GET /api/trips — list trips routes.get("/api/trips", async (c) => { const rows = await sql.unsafe( `SELECT t.*, count(DISTINCT d.id)::int as destination_count, count(DISTINCT e.id)::int as expense_count, coalesce(sum(e.amount), 0)::numeric as total_spent FROM rtrips.trips t LEFT JOIN rtrips.destinations d ON d.trip_id = t.id LEFT JOIN rtrips.expenses e ON e.trip_id = t.id GROUP BY t.id ORDER BY t.created_at DESC` ); 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 slug = title.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); const rows = await sql.unsafe( `INSERT INTO rtrips.trips (title, slug, description, start_date, end_date, budget_total, budget_currency, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, [title.trim(), slug, description || null, start_date || null, end_date || null, budget_total || null, budget_currency || "USD", claims.sub] ); return c.json(rows[0], 201); }); // GET /api/trips/:id — trip detail with all sub-resources routes.get("/api/trips/:id", async (c) => { const id = c.req.param("id"); const trip = await sql.unsafe("SELECT * FROM rtrips.trips WHERE id = $1", [id]); if (trip.length === 0) return c.json({ error: "Trip not found" }, 404); const [destinations, itinerary, bookings, expenses, packing] = await Promise.all([ sql.unsafe("SELECT * FROM rtrips.destinations WHERE trip_id = $1 ORDER BY sort_order", [id]), sql.unsafe("SELECT * FROM rtrips.itinerary_items WHERE trip_id = $1 ORDER BY date, sort_order", [id]), sql.unsafe("SELECT * FROM rtrips.bookings WHERE trip_id = $1 ORDER BY start_date", [id]), sql.unsafe("SELECT * FROM rtrips.expenses WHERE trip_id = $1 ORDER BY date DESC", [id]), sql.unsafe("SELECT * FROM rtrips.packing_items WHERE trip_id = $1 ORDER BY category, sort_order", [id]), ]); return c.json({ ...trip[0], destinations, itinerary, bookings, expenses, packing }); }); // PUT /api/trips/:id — update trip routes.put("/api/trips/:id", async (c) => { const id = c.req.param("id"); const body = await c.req.json(); const { title, description, start_date, end_date, budget_total, budget_currency, status } = body; const fields: string[] = []; const params: unknown[] = []; let idx = 1; if (title !== undefined) { fields.push(`title = $${idx}`); params.push(title); idx++; } if (description !== undefined) { fields.push(`description = $${idx}`); params.push(description); idx++; } if (start_date !== undefined) { fields.push(`start_date = $${idx}`); params.push(start_date); idx++; } if (end_date !== undefined) { fields.push(`end_date = $${idx}`); params.push(end_date); idx++; } if (budget_total !== undefined) { fields.push(`budget_total = $${idx}`); params.push(budget_total); idx++; } if (budget_currency !== undefined) { fields.push(`budget_currency = $${idx}`); params.push(budget_currency); idx++; } if (status !== undefined) { fields.push(`status = $${idx}`); params.push(status); idx++; } if (fields.length === 0) return c.json({ error: "No fields" }, 400); fields.push("updated_at = NOW()"); params.push(id); const rows = await sql.unsafe( `UPDATE rtrips.trips SET ${fields.join(", ")} WHERE id = $${idx} RETURNING *`, params ); if (rows.length === 0) return c.json({ error: "Not found" }, 404); return c.json(rows[0]); }); // ── 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 body = await c.req.json(); const rows = await sql.unsafe( `INSERT INTO rtrips.destinations (trip_id, name, country, lat, lng, arrival_date, departure_date, notes, sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`, [c.req.param("id"), body.name, body.country || null, body.lat || null, body.lng || null, body.arrival_date || null, body.departure_date || null, body.notes || null, body.sort_order ?? 0] ); return c.json(rows[0], 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 body = await c.req.json(); const rows = await sql.unsafe( `INSERT INTO rtrips.itinerary_items (trip_id, destination_id, title, category, date, start_time, end_time, notes, sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`, [c.req.param("id"), body.destination_id || null, body.title, body.category || "ACTIVITY", body.date || null, body.start_time || null, body.end_time || null, body.notes || null, body.sort_order ?? 0] ); return c.json(rows[0], 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 body = await c.req.json(); const rows = await sql.unsafe( `INSERT INTO rtrips.bookings (trip_id, type, provider, confirmation_number, cost, currency, start_date, end_date, notes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`, [c.req.param("id"), body.type || "OTHER", body.provider || null, body.confirmation_number || null, body.cost || null, body.currency || "USD", body.start_date || null, body.end_date || null, body.notes || null] ); return c.json(rows[0], 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 body = await c.req.json(); const rows = await sql.unsafe( `INSERT INTO rtrips.expenses (trip_id, description, amount, currency, category, date, split_type) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, [c.req.param("id"), body.description, body.amount, body.currency || "USD", body.category || "OTHER", body.date || null, body.split_type || "EQUAL"] ); return c.json(rows[0], 201); }); // ── API: Packing ── routes.get("/api/trips/:id/packing", async (c) => { const rows = await sql.unsafe( "SELECT * FROM rtrips.packing_items WHERE trip_id = $1 ORDER BY category, sort_order", [c.req.param("id")] ); 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 body = await c.req.json(); const rows = await sql.unsafe( `INSERT INTO rtrips.packing_items (trip_id, name, category, quantity, sort_order) VALUES ($1, $2, $3, $4, $5) RETURNING *`, [c.req.param("id"), body.name, body.category || "GENERAL", body.quantity || 1, body.sort_order ?? 0] ); return c.json(rows[0], 201); }); routes.patch("/api/packing/:id", async (c) => { const body = await c.req.json(); const rows = await sql.unsafe( "UPDATE rtrips.packing_items SET packed = $1 WHERE id = $2 RETURNING *", [body.packed ?? false, c.req.param("id")] ); if (rows.length === 0) return c.json({ error: "Not found" }, 404); return c.json(rows[0]); }); // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ title: `${space} — Trips | rSpace`, moduleId: "trips", spaceSlug: space, modules: getModuleInfoList(), theme: "light", styles: ``, body: ``, scripts: ``, })); }); export const tripsModule: RSpaceModule = { id: "trips", name: "rTrips", icon: "\u{2708}\u{FE0F}", description: "Collaborative trip planner with itinerary, bookings, and expense splitting", routes, standaloneDomain: "rtrips.online", };