trips) adding transportation, checklist, and experience planning

This commit is contained in:
2026-02-20 19:47:51 -05:00
parent cf2fd658f1
commit a348fb034d
34 changed files with 6499 additions and 2040 deletions

View File

@@ -428,9 +428,15 @@ export function deleteOperatorTourDay(id: number): void {
}
}
// --- Operator Tour Day Plans (transport / lodging templates per day) ---
// --- Operator Tour Day Plans (template plans per day) ---
export type OperatorTourDayPlanType = 'transport' | 'lodging';
export type OperatorTourDayPlanType =
| 'transport'
| 'lodging'
| 'activity'
| 'restaurant'
| 'packing'
| 'todo';
export interface OperatorTourDayPlan {
id: number;
@@ -449,6 +455,23 @@ export interface OperatorTourDayPlan {
country?: string | null;
country_code?: string | null;
postal_code?: string | null;
// Transport-only template details (other transport mechanism)
transport_kind?: 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;
// Activity/restaurant fields
booking_id?: string | null;
total_cost?: number | null;
description?: string | null;
website?: string | null;
address?: string | null;
contact_number?: string | null;
// Packing/todo fields
items_json?: string | null;
}
export function listPlansForOperatorTour(tourId: number): OperatorTourDayPlan[] {
@@ -474,7 +497,31 @@ export function createOperatorTourDayPlan(
country?: string | null;
country_code?: string | null;
postal_code?: string | null;
}
},
transportFields?: {
transport_kind?: 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;
},
experienceFields?: {
booking_id?: string | null;
total_cost?: number | null;
description?: string | null;
website?: string | null;
address?: string | null;
contact_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;
},
checklistItemsJson?: string | null
): OperatorTourDayPlan {
const maxPos = db.get<{ pos: number }>(
'SELECT COALESCE(MAX(position), -1) + 1 as pos FROM operator_tour_day_plans WHERE operator_tour_day_id = ?',
@@ -483,32 +530,68 @@ export function createOperatorTourDayPlan(
const position = maxPos?.pos ?? 0;
const l = lodgingFields;
const cols =
type === 'lodging' && l
? 'operator_tour_day_id, type, title, notes, position, chain, address_line1, address_line2, city_name, country, country_code, postal_code'
: 'operator_tour_day_id, type, title, notes, position';
const vals =
type === 'lodging' && l
? [
dayId,
type,
title.trim(),
notes?.trim() || null,
position,
l.chain?.trim() || null,
l.address_line1?.trim() || null,
l.address_line2?.trim() || null,
l.city_name?.trim() || null,
l.country?.trim() || null,
l.country_code?.trim() || null,
l.postal_code?.trim() || null
]
: [dayId, type, title.trim(), notes?.trim() || null, position];
const t = transportFields;
let cols = 'operator_tour_day_id, type, title, notes, position';
let vals: Array<string | number | null> = [
dayId,
type,
title.trim(),
notes?.trim() || null,
position
];
if (type === 'lodging' && l) {
cols += ', chain, address_line1, address_line2, city_name, country, country_code, postal_code';
vals = [
...vals,
l.chain?.trim() || null,
l.address_line1?.trim() || null,
l.address_line2?.trim() || null,
l.city_name?.trim() || null,
l.country?.trim() || null,
l.country_code?.trim() || null,
l.postal_code?.trim() || null
];
}
if (type === 'transport') {
cols +=
', transport_kind, start_date, start_time, start_timezone, end_date, end_time, end_timezone';
vals = [
...vals,
t?.transport_kind?.trim() || 'other',
t?.start_date?.trim() || null,
t?.start_time?.trim() || null,
t?.start_timezone?.trim() || null,
t?.end_date?.trim() || null,
t?.end_time?.trim() || null,
t?.end_timezone?.trim() || null
];
}
if (type === 'activity' || type === 'restaurant') {
const e = experienceFields;
cols +=
', booking_id, total_cost, description, website, address, contact_number, start_date, start_time, start_timezone, end_date, end_time, end_timezone';
vals = [
...vals,
e?.booking_id?.trim() || null,
e?.total_cost ?? null,
e?.description?.trim() || null,
e?.website?.trim() || null,
e?.address?.trim() || null,
e?.contact_number?.trim() || null,
e?.start_date?.trim() || null,
e?.start_time?.trim() || null,
e?.start_timezone?.trim() || null,
e?.end_date?.trim() || null,
e?.end_time?.trim() || null,
e?.end_timezone?.trim() || null
];
}
if (type === 'packing' || type === 'todo') {
cols += ', items_json';
vals = [...vals, checklistItemsJson?.trim() || null];
}
const placeholders = vals.map(() => '?').join(', ');
db.run(
`INSERT INTO operator_tour_day_plans (${cols}) VALUES (${placeholders})`,
vals
);
db.run(`INSERT INTO operator_tour_day_plans (${cols}) VALUES (${placeholders})`, vals);
return db.get<OperatorTourDayPlan>(
'SELECT * FROM operator_tour_day_plans WHERE id = last_insert_rowid()'
)!;
@@ -526,14 +609,28 @@ export function updateOperatorTourDayPlan(
country?: string | null;
country_code?: string | null;
postal_code?: string | null;
transport_kind?: 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;
booking_id?: string | null;
total_cost?: number | null;
description?: string | null;
website?: string | null;
address?: string | null;
contact_number?: string | null;
items_json?: string | null;
}
): void {
const setClauses: string[] = [];
const values: (string | null | number)[] = [];
const optional = (key: string, v: string | null | undefined) => {
const optional = (key: string, v: string | number | null | undefined) => {
if (v !== undefined) {
setClauses.push(`${key} = ?`);
values.push(v === null ? null : (typeof v === 'string' ? v.trim() : v));
values.push(v === null ? null : typeof v === 'string' ? v.trim() : v);
}
};
optional('title', updates.title);
@@ -545,13 +642,24 @@ export function updateOperatorTourDayPlan(
optional('country', updates.country ?? undefined);
optional('country_code', updates.country_code ?? undefined);
optional('postal_code', updates.postal_code ?? undefined);
optional('transport_kind', updates.transport_kind ?? undefined);
optional('start_date', updates.start_date ?? undefined);
optional('start_time', updates.start_time ?? undefined);
optional('start_timezone', updates.start_timezone ?? undefined);
optional('end_date', updates.end_date ?? undefined);
optional('end_time', updates.end_time ?? undefined);
optional('end_timezone', updates.end_timezone ?? undefined);
optional('booking_id', updates.booking_id ?? undefined);
optional('total_cost', updates.total_cost ?? undefined);
optional('description', updates.description ?? undefined);
optional('website', updates.website ?? undefined);
optional('address', updates.address ?? undefined);
optional('contact_number', updates.contact_number ?? undefined);
optional('items_json', updates.items_json ?? undefined);
if (setClauses.length === 0) return;
setClauses.push("updated_at = datetime('now')");
values.push(id);
db.run(
`UPDATE operator_tour_day_plans SET ${setClauses.join(', ')} WHERE id = ?`,
values
);
db.run(`UPDATE operator_tour_day_plans SET ${setClauses.join(', ')} WHERE id = ?`, values);
}
export function deleteOperatorTourDayPlan(id: number): void {

View File

@@ -0,0 +1,161 @@
import { randomUUID } from 'crypto';
import { db } from './db/index.js';
import type { PlanStatus } from './plans.js';
import { createPlan } from './plans.js';
export type ChecklistType = 'packing' | 'todo';
export interface Checklist {
id: string;
plan_id: string;
created_at: string;
updated_at: string;
}
export interface ChecklistItem {
id: string;
checklist_id: string;
content: string;
is_checked: number;
position: number;
created_at: string;
updated_at: string;
}
export interface ChecklistWithItems extends Checklist {
items: ChecklistItem[];
}
export interface CreateChecklistInput {
tripId: string;
userId: string;
type: ChecklistType;
name: string;
parentId?: string;
status?: PlanStatus;
items?: string[];
}
export interface UpdateChecklistItemInput {
id?: string;
content: string;
isChecked?: boolean;
}
export interface UpdateChecklistInput {
checklistId: string;
userId: string;
name: string;
status?: PlanStatus;
items: UpdateChecklistItemInput[];
}
export function createChecklist(input: CreateChecklistInput): ChecklistWithItems {
const plan = createPlan({
tripId: input.tripId,
userId: input.userId,
type: input.type,
title: input.name.trim(),
parentId: input.parentId ?? null,
status: input.status ?? 'idea'
});
const checklistId = randomUUID();
db.run(`INSERT INTO checklists (id, plan_id) VALUES (?, ?)`, [checklistId, plan.id]);
const cleanItems = (input.items ?? []).map((item) => item.trim()).filter(Boolean);
cleanItems.forEach((content, index) => {
db.run(
`INSERT INTO checklist_items (id, checklist_id, content, is_checked, position)
VALUES (?, ?, ?, 0, ?)`,
[randomUUID(), checklistId, content, index]
);
});
const checklist = db.get<Checklist>('SELECT * FROM checklists WHERE id = ?', [checklistId])!;
return {
...checklist,
items: db.all<ChecklistItem>(
'SELECT * FROM checklist_items WHERE checklist_id = ? ORDER BY position ASC, created_at ASC',
[checklistId]
)
};
}
export function getChecklistsForTrip(
tripId: string,
userId: string,
type?: ChecklistType
): ChecklistWithItems[] {
const params: unknown[] = [tripId, userId];
let typeFilter = '';
if (type) {
typeFilter = ' AND p.type = ?';
params.push(type);
}
const checklists = db.all<Checklist>(
`SELECT c.* FROM checklists c
JOIN plans p ON p.id = c.plan_id
WHERE p.trip_id = ? AND p.user_id = ?${typeFilter}
ORDER BY p.position ASC, c.created_at ASC`,
params
);
return checklists.map((checklist) => ({
...checklist,
items: db.all<ChecklistItem>(
'SELECT * FROM checklist_items WHERE checklist_id = ? ORDER BY position ASC, created_at ASC',
[checklist.id]
)
}));
}
export function updateChecklist(input: UpdateChecklistInput): void {
const existing = db.get<{ checklist_id: string; plan_id: string }>(
`SELECT c.id as checklist_id, c.plan_id
FROM checklists c
JOIN plans p ON p.id = c.plan_id
WHERE c.id = ? AND p.user_id = ?`,
[input.checklistId, input.userId]
);
if (!existing) throw new Error('Checklist not found or not authorized');
db.run(`UPDATE plans SET status = ?, title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [
input.status ?? 'idea',
input.name.trim(),
existing.plan_id
]);
db.run('DELETE FROM checklist_items WHERE checklist_id = ?', [input.checklistId]);
const cleanItems = input.items
.map((item) => ({ content: item.content.trim(), isChecked: item.isChecked }))
.filter((item) => item.content.length > 0);
cleanItems.forEach((item, index) => {
db.run(
`INSERT INTO checklist_items (id, checklist_id, content, is_checked, position)
VALUES (?, ?, ?, ?, ?)`,
[randomUUID(), input.checklistId, item.content, item.isChecked ? 1 : 0, index]
);
});
}
export function toggleChecklistItem(input: {
itemId: string;
userId: string;
isChecked: boolean;
}): void {
const row = db.get<{ id: string }>(
`SELECT ci.id
FROM checklist_items ci
JOIN checklists c ON c.id = ci.checklist_id
JOIN plans p ON p.id = c.plan_id
WHERE ci.id = ? AND p.user_id = ?`,
[input.itemId, input.userId]
);
if (!row) throw new Error('Checklist item not found or not authorized');
db.run(`UPDATE checklist_items SET is_checked = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [
input.isChecked ? 1 : 0,
input.itemId
]);
}

View File

@@ -211,6 +211,100 @@ export function runMigrations(db: Database): void {
)
`);
// Private vehicle transportation details - links to a transport plan
db.run(`
CREATE TABLE IF NOT EXISTS private_vehicles (
id TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
start_address TEXT NOT NULL,
end_address TEXT NOT NULL,
departure_date TEXT,
departure_time TEXT,
departure_timezone TEXT,
arrival_date TEXT,
arrival_time TEXT,
arrival_timezone TEXT,
start_plan_id TEXT REFERENCES plans(id) ON DELETE SET NULL,
end_plan_id TEXT REFERENCES plans(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
for (const col of [
'departure_date TEXT',
'departure_time TEXT',
'departure_timezone TEXT',
'arrival_date TEXT',
'arrival_time TEXT',
'arrival_timezone TEXT'
]) {
try {
db.run(`ALTER TABLE private_vehicles ADD COLUMN ${col}`);
} catch {
/* already exists */
}
}
// Other transportation details - links to a transport plan
db.run(`
CREATE TABLE IF NOT EXISTS other_transports (
id TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
start_date TEXT,
start_time TEXT,
start_timezone TEXT,
end_date TEXT,
end_time TEXT,
end_timezone TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Activity / restaurant details - links to a plan
db.run(`
CREATE TABLE IF NOT EXISTS experience_plans (
id TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
booking_id TEXT,
total_cost REAL,
description TEXT,
website TEXT,
address TEXT,
contact_number TEXT,
start_date TEXT,
start_time TEXT,
start_timezone TEXT,
end_date TEXT,
end_time TEXT,
end_timezone TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Checklist master table (packing/todo details come from linked plan.type)
db.run(`
CREATE TABLE IF NOT EXISTS checklists (
id TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS checklist_items (
id TEXT PRIMARY KEY,
checklist_id TEXT NOT NULL REFERENCES checklists(id) ON DELETE CASCADE,
content TEXT NOT NULL,
is_checked INTEGER NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Lodgings table - links to a plan
db.run(`
CREATE TABLE IF NOT EXISTS lodgings (
@@ -330,12 +424,12 @@ export function runMigrations(db: Database): void {
)
`);
// Template plans (transport/lodging) attached to operator tour days
// Template plans attached to operator tour days
db.run(`
CREATE TABLE IF NOT EXISTS operator_tour_day_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
operator_tour_day_id INTEGER NOT NULL REFERENCES operator_tour_days(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK (type IN ('transport', 'lodging')),
type TEXT NOT NULL CHECK (type IN ('transport', 'lodging', 'activity', 'restaurant', 'packing', 'todo')),
title TEXT NOT NULL,
notes TEXT,
position INTEGER NOT NULL DEFAULT 0,
@@ -344,6 +438,65 @@ export function runMigrations(db: Database): void {
)
`);
// Migration: expand operator_tour_day_plans `type` CHECK constraint if it still only supports transport/lodging.
try {
const schema = db.get<{ sql: string }>(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'operator_tour_day_plans'`
);
const sql = schema?.sql?.toLowerCase() ?? '';
if (sql && !sql.includes("'activity'")) {
db.run(`
CREATE TABLE operator_tour_day_plans__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
operator_tour_day_id INTEGER NOT NULL REFERENCES operator_tour_days(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK (type IN ('transport', 'lodging', 'activity', 'restaurant', 'packing', 'todo')),
title TEXT NOT NULL,
notes TEXT,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
chain TEXT,
address_line1 TEXT,
address_line2 TEXT,
city_name TEXT,
country TEXT,
country_code TEXT,
postal_code TEXT,
transport_kind TEXT,
start_date TEXT,
start_time TEXT,
start_timezone TEXT,
end_date TEXT,
end_time TEXT,
end_timezone TEXT,
booking_id TEXT,
total_cost REAL,
description TEXT,
website TEXT,
address TEXT,
contact_number TEXT,
items_json TEXT
)
`);
db.run(`
INSERT INTO operator_tour_day_plans__new (
id, operator_tour_day_id, type, title, notes, position, created_at, updated_at,
chain, address_line1, address_line2, city_name, country, country_code, postal_code,
transport_kind, start_date, start_time, start_timezone, end_date, end_time, end_timezone
)
SELECT
id, operator_tour_day_id, type, title, notes, position, created_at, updated_at,
chain, address_line1, address_line2, city_name, country, country_code, postal_code,
transport_kind, start_date, start_time, start_timezone, end_date, end_time, end_timezone
FROM operator_tour_day_plans
`);
db.run('DROP TABLE operator_tour_day_plans');
db.run('ALTER TABLE operator_tour_day_plans__new RENAME TO operator_tour_day_plans');
}
} catch {
/* best-effort migration */
}
// Lodging-specific fields for operator_tour_day_plans (type=lodging) — same shape as trip lodgings
for (const col of [
'chain TEXT',
@@ -352,7 +505,21 @@ export function runMigrations(db: Database): void {
'city_name TEXT',
'country TEXT',
'country_code TEXT',
'postal_code TEXT'
'postal_code TEXT',
'transport_kind TEXT',
'start_date TEXT',
'start_time TEXT',
'start_timezone TEXT',
'end_date TEXT',
'end_time TEXT',
'end_timezone TEXT',
'booking_id TEXT',
'total_cost REAL',
'description TEXT',
'website TEXT',
'address TEXT',
'contact_number TEXT',
'items_json TEXT'
]) {
try {
db.run(`ALTER TABLE operator_tour_day_plans ADD COLUMN ${col}`);
@@ -360,6 +527,15 @@ export function runMigrations(db: Database): void {
/* already exists */
}
}
try {
db.run(
`UPDATE operator_tour_day_plans
SET transport_kind = 'other'
WHERE type = 'transport' AND (transport_kind IS NULL OR TRIM(transport_kind) = '')`
);
} catch {
/* best-effort migration */
}
// Countries table — editable list for admin (used by country selector)
db.run(`

View File

@@ -0,0 +1,164 @@
import { randomUUID } from 'crypto';
import { db } from './db/index.js';
import type { PlanStatus } from './plans.js';
import { createPlan } from './plans.js';
export type ExperienceType = 'activity' | 'restaurant';
export interface ExperiencePlan {
id: string;
plan_id: string;
booking_id: string | null;
total_cost: number | null;
description: string | null;
website: string | null;
address: string | null;
contact_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;
created_at: string;
updated_at: string;
}
export interface CreateExperienceInput {
tripId: string;
userId: string;
type: ExperienceType;
name: string;
parentId?: string;
status?: PlanStatus;
bookingId?: string;
totalCost?: number;
description?: string;
website?: string;
address?: string;
contactNumber?: string;
startDate?: string;
startTime?: string;
startTimezone?: string;
endDate?: string;
endTime?: string;
endTimezone?: string;
}
export interface UpdateExperienceInput {
experienceId: string;
userId: string;
name: string;
status?: PlanStatus;
bookingId?: string;
totalCost?: number;
description?: string;
website?: string;
address?: string;
contactNumber?: string;
startDate?: string;
startTime?: string;
startTimezone?: string;
endDate?: string;
endTime?: string;
endTimezone?: string;
}
export function createExperience(input: CreateExperienceInput): ExperiencePlan {
const plan = createPlan({
tripId: input.tripId,
userId: input.userId,
type: input.type,
title: input.name.trim(),
notes: input.description?.trim() || null,
parentId: input.parentId ?? null,
status: input.status ?? 'idea'
});
const experienceId = randomUUID();
db.run(
`INSERT INTO experience_plans (
id, plan_id, booking_id, total_cost, description, website, address, contact_number,
start_date, start_time, start_timezone, end_date, end_time, end_timezone
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
experienceId,
plan.id,
input.bookingId?.trim() || null,
input.totalCost ?? null,
input.description?.trim() || null,
input.website?.trim() || null,
input.address?.trim() || null,
input.contactNumber?.trim() || null,
input.startDate?.trim() || null,
input.startTime?.trim() || null,
input.startTimezone?.trim() || null,
input.endDate?.trim() || null,
input.endTime?.trim() || null,
input.endTimezone?.trim() || null
]
);
return db.get<ExperiencePlan>('SELECT * FROM experience_plans WHERE id = ?', [experienceId])!;
}
export function updateExperience(input: UpdateExperienceInput): void {
const existing = db.get<{ plan_id: string; user_id: string }>(
`SELECT ep.plan_id, p.user_id
FROM experience_plans ep
JOIN plans p ON p.id = ep.plan_id
WHERE ep.id = ? AND p.user_id = ?`,
[input.experienceId, input.userId]
);
if (!existing) throw new Error('Plan not found or not authorized');
db.run(`UPDATE plans SET status = ?, title = ?, notes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [
input.status ?? 'idea',
input.name.trim(),
input.description?.trim() || null,
existing.plan_id
]);
db.run(
`UPDATE experience_plans
SET booking_id = ?, total_cost = ?, description = ?, website = ?, address = ?, contact_number = ?,
start_date = ?, start_time = ?, start_timezone = ?, end_date = ?, end_time = ?, end_timezone = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[
input.bookingId?.trim() || null,
input.totalCost ?? null,
input.description?.trim() || null,
input.website?.trim() || null,
input.address?.trim() || null,
input.contactNumber?.trim() || null,
input.startDate?.trim() || null,
input.startTime?.trim() || null,
input.startTimezone?.trim() || null,
input.endDate?.trim() || null,
input.endTime?.trim() || null,
input.endTimezone?.trim() || null,
input.experienceId
]
);
}
export function getExperiencesForTrip(
tripId: string,
userId: string,
type?: ExperienceType
): ExperiencePlan[] {
const params: unknown[] = [tripId, userId];
let typeFilter = '';
if (type) {
typeFilter = ' AND p.type = ?';
params.push(type);
}
return db.all<ExperiencePlan>(
`SELECT ep.* FROM experience_plans ep
JOIN plans p ON p.id = ep.plan_id
WHERE p.trip_id = ? AND p.user_id = ?${typeFilter}
ORDER BY p.position ASC, ep.created_at ASC`,
params
);
}

View File

@@ -0,0 +1,137 @@
import { randomUUID } from 'crypto';
import { db } from './db/index.js';
import type { PlanStatus } from './plans.js';
export interface OtherTransport {
id: string;
plan_id: string;
start_date: string | null;
start_time: string | null;
start_timezone: string | null;
end_date: string | null;
end_time: string | null;
end_timezone: string | null;
created_at: string;
updated_at: string;
}
export interface CreateOtherTransportInput {
tripId: string;
userId: string;
parentId?: string;
status?: PlanStatus;
title: string;
notes?: string;
startDate?: string;
startTime?: string;
startTimezone?: string;
endDate?: string;
endTime?: string;
endTimezone?: string;
}
export interface UpdateOtherTransportInput {
otherTransportId: string;
userId: string;
status?: PlanStatus;
title: string;
notes?: string;
startDate?: string;
startTime?: string;
startTimezone?: string;
endDate?: string;
endTime?: string;
endTimezone?: string;
}
export function createOtherTransport(input: CreateOtherTransportInput): OtherTransport {
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, notes, parent_id, position)
VALUES (?, ?, ?, 'transport', ?, ?, ?, ?, ?)`,
[
planId,
input.tripId,
input.userId,
input.status ?? 'idea',
input.title.trim(),
input.notes?.trim() || null,
input.parentId ?? null,
position
]
);
const otherTransportId = randomUUID();
db.run(
`INSERT INTO other_transports (
id, plan_id,
start_date, start_time, start_timezone,
end_date, end_time, end_timezone
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
otherTransportId,
planId,
input.startDate ?? null,
input.startTime ?? null,
input.startTimezone ?? null,
input.endDate ?? null,
input.endTime ?? null,
input.endTimezone ?? null
]
);
return db.get<OtherTransport>('SELECT * FROM other_transports WHERE id = ?', [otherTransportId])!;
}
export function updateOtherTransport(input: UpdateOtherTransportInput): void {
const existing = db.get<OtherTransport & { user_id: string }>(
`SELECT ot.*, p.user_id FROM other_transports ot
JOIN plans p ON p.id = ot.plan_id
WHERE ot.id = ? AND p.user_id = ?`,
[input.otherTransportId, input.userId]
);
if (!existing) throw new Error('Other transportation not found or not authorized');
db.run(
`UPDATE plans SET status = ?, title = ?, notes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[
input.status ?? 'idea',
input.title.trim(),
input.notes?.trim() || null,
existing.plan_id
]
);
db.run(
`UPDATE other_transports
SET start_date = ?, start_time = ?, start_timezone = ?,
end_date = ?, end_time = ?, end_timezone = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[
input.startDate ?? null,
input.startTime ?? null,
input.startTimezone ?? null,
input.endDate ?? null,
input.endTime ?? null,
input.endTimezone ?? null,
input.otherTransportId
]
);
}
export function getOtherTransportsForTrip(tripId: string, userId: string): OtherTransport[] {
return db.all<OtherTransport>(
`SELECT ot.* FROM other_transports ot
JOIN plans p ON p.id = ot.plan_id
WHERE p.trip_id = ? AND p.user_id = ? AND p.type = 'transport'
ORDER BY p.position ASC, ot.created_at ASC`,
[tripId, userId]
);
}

View File

@@ -2,6 +2,9 @@ import { db } from './db/index.js';
import { randomUUID } from 'crypto';
import type { PlanStatus } from './plans.js';
import { createPlan } from './plans.js';
import { createOtherTransport } from './other-transports.js';
import { createExperience } from './experiences.js';
import { createChecklist } from './checklists.js';
import { listDaysForOperatorTour, listPlansForOperatorTour } from './admin/data.js';
export interface PackageTour {
@@ -323,15 +326,81 @@ export function cloneTemplateDaysToTour(input: {
});
const dayPlans = allDayPlans.filter((p) => p.operator_tour_day_id === day.id);
for (const dp of dayPlans) {
createPlan({
tripId: input.tripId,
userId: input.userId,
type: dp.type,
title: dp.title,
notes: dp.notes ?? undefined,
parentId: newDay.plan_id,
status: 'idea'
});
if (dp.type === 'transport') {
createOtherTransport({
tripId: input.tripId,
userId: input.userId,
parentId: newDay.plan_id,
status: 'idea',
title: dp.title,
notes: dp.notes ?? undefined,
startDate: dp.start_date ?? undefined,
startTime: dp.start_time ?? undefined,
startTimezone: dp.start_timezone ?? undefined,
endDate: dp.end_date ?? undefined,
endTime: dp.end_time ?? undefined,
endTimezone: dp.end_timezone ?? undefined
});
} else if (dp.type === 'activity' || dp.type === 'restaurant') {
createExperience({
tripId: input.tripId,
userId: input.userId,
type: dp.type,
name: dp.title,
parentId: newDay.plan_id,
status: 'idea',
bookingId: dp.booking_id ?? undefined,
totalCost: dp.total_cost ?? undefined,
description: dp.description ?? dp.notes ?? undefined,
website: dp.website ?? undefined,
address: dp.address ?? undefined,
contactNumber: dp.contact_number ?? undefined,
startDate: dp.start_date ?? undefined,
startTime: dp.start_time ?? undefined,
startTimezone: dp.start_timezone ?? undefined,
endDate: dp.end_date ?? undefined,
endTime: dp.end_time ?? undefined,
endTimezone: dp.end_timezone ?? undefined
});
} else if (dp.type === 'packing' || dp.type === 'todo') {
let items: string[] = [];
try {
const parsed = JSON.parse(dp.items_json ?? '[]');
if (Array.isArray(parsed)) {
items = parsed
.map((item) =>
typeof item === 'string'
? item
: typeof item === 'object' && item && 'content' in item
? String(item.content)
: ''
)
.map((value) => value.trim())
.filter(Boolean);
}
} catch {
items = [];
}
createChecklist({
tripId: input.tripId,
userId: input.userId,
type: dp.type,
name: dp.title,
parentId: newDay.plan_id,
status: 'idea',
items
});
} else {
createPlan({
tripId: input.tripId,
userId: input.userId,
type: dp.type,
title: dp.title,
notes: dp.notes ?? undefined,
parentId: newDay.plan_id,
status: 'idea'
});
}
}
}
}

View File

@@ -0,0 +1,161 @@
import { randomUUID } from 'crypto';
import { db } from './db/index.js';
import type { PlanStatus } from './plans.js';
export interface PrivateVehicleTransport {
id: string;
plan_id: string;
start_address: string;
end_address: string;
departure_date: string | null;
departure_time: string | null;
departure_timezone: string | null;
arrival_date: string | null;
arrival_time: string | null;
arrival_timezone: string | null;
start_plan_id: string | null;
end_plan_id: string | null;
created_at: string;
updated_at: string;
}
export interface CreatePrivateVehicleInput {
tripId: string;
userId: string;
parentId?: string;
status?: PlanStatus;
startAddress: string;
endAddress: string;
departureDate?: string;
departureTime?: string;
departureTimezone?: string;
arrivalDate?: string;
arrivalTime?: string;
arrivalTimezone?: string;
startPlanId?: string;
endPlanId?: string;
}
export interface UpdatePrivateVehicleInput {
privateVehicleId: string;
userId: string;
status?: PlanStatus;
startAddress: string;
endAddress: string;
departureDate?: string;
departureTime?: string;
departureTimezone?: string;
arrivalDate?: string;
arrivalTime?: string;
arrivalTimezone?: string;
startPlanId?: string;
endPlanId?: string;
}
function buildTitle(startAddress: string, endAddress: string): string {
return `Private vehicle: ${startAddress} to ${endAddress}`;
}
export function createPrivateVehicle(input: CreatePrivateVehicleInput): PrivateVehicleTransport {
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, parent_id, position)
VALUES (?, ?, ?, 'transport', ?, ?, ?, ?)`,
[
planId,
input.tripId,
input.userId,
input.status ?? 'idea',
buildTitle(input.startAddress, input.endAddress),
input.parentId ?? null,
position
]
);
const privateVehicleId = randomUUID();
db.run(
`INSERT INTO private_vehicles (
id, plan_id, start_address, end_address,
departure_date, departure_time, departure_timezone,
arrival_date, arrival_time, arrival_timezone,
start_plan_id, end_plan_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
privateVehicleId,
planId,
input.startAddress,
input.endAddress,
input.departureDate ?? null,
input.departureTime ?? null,
input.departureTimezone ?? null,
input.arrivalDate ?? null,
input.arrivalTime ?? null,
input.arrivalTimezone ?? null,
input.startPlanId ?? null,
input.endPlanId ?? null
]
);
return db.get<PrivateVehicleTransport>('SELECT * FROM private_vehicles WHERE id = ?', [
privateVehicleId
])!;
}
export function updatePrivateVehicle(input: UpdatePrivateVehicleInput): void {
const privateVehicle = db.get<PrivateVehicleTransport & { user_id: string }>(
`SELECT pv.*, p.user_id FROM private_vehicles pv
JOIN plans p ON p.id = pv.plan_id
WHERE pv.id = ? AND p.user_id = ?`,
[input.privateVehicleId, input.userId]
);
if (!privateVehicle)
throw new Error('Private vehicle transportation not found or not authorized');
db.run(`UPDATE plans SET status = ?, title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [
input.status ?? 'idea',
buildTitle(input.startAddress, input.endAddress),
privateVehicle.plan_id
]);
db.run(
`UPDATE private_vehicles
SET start_address = ?, end_address = ?,
departure_date = ?, departure_time = ?, departure_timezone = ?,
arrival_date = ?, arrival_time = ?, arrival_timezone = ?,
start_plan_id = ?, end_plan_id = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[
input.startAddress,
input.endAddress,
input.departureDate ?? null,
input.departureTime ?? null,
input.departureTimezone ?? null,
input.arrivalDate ?? null,
input.arrivalTime ?? null,
input.arrivalTimezone ?? null,
input.startPlanId ?? null,
input.endPlanId ?? null,
input.privateVehicleId
]
);
}
export function getPrivateVehiclesForTrip(
tripId: string,
userId: string
): PrivateVehicleTransport[] {
return db.all<PrivateVehicleTransport>(
`SELECT pv.* FROM private_vehicles pv
JOIN plans p ON p.id = pv.plan_id
WHERE p.trip_id = ? AND p.user_id = ? AND p.type = 'transport'
ORDER BY p.position ASC, pv.created_at ASC`,
[tripId, userId]
);
}