From 16b81a8356841476c92f19f74b269ec0ff3d96cb Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 20 Apr 2026 23:08:42 +0200 Subject: [PATCH] fix(bookings): preserve accommodation dates when place is unlinked or missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove NOT NULL constraint on day_accommodations.place_id (migration) and change ON DELETE CASCADE → SET NULL so deleting a place no longer cascades to the accommodation row - Switch listAccommodations / getAccommodationWithPlace to LEFT JOIN so accommodations without a linked place are visible to the modal - Relax create/update guards in reservationService to only require start_day_id + end_day_id, not place_id; place_id remains optional - Client save guard now sends create_accommodation whenever FROM/TO days are set, regardless of whether a hotel place was selected - Add re-hydration useEffect in ReservationModal to back-fill hotel fields from the accommodations prop when it arrives after modal opens (race between isOpen and the tripAccommodations fetch) - Fix demo-seed TDZ crash: move db Proxy declaration before DEMO_MODE block so circular require in demo-reset resolves correctly - Sidebar accommodation badge falls back to reservation title when place_name is null; click/cursor disabled for placeless accommodations - listAccommodations now joins reservations to expose reservation_title --- .../src/components/Planner/DayPlanSidebar.tsx | 4 +-- .../components/Planner/ReservationModal.tsx | 16 ++++++++-- server/src/db/database.ts | 18 +++++------ server/src/db/migrations.ts | 30 +++++++++++++++++++ server/src/db/schema.ts | 2 +- server/src/services/dayService.ts | 10 ++++--- server/src/services/reservationService.ts | 17 +++++++---- 7 files changed, 73 insertions(+), 24 deletions(-) diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 66590e1c..64049e89 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -1235,9 +1235,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const border = isCheckOut && !isCheckIn ? 'rgba(239,68,68,0.2)' : isCheckIn ? 'rgba(34,197,94,0.2)' : 'var(--border-primary)' const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-muted)' return ( - { e.stopPropagation(); onPlaceClick(acc.place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: bg, border: `1px solid ${border}`, flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: 'pointer' }}> + { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: bg, border: `1px solid ${border}`, flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: (acc as any).place_id ? 'pointer' : 'default' }}> - {acc.place_name} + {(acc as any).place_name || (acc as any).reservation_title} ) }) diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index e0e797c2..38408599 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -143,6 +143,18 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p } }, [reservation, isOpen, selectedDayId, defaultAssignmentId]) + // Re-hydrate hotel day range when the accommodations prop arrives after the modal opens + // (race: tripAccommodations fetch may complete after isOpen fires, leaving hotel fields empty) + useEffect(() => { + if (!isOpen || !reservation || reservation.type !== 'hotel' || !reservation.accommodation_id) return + const acc = accommodations.find(a => a.id == reservation.accommodation_id) + if (!acc) return + setForm(prev => { + if (prev.hotel_place_id !== '' || prev.hotel_start_day !== '' || prev.hotel_end_day !== '') return prev + return { ...prev, hotel_place_id: acc.place_id, hotel_start_day: acc.start_day_id, hotel_end_day: acc.end_day_id } + }) + }, [accommodations, isOpen, reservation]) + const set = (field, value) => setForm(prev => ({ ...prev, [field]: value })) const isEndBeforeStart = (() => { @@ -193,9 +205,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p ? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' } : { total_price: 0 } } - if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) { + if (form.type === 'hotel' && form.hotel_start_day && form.hotel_end_day) { saveData.create_accommodation = { - place_id: form.hotel_place_id, + place_id: form.hotel_place_id || null, start_day_id: form.hotel_start_day, end_day_id: form.hotel_end_day, check_in: form.meta_check_in_time || null, diff --git a/server/src/db/database.ts b/server/src/db/database.ts index 410b4801..2578608b 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -35,15 +35,6 @@ function initDb(): void { initDb(); -if (process.env.DEMO_MODE === 'true') { - try { - const { seedDemoData } = require('../demo/demo-seed'); - seedDemoData(_db); - } catch (err: unknown) { - console.error('[Demo] Seed error:', err instanceof Error ? err.message : err); - } -} - const db = new Proxy({} as Database.Database, { get(_, prop: string | symbol) { if (!_db) throw new Error('Database connection is not available (restore in progress?)'); @@ -56,6 +47,15 @@ const db = new Proxy({} as Database.Database, { }, }); +if (process.env.DEMO_MODE === 'true') { + try { + const { seedDemoData } = require('../demo/demo-seed'); + seedDemoData(_db); + } catch (err: unknown) { + console.error('[Demo] Seed error:', err instanceof Error ? err.message : err); + } +} + function closeDb(): void { if (_db) { try { _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {} diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 4af7f35c..7c8ff903 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -1876,6 +1876,36 @@ function runMigrations(db: Database.Database): void { if (!hasCol) return; db.prepare('DELETE FROM oauth_tokens WHERE audience IS NULL').run(); }, + // Remove NOT NULL constraint on day_accommodations.place_id so hotel + // reservations created from the Bookings tab without a linked place can + // still persist their date range. Change ON DELETE CASCADE → SET NULL so + // deleting a place orphans the accommodation row instead of cascading. + () => { + db.exec(` + CREATE TABLE day_accommodations_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, + start_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + end_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + check_in TEXT, + check_in_end TEXT, + check_out TEXT, + confirmation TEXT, + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + INSERT INTO day_accommodations_new + SELECT id, trip_id, place_id, start_day_id, end_day_id, + check_in, check_in_end, check_out, confirmation, notes, created_at + FROM day_accommodations; + DROP TABLE day_accommodations; + ALTER TABLE day_accommodations_new RENAME TO day_accommodations; + CREATE INDEX IF NOT EXISTS idx_day_accommodations_trip_id ON day_accommodations(trip_id); + CREATE INDEX IF NOT EXISTS idx_day_accommodations_start_day_id ON day_accommodations(start_day_id); + CREATE INDEX IF NOT EXISTS idx_day_accommodations_end_day_id ON day_accommodations(end_day_id); + `); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 289e1851..5cf49e79 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -344,7 +344,7 @@ function createTables(db: Database.Database): void { CREATE TABLE IF NOT EXISTS day_accommodations ( id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, + place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, start_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, end_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, check_in TEXT, diff --git a/server/src/services/dayService.ts b/server/src/services/dayService.ts index 99e846d4..80b773ad 100644 --- a/server/src/services/dayService.ts +++ b/server/src/services/dayService.ts @@ -166,7 +166,7 @@ export function deleteDay(id: string | number) { export interface DayAccommodation { id: number; trip_id: number; - place_id: number; + place_id: number | null; start_day_id: number; end_day_id: number; check_in: string | null; @@ -180,7 +180,7 @@ function getAccommodationWithPlace(id: number | bigint) { return db.prepare(` SELECT a.*, p.name as place_name, p.address as place_address, p.image_url as place_image, p.lat as place_lat, p.lng as place_lng FROM day_accommodations a - JOIN places p ON a.place_id = p.id + LEFT JOIN places p ON a.place_id = p.id WHERE a.id = ? `).get(id); } @@ -191,9 +191,11 @@ function getAccommodationWithPlace(id: number | bigint) { export function listAccommodations(tripId: string | number) { return db.prepare(` - SELECT a.*, p.name as place_name, p.address as place_address, p.image_url as place_image, p.lat as place_lat, p.lng as place_lng + SELECT a.*, p.name as place_name, p.address as place_address, p.image_url as place_image, p.lat as place_lat, p.lng as place_lng, + r.title as reservation_title FROM day_accommodations a - JOIN places p ON a.place_id = p.id + LEFT JOIN places p ON a.place_id = p.id + LEFT JOIN reservations r ON r.accommodation_id = a.id WHERE a.trip_id = ? ORDER BY a.created_at ASC `).all(tripId); diff --git a/server/src/services/reservationService.ts b/server/src/services/reservationService.ts index 661c7a33..af5c0851 100644 --- a/server/src/services/reservationService.ts +++ b/server/src/services/reservationService.ts @@ -149,10 +149,10 @@ export function createReservation(tripId: string | number, data: CreateReservati let resolvedAccommodationId: number | null = accommodation_id || null; if (type === 'hotel' && !resolvedAccommodationId && create_accommodation) { const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation; - if (accPlaceId && start_day_id && end_day_id) { + if (start_day_id && end_day_id) { const accResult = db.prepare( 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)' - ).run(tripId, accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null); + ).run(tripId, accPlaceId || null, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null); resolvedAccommodationId = Number(accResult.lastInsertRowid); accommodationCreated = true; } @@ -274,11 +274,16 @@ export function updateReservation(id: string | number, tripId: string | number, } if (type === 'hotel' && create_accommodation) { const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation; - if (accPlaceId && start_day_id && end_day_id) { + if (start_day_id && end_day_id) { if (resolvedAccId) { - db.prepare('UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?') - .run(accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId); - } else { + if (accPlaceId) { + db.prepare('UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?') + .run(accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId); + } else { + db.prepare('UPDATE day_accommodations SET start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?') + .run(start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId); + } + } else if (accPlaceId) { const accResult = db.prepare( 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)' ).run(tripId, accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null);