mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
cc1c16ec34
Saving a start on a stop rewrote the whole day as the timed stops in time order followed by every untimed stop. Its own comment and the commit that added it promised that untimed stops keep their place, but a day of two untimed stops and a third one given 14:00 came back with the third one first. On a road trip the stop order is the drive, so the day was re-routed, its vias landed on other legs, and collaborators, who only got the one edited row, saw a third order until they reloaded. The intended rule, an untimed stop inheriting the time of the timed stop before it, now lives once as chronoOrder in @trek/shared, used by both the day plan (dayMerge) and AssignmentsService.updateTime. The server still reads a booked night's hour from its check-in, and a value without a colon still sorts after every clock time. The day is sorted only when the stop's start actually changed, so taking an End off or saving an unchanged start no longer snaps back a day somebody dragged out of time order. A sorted day is numbered from 0 like a drag. In the same transaction the day's road trip vias follow the stop they were drawn behind, the planner's own drag rule, and a via behind a last stop that stays last is kept, so the drive into the next day keeps its road. PUT /assignments/:id/time and the MCP update_assignment_time tool both broadcast assignment:reordered with the whole day, to the writer as well, and roadtripVia:changed when vias moved, so every client ends up with the same order. ASG-SVC-022, 023 and 027 pinned the old behaviour and now pin the intended one.
257 lines
9.8 KiB
TypeScript
257 lines
9.8 KiB
TypeScript
import {
|
|
Body,
|
|
Controller,
|
|
Delete,
|
|
Get,
|
|
Headers,
|
|
HttpException,
|
|
Param,
|
|
Post,
|
|
Put,
|
|
UseGuards,
|
|
} from '@nestjs/common';
|
|
import type { User } from '../../types';
|
|
import { AssignmentsService } from './assignments.service';
|
|
import {
|
|
AssignmentCreateDto,
|
|
AssignmentReorderDto,
|
|
AssignmentMoveDto,
|
|
AssignmentTimeDto,
|
|
AssignmentEndDayDto,
|
|
AssignmentNotesDto,
|
|
AssignmentTransportDto,
|
|
AssignmentParticipantsDto,
|
|
} from './assignments.dto';
|
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
|
import { CurrentUser } from '../auth/current-user.decorator';
|
|
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
|
|
|
|
type Trip = NonNullable<ReturnType<AssignmentsService['verifyTripAccess']>>;
|
|
|
|
|
|
|
|
/**
|
|
* /api/trips/:tripId/days/:dayId/assignments — the day's ordered itinerary items.
|
|
*
|
|
* Parity with the retired legacy Express route: trip access (404), 'day_edit'
|
|
* on mutations (403, GET is access-only), create 201 / rest 200, the bespoke
|
|
* "Day not found" / "Place not found" / "Assignment not found" bodies, the
|
|
* journey skeleton reconcile, and WebSocket broadcasts. Bodies are validated
|
|
* by the @trek/shared Zod contracts via assignments.dto.ts (global pipe).
|
|
*/
|
|
@Controller('api/trips/:tripId/days/:dayId/assignments')
|
|
// TripAccessGuard resolves :tripId and 404s a trip the user cannot reach; mutations
|
|
// add @RequirePermission('day_edit'), the same action string the service's canEdit
|
|
// passes, so the HTTP and MCP paths cannot demand different rights.
|
|
@UseGuards(JwtAuthGuard, TripAccessGuard)
|
|
export class DayAssignmentsController {
|
|
constructor(private readonly assignments: AssignmentsService) {}
|
|
|
|
@Get()
|
|
list(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('dayId') dayId: string) {
|
|
if (!this.assignments.dayExists(dayId, tripId)) {
|
|
throw new HttpException({ error: 'Day not found' }, 404);
|
|
}
|
|
return { assignments: this.assignments.listDayAssignments(dayId) };
|
|
}
|
|
|
|
@RequirePermission('day_edit')
|
|
@Post()
|
|
create(
|
|
@CurrentUser() user: User,
|
|
@Param('tripId') tripId: string,
|
|
@Param('dayId') dayId: string,
|
|
@Body() body: AssignmentCreateDto,
|
|
@Headers('x-socket-id') socketId?: string,
|
|
) {
|
|
if (!this.assignments.dayExists(dayId, tripId)) {
|
|
throw new HttpException({ error: 'Day not found' }, 404);
|
|
}
|
|
if (!this.assignments.placeExists(body.place_id, tripId)) {
|
|
throw new HttpException({ error: 'Place not found' }, 404);
|
|
}
|
|
const assignment = this.assignments.createAssignment(dayId, body.place_id, body.notes);
|
|
this.assignments.broadcast(tripId, 'assignment:created', { assignment }, socketId);
|
|
this.assignments.reconcile(tripId, socketId);
|
|
return { assignment };
|
|
}
|
|
|
|
@RequirePermission('day_edit')
|
|
@Put('reorder')
|
|
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)) {
|
|
throw new HttpException({ error: 'Day not found' }, 404);
|
|
}
|
|
this.assignments.reorderAssignments(dayId, body.orderedIds);
|
|
this.assignments.broadcast(tripId, 'assignment:reordered', { dayId: Number(dayId), orderedIds: body.orderedIds }, socketId);
|
|
return { success: true };
|
|
}
|
|
|
|
@RequirePermission('day_edit')
|
|
@Delete(':id')
|
|
remove(
|
|
@CurrentUser() user: User,
|
|
@Param('tripId') tripId: string,
|
|
@Param('dayId') dayId: string,
|
|
@Param('id') id: string,
|
|
@Headers('x-socket-id') socketId?: string,
|
|
) {
|
|
if (!this.assignments.assignmentExistsInDay(id, dayId, tripId)) {
|
|
throw new HttpException({ error: 'Assignment not found' }, 404);
|
|
}
|
|
this.assignments.deleteAssignment(id);
|
|
this.assignments.broadcast(tripId, 'assignment:deleted', { assignmentId: Number(id), dayId: Number(dayId) }, socketId);
|
|
this.assignments.reconcile(tripId, socketId);
|
|
return { success: true };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* /api/trips/:tripId/assignments/:id/* — per-assignment ops (move, time,
|
|
* participants), independent of the day path. Same parity rules as above.
|
|
*
|
|
* Same guard pair as the day controller, and for the same reason: TripAccessGuard
|
|
* is what resolves :tripId and what reads @RequirePermission. The per-handler
|
|
* getAssignmentForTrip calls answer a different question — whether the assignment
|
|
* sits on the trip in the URL, not whether the caller may be on that trip.
|
|
*/
|
|
@Controller('api/trips/:tripId/assignments')
|
|
@UseGuards(JwtAuthGuard, TripAccessGuard)
|
|
export class AssignmentOpsController {
|
|
constructor(private readonly assignments: AssignmentsService) {}
|
|
|
|
@RequirePermission('day_edit')
|
|
@Put(':id/move')
|
|
move(
|
|
@CurrentUser() user: User,
|
|
@Param('tripId') tripId: string,
|
|
@Param('id') id: string,
|
|
@Body() body: AssignmentMoveDto,
|
|
@Headers('x-socket-id') socketId?: string,
|
|
) {
|
|
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
|
|
throw new HttpException({ error: 'Assignment not found' }, 404);
|
|
}
|
|
if (!this.assignments.dayExists(String(body.new_day_id), tripId)) {
|
|
throw new HttpException({ error: 'Target day not found' }, 404);
|
|
}
|
|
const { assignment, oldDayId } = this.assignments.moveAssignment(id, body.new_day_id, body.order_index);
|
|
this.assignments.broadcast(tripId, 'assignment:moved', { assignment, oldDayId: Number(oldDayId), newDayId: Number(body.new_day_id) }, socketId);
|
|
this.assignments.reconcile(tripId, socketId);
|
|
return { assignment };
|
|
}
|
|
|
|
@Get(':id/participants')
|
|
participants(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string) {
|
|
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
|
|
throw new HttpException({ error: 'Assignment not found' }, 404);
|
|
}
|
|
return { participants: this.assignments.getParticipants(id) };
|
|
}
|
|
|
|
@RequirePermission('day_edit')
|
|
@Put(':id/time')
|
|
time(
|
|
@CurrentUser() user: User,
|
|
@Param('tripId') tripId: string,
|
|
@Param('id') id: string,
|
|
@Body() body: AssignmentTimeDto,
|
|
@Headers('x-socket-id') socketId?: string,
|
|
) {
|
|
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
|
|
throw new HttpException({ error: 'Assignment not found' }, 404);
|
|
}
|
|
const { assignment, reordered, vias } = this.assignments.updateTime(id, body.place_time, body.end_time);
|
|
this.assignments.broadcast(tripId, 'assignment:updated', { assignment }, socketId);
|
|
// The whole day when a start moved stops, or collaborators apply the one row
|
|
// they were sent to their old order and end up with a third one.
|
|
//
|
|
// Both to every socket, the writer's included, so the order and the vias pinned
|
|
// to it arrive together. The planner holds its vias in memory and would route the
|
|
// new anchors on the old order until its reload of the day came back, and a save
|
|
// replayed from the offline queue has no reload after it at all.
|
|
if (reordered) this.assignments.broadcast(tripId, 'assignment:reordered', reordered, undefined);
|
|
if (vias) this.assignments.broadcast(tripId, 'roadtripVia:changed', vias, undefined);
|
|
this.assignments.reconcile(tripId, socketId);
|
|
return { assignment };
|
|
}
|
|
|
|
@RequirePermission('day_edit')
|
|
@Put(':id/end-day')
|
|
endDay(
|
|
@Param('tripId') tripId: string,
|
|
@Param('id') id: string,
|
|
@Body() body: AssignmentEndDayDto,
|
|
@Headers('x-socket-id') socketId?: string,
|
|
) {
|
|
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
|
|
throw new HttpException({ error: 'Assignment not found' }, 404);
|
|
}
|
|
const assignment = this.assignments.setEndDay(id, body.end_day);
|
|
this.assignments.broadcast(tripId, 'assignment:updated', { assignment }, socketId);
|
|
return { assignment };
|
|
}
|
|
|
|
// #2163: the per-assignment note was write-once (create bodies, MCP, plugin
|
|
// RPC) with no edit path anywhere. Same guard shape and 404 body as its
|
|
// neighbours; no reconcile — the note doesn't touch the journey skeleton.
|
|
@RequirePermission('day_edit')
|
|
@Put(':id/notes')
|
|
notes(
|
|
@CurrentUser() user: User,
|
|
@Param('tripId') tripId: string,
|
|
@Param('id') id: string,
|
|
@Body() body: AssignmentNotesDto,
|
|
@Headers('x-socket-id') socketId?: string,
|
|
) {
|
|
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
|
|
throw new HttpException({ error: 'Assignment not found' }, 404);
|
|
}
|
|
const assignment = this.assignments.updateNotes(id, body.notes);
|
|
this.assignments.broadcast(tripId, 'assignment:updated', { assignment }, socketId);
|
|
return { assignment };
|
|
}
|
|
|
|
@RequirePermission('day_edit')
|
|
@Put(':id/transport')
|
|
transport(
|
|
@CurrentUser() user: User,
|
|
@Param('tripId') tripId: string,
|
|
@Param('id') id: string,
|
|
@Body() body: AssignmentTransportDto,
|
|
@Headers('x-socket-id') socketId?: string,
|
|
) {
|
|
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
|
|
throw new HttpException({ error: 'Assignment not found' }, 404);
|
|
}
|
|
const assignment = body.direction === 'incoming'
|
|
? this.assignments.setIncomingLegTransportMode(id, body.transport_mode ?? null)
|
|
: this.assignments.setLegTransportMode(id, body.transport_mode ?? null);
|
|
this.assignments.broadcast(tripId, 'assignment:updated', { assignment }, socketId);
|
|
return { assignment };
|
|
}
|
|
|
|
@RequirePermission('day_edit')
|
|
@Put(':id/participants')
|
|
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)) {
|
|
throw new HttpException({ error: 'Assignment not found' }, 404);
|
|
}
|
|
const participants = this.assignments.setParticipants(id, body.user_ids, tripId);
|
|
this.assignments.broadcast(tripId, 'assignment:participants', { assignmentId: Number(id), participants }, socketId);
|
|
return { participants };
|
|
}
|
|
}
|