trips) adding package-tours option
This commit is contained in:
12
src/lib/server/admin/auth.ts
Normal file
12
src/lib/server/admin/auth.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
313
src/lib/server/admin/data.ts
Normal file
313
src/lib/server/admin/data.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
169
src/lib/server/lodgings.test.ts
Normal file
169
src/lib/server/lodgings.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
183
src/lib/server/package-tours.test.ts
Normal file
183
src/lib/server/package-tours.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
202
src/lib/server/package-tours.ts
Normal file
202
src/lib/server/package-tours.ts
Normal 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]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
133
src/lib/server/plans.test.ts
Normal file
133
src/lib/server/plans.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
107
src/lib/server/trips.test.ts
Normal file
107
src/lib/server/trips.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user