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:
2026-07-18 23:15:29 +07:00
commit fe89bb2b1c
54 changed files with 8565 additions and 0 deletions
+74
View File
@@ -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;
+8
View File
@@ -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
+88
View File
@@ -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;
}
+25
View File
@@ -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)}`);
});
+46
View File
@@ -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;
}
+84
View File
@@ -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;
}
+313
View File
@@ -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;
}
+48
View File
@@ -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;
}
+376
View File
@@ -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;
}
+7
View File
@@ -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);
}
+119
View File
@@ -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;
}
+24
View File
@@ -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;
}
+17
View File
@@ -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;
}
+44
View File
@@ -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));
}
+21
View File
@@ -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)}`;
}
+84
View File
@@ -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 };
}
+81
View File
@@ -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 };
}
+56
View File
@@ -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));
}