From 21456b983efa7764f771be6fa32e9b274c62c6df Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 24 Jul 2026 13:50:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(reservations):=20assign=20trip=20members?= =?UTF-8?q?=20and=20guests=20to=20bookings=20=E2=80=94=20backend=20(schema?= =?UTF-8?q?,=20migration,=20travelers=20endpoint)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/db/migrations.ts | 13 ++++ .../reservations/reservations.controller.ts | 21 ++++++ .../nest/reservations/reservations.service.ts | 7 ++ server/src/services/reservationService.ts | 69 +++++++++++++++++++ shared/src/reservation/reservation.schema.ts | 22 ++++++ 5 files changed, 132 insertions(+) diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 9a6cd18cb..734f3cfb1 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -3786,6 +3786,19 @@ function runMigrations(db: Database.Database): void { db.exec("ALTER TABLE vacay_holiday_calendars ADD COLUMN type TEXT NOT NULL DEFAULT 'public_holiday'"); } }, + // #1517 — assign trip members / named guests to a reservation (mirrors budget_item_members). + () => { + db.exec(` + CREATE TABLE IF NOT EXISTS reservation_travelers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(reservation_id, user_id) + ); + CREATE INDEX IF NOT EXISTS idx_reservation_travelers_res ON reservation_travelers(reservation_id); + CREATE INDEX IF NOT EXISTS idx_reservation_travelers_user ON reservation_travelers(user_id); + `); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/nest/reservations/reservations.controller.ts b/server/src/nest/reservations/reservations.controller.ts index efa387103..bfba35e9c 100644 --- a/server/src/nest/reservations/reservations.controller.ts +++ b/server/src/nest/reservations/reservations.controller.ts @@ -125,6 +125,27 @@ export class ReservationsController { return { reservation }; } + @Put(':id/travelers') + updateTravelers( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @Param('id') id: string, + @Body('user_ids') userIds: unknown, + @Headers('x-socket-id') socketId?: string, + ) { + const trip = this.requireTrip(tripId, user); + this.requireEdit(trip, user); + if (!Array.isArray(userIds)) { + throw new HttpException({ error: 'user_ids must be an array' }, 400); + } + const result = this.reservations.setTravelers(id, tripId, userIds as number[]); + if (!result) { + throw new HttpException({ error: 'Reservation not found' }, 404); + } + this.reservations.broadcast(tripId, 'reservation:travelers-updated', { reservationId: Number(id), travelers: result.travelers }, socketId); + return { travelers: result.travelers, reservation: result.reservation }; + } + @Delete(':id') remove( @CurrentUser() user: User, diff --git a/server/src/nest/reservations/reservations.service.ts b/server/src/nest/reservations/reservations.service.ts index 8aa63dfbd..a30464b27 100644 --- a/server/src/nest/reservations/reservations.service.ts +++ b/server/src/nest/reservations/reservations.service.ts @@ -61,6 +61,13 @@ export class ReservationsService { return svc.deleteReservation(id, tripId); } + /** Assign trip members / named guests to a reservation (#1517). Null when off-trip. */ + setTravelers(id: string, tripId: string, userIds: number[]) { + if (!svc.getReservation(id, tripId)) return null; + svc.setReservationTravelers(id, tripId, userIds); + return { travelers: svc.loadTravelers(id), reservation: svc.getReservationWithJoins(Number(id)) }; + } + /** POST side effect: auto-create a linked budget item when a price is provided. */ syncBudgetOnCreate(tripId: string, reservationId: number, title: string, type: string | undefined, entry: BudgetEntry, socketId: string | undefined): void { if (!entry || !(Number(entry.total_price) > 0)) return; diff --git a/server/src/services/reservationService.ts b/server/src/services/reservationService.ts index a9a552a1b..d2c4818e0 100644 --- a/server/src/services/reservationService.ts +++ b/server/src/services/reservationService.ts @@ -1,5 +1,6 @@ import { db } from '../db/database'; import { Reservation } from '../types'; +import { avatarUrl } from './avatarUrl'; export { verifyTripAccess } from './tripAccess'; @@ -73,6 +74,71 @@ function loadEndpoints(reservationId: number): ReservationEndpoint[] { ).all(reservationId) as ReservationEndpoint[]; } +// --- Travelers (#1517): trip members / named guests assigned to a booking ----- + +export interface ReservationTraveler { + user_id: number; + username: string; + avatar: string | null; + avatar_url?: string | null; + is_guest?: number | null; +} + +const toTraveler = (r: { user_id: number; username: string; avatar: string | null; is_guest?: number | null }): ReservationTraveler => + ({ user_id: r.user_id, username: r.username, avatar: r.avatar, is_guest: r.is_guest ?? null, avatar_url: avatarUrl(r) }); + +/** Users assignable on a trip: its members (guests included) plus the owner. */ +function assignableUserIds(tripId: string | number): Set { + const members = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(tripId) as { user_id: number }[]; + const ids = new Set(members.map(m => m.user_id)); + const owner = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined; + if (owner) ids.add(owner.user_id); + return ids; +} + +export function loadTravelersByTrip(tripId: string | number): Map { + const rows = db.prepare(` + SELECT rt.reservation_id, rt.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar, u.is_guest + FROM reservation_travelers rt + JOIN reservations r ON rt.reservation_id = r.id + JOIN users u ON rt.user_id = u.id + WHERE r.trip_id = ? + ORDER BY rt.reservation_id + `).all(tripId) as (ReservationTraveler & { reservation_id: number })[]; + const map = new Map(); + for (const row of rows) { + const list = map.get(row.reservation_id) ?? []; + list.push(toTraveler(row)); + map.set(row.reservation_id, list); + } + return map; +} + +export function loadTravelers(reservationId: number | string): ReservationTraveler[] { + const rows = db.prepare(` + SELECT rt.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar, u.is_guest + FROM reservation_travelers rt + JOIN users u ON rt.user_id = u.id + WHERE rt.reservation_id = ? + `).all(reservationId) as ReservationTraveler[]; + return rows.map(toTraveler); +} + +/** + * Replace a reservation's assigned travelers. Only real trip members/guests are + * accepted — ids that aren't on the trip are silently dropped so a stale client + * can't leak a cross-trip user. #1517. + */ +export function setReservationTravelers(reservationId: number | string, tripId: string | number, userIds: number[]): void { + const allowed = assignableUserIds(tripId); + const ids = [...new Set(userIds)].filter(uid => allowed.has(uid)); + db.prepare('DELETE FROM reservation_travelers WHERE reservation_id = ?').run(reservationId); + if (ids.length > 0) { + const insert = db.prepare('INSERT OR IGNORE INTO reservation_travelers (reservation_id, user_id) VALUES (?, ?)'); + for (const uid of ids) insert.run(reservationId, uid); + } +} + // Resolve the day row whose date matches the date portion of an ISO-ish // timestamp. Used to keep `day_id` / `end_day_id` in sync with // `reservation_time` / `reservation_end_time` so non-transport bookings @@ -184,10 +250,12 @@ export function listReservations(tripId: string | number) { } const endpointsMap = loadEndpointsByTrip(tripId); + const travelersMap = loadTravelersByTrip(tripId); for (const r of reservations) { r.day_positions = posMap.get(r.id) || null; r.endpoints = endpointsMap.get(r.id) || []; + r.travelers = travelersMap.get(r.id) || []; // accommodation_id is a TEXT column; the integer FK reads back as a numeric // string (e.g. "14.0"). Normalize to an int so clients can parse it. r.accommodation_id = r.accommodation_id == null ? null : Math.trunc(Number(r.accommodation_id)); @@ -244,6 +312,7 @@ export function getReservationWithJoins(id: string | number) { `).get(id) as any; if (!row) return undefined; row.endpoints = loadEndpoints(row.id); + row.travelers = loadTravelers(row.id); // accommodation_id is a TEXT column; the integer FK reads back as a numeric // string (e.g. "14.0"). Normalize to an int so clients can parse it. row.accommodation_id = row.accommodation_id == null ? null : Math.trunc(Number(row.accommodation_id)); diff --git a/shared/src/reservation/reservation.schema.ts b/shared/src/reservation/reservation.schema.ts index 74491a15a..44242b290 100644 --- a/shared/src/reservation/reservation.schema.ts +++ b/shared/src/reservation/reservation.schema.ts @@ -34,6 +34,20 @@ export const reservationEndpointSchema = z.object({ }); export type ReservationEndpoint = z.infer; +/** + * A traveler assigned to a reservation — a trip member or a named guest (both are + * `users` rows; a guest carries is_guest=1). Mirrors the budget member read shape + * without the cost-split fields; joined + avatar-resolved in reservationService. + */ +export const reservationTravelerSchema = z.object({ + user_id: z.number(), + username: z.string(), + avatar_url: z.string().nullable().optional(), + avatar: z.string().nullable().optional(), + is_guest: z.number().nullable().optional(), +}); +export type ReservationTraveler = z.infer; + /** * Endpoints as accepted on a WRITE (create/update body `endpoints` array). This * pins the STRUCTURE (role must be a known value, name/code are strings) so a @@ -102,6 +116,8 @@ export const reservationSchema = z.object({ accommodation_end_day_id: z.number().nullable().optional(), day_positions: z.record(z.string(), z.number()).nullable().optional(), endpoints: z.array(reservationEndpointSchema).optional(), + // Trip members / named guests this booking is for (#1517). Joined in listReservations. + travelers: z.array(reservationTravelerSchema).optional(), }); export type Reservation = z.infer; @@ -139,6 +155,12 @@ export type ReservationCreateRequest = z.infer; +/** Assign trip members/guests to a reservation (mirrors budget's PUT :id/members). */ +export const reservationTravelersRequestSchema = z.object({ + user_ids: z.array(z.number()), +}); +export type ReservationTravelersRequest = z.infer; + export const reservationPositionsRequestSchema = z.object({ positions: z.array(z.object({ id: z.number(), day_plan_position: z.number() })), day_id: z.union([z.number(), z.string()]).nullable().optional(),