trips) adding initial commit
This commit is contained in:
1
src/lib/server/data/cities.json
Normal file
1
src/lib/server/data/cities.json
Normal file
File diff suppressed because one or more lines are too long
20
src/lib/server/db/index.ts
Normal file
20
src/lib/server/db/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { createSqliteDb } from './sqlite.js';
|
||||
import { runMigrations } from './migrations.js';
|
||||
import type { Database } from './types.js';
|
||||
|
||||
function createDb(): Database {
|
||||
const url = env.DATABASE_URL ?? 'file:trips.db';
|
||||
|
||||
if (url.startsWith('file:') || url.endsWith('.db')) {
|
||||
return createSqliteDb(url);
|
||||
}
|
||||
|
||||
// Postgres support: add a postgres.ts adapter and import it here
|
||||
// e.g. if (url.startsWith('postgres')) return createPostgresDb(url);
|
||||
throw new Error(`Unsupported DATABASE_URL scheme: ${url}`);
|
||||
}
|
||||
|
||||
export const db: Database = createDb();
|
||||
|
||||
runMigrations(db);
|
||||
112
src/lib/server/db/migrations.ts
Normal file
112
src/lib/server/db/migrations.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import type { Database } from './types.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export function runMigrations(db: Database): void {
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS trips (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
start_date TEXT,
|
||||
end_date TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS cities (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
country TEXT NOT NULL,
|
||||
country_code TEXT NOT NULL,
|
||||
population INTEGER
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS plans (
|
||||
id TEXT PRIMARY KEY,
|
||||
trip_id TEXT NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'idea',
|
||||
parent_id TEXT REFERENCES plans(id) ON DELETE SET NULL,
|
||||
title TEXT NOT NULL,
|
||||
notes TEXT,
|
||||
city_id INTEGER REFERENCES cities(id),
|
||||
city_name TEXT,
|
||||
country TEXT,
|
||||
country_code TEXT,
|
||||
start_date TEXT,
|
||||
end_date TEXT,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS travellers (
|
||||
id TEXT PRIMARY KEY,
|
||||
trip_id TEXT NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS people (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
is_self INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS trip_travellers (
|
||||
trip_id TEXT NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
person_id TEXT NOT NULL REFERENCES people(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (trip_id, person_id)
|
||||
)
|
||||
`);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
function seedCities(db: Database): void {
|
||||
try {
|
||||
const data = readFileSync(join(__dirname, '../data/cities.json'), 'utf-8');
|
||||
const cities: { name: string; country: string; country_code: string; population: number }[] =
|
||||
JSON.parse(data);
|
||||
let id = 1;
|
||||
for (const city of cities) {
|
||||
db.run(
|
||||
'INSERT OR IGNORE INTO cities (id, name, country, country_code, population) VALUES (?, ?, ?, ?, ?)',
|
||||
[id++, city.name, city.country, city.country_code, city.population]
|
||||
);
|
||||
}
|
||||
console.log(`[db] Seeded ${cities.length} cities`);
|
||||
} catch (e) {
|
||||
console.error('[db] Failed to seed cities:', e);
|
||||
}
|
||||
}
|
||||
26
src/lib/server/db/sqlite.ts
Normal file
26
src/lib/server/db/sqlite.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import BetterSqlite3 from 'better-sqlite3';
|
||||
import type { Database } from './types.js';
|
||||
|
||||
export function createSqliteDb(url: string): Database {
|
||||
// Strip the "file:" prefix if present
|
||||
const path = url.startsWith('file:') ? url.slice(5) : url;
|
||||
const db = new BetterSqlite3(path);
|
||||
|
||||
// Enable WAL mode for better concurrent read performance
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
return {
|
||||
run(sql, params = []) {
|
||||
db.prepare(sql).run(params);
|
||||
},
|
||||
get<T = Record<string, unknown>>(sql: string, params: unknown[] = []) {
|
||||
return db.prepare(sql).get(params) as T | undefined;
|
||||
},
|
||||
all<T = Record<string, unknown>>(sql: string, params: unknown[] = []) {
|
||||
return db.prepare(sql).all(params) as T[];
|
||||
},
|
||||
close() {
|
||||
db.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
10
src/lib/server/db/types.ts
Normal file
10
src/lib/server/db/types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface Database {
|
||||
/** Execute a statement that returns no rows (INSERT, UPDATE, DELETE, CREATE, etc.) */
|
||||
run(sql: string, params?: unknown[]): void;
|
||||
/** Execute a SELECT and return the first matching row, or undefined */
|
||||
get<T = Record<string, unknown>>(sql: string, params?: unknown[]): T | undefined;
|
||||
/** Execute a SELECT and return all matching rows */
|
||||
all<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[];
|
||||
/** Close the database connection */
|
||||
close(): void;
|
||||
}
|
||||
124
src/lib/server/plans.ts
Normal file
124
src/lib/server/plans.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { db } from './db/index.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export type PlanType =
|
||||
| 'destination'
|
||||
| 'activity'
|
||||
| 'transport'
|
||||
| 'lodging'
|
||||
| 'restaurant'
|
||||
| 'tour'
|
||||
| 'packing'
|
||||
| 'todo';
|
||||
|
||||
export type PlanStatus = 'idea' | 'tentative' | 'confirmed';
|
||||
|
||||
export interface Plan {
|
||||
id: string;
|
||||
trip_id: string;
|
||||
user_id: string;
|
||||
type: PlanType;
|
||||
status: PlanStatus;
|
||||
parent_id: string | null;
|
||||
title: string;
|
||||
notes: string | null;
|
||||
city_id: number | null;
|
||||
city_name: string | null;
|
||||
country: string | null;
|
||||
country_code: string | null;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
position: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface City {
|
||||
id: number;
|
||||
name: string;
|
||||
country: string;
|
||||
country_code: string;
|
||||
population: number | null;
|
||||
}
|
||||
|
||||
export interface CreateDestinationInput {
|
||||
tripId: string;
|
||||
userId: string;
|
||||
cityId?: number;
|
||||
cityName: string;
|
||||
country: string;
|
||||
countryCode: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
status?: PlanStatus;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export function createDestination(input: CreateDestinationInput): Plan {
|
||||
const id = 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, notes, city_id, city_name, country, country_code, start_date, end_date, position)
|
||||
VALUES (?, ?, ?, 'destination', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id,
|
||||
input.tripId,
|
||||
input.userId,
|
||||
input.status ?? 'idea',
|
||||
input.cityName,
|
||||
input.notes ?? null,
|
||||
input.cityId ?? null,
|
||||
input.cityName,
|
||||
input.country,
|
||||
input.countryCode,
|
||||
input.startDate ?? null,
|
||||
input.endDate ?? null,
|
||||
position
|
||||
]
|
||||
);
|
||||
return db.get<Plan>('SELECT * FROM plans WHERE id = ?', [id])!;
|
||||
}
|
||||
|
||||
export function getPlansForTrip(tripId: string, userId: string): Plan[] {
|
||||
return db.all<Plan>(
|
||||
`SELECT * FROM plans WHERE trip_id = ? AND user_id = ? ORDER BY position ASC, created_at ASC`,
|
||||
[tripId, userId]
|
||||
);
|
||||
}
|
||||
|
||||
export function getPlanCountForTrip(tripId: string, userId: string): number {
|
||||
const row = db.get<{ count: number }>(
|
||||
`SELECT COUNT(*) as count FROM plans WHERE trip_id = ? AND user_id = ?`,
|
||||
[tripId, userId]
|
||||
);
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
export function searchCities(query: string): City[] {
|
||||
if (!query.trim()) return [];
|
||||
const pattern = `%${query.trim()}%`;
|
||||
return db.all<City>(
|
||||
`SELECT id, name, country, country_code, population
|
||||
FROM cities
|
||||
WHERE name LIKE ? COLLATE NOCASE
|
||||
ORDER BY
|
||||
CASE WHEN name LIKE ? COLLATE NOCASE THEN 0 ELSE 1 END,
|
||||
population DESC NULLS LAST
|
||||
LIMIT 10`,
|
||||
[pattern, `${query.trim()}%`]
|
||||
);
|
||||
}
|
||||
|
||||
export function deletePlan(planId: string, userId: string): void {
|
||||
// Verify the plan belongs to the user
|
||||
const plan = db.get<Plan>('SELECT * FROM plans WHERE id = ? AND user_id = ?', [planId, userId]);
|
||||
if (!plan) {
|
||||
throw new Error('Plan not found or not authorized');
|
||||
}
|
||||
db.run('DELETE FROM plans WHERE id = ?', [planId]);
|
||||
}
|
||||
142
src/lib/server/travellers.ts
Normal file
142
src/lib/server/travellers.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { db } from './db/index.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export interface Person {
|
||||
id: string;
|
||||
user_id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string | null;
|
||||
is_self: number; // 1 = owner's own profile
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global people management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getSelfProfile(userId: string): Person | undefined {
|
||||
return db.get<Person>(`SELECT * FROM people WHERE user_id = ? AND is_self = 1 LIMIT 1`, [userId]);
|
||||
}
|
||||
|
||||
export function upsertSelfProfile(
|
||||
userId: string,
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
email?: string
|
||||
): Person {
|
||||
const existing = getSelfProfile(userId);
|
||||
if (existing) {
|
||||
db.run(
|
||||
`UPDATE people SET first_name = ?, last_name = ?, email = ?, updated_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
[firstName, lastName, email ?? null, existing.id]
|
||||
);
|
||||
return db.get<Person>('SELECT * FROM people WHERE id = ?', [existing.id])!;
|
||||
}
|
||||
const id = randomUUID();
|
||||
db.run(
|
||||
`INSERT INTO people (id, user_id, first_name, last_name, email, is_self)
|
||||
VALUES (?, ?, ?, ?, ?, 1)`,
|
||||
[id, userId, firstName, lastName, email ?? null]
|
||||
);
|
||||
return db.get<Person>('SELECT * FROM people WHERE id = ?', [id])!;
|
||||
}
|
||||
|
||||
export function createPerson(
|
||||
userId: string,
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
email?: string
|
||||
): Person {
|
||||
const id = randomUUID();
|
||||
db.run(
|
||||
`INSERT INTO people (id, user_id, first_name, last_name, email, is_self)
|
||||
VALUES (?, ?, ?, ?, ?, 0)`,
|
||||
[id, userId, firstName, lastName, email ?? null]
|
||||
);
|
||||
return db.get<Person>('SELECT * FROM people WHERE id = ?', [id])!;
|
||||
}
|
||||
|
||||
export function getPeopleForUser(userId: string): Person[] {
|
||||
// Own profile first, then others alphabetically
|
||||
return db.all<Person>(
|
||||
`SELECT * FROM people WHERE user_id = ?
|
||||
ORDER BY is_self DESC, first_name ASC, last_name ASC`,
|
||||
[userId]
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trip assignment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function addPersonToTrip(tripId: string, personId: string): void {
|
||||
const maxPos = db.get<{ pos: number }>(
|
||||
`SELECT COALESCE(MAX(position), -1) + 1 as pos FROM trip_travellers WHERE trip_id = ?`,
|
||||
[tripId]
|
||||
);
|
||||
db.run(`INSERT OR IGNORE INTO trip_travellers (trip_id, person_id, position) VALUES (?, ?, ?)`, [
|
||||
tripId,
|
||||
personId,
|
||||
maxPos?.pos ?? 0
|
||||
]);
|
||||
}
|
||||
|
||||
export function addTraveller(input: {
|
||||
tripId: string;
|
||||
userId: string;
|
||||
personId?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
}): void {
|
||||
let personId = input.personId;
|
||||
|
||||
// If no personId provided, create a new person
|
||||
if (!personId) {
|
||||
if (!input.firstName || !input.lastName) {
|
||||
throw new Error('First name and last name are required when creating a new person');
|
||||
}
|
||||
const person = createPerson(input.userId, input.firstName, input.lastName, input.email);
|
||||
personId = person.id;
|
||||
}
|
||||
|
||||
// Add the person to the trip
|
||||
addPersonToTrip(input.tripId, personId);
|
||||
}
|
||||
|
||||
export function getTravellersForTrip(tripId: string, userId: string): Person[] {
|
||||
return db.all<Person>(
|
||||
`SELECT p.* FROM people p
|
||||
JOIN trip_travellers tt ON tt.person_id = p.id
|
||||
WHERE tt.trip_id = ? AND p.user_id = ?
|
||||
ORDER BY tt.position ASC, p.first_name ASC`,
|
||||
[tripId, userId]
|
||||
);
|
||||
}
|
||||
|
||||
export function getTravellerCountForTrip(tripId: string, userId: string): number {
|
||||
const row = db.get<{ count: number }>(
|
||||
`SELECT COUNT(*) as count FROM trip_travellers tt
|
||||
JOIN people p ON p.id = tt.person_id
|
||||
WHERE tt.trip_id = ? AND p.user_id = ?`,
|
||||
[tripId, userId]
|
||||
);
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
export function removeTravellerFromTrip(tripId: string, personId: string, userId: string): void {
|
||||
// Verify the person belongs to the user and is on the trip
|
||||
const traveller = db.get<Person>(
|
||||
`SELECT p.* FROM people p
|
||||
JOIN trip_travellers tt ON tt.person_id = p.id
|
||||
WHERE tt.trip_id = ? AND p.id = ? AND p.user_id = ?`,
|
||||
[tripId, personId, userId]
|
||||
);
|
||||
if (!traveller) {
|
||||
throw new Error('Traveller not found or not authorized');
|
||||
}
|
||||
db.run(`DELETE FROM trip_travellers WHERE trip_id = ? AND person_id = ?`, [tripId, personId]);
|
||||
}
|
||||
89
src/lib/server/trips.ts
Normal file
89
src/lib/server/trips.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { db } from './db/index.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export interface Trip {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateTripInput {
|
||||
userId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTripInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export function createTrip(input: CreateTripInput): Trip {
|
||||
const id = randomUUID();
|
||||
db.run(
|
||||
`INSERT INTO trips (id, user_id, name, description, start_date, end_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id,
|
||||
input.userId,
|
||||
input.name,
|
||||
input.description ?? null,
|
||||
input.startDate ?? null,
|
||||
input.endDate ?? null
|
||||
]
|
||||
);
|
||||
return db.get<Trip>('SELECT * FROM trips WHERE id = ?', [id])!;
|
||||
}
|
||||
|
||||
export function updateTrip(id: string, userId: string, input: UpdateTripInput): Trip | undefined {
|
||||
db.run(
|
||||
`UPDATE trips
|
||||
SET name = ?, description = ?, start_date = ?, end_date = ?, updated_at = datetime('now')
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
[
|
||||
input.name,
|
||||
input.description ?? null,
|
||||
input.startDate ?? null,
|
||||
input.endDate ?? null,
|
||||
id,
|
||||
userId
|
||||
]
|
||||
);
|
||||
return db.get<Trip>('SELECT * FROM trips WHERE id = ? AND user_id = ?', [id, userId]);
|
||||
}
|
||||
|
||||
export function getTripById(id: string, userId: string): Trip | undefined {
|
||||
return db.get<Trip>('SELECT * FROM trips WHERE id = ? AND user_id = ?', [id, userId]);
|
||||
}
|
||||
|
||||
export function getUpcomingTrips(userId: string): Trip[] {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return db.all<Trip>(
|
||||
`SELECT * FROM trips
|
||||
WHERE user_id = ?
|
||||
AND (end_date IS NULL OR end_date >= ?)
|
||||
ORDER BY start_date ASC NULLS FIRST`,
|
||||
[userId, today]
|
||||
);
|
||||
}
|
||||
|
||||
export function getPastTrips(userId: string): Trip[] {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return db.all<Trip>(
|
||||
`SELECT * FROM trips
|
||||
WHERE user_id = ?
|
||||
AND end_date IS NOT NULL
|
||||
AND end_date < ?
|
||||
ORDER BY end_date DESC`,
|
||||
[userId, today]
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user