tour-operators) sharing add/edit lodging across admin and user facing side
This commit is contained in:
@@ -318,6 +318,7 @@ export interface OperatorTour {
|
||||
id: number;
|
||||
operator_id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -334,16 +335,24 @@ export function listToursForOperatorByName(operatorName: string): OperatorTour[]
|
||||
return listToursForOperator(operator.id);
|
||||
}
|
||||
|
||||
export function createOperatorTour(operatorId: number, name: string): OperatorTour {
|
||||
db.run('INSERT INTO operator_tours (operator_id, name) VALUES (?, ?)', [operatorId, name.trim()]);
|
||||
export function createOperatorTour(
|
||||
operatorId: number,
|
||||
name: string,
|
||||
description?: string | null
|
||||
): OperatorTour {
|
||||
db.run('INSERT INTO operator_tours (operator_id, name, description) VALUES (?, ?, ?)', [
|
||||
operatorId,
|
||||
name.trim(),
|
||||
description?.trim() || null
|
||||
]);
|
||||
return db.get<OperatorTour>('SELECT * FROM operator_tours WHERE id = last_insert_rowid()')!;
|
||||
}
|
||||
|
||||
export function updateOperatorTour(id: number, name: string): void {
|
||||
db.run(`UPDATE operator_tours SET name = ?, updated_at = datetime('now') WHERE id = ?`, [
|
||||
name.trim(),
|
||||
id
|
||||
]);
|
||||
export function updateOperatorTour(id: number, name: string, description?: string | null): void {
|
||||
db.run(
|
||||
`UPDATE operator_tours SET name = ?, description = ?, updated_at = datetime('now') WHERE id = ?`,
|
||||
[name.trim(), description?.trim() || null, id]
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteOperatorTour(id: number): void {
|
||||
@@ -418,3 +427,133 @@ export function deleteOperatorTourDay(id: number): void {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Operator Tour Day Plans (transport / lodging templates per day) ---
|
||||
|
||||
export type OperatorTourDayPlanType = 'transport' | 'lodging';
|
||||
|
||||
export interface OperatorTourDayPlan {
|
||||
id: number;
|
||||
operator_tour_day_id: number;
|
||||
type: OperatorTourDayPlanType;
|
||||
title: string;
|
||||
notes: string | null;
|
||||
position: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// Lodging-only (when type === 'lodging'), aligned with trip lodgings
|
||||
chain?: string | null;
|
||||
address_line1?: string | null;
|
||||
address_line2?: string | null;
|
||||
city_name?: string | null;
|
||||
country?: string | null;
|
||||
country_code?: string | null;
|
||||
postal_code?: string | null;
|
||||
}
|
||||
|
||||
export function listPlansForOperatorTour(tourId: number): OperatorTourDayPlan[] {
|
||||
return db.all<OperatorTourDayPlan>(
|
||||
`SELECT p.* FROM operator_tour_day_plans p
|
||||
JOIN operator_tour_days d ON d.id = p.operator_tour_day_id
|
||||
WHERE d.operator_tour_id = ?
|
||||
ORDER BY d.position ASC, d.id ASC, p.position ASC, p.id ASC`,
|
||||
[tourId]
|
||||
);
|
||||
}
|
||||
|
||||
export function createOperatorTourDayPlan(
|
||||
dayId: number,
|
||||
type: OperatorTourDayPlanType,
|
||||
title: string,
|
||||
notes?: string | null,
|
||||
lodgingFields?: {
|
||||
chain?: string | null;
|
||||
address_line1?: string | null;
|
||||
address_line2?: string | null;
|
||||
city_name?: string | null;
|
||||
country?: string | null;
|
||||
country_code?: string | null;
|
||||
postal_code?: 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 = ?',
|
||||
[dayId]
|
||||
);
|
||||
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 placeholders = vals.map(() => '?').join(', ');
|
||||
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()'
|
||||
)!;
|
||||
}
|
||||
|
||||
export function updateOperatorTourDayPlan(
|
||||
id: number,
|
||||
updates: {
|
||||
title?: string;
|
||||
notes?: string | null;
|
||||
chain?: string | null;
|
||||
address_line1?: string | null;
|
||||
address_line2?: string | null;
|
||||
city_name?: string | null;
|
||||
country?: string | null;
|
||||
country_code?: string | null;
|
||||
postal_code?: string | null;
|
||||
}
|
||||
): void {
|
||||
const setClauses: string[] = [];
|
||||
const values: (string | null | number)[] = [];
|
||||
const optional = (key: string, v: string | null | undefined) => {
|
||||
if (v !== undefined) {
|
||||
setClauses.push(`${key} = ?`);
|
||||
values.push(v === null ? null : (typeof v === 'string' ? v.trim() : v));
|
||||
}
|
||||
};
|
||||
optional('title', updates.title);
|
||||
optional('notes', updates.notes ?? undefined);
|
||||
optional('chain', updates.chain ?? undefined);
|
||||
optional('address_line1', updates.address_line1 ?? undefined);
|
||||
optional('address_line2', updates.address_line2 ?? undefined);
|
||||
optional('city_name', updates.city_name ?? undefined);
|
||||
optional('country', updates.country ?? undefined);
|
||||
optional('country_code', updates.country_code ?? undefined);
|
||||
optional('postal_code', updates.postal_code ?? 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
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteOperatorTourDayPlan(id: number): void {
|
||||
db.run('DELETE FROM operator_tour_day_plans WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,159 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { TourProvider, TourSearchResult } from './types.js';
|
||||
import type {
|
||||
TourDetail,
|
||||
TourDay,
|
||||
TourDayPlan,
|
||||
TourDayPlanLodgingFields,
|
||||
TourProvider,
|
||||
TourSearchResult
|
||||
} from './types.js';
|
||||
|
||||
const BASE = 'https://rest.gadventures.com';
|
||||
|
||||
function authHeaders() {
|
||||
return { 'X-Application-Key': env.GADVENTURES_API_KEY };
|
||||
}
|
||||
|
||||
// Raw API shapes for itinerary days and components
|
||||
interface GAdventureComponent {
|
||||
type?: string;
|
||||
summary?: string;
|
||||
accommodation_dossier?: { id: string; href: string; name: string };
|
||||
transport_dossier?: { id: string; href: string; name: string };
|
||||
}
|
||||
|
||||
interface GAdventureItineraryDay {
|
||||
day: number;
|
||||
label?: string;
|
||||
summary?: string;
|
||||
components?: GAdventureComponent[];
|
||||
}
|
||||
|
||||
// Accommodation dossier detail response (from following accommodation_dossier href)
|
||||
interface GAdventureAccommodationDossier {
|
||||
name?: string;
|
||||
address?: {
|
||||
address_line_1?: string;
|
||||
address_line_2?: string;
|
||||
address_line_3?: string;
|
||||
postal_code?: string;
|
||||
city?: { name?: string };
|
||||
country?: { name?: string };
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchAccommodationDetails(href: string): Promise<GAdventureAccommodationDossier | null> {
|
||||
try {
|
||||
const res = await fetch(href, { headers: authHeaders() });
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function lodgingFieldsFromDossier(dossier: GAdventureAccommodationDossier): TourDayPlanLodgingFields | undefined {
|
||||
const addr = dossier.address;
|
||||
if (!addr) return undefined;
|
||||
const address_line1 = addr.address_line_1?.trim() || null;
|
||||
const address_line2 = addr.address_line_2?.trim() || null;
|
||||
const city_name = addr.city?.name?.trim() || null;
|
||||
const country = addr.country?.name?.trim() || null;
|
||||
const country_code = addr.country?.id?.trim() || null;
|
||||
const postal_code = addr.postal_code?.trim() || null;
|
||||
if (
|
||||
!address_line1 &&
|
||||
!address_line2 &&
|
||||
!city_name &&
|
||||
!country &&
|
||||
!country_code &&
|
||||
!postal_code
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
address_line1: address_line1 ?? undefined,
|
||||
address_line2: address_line2 ?? undefined,
|
||||
city_name: city_name ?? undefined,
|
||||
country: country ?? undefined,
|
||||
country_code: country_code ?? undefined,
|
||||
postal_code: postal_code ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
async function dayPlansFromComponents(components: GAdventureComponent[]): Promise<TourDayPlan[]> {
|
||||
const plans: TourDayPlan[] = [];
|
||||
for (const c of components ?? []) {
|
||||
const type = c.type?.toUpperCase();
|
||||
if (type === 'TRANSPORT' && c.transport_dossier) {
|
||||
plans.push({
|
||||
type: 'transport',
|
||||
title: c.transport_dossier.name?.trim() || c.summary?.trim() || 'Transport',
|
||||
notes: c.summary?.trim() || null
|
||||
});
|
||||
} else if (type === 'ACCOMMODATION' && c.accommodation_dossier) {
|
||||
const dossier = c.accommodation_dossier;
|
||||
let title = dossier.name?.trim() || c.summary?.trim() || 'Accommodation';
|
||||
const notes: string | null = c.summary?.trim() || null;
|
||||
let lodgingFields: TourDayPlanLodgingFields | undefined;
|
||||
if (dossier.href) {
|
||||
const details = await fetchAccommodationDetails(dossier.href);
|
||||
if (details?.name?.trim()) title = details.name.trim();
|
||||
if (details) lodgingFields = lodgingFieldsFromDossier(details);
|
||||
}
|
||||
plans.push({ type: 'lodging', title, notes: notes ?? undefined, lodgingFields });
|
||||
}
|
||||
}
|
||||
return plans;
|
||||
}
|
||||
|
||||
export const GAdventuresProvider: TourProvider = {
|
||||
name: 'G Adventures',
|
||||
|
||||
search: async (query: string): Promise<TourSearchResult[]> => {
|
||||
const params = new URLSearchParams();
|
||||
if (query.trim()) params.set('name', query.trim());
|
||||
const url = `https://rest.gadventures.com/tour_dossiers?${params}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'X-Application-Key': env.GADVENTURES_API_KEY }
|
||||
});
|
||||
console.dir(res);
|
||||
const res = await fetch(`${BASE}/tour_dossiers?${params}`, { headers: authHeaders() });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return (data.results ?? []).map((r: { id: string; name: string }) => ({
|
||||
id: r.id,
|
||||
title: r.name
|
||||
}));
|
||||
},
|
||||
|
||||
getDetail: async (id: string): Promise<TourDetail | null> => {
|
||||
// 1. Fetch the tour dossier
|
||||
const dossierRes = await fetch(`${BASE}/tour_dossiers/${id}`, { headers: authHeaders() });
|
||||
if (!dossierRes.ok) return null;
|
||||
const dossier = await dossierRes.json();
|
||||
|
||||
const title: string = dossier.name ?? '';
|
||||
const description: string = dossier.description ?? '';
|
||||
|
||||
// 2. Resolve itinerary — use the first structured itinerary's href
|
||||
const itineraryHref: string | undefined = dossier.structured_itineraries?.[0]?.href;
|
||||
if (!itineraryHref) {
|
||||
return { id, title, description, days: [] };
|
||||
}
|
||||
|
||||
const itinRes = await fetch(itineraryHref, { headers: authHeaders() });
|
||||
if (!itinRes.ok) return { id, title, description, days: [] };
|
||||
const itinerary = await itinRes.json();
|
||||
|
||||
const rawDays: GAdventureItineraryDay[] = itinerary.days ?? [];
|
||||
const days: TourDay[] = [];
|
||||
|
||||
for (const d of rawDays) {
|
||||
const dayPlans = await dayPlansFromComponents(d.components ?? []);
|
||||
days.push({
|
||||
dayNumber: d.day,
|
||||
title: d.label ?? '',
|
||||
description: d.summary ?? '',
|
||||
dayPlans: dayPlans.length > 0 ? dayPlans : undefined
|
||||
});
|
||||
}
|
||||
|
||||
return { id, title, description, days };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,9 +5,53 @@ export interface TourSearchResult {
|
||||
title: string;
|
||||
}
|
||||
|
||||
/** Lodging-specific fields (from provider accommodation dossier) */
|
||||
export interface TourDayPlanLodgingFields {
|
||||
chain?: string | null;
|
||||
address_line1?: string | null;
|
||||
address_line2?: string | null;
|
||||
city_name?: string | null;
|
||||
country?: string | null;
|
||||
country_code?: string | null;
|
||||
postal_code?: string | null;
|
||||
}
|
||||
|
||||
/** A transport or lodging plan attached to a day (from provider components) */
|
||||
export interface TourDayPlan {
|
||||
type: 'transport' | 'lodging';
|
||||
title: string;
|
||||
notes?: string | null;
|
||||
/** When type is 'lodging', structured address/location from provider */
|
||||
lodgingFields?: TourDayPlanLodgingFields;
|
||||
}
|
||||
|
||||
export interface TourDay {
|
||||
/** 1-based day number */
|
||||
dayNumber: number;
|
||||
/** Short title / label for the day */
|
||||
title: string;
|
||||
/** Longer description / summary for the day */
|
||||
description: string;
|
||||
/** Optional transport/lodging items parsed from day components */
|
||||
dayPlans?: TourDayPlan[];
|
||||
}
|
||||
|
||||
export interface TourDetail {
|
||||
/** Provider-specific identifier */
|
||||
id: string;
|
||||
/** Display name of the tour */
|
||||
title: string;
|
||||
/** Overview description of the tour */
|
||||
description: string;
|
||||
/** Ordered list of days in the itinerary */
|
||||
days: TourDay[];
|
||||
}
|
||||
|
||||
export interface TourProvider {
|
||||
/** Human-readable provider name shown in the import drawer */
|
||||
name: string;
|
||||
/** Search tours by query string. Empty query returns a default set of results. */
|
||||
search(query: string): Promise<TourSearchResult[]>;
|
||||
/** Fetch full details (description + itinerary days) for a specific tour by provider ID */
|
||||
getDetail(id: string): Promise<TourDetail | null>;
|
||||
}
|
||||
|
||||
@@ -303,11 +303,19 @@ export function runMigrations(db: Database): void {
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
operator_id INTEGER NOT NULL REFERENCES tour_operators(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
// Migration: add description to existing operator_tours tables
|
||||
try {
|
||||
db.run(`ALTER TABLE operator_tours ADD COLUMN description TEXT`);
|
||||
} catch {
|
||||
/* already exists */
|
||||
}
|
||||
|
||||
// Template itinerary days for predefined operator tours
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS operator_tour_days (
|
||||
@@ -322,6 +330,37 @@ export function runMigrations(db: Database): void {
|
||||
)
|
||||
`);
|
||||
|
||||
// Template plans (transport/lodging) 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')),
|
||||
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'))
|
||||
)
|
||||
`);
|
||||
|
||||
// Lodging-specific fields for operator_tour_day_plans (type=lodging) — same shape as trip lodgings
|
||||
for (const col of [
|
||||
'chain TEXT',
|
||||
'address_line1 TEXT',
|
||||
'address_line2 TEXT',
|
||||
'city_name TEXT',
|
||||
'country TEXT',
|
||||
'country_code TEXT',
|
||||
'postal_code TEXT'
|
||||
]) {
|
||||
try {
|
||||
db.run(`ALTER TABLE operator_tour_day_plans ADD COLUMN ${col}`);
|
||||
} catch {
|
||||
/* already exists */
|
||||
}
|
||||
}
|
||||
|
||||
// Countries table — editable list for admin (used by country selector)
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS countries (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { db } from './db/index.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { PlanStatus } from './plans.js';
|
||||
import { listDaysForOperatorTour } from './admin/data.js';
|
||||
import { createPlan } from './plans.js';
|
||||
import { listDaysForOperatorTour, listPlansForOperatorTour } from './admin/data.js';
|
||||
|
||||
export interface PackageTour {
|
||||
id: string;
|
||||
@@ -311,13 +312,26 @@ export function cloneTemplateDaysToTour(input: {
|
||||
tourPlanId: string;
|
||||
}): void {
|
||||
const days = listDaysForOperatorTour(input.operatorTourId);
|
||||
const allDayPlans = listPlansForOperatorTour(input.operatorTourId);
|
||||
for (const day of days) {
|
||||
createTourDay({
|
||||
const newDay = createTourDay({
|
||||
tripId: input.tripId,
|
||||
userId: input.userId,
|
||||
tourPlanId: input.tourPlanId,
|
||||
title: day.title ?? undefined,
|
||||
notes: day.notes ?? undefined
|
||||
});
|
||||
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'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,47 @@ export function createDestination(input: CreateDestinationInput): Plan {
|
||||
return db.get<Plan>('SELECT * FROM plans WHERE id = ?', [id])!;
|
||||
}
|
||||
|
||||
export interface CreatePlanInput {
|
||||
tripId: string;
|
||||
userId: string;
|
||||
type: PlanType;
|
||||
title: string;
|
||||
notes?: string | null;
|
||||
parentId?: string | null;
|
||||
status?: PlanStatus;
|
||||
}
|
||||
|
||||
export function createPlan(input: CreatePlanInput): Plan {
|
||||
const id = randomUUID();
|
||||
const maxPos = input.parentId
|
||||
? db.get<{ pos: number }>(
|
||||
`SELECT COALESCE(MAX(position), -1) + 1 as pos FROM plans WHERE parent_id = ? AND user_id = ?`,
|
||||
[input.parentId, input.userId]
|
||||
)
|
||||
: 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id,
|
||||
input.tripId,
|
||||
input.userId,
|
||||
input.type,
|
||||
input.status ?? 'idea',
|
||||
input.title,
|
||||
input.notes ?? null,
|
||||
input.parentId ?? 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`,
|
||||
|
||||
Reference in New Issue
Block a user