feat(reservations): assign trip members and guests to bookings — backend (schema, migration, travelers endpoint)

This commit is contained in:
Maurice
2026-07-24 13:50:14 +02:00
committed by Maurice
parent 9344d4508c
commit 21456b983e
5 changed files with 132 additions and 0 deletions
+13
View File
@@ -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) {
@@ -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,
@@ -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;
+69
View File
@@ -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<number> {
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<number, ReservationTraveler[]> {
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<number, ReservationTraveler[]>();
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));
@@ -34,6 +34,20 @@ export const reservationEndpointSchema = z.object({
});
export type ReservationEndpoint = z.infer<typeof reservationEndpointSchema>;
/**
* 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<typeof reservationTravelerSchema>;
/**
* 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<typeof reservationSchema>;
@@ -139,6 +155,12 @@ export type ReservationCreateRequest = z.infer<typeof reservationCreateRequestSc
export const reservationUpdateRequestSchema = open;
export type ReservationUpdateRequest = z.infer<typeof reservationUpdateRequestSchema>;
/** 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<typeof reservationTravelersRequestSchema>;
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(),