Initial release: collaborative trip planner
Multi-user trip planning web app in a single Docker container. Mullvad-style token accounts, trip sharing via join codes, day-by-day calendar with typed entries (activity, hotel, travel, flight, rental car, immigration, note), multi-leg flight segments with bundled IATA airport dataset, Leaflet/OSM map with per-leg great-circle km (air vs ground), rough km-driven vs rental included-km comparison, cost splitting with settle-up suggestions, flip-clock departure countdown. Node 20 + Express + SQLite (WAL, additive migrations), vanilla JS SPA, 44 API tests.
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import express from 'express';
|
||||
import cookieSession from 'cookie-session';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { openDb } from './db.js';
|
||||
import { requireAuth } from './auth.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import tripsRoutes from './routes/trips.js';
|
||||
import entriesRoutes from './routes/entries.js';
|
||||
import geocodeRoutes from './routes/geocode.js';
|
||||
import airportsRoutes from './routes/airports.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
|
||||
|
||||
// Build the Express app. Accepts either an options object
|
||||
// { dbPath, sessionSecret } or a bare dbPath string (per API.md).
|
||||
export function createApp(options = {}) {
|
||||
const opts = typeof options === 'string' ? { dbPath: options } : options;
|
||||
const dbPath = opts.dbPath || ':memory:';
|
||||
const sessionSecret = opts.sessionSecret || 'dev-secret';
|
||||
|
||||
const db = openDb(dbPath);
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
cookieSession({
|
||||
name: 'trip_session',
|
||||
secret: sessionSecret,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||
})
|
||||
);
|
||||
|
||||
// no-cache (not no-store): browsers must revalidate, ETags turn unchanged
|
||||
// files into 304s. Without this, upgraded deployments keep serving a stale
|
||||
// cached frontend against the new API.
|
||||
app.use(
|
||||
express.static(PUBLIC_DIR, {
|
||||
etag: true,
|
||||
lastModified: true,
|
||||
setHeaders: (res) => res.setHeader('Cache-Control', 'no-cache'),
|
||||
})
|
||||
);
|
||||
|
||||
app.use('/api/auth', authRoutes(db));
|
||||
app.use('/api/trips', requireAuth, tripsRoutes(db));
|
||||
app.use('/api', requireAuth, entriesRoutes(db)); // /trips/:id/entries + /entries/:id
|
||||
app.use('/api/geocode', requireAuth, geocodeRoutes(db));
|
||||
app.use('/api/airports', requireAuth, airportsRoutes());
|
||||
|
||||
// JSON 404 for any unmatched /api route.
|
||||
app.use('/api', (req, res) => {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
});
|
||||
|
||||
// JSON error handler (malformed body, unexpected failures).
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
if (err && err.type === 'entity.parse.failed') {
|
||||
return res.status(400).json({ error: 'invalid JSON body' });
|
||||
}
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'internal server error' });
|
||||
});
|
||||
|
||||
app.locals.db = db;
|
||||
return app;
|
||||
}
|
||||
|
||||
export default createApp;
|
||||
@@ -0,0 +1,8 @@
|
||||
// Session guard: rejects unauthenticated requests with 401 JSON.
|
||||
// Identity is Mullvad-style (account tokens); token hashing lives in util/token.js.
|
||||
export function requireAuth(req, res, next) {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'unauthorized' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,88 @@
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
token_hash TEXT UNIQUE NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT current_timestamp
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trips (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT NOT NULL,
|
||||
owner_id INTEGER NOT NULL REFERENCES users(id),
|
||||
currency TEXT NOT NULL DEFAULT 'USD',
|
||||
join_code TEXT UNIQUE NOT NULL,
|
||||
created_at TEXT DEFAULT current_timestamp
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trip_members (
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
role TEXT NOT NULL DEFAULT 'editor',
|
||||
PRIMARY KEY (trip_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
id INTEGER PRIMARY KEY,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id),
|
||||
date TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
details TEXT DEFAULT '',
|
||||
start_time TEXT,
|
||||
end_time TEXT,
|
||||
location_name TEXT,
|
||||
lat REAL,
|
||||
lng REAL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
price REAL,
|
||||
paid_by INTEGER REFERENCES users(id),
|
||||
split_mode TEXT NOT NULL DEFAULT 'equal',
|
||||
segments TEXT,
|
||||
rental TEXT,
|
||||
created_at TEXT DEFAULT current_timestamp
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entry_participants (
|
||||
entry_id INTEGER NOT NULL REFERENCES entries(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
PRIMARY KEY (entry_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_trip ON entries(trip_id, date, sort_order, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_members_user ON trip_members(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_participants_entry ON entry_participants(entry_id);
|
||||
`;
|
||||
|
||||
// Columns added after the initial release: CREATE TABLE IF NOT EXISTS never
|
||||
// alters existing tables, so databases created by an older version need them
|
||||
// backfilled with ALTER TABLE.
|
||||
const MIGRATIONS = [
|
||||
{ table: 'trips', column: 'currency', ddl: "ALTER TABLE trips ADD COLUMN currency TEXT NOT NULL DEFAULT 'USD'" },
|
||||
{ table: 'entries', column: 'price', ddl: 'ALTER TABLE entries ADD COLUMN price REAL' },
|
||||
{ table: 'entries', column: 'paid_by', ddl: 'ALTER TABLE entries ADD COLUMN paid_by INTEGER REFERENCES users(id)' },
|
||||
{ table: 'entries', column: 'split_mode', ddl: "ALTER TABLE entries ADD COLUMN split_mode TEXT NOT NULL DEFAULT 'equal'" },
|
||||
{ table: 'entries', column: 'segments', ddl: 'ALTER TABLE entries ADD COLUMN segments TEXT' },
|
||||
{ table: 'entries', column: 'rental', ddl: 'ALTER TABLE entries ADD COLUMN rental TEXT' },
|
||||
];
|
||||
|
||||
function applyMigrations(db) {
|
||||
for (const { table, column, ddl } of MIGRATIONS) {
|
||||
const columns = db.prepare(`PRAGMA table_info(${table})`).all();
|
||||
if (!columns.some((c) => c.name === column)) db.exec(ddl);
|
||||
}
|
||||
}
|
||||
|
||||
// Open (or create) the SQLite database at dbPath and ensure the schema exists.
|
||||
export function openDb(dbPath) {
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.exec(SCHEMA);
|
||||
applyMigrations(db);
|
||||
return db;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createApp } from './app.js';
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3000;
|
||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||
|
||||
let sessionSecret = process.env.SESSION_SECRET;
|
||||
if (!sessionSecret) {
|
||||
sessionSecret = 'trip-plan-dev-secret-change-me';
|
||||
console.warn(
|
||||
'[trip-plan] SESSION_SECRET is not set; using an insecure development default. ' +
|
||||
'Set SESSION_SECRET in production.'
|
||||
);
|
||||
}
|
||||
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const dbPath = path.join(DATA_DIR, 'trip-plan.db');
|
||||
|
||||
const app = createApp({ dbPath, sessionSecret });
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[trip-plan] listening on http://localhost:${PORT}`);
|
||||
console.log(`[trip-plan] data directory: ${path.resolve(DATA_DIR)}`);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import express from 'express';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_PATH = path.join(__dirname, '..', 'data', 'airports.json');
|
||||
const MAX_RESULTS = 8;
|
||||
|
||||
// Loaded once at startup (bundled, public-domain OurAirports subset).
|
||||
const AIRPORTS = JSON.parse(fs.readFileSync(DATA_PATH, 'utf8'));
|
||||
|
||||
export default function airportsRoutes() {
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/airports?q=<query>
|
||||
router.get('/', (req, res) => {
|
||||
const q = typeof req.query.q === 'string' ? req.query.q.trim() : '';
|
||||
if (!q) return res.status(200).json({ results: [] });
|
||||
const ql = q.toLowerCase();
|
||||
|
||||
// Rank: exact IATA, then IATA prefix, then name/city substring.
|
||||
const exact = [];
|
||||
const prefix = [];
|
||||
const substring = [];
|
||||
for (const a of AIRPORTS) {
|
||||
const code = a.code.toLowerCase();
|
||||
if (code === ql) {
|
||||
exact.push(a);
|
||||
} else if (code.startsWith(ql)) {
|
||||
prefix.push(a);
|
||||
} else if (
|
||||
(a.name && a.name.toLowerCase().includes(ql)) ||
|
||||
(a.city && a.city.toLowerCase().includes(ql))
|
||||
) {
|
||||
substring.push(a);
|
||||
}
|
||||
if (exact.length >= MAX_RESULTS) break;
|
||||
}
|
||||
|
||||
const results = [...exact, ...prefix, ...substring].slice(0, MAX_RESULTS);
|
||||
res.status(200).json({ results });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import express from 'express';
|
||||
import { requireAuth } from '../auth.js';
|
||||
import {
|
||||
generateAccountToken,
|
||||
formatToken,
|
||||
hashToken,
|
||||
} from '../util/token.js';
|
||||
import { generateDisplayName } from '../util/names.js';
|
||||
|
||||
export default function authRoutes(db) {
|
||||
const router = express.Router();
|
||||
|
||||
const insertUser = db.prepare(
|
||||
'INSERT INTO users (token_hash, display_name) VALUES (?, ?)'
|
||||
);
|
||||
const findByHash = db.prepare(
|
||||
'SELECT id, display_name FROM users WHERE token_hash = ?'
|
||||
);
|
||||
const findById = db.prepare('SELECT id, display_name FROM users WHERE id = ?');
|
||||
const hashExists = db.prepare('SELECT 1 FROM users WHERE token_hash = ?');
|
||||
|
||||
// POST /api/auth/account — create an account, log in, return the raw token once.
|
||||
router.post('/account', (req, res) => {
|
||||
let raw;
|
||||
let hash;
|
||||
do {
|
||||
raw = generateAccountToken();
|
||||
hash = hashToken(raw);
|
||||
} while (hashExists.get(hash));
|
||||
|
||||
const displayName = generateDisplayName();
|
||||
const info = insertUser.run(hash, displayName);
|
||||
const user = { id: Number(info.lastInsertRowid), display_name: displayName };
|
||||
req.session.userId = user.id;
|
||||
res.status(201).json({ user, token: formatToken(raw) });
|
||||
});
|
||||
|
||||
// POST /api/auth/login — { token }
|
||||
router.post('/login', (req, res) => {
|
||||
const { token } = req.body || {};
|
||||
if (typeof token !== 'string' || token.trim() === '') {
|
||||
return res.status(401).json({ error: 'invalid token' });
|
||||
}
|
||||
const user = findByHash.get(hashToken(token));
|
||||
if (!user) return res.status(401).json({ error: 'invalid token' });
|
||||
req.session.userId = user.id;
|
||||
res.status(200).json({ user });
|
||||
});
|
||||
|
||||
// POST /api/auth/logout
|
||||
router.post('/logout', (req, res) => {
|
||||
req.session = null;
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// GET /api/auth/me
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
const user = findById.get(req.session.userId);
|
||||
if (!user) {
|
||||
req.session = null;
|
||||
return res.status(401).json({ error: 'unauthorized' });
|
||||
}
|
||||
res.status(200).json({ user });
|
||||
});
|
||||
|
||||
// PATCH /api/auth/me — { display_name }
|
||||
router.patch('/me', requireAuth, (req, res) => {
|
||||
const { display_name } = req.body || {};
|
||||
if (typeof display_name !== 'string') {
|
||||
return res.status(400).json({ error: 'display_name is required' });
|
||||
}
|
||||
const trimmed = display_name.trim();
|
||||
if (trimmed.length < 1 || trimmed.length > 40) {
|
||||
return res.status(400).json({ error: 'display_name must be 1-40 characters' });
|
||||
}
|
||||
db.prepare('UPDATE users SET display_name = ? WHERE id = ?').run(
|
||||
trimmed,
|
||||
req.session.userId
|
||||
);
|
||||
res.status(200).json({ user: findById.get(req.session.userId) });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import express from 'express';
|
||||
import { isValidDateStr } from '../util/dates.js';
|
||||
import { membership } from '../util/access.js';
|
||||
import { ENTRY_COLUMNS, attachParticipants } from '../util/entrySerialize.js';
|
||||
import { validateSegments } from '../util/segments.js';
|
||||
import { validateRental } from '../util/rental.js';
|
||||
|
||||
const ENTRY_TYPES = new Set([
|
||||
'flight',
|
||||
'immigration',
|
||||
'travel',
|
||||
'hotel',
|
||||
'activity',
|
||||
'rental',
|
||||
'note',
|
||||
]);
|
||||
const SPLIT_MODES = new Set(['equal', 'own', 'payer']);
|
||||
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
function validTime(v) {
|
||||
return v === null || v === undefined || (typeof v === 'string' && TIME_RE.test(v));
|
||||
}
|
||||
|
||||
function validCoord(v, min, max) {
|
||||
return typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max;
|
||||
}
|
||||
|
||||
// Validate an entry body. `partial` = true for PATCH (only provided keys checked).
|
||||
// `existing` is the current row (PATCH merges for the payer/paid_by rule).
|
||||
// `memberIds` is a Set of the trip's member user ids.
|
||||
// Returns { error } or { fields, participants, hasParticipants } where
|
||||
// participants is null (= all members) or an array of ids.
|
||||
function validateEntry(body, { partial, existing, memberIds }) {
|
||||
const fields = {};
|
||||
const has = (k) => k in body;
|
||||
|
||||
if (!partial || has('type')) {
|
||||
if (!ENTRY_TYPES.has(body.type)) return { error: 'invalid entry type' };
|
||||
fields.type = body.type;
|
||||
}
|
||||
if (!partial || has('date')) {
|
||||
if (!isValidDateStr(body.date)) {
|
||||
return { error: 'date must be a valid YYYY-MM-DD date' };
|
||||
}
|
||||
fields.date = body.date;
|
||||
}
|
||||
if (!partial || has('title')) {
|
||||
if (typeof body.title !== 'string' || body.title.trim() === '') {
|
||||
return { error: 'title is required' };
|
||||
}
|
||||
if (body.title.length > 200) {
|
||||
return { error: 'title must be at most 200 characters' };
|
||||
}
|
||||
fields.title = body.title.trim();
|
||||
}
|
||||
if (has('details')) {
|
||||
if (typeof body.details !== 'string') {
|
||||
return { error: 'details must be a string' };
|
||||
}
|
||||
fields.details = body.details;
|
||||
}
|
||||
for (const key of ['start_time', 'end_time']) {
|
||||
if (has(key)) {
|
||||
if (!validTime(body[key])) return { error: `${key} must be HH:MM` };
|
||||
fields[key] = body[key] ?? null;
|
||||
}
|
||||
}
|
||||
if (has('location_name')) {
|
||||
const v = body.location_name;
|
||||
if (v !== null && typeof v !== 'string') {
|
||||
return { error: 'location_name must be a string' };
|
||||
}
|
||||
fields.location_name = v ?? null;
|
||||
}
|
||||
|
||||
// lat/lng: both present or both absent.
|
||||
const hasLat = has('lat');
|
||||
const hasLng = has('lng');
|
||||
if (hasLat !== hasLng) {
|
||||
return { error: 'lat and lng must both be present or both absent' };
|
||||
}
|
||||
if (hasLat && hasLng) {
|
||||
const bothNull = body.lat === null && body.lng === null;
|
||||
if (!bothNull) {
|
||||
if (!validCoord(body.lat, -90, 90)) return { error: 'lat must be in [-90, 90]' };
|
||||
if (!validCoord(body.lng, -180, 180)) {
|
||||
return { error: 'lng must be in [-180, 180]' };
|
||||
}
|
||||
}
|
||||
fields.lat = bothNull ? null : body.lat;
|
||||
fields.lng = bothNull ? null : body.lng;
|
||||
}
|
||||
|
||||
if (has('sort_order')) {
|
||||
if (!Number.isInteger(body.sort_order)) {
|
||||
return { error: 'sort_order must be an integer' };
|
||||
}
|
||||
fields.sort_order = body.sort_order;
|
||||
}
|
||||
|
||||
// price: null or a number >= 0.
|
||||
if (has('price')) {
|
||||
const v = body.price;
|
||||
if (v !== null && !(typeof v === 'number' && Number.isFinite(v) && v >= 0)) {
|
||||
return { error: 'price must be null or a number >= 0' };
|
||||
}
|
||||
fields.price = v;
|
||||
}
|
||||
|
||||
// paid_by: null or a trip-member user id.
|
||||
if (has('paid_by')) {
|
||||
const v = body.paid_by;
|
||||
if (v !== null && !(Number.isInteger(v) && memberIds.has(v))) {
|
||||
return { error: 'paid_by must be null or a trip member id' };
|
||||
}
|
||||
fields.paid_by = v;
|
||||
}
|
||||
|
||||
// split_mode: enum.
|
||||
if (has('split_mode')) {
|
||||
if (!SPLIT_MODES.has(body.split_mode)) {
|
||||
return { error: 'split_mode must be one of equal, own, payer' };
|
||||
}
|
||||
fields.split_mode = body.split_mode;
|
||||
}
|
||||
|
||||
// 'payer' requires an effective paid_by (merging existing values on PATCH).
|
||||
const effMode = 'split_mode' in fields ? fields.split_mode : existing?.split_mode ?? 'equal';
|
||||
const effPaidBy = 'paid_by' in fields ? fields.paid_by : existing?.paid_by ?? null;
|
||||
if (effMode === 'payer' && (effPaidBy === null || effPaidBy === undefined)) {
|
||||
return { error: "split_mode 'payer' requires paid_by" };
|
||||
}
|
||||
|
||||
// participants: null/[] (= all members) or array of trip-member ids.
|
||||
let participants;
|
||||
if (has('participants')) {
|
||||
const v = body.participants;
|
||||
if (v === null || (Array.isArray(v) && v.length === 0)) {
|
||||
participants = null;
|
||||
} else if (Array.isArray(v)) {
|
||||
for (const id of v) {
|
||||
if (!Number.isInteger(id) || !memberIds.has(id)) {
|
||||
return { error: 'participants must be trip member ids' };
|
||||
}
|
||||
}
|
||||
participants = [...new Set(v)];
|
||||
} else {
|
||||
return { error: 'participants must be null or an array of member ids' };
|
||||
}
|
||||
}
|
||||
|
||||
// segments: flight-only structured itinerary (null clears them).
|
||||
if (has('segments')) {
|
||||
if (body.segments === null) {
|
||||
fields.segments = null;
|
||||
} else {
|
||||
const effType = 'type' in fields ? fields.type : existing?.type;
|
||||
if (effType !== 'flight') {
|
||||
return { error: 'segments are only allowed on flight entries' };
|
||||
}
|
||||
const checked = validateSegments(body.segments);
|
||||
if (checked.error) return { error: checked.error };
|
||||
fields.segments = JSON.stringify(checked.value);
|
||||
}
|
||||
}
|
||||
|
||||
// rental: rental-only structured details (null clears them).
|
||||
if (has('rental')) {
|
||||
if (body.rental === null) {
|
||||
fields.rental = null;
|
||||
} else {
|
||||
const effType = 'type' in fields ? fields.type : existing?.type;
|
||||
if (effType !== 'rental') {
|
||||
return { error: 'rental details are only allowed on rental entries' };
|
||||
}
|
||||
const checked = validateRental(body.rental);
|
||||
if (checked.error) return { error: checked.error };
|
||||
fields.rental = JSON.stringify(checked.value);
|
||||
}
|
||||
}
|
||||
|
||||
return { fields, participants, hasParticipants: has('participants') };
|
||||
}
|
||||
|
||||
export default function entriesRoutes(db) {
|
||||
const router = express.Router();
|
||||
|
||||
const getEntry = db.prepare(`SELECT ${ENTRY_COLUMNS} FROM entries WHERE id = ?`);
|
||||
const getTripEntries = db.prepare(
|
||||
`SELECT ${ENTRY_COLUMNS} FROM entries WHERE trip_id = ? ORDER BY date, sort_order, id`
|
||||
);
|
||||
const getEntryRow = db.prepare(
|
||||
'SELECT trip_id, split_mode, paid_by, type FROM entries WHERE id = ?'
|
||||
);
|
||||
const getMemberIds = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?');
|
||||
const insertParticipant = db.prepare(
|
||||
'INSERT OR IGNORE INTO entry_participants (entry_id, user_id) VALUES (?, ?)'
|
||||
);
|
||||
const clearParticipants = db.prepare('DELETE FROM entry_participants WHERE entry_id = ?');
|
||||
|
||||
const memberIdSet = (tripId) => new Set(getMemberIds.all(tripId).map((r) => r.user_id));
|
||||
|
||||
function writeParticipants(entryId, participants) {
|
||||
clearParticipants.run(entryId);
|
||||
if (Array.isArray(participants)) {
|
||||
for (const uid of participants) insertParticipant.run(entryId, uid);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/trips/:id/entries
|
||||
router.get('/trips/:id/entries', (req, res) => {
|
||||
const tripId = Number(req.params.id);
|
||||
if (!Number.isInteger(tripId) || !membership(db, tripId, req.session.userId)) {
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
const entries = getTripEntries.all(tripId).map((r) => attachParticipants(db, r));
|
||||
res.status(200).json({ entries });
|
||||
});
|
||||
|
||||
// POST /api/trips/:id/entries
|
||||
router.post('/trips/:id/entries', (req, res) => {
|
||||
const tripId = Number(req.params.id);
|
||||
if (!Number.isInteger(tripId) || !membership(db, tripId, req.session.userId)) {
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
const check = validateEntry(req.body || {}, {
|
||||
partial: false,
|
||||
existing: null,
|
||||
memberIds: memberIdSet(tripId),
|
||||
});
|
||||
if (check.error) return res.status(400).json({ error: check.error });
|
||||
const f = check.fields;
|
||||
|
||||
const entry = db.transaction(() => {
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO entries
|
||||
(trip_id, date, type, title, details, start_time, end_time,
|
||||
location_name, lat, lng, sort_order, price, paid_by, split_mode, segments, rental)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
tripId,
|
||||
f.date,
|
||||
f.type,
|
||||
f.title,
|
||||
f.details ?? '',
|
||||
f.start_time ?? null,
|
||||
f.end_time ?? null,
|
||||
f.location_name ?? null,
|
||||
f.lat ?? null,
|
||||
f.lng ?? null,
|
||||
f.sort_order ?? 0,
|
||||
f.price ?? null,
|
||||
f.paid_by ?? null,
|
||||
f.split_mode ?? 'equal',
|
||||
f.segments ?? null,
|
||||
f.rental ?? null
|
||||
);
|
||||
const id = Number(info.lastInsertRowid);
|
||||
// participants provided as an array -> store rows; null/absent -> all members.
|
||||
if (Array.isArray(check.participants)) writeParticipants(id, check.participants);
|
||||
return attachParticipants(db, getEntry.get(id));
|
||||
})();
|
||||
|
||||
res.status(201).json({ entry });
|
||||
});
|
||||
|
||||
// PATCH /api/entries/:id
|
||||
router.patch('/entries/:id', (req, res) => {
|
||||
const entryId = Number(req.params.id);
|
||||
const row = Number.isInteger(entryId) ? getEntryRow.get(entryId) : null;
|
||||
if (!row || !membership(db, row.trip_id, req.session.userId)) {
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
const check = validateEntry(req.body || {}, {
|
||||
partial: true,
|
||||
existing: row,
|
||||
memberIds: memberIdSet(row.trip_id),
|
||||
});
|
||||
if (check.error) return res.status(400).json({ error: check.error });
|
||||
|
||||
const entry = db.transaction(() => {
|
||||
const keys = Object.keys(check.fields);
|
||||
if (keys.length > 0) {
|
||||
const setClause = keys.map((k) => `${k} = ?`).join(', ');
|
||||
const values = keys.map((k) => check.fields[k]);
|
||||
db.prepare(`UPDATE entries SET ${setClause} WHERE id = ?`).run(...values, entryId);
|
||||
}
|
||||
// participants key present -> replace the whole set (null/[] = all members).
|
||||
if (check.hasParticipants) writeParticipants(entryId, check.participants);
|
||||
return attachParticipants(db, getEntry.get(entryId));
|
||||
})();
|
||||
|
||||
res.status(200).json({ entry });
|
||||
});
|
||||
|
||||
// DELETE /api/entries/:id
|
||||
router.delete('/entries/:id', (req, res) => {
|
||||
const entryId = Number(req.params.id);
|
||||
const row = Number.isInteger(entryId) ? getEntryRow.get(entryId) : null;
|
||||
if (!row || !membership(db, row.trip_id, req.session.userId)) {
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
db.transaction(() => {
|
||||
clearParticipants.run(entryId);
|
||||
db.prepare('DELETE FROM entries WHERE id = ?').run(entryId);
|
||||
})();
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import express from 'express';
|
||||
|
||||
const NOMINATIM_URL = 'https://nominatim.openstreetmap.org/search';
|
||||
const USER_AGENT = 'trip-plan-app/0.1 (self-hosted)';
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const MAX_RESULTS = 5;
|
||||
|
||||
export default function geocodeRoutes() {
|
||||
const router = express.Router();
|
||||
const cache = new Map(); // query -> { at, results }
|
||||
|
||||
// GET /api/geocode?q=...
|
||||
router.get('/', async (req, res) => {
|
||||
const q = typeof req.query.q === 'string' ? req.query.q.trim() : '';
|
||||
if (!q) return res.status(400).json({ error: 'query parameter q is required' });
|
||||
|
||||
const cached = cache.get(q);
|
||||
if (cached && Date.now() - cached.at < CACHE_TTL_MS) {
|
||||
return res.status(200).json({ results: cached.results });
|
||||
}
|
||||
|
||||
const url = `${NOMINATIM_URL}?format=jsonv2&limit=${MAX_RESULTS}&accept-language=en&q=${encodeURIComponent(q)}`;
|
||||
let data;
|
||||
try {
|
||||
const upstream = await fetch(url, {
|
||||
headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
|
||||
});
|
||||
if (!upstream.ok) throw new Error(`upstream status ${upstream.status}`);
|
||||
data = await upstream.json();
|
||||
} catch {
|
||||
return res.status(502).json({ error: 'geocoding unavailable' });
|
||||
}
|
||||
|
||||
const results = (Array.isArray(data) ? data : [])
|
||||
.slice(0, MAX_RESULTS)
|
||||
.map((r) => ({
|
||||
name: r.display_name,
|
||||
lat: Number(r.lat),
|
||||
lng: Number(r.lon),
|
||||
}))
|
||||
.filter((r) => Number.isFinite(r.lat) && Number.isFinite(r.lng));
|
||||
|
||||
cache.set(q, { at: Date.now(), results });
|
||||
res.status(200).json({ results });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import express from 'express';
|
||||
import { isValidDateStr, daysInclusive } from '../util/dates.js';
|
||||
import { haversineKm } from '../util/distance.js';
|
||||
import { membership } from '../util/access.js';
|
||||
import { computeCosts } from '../util/costs.js';
|
||||
import {
|
||||
ENTRY_COLUMNS,
|
||||
attachParticipantsAll,
|
||||
parseSegments,
|
||||
parseRental,
|
||||
} from '../util/entrySerialize.js';
|
||||
import { generateJoinCode, formatJoinCode, normalizeCode } from '../util/token.js';
|
||||
|
||||
const MAX_RANGE_DAYS = 365;
|
||||
const MIN_LEG_KM = 0.05;
|
||||
const CURRENCY_RE = /^[A-Z]{3}$/;
|
||||
|
||||
// Trip JSON with the join_code shown in grouped display form.
|
||||
function tripJson(row) {
|
||||
if (!row) return row;
|
||||
return { ...row, join_code: formatJoinCode(row.join_code) };
|
||||
}
|
||||
|
||||
// Airport stops derived from a flight entry's coordinate-bearing segments:
|
||||
// `from` of the first segment then `to` of each, skipping coordless airports
|
||||
// and collapsing consecutive duplicate coordinates. Returns null if none.
|
||||
function airportStopsFor(entry) {
|
||||
const segs = entry.segments;
|
||||
if (!Array.isArray(segs) || segs.length === 0) return null;
|
||||
const candidates = [segs[0].from, ...segs.map((s) => s.to)];
|
||||
const stops = [];
|
||||
for (const a of candidates) {
|
||||
if (!a || a.lat === null || a.lat === undefined || a.lng === null || a.lng === undefined) {
|
||||
continue;
|
||||
}
|
||||
const prev = stops[stops.length - 1];
|
||||
if (prev && prev.lat === a.lat && prev.lng === a.lng) continue;
|
||||
stops.push({ code: a.code, name: a.name ?? null, lat: a.lat, lng: a.lng });
|
||||
}
|
||||
return stops.length ? stops : null;
|
||||
}
|
||||
|
||||
// Ordered map stops: flight entries with located segments expand into airport
|
||||
// stops; every other located entry is a single stop.
|
||||
function buildStops(entries) {
|
||||
const stops = [];
|
||||
for (const e of entries) {
|
||||
const airports = e.type === 'flight' ? airportStopsFor(e) : null;
|
||||
if (airports) {
|
||||
for (const a of airports) {
|
||||
stops.push({
|
||||
entryId: e.id,
|
||||
date: e.date,
|
||||
type: e.type,
|
||||
title: e.title,
|
||||
kind: 'airport',
|
||||
code: a.code,
|
||||
location_name: a.name,
|
||||
lat: a.lat,
|
||||
lng: a.lng,
|
||||
});
|
||||
}
|
||||
} else if (e.lat !== null && e.lng !== null) {
|
||||
stops.push({
|
||||
entryId: e.id,
|
||||
date: e.date,
|
||||
type: e.type,
|
||||
title: e.title,
|
||||
location_name: e.location_name,
|
||||
lat: e.lat,
|
||||
lng: e.lng,
|
||||
});
|
||||
}
|
||||
}
|
||||
return stops;
|
||||
}
|
||||
|
||||
// Validate a {name, start_date, end_date, currency} object, merging with
|
||||
// existing values (for PATCH). Returns { error } or { values }.
|
||||
function validateTripFields(body, existing) {
|
||||
const name = 'name' in body ? body.name : existing?.name;
|
||||
const start = 'start_date' in body ? body.start_date : existing?.start_date;
|
||||
const end = 'end_date' in body ? body.end_date : existing?.end_date;
|
||||
const currency =
|
||||
'currency' in body ? body.currency : existing?.currency ?? 'USD';
|
||||
|
||||
if (typeof name !== 'string' || name.trim() === '') {
|
||||
return { error: 'name is required' };
|
||||
}
|
||||
if (name.length > 120) {
|
||||
return { error: 'name must be at most 120 characters' };
|
||||
}
|
||||
if (!isValidDateStr(start)) {
|
||||
return { error: 'start_date must be a valid YYYY-MM-DD date' };
|
||||
}
|
||||
if (!isValidDateStr(end)) {
|
||||
return { error: 'end_date must be a valid YYYY-MM-DD date' };
|
||||
}
|
||||
if (end < start) {
|
||||
return { error: 'end_date must be on or after start_date' };
|
||||
}
|
||||
if (daysInclusive(start, end) > MAX_RANGE_DAYS) {
|
||||
return { error: 'date range must be at most 365 days' };
|
||||
}
|
||||
if (typeof currency !== 'string' || !CURRENCY_RE.test(currency)) {
|
||||
return { error: 'currency must be a 3-letter uppercase code' };
|
||||
}
|
||||
return {
|
||||
values: { name: name.trim(), start_date: start, end_date: end, currency },
|
||||
};
|
||||
}
|
||||
|
||||
export default function tripsRoutes(db) {
|
||||
const router = express.Router();
|
||||
|
||||
const listForUser = db.prepare(`
|
||||
SELECT t.id, t.name, t.start_date, t.end_date, t.owner_id, t.currency, tm.role,
|
||||
(SELECT COUNT(*) FROM trip_members WHERE trip_id = t.id) AS member_count,
|
||||
(SELECT COUNT(*) FROM entries WHERE trip_id = t.id) AS entry_count
|
||||
FROM trips t
|
||||
JOIN trip_members tm ON tm.trip_id = t.id AND tm.user_id = ?
|
||||
ORDER BY t.created_at DESC, t.id DESC
|
||||
`);
|
||||
const insertTrip = db.prepare(
|
||||
'INSERT INTO trips (name, start_date, end_date, owner_id, currency, join_code) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
const insertMember = db.prepare(
|
||||
'INSERT INTO trip_members (trip_id, user_id, role) VALUES (?, ?, ?)'
|
||||
);
|
||||
const getTrip = db.prepare(
|
||||
'SELECT id, name, start_date, end_date, owner_id, currency, join_code FROM trips WHERE id = ?'
|
||||
);
|
||||
const getMembers = db.prepare(`
|
||||
SELECT u.id, u.display_name, tm.role
|
||||
FROM trip_members tm JOIN users u ON u.id = tm.user_id
|
||||
WHERE tm.trip_id = ? ORDER BY tm.role = 'owner' DESC, u.display_name
|
||||
`);
|
||||
const getEntries = db.prepare(
|
||||
`SELECT ${ENTRY_COLUMNS} FROM entries WHERE trip_id = ? ORDER BY date, sort_order, id`
|
||||
);
|
||||
const findTripByJoinCode = db.prepare('SELECT id FROM trips WHERE join_code = ?');
|
||||
const joinCodeExists = db.prepare('SELECT 1 FROM trips WHERE join_code = ?');
|
||||
|
||||
// Generate a join_code not already in use.
|
||||
function uniqueJoinCode() {
|
||||
let code;
|
||||
do {
|
||||
code = generateJoinCode();
|
||||
} while (joinCodeExists.get(code));
|
||||
return code;
|
||||
}
|
||||
|
||||
// Resolve :id as an integer and confirm membership. Sends 404 and returns
|
||||
// null when the trip does not exist or the user is not a member.
|
||||
function requireMember(req, res) {
|
||||
const tripId = Number(req.params.id);
|
||||
if (!Number.isInteger(tripId)) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return null;
|
||||
}
|
||||
const member = membership(db, tripId, req.session.userId);
|
||||
if (!member) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return null;
|
||||
}
|
||||
return { tripId, role: member.role };
|
||||
}
|
||||
|
||||
// GET /api/trips
|
||||
router.get('/', (req, res) => {
|
||||
const trips = listForUser.all(req.session.userId);
|
||||
res.status(200).json({ trips });
|
||||
});
|
||||
|
||||
// POST /api/trips
|
||||
router.post('/', (req, res) => {
|
||||
const check = validateTripFields(req.body || {}, null);
|
||||
if (check.error) return res.status(400).json({ error: check.error });
|
||||
const { name, start_date, end_date, currency } = check.values;
|
||||
const userId = req.session.userId;
|
||||
const trip = db.transaction(() => {
|
||||
const info = insertTrip.run(
|
||||
name,
|
||||
start_date,
|
||||
end_date,
|
||||
userId,
|
||||
currency,
|
||||
uniqueJoinCode()
|
||||
);
|
||||
const id = Number(info.lastInsertRowid);
|
||||
insertMember.run(id, userId, 'owner');
|
||||
return getTrip.get(id);
|
||||
})();
|
||||
res.status(201).json({ trip: tripJson(trip) });
|
||||
});
|
||||
|
||||
// POST /api/trips/join { code } — join by code as editor (idempotent).
|
||||
router.post('/join', (req, res) => {
|
||||
const code = normalizeCode((req.body || {}).code);
|
||||
if (!code) return res.status(404).json({ error: 'not found' });
|
||||
const found = findTripByJoinCode.get(code);
|
||||
if (!found) return res.status(404).json({ error: 'not found' });
|
||||
if (!membership(db, found.id, req.session.userId)) {
|
||||
insertMember.run(found.id, req.session.userId, 'editor');
|
||||
}
|
||||
res.status(200).json({ trip: tripJson(getTrip.get(found.id)) });
|
||||
});
|
||||
|
||||
// GET /api/trips/:id
|
||||
router.get('/:id', (req, res) => {
|
||||
const ctx = requireMember(req, res);
|
||||
if (!ctx) return;
|
||||
res.status(200).json({
|
||||
trip: tripJson(getTrip.get(ctx.tripId)),
|
||||
members: getMembers.all(ctx.tripId),
|
||||
entries: attachParticipantsAll(db, getEntries.all(ctx.tripId)),
|
||||
});
|
||||
});
|
||||
|
||||
// PATCH /api/trips/:id
|
||||
router.patch('/:id', (req, res) => {
|
||||
const ctx = requireMember(req, res);
|
||||
if (!ctx) return;
|
||||
const existing = getTrip.get(ctx.tripId);
|
||||
const check = validateTripFields(req.body || {}, existing);
|
||||
if (check.error) return res.status(400).json({ error: check.error });
|
||||
const { name, start_date, end_date, currency } = check.values;
|
||||
db.prepare(
|
||||
'UPDATE trips SET name = ?, start_date = ?, end_date = ?, currency = ? WHERE id = ?'
|
||||
).run(name, start_date, end_date, currency, ctx.tripId);
|
||||
res.status(200).json({ trip: tripJson(getTrip.get(ctx.tripId)) });
|
||||
});
|
||||
|
||||
// DELETE /api/trips/:id (owner only)
|
||||
router.delete('/:id', (req, res) => {
|
||||
const ctx = requireMember(req, res);
|
||||
if (!ctx) return;
|
||||
if (ctx.role !== 'owner') {
|
||||
return res.status(403).json({ error: 'only the owner can delete a trip' });
|
||||
}
|
||||
db.transaction(() => {
|
||||
db.prepare('DELETE FROM entries WHERE trip_id = ?').run(ctx.tripId);
|
||||
db.prepare('DELETE FROM trip_members WHERE trip_id = ?').run(ctx.tripId);
|
||||
db.prepare('DELETE FROM trips WHERE id = ?').run(ctx.tripId);
|
||||
})();
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// POST /api/trips/:id/join-code (owner only) — regenerate the join code.
|
||||
router.post('/:id/join-code', (req, res) => {
|
||||
const ctx = requireMember(req, res);
|
||||
if (!ctx) return;
|
||||
if (ctx.role !== 'owner') {
|
||||
return res.status(403).json({ error: 'only the owner can regenerate the join code' });
|
||||
}
|
||||
db.prepare('UPDATE trips SET join_code = ? WHERE id = ?').run(
|
||||
uniqueJoinCode(),
|
||||
ctx.tripId
|
||||
);
|
||||
res.status(200).json({ trip: tripJson(getTrip.get(ctx.tripId)) });
|
||||
});
|
||||
|
||||
// DELETE /api/trips/:id/members/:userId (owner only)
|
||||
router.delete('/:id/members/:userId', (req, res) => {
|
||||
const ctx = requireMember(req, res);
|
||||
if (!ctx) return;
|
||||
if (ctx.role !== 'owner') {
|
||||
return res.status(403).json({ error: 'only the owner can remove members' });
|
||||
}
|
||||
const userId = Number(req.params.userId);
|
||||
if (userId === req.session.userId) {
|
||||
return res.status(400).json({ error: 'owner cannot remove themselves' });
|
||||
}
|
||||
db.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(
|
||||
ctx.tripId,
|
||||
userId
|
||||
);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// GET /api/trips/:id/route (computed legs + summary)
|
||||
router.get('/:id/route', (req, res) => {
|
||||
const ctx = requireMember(req, res);
|
||||
if (!ctx) return;
|
||||
const trip = getTrip.get(ctx.tripId);
|
||||
const allEntries = getEntries
|
||||
.all(ctx.tripId)
|
||||
.map((r) => ({
|
||||
...r,
|
||||
segments: parseSegments(r.segments),
|
||||
rental: parseRental(r.rental),
|
||||
}));
|
||||
|
||||
const stops = buildStops(allEntries);
|
||||
|
||||
const legs = [];
|
||||
let totalKm = 0;
|
||||
let kmAir = 0;
|
||||
let kmDriven = 0;
|
||||
for (let i = 1; i < stops.length; i++) {
|
||||
const a = stops[i - 1];
|
||||
const b = stops[i];
|
||||
const km = haversineKm(a.lat, a.lng, b.lat, b.lng);
|
||||
if (km < MIN_LEG_KM) continue;
|
||||
// "air" only when both endpoints are airport stops from the same flight entry.
|
||||
const isAir =
|
||||
a.kind === 'airport' && b.kind === 'airport' && a.entryId === b.entryId;
|
||||
const mode = isAir ? 'air' : 'ground';
|
||||
legs.push({
|
||||
fromEntryId: a.entryId,
|
||||
toEntryId: b.entryId,
|
||||
km: Math.round(km * 10) / 10,
|
||||
mode,
|
||||
});
|
||||
totalKm += km;
|
||||
if (isAir) kmAir += km;
|
||||
else kmDriven += km;
|
||||
}
|
||||
|
||||
const countType = (t) => allEntries.filter((e) => e.type === t).length;
|
||||
const flightSegments = allEntries.reduce(
|
||||
(n, e) =>
|
||||
n + (e.type === 'flight' && Array.isArray(e.segments) ? e.segments.length : 0),
|
||||
0
|
||||
);
|
||||
// Sum non-null included_km across rentals; null when none specify one.
|
||||
let includedKm = null;
|
||||
for (const e of allEntries) {
|
||||
if (e.type === 'rental' && e.rental && typeof e.rental.included_km === 'number') {
|
||||
includedKm = (includedKm ?? 0) + e.rental.included_km;
|
||||
}
|
||||
}
|
||||
if (includedKm !== null) includedKm = Math.round(includedKm * 10) / 10;
|
||||
const days = daysInclusive(trip.start_date, trip.end_date);
|
||||
const locations = [];
|
||||
for (const s of stops) {
|
||||
if (s.location_name && !locations.includes(s.location_name)) {
|
||||
locations.push(s.location_name);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
stops,
|
||||
legs,
|
||||
totalKm: Math.round(totalKm * 10) / 10,
|
||||
summary: {
|
||||
days,
|
||||
nights: Math.max(0, days - 1),
|
||||
flights: countType('flight'),
|
||||
flightSegments,
|
||||
hotels: countType('hotel'),
|
||||
travelLegs: countType('travel'),
|
||||
activities: countType('activity'),
|
||||
rentals: countType('rental'),
|
||||
kmAir: Math.round(kmAir * 10) / 10,
|
||||
kmDriven: Math.round(kmDriven * 10) / 10,
|
||||
includedKm,
|
||||
locations,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/trips/:id/costs (computed cost split + settlements)
|
||||
router.get('/:id/costs', (req, res) => {
|
||||
const ctx = requireMember(req, res);
|
||||
if (!ctx) return;
|
||||
const trip = getTrip.get(ctx.tripId);
|
||||
const members = getMembers.all(ctx.tripId);
|
||||
const entries = attachParticipantsAll(db, getEntries.all(ctx.tripId));
|
||||
res.status(200).json(
|
||||
computeCosts({ currency: trip.currency, members, entries })
|
||||
);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Shared trip-membership lookup used by trip and entry routes.
|
||||
|
||||
export function membership(db, tripId, userId) {
|
||||
return db
|
||||
.prepare('SELECT role FROM trip_members WHERE trip_id = ? AND user_id = ?')
|
||||
.get(tripId, userId);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Pure cost/splitting computation for GET /api/trips/:id/costs.
|
||||
// Kept side-effect free so it can be unit-tested directly.
|
||||
|
||||
function round2(v) {
|
||||
return Math.round((v + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
// members: [{ id, display_name }] (current trip members, in display order)
|
||||
// entries: [{ type, price, paid_by, split_mode, participants }]
|
||||
// participants = array of user ids ([] = all members)
|
||||
// currency: trip currency string
|
||||
export function computeCosts({ currency, members, entries }) {
|
||||
const memberIds = members.map((m) => m.id);
|
||||
const memberSet = new Set(memberIds);
|
||||
const share = new Map(memberIds.map((id) => [id, 0]));
|
||||
const paid = new Map(memberIds.map((id) => [id, 0]));
|
||||
const add = (map, id, amt) => map.set(id, (map.get(id) || 0) + amt);
|
||||
|
||||
const byType = {};
|
||||
let totalCost = 0;
|
||||
let unassigned = 0;
|
||||
|
||||
for (const e of entries) {
|
||||
if (e.price === null || e.price === undefined) continue;
|
||||
|
||||
// Effective participants: the entry's rows, else all current members.
|
||||
let eff =
|
||||
Array.isArray(e.participants) && e.participants.length
|
||||
? e.participants.filter((id) => memberSet.has(id))
|
||||
: memberIds;
|
||||
if (eff.length === 0) eff = memberIds;
|
||||
|
||||
const mode = e.split_mode || 'equal';
|
||||
let effTotal;
|
||||
|
||||
if (mode === 'own') {
|
||||
// price is per person; each pays their own, no debt.
|
||||
for (const id of eff) {
|
||||
add(share, id, e.price);
|
||||
add(paid, id, e.price);
|
||||
}
|
||||
effTotal = e.price * eff.length;
|
||||
} else if (mode === 'payer') {
|
||||
// personal expense: paid_by owes and pays the whole price alone.
|
||||
add(share, e.paid_by, e.price);
|
||||
add(paid, e.paid_by, e.price);
|
||||
effTotal = e.price;
|
||||
} else {
|
||||
// equal: price is the total, split equally among participants.
|
||||
const per = e.price / eff.length;
|
||||
for (const id of eff) add(share, id, per);
|
||||
if (e.paid_by === null || e.paid_by === undefined) {
|
||||
unassigned += e.price;
|
||||
} else {
|
||||
add(paid, e.paid_by, e.price);
|
||||
}
|
||||
effTotal = e.price;
|
||||
}
|
||||
|
||||
totalCost += effTotal;
|
||||
byType[e.type] = (byType[e.type] || 0) + effTotal;
|
||||
}
|
||||
|
||||
const perUser = members.map((m) => {
|
||||
const s = share.get(m.id) || 0;
|
||||
const p = paid.get(m.id) || 0;
|
||||
return {
|
||||
userId: m.id,
|
||||
displayName: m.display_name,
|
||||
share: round2(s),
|
||||
paid: round2(p),
|
||||
net: round2(p - s),
|
||||
};
|
||||
});
|
||||
|
||||
const roundedByType = {};
|
||||
for (const [type, amt] of Object.entries(byType)) {
|
||||
roundedByType[type] = round2(amt);
|
||||
}
|
||||
|
||||
return {
|
||||
currency,
|
||||
totalCost: round2(totalCost),
|
||||
byType: roundedByType,
|
||||
perUser,
|
||||
settlements: settle(perUser),
|
||||
unassigned: round2(unassigned),
|
||||
};
|
||||
}
|
||||
|
||||
// Greedy minimal-transfer settlement over the rounded net balances.
|
||||
function settle(perUser) {
|
||||
const debtors = perUser
|
||||
.filter((u) => u.net < -0.005)
|
||||
.map((u) => ({ id: u.userId, amt: -u.net }));
|
||||
const creditors = perUser
|
||||
.filter((u) => u.net > 0.005)
|
||||
.map((u) => ({ id: u.userId, amt: u.net }));
|
||||
|
||||
const settlements = [];
|
||||
while (debtors.length && creditors.length) {
|
||||
debtors.sort((a, b) => b.amt - a.amt);
|
||||
creditors.sort((a, b) => b.amt - a.amt);
|
||||
const d = debtors[0];
|
||||
const c = creditors[0];
|
||||
const pay = Math.min(d.amt, c.amt);
|
||||
if (pay < 0.01) break;
|
||||
settlements.push({
|
||||
fromUserId: d.id,
|
||||
toUserId: c.id,
|
||||
amount: round2(pay),
|
||||
});
|
||||
d.amt = round2(d.amt - pay);
|
||||
c.amt = round2(c.amt - pay);
|
||||
if (d.amt < 0.01) debtors.shift();
|
||||
if (c.amt < 0.01) creditors.shift();
|
||||
}
|
||||
return settlements;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Date-string helpers. All trip/entry dates are YYYY-MM-DD strings.
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
export function isValidDateStr(s) {
|
||||
if (typeof s !== 'string' || !DATE_RE.test(s)) return false;
|
||||
const [y, m, d] = s.split('-').map(Number);
|
||||
const dt = new Date(Date.UTC(y, m - 1, d));
|
||||
return (
|
||||
dt.getUTCFullYear() === y &&
|
||||
dt.getUTCMonth() === m - 1 &&
|
||||
dt.getUTCDate() === d
|
||||
);
|
||||
}
|
||||
|
||||
function dateToUTC(s) {
|
||||
const [y, m, d] = s.split('-').map(Number);
|
||||
return Date.UTC(y, m - 1, d);
|
||||
}
|
||||
|
||||
// Inclusive day count between two valid date strings (start <= end).
|
||||
export function daysInclusive(start, end) {
|
||||
return Math.round((dateToUTC(end) - dateToUTC(start)) / 86400000) + 1;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Great-circle distance between two lat/lng points, in kilometers.
|
||||
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
|
||||
function toRad(deg) {
|
||||
return (deg * Math.PI) / 180;
|
||||
}
|
||||
|
||||
export function haversineKm(lat1, lng1, lat2, lng2) {
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return EARTH_RADIUS_KM * c;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Shared entry column list and JSON serialization. Every entry response is a
|
||||
// full row plus a `participants` array (the entry_participants rows; [] means
|
||||
// "all trip members participate") and a parsed `segments` array (or null).
|
||||
|
||||
export const ENTRY_COLUMNS =
|
||||
'id, trip_id, date, type, title, details, start_time, end_time, ' +
|
||||
'location_name, lat, lng, sort_order, price, paid_by, split_mode, segments, rental';
|
||||
|
||||
// Parse the stored segments JSON text into an array, or null if absent/invalid.
|
||||
export function parseSegments(value) {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the stored rental JSON text into an object, or null if absent/invalid.
|
||||
export function parseRental(value) {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function attachParticipants(db, row) {
|
||||
if (!row) return row;
|
||||
const rows = db
|
||||
.prepare('SELECT user_id FROM entry_participants WHERE entry_id = ? ORDER BY user_id')
|
||||
.all(row.id);
|
||||
row.participants = rows.map((r) => r.user_id);
|
||||
row.segments = parseSegments(row.segments);
|
||||
row.rental = parseRental(row.rental);
|
||||
return row;
|
||||
}
|
||||
|
||||
export function attachParticipantsAll(db, rows) {
|
||||
return rows.map((r) => attachParticipants(db, r));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Auto-generated, non-unique display names: adjective-animal (e.g. brave-otter).
|
||||
|
||||
const ADJECTIVES = [
|
||||
'brave', 'calm', 'clever', 'bold', 'gentle', 'swift', 'quiet', 'bright',
|
||||
'lucky', 'mellow', 'nimble', 'sunny', 'witty', 'eager', 'jolly', 'keen',
|
||||
'proud', 'wise', 'zesty', 'cosmic',
|
||||
];
|
||||
|
||||
const ANIMALS = [
|
||||
'otter', 'heron', 'lynx', 'panda', 'koala', 'falcon', 'marmot', 'gecko',
|
||||
'tapir', 'ibex', 'narwhal', 'quokka', 'badger', 'osprey', 'manta', 'puffin',
|
||||
'yak', 'wombat', 'civet', 'raven',
|
||||
];
|
||||
|
||||
function pick(list) {
|
||||
return list[Math.floor(Math.random() * list.length)];
|
||||
}
|
||||
|
||||
export function generateDisplayName() {
|
||||
return `${pick(ADJECTIVES)}-${pick(ANIMALS)}`;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Validation + normalization for rental-car entry details (see docs/API.md).
|
||||
|
||||
import { isValidDateStr } from './dates.js';
|
||||
|
||||
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
function validCoord(v, min, max) {
|
||||
return typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max;
|
||||
}
|
||||
|
||||
// Optional string field with a max length.
|
||||
function checkString(obj, key, max) {
|
||||
const v = obj[key];
|
||||
if (v === undefined || v === null) return { skip: true };
|
||||
if (typeof v !== 'string' || v.length > max) {
|
||||
return { error: `${key} must be a string <= ${max} chars` };
|
||||
}
|
||||
return { value: v };
|
||||
}
|
||||
|
||||
// Validate a pickup/dropoff endpoint: date required, others optional.
|
||||
function normalizeStop(stop, side) {
|
||||
if (typeof stop !== 'object' || stop === null || Array.isArray(stop)) {
|
||||
return { error: `${side} must be an object` };
|
||||
}
|
||||
if (!isValidDateStr(stop.date)) {
|
||||
return { error: `${side}.date must be a valid YYYY-MM-DD date` };
|
||||
}
|
||||
const out = { date: stop.date };
|
||||
if (stop.time !== undefined && stop.time !== null) {
|
||||
if (typeof stop.time !== 'string' || !TIME_RE.test(stop.time)) {
|
||||
return { error: `${side}.time must be HH:MM` };
|
||||
}
|
||||
out.time = stop.time;
|
||||
}
|
||||
const name = checkString(stop, 'location_name', 120);
|
||||
if (name.error) return { error: `${side}.${name.error}` };
|
||||
if (!name.skip) out.location_name = name.value;
|
||||
|
||||
const hasLat = stop.lat !== undefined && stop.lat !== null;
|
||||
const hasLng = stop.lng !== undefined && stop.lng !== null;
|
||||
if (hasLat !== hasLng) {
|
||||
return { error: `${side} lat/lng must both be present or both absent` };
|
||||
}
|
||||
if (hasLat) {
|
||||
if (!validCoord(stop.lat, -90, 90)) return { error: `${side}.lat out of range` };
|
||||
if (!validCoord(stop.lng, -180, 180)) return { error: `${side}.lng out of range` };
|
||||
out.lat = stop.lat;
|
||||
out.lng = stop.lng;
|
||||
}
|
||||
return { value: out };
|
||||
}
|
||||
|
||||
// Validate a rental object. Returns { error } or { value: normalized }.
|
||||
export function validateRental(rental) {
|
||||
if (typeof rental !== 'object' || rental === null || Array.isArray(rental)) {
|
||||
return { error: 'rental must be an object' };
|
||||
}
|
||||
const out = {};
|
||||
|
||||
for (const [key, max] of [['brand', 60], ['model', 60], ['car_type', 40], ['booking_ref', 60]]) {
|
||||
const r = checkString(rental, key, max);
|
||||
if (r.error) return { error: r.error };
|
||||
if (!r.skip) out[key] = r.value;
|
||||
}
|
||||
|
||||
if (rental.included_km !== undefined) {
|
||||
const v = rental.included_km;
|
||||
if (v !== null && !(typeof v === 'number' && Number.isFinite(v) && v >= 0)) {
|
||||
return { error: 'included_km must be null or a number >= 0' };
|
||||
}
|
||||
out.included_km = v;
|
||||
}
|
||||
|
||||
for (const side of ['pickup', 'dropoff']) {
|
||||
if (rental[side] !== undefined && rental[side] !== null) {
|
||||
const checked = normalizeStop(rental[side], side);
|
||||
if (checked.error) return { error: checked.error };
|
||||
out[side] = checked.value;
|
||||
}
|
||||
}
|
||||
|
||||
return { value: out };
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Validation + normalization for flight-entry segments (see docs/API.md).
|
||||
|
||||
const CODE_RE = /^[A-Z0-9]{2,4}$/;
|
||||
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
const MAX_SEGMENTS = 8;
|
||||
|
||||
function validCoord(v, min, max) {
|
||||
return typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max;
|
||||
}
|
||||
|
||||
// Validate and normalize one airport endpoint ({code, name?, lat?, lng?}).
|
||||
function normalizeAirport(a, side, i) {
|
||||
if (!a || typeof a !== 'object' || Array.isArray(a)) {
|
||||
return { error: `segment ${i} ${side} is required` };
|
||||
}
|
||||
if (typeof a.code !== 'string') {
|
||||
return { error: `segment ${i} ${side}.code is required` };
|
||||
}
|
||||
const code = a.code.trim().toUpperCase();
|
||||
if (!CODE_RE.test(code)) {
|
||||
return { error: `segment ${i} ${side}.code must be 2-4 alphanumerics` };
|
||||
}
|
||||
const out = { code };
|
||||
if (a.name !== undefined && a.name !== null) {
|
||||
if (typeof a.name !== 'string' || a.name.length > 80) {
|
||||
return { error: `segment ${i} ${side}.name must be a string <= 80 chars` };
|
||||
}
|
||||
out.name = a.name;
|
||||
}
|
||||
const hasLat = a.lat !== undefined && a.lat !== null;
|
||||
const hasLng = a.lng !== undefined && a.lng !== null;
|
||||
if (hasLat !== hasLng) {
|
||||
return { error: `segment ${i} ${side} lat/lng must both be present or both absent` };
|
||||
}
|
||||
if (hasLat) {
|
||||
if (!validCoord(a.lat, -90, 90)) return { error: `segment ${i} ${side}.lat out of range` };
|
||||
if (!validCoord(a.lng, -180, 180)) return { error: `segment ${i} ${side}.lng out of range` };
|
||||
out.lat = a.lat;
|
||||
out.lng = a.lng;
|
||||
}
|
||||
return { value: out };
|
||||
}
|
||||
|
||||
// Validate a segments array. Returns { error } or { value: normalizedArray }.
|
||||
export function validateSegments(segments) {
|
||||
if (!Array.isArray(segments)) {
|
||||
return { error: 'segments must be an array' };
|
||||
}
|
||||
if (segments.length < 1 || segments.length > MAX_SEGMENTS) {
|
||||
return { error: `segments must have 1-${MAX_SEGMENTS} entries` };
|
||||
}
|
||||
const out = [];
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const s = segments[i];
|
||||
if (!s || typeof s !== 'object' || Array.isArray(s)) {
|
||||
return { error: `segment ${i} must be an object` };
|
||||
}
|
||||
const from = normalizeAirport(s.from, 'from', i);
|
||||
if (from.error) return from;
|
||||
const to = normalizeAirport(s.to, 'to', i);
|
||||
if (to.error) return to;
|
||||
|
||||
const seg = { from: from.value, to: to.value };
|
||||
if (s.flight_no !== undefined && s.flight_no !== null) {
|
||||
if (typeof s.flight_no !== 'string' || s.flight_no.length > 12) {
|
||||
return { error: `segment ${i} flight_no must be a string <= 12 chars` };
|
||||
}
|
||||
seg.flight_no = s.flight_no;
|
||||
}
|
||||
for (const key of ['dep_time', 'arr_time']) {
|
||||
if (s[key] !== undefined && s[key] !== null) {
|
||||
if (typeof s[key] !== 'string' || !TIME_RE.test(s[key])) {
|
||||
return { error: `segment ${i} ${key} must be HH:MM` };
|
||||
}
|
||||
seg[key] = s[key];
|
||||
}
|
||||
}
|
||||
out.push(seg);
|
||||
}
|
||||
return { value: out };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
// Unambiguous alphabet (no I/L/O/0/1) shared by account tokens and join codes.
|
||||
const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // 31 chars
|
||||
|
||||
// Random string of `length` chars, uniformly sampled (rejection sampling to
|
||||
// avoid modulo bias) from ALPHABET.
|
||||
export function randomCode(length) {
|
||||
const n = ALPHABET.length;
|
||||
const maxUnbiased = Math.floor(256 / n) * n; // 248: reject bytes >= this
|
||||
let out = '';
|
||||
while (out.length < length) {
|
||||
const bytes = crypto.randomBytes(length - out.length);
|
||||
for (const b of bytes) {
|
||||
if (b >= maxUnbiased) continue;
|
||||
out += ALPHABET[b % n];
|
||||
if (out.length === length) break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Strip dashes/spaces and uppercase — applied to both stored codes and user input.
|
||||
export function normalizeCode(input) {
|
||||
return String(input ?? '').replace(/[\s-]/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
export function sha256Hex(s) {
|
||||
return crypto.createHash('sha256').update(s).digest('hex');
|
||||
}
|
||||
|
||||
// Group a raw code into dash-separated blocks of `size` for display.
|
||||
function group(raw, size) {
|
||||
return raw.match(new RegExp(`.{1,${size}}`, 'g')).join('-');
|
||||
}
|
||||
|
||||
export function generateAccountToken() {
|
||||
return randomCode(16);
|
||||
}
|
||||
|
||||
export function generateJoinCode() {
|
||||
return randomCode(8);
|
||||
}
|
||||
|
||||
export function formatToken(raw) {
|
||||
return group(raw, 4); // XXXX-XXXX-XXXX-XXXX
|
||||
}
|
||||
|
||||
export function formatJoinCode(raw) {
|
||||
return group(raw, 4); // XXXX-XXXX
|
||||
}
|
||||
|
||||
// sha256 of the normalized token/code — the only thing stored for accounts.
|
||||
export function hashToken(input) {
|
||||
return sha256Hex(normalizeCode(input));
|
||||
}
|
||||
Reference in New Issue
Block a user