Files
TREK/server/src/nest/days/days.controller.ts
T
Maurice f46cc8a98e Reorder whole days and insert a day (#589) (#1148)
* feat(days): reorder whole days and insert a day at a position

Adds reorderDays + insertDay to the day service and a PUT /days/reorder route
(plus an optional position on create). Day rows stay stable so a day's
assignments, notes, bookings and accommodations ride along by id; on a dated
trip the calendar dates stay pinned to their slots while the content moves
across them, and each booking's date is re-stamped onto its day's new date
(time-of-day preserved) so day_id stays consistent. Renumbering uses the
two-phase write to avoid the UNIQUE(trip_id, day_number) collision, and a move
that would invert an accommodation's check-in/out span is rejected.

* feat(planner): reorder days from a toolbar popup, and add days

A new toolbar button opens a popup listing the days; drag a row by its grip or
use the up/down arrows to reorder, and add a day from there. Reorders apply
optimistically with rollback and sync over WebSocket; the day headers are left
untouched, so the existing place drop-targets are unaffected.

* i18n: add day-reorder strings across all languages
2026-06-12 00:17:49 +02:00

133 lines
4.2 KiB
TypeScript

import {
Body,
Controller,
Delete,
Get,
Headers,
HttpException,
Param,
Post,
Put,
UseGuards,
} from '@nestjs/common';
import type { User } from '../../types';
import { DaysService } from './days.service';
import { DayReorderError } from '../../services/dayService';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
/**
* /api/trips/:tripId/days — trip itinerary days.
*
* Byte-identical to the legacy Express route (server/src/routes/days.ts): trip
* access (404 "Trip not found"), the 'day_edit' permission on mutations (403),
* create 201 / rest 200, the bespoke 404 "Day not found", and WebSocket
* broadcasts with the forwarded X-Socket-Id.
*/
@Controller('api/trips/:tripId/days')
@UseGuards(JwtAuthGuard)
export class DaysController {
constructor(private readonly days: DaysService) {}
private requireTrip(tripId: string, user: User) {
const trip = this.days.verifyTripAccess(tripId, user.id);
if (!trip) {
throw new HttpException({ error: 'Trip not found' }, 404);
}
return trip;
}
private requireEdit(trip: NonNullable<ReturnType<DaysService['verifyTripAccess']>>, user: User): void {
if (!this.days.canEdit(trip, user)) {
throw new HttpException({ error: 'No permission' }, 403);
}
}
@Get()
list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
this.requireTrip(tripId, user);
return this.days.list(tripId);
}
@Post()
create(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: { date?: string; notes?: string; position?: number },
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
// A `position` means "insert a new empty day here" (which on a dated trip
// extends the trip and re-pins dates); without it, the legacy append.
const day = body.position !== undefined
? this.days.insert(tripId, body.position)
: this.days.create(tripId, body.date, body.notes);
// An insert can shuffle dates/positions of other days, so collaborators
// refetch the whole list; a plain append only needs the new day.
const event = body.position !== undefined ? 'day:reordered' : 'day:created';
this.days.broadcast(tripId, event, { day }, socketId);
return { day };
}
@Put('reorder')
reorder(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: { orderedIds?: number[] },
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
if (!Array.isArray(body.orderedIds)) {
throw new HttpException({ error: 'orderedIds must be an array' }, 400);
}
try {
this.days.reorder(tripId, body.orderedIds);
} catch (err) {
if (err instanceof DayReorderError) {
throw new HttpException({ error: err.message }, 400);
}
throw err;
}
this.days.broadcast(tripId, 'day:reordered', { orderedIds: body.orderedIds }, socketId);
return { success: true };
}
@Put(':id')
update(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: { notes?: string; title?: string | null },
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
const current = this.days.getDay(id, tripId);
if (!current) {
throw new HttpException({ error: 'Day not found' }, 404);
}
const day = this.days.update(id, current as never, { notes: body.notes, title: body.title });
this.days.broadcast(tripId, 'day:updated', { day }, socketId);
return { day };
}
@Delete(':id')
remove(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
if (!this.days.getDay(id, tripId)) {
throw new HttpException({ error: 'Day not found' }, 404);
}
this.days.remove(id);
this.days.broadcast(tripId, 'day:deleted', { dayId: Number(id) }, socketId);
return { success: true };
}
}