refactor(assignments, public-api, accommodations, places): async DB-touching methods; transactions through UnitOfWork

This commit is contained in:
jubnl
2026-09-22 05:21:48 +02:00
parent 3a8b54d761
commit 5c8decd3ea
37 changed files with 616 additions and 589 deletions
@@ -68,8 +68,8 @@ export class AccommodationsController {
}
@Get()
list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
return { accommodations: this.accommodations.list(tripId) };
async list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
return { accommodations: await this.accommodations.list(tripId) };
}
@RequirePermission('day_edit')
@@ -85,7 +85,7 @@ export class AccommodationsController {
if (!place_id || !start_day_id || !end_day_id) {
throw new HttpException({ error: 'place_id, start_day_id, and end_day_id are required' }, 400);
}
const errors = this.accommodations.validateRefs(tripId, place_id, start_day_id, end_day_id);
const errors = await this.accommodations.validateRefs(tripId, place_id, start_day_id, end_day_id);
if (errors.length > 0) {
throw new HttpException({ error: errors[0].message }, 404);
}
@@ -108,12 +108,12 @@ export class AccommodationsController {
@Headers('x-socket-id') socketId?: string,
) {
const body = rawBody as AccommodationBody;
const existing = this.accommodations.get(id, tripId);
const existing = await this.accommodations.get(id, tripId);
if (!existing) {
throw new HttpException({ error: 'Accommodation not found' }, 404);
}
const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = body;
const errors = this.accommodations.validateRefs(tripId, place_id, start_day_id, end_day_id);
const errors = await this.accommodations.validateRefs(tripId, place_id, start_day_id, end_day_id);
if (errors.length > 0) {
throw new HttpException({ error: errors[0].message }, 404);
}
@@ -132,7 +132,7 @@ export class AccommodationsController {
@Headers('x-socket-id') socketId?: string,
@Query('keepStop') keepStop?: string,
) {
if (!this.accommodations.get(id, tripId)) {
if (!(await this.accommodations.get(id, tripId))) {
throw new HttpException({ error: 'Accommodation not found' }, 404);
}
// Turning a night back into a pause in road trip mode: the booking goes, the
@@ -69,9 +69,9 @@ export class AccommodationsMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.accommodations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
const errors = this.accommodations.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
const errors = await this.accommodations.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
if (errors.length > 0) return { content: [{ type: 'text' as const, text: errors.map(e => e.message).join(', ') }], isError: true };
const { accommodation, mirror } = await this.accommodations.createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
this.guards.safeBroadcast(tripId, 'accommodation:created', { accommodation });
@@ -120,13 +120,13 @@ export class AccommodationsMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.accommodations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
const dayErrors = this.accommodations.validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id);
const dayErrors = await this.accommodations.validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id);
if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true };
try {
const result = await this.uow.transactional(async () => {
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes: place_notes, website, phone, price, currency });
const place = await this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes: place_notes, website, phone, price, currency });
const { accommodation, mirror } = await this.accommodations.createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes: accommodation_notes });
return { place, accommodation, mirror };
});
@@ -168,9 +168,9 @@ export class AccommodationsMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.accommodations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
const existing = this.accommodations.getAccommodation(accommodationId, tripId);
const existing = await this.accommodations.getAccommodation(accommodationId, tripId);
if (!existing) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
const { accommodation, mirror } = await this.accommodations.updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
this.guards.safeBroadcast(tripId, 'accommodation:updated', { accommodation });
@@ -192,9 +192,9 @@ export class AccommodationsMcp {
})
async deleteAccommodation({ tripId, accommodationId }: { tripId: number; accommodationId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.accommodations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.accommodations.getAccommodation(accommodationId, tripId)) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
if (!(await this.accommodations.getAccommodation(accommodationId, tripId))) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
// linkedReservationId stays the first one so the tool's answer keeps its shape;
// linkedReservationIds carries the rest for a block that had more than one booking.
const { linkedReservationId, linkedReservationIds, mirror } = await this.accommodations.deleteAccommodation(accommodationId);
@@ -212,7 +212,7 @@ export class AccommodationsMcp {
})
async tripAccommodationsResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) {
const id = parseId(tripId);
if (id === null || !this.accommodations.verifyTripAccess(id, ctx.userId)) {
if (id === null || !(await this.accommodations.verifyTripAccess(id, ctx.userId))) {
return {
contents: [{
uri: uri.href,
@@ -221,7 +221,7 @@ export class AccommodationsMcp {
}],
};
}
const accommodations = this.accommodations.listAccommodations(id);
const accommodations = await this.accommodations.listAccommodations(id);
return {
contents: [{
uri: uri.href,
@@ -54,7 +54,7 @@ export class AccommodationsRpc {
const endDayId = Math.trunc(Number(input.end_day_id));
if (!placeId || !startDayId || !endDayId) throw new BadParams('place_id, start_day_id, and end_day_id are required');
// Verifies the place and both days belong to this trip.
const errors = this.days.validateAccommodationRefs(tripId, placeId, startDayId, endDayId);
const errors = await this.days.validateAccommodationRefs(tripId, placeId, startDayId, endDayId);
if (errors.length > 0) throw new ForbiddenResource(errors[0].message);
const { accommodation, mirror } = await this.days.createAccommodation(tripId, {
place_id: placeId,
@@ -81,10 +81,10 @@ export class AccommodationsRpc {
const parsed = accommodationUpdateRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid accommodation: ${schemaMessage(parsed.error)}`);
await this.guards.requireTripEdit(tripId, actor, ACCOMMODATION_EDIT_ACTION);
const existing = this.days.getAccommodation(accommodationId, tripId);
const existing = await this.days.getAccommodation(accommodationId, tripId);
if (!existing) throw new ForbiddenResource(`no accommodation ${accommodationId} on trip ${tripId}`);
const input = parsed.data as { place_id?: number; start_day_id?: number; end_day_id?: number; check_in?: string; check_in_end?: string; check_out?: string; confirmation?: string; notes?: string };
const errors = this.days.validateAccommodationRefs(tripId, input.place_id, input.start_day_id, input.end_day_id);
const errors = await this.days.validateAccommodationRefs(tripId, input.place_id, input.start_day_id, input.end_day_id);
if (errors.length > 0) throw new ForbiddenResource(errors[0].message);
const { accommodation, mirror } = await this.days.updateAccommodation(accommodationId, existing, input);
this.realtime.broadcast(tripId, 'accommodation:updated', { accommodation });
@@ -98,7 +98,7 @@ export class AccommodationsRpc {
const accommodationId = num(params.accommodationId, 'accommodationId');
const actor = this.guards.requireActor(ctx, 'accommodation');
await this.guards.requireTripEdit(tripId, actor, ACCOMMODATION_EDIT_ACTION);
if (!this.days.getAccommodation(accommodationId, tripId)) {
if (!(await this.days.getAccommodation(accommodationId, tripId))) {
throw new ForbiddenResource(`no accommodation ${accommodationId} on trip ${tripId}`);
}
// Deleting a block can take its partner reservation and budget item with it.
@@ -105,7 +105,7 @@ export class AccommodationsService {
/** Owner or member, returning the trip. Takes a number too: the MCP tools pass
* the parsed id, the REST path the raw param. */
verifyTripAccess(tripId: string | number, userId: number) {
async verifyTripAccess(tripId: string | number, userId: number) {
return this.dbs.canAccessTrip(Number(tripId), userId);
}
@@ -235,7 +235,7 @@ export class AccommodationsService {
// -------------------------------------------------------------------------
private getAccommodationWithPlace(id: number | bigint) {
private async getAccommodationWithPlace(id: number | bigint) {
return this.db.get(`
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
@@ -244,7 +244,7 @@ export class AccommodationsService {
`, id);
}
listAccommodations(tripId: string | number) {
async listAccommodations(tripId: string | number) {
return this.db.all(`
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
@@ -256,7 +256,7 @@ export class AccommodationsService {
`, tripId);
}
validateAccommodationRefs(tripId: string | number, placeId?: number, startDayId?: number, endDayId?: number) {
async validateAccommodationRefs(tripId: string | number, placeId?: number, startDayId?: number, endDayId?: number) {
const errors: { field: string; message: string }[] = [];
if (placeId !== undefined) {
const place = this.db.get('SELECT id FROM places WHERE id = ? AND trip_id = ?', placeId, tripId);
@@ -294,7 +294,7 @@ export class AccommodationsService {
* pulled it forward, and a night booked for ten in the morning sat behind a whole
* day of unpinned stops, reached at a quarter past twelve.
*/
private positionForCheckIn(dayId: number, checkIn: string | null | undefined, excludeId?: number): number {
private async positionForCheckIn(dayId: number, checkIn: string | null | undefined, excludeId?: number): Promise<number> {
if (!checkIn) return 0;
// excludeId leaves the row being re-seated out of the chain it is measured
// against. Without it a night parked at the end of the day can find itself.
@@ -321,7 +321,7 @@ export class AccommodationsService {
* without an hour are passed over in both directions: the night may have been
* dragged past them on purpose, and a change of notes is no reason to undo that.
*/
private seatedByCheckIn(dayId: number, ownId: number, checkIn: string | null | undefined): boolean {
private async seatedByCheckIn(dayId: number, ownId: number, checkIn: string | null | undefined): Promise<boolean> {
if (!checkIn) return true;
const rows = this.db.all<{ id: number; at: string | null }>(`
SELECT da.id, COALESCE(da.assignment_time, p.place_time, other.check_in) AS at
@@ -345,7 +345,7 @@ export class AccommodationsService {
* road trip. Only ever filled in when it is empty: a type the traveller picked
* (a campsite, say) is theirs.
*/
private stampLodging(placeId: number): PlaceWithTags | null {
private async stampLodging(placeId: number): Promise<PlaceWithTags | null> {
const place = this.db.get<{ stop_type: string | null }>('SELECT stop_type FROM places WHERE id = ?', placeId);
if (!place || place.stop_type) return null;
this.db.run("UPDATE places SET stop_type = 'hotel' WHERE id = ?", placeId);
@@ -359,11 +359,15 @@ export class AccommodationsService {
* the n-th stop that has coordinates, not behind a row id. Stops without
* coordinates are never routed and so never counted.
*/
private stopOrders(dayIds: number[]): Map<number, number[]> {
return new Map([...new Set(dayIds)].map((dayId): [number, number[]] => [dayId, this.locatedStopIds(dayId)]));
private async stopOrders(dayIds: number[]): Promise<Map<number, number[]>> {
const result = new Map<number, number[]>();
for (const dayId of new Set(dayIds)) {
result.set(dayId, await this.locatedStopIds(dayId));
}
return result;
}
private locatedStopIds(dayId: number): number[] {
private async locatedStopIds(dayId: number): Promise<number[]> {
return this.db.all<{ id: number }>(`
SELECT da.id FROM day_assignments da JOIN places p ON p.id = da.place_id
WHERE da.day_id = ? AND p.lat IS NOT NULL AND p.lng IS NOT NULL
@@ -385,9 +389,9 @@ export class AccommodationsService {
*
* Runs inside the caller's transaction.
*/
private reanchorVias(mirror: AccommodationMirror, before: Map<number, number[]>): void {
private async reanchorVias(mirror: AccommodationMirror, before: Map<number, number[]>): Promise<void> {
for (const [dayId, previousIds] of before) {
const nextIds = this.locatedStopIds(dayId);
const nextIds = await this.locatedStopIds(dayId);
if (previousIds.length === nextIds.length && previousIds.every((id, i) => id === nextIds[i])) continue;
const vias = this.db.all<PinnedVia>('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?', dayId);
if (!vias.length) continue;
@@ -411,7 +415,7 @@ export class AccommodationsService {
for (const viaId of remove) this.db.run('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?', viaId, dayId);
for (const via of moved) this.db.run('UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ? AND day_id = ?', via.after_order_index, via.id, dayId);
this.renumberMergedLegs(dayId, vias.filter(via => !remove.includes(via.id)), moved);
await this.renumberMergedLegs(dayId, vias.filter(via => !remove.includes(via.id)), moved);
this.noteVias(mirror, {
dayId,
vias: this.db.all<RoadtripVia>(
@@ -445,7 +449,7 @@ export class AccommodationsService {
* `RoadtripService.reanchor` does it, the earlier leg's points first, then by
* their old sequence, then by id. Only a leg that received a via is touched.
*/
private renumberMergedLegs(dayId: number, kept: PinnedVia[], moved: { id: number; after_order_index: number }[]): void {
private async renumberMergedLegs(dayId: number, kept: PinnedVia[], moved: { id: number; after_order_index: number }[]): Promise<void> {
const landed = new Map(moved.map(via => [via.id, via.after_order_index]));
const byLeg = new Map<number, PinnedVia[]>();
for (const via of kept) {
@@ -506,7 +510,7 @@ export class AccommodationsService {
const end = (max.max !== null ? max.max : -1) + 1;
this.db.run('UPDATE day_assignments SET day_id = ?, place_id = ?, order_index = ? WHERE id = ?', dayId, placeId, end, stop.id);
const seat = this.positionForCheckIn(dayId, checkIn, stop.id);
const seat = await this.positionForCheckIn(dayId, checkIn, stop.id);
if (seat < end) {
this.db.run(
'UPDATE day_assignments SET order_index = order_index + 1 WHERE day_id = ? AND order_index >= ? AND id != ?',
@@ -524,7 +528,7 @@ export class AccommodationsService {
// form writes stays that never had one. Nothing to put on the map then.
if (!placeId) return mirror;
mirror.stamped = this.stampLodging(placeId);
mirror.stamped = await this.stampLodging(placeId);
// The road-trip flow assigns the place to the day and only then books the night.
// Claiming that row would make cancelling the booking delete a stop the traveller
@@ -532,7 +536,7 @@ export class AccommodationsService {
// answer for a place already planned for that day by hand.
if (this.db.get('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ?', dayId, placeId)) return mirror;
const before = this.stopOrders([dayId]);
const before = await this.stopOrders([dayId]);
// Through AssignmentsService, seated where the check-in says (see
// positionForCheckIn), with everything behind it moved up one.
//
@@ -543,14 +547,14 @@ export class AccommodationsService {
// time until the next reload.
mirror.created = await this.assignments.createAssignment(dayId, placeId, null, {
accommodationId,
orderIndex: this.positionForCheckIn(dayId, checkIn),
orderIndex: await this.positionForCheckIn(dayId, checkIn),
});
this.reanchorVias(mirror, before);
await this.reanchorVias(mirror, before);
return mirror;
}
/** The day stops this booking, and only this booking, put on the plan. */
private ownStops(accommodationId: number) {
private async ownStops(accommodationId: number) {
return this.db.all<{ id: number; day_id: number; place_id: number; order_index: number }>(
'SELECT id, day_id, place_id, order_index FROM day_assignments WHERE accommodation_id = ?', accommodationId
);
@@ -572,8 +576,8 @@ export class AccommodationsService {
*/
private async releaseStops(accommodationId: number, opts: { keepStop?: boolean }): Promise<AccommodationMirror> {
const mirror = noMirror();
const own = this.ownStops(accommodationId);
const before = this.stopOrders(own.map(stop => stop.day_id));
const own = await this.ownStops(accommodationId);
const before = await this.stopOrders(own.map(stop => stop.day_id));
for (const stop of own) {
if (opts.keepStop) {
this.db.run('UPDATE day_assignments SET accommodation_id = NULL WHERE id = ?', stop.id);
@@ -587,7 +591,7 @@ export class AccommodationsService {
this.db.run('DELETE FROM day_assignments WHERE id = ?', stop.id);
mirror.removed.push({ id: stop.id, dayId: stop.day_id });
}
this.reanchorVias(mirror, before);
await this.reanchorVias(mirror, before);
return mirror;
}
@@ -610,7 +614,7 @@ export class AccommodationsService {
checkIn?: string | null,
opts: { checkInChanged?: boolean } = {},
): Promise<AccommodationMirror> {
const own = this.ownStops(accommodationId);
const own = await this.ownStops(accommodationId);
if (own.length === 0) return noMirror();
if (own.length === 1 && own[0].day_id === dayId && own[0].place_id === placeId) {
// Same place, same day. A check-in given a new hour seats the night afresh, the
@@ -618,8 +622,8 @@ export class AccommodationsService {
// it is unless the clocks around it say it is in the wrong place, so a night the
// traveller dragged somewhere stays there through a change of notes.
const settled = opts.checkInChanged
? this.positionForCheckIn(dayId, checkIn, own[0].id) === own[0].order_index
: this.seatedByCheckIn(dayId, own[0].id, checkIn);
? (await this.positionForCheckIn(dayId, checkIn, own[0].id)) === own[0].order_index
: await this.seatedByCheckIn(dayId, own[0].id, checkIn);
if (settled) return noMirror();
}
@@ -630,22 +634,22 @@ export class AccommodationsService {
// note, its hour, its end-of-day flag and the road-trip day boundary anchored
// on its id, all of which a DELETE takes with it.
if (own.length === 1 && placeId) {
const before = this.stopOrders([own[0].day_id, dayId]);
const before = await this.stopOrders([own[0].day_id, dayId]);
const moved = await this.relocateOwnStop(own[0], placeId, dayId, checkIn);
if (moved) {
mirror.moved = { assignment: moved, oldDayId: own[0].day_id };
mirror.stamped = this.stampLodging(placeId);
this.reanchorVias(mirror, before);
mirror.stamped = await this.stampLodging(placeId);
await this.reanchorVias(mirror, before);
return mirror;
}
}
const beforeRebuild = this.stopOrders(own.map(stop => stop.day_id));
const beforeRebuild = await this.stopOrders(own.map(stop => stop.day_id));
for (const stop of own) {
this.db.run('DELETE FROM day_assignments WHERE id = ?', stop.id);
mirror.removed.push({ id: stop.id, dayId: stop.day_id });
}
this.reanchorVias(mirror, beforeRebuild);
await this.reanchorVias(mirror, beforeRebuild);
const fresh = await this.mirrorStay(accommodationId, placeId, dayId, checkIn);
mirror.created = fresh.created;
mirror.stamped = fresh.stamped;
@@ -685,10 +689,10 @@ export class AccommodationsService {
return { accommodationId: newId, mirror: await this.mirrorStay(Number(newId), place_id ?? null, start_day_id, check_in) };
});
return { accommodation: this.getAccommodationWithPlace(written.accommodationId), mirror: written.mirror };
return { accommodation: await this.getAccommodationWithPlace(written.accommodationId), mirror: written.mirror };
}
getAccommodation(id: string | number, tripId: string | number) {
async getAccommodation(id: string | number, tripId: string | number) {
return this.db.get<DayAccommodation>('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?', id, tripId);
}
@@ -731,7 +735,7 @@ export class AccommodationsService {
JSON.stringify(meta), newConfirmation || null, res.id);
}
return { accommodation: this.getAccommodationWithPlace(Number(id)), mirror };
return { accommodation: await this.getAccommodationWithPlace(Number(id)), mirror };
}
/**
@@ -49,7 +49,7 @@ export class DayAssignmentsController {
@Get()
async list(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('dayId') dayId: string) {
if (!this.assignments.dayExists(dayId, tripId)) {
if (!await this.assignments.dayExists(dayId, tripId)) {
throw new HttpException({ error: 'Day not found' }, 404);
}
return { assignments: await this.assignments.listDayAssignments(dayId) };
@@ -64,10 +64,10 @@ export class DayAssignmentsController {
@Body() body: AssignmentCreateDto,
@Headers('x-socket-id') socketId?: string,
) {
if (!this.assignments.dayExists(dayId, tripId)) {
if (!await this.assignments.dayExists(dayId, tripId)) {
throw new HttpException({ error: 'Day not found' }, 404);
}
if (!this.assignments.placeExists(body.place_id, tripId)) {
if (!await this.assignments.placeExists(body.place_id, tripId)) {
throw new HttpException({ error: 'Place not found' }, 404);
}
const assignment = await this.assignments.createAssignment(dayId, body.place_id, body.notes);
@@ -78,17 +78,17 @@ export class DayAssignmentsController {
@RequirePermission('day_edit')
@Put('reorder')
reorder(
async reorder(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('dayId') dayId: string,
@Body() body: AssignmentReorderDto,
@Headers('x-socket-id') socketId?: string,
) {
if (!this.assignments.dayExists(dayId, tripId)) {
if (!await this.assignments.dayExists(dayId, tripId)) {
throw new HttpException({ error: 'Day not found' }, 404);
}
this.assignments.reorderAssignments(dayId, body.orderedIds);
await this.assignments.reorderAssignments(dayId, body.orderedIds);
this.assignments.broadcast(tripId, 'assignment:reordered', { dayId: Number(dayId), orderedIds: body.orderedIds }, socketId);
return { success: true };
}
@@ -102,10 +102,10 @@ export class DayAssignmentsController {
@Param('id') id: string,
@Headers('x-socket-id') socketId?: string,
) {
if (!this.assignments.assignmentExistsInDay(id, dayId, tripId)) {
if (!await this.assignments.assignmentExistsInDay(id, dayId, tripId)) {
throw new HttpException({ error: 'Assignment not found' }, 404);
}
this.assignments.deleteAssignment(id);
await this.assignments.deleteAssignment(id);
this.assignments.broadcast(tripId, 'assignment:deleted', { assignmentId: Number(id), dayId: Number(dayId) }, socketId);
await this.assignments.reconcile(tripId, socketId);
return { success: true };
@@ -135,10 +135,10 @@ export class AssignmentOpsController {
@Body() body: AssignmentMoveDto,
@Headers('x-socket-id') socketId?: string,
) {
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
if (!await this.assignments.getAssignmentForTrip(id, tripId)) {
throw new HttpException({ error: 'Assignment not found' }, 404);
}
if (!this.assignments.dayExists(String(body.new_day_id), tripId)) {
if (!await this.assignments.dayExists(String(body.new_day_id), tripId)) {
throw new HttpException({ error: 'Target day not found' }, 404);
}
const { assignment, oldDayId } = await this.assignments.moveAssignment(id, body.new_day_id, body.order_index);
@@ -148,11 +148,11 @@ export class AssignmentOpsController {
}
@Get(':id/participants')
participants(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string) {
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
async participants(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string) {
if (!await this.assignments.getAssignmentForTrip(id, tripId)) {
throw new HttpException({ error: 'Assignment not found' }, 404);
}
return { participants: this.assignments.getParticipants(id) };
return { participants: await this.assignments.getParticipants(id) };
}
@RequirePermission('day_edit')
@@ -164,7 +164,7 @@ export class AssignmentOpsController {
@Body() body: AssignmentTimeDto,
@Headers('x-socket-id') socketId?: string,
) {
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
if (!await this.assignments.getAssignmentForTrip(id, tripId)) {
throw new HttpException({ error: 'Assignment not found' }, 404);
}
const { assignment, reordered, vias } = await this.assignments.updateTime(id, body.place_time, body.end_time);
@@ -190,7 +190,7 @@ export class AssignmentOpsController {
@Body() body: AssignmentEndDayDto,
@Headers('x-socket-id') socketId?: string,
) {
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
if (!await this.assignments.getAssignmentForTrip(id, tripId)) {
throw new HttpException({ error: 'Assignment not found' }, 404);
}
const assignment = await this.assignments.setEndDay(id, body.end_day);
@@ -210,7 +210,7 @@ export class AssignmentOpsController {
@Body() body: AssignmentNotesDto,
@Headers('x-socket-id') socketId?: string,
) {
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
if (!await this.assignments.getAssignmentForTrip(id, tripId)) {
throw new HttpException({ error: 'Assignment not found' }, 404);
}
const assignment = await this.assignments.updateNotes(id, body.notes);
@@ -227,7 +227,7 @@ export class AssignmentOpsController {
@Body() body: AssignmentTransportDto,
@Headers('x-socket-id') socketId?: string,
) {
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
if (!await this.assignments.getAssignmentForTrip(id, tripId)) {
throw new HttpException({ error: 'Assignment not found' }, 404);
}
const assignment = body.direction === 'incoming'
@@ -239,17 +239,17 @@ export class AssignmentOpsController {
@RequirePermission('day_edit')
@Put(':id/participants')
setParticipants(
async setParticipants(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: AssignmentParticipantsDto,
@Headers('x-socket-id') socketId?: string,
) {
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
if (!await this.assignments.getAssignmentForTrip(id, tripId)) {
throw new HttpException({ error: 'Assignment not found' }, 404);
}
const participants = this.assignments.setParticipants(id, body.user_ids, tripId);
const participants = await this.assignments.setParticipants(id, body.user_ids, tripId);
this.assignments.broadcast(tripId, 'assignment:participants', { assignmentId: Number(id), participants }, socketId);
return { participants };
}
+24 -24
View File
@@ -47,10 +47,10 @@ export class AssignmentsMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.assignments.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.assignments.dayExists(dayId, tripId)) return errorResult('Day not found.');
if (!this.assignments.placeExists(placeId, tripId)) return errorResult('Place not found.');
if (!(await this.assignments.dayExists(dayId, tripId))) return errorResult('Day not found.');
if (!(await this.assignments.placeExists(placeId, tripId))) return errorResult('Place not found.');
const assignment = await this.assignments.createAssignment(dayId, placeId, notes || null);
this.guards.safeBroadcast(tripId, 'assignment:created', { assignment });
await this.assignments.reconcile(tripId);
@@ -73,9 +73,9 @@ export class AssignmentsMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.assignments.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
if (!(await this.assignments.getAssignmentForTrip(assignmentId, tripId))) return errorResult('Assignment not found.');
const assignment = await this.assignments.setEndDay(assignmentId, end_day);
this.guards.safeBroadcast(tripId, 'assignment:updated', { assignment });
return ok({ assignment });
@@ -97,11 +97,11 @@ export class AssignmentsMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.assignments.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.assignments.assignmentExistsInDay(assignmentId, dayId, tripId))
if (!(await this.assignments.assignmentExistsInDay(assignmentId, dayId, tripId)))
return errorResult('Assignment not found.');
this.assignments.deleteAssignment(assignmentId);
await this.assignments.deleteAssignment(assignmentId);
this.guards.safeBroadcast(tripId, 'assignment:deleted', { assignmentId, dayId });
await this.assignments.reconcile(tripId);
return ok({ success: true });
@@ -126,9 +126,9 @@ export class AssignmentsMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.assignments.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
const existing = this.assignments.getAssignmentForTrip(assignmentId, tripId);
const existing = await this.assignments.getAssignmentForTrip(assignmentId, tripId);
if (!existing) return errorResult('Assignment not found.');
const { assignment, reordered, vias } = await this.assignments.updateTime(
assignmentId,
@@ -162,9 +162,9 @@ export class AssignmentsMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.assignments.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
if (!(await this.assignments.getAssignmentForTrip(assignmentId, tripId))) return errorResult('Assignment not found.');
const assignment = await this.assignments.updateNotes(assignmentId, notes);
this.guards.safeBroadcast(tripId, 'assignment:updated', { assignment });
return ok({ assignment });
@@ -189,9 +189,9 @@ export class AssignmentsMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.assignments.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
if (!(await this.assignments.getAssignmentForTrip(assignmentId, tripId))) return errorResult('Assignment not found.');
const assignment = direction === 'incoming'
? await this.assignments.setIncomingLegTransportMode(assignmentId, transport_mode ?? null)
: await this.assignments.setLegTransportMode(assignmentId, transport_mode ?? null);
@@ -219,9 +219,9 @@ export class AssignmentsMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.assignments.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
if (!(await this.assignments.getAssignmentForTrip(assignmentId, tripId))) return errorResult('Assignment not found.');
if (!(await this.days.getDay(newDayId, tripId))) return errorResult('Day not found.');
const result = await this.assignments.moveAssignment(assignmentId, newDayId, orderIndex ?? 0);
// REST parity shape ({ assignment, oldDayId, newDayId }) — the client keys its
@@ -246,9 +246,9 @@ export class AssignmentsMcp {
{ tripId, assignmentId }: { tripId: number; assignmentId: number },
ctx: McpContext,
) {
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
const participants = this.assignments.getParticipants(assignmentId);
if (!(await this.assignments.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.assignments.getAssignmentForTrip(assignmentId, tripId))) return errorResult('Assignment not found.');
const participants = await this.assignments.getParticipants(assignmentId);
return ok({ participants });
}
@@ -268,10 +268,10 @@ export class AssignmentsMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.assignments.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
const participants = this.assignments.setParticipants(assignmentId, userIds, tripId);
if (!(await this.assignments.getAssignmentForTrip(assignmentId, tripId))) return errorResult('Assignment not found.');
const participants = await this.assignments.setParticipants(assignmentId, userIds, tripId);
this.guards.safeBroadcast(tripId, 'assignment:participants', { assignmentId, participants });
return ok({ participants });
}
@@ -292,10 +292,10 @@ export class AssignmentsMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.assignments.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.days.getDay(dayId, tripId))) return errorResult('Day not found.');
this.assignments.reorderAssignments(dayId, assignmentIds);
await this.assignments.reorderAssignments(dayId, assignmentIds);
// REST parity shape ({ dayId, orderedIds }) — the client only reads orderedIds,
// so broadcasting the tool-input key name emptied the day for collaborators.
this.guards.safeBroadcast(tripId, 'assignment:reordered', { dayId, orderedIds: assignmentIds });
@@ -8,6 +8,7 @@ import { QueryHelpersService } from '../query-helpers/query-helpers.service';
import { formatAssignmentWithPlace } from '../common/rowShape';
import type { AssignmentRow, DayAssignment, User, Participant } from '../../types';
import { JourneyDomainService } from '../journey/journey-domain.service';
import { UnitOfWork } from '../database/unit-of-work';
type Trip = TripAccess;
@@ -64,9 +65,10 @@ export class AssignmentsService {
private readonly realtime: RealtimeService,
private readonly queryHelpers: QueryHelpersService,
private readonly journey: JourneyDomainService,
private readonly uow: UnitOfWork,
) {}
verifyTripAccess(tripId: string | number, userId: number) {
async verifyTripAccess(tripId: string | number, userId: number) {
return this.dbs.canAccessTrip(Number(tripId), userId);
}
@@ -157,11 +159,11 @@ export class AssignmentsService {
});
}
dayExists(dayId: string | number, tripId: string | number) {
async dayExists(dayId: string | number, tripId: string | number) {
return !!this.dbs.get('SELECT id FROM days WHERE id = ? AND trip_id = ?', dayId, tripId);
}
placeExists(placeId: unknown, tripId: string | number) {
async placeExists(placeId: unknown, tripId: string | number) {
return !!this.dbs.get('SELECT id FROM places WHERE id = ? AND trip_id = ?', placeId, tripId);
}
@@ -172,8 +174,8 @@ export class AssignmentsService {
* stop that reaches it without its booking id is one the day list cannot tell
* from a place the traveller added, so it draws the hotel a second time.
*/
createAssignment(dayId: string | number, placeId: unknown, notes?: string | null, opts: { accommodationId?: number; orderIndex?: number } = {}) {
const result = this.dbs.transaction(() => {
async createAssignment(dayId: string | number, placeId: unknown, notes?: string | null, opts: { accommodationId?: number; orderIndex?: number } = {}) {
const result = await this.uow.transactional(async () => {
const maxOrder = this.dbs.get<{ max: number | null }>('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?', dayId)!;
const end = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
// Somewhere in the middle when the caller says so, which means everything from
@@ -190,30 +192,30 @@ export class AssignmentsService {
);
});
return this.getAssignmentWithPlace(result.lastInsertRowid);
return await this.getAssignmentWithPlace(result.lastInsertRowid);
}
assignmentExistsInDay(id: string | number, dayId: string | number, tripId: string | number) {
async assignmentExistsInDay(id: string | number, dayId: string | number, tripId: string | number) {
return !!this.dbs.get(
'SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND da.day_id = ? AND d.trip_id = ?',
id, dayId, tripId
);
}
deleteAssignment(id: string | number): void {
async deleteAssignment(id: string | number): Promise<void> {
this.dbs.run('DELETE FROM day_assignments WHERE id = ?', id);
}
reorderAssignments(dayId: string | number, orderedIds: number[]): void {
async reorderAssignments(dayId: string | number, orderedIds: number[]): Promise<void> {
const update = this.dbs.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ? AND day_id = ?');
this.dbs.transaction(() => {
await this.uow.transactional(async () => {
orderedIds.forEach((id: number, index: number) => {
update.run(index, id, dayId);
});
});
}
getAssignmentForTrip(id: string | number, tripId: string | number) {
async getAssignmentForTrip(id: string | number, tripId: string | number) {
return this.dbs.get<DayAssignment>(`
SELECT da.* FROM day_assignments da
JOIN days d ON da.day_id = d.id
@@ -224,7 +226,7 @@ export class AssignmentsService {
async moveAssignment(id: string | number, newDayId: unknown, orderIndex: number | null | undefined) {
// The source day comes from the row, not the caller — callers can't lie
// about (or race on) where the assignment was.
const oldDayId = this.dbs.transaction(() => {
const oldDayId = await this.uow.transactional(async () => {
const row = this.dbs.get<{ day_id: number }>('SELECT day_id FROM day_assignments WHERE id = ?', id);
this.dbs.run('UPDATE day_assignments SET day_id = ?, order_index = ? WHERE id = ?', newDayId, orderIndex ?? 0, id);
return row?.day_id;
@@ -233,7 +235,7 @@ export class AssignmentsService {
return { assignment: updated, oldDayId };
}
getParticipants(assignmentId: string | number) {
async getParticipants(assignmentId: string | number) {
return this.dbs.all(`
SELECT ap.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM assignment_participants ap
@@ -256,7 +258,7 @@ export class AssignmentsService {
* same.
*/
async updateTime(id: string | number, placeTime: unknown, endTime: unknown): Promise<AssignmentTimeUpdate> {
const sorted = this.dbs.transaction(() => {
const sorted = await this.uow.transactional(async () => {
const stored = this.dbs.get<{ day_id: number; start: string | null }>(`
SELECT da.day_id, COALESCE(da.assignment_time, p.place_time, acc.check_in) AS start
FROM day_assignments da
@@ -278,13 +280,15 @@ export class AssignmentsService {
// cleared start leaves the day alone too.
if (!placeTime || !stored) return null;
if (sortMinutes(String(placeTime)) === sortMinutes(stored.start)) return null;
return this.sortDayByTime(stored.day_id);
return await this.sortDayByTime(stored.day_id);
});
const vias = sorted?.viasMoved ? { dayId: sorted.dayId, vias: await this.listDayVias(sorted.dayId) } : null;
return {
assignment: await this.getAssignmentWithPlace(Number(id)),
reordered: sorted ? { dayId: sorted.dayId, orderedIds: sorted.orderedIds } : null,
vias: sorted?.viasMoved ? { dayId: sorted.dayId, vias: this.listDayVias(sorted.dayId) } : null,
vias,
};
}
@@ -292,7 +296,7 @@ export class AssignmentsService {
* Puts one day in time order. Writes nothing when it already is, which is the usual
* case: most starts are typed in the order the day is planned.
*/
private sortDayByTime(dayId: number): { dayId: number; orderedIds: number[]; viasMoved: boolean } | null {
private async sortDayByTime(dayId: number): Promise<{ dayId: number; orderedIds: number[]; viasMoved: boolean } | null> {
// A booked night's hour lives on the booking, not on the stop: nobody types a
// time into a hotel row, they type a check-in. Left out of this, the night
// counted as untimed and stayed wherever it had been dropped, so pinning an
@@ -321,7 +325,7 @@ export class AssignmentsService {
if (row.order_index !== i) update.run(i, row.id);
});
return { dayId, orderedIds: sorted.map(row => row.id), viasMoved: this.reanchorVias(dayId, rows, sorted) };
return { dayId, orderedIds: sorted.map(row => row.id), viasMoved: await this.reanchorVias(dayId, rows, sorted) };
}
/**
@@ -334,7 +338,7 @@ export class AssignmentsService {
* No sequence renumbering, unlike RoadtripService.reanchor: a reorder maps each leg
* onto a different one, so two legs' vias never end up on the same leg.
*/
private reanchorVias(dayId: number, before: DayStopRow[], after: DayStopRow[]): boolean {
private async reanchorVias(dayId: number, before: DayStopRow[], after: DayStopRow[]): Promise<boolean> {
const located = (rows: DayStopRow[]) => rows.filter(row => row.located).map(row => row.id);
const previousIds = located(before);
const nextIds = located(after);
@@ -364,7 +368,7 @@ export class AssignmentsService {
* The day's vias in the shape the road trip routes broadcast them. RoadtripService
* has this query too, but its module imports this one, so it cannot be injected here.
*/
private listDayVias(dayId: number): RoadtripVia[] {
private async listDayVias(dayId: number): Promise<RoadtripVia[]> {
return this.dbs.all<RoadtripVia>(
`SELECT id, day_id, after_order_index, sequence, lat, lng, created_at
FROM roadtrip_vias
@@ -374,9 +378,9 @@ export class AssignmentsService {
);
}
setEndDay(id: string | number, endDay: boolean) {
async setEndDay(id: string | number, endDay: boolean) {
this.dbs.run('UPDATE day_assignments SET end_day = ? WHERE id = ?', endDay ? 1 : 0, id);
return this.getAssignmentWithPlace(Number(id));
return await this.getAssignmentWithPlace(Number(id));
}
/**
@@ -387,9 +391,9 @@ export class AssignmentsService {
* journey reconcile: the note affects neither the day order nor the skeleton
* mirror (same as the transport-mode writes).
*/
updateNotes(id: string | number, notes: string | null | undefined) {
async updateNotes(id: string | number, notes: string | null | undefined) {
this.dbs.run('UPDATE day_assignments SET notes = ? WHERE id = ?', notes || null, id);
return this.getAssignmentWithPlace(Number(id));
return await this.getAssignmentWithPlace(Number(id));
}
/**
@@ -398,9 +402,9 @@ export class AssignmentsService {
* sticky by design: changing the whole-day default never touches a leg that
* carries its own explicit mode.
*/
setLegTransportMode(id: string | number, mode: string | null) {
async setLegTransportMode(id: string | number, mode: string | null) {
this.dbs.run('UPDATE day_assignments SET leg_transport_mode = ? WHERE id = ?', mode ?? null, id);
return this.getAssignmentWithPlace(Number(id));
return await this.getAssignmentWithPlace(Number(id));
}
/**
@@ -409,9 +413,9 @@ export class AssignmentsService {
* when the previous timeline element is a place (the column is only read for
* non-place origins like a booking arrival or a morning hotel departure).
*/
setIncomingLegTransportMode(id: string | number, mode: string | null) {
async setIncomingLegTransportMode(id: string | number, mode: string | null) {
this.dbs.run('UPDATE day_assignments SET incoming_leg_transport_mode = ? WHERE id = ?', mode ?? null, id);
return this.getAssignmentWithPlace(Number(id));
return await this.getAssignmentWithPlace(Number(id));
}
/**
@@ -421,10 +425,10 @@ export class AssignmentsService {
* the participants box sends the whole list back on every edit, so rejecting
* the request would strand a trip whose membership changed underneath it.
*/
setParticipants(assignmentId: string | number, userIds: number[], tripId: string | number) {
async setParticipants(assignmentId: string | number, userIds: number[], tripId: string | number) {
const roster = this.dbs.rosterUserIds(tripId);
const scoped = userIds.filter(id => roster.has(id));
this.dbs.transaction(() => {
await this.uow.transactional(async () => {
this.dbs.run('DELETE FROM assignment_participants WHERE assignment_id = ?', assignmentId);
if (scoped.length > 0) {
const insert = this.dbs.prepare('INSERT OR IGNORE INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)');
+4 -4
View File
@@ -32,8 +32,8 @@ export class ItineraryRpc {
const actor = this.guards.requireActor(ctx, 'itinerary');
const notes = params.notes === undefined || params.notes === null ? null : str(params.notes, 'notes');
await this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
if (!this.assignments.dayExists(dayId, tripId)) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`);
if (!this.assignments.placeExists(placeId, tripId)) throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`);
if (!(await this.assignments.dayExists(dayId, tripId))) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`);
if (!(await this.assignments.placeExists(placeId, tripId))) throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`);
const assignment = await this.assignments.createAssignment(dayId, placeId, notes);
this.realtime.broadcast(tripId, 'assignment:created', { assignment });
await this.assignments.reconcile(tripId);
@@ -46,9 +46,9 @@ export class ItineraryRpc {
const assignmentId = num(params.assignmentId, 'assignmentId');
const actor = this.guards.requireActor(ctx, 'itinerary');
await this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
const existing = this.assignments.getAssignmentForTrip(assignmentId, tripId);
const existing = await this.assignments.getAssignmentForTrip(assignmentId, tripId);
if (!existing) throw new ForbiddenResource(`no assignment ${assignmentId} on trip ${tripId}`);
this.assignments.deleteAssignment(assignmentId);
await this.assignments.deleteAssignment(assignmentId);
// The dayId is what the client reducer keys the eviction on. Without it nothing
// leaves the day, so keep the payload shape identical to the REST/MCP delete.
this.realtime.broadcast(tripId, 'assignment:deleted', { assignmentId, dayId: existing.day_id });
@@ -252,7 +252,7 @@ export class BookingImportService {
}
}
const place = this.places.create(tripId, {
const place = await this.places.create(tripId, {
name: _venue.name,
lat,
lng,
@@ -225,7 +225,7 @@ export class DawarichSuggestionsService {
if (body.dayId !== undefined) {
await this.requirePermission('day_edit', access.user_id, userId);
if (!this.assignments.dayExists(body.dayId, tripId)) {
if (!(await this.assignments.dayExists(body.dayId, tripId))) {
throw new AcceptError('day_not_on_trip', 'Day does not belong to this trip', 400);
}
}
@@ -238,7 +238,7 @@ export class DawarichSuggestionsService {
// worse than none: the stay would come back as unhandled while the place it
// already produced sat on the trip.
const { created, placeId, assignment } = await this.uow.transactional(async () => {
const place = this.places.create(String(tripId), {
const place = await this.places.create(String(tripId), {
name: body.name?.trim() || row.name,
lat,
lng,
+27 -27
View File
@@ -118,15 +118,15 @@ export class PlacesController {
private readonly storage: StorageService,
) {}
private requireTrip(tripId: string, user: User) {
const trip = this.places.verifyTripAccess(tripId, user.id);
private async requireTrip(tripId: string, user: User) {
const trip = await this.places.verifyTripAccess(tripId, user.id);
if (!trip) {
throw new HttpException({ error: 'Trip not found' }, 404);
}
return trip;
}
private async requireEdit(trip: NonNullable<ReturnType<PlacesService['verifyTripAccess']>>, user: User): Promise<void> {
private async requireEdit(trip: NonNullable<Awaited<ReturnType<PlacesService['verifyTripAccess']>>>, user: User): Promise<void> {
if (!(await this.places.canEdit(trip, user))) {
throw new HttpException({ error: 'No permission' }, 403);
}
@@ -151,12 +151,12 @@ export class PlacesController {
@Body() body: PlaceCreateDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
const trip = await this.requireTrip(tripId, user);
validateLengths(body);
validateRouteColor(body);
validateUrlFields(body);
await this.requireEdit(trip, user);
const place = this.places.create(tripId, body as never);
const place = await this.places.create(tripId, body as never);
this.places.broadcast(tripId, 'place:created', { place }, socketId);
await this.places.onCreated(tripId, place.id);
return { place };
@@ -171,7 +171,7 @@ export class PlacesController {
@Body() body: PlaceImportGpxDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
const trip = await this.requireTrip(tripId, user);
await this.requireEdit(trip, user);
if (!file) {
throw new HttpException({ error: 'No file uploaded' }, 400);
@@ -182,7 +182,7 @@ export class PlacesController {
if (!importWaypoints && !importRoutes && !importTracks) {
throw new HttpException({ error: 'No import types selected' }, 400);
}
const result = this.places.importGpx(tripId, file.buffer, { importWaypoints, importRoutes, importTracks, defaultName: file.originalname });
const result = await this.places.importGpx(tripId, file.buffer, { importWaypoints, importRoutes, importTracks, defaultName: file.originalname });
if (!result) {
throw new HttpException({ error: 'No matching places found in GPX file' }, 400);
}
@@ -198,20 +198,20 @@ export class PlacesController {
* before the `:id` routes so the literal path wins.
*/
@Get('export.gpx')
exportGpx(
async exportGpx(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Query() query: PlaceExportGpxDto,
@Res() res: Response,
) {
this.requireTrip(tripId, user);
await this.requireTrip(tripId, user);
const waypoints = parseBool(query.waypoints, true);
const tracks = parseBool(query.tracks, true);
const dayRoutes = parseBool(query.dayRoutes, true);
if (!waypoints && !tracks && !dayRoutes) {
throw new HttpException({ error: 'No export types selected' }, 400);
}
const result = this.places.exportGpx(tripId, { waypoints, tracks, dayRoutes });
const result = await this.places.exportGpx(tripId, { waypoints, tracks, dayRoutes });
if (!result) {
throw new HttpException({ error: 'Nothing to export' }, 404);
}
@@ -229,7 +229,7 @@ export class PlacesController {
@Body() body: PlaceImportMapDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
const trip = await this.requireTrip(tripId, user);
await this.requireEdit(trip, user);
if (!file) {
throw new HttpException({ error: 'No file uploaded' }, 400);
@@ -267,7 +267,7 @@ export class PlacesController {
/** Shared google/naver list import — identical flow, different provider + error string. */
private async importList(provider: 'google' | 'naver', user: User, tripId: string, body: PlaceImportListDto, socketId?: string) {
const trip = this.requireTrip(tripId, user);
const trip = await this.requireTrip(tripId, user);
await this.requireEdit(trip, user);
const { url, enrich } = body;
// Opt-in: re-resolve each imported place via the Places API to fill in
@@ -306,7 +306,7 @@ export class PlacesController {
@Body() body: PlaceBulkDeleteDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
const trip = await this.requireTrip(tripId, user);
await this.requireEdit(trip, user);
const { ids } = body;
if (ids.length === 0) {
@@ -317,10 +317,10 @@ export class PlacesController {
// journey entries even though removeMany below refuses it. Still ahead of
// the DELETE — journey_entries.source_place_id is ON DELETE SET NULL, so
// afterwards there is nothing left to detach.
const scoped = this.places.scopedIds(tripId, ids);
const scoped = await this.places.scopedIds(tripId, ids);
for (const id of scoped) await this.places.onDeleted(id);
// Read the linked expenses before the delete — afterwards the link is gone (#1298).
const expenseIds = this.places.linkedExpenseIds(tripId, scoped);
const expenseIds = await this.places.linkedExpenseIds(tripId, scoped);
const { deleted, cancelled } = await this.places.removeMany(tripId, ids);
for (const id of deleted) {
this.places.broadcast(tripId, 'place:deleted', { placeId: id }, socketId);
@@ -350,7 +350,7 @@ export class PlacesController {
@Body() body: PlaceBulkUpdateDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
const trip = await this.requireTrip(tripId, user);
await this.requireEdit(trip, user);
const { ids } = body;
if (ids.length === 0) {
@@ -371,8 +371,8 @@ export class PlacesController {
@Get(':id')
@UseGuards(TripAccessGuard)
get(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string) {
const place = this.places.get(tripId, id);
async get(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string) {
const place = await this.places.get(tripId, id);
if (!place) {
throw new HttpException({ error: 'Place not found' }, 404);
}
@@ -389,7 +389,7 @@ export class PlacesController {
@UploadedFile() file: Express.Multer.File | undefined,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
const trip = await this.requireTrip(tripId, user);
await this.requireEdit(trip, user);
// Inline rather than DemoWriteGuard: requireEdit above answers 404/403 for a
// trip the caller cannot reach, and a guard would run before it.
@@ -415,7 +415,7 @@ export class PlacesController {
}
@Put(':id/rating')
rate(
async rate(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@@ -424,8 +424,8 @@ export class PlacesController {
) {
// Deliberately no place_edit check: every trip member may cast their own
// vote (#1435), even when an admin restricts place editing.
this.requireTrip(tripId, user);
const place = this.places.rate(tripId, id, user.id, body.rating);
await this.requireTrip(tripId, user);
const place = await this.places.rate(tripId, id, user.id, body.rating);
if (!place) {
throw new HttpException({ error: 'Place not found' }, 404);
}
@@ -435,8 +435,8 @@ export class PlacesController {
@Delete(':id/rating')
@UseGuards(TripAccessGuard)
unrate(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
const place = this.places.rate(tripId, id, user.id, null);
async unrate(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
const place = await this.places.rate(tripId, id, user.id, null);
if (!place) {
throw new HttpException({ error: 'Place not found' }, 404);
}
@@ -469,7 +469,7 @@ export class PlacesController {
@Headers('x-socket-id') socketId?: string,
@Headers('x-base-updated-at') ifMatch?: string,
) {
const trip = this.requireTrip(tripId, user);
const trip = await this.requireTrip(tripId, user);
validateLengths(body);
validateRouteColor(body);
validateUrlFields(body);
@@ -495,11 +495,11 @@ export class PlacesController {
async remove(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
// Scope the id to the trip before the hook (see bulkDelete), then sync the
// journey ahead of the actual delete.
if (!this.places.get(tripId, id)) {
if (!(await this.places.get(tripId, id))) {
throw new HttpException({ error: 'Place not found' }, 404);
}
await this.places.onDeleted(Number(id));
const expenseIds = this.places.linkedExpenseIds(tripId, [id]);
const expenseIds = await this.places.linkedExpenseIds(tripId, [id]);
const { deleted, cancelled } = await this.places.remove(tripId, id);
if (!deleted) {
throw new HttpException({ error: 'Place not found' }, 404);
+10 -10
View File
@@ -99,7 +99,7 @@ export class PlacesMcp {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes, website, phone, image_url, price, currency, stop_type });
const place = await this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes, website, phone, image_url, price, currency, stop_type });
this.guards.safeBroadcast(tripId, 'place:created', { place });
return ok({ place });
}
@@ -144,10 +144,10 @@ export class PlacesMcp {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.assignments.dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
if (!(await this.assignments.dayExists(dayId, tripId))) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
try {
const result = await this.uow.transactional(async () => {
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes: place_notes, website, phone, image_url, price, currency, stop_type });
const place = await this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes: place_notes, website, phone, image_url, price, currency, stop_type });
const assignment = await this.assignments.createAssignment(dayId, place.id, assignment_notes ?? null);
return { place, assignment };
});
@@ -231,7 +231,7 @@ export class PlacesMcp {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
// Rating is a personal vote — any trip member may cast one, place_edit not required.
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
const place = this.places.rate(String(tripId), String(placeId), ctx.userId, rating ?? null);
const place = await this.places.rate(String(tripId), String(placeId), ctx.userId, rating ?? null);
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
this.guards.safeBroadcast(tripId, 'place:updated', { place });
return ok({ place });
@@ -254,12 +254,12 @@ export class PlacesMcp {
// Scope the id to the trip before the hook: onPlaceDeleted keys on the place
// id alone, so a foreign id would detach that trip's journey entries even
// though the delete below refuses it.
if (!this.places.get(String(tripId), String(placeId))) {
if (!(await this.places.get(String(tripId), String(placeId)))) {
return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
}
try { await this.journey.onPlaceDeleted(placeId); } catch { /* non-fatal */ } // sync journeys before the row is gone
// The link is gone once the place is, so read it first (#1298).
const expenseIds = this.places.linkedExpenseIds(tripId, [placeId]);
const expenseIds = await this.places.linkedExpenseIds(tripId, [placeId]);
const { deleted, cancelled } = await this.places.remove(String(tripId), String(placeId));
if (!deleted) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
this.guards.safeBroadcast(tripId, 'place:deleted', { placeId });
@@ -378,7 +378,7 @@ export class PlacesMcp {
if (!(await this.guards.hasTripPermission('place_edit', input.tripId, ctx.userId))) return permissionDenied();
if (!input.importWaypoints && !input.importRoutes && !input.importTracks) return errorResult('No import types selected.');
try {
const imported = this.places.importGpx(String(input.tripId), Buffer.from(input.gpx, 'utf8'), { importWaypoints: input.importWaypoints, importRoutes: input.importRoutes, importTracks: input.importTracks, defaultName: input.name });
const imported = await this.places.importGpx(String(input.tripId), Buffer.from(input.gpx, 'utf8'), { importWaypoints: input.importWaypoints, importRoutes: input.importRoutes, importTracks: input.importTracks, defaultName: input.name });
if (!imported) return errorResult('No matching places found in GPX.');
for (const place of imported.places) this.guards.safeBroadcast(input.tripId, 'place:created', { place });
return ok(imported);
@@ -408,7 +408,7 @@ export class PlacesMcp {
// A read, like the REST route: seeing the trip is enough, no place_edit.
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!waypoints && !tracks && !dayRoutes) return errorResult('No export types selected.');
const result = this.places.exportGpx(String(tripId), { waypoints, tracks, dayRoutes });
const result = await this.places.exportGpx(String(tripId), { waypoints, tracks, dayRoutes });
if (!result) return errorResult('Nothing to export.');
return ok({ gpx: result.gpx, filename: result.filename });
}
@@ -431,12 +431,12 @@ export class PlacesMcp {
// Trip-scoped, and ahead of the DELETE: journey_entries.source_place_id is
// ON DELETE SET NULL, so a hook that ran afterwards found nothing left to
// detach and left the entries as orphans.
const scoped = this.places.scopedIds(String(tripId), placeIds);
const scoped = await this.places.scopedIds(String(tripId), placeIds);
for (const id of scoped) {
try { await this.journey.onPlaceDeleted(id); } catch { /* non-fatal */ }
}
// The link is gone once the places are, so read it first (#1298).
const expenseIds = this.places.linkedExpenseIds(tripId, scoped);
const expenseIds = await this.places.linkedExpenseIds(tripId, scoped);
const { deleted, cancelled } = await this.places.removeMany(String(tripId), placeIds);
for (const id of deleted) this.guards.safeBroadcast(tripId, 'place:deleted', { placeId: id });
// A night booked at this place went with it, and took its partner booking and
+3 -3
View File
@@ -60,7 +60,7 @@ export class PlacesRpc {
this.guards.capStrings(parsed.data as Record<string, unknown>, PLACE_STR_LIMITS);
capUrls(parsed.data as Record<string, unknown>);
await this.guards.requireTripEdit(tripId, actor, PLACE_EDIT_ACTION);
const place = this.places.create(String(tripId), parsed.data as unknown as PlaceCreateInput);
const place = await this.places.create(String(tripId), parsed.data as unknown as PlaceCreateInput);
this.realtime.broadcast(tripId, 'place:created', { place });
await this.mirrorJourneys(() => this.journey.onPlaceCreated(tripId, place.id));
return place;
@@ -95,7 +95,7 @@ export class PlacesRpc {
// Scope the id to the trip before anything else: onPlaceDeleted keys on the place
// alone, so an id belonging to a foreign trip would detach THAT trip's journey
// entries even though the delete below refuses it.
if (!this.places.get(String(tripId), String(placeId))) {
if (!(await this.places.get(String(tripId), String(placeId)))) {
throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`);
}
// Ahead of the DELETE, like the REST route and the MCP tool:
@@ -103,7 +103,7 @@ export class PlacesRpc {
// finds nothing left to detach and the entries linger as orphans.
await this.mirrorJourneys(() => this.journey.onPlaceDeleted(placeId));
// The link is gone once the place is, so read it first (#1298).
const expenseIds = this.places.linkedExpenseIds(tripId, [placeId]);
const expenseIds = await this.places.linkedExpenseIds(tripId, [placeId]);
// remove is async (it deletes the place's storage object): await it so the
// refusal check sees the resolved answer, not an always-truthy Promise.
const { deleted, cancelled } = await this.places.remove(String(tripId), String(placeId));
+48 -49
View File
@@ -137,7 +137,7 @@ export class PlacesService {
private readonly uow: UnitOfWork,
) {}
verifyTripAccess(tripId: string, userId: number) {
async verifyTripAccess(tripId: string, userId: number) {
return this.dbs.canAccessTrip(Number(tripId), userId);
}
@@ -157,7 +157,7 @@ export class PlacesService {
* arrive from an older client that had no business knowing it existed, and the
* read-back join hands `tags.user_id` straight to the caller.
*/
private tagsOnTrip(tripId: string | number, tagIds: number[]): number[] {
private async tagsOnTrip(tripId: string | number, tagIds: number[]): Promise<number[]> {
const unique = [...new Set(tagIds)];
if (unique.length === 0) return [];
const roster = this.dbs.rosterUserIds(tripId);
@@ -240,7 +240,7 @@ export class PlacesService {
// Create place
// -------------------------------------------------------------------------
create(tripId: string, body: PlaceCreateInput) {
async create(tripId: string, body: PlaceCreateInput) {
const {
name, description, lat, lng, address, category_id, price, currency,
place_time, end_time,
@@ -278,7 +278,7 @@ export class PlacesService {
if (tags && tags.length > 0) {
const insertTag = this.dbs.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)');
for (const tagId of this.tagsOnTrip(tripId, tags)) {
for (const tagId of await this.tagsOnTrip(tripId, tags)) {
insertTag.run(placeId, tagId);
}
}
@@ -290,7 +290,7 @@ export class PlacesService {
// Get single place
// -------------------------------------------------------------------------
get(tripId: string, placeId: string) {
async get(tripId: string, placeId: string) {
const placeCheck = this.dbs.get('SELECT id FROM places WHERE id = ? AND trip_id = ?', placeId, tripId);
if (!placeCheck) return null;
return this.dbs.getPlaceWithTags(placeId);
@@ -306,22 +306,22 @@ export class PlacesService {
body: PlaceUpdateInput,
ifMatch?: string,
): Promise<PlaceWithTags | UpdateConflict | null> {
const { result, reclaim } = this.applyUpdate(tripId, placeId, body, ifMatch);
const { result, reclaim } = await this.applyUpdate(tripId, placeId, body, ifMatch);
if (reclaim !== undefined) await reclaimPlaceImage(this.storage, reclaim);
return result;
}
/**
* The synchronous DB half of update(). Split out so updateMany() can run it
* inside a better-sqlite3 transaction (which cannot await) and settle the
* storage reclaims after the transaction commits.
* The DB half of update(). Split out so updateMany() can run it inside the
* same UnitOfWork transaction and settle the storage reclaims after the
* transaction commits.
*/
private applyUpdate(
private async applyUpdate(
tripId: string,
placeId: string,
body: PlaceUpdateInput,
ifMatch?: string,
): { result: PlaceWithTags | UpdateConflict | null; reclaim?: string | null } {
): Promise<{ result: PlaceWithTags | UpdateConflict | null; reclaim?: string | null }> {
const existingPlace = this.dbs.get<Place>('SELECT * FROM places WHERE id = ? AND trip_id = ?', placeId, tripId);
if (!existingPlace) return { result: null };
@@ -405,7 +405,7 @@ export class PlacesService {
this.dbs.run('DELETE FROM place_tags WHERE place_id = ?', placeId);
if (tags.length > 0) {
const insertTag = this.dbs.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)');
for (const tagId of this.tagsOnTrip(tripId, tags)) {
for (const tagId of await this.tagsOnTrip(tripId, tags)) {
insertTag.run(placeId, tagId);
}
}
@@ -430,7 +430,7 @@ export class PlacesService {
* budget:deleted events for the rows remove()/removeMany() are about to take
* with them. Read it BEFORE deleting — afterwards the link is gone.
*/
linkedExpenseIds(tripId: string | number, placeIds: Array<string | number>): number[] {
async linkedExpenseIds(tripId: string | number, placeIds: Array<string | number>): Promise<number[]> {
if (placeIds.length === 0) return [];
const rows = this.dbs.all<{ id: number }>(
`SELECT id FROM budget_items WHERE trip_id = ? AND place_id IN (${placeIds.map(() => '?').join(',')})`,
@@ -518,7 +518,7 @@ export class PlacesService {
* those key on the place id alone, so an id from another trip would detach
* that trip's journey entries even though the delete itself refuses it.
*/
scopedIds(tripId: string, ids: number[]): number[] {
async scopedIds(tripId: string, ids: number[]): Promise<number[]> {
if (ids.length === 0) return [];
const placeholders = ids.map(() => '?').join(',');
const rows = this.dbs.all<{ id: number }>(
@@ -542,11 +542,11 @@ export class PlacesService {
if (ids.length === 0) return [];
const updated: PlaceWithTags[] = [];
const reclaims: (string | null)[] = [];
this.dbs.transaction(() => {
await this.uow.transactional(async () => {
for (const id of ids) {
// Bulk update sends no If-Match, so applyUpdate() never returns a
// conflict here; the guard keeps the types honest.
const { result: place, reclaim } = this.applyUpdate(tripId, String(id), body);
const { result: place, reclaim } = await this.applyUpdate(tripId, String(id), body);
if (place && !isUpdateConflict(place)) updated.push(place);
if (reclaim !== undefined) reclaims.push(reclaim);
}
@@ -562,7 +562,7 @@ export class PlacesService {
// -------------------------------------------------------------------------
/** Build a lookup of names/coords for places already in a trip. */
private buildDedupSet(tripId: string): DedupSet {
private async buildDedupSet(tripId: string): Promise<DedupSet> {
const rows = this.dbs.all<{
name: string | null; lat: number | null; lng: number | null;
google_place_id: string | null; google_ftid: string | null; osm_id: string | null; amap_poi_id: string | null;
@@ -595,8 +595,8 @@ export class PlacesService {
* `google_ftid` for the bulk importer's backfill, which is a detail of that
* caller and not part of the question "which place is this?".
*/
findMatchingPlaceId(tripId: string, candidate: PlaceMatchCandidate): number | null {
return this.findDuplicatePlace(tripId, candidate)?.id ?? null;
async findMatchingPlaceId(tripId: string, candidate: PlaceMatchCandidate): Promise<number | null> {
return (await this.findDuplicatePlace(tripId, candidate))?.id ?? null;
}
/**
@@ -623,10 +623,10 @@ export class PlacesService {
* `findMatchingPlaceId` wants — a booking with no place name should link to
* the hotel that has one — so it is stated rather than removed.
*/
private findDuplicatePlace(
private async findDuplicatePlace(
tripId: string,
place: PlaceMatchCandidate,
): { id: number; google_ftid: string | null } | null {
): Promise<{ id: number; google_ftid: string | null } | null> {
for (const strategy of placeMatchStrategies(place)) {
let hit: { id: number; google_ftid: string | null } | undefined;
if (strategy.by === 'externalId') {
@@ -663,9 +663,9 @@ export class PlacesService {
// Import GPX
// -------------------------------------------------------------------------
importGpx(tripId: string, fileBuffer: Buffer, opts: GpxImportOptions = {}): GpxImportResult | null {
const result = this.importGpxRows(tripId, fileBuffer, opts);
this.colorizeImportedTracks(tripId, result);
async importGpx(tripId: string, fileBuffer: Buffer, opts: GpxImportOptions = {}): Promise<GpxImportResult | null> {
const result = await this.importGpxRows(tripId, fileBuffer, opts);
await this.colorizeImportedTracks(tripId, result);
return result;
}
@@ -678,7 +678,7 @@ export class PlacesService {
* Returns null when the selection yields nothing, so the caller answers 404 rather
* than handing over a file that imports as nothing on the other end.
*/
exportGpx(tripId: string, opts: GpxExportOptions = {}): { gpx: string; filename: string } | null {
async exportGpx(tripId: string, opts: GpxExportOptions = {}): Promise<{ gpx: string; filename: string } | null> {
const trip = this.dbs.get<{ title: string }>('SELECT title FROM trips WHERE id = ?', tripId);
if (!trip) return null;
@@ -717,7 +717,7 @@ export class PlacesService {
return gpx ? { gpx, filename: gpxFilename(trip.title) } : null;
}
private importGpxRows(tripId: string, fileBuffer: Buffer, opts: GpxImportOptions = {}): GpxImportResult | null {
private async importGpxRows(tripId: string, fileBuffer: Buffer, opts: GpxImportOptions = {}): Promise<GpxImportResult | null> {
const { importWaypoints = true, importRoutes = true, importTracks = true, defaultName } = opts;
const parsed = gpxParser.parse(fileBuffer.toString('utf-8'));
@@ -789,14 +789,14 @@ export class PlacesService {
if (waypoints.length === 0) return null;
const dedup = this.buildDedupSet(tripId);
const dedup = await this.buildDedupSet(tripId);
const insertStmt = this.dbs.prepare(`
INSERT INTO places (trip_id, name, description, lat, lng, transport_mode, route_geometry)
VALUES (?, ?, ?, ?, ?, 'walking', ?)
`);
const created: PlaceWithTags[] = [];
let skipped = 0;
this.dbs.transaction(() => {
await this.uow.transactional(async () => {
for (const wp of waypoints) {
if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) {
skipped++;
@@ -818,7 +818,7 @@ export class PlacesService {
async importMapFile(tripId: string, fileBuffer: Buffer, filename: string, opts: KmlImportOptions = {}): Promise<PlaceImportResult> {
const result = await this.importMapFileRows(tripId, fileBuffer, filename, opts);
this.colorizeImportedTracks(tripId, result);
await this.colorizeImportedTracks(tripId, result);
return result;
}
@@ -834,7 +834,7 @@ export class PlacesService {
return this.importKmlPlaces(tripId, kmlBuffer, opts);
}
importKmlPlaces(tripId: string, fileBuffer: Buffer, opts: KmlImportOptions = {}): PlaceImportResult {
async importKmlPlaces(tripId: string, fileBuffer: Buffer, opts: KmlImportOptions = {}): Promise<PlaceImportResult> {
const { importPoints = true, importPaths = true } = opts;
const decoded = decodeUtf8WithWarning(fileBuffer);
@@ -859,7 +859,7 @@ export class PlacesService {
const categories = this.dbs.all<{ id: number; name: string }>('SELECT id, name FROM categories');
const categoryLookup = buildCategoryNameLookup(categories);
const dedup = this.buildDedupSet(tripId);
const dedup = await this.buildDedupSet(tripId);
const created: PlaceWithTags[] = [];
let dupCount = 0;
@@ -868,7 +868,7 @@ export class PlacesService {
VALUES (?, ?, ?, ?, ?, ?, 'walking', ?)
`);
this.dbs.transaction(() => {
await this.uow.transactional(async () => {
let fallbackIndex = 1;
for (const node of placemarkNodes) {
const parsedPlacemark = parsePlacemarkNode(node);
@@ -955,25 +955,24 @@ export class PlacesService {
* it used to hand the same object back, which read like a transformation and
* was none.
*/
private colorizeImportedTracks(tripId: string, result: { places: ImportedPlace[] } | null): void {
private async colorizeImportedTracks(tripId: string, result: { places: ImportedPlace[] } | null): Promise<void> {
const tracks = result?.places?.filter((p) => p.route_geometry && !p.route_color) ?? [];
if (tracks.length === 0) return;
// Read and write in one transaction so two concurrent imports cannot both
// read the same set of free colours.
this.dbs.transaction((conn) => {
await this.uow.transactional(async () => {
const taken = new Set(
(conn
.prepare('SELECT DISTINCT route_color AS c FROM places WHERE trip_id = ? AND route_color IS NOT NULL')
.all(tripId) as { c: string }[]).map((r) => r.c),
this.dbs.all<{ c: string }>(
'SELECT DISTINCT route_color AS c FROM places WHERE trip_id = ? AND route_color IS NOT NULL', tripId,
).map((r) => r.c),
);
const free = TRACK_COLORS.filter((c) => !taken.has(c));
const stmt = conn.prepare('UPDATE places SET route_color = ? WHERE id = ?');
tracks.forEach((track, i) => {
// Free ones first, then wrap through the whole palette — never reuse a
// free colour twice within the same import.
const color = i < free.length ? free[i] : TRACK_COLORS[(i - free.length) % TRACK_COLORS.length];
stmt.run(color, track.id);
this.dbs.run('UPDATE places SET route_color = ? WHERE id = ?', color, track.id);
track.route_color = color;
});
});
@@ -1103,7 +1102,7 @@ export class PlacesService {
return { error: 'No places with coordinates found in list', status: 400 };
}
const { created, skipped } = this.storeGooglePlaces(tripId, places);
const { created, skipped } = await this.storeGooglePlaces(tripId, places);
if (created.length) {
void this.enrichImportedList(tripId, created as EnrichablePlace[], opts);
@@ -1119,11 +1118,11 @@ export class PlacesService {
* the same rules — a place already on the trip is skipped rather than doubled, and a
* row that matches but carries no provider id is given the one this import knows.
*/
private storeGooglePlaces(
private async storeGooglePlaces(
tripId: string,
places: { name: string; lat: number; lng: number; notes: string | null; googleFtid: string | null }[],
): { created: PlaceWithTags[]; skipped: number } {
const dedup = this.buildDedupSet(tripId);
): Promise<{ created: PlaceWithTags[]; skipped: number }> {
const dedup = await this.buildDedupSet(tripId);
const insertStmt = this.dbs.prepare(`
INSERT INTO places (trip_id, name, lat, lng, notes, google_ftid, transport_mode)
VALUES (?, ?, ?, ?, ?, ?, 'walking')
@@ -1131,7 +1130,7 @@ export class PlacesService {
const updateGoogleFtidStmt = this.dbs.prepare('UPDATE places SET google_ftid = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?');
const created: PlaceWithTags[] = [];
let skipped = 0;
this.dbs.transaction(() => {
await this.uow.transactional(async () => {
for (const p of places) {
// One candidate for both halves. Passing the raw parser object to the SQL
// half used to mean its provider id never arrived — the field is
@@ -1140,7 +1139,7 @@ export class PlacesService {
// this candidate's ftid on the backfill below.
const candidate = { name: p.name, lat: p.lat, lng: p.lng, google_ftid: p.googleFtid };
if (isPlaceDuplicate(candidate, dedup)) {
const duplicate = this.findDuplicatePlace(tripId, candidate);
const duplicate = await this.findDuplicatePlace(tripId, candidate);
if (duplicate && !duplicate.google_ftid && p.googleFtid) {
updateGoogleFtidStmt.run(p.googleFtid, duplicate.id);
}
@@ -1246,7 +1245,7 @@ export class PlacesService {
return { error: 'None of the stops in that link could be placed on the map.', status: 400 };
}
const { created, skipped } = this.storeGooglePlaces(tripId, places);
const { created, skipped } = await this.storeGooglePlaces(tripId, places);
if (created.length) {
void this.enrichImportedList(tripId, created as EnrichablePlace[], opts);
}
@@ -1374,14 +1373,14 @@ export class PlacesService {
return { error: 'No places with coordinates found in list', status: 400 };
}
const dedup = this.buildDedupSet(tripId);
const dedup = await this.buildDedupSet(tripId);
const insertStmt = this.dbs.prepare(`
INSERT INTO places (trip_id, name, lat, lng, address, notes, transport_mode)
VALUES (?, ?, ?, ?, ?, ?, 'walking')
`);
const created: PlaceWithTags[] = [];
let skipped = 0;
this.dbs.transaction(() => {
await this.uow.transactional(async () => {
for (const p of places) {
if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) {
skipped++;
@@ -1579,7 +1578,7 @@ export class PlacesService {
* a vote must not 409 another member's offline edit. Returns the refreshed
* place (with the new aggregate) or null when the place isn't in the trip.
*/
rate(tripId: string, placeId: string, userId: number, rating: number | null): PlaceWithTags | null {
async rate(tripId: string, placeId: string, userId: number, rating: number | null): Promise<PlaceWithTags | null> {
const place = this.dbs.get('SELECT id FROM places WHERE id = ? AND trip_id = ?', placeId, tripId);
if (!place) return null;
if (rating === null) {
@@ -66,10 +66,10 @@ export class PublicApiController {
* this is the one list in TREK it can answer questions about.
*/
@Get('bucket-list')
listBucketList(@Req() req: Request): PublicApiBucketList {
async listBucketList(@Req() req: Request): Promise<PublicApiBucketList> {
this.limit(req);
requireScope(req, 'bucket-list');
return { items: this.api.listBucketList(requireUserId(req)) };
return { items: await this.api.listBucketList(requireUserId(req)) };
}
/**
@@ -80,11 +80,11 @@ export class PublicApiController {
* endpoint into a way to count someone else's trips.
*/
@Get('trips/:id')
getTrip(
async getTrip(
@Req() req: Request,
@Param('id') id: string,
@Query('include') include?: string,
): PublicApiTrip {
): Promise<PublicApiTrip> {
this.limit(req);
requireScope(req, 'trips');
const tripId = parseTripId(id);
@@ -102,7 +102,7 @@ export class PublicApiController {
// The grant goes in as well as the narrowed include: days are implied by any
// section that hangs off them, and the implied block must still be measured
// against what the key may read.
const trip = this.api.getTrip(tripId, requireUserId(req), allowed, grantedScopes(req));
const trip = await this.api.getTrip(tripId, requireUserId(req), allowed, grantedScopes(req));
if (!trip) {
throw new HttpException({ error: 'Trip not found' }, 404);
}
@@ -61,7 +61,7 @@ export class PublicApiService {
* turns both into the same 404, so the endpoint cannot be used to probe which
* trip ids exist.
*/
getTrip(tripId: number, userId: number, include: PublicApiInclude[], granted: readonly string[] = include): PublicApiTrip | null {
async getTrip(tripId: number, userId: number, include: PublicApiInclude[], granted: readonly string[] = include): Promise<PublicApiTrip | null> {
if (!this.db.canAccessTrip(tripId, userId)) return null;
const row = this.db.get<TripRow>(
`SELECT id, title, description, start_date, end_date, currency, is_archived, updated_at
@@ -80,19 +80,19 @@ export class PublicApiService {
// through the container those places arrive in. The shell it does get is the
// join key the children are useless without.
if (DAY_SCOPED.some((section) => include.includes(section))) {
trip.days = this.buildDays(tripId, include, granted.includes('days'));
trip.days = await this.buildDays(tripId, include, granted.includes('days'));
}
if (include.includes('places')) {
trip.unplanned_places = this.buildUnplannedPlaces(tripId);
trip.unplanned_places = await this.buildUnplannedPlaces(tripId);
}
if (include.includes('reservations')) {
trip.unscheduled_reservations = this.buildUnscheduledReservations(tripId);
trip.unscheduled_reservations = await this.buildUnscheduledReservations(tripId);
}
if (include.includes('accommodations')) {
trip.accommodations = this.buildAccommodations(tripId);
trip.accommodations = await this.buildAccommodations(tripId);
}
if (include.includes('travellers')) {
trip.travellers = this.buildTravellers(tripId);
trip.travellers = await this.buildTravellers(tripId);
}
return trip;
}
@@ -105,7 +105,7 @@ export class PublicApiService {
* rather than queried per day — a two-week trip would otherwise cost 42 round
* trips for the same rows.
*/
private buildDays(tripId: number, include: PublicApiInclude[], dayFields: boolean): PublicApiDay[] {
private async buildDays(tripId: number, include: PublicApiInclude[], dayFields: boolean): Promise<PublicApiDay[]> {
const days = this.db.all<DayRow>(
`SELECT id, day_number, date, title, notes
FROM days WHERE trip_id = ? ORDER BY day_number ASC`,
@@ -113,10 +113,10 @@ export class PublicApiService {
);
if (days.length === 0) return [];
const placesByDay = include.includes('places') ? this.placesByDay(tripId) : new Map();
const notesByDay = include.includes('notes') ? this.dayNotesByDay(tripId) : new Map();
const placesByDay = include.includes('places') ? await this.placesByDay(tripId) : new Map();
const notesByDay = include.includes('notes') ? await this.dayNotesByDay(tripId) : new Map();
const reservationsByDay = include.includes('reservations')
? this.reservationsByDay(tripId)
? await this.reservationsByDay(tripId)
: new Map();
return days.map((day) => ({
@@ -143,7 +143,7 @@ export class PublicApiService {
* listed here as well it would read as two different intentions. Same rule as
* the shortlist below.
*/
private placesByDay(tripId: number): Map<number, PublicApiPlace[]> {
private async placesByDay(tripId: number): Promise<Map<number, PublicApiPlace[]>> {
const rows = this.db.all<PlaceRow>(
`SELECT da.day_id,
p.name, p.address, p.lat, p.lng, p.place_time, p.end_time,
@@ -160,7 +160,7 @@ export class PublicApiService {
return groupBy(rows, (r: PlaceRow) => r.day_id, toPlace);
}
private dayNotesByDay(tripId: number): Map<number, PublicApiDayNote[]> {
private async dayNotesByDay(tripId: number): Promise<Map<number, PublicApiDayNote[]>> {
const rows = this.db.all<DayNoteRow>(
`SELECT day_id, text, time
FROM day_notes WHERE trip_id = ?
@@ -180,7 +180,7 @@ export class PublicApiService {
* repeated on each — a consumer that sees the same flight on three days has no
* way to tell that from three flights.
*/
private reservationsByDay(tripId: number): Map<number, PublicApiReservation[]> {
private async reservationsByDay(tripId: number): Promise<Map<number, PublicApiReservation[]>> {
const rows = this.db.all<ReservationRow>(
`SELECT day_id, type, title, location, reservation_time, reservation_end_time,
status, notes
@@ -198,7 +198,7 @@ export class PublicApiService {
* Stored as day ids, reported as ISO dates: a consumer has no way to look up a
* TREK day id, and the dates are what it actually needs to match its own nights.
*/
private buildAccommodations(tripId: number): PublicApiAccommodation[] {
private async buildAccommodations(tripId: number): Promise<PublicApiAccommodation[]> {
const rows = this.db.all<AccommodationRow>(
`SELECT p.name, p.address, p.lat, p.lng,
ds.date AS start_date, de.date AS end_date,
@@ -240,7 +240,7 @@ export class PublicApiService {
* but it is not a shortlist entry and it is already reported in full under
* `accommodations` — listing it twice would read as two different intentions.
*/
private buildUnplannedPlaces(tripId: number): PublicApiPlace[] {
private async buildUnplannedPlaces(tripId: number): Promise<PublicApiPlace[]> {
const rows = this.db.all<Omit<PlaceRow, 'day_id'>>(
`SELECT p.name, p.address, p.lat, p.lng, p.place_time, p.end_time,
p.duration_minutes, p.notes, p.transport_mode,
@@ -261,7 +261,7 @@ export class PublicApiService {
* sets it null rather than cascading, so a flight can outlive the day it was
* pinned to. Reporting only day-bound bookings would quietly lose those.
*/
private buildUnscheduledReservations(tripId: number): PublicApiReservation[] {
private async buildUnscheduledReservations(tripId: number): Promise<PublicApiReservation[]> {
const rows = this.db.all<Omit<ReservationRow, 'day_id'>>(
`SELECT type, title, location, reservation_time, reservation_end_time,
status, notes
@@ -285,7 +285,7 @@ export class PublicApiService {
* whether TREK shows the feature, not whether the rows exist, and a key whose
* answers change when an unrelated toggle moves is a key nobody can build on.
*/
listBucketList(userId: number): PublicApiBucketListItem[] {
async listBucketList(userId: number): Promise<PublicApiBucketListItem[]> {
const rows = this.db.all<BucketListRow>(
`SELECT name, lat, lng, country_code, notes, target_date
FROM bucket_list WHERE user_id = ?
@@ -310,7 +310,7 @@ export class PublicApiService {
* itinerary, not for enumerating the people around them. What it returns is
* exactly what those people already see on the trip in TREK.
*/
private buildTravellers(tripId: number): PublicApiTraveller[] {
private async buildTravellers(tripId: number): Promise<PublicApiTraveller[]> {
const rows = this.db.all<TravellerRow>(
`SELECT u.username, 1 AS is_owner
FROM trips t JOIN users u ON u.id = t.user_id
@@ -68,7 +68,7 @@ export class ReservationsController {
@Headers('x-socket-id') socketId?: string,
) {
const body = rawBody as ReservationBody & { title: string };
this.rejectForeignReferences(tripId, body);
await this.rejectForeignReferences(tripId, body);
const { reservation, accommodationCreated } = await this.reservations.create(tripId, body as never);
if (accommodationCreated) {
this.reservations.broadcast(tripId, 'accommodation:created', {}, socketId);
@@ -108,7 +108,7 @@ export class ReservationsController {
if (!current) {
throw new HttpException({ error: 'Reservation not found' }, 404);
}
this.rejectForeignReferences(tripId, body);
await this.rejectForeignReferences(tripId, body);
const { reservation, accommodationChanged } = await this.reservations.update(id, tripId, body as never, current as never);
if (accommodationChanged) {
this.reservations.broadcast(tripId, 'accommodation:updated', {}, socketId);
@@ -180,12 +180,12 @@ export class ReservationsController {
* trip" about an id that is part of nothing would send the caller looking in
* the wrong place.
*/
private rejectForeignReferences(tripId: string, body: ReservationBody): void {
private async rejectForeignReferences(tripId: string, body: ReservationBody): Promise<void> {
const offenders = this.reservations.referencesOutsideTrip(tripId, body as never);
if (offenders.length > 0) {
throw new HttpException({ error: `Not part of this trip: ${offenders.join(', ')}` }, 400);
}
const unknown = this.reservations.unresolvedReferences(tripId, body as never);
const unknown = await this.reservations.unresolvedReferences(tripId, body as never);
if (unknown.length > 0) {
throw new HttpException({ error: `Unknown reference: ${unknown.join(', ')}` }, 400);
}
@@ -368,13 +368,13 @@ export class ReservationsMcp {
// Validate that all referenced IDs belong to this trip
if (day_id && !(await this.days.getDay(day_id, tripId)))
return errorResult('day_id does not belong to this trip.');
if (place_id && !this.assignments.placeExists(place_id, tripId))
if (place_id && !(await this.assignments.placeExists(place_id, tripId)))
return errorResult('place_id does not belong to this trip.');
if (start_day_id && !(await this.days.getDay(start_day_id, tripId)))
return errorResult('start_day_id does not belong to this trip.');
if (end_day_id && !(await this.days.getDay(end_day_id, tripId)))
return errorResult('end_day_id does not belong to this trip.');
if (assignment_id && !this.assignments.getAssignmentForTrip(assignment_id, tripId))
if (assignment_id && !(await this.assignments.getAssignmentForTrip(assignment_id, tripId)))
return errorResult('assignment_id does not belong to this trip.');
const createAccommodation = (type === 'hotel' && place_id && start_day_id && end_day_id)
@@ -443,9 +443,9 @@ export class ReservationsMcp {
const existing = this.reservations.getReservation(reservationId, tripId);
if (!existing) return errorResult('Reservation not found.');
if (place_id != null && !this.assignments.placeExists(place_id, tripId))
if (place_id != null && !(await this.assignments.placeExists(place_id, tripId)))
return errorResult('place_id does not belong to this trip.');
if (assignment_id != null && !this.assignments.getAssignmentForTrip(assignment_id, tripId))
if (assignment_id != null && !(await this.assignments.getAssignmentForTrip(assignment_id, tripId)))
return errorResult('assignment_id does not belong to this trip.');
const { reservation } = await this.reservations.update(reservationId, tripId, {
@@ -577,7 +577,7 @@ export class ReservationsMcp {
if (!current) return errorResult('Reservation not found.');
if (current.type !== 'hotel') return errorResult('Reservation is not of type hotel.');
if (!this.assignments.placeExists(place_id, tripId))
if (!(await this.assignments.placeExists(place_id, tripId)))
return errorResult('place_id does not belong to this trip.');
if (!(await this.days.getDay(start_day_id, tripId)))
return errorResult('start_day_id does not belong to this trip.');
@@ -38,7 +38,7 @@ export class ReservationsRpc {
const input = parsed.data as Record<string, unknown>;
this.requireValidEndpoints(input.endpoints);
await this.guards.requireTripEdit(tripId, actor, RESERVATION_EDIT_ACTION);
this.requireOwnReferences(tripId, input);
await this.requireOwnReferences(tripId, input);
const { reservation, accommodationCreated } = await this.reservations.create(String(tripId), input as never);
if (accommodationCreated) this.realtime.broadcast(tripId, 'accommodation:created', {}, undefined);
const i = input as { title?: string; type?: string; create_budget_entry?: unknown };
@@ -60,7 +60,7 @@ export class ReservationsRpc {
await this.guards.requireTripEdit(tripId, actor, RESERVATION_EDIT_ACTION);
const current = this.reservations.getReservation(String(reservationId), String(tripId));
if (!current) throw new ForbiddenResource(`no reservation ${reservationId} on trip ${tripId}`);
this.requireOwnReferences(tripId, input);
await this.requireOwnReferences(tripId, input);
const { reservation, accommodationChanged } = await this.reservations.update(String(reservationId), String(tripId), input as never, current as never);
if (accommodationChanged) this.realtime.broadcast(tripId, 'accommodation:updated', {}, undefined);
const cur = current as { title: string; type?: string };
@@ -109,10 +109,10 @@ export class ReservationsRpc {
* resolves to nothing is a foreign-key error the plugin reads as a crash (#2355),
* so it is named here as well, after the ownership check.
*/
private requireOwnReferences(tripId: number, input: Record<string, unknown>): void {
private async requireOwnReferences(tripId: number, input: Record<string, unknown>): Promise<void> {
const offenders = this.reservations.referencesOutsideTrip(String(tripId), input as never);
if (offenders.length > 0) throw new ForbiddenResource(`not part of trip ${tripId}: ${offenders.join(', ')}`);
const unknown = this.reservations.unresolvedReferences(String(tripId), input as never);
const unknown = await this.reservations.unresolvedReferences(String(tripId), input as never);
if (unknown.length > 0) throw new BadParams(`unknown reference: ${unknown.join(', ')}`);
}
@@ -587,7 +587,7 @@ export class ReservationsService {
* that belongs to another trip is named here too — the REST controller asks
* the older guard first, so that case keeps its own answer.
*/
unresolvedReferences(tripId: string | number, data: CreateReservationData | UpdateReservationData): string[] {
async unresolvedReferences(tripId: string | number, data: CreateReservationData | UpdateReservationData): Promise<string[]> {
const offenders: string[] = [];
const onTrip = (table: 'days' | 'places', id: unknown) =>
!!this.db.get(`SELECT id FROM ${table} WHERE id = ? AND trip_id = ?`, id, tripId);
@@ -610,7 +610,7 @@ export class ReservationsService {
// on a write that currently succeeds is not what this guard is for.
if (data.create_accommodation && data.type === 'hotel') {
const acc = data.create_accommodation;
const errors = this.accommodations.validateAccommodationRefs(
const errors = await this.accommodations.validateAccommodationRefs(
tripId, acc.place_id || undefined, acc.start_day_id || undefined, acc.end_day_id || undefined,
);
for (const { field } of errors) offenders.push(`create_accommodation.${field}`);
@@ -65,13 +65,13 @@ export class GoogleRouteService {
if (!(await this.permissions.checkPermission(action, role, access.user_id, userId, access.user_id !== userId)))
throw new HttpException({ error: 'Permission denied' }, 403);
}
if (!this.assignments.dayExists(String(input.dayId), String(tripId))) throw new HttpException({ error: 'Day not found' }, 404);
if (!(await this.assignments.dayExists(String(input.dayId), String(tripId)))) throw new HttpException({ error: 'Day not found' }, 404);
// `map` cannot await the now-async assignment write, so the same per-stop
// sequence runs as an explicit loop inside the transaction.
const imported = await this.uow.transactional(async () => {
const rows: { place: ReturnType<PlacesService['create']>; assignment: Awaited<ReturnType<AssignmentsService['createAssignment']>> }[] = [];
const rows: { place: Awaited<ReturnType<PlacesService['create']>>; assignment: Awaited<ReturnType<AssignmentsService['createAssignment']>> }[] = [];
for (const stop of input.stops) {
const place = this.places.create(String(tripId), { ...stop, transport_mode: 'car', duration_minutes: 0 });
const place = await this.places.create(String(tripId), { ...stop, transport_mode: 'car', duration_minutes: 0 });
const assignment = await this.assignments.createAssignment(input.dayId, place.id);
rows.push({ place, assignment });
}
@@ -60,7 +60,7 @@ export class TripReadModelService {
const { days: rawDays } = await this.days.list(tripId);
const days = rawDays.map(({ notes_items, ...day }) => ({ ...day, notes: notes_items }));
const accommodations = this.accommodations.list(tripId);
const accommodations = await this.accommodations.list(tripId);
const budgetItems = await this.budget.listBudgetItems(tripId);
const budget = {
@@ -111,7 +111,7 @@ export class TripReadModelService {
budgetItems: await this.budget.listBudgetItems(tripId),
reservations: this.reservations.list(tripId),
files: await this.files.listFiles(tripId, false),
accommodations: this.accommodations.list(tripId),
accommodations: await this.accommodations.list(tripId),
members: [owner, ...(members || [])].filter(Boolean),
};
}
+1 -1
View File
@@ -83,7 +83,7 @@ export class TripsRpc {
@PluginMethod('trips.getAccommodations', { permission: 'db:read:trips' })
getAccommodations(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
return this.guards.tripRead(params, ctx, () => this.accommodations.list(num(params.tripId, 'tripId')) as unknown[]);
return this.guards.tripRead(params, ctx, async () => (await this.accommodations.list(num(params.tripId, 'tripId'))) as unknown[]);
}
@PluginMethod('trips.listMine', { permission: 'db:read:trips' })