mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(bookings): add a dedicated URL field to reservations (#935)
Bookings get a first-class url column (migration) instead of users pasting links into notes. It's editable in the booking modal and rendered as a clickable link on the reservation card. The reservation request schemas are open passthroughs, so only the entity schema + service SQL enumerate it.
This commit is contained in:
@@ -86,7 +86,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
const [form, setForm] = useState({
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||
notes: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
|
||||
notes: '', url: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
|
||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
|
||||
hotel_address: '',
|
||||
@@ -136,6 +136,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
location: reservation.location || '',
|
||||
confirmation_number: reservation.confirmation_number || '',
|
||||
notes: reservation.notes || '',
|
||||
url: reservation.url || '',
|
||||
assignment_id: reservation.assignment_id || '',
|
||||
accommodation_id: reservation.accommodation_id || '',
|
||||
meta_check_in_time: meta.check_in_time || '',
|
||||
@@ -164,6 +165,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
location: prefill.location || '',
|
||||
confirmation_number: prefill.confirmation_number || '',
|
||||
notes: prefill.notes || '',
|
||||
url: (prefill as { url?: string }).url || '',
|
||||
assignment_id: defaultAssignmentId ?? '',
|
||||
accommodation_id: '',
|
||||
meta_check_in_time: meta.check_in_time || '',
|
||||
@@ -180,7 +182,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
setForm({
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||
notes: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
|
||||
notes: '', url: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
|
||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '', hotel_address: '',
|
||||
})
|
||||
@@ -237,6 +239,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
reservation_end_time: form.type === 'hotel' ? null : (combinedEndTime || null),
|
||||
location: form.location, confirmation_number: form.confirmation_number,
|
||||
notes: form.notes,
|
||||
url: form.url,
|
||||
assignment_id: (form.type === 'hotel' && !form.accommodation_id) ? null : (form.assignment_id || null),
|
||||
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||
@@ -591,6 +594,16 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Link */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.urlLabel')}</label>
|
||||
<div className="relative">
|
||||
<Link2 size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted pointer-events-none" />
|
||||
<input type="url" value={form.url} onChange={e => set('url', e.target.value)}
|
||||
placeholder={t('reservations.urlPlaceholder')} className={inputClass} style={{ paddingLeft: 34 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.notes')}</label>
|
||||
|
||||
@@ -361,6 +361,18 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link */}
|
||||
{r.url && (
|
||||
<div>
|
||||
<div className={fieldLabelClass}>{t('reservations.urlLabel')}</div>
|
||||
<div className={fieldValueClass} style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 400 }}>
|
||||
<ExternalLink size={13} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<a href={r.url} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline"
|
||||
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.url}</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{r.notes && (
|
||||
<div>
|
||||
|
||||
@@ -3123,6 +3123,15 @@ function runMigrations(db: Database.Database): void {
|
||||
}
|
||||
}
|
||||
},
|
||||
// Dedicated booking URL (#935) — users previously stuffed links into notes.
|
||||
// Additive nullable TEXT; existing rows default to NULL.
|
||||
() => {
|
||||
try {
|
||||
db.exec('ALTER TABLE reservations ADD COLUMN url TEXT');
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('duplicate column name')) throw err;
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -232,6 +232,7 @@ interface CreateReservationData {
|
||||
location?: string;
|
||||
confirmation_number?: string;
|
||||
notes?: string;
|
||||
url?: string;
|
||||
day_id?: number;
|
||||
end_day_id?: number;
|
||||
place_id?: number;
|
||||
@@ -248,7 +249,7 @@ interface CreateReservationData {
|
||||
export function createReservation(tripId: string | number, data: CreateReservationData): { reservation: any; accommodationCreated: boolean } {
|
||||
const {
|
||||
title, reservation_time, reservation_end_time, location,
|
||||
confirmation_number, notes, day_id, end_day_id, place_id, assignment_id,
|
||||
confirmation_number, notes, url, day_id, end_day_id, place_id, assignment_id,
|
||||
status, type, accommodation_id, metadata, create_accommodation,
|
||||
endpoints, needs_review
|
||||
} = data;
|
||||
@@ -282,8 +283,8 @@ export function createReservation(tripId: string | number, data: CreateReservati
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata, needs_review)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, url, status, type, accommodation_id, metadata, needs_review)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
tripId,
|
||||
resolvedDayId,
|
||||
@@ -296,6 +297,7 @@ export function createReservation(tripId: string | number, data: CreateReservati
|
||||
location || null,
|
||||
confirmation_number || null,
|
||||
notes || null,
|
||||
url || null,
|
||||
status || 'pending',
|
||||
resolvedType,
|
||||
resolvedAccommodationId,
|
||||
@@ -357,6 +359,7 @@ interface UpdateReservationData {
|
||||
location?: string;
|
||||
confirmation_number?: string;
|
||||
notes?: string;
|
||||
url?: string;
|
||||
day_id?: number;
|
||||
end_day_id?: number | null;
|
||||
place_id?: number;
|
||||
@@ -373,7 +376,7 @@ interface UpdateReservationData {
|
||||
export function updateReservation(id: string | number, tripId: string | number, data: UpdateReservationData, current: Reservation): { reservation: any; accommodationChanged: boolean } {
|
||||
const {
|
||||
title, reservation_time, reservation_end_time, location,
|
||||
confirmation_number, notes, day_id, end_day_id, place_id, assignment_id,
|
||||
confirmation_number, notes, url, day_id, end_day_id, place_id, assignment_id,
|
||||
status, type, accommodation_id, metadata, create_accommodation,
|
||||
endpoints, needs_review
|
||||
} = data;
|
||||
@@ -446,6 +449,7 @@ export function updateReservation(id: string | number, tripId: string | number,
|
||||
location = ?,
|
||||
confirmation_number = ?,
|
||||
notes = ?,
|
||||
url = ?,
|
||||
day_id = ?,
|
||||
end_day_id = ?,
|
||||
place_id = ?,
|
||||
@@ -463,6 +467,7 @@ export function updateReservation(id: string | number, tripId: string | number,
|
||||
location !== undefined ? (location || null) : current.location,
|
||||
confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number,
|
||||
notes !== undefined ? (notes || null) : current.notes,
|
||||
url !== undefined ? (url || null) : (current as any).url,
|
||||
nextDayId,
|
||||
nextEndDayId,
|
||||
place_id !== undefined ? (place_id || null) : current.place_id,
|
||||
|
||||
@@ -88,6 +88,25 @@ describe('Create reservation', () => {
|
||||
expect(res.body.reservation.type).toBe('hotel');
|
||||
});
|
||||
|
||||
it('RESV-001b — persists and updates the dedicated url field (#935)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const created = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Hotel', type: 'hotel', url: 'https://hotel.example/booking' });
|
||||
expect(created.status).toBe(201);
|
||||
expect(created.body.reservation.url).toBe('https://hotel.example/booking');
|
||||
|
||||
const updated = await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/${created.body.reservation.id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ url: 'https://hotel.example/changed' });
|
||||
expect(updated.status).toBe(200);
|
||||
expect(updated.body.reservation.url).toBe('https://hotel.example/changed');
|
||||
});
|
||||
|
||||
it('RESV-001 — POST without title returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -23,6 +23,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.timeAlt': 'Uhrzeit (alternativ, z.B. 19:30)',
|
||||
'reservations.notes': 'Notizen',
|
||||
'reservations.notesPlaceholder': 'Zusätzliche Notizen...',
|
||||
'reservations.urlLabel': 'Link',
|
||||
'reservations.urlPlaceholder': 'https://...',
|
||||
'reservations.meta.airline': 'Fluggesellschaft',
|
||||
'reservations.meta.flightNumber': 'Flugnr.',
|
||||
'reservations.meta.from': 'Von',
|
||||
|
||||
@@ -22,6 +22,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.timeAlt': 'Time (alternative, e.g. 19:30)',
|
||||
'reservations.notes': 'Notes',
|
||||
'reservations.notesPlaceholder': 'Additional notes...',
|
||||
'reservations.urlLabel': 'Link',
|
||||
'reservations.urlPlaceholder': 'https://...',
|
||||
'reservations.meta.airline': 'Airline',
|
||||
'reservations.meta.flightNumber': 'Flight No.',
|
||||
'reservations.meta.from': 'From',
|
||||
|
||||
@@ -54,6 +54,7 @@ export const reservationSchema = z.object({
|
||||
location: z.string().nullable().optional(),
|
||||
confirmation_number: z.string().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
url: z.string().nullable().optional(),
|
||||
status: z.string(),
|
||||
type: z.string(),
|
||||
accommodation_id: z.union([z.number(), z.string()]).nullable().optional(),
|
||||
|
||||
Reference in New Issue
Block a user