trips) adding package-tours option

This commit is contained in:
2026-02-19 13:08:25 -05:00
parent 50e216daec
commit dba9aa0416
51 changed files with 5581 additions and 57 deletions

View File

@@ -0,0 +1,12 @@
import { env } from '$env/dynamic/private';
export function requireAdmin(userId: string | undefined): void {
if (!userId) throw new Error('Not authenticated');
const adminIds = (env.ADMIN_USER_IDS ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (adminIds.length === 0 || !adminIds.includes(userId)) {
throw new Error('Admin access required');
}
}

View File

@@ -0,0 +1,313 @@
import { db } from '../db/index.js';
import type { Country } from '../plans.js';
import type { City } from '../plans.js';
import type { Airport, Airline } from '../flights.js';
// --- Countries ---
export function listCountries(query?: string): (Country & { id: number })[] {
if (!query?.trim()) {
return db.all<Country & { id: number }>(
'SELECT id, name, country_code FROM countries ORDER BY name'
);
}
const pattern = `%${query.trim()}%`;
return db.all<Country & { id: number }>(
`SELECT id, name, country_code FROM countries
WHERE name LIKE ? COLLATE NOCASE OR country_code LIKE ? COLLATE NOCASE
ORDER BY name LIMIT 200`,
[pattern, pattern]
);
}
export function createCountry(name: string, countryCode: string): Country & { id: number } {
db.run('INSERT INTO countries (name, country_code) VALUES (?, ?)', [
name.trim(),
countryCode.trim().toUpperCase()
]);
return db.get<Country & { id: number }>(
'SELECT id, name, country_code FROM countries WHERE country_code = ?',
[countryCode.trim().toUpperCase()]
)!;
}
export function updateCountry(id: number, name: string, countryCode: string): void {
db.run('UPDATE countries SET name = ?, country_code = ? WHERE id = ?', [
name.trim(),
countryCode.trim().toUpperCase(),
id
]);
}
export function deleteCountry(id: number): void {
db.run('DELETE FROM countries WHERE id = ?', [id]);
}
// --- Cities ---
export function listCities(query?: string, countryCode?: string): City[] {
if (!query?.trim()) {
if (countryCode?.trim()) {
return db.all<City>(
'SELECT id, name, country, country_code, population FROM cities WHERE country_code = ? ORDER BY name LIMIT 200',
[countryCode.trim().toUpperCase()]
);
}
return db.all<City>(
'SELECT id, name, country, country_code, population FROM cities ORDER BY name LIMIT 200'
);
}
const pattern = `%${query.trim()}%`;
if (countryCode?.trim()) {
return db.all<City>(
`SELECT id, name, country, country_code, population FROM cities
WHERE (name LIKE ? COLLATE NOCASE OR country LIKE ? COLLATE NOCASE) AND country_code = ?
ORDER BY name LIMIT 200`,
[pattern, pattern, countryCode.trim().toUpperCase()]
);
}
return db.all<City>(
`SELECT id, name, country, country_code, population FROM cities
WHERE name LIKE ? COLLATE NOCASE OR country LIKE ? COLLATE NOCASE
ORDER BY name LIMIT 200`,
[pattern, pattern]
);
}
export function createCity(
name: string,
country: string,
countryCode: string,
population?: number | null
): City {
const maxId = db.get<{ max: number }>('SELECT COALESCE(MAX(id), 0) + 1 as max FROM cities');
const id = maxId?.max ?? 1;
db.run(
'INSERT INTO cities (id, name, country, country_code, population) VALUES (?, ?, ?, ?, ?)',
[id, name.trim(), country.trim(), countryCode.trim().toUpperCase(), population ?? null]
);
return db.get<City>('SELECT * FROM cities WHERE id = ?', [id])!;
}
export function updateCity(
id: number,
name: string,
country: string,
countryCode: string,
population?: number | null
): void {
db.run('UPDATE cities SET name = ?, country = ?, country_code = ?, population = ? WHERE id = ?', [
name.trim(),
country.trim(),
countryCode.trim().toUpperCase(),
population ?? null,
id
]);
}
export function deleteCity(id: number): void {
db.run('DELETE FROM cities WHERE id = ?', [id]);
}
// --- Airports ---
export function listAirports(query?: string): Airport[] {
if (!query?.trim()) {
return db.all<Airport>('SELECT * FROM airports ORDER BY name LIMIT 200');
}
const pattern = `%${query.trim()}%`;
return db.all<Airport>(
`SELECT * FROM airports
WHERE name LIKE ? COLLATE NOCASE OR iata_code LIKE ? COLLATE NOCASE OR icao_code LIKE ? COLLATE NOCASE
OR city LIKE ? COLLATE NOCASE OR country LIKE ? COLLATE NOCASE
ORDER BY name LIMIT 200`,
[pattern, pattern, pattern, pattern, pattern]
);
}
export function createAirport(
name: string,
country: string,
countryCode: string,
iataCode?: string | null,
icaoCode?: string | null,
city?: string | null,
latitude?: number | null,
longitude?: number | null,
timezone?: string | null
): Airport {
db.run(
`INSERT INTO airports (name, city, country, country_code, iata_code, icao_code, latitude, longitude, timezone)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
name.trim(),
city?.trim() ?? null,
country.trim(),
countryCode.trim().toUpperCase(),
iataCode?.trim() ?? null,
icaoCode?.trim() ?? null,
latitude ?? null,
longitude ?? null,
timezone?.trim() ?? null
]
);
return db.get<Airport>('SELECT * FROM airports WHERE id = last_insert_rowid()')!;
}
export function updateAirport(
id: number,
name: string,
country: string,
countryCode: string,
iataCode?: string | null,
icaoCode?: string | null,
city?: string | null,
latitude?: number | null,
longitude?: number | null,
timezone?: string | null
): void {
db.run(
`UPDATE airports SET name = ?, city = ?, country = ?, country_code = ?,
iata_code = ?, icao_code = ?, latitude = ?, longitude = ?, timezone = ?
WHERE id = ?`,
[
name.trim(),
city?.trim() ?? null,
country.trim(),
countryCode.trim().toUpperCase(),
iataCode?.trim() ?? null,
icaoCode?.trim() ?? null,
latitude ?? null,
longitude ?? null,
timezone?.trim() ?? null,
id
]
);
}
export function deleteAirport(id: number): void {
db.run('DELETE FROM airports WHERE id = ?', [id]);
}
// --- Airlines ---
export function listAirlines(query?: string): Airline[] {
if (!query?.trim()) {
return db.all<Airline>('SELECT * FROM airlines ORDER BY name LIMIT 200');
}
const pattern = `%${query.trim()}%`;
return db.all<Airline>(
`SELECT * FROM airlines
WHERE name LIKE ? COLLATE NOCASE OR iata_code LIKE ? COLLATE NOCASE OR icao_code LIKE ? COLLATE NOCASE
OR country LIKE ? COLLATE NOCASE
ORDER BY name LIMIT 200`,
[pattern, pattern, pattern, pattern]
);
}
export function createAirline(
name: string,
country?: string | null,
countryCode?: string | null,
iataCode?: string | null,
icaoCode?: string | null
): Airline {
db.run(
'INSERT INTO airlines (name, country, country_code, iata_code, icao_code) VALUES (?, ?, ?, ?, ?)',
[
name.trim(),
country?.trim() ?? null,
countryCode?.trim()?.toUpperCase() ?? null,
iataCode?.trim() ?? null,
icaoCode?.trim() ?? null
]
);
return db.get<Airline>('SELECT * FROM airlines WHERE id = last_insert_rowid()')!;
}
export function updateAirline(
id: number,
name: string,
country?: string | null,
countryCode?: string | null,
iataCode?: string | null,
icaoCode?: string | null
): void {
db.run(
'UPDATE airlines SET name = ?, country = ?, country_code = ?, iata_code = ?, icao_code = ? WHERE id = ?',
[
name.trim(),
country?.trim() ?? null,
countryCode?.trim()?.toUpperCase() ?? null,
iataCode?.trim() ?? null,
icaoCode?.trim() ?? null,
id
]
);
}
export function deleteAirline(id: number): void {
db.run('DELETE FROM airlines WHERE id = ?', [id]);
}
// --- Tour Operators ---
export interface TourOperator {
id: number;
name: string;
website: string | null;
highlight_color: string | null;
created_at: string;
updated_at: string;
}
export function listTourOperators(query?: string): TourOperator[] {
if (!query?.trim()) {
return db.all<TourOperator>('SELECT * FROM tour_operators ORDER BY name');
}
const pattern = `%${query.trim()}%`;
return db.all<TourOperator>(
`SELECT * FROM tour_operators
WHERE name LIKE ? COLLATE NOCASE OR website LIKE ? COLLATE NOCASE
ORDER BY name LIMIT 200`,
[pattern, pattern]
);
}
export function createTourOperator(
name: string,
website?: string | null,
highlightColor?: string | null
): TourOperator {
db.run('INSERT INTO tour_operators (name, website, highlight_color) VALUES (?, ?, ?)', [
name.trim(),
website?.trim() ?? null,
highlightColor?.trim() ?? null
]);
return db.get<TourOperator>('SELECT * FROM tour_operators WHERE id = last_insert_rowid()')!;
}
export function updateTourOperator(
id: number,
name: string,
website?: string | null,
highlightColor?: string | null
): void {
db.run(
`UPDATE tour_operators SET name = ?, website = ?, highlight_color = ?,
updated_at = datetime('now') WHERE id = ?`,
[name.trim(), website?.trim() ?? null, highlightColor?.trim() ?? null, id]
);
}
export function deleteTourOperator(id: number): void {
db.run('DELETE FROM tour_operators WHERE id = ?', [id]);
}
export function getTourOperatorByName(name: string): TourOperator | null {
return (
db.get<TourOperator>('SELECT * FROM tour_operators WHERE name = ? COLLATE NOCASE', [
name.trim()
]) ?? null
);
}

View File

@@ -15,6 +15,14 @@ function createDb(): Database {
throw new Error(`Unsupported DATABASE_URL scheme: ${url}`);
}
export const db: Database = createDb();
export let db: Database = createDb();
runMigrations(db);
/**
* Replace the database singleton. For use in tests only — call this with an
* in-memory SQLite instance before each test suite, then close it after.
*/
export function _setDb(database: Database): void {
db = database;
}

View File

@@ -249,12 +249,71 @@ export function runMigrations(db: Database): void {
)
`);
// Package tours table - links to a plan
db.run(`
CREATE TABLE IF NOT EXISTS package_tours (
id TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
operator_name TEXT NOT NULL,
confirmation_number TEXT,
start_date TEXT,
start_time TEXT,
start_timezone TEXT,
end_date TEXT,
end_time TEXT,
end_timezone TEXT,
price REAL,
currency TEXT NOT NULL DEFAULT 'USD',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Package tour travellers - links people to package tours
db.run(`
CREATE TABLE IF NOT EXISTS package_tour_travellers (
package_tour_id TEXT NOT NULL REFERENCES package_tours(id) ON DELETE CASCADE,
person_id TEXT NOT NULL REFERENCES people(id) ON DELETE CASCADE,
PRIMARY KEY (package_tour_id, person_id)
)
`);
// Tour operators reference table
db.run(`
CREATE TABLE IF NOT EXISTS tour_operators (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
website TEXT,
highlight_color TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Countries table — editable list for admin (used by country selector)
db.run(`
CREATE TABLE IF NOT EXISTS countries (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
country_code TEXT NOT NULL UNIQUE
)
`);
// Seed cities table on first run
const row = db.get<{ count: number }>('SELECT COUNT(*) as count FROM cities');
if ((row?.count ?? 0) === 0) {
seedCities(db);
}
// Seed countries from cities if empty
const countriesRow = db.get<{ count: number }>('SELECT COUNT(*) as count FROM countries');
if ((countriesRow?.count ?? 0) === 0) {
db.run(`
INSERT OR IGNORE INTO countries (name, country_code)
SELECT DISTINCT country, country_code FROM cities ORDER BY country
`);
}
// Seed airports and airlines on first run only
const airportsRow = db.get<{ count: number }>('SELECT COUNT(*) as count FROM airports');
if ((airportsRow?.count ?? 0) === 0) {
@@ -306,7 +365,7 @@ function seedAirports(db: Database): void {
// OpenFlights format: ID, Name, City, Country, IATA, ICAO, Lat, Lon, Alt, TZ, DST, TZ_DB, Type, Source
if (fields.length < 8) continue;
const [idStr, name, city, country, iata, icao, latStr, lonStr, , , , tzDb] = fields;
const [_idStr, name, city, country, iata, icao, latStr, lonStr, , , , tzDb] = fields;
// Skip if missing essential data
if (!name || !country) {
@@ -344,14 +403,14 @@ function seedAirports(db: Database): void {
]
);
imported++;
} catch (err) {
} catch (_err) {
// Skip duplicates or invalid data
skipped++;
}
}
console.log(`[db] Seeded ${imported} airports from airports.dat (${skipped} skipped)`);
return;
} catch (fileError) {
} catch (_fileError) {
// File doesn't exist, fall back to minimal seed only if table is empty
const airportsRow = db.get<{ count: number }>('SELECT COUNT(*) as count FROM airports');
if ((airportsRow?.count ?? 0) === 0) {
@@ -500,7 +559,7 @@ function seedAirlines(db: Database): void {
// OpenFlights format: ID, Name, Alias, IATA, ICAO, Callsign, Country, Active
if (fields.length < 7) continue;
const [idStr, name, , iata, icao, , country, active] = fields;
const [_idStr, name, , iata, icao, , country, active] = fields;
// Skip if missing essential data or inactive
if (!name || !country || active !== 'Y') {
@@ -525,14 +584,14 @@ function seedAirlines(db: Database): void {
[iata || null, icao || null, name, country, countryCode]
);
imported++;
} catch (err) {
} catch (_err) {
// Skip duplicates or invalid data
skipped++;
}
}
console.log(`[db] Seeded ${imported} airlines from airlines.dat (${skipped} skipped)`);
return;
} catch (fileError) {
} catch (_fileError) {
// File doesn't exist, fall back to minimal seed only if table is empty
const airlinesRow = db.get<{ count: number }>('SELECT COUNT(*) as count FROM airlines');
if ((airlinesRow?.count ?? 0) === 0) {

View File

@@ -133,6 +133,7 @@ export interface FlightRoute {
export interface CreateFlightInput {
tripId: string;
userId: string;
parentId?: string;
confirmationNumber?: string;
price?: number;
currency?: string;
@@ -233,9 +234,17 @@ export function createFlight(input: CreateFlightInput): FlightBooking {
: 'Flight';
db.run(
`INSERT INTO plans (id, trip_id, user_id, type, status, title, position)
VALUES (?, ?, ?, 'transport', ?, ?, ?)`,
[planId, input.tripId, input.userId, input.status ?? 'idea', title, position]
`INSERT INTO plans (id, trip_id, user_id, type, status, title, parent_id, position)
VALUES (?, ?, ?, 'transport', ?, ?, ?, ?)`,
[
planId,
input.tripId,
input.userId,
input.status ?? 'idea',
title,
input.parentId ?? null,
position
]
);
// Create the flight booking

View File

@@ -0,0 +1,169 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { setupTestDb } from '../../tests/helpers.js';
import type { Database } from './db/types.js';
import { createTrip } from './trips.js';
import { createPerson } from './travellers.js';
import { createLodging, getLodgingsForTrip, updateLodging } from './lodgings.js';
let db: Database;
beforeEach(() => {
db = setupTestDb();
});
afterEach(() => {
db.close();
});
function makeTrip(userId = 'u1') {
return createTrip({ userId, name: 'Test Trip' });
}
describe('createLodging', () => {
it('creates plan and lodging rows', () => {
const trip = makeTrip();
const lodging = createLodging({ tripId: trip.id, userId: 'u1', name: 'Hilton Tokyo' });
expect(lodging.id).toBeTruthy();
expect(lodging.name).toBe('Hilton Tokyo');
expect(lodging.currency).toBe('USD');
expect(lodging.plan_id).toBeTruthy();
});
it('stores all optional fields', () => {
const trip = makeTrip();
const lodging = createLodging({
tripId: trip.id,
userId: 'u1',
name: 'Park Hyatt',
chain: 'Hyatt',
checkInDate: '2025-10-01',
checkInTime: '15:00',
checkInTimezone: 'Asia/Tokyo',
checkOutDate: '2025-10-05',
checkOutTime: '12:00',
checkOutTimezone: 'Asia/Tokyo',
confirmationNumber: 'HYT123',
price: 1200,
currency: 'JPY',
cityName: 'Tokyo',
country: 'Japan',
countryCode: 'JP'
});
expect(lodging.chain).toBe('Hyatt');
expect(lodging.check_in_date).toBe('2025-10-01');
expect(lodging.check_in_time).toBe('15:00');
expect(lodging.check_in_timezone).toBe('Asia/Tokyo');
expect(lodging.check_out_date).toBe('2025-10-05');
expect(lodging.confirmation_number).toBe('HYT123');
expect(lodging.price).toBe(1200);
expect(lodging.currency).toBe('JPY');
});
it('sets parent_id when provided', () => {
const trip = makeTrip();
// Create a parent plan directly
db.run(
`INSERT INTO plans (id, trip_id, user_id, type, status, title, position)
VALUES ('parent-plan-1', ?, 'u1', 'tour', 'confirmed', 'Tour', 0)`,
[trip.id]
);
const lodging = createLodging({
tripId: trip.id,
userId: 'u1',
name: 'Tour Hotel',
parentId: 'parent-plan-1'
});
const planRow = db.get<{ parent_id: string | null }>(
'SELECT parent_id FROM plans WHERE id = ?',
[lodging.plan_id]
);
expect(planRow?.parent_id).toBe('parent-plan-1');
});
it('links guests', () => {
const trip = makeTrip();
const person = createPerson('u1', 'Alice', 'Smith');
const lodging = createLodging({
tripId: trip.id,
userId: 'u1',
name: 'Boutique Inn',
guestIds: [person.id]
});
const guests = db.all<{ person_id: string }>(
'SELECT person_id FROM lodging_guests WHERE lodging_id = ?',
[lodging.id]
);
expect(guests.map((g) => g.person_id)).toContain(person.id);
});
});
describe('getLodgingsForTrip', () => {
it('returns lodgings with planStatus and guestIds', () => {
const trip = makeTrip();
const person = createPerson('u1', 'Bob', 'Jones');
createLodging({
tripId: trip.id,
userId: 'u1',
name: 'Grand Hotel',
status: 'confirmed',
guestIds: [person.id]
});
const lodgings = getLodgingsForTrip(trip.id, 'u1');
expect(lodgings).toHaveLength(1);
expect(lodgings[0].planStatus).toBe('confirmed');
expect(lodgings[0].guestIds).toContain(person.id);
});
it('does not return lodgings for another user', () => {
const trip = makeTrip('u1');
createLodging({ tripId: trip.id, userId: 'u1', name: 'Secret Hotel' });
expect(getLodgingsForTrip(trip.id, 'u2')).toHaveLength(0);
});
});
describe('updateLodging', () => {
it('updates both plan and lodging rows', () => {
const trip = makeTrip();
const lodging = createLodging({
tripId: trip.id,
userId: 'u1',
name: 'Old Name',
status: 'idea'
});
updateLodging({
lodgingId: lodging.id,
userId: 'u1',
name: 'New Name',
status: 'confirmed',
price: 500,
currency: 'EUR'
});
const updated = getLodgingsForTrip(trip.id, 'u1')[0];
expect(updated.name).toBe('New Name');
expect(updated.planStatus).toBe('confirmed');
expect(updated.price).toBe(500);
expect(updated.currency).toBe('EUR');
});
it('replaces guests on update', () => {
const trip = makeTrip();
const p1 = createPerson('u1', 'A', 'A');
const p2 = createPerson('u1', 'B', 'B');
const lodging = createLodging({
tripId: trip.id,
userId: 'u1',
name: 'Hotel',
guestIds: [p1.id]
});
updateLodging({ lodgingId: lodging.id, userId: 'u1', name: 'Hotel', guestIds: [p2.id] });
const updated = getLodgingsForTrip(trip.id, 'u1')[0];
expect(updated.guestIds).toContain(p2.id);
expect(updated.guestIds).not.toContain(p1.id);
});
it('throws when the user does not own the lodging', () => {
const trip = makeTrip();
const lodging = createLodging({ tripId: trip.id, userId: 'u1', name: 'Hotel' });
expect(() =>
updateLodging({ lodgingId: lodging.id, userId: 'u2', name: 'Hijacked' })
).toThrow();
});
});

View File

@@ -31,6 +31,7 @@ export interface Lodging {
export interface CreateLodgingInput {
tripId: string;
userId: string;
parentId?: string;
status?: PlanStatus;
name: string;
chain?: string;
@@ -89,8 +90,8 @@ export function createLodging(input: CreateLodgingInput): Lodging {
const position = maxPos?.pos ?? 0;
db.run(
`INSERT INTO plans (id, trip_id, user_id, type, status, title, city_name, country, country_code, position)
VALUES (?, ?, ?, 'lodging', ?, ?, ?, ?, ?, ?)`,
`INSERT INTO plans (id, trip_id, user_id, type, status, title, city_name, country, country_code, parent_id, position)
VALUES (?, ?, ?, 'lodging', ?, ?, ?, ?, ?, ?, ?)`,
[
planId,
input.tripId,
@@ -100,6 +101,7 @@ export function createLodging(input: CreateLodgingInput): Lodging {
input.cityName ?? null,
input.country ?? null,
input.countryCode ?? null,
input.parentId ?? null,
position
]
);
@@ -140,10 +142,10 @@ export function createLodging(input: CreateLodgingInput): Lodging {
if (input.guestIds && input.guestIds.length > 0) {
for (const personId of input.guestIds) {
db.run(
`INSERT OR IGNORE INTO lodging_guests (lodging_id, person_id) VALUES (?, ?)`,
[lodgingId, personId]
);
db.run(`INSERT OR IGNORE INTO lodging_guests (lodging_id, person_id) VALUES (?, ?)`, [
lodgingId,
personId
]);
}
}
@@ -165,10 +167,9 @@ export function getLodgingsForTrip(
return lodgings.map((lodging) => {
const guestIds = db
.all<{ person_id: string }>(
'SELECT person_id FROM lodging_guests WHERE lodging_id = ?',
[lodging.id]
)
.all<{
person_id: string;
}>('SELECT person_id FROM lodging_guests WHERE lodging_id = ?', [lodging.id])
.map((r) => r.person_id);
const { plan_status, ...rest } = lodging;
return { ...rest, guestIds, planStatus: plan_status as PlanStatus };
@@ -236,10 +237,10 @@ export function updateLodging(input: UpdateLodgingInput): void {
db.run('DELETE FROM lodging_guests WHERE lodging_id = ?', [input.lodgingId]);
if (input.guestIds && input.guestIds.length > 0) {
for (const personId of input.guestIds) {
db.run(
`INSERT OR IGNORE INTO lodging_guests (lodging_id, person_id) VALUES (?, ?)`,
[input.lodgingId, personId]
);
db.run(`INSERT OR IGNORE INTO lodging_guests (lodging_id, person_id) VALUES (?, ?)`, [
input.lodgingId,
personId
]);
}
}
}

View File

@@ -0,0 +1,183 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { setupTestDb } from '../../tests/helpers.js';
import type { Database } from './db/types.js';
import { createTrip } from './trips.js';
import { createPerson } from './travellers.js';
import { createPackageTour, getPackageToursForTrip, updatePackageTour } from './package-tours.js';
let db: Database;
beforeEach(() => {
db = setupTestDb();
});
afterEach(() => {
db.close();
});
function makeTrip(userId = 'u1') {
return createTrip({ userId, name: 'Test Trip' });
}
describe('createPackageTour', () => {
it('creates plan and package_tours rows', () => {
const trip = makeTrip();
const tour = createPackageTour({ tripId: trip.id, userId: 'u1', operatorName: 'Viking' });
expect(tour.id).toBeTruthy();
expect(tour.operator_name).toBe('Viking');
expect(tour.plan_id).toBeTruthy();
expect(tour.currency).toBe('USD');
const plan = db.get<{ type: string; title: string }>(
'SELECT type, title FROM plans WHERE id = ?',
[tour.plan_id]
);
expect(plan?.type).toBe('tour');
expect(plan?.title).toBe('Viking');
});
it('stores all optional fields', () => {
const trip = makeTrip();
const tour = createPackageTour({
tripId: trip.id,
userId: 'u1',
operatorName: 'Trafalgar',
status: 'confirmed',
confirmationNumber: 'TRF999',
startDate: '2025-08-01',
startTime: '09:00',
startTimezone: 'Europe/London',
endDate: '2025-08-15',
endTime: '18:00',
endTimezone: 'Europe/London',
price: 4500,
currency: 'GBP'
});
expect(tour.confirmation_number).toBe('TRF999');
expect(tour.start_date).toBe('2025-08-01');
expect(tour.start_time).toBe('09:00');
expect(tour.start_timezone).toBe('Europe/London');
expect(tour.end_date).toBe('2025-08-15');
expect(tour.price).toBe(4500);
expect(tour.currency).toBe('GBP');
});
it('links travellers', () => {
const trip = makeTrip();
const person = createPerson('u1', 'Alice', 'A');
const tour = createPackageTour({
tripId: trip.id,
userId: 'u1',
operatorName: 'G Adventures',
travellerIds: [person.id]
});
const rows = db.all<{ person_id: string }>(
'SELECT person_id FROM package_tour_travellers WHERE package_tour_id = ?',
[tour.id]
);
expect(rows.map((r) => r.person_id)).toContain(person.id);
});
});
describe('getPackageToursForTrip', () => {
it('returns tours with planStatus, travellerIds, and childPlans', () => {
const trip = makeTrip();
const person = createPerson('u1', 'Bob', 'B');
const tour = createPackageTour({
tripId: trip.id,
userId: 'u1',
operatorName: 'Contiki',
status: 'tentative',
travellerIds: [person.id]
});
// Insert a child plan manually
db.run(
`INSERT INTO plans (id, trip_id, user_id, type, status, title, parent_id, position)
VALUES ('child-1', ?, 'u1', 'lodging', 'confirmed', 'Tour Hotel', ?, 99)`,
[trip.id, tour.plan_id]
);
const tours = getPackageToursForTrip(trip.id, 'u1');
expect(tours).toHaveLength(1);
expect(tours[0].planStatus).toBe('tentative');
expect(tours[0].travellerIds).toContain(person.id);
expect(tours[0].childPlans).toHaveLength(1);
expect(tours[0].childPlans[0].title).toBe('Tour Hotel');
expect(tours[0].childPlans[0].type).toBe('lodging');
});
it('returns highlight_color from the tour_operators table', () => {
const trip = makeTrip();
db.run(`INSERT INTO tour_operators (name, highlight_color) VALUES ('Viking', '#F97316')`);
createPackageTour({ tripId: trip.id, userId: 'u1', operatorName: 'Viking' });
const tours = getPackageToursForTrip(trip.id, 'u1');
expect(tours[0].highlight_color).toBe('#F97316');
});
it('returns null highlight_color when operator is not in tour_operators', () => {
const trip = makeTrip();
createPackageTour({ tripId: trip.id, userId: 'u1', operatorName: 'Unknown Operator' });
const tours = getPackageToursForTrip(trip.id, 'u1');
expect(tours[0].highlight_color).toBeNull();
});
it('does not return tours for another user', () => {
const trip = makeTrip('u1');
createPackageTour({ tripId: trip.id, userId: 'u1', operatorName: 'Globus' });
expect(getPackageToursForTrip(trip.id, 'u2')).toHaveLength(0);
});
});
describe('updatePackageTour', () => {
it('updates both plan and package_tours rows', () => {
const trip = makeTrip();
const tour = createPackageTour({
tripId: trip.id,
userId: 'u1',
operatorName: 'Old Name',
status: 'idea'
});
updatePackageTour({
tourId: tour.id,
userId: 'u1',
operatorName: 'New Name',
status: 'confirmed',
price: 2000,
currency: 'EUR'
});
const updated = getPackageToursForTrip(trip.id, 'u1')[0];
expect(updated.operator_name).toBe('New Name');
expect(updated.planStatus).toBe('confirmed');
expect(updated.price).toBe(2000);
expect(updated.currency).toBe('EUR');
});
it('replaces travellers on update', () => {
const trip = makeTrip();
const p1 = createPerson('u1', 'A', 'A');
const p2 = createPerson('u1', 'B', 'B');
const tour = createPackageTour({
tripId: trip.id,
userId: 'u1',
operatorName: 'Tour',
travellerIds: [p1.id]
});
updatePackageTour({
tourId: tour.id,
userId: 'u1',
operatorName: 'Tour',
travellerIds: [p2.id]
});
const updated = getPackageToursForTrip(trip.id, 'u1')[0];
expect(updated.travellerIds).toContain(p2.id);
expect(updated.travellerIds).not.toContain(p1.id);
});
it('throws when the user does not own the tour', () => {
const trip = makeTrip();
const tour = createPackageTour({ tripId: trip.id, userId: 'u1', operatorName: 'Tour' });
expect(() =>
updatePackageTour({ tourId: tour.id, userId: 'u2', operatorName: 'Hijacked' })
).toThrow();
});
});

View File

@@ -0,0 +1,202 @@
import { db } from './db/index.js';
import { randomUUID } from 'crypto';
import type { PlanStatus } from './plans.js';
export interface PackageTour {
id: string;
plan_id: string;
operator_name: string;
confirmation_number: string | null;
start_date: string | null;
start_time: string | null;
start_timezone: string | null;
end_date: string | null;
end_time: string | null;
end_timezone: string | null;
price: number | null;
currency: string;
created_at: string;
updated_at: string;
}
export interface ChildPlanSummary {
id: string;
type: string;
title: string;
status: PlanStatus;
start_date: string | null;
}
export interface CreatePackageTourInput {
tripId: string;
userId: string;
status?: PlanStatus;
operatorName: string;
confirmationNumber?: string;
startDate?: string;
startTime?: string;
startTimezone?: string;
endDate?: string;
endTime?: string;
endTimezone?: string;
price?: number;
currency?: string;
travellerIds?: string[];
}
export interface UpdatePackageTourInput {
tourId: string;
userId: string;
status?: PlanStatus;
operatorName: string;
confirmationNumber?: string;
startDate?: string;
startTime?: string;
startTimezone?: string;
endDate?: string;
endTime?: string;
endTimezone?: string;
price?: number;
currency?: string;
travellerIds?: string[];
}
export function createPackageTour(input: CreatePackageTourInput): PackageTour {
const planId = randomUUID();
const maxPos = db.get<{ pos: number }>(
`SELECT COALESCE(MAX(position), -1) + 1 as pos FROM plans WHERE trip_id = ? AND user_id = ?`,
[input.tripId, input.userId]
);
const position = maxPos?.pos ?? 0;
db.run(
`INSERT INTO plans (id, trip_id, user_id, type, status, title, position)
VALUES (?, ?, ?, 'tour', ?, ?, ?)`,
[planId, input.tripId, input.userId, input.status ?? 'idea', input.operatorName, position]
);
const tourId = randomUUID();
db.run(
`INSERT INTO package_tours (
id, plan_id, operator_name, confirmation_number,
start_date, start_time, start_timezone,
end_date, end_time, end_timezone,
price, currency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
tourId,
planId,
input.operatorName,
input.confirmationNumber ?? null,
input.startDate ?? null,
input.startTime ?? null,
input.startTimezone ?? null,
input.endDate ?? null,
input.endTime ?? null,
input.endTimezone ?? null,
input.price ?? null,
input.currency ?? 'USD'
]
);
if (input.travellerIds && input.travellerIds.length > 0) {
for (const personId of input.travellerIds) {
db.run(
`INSERT OR IGNORE INTO package_tour_travellers (package_tour_id, person_id) VALUES (?, ?)`,
[tourId, personId]
);
}
}
return db.get<PackageTour>('SELECT * FROM package_tours WHERE id = ?', [tourId])!;
}
export function getPackageToursForTrip(
tripId: string,
userId: string
): Array<
PackageTour & {
travellerIds: string[];
planStatus: PlanStatus;
childPlans: ChildPlanSummary[];
highlight_color: string | null;
}
> {
const tours = db.all<PackageTour & { plan_status: string; highlight_color: string | null }>(
`SELECT pt.*, p.status as plan_status,
(SELECT o.highlight_color FROM tour_operators o WHERE o.name = pt.operator_name LIMIT 1) as highlight_color
FROM package_tours pt
JOIN plans p ON p.id = pt.plan_id
WHERE p.trip_id = ? AND p.user_id = ? AND p.type = 'tour'
ORDER BY p.position ASC, pt.created_at ASC`,
[tripId, userId]
);
return tours.map((tour) => {
const travellerIds = db
.all<{
person_id: string;
}>('SELECT person_id FROM package_tour_travellers WHERE package_tour_id = ?', [tour.id])
.map((r) => r.person_id);
const childPlans = db.all<ChildPlanSummary>(
`SELECT id, type, title, status, start_date
FROM plans
WHERE parent_id = ? AND user_id = ?
ORDER BY position ASC, created_at ASC`,
[tour.plan_id, userId]
);
const { plan_status, ...rest } = tour;
return { ...rest, travellerIds, planStatus: plan_status as PlanStatus, childPlans };
});
}
export function updatePackageTour(input: UpdatePackageTourInput): void {
const tour = db.get<{ id: string; plan_id: string }>(
`SELECT pt.id, pt.plan_id FROM package_tours pt
JOIN plans p ON p.id = pt.plan_id
WHERE pt.id = ? AND p.user_id = ?`,
[input.tourId, input.userId]
);
if (!tour) throw new Error('Package tour not found or not authorized');
db.run(`UPDATE plans SET status = ?, title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [
input.status ?? 'idea',
input.operatorName,
tour.plan_id
]);
db.run(
`UPDATE package_tours SET
operator_name = ?, confirmation_number = ?,
start_date = ?, start_time = ?, start_timezone = ?,
end_date = ?, end_time = ?, end_timezone = ?,
price = ?, currency = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[
input.operatorName,
input.confirmationNumber ?? null,
input.startDate ?? null,
input.startTime ?? null,
input.startTimezone ?? null,
input.endDate ?? null,
input.endTime ?? null,
input.endTimezone ?? null,
input.price ?? null,
input.currency ?? 'USD',
input.tourId
]
);
db.run('DELETE FROM package_tour_travellers WHERE package_tour_id = ?', [input.tourId]);
if (input.travellerIds && input.travellerIds.length > 0) {
for (const personId of input.travellerIds) {
db.run(
`INSERT OR IGNORE INTO package_tour_travellers (package_tour_id, person_id) VALUES (?, ?)`,
[input.tourId, personId]
);
}
}
}

View File

@@ -0,0 +1,133 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { setupTestDb } from '../../tests/helpers.js';
import type { Database } from './db/types.js';
import { createTrip } from './trips.js';
import {
createDestination,
getPlansForTrip,
getPlanCountForTrip,
deletePlan,
searchCities
} from './plans.js';
let db: Database;
beforeEach(() => { db = setupTestDb(); });
afterEach(() => { db.close(); });
// Shared fixture
function makeTrip(userId = 'u1') {
return createTrip({ userId, name: 'Test Trip' });
}
describe('createDestination', () => {
it('creates a plan row with type destination', () => {
const trip = makeTrip();
const plan = createDestination({
tripId: trip.id,
userId: 'u1',
cityName: 'Tokyo',
country: 'Japan',
countryCode: 'JP'
});
expect(plan.type).toBe('destination');
expect(plan.title).toBe('Tokyo');
expect(plan.country).toBe('Japan');
expect(plan.country_code).toBe('JP');
expect(plan.trip_id).toBe(trip.id);
expect(plan.user_id).toBe('u1');
expect(plan.status).toBe('idea');
});
it('assigns incrementing positions', () => {
const trip = makeTrip();
const p1 = createDestination({ tripId: trip.id, userId: 'u1', cityName: 'A', country: 'X', countryCode: 'XX' });
const p2 = createDestination({ tripId: trip.id, userId: 'u1', cityName: 'B', country: 'X', countryCode: 'XX' });
expect(p2.position).toBe(p1.position + 1);
});
it('stores optional status, dates and notes', () => {
const trip = makeTrip();
const plan = createDestination({
tripId: trip.id,
userId: 'u1',
cityName: 'Paris',
country: 'France',
countryCode: 'FR',
status: 'confirmed',
startDate: '2025-09-01',
endDate: '2025-09-10',
notes: 'city of light'
});
expect(plan.status).toBe('confirmed');
expect(plan.start_date).toBe('2025-09-01');
expect(plan.end_date).toBe('2025-09-10');
expect(plan.notes).toBe('city of light');
});
});
describe('getPlansForTrip', () => {
it('returns only plans for the specified trip and user', () => {
const trip1 = makeTrip('u1');
const trip2 = makeTrip('u1');
createDestination({ tripId: trip1.id, userId: 'u1', cityName: 'Rome', country: 'Italy', countryCode: 'IT' });
createDestination({ tripId: trip2.id, userId: 'u1', cityName: 'Madrid', country: 'Spain', countryCode: 'ES' });
const plans = getPlansForTrip(trip1.id, 'u1');
expect(plans).toHaveLength(1);
expect(plans[0].title).toBe('Rome');
});
it('does not return plans belonging to another user', () => {
const trip = makeTrip('u1');
createDestination({ tripId: trip.id, userId: 'u1', cityName: 'Oslo', country: 'Norway', countryCode: 'NO' });
expect(getPlansForTrip(trip.id, 'u2')).toHaveLength(0);
});
});
describe('getPlanCountForTrip', () => {
it('counts plans correctly', () => {
const trip = makeTrip();
expect(getPlanCountForTrip(trip.id, 'u1')).toBe(0);
createDestination({ tripId: trip.id, userId: 'u1', cityName: 'A', country: 'X', countryCode: 'XX' });
createDestination({ tripId: trip.id, userId: 'u1', cityName: 'B', country: 'X', countryCode: 'XX' });
expect(getPlanCountForTrip(trip.id, 'u1')).toBe(2);
});
});
describe('deletePlan', () => {
it('removes the plan from the database', () => {
const trip = makeTrip();
const plan = createDestination({ tripId: trip.id, userId: 'u1', cityName: 'X', country: 'Y', countryCode: 'YY' });
deletePlan(plan.id, 'u1');
expect(getPlansForTrip(trip.id, 'u1')).toHaveLength(0);
});
it('throws when the user does not own the plan', () => {
const trip = makeTrip();
const plan = createDestination({ tripId: trip.id, userId: 'u1', cityName: 'X', country: 'Y', countryCode: 'YY' });
expect(() => deletePlan(plan.id, 'u2')).toThrow();
});
});
describe('searchCities', () => {
it('returns an empty array for an empty query', () => {
expect(searchCities('')).toHaveLength(0);
});
it('returns matching cities from the seeded data', () => {
// The migrations seed cities from a CSV. Tokyo should be present.
const results = searchCities('Tokyo');
// The CSV may or may not be available in test environment; if present we get results
if (results.length > 0) {
expect(results[0].name).toMatch(/Tokyo/i);
}
});
it('filters by country code when provided', () => {
const all = searchCities('Paris');
const fr = searchCities('Paris', 'FR');
// French results should be a subset
expect(fr.every((c) => c.country_code === 'FR')).toBe(true);
expect(fr.length).toBeLessThanOrEqual(all.length);
});
});

View File

@@ -107,17 +107,16 @@ export interface Country {
export function getCountries(query?: string): Country[] {
if (!query?.trim()) {
return db.all<Country>(
`SELECT DISTINCT country as name, country_code FROM cities ORDER BY country`
`SELECT name, country_code FROM countries ORDER BY name`
);
}
const pattern = `%${query.trim()}%`;
return db.all<Country>(
`SELECT DISTINCT country as name, country_code
FROM cities
WHERE country LIKE ? COLLATE NOCASE
ORDER BY country
`SELECT name, country_code FROM countries
WHERE name LIKE ? COLLATE NOCASE OR country_code LIKE ? COLLATE NOCASE
ORDER BY name
LIMIT 50`,
[pattern]
[pattern, pattern]
);
}

View File

@@ -0,0 +1,107 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { setupTestDb } from '../../tests/helpers.js';
import type { Database } from './db/types.js';
import {
createTrip,
getTripById,
updateTrip,
getUpcomingTrips,
getPastTrips
} from './trips.js';
let db: Database;
beforeEach(() => { db = setupTestDb(); });
afterEach(() => { db.close(); });
describe('createTrip', () => {
it('creates a trip and returns it with all fields', () => {
const trip = createTrip({ userId: 'u1', name: 'Japan 2025' });
expect(trip.id).toBeTruthy();
expect(trip.user_id).toBe('u1');
expect(trip.name).toBe('Japan 2025');
expect(trip.description).toBeNull();
expect(trip.start_date).toBeNull();
expect(trip.end_date).toBeNull();
expect(trip.created_at).toBeTruthy();
});
it('stores optional fields', () => {
const trip = createTrip({
userId: 'u1',
name: 'Europe',
description: 'A grand tour',
startDate: '2025-06-01',
endDate: '2025-06-30'
});
expect(trip.description).toBe('A grand tour');
expect(trip.start_date).toBe('2025-06-01');
expect(trip.end_date).toBe('2025-06-30');
});
});
describe('getTripById', () => {
it('returns the trip for the correct user', () => {
const created = createTrip({ userId: 'u1', name: 'Test' });
const found = getTripById(created.id, 'u1');
expect(found?.id).toBe(created.id);
});
it('returns undefined for a different user', () => {
const created = createTrip({ userId: 'u1', name: 'Test' });
expect(getTripById(created.id, 'u2')).toBeUndefined();
});
it('returns undefined for a non-existent id', () => {
expect(getTripById('no-such-id', 'u1')).toBeUndefined();
});
});
describe('updateTrip', () => {
it('updates name, description and dates', () => {
const trip = createTrip({ userId: 'u1', name: 'Original' });
const updated = updateTrip(trip.id, 'u1', {
name: 'Updated',
description: 'New desc',
startDate: '2025-07-01',
endDate: '2025-07-15'
});
expect(updated?.name).toBe('Updated');
expect(updated?.description).toBe('New desc');
expect(updated?.start_date).toBe('2025-07-01');
expect(updated?.end_date).toBe('2025-07-15');
});
it('does not update a trip belonging to another user', () => {
const trip = createTrip({ userId: 'u1', name: 'Mine' });
updateTrip(trip.id, 'u2', { name: 'Hijacked' });
// Original should be unchanged
expect(getTripById(trip.id, 'u1')?.name).toBe('Mine');
});
});
describe('getUpcomingTrips / getPastTrips', () => {
it('separates upcoming and past trips by end_date', () => {
const future = createTrip({ userId: 'u1', name: 'Future', startDate: '2099-01-01', endDate: '2099-12-31' });
const past = createTrip({ userId: 'u1', name: 'Past', startDate: '2000-01-01', endDate: '2000-12-31' });
// Trip with no end_date counts as upcoming
const noDate = createTrip({ userId: 'u1', name: 'TBD' });
const upcoming = getUpcomingTrips('u1');
const pastList = getPastTrips('u1');
expect(upcoming.map((t) => t.id)).toContain(future.id);
expect(upcoming.map((t) => t.id)).toContain(noDate.id);
expect(upcoming.map((t) => t.id)).not.toContain(past.id);
expect(pastList.map((t) => t.id)).toContain(past.id);
expect(pastList.map((t) => t.id)).not.toContain(future.id);
expect(pastList.map((t) => t.id)).not.toContain(noDate.id);
});
it('only returns trips for the requested user', () => {
createTrip({ userId: 'u1', name: 'U1 trip', endDate: '2099-01-01' });
createTrip({ userId: 'u2', name: 'U2 trip', endDate: '2099-01-01' });
const u1trips = getUpcomingTrips('u1');
expect(u1trips.every((t) => t.user_id === 'u1')).toBe(true);
});
});