mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
feat(roadtrip): add daily scheduling and configurable day endings
This commit is contained in:
@@ -4534,6 +4534,22 @@ function runMigrations(db: Database.Database): void {
|
||||
db.exec('ALTER TABLE places ADD COLUMN fill_percent INTEGER');
|
||||
}
|
||||
},
|
||||
() => {
|
||||
const cols = db.prepare("SELECT name FROM pragma_table_info('day_assignments')").all() as Array<{ name: string }>;
|
||||
if (!cols.some(c => c.name === 'end_day')) {
|
||||
db.exec('ALTER TABLE day_assignments ADD COLUMN end_day INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
},
|
||||
() => {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS roadtrip_day_boundaries (
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
day_number INTEGER NOT NULL CHECK (day_number BETWEEN 1 AND 366),
|
||||
from_assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE,
|
||||
to_assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE,
|
||||
fraction REAL NOT NULL CHECK (fraction BETWEEN 0 AND 1),
|
||||
PRIMARY KEY (trip_id, day_number)
|
||||
)`);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
AssignmentReorderDto,
|
||||
AssignmentMoveDto,
|
||||
AssignmentTimeDto,
|
||||
AssignmentEndDayDto,
|
||||
AssignmentNotesDto,
|
||||
AssignmentTransportDto,
|
||||
AssignmentParticipantsDto,
|
||||
@@ -172,6 +173,22 @@ export class AssignmentOpsController {
|
||||
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.
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
assignmentReorderRequestSchema,
|
||||
assignmentMoveRequestSchema,
|
||||
assignmentTimeRequestSchema,
|
||||
assignmentEndDayRequestSchema,
|
||||
assignmentNotesRequestSchema,
|
||||
assignmentTransportRequestSchema,
|
||||
assignmentParticipantsRequestSchema,
|
||||
@@ -20,6 +21,7 @@ export class AssignmentCreateDto extends createZodDto(assignmentCreateRequestSch
|
||||
export class AssignmentReorderDto extends createZodDto(assignmentReorderRequestSchema) {}
|
||||
export class AssignmentMoveDto extends createZodDto(assignmentMoveRequestSchema) {}
|
||||
export class AssignmentTimeDto extends createZodDto(assignmentTimeRequestSchema) {}
|
||||
export class AssignmentEndDayDto extends createZodDto(assignmentEndDayRequestSchema) {}
|
||||
export class AssignmentNotesDto extends createZodDto(assignmentNotesRequestSchema) {}
|
||||
export class AssignmentTransportDto extends createZodDto(assignmentTransportRequestSchema) {}
|
||||
export class AssignmentParticipantsDto extends createZodDto(assignmentParticipantsRequestSchema) {}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '../../nest-mcp';
|
||||
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
||||
import { z } from 'zod';
|
||||
import { assignmentEndDayRequestSchema, type AssignmentEndDayRequest } from '@trek/shared';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import { AssignmentsService } from './assignments.service';
|
||||
@@ -56,6 +57,30 @@ export class AssignmentsMcp {
|
||||
return ok({ assignment });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'set_assignment_end_day',
|
||||
description: 'End the travel day after this visit and its stay. Applies only with daily travel times enabled. Pass false to follow the default again.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
assignmentId: z.number().int().positive(),
|
||||
...assignmentEndDayRequestSchema.shape,
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'places', mode: 'write' },
|
||||
})
|
||||
async setAssignmentEndDay(
|
||||
{ tripId, assignmentId, end_day }: AssignmentEndDayRequest & { tripId: number; assignmentId: number },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
|
||||
const assignment = this.assignments.setEndDay(assignmentId, end_day);
|
||||
this.guards.safeBroadcast(tripId, 'assignment:updated', { assignment });
|
||||
return ok({ assignment });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'unassign_place',
|
||||
description: 'Remove a place assignment from a day.',
|
||||
|
||||
@@ -225,6 +225,11 @@ export class AssignmentsService {
|
||||
return this.getAssignmentWithPlace(Number(id));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit the per-assignment note after creation (#2163) — until now the note
|
||||
* was write-once via the create paths (REST body, MCP tools, plugin RPC) and
|
||||
|
||||
@@ -18,6 +18,7 @@ export function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial<Tag>[]
|
||||
notes: a.notes,
|
||||
assignment_time: a.assignment_time ?? null,
|
||||
assignment_end_time: a.assignment_end_time ?? null,
|
||||
end_day: a.end_day === 1,
|
||||
leg_transport_mode: a.leg_transport_mode ?? null,
|
||||
incoming_leg_transport_mode: a.incoming_leg_transport_mode ?? null,
|
||||
participants: participants || [],
|
||||
|
||||
@@ -112,6 +112,7 @@ export class DaysService {
|
||||
id: a.id,
|
||||
day_id: a.day_id,
|
||||
order_index: a.order_index,
|
||||
end_day: a.end_day === 1,
|
||||
notes: a.notes,
|
||||
created_at: a.created_at,
|
||||
place: {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Body, Controller, Delete, Get, Headers, Param, ParseIntPipe, Put, UseGuards } from '@nestjs/common';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { roadtripDayBoundarySchema } from '@trek/shared';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { RequireAddon } from '../addons/require-addon.decorator';
|
||||
import { AddonGuard } from '../addons/addon.guard';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { DayBoundariesService } from './day-boundaries.service';
|
||||
|
||||
class DayBoundaryDto extends createZodDto(roadtripDayBoundarySchema) {}
|
||||
|
||||
@Controller('api/trips/:tripId/roadtrip/day-boundaries')
|
||||
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
|
||||
@RequireAddon(ADDON_IDS.ROADTRIP, 'Road trip')
|
||||
export class DayBoundariesController {
|
||||
constructor(private readonly boundaries: DayBoundariesService, private readonly realtime: RealtimeService) {}
|
||||
|
||||
@Get()
|
||||
list(@Param('tripId') tripId: string) {
|
||||
return { boundaries: this.boundaries.list(tripId) };
|
||||
}
|
||||
|
||||
@Put()
|
||||
@RequirePermission('day_edit')
|
||||
save(@Param('tripId') tripId: string, @Body() body: DayBoundaryDto, @Headers('x-socket-id') socketId?: string) {
|
||||
const boundaries = this.boundaries.save(tripId, body);
|
||||
this.realtime.broadcast(tripId, 'roadtripBoundary:changed', { boundaries }, socketId);
|
||||
return { boundaries };
|
||||
}
|
||||
|
||||
@Delete(':dayNumber')
|
||||
@RequirePermission('day_edit')
|
||||
remove(@Param('tripId') tripId: string, @Param('dayNumber', ParseIntPipe) dayNumber: number, @Headers('x-socket-id') socketId?: string) {
|
||||
const boundaries = this.boundaries.remove(tripId, dayNumber);
|
||||
this.realtime.broadcast(tripId, 'roadtripBoundary:changed', { boundaries }, socketId);
|
||||
return { boundaries };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { z } from 'zod';
|
||||
import { roadtripDayBoundarySchema, type RoadtripDayBoundary } from '@trek/shared';
|
||||
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, ok, type McpContext } from '../../nest-mcp';
|
||||
import { demoDenied, noAccess, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { DayBoundariesService } from './day-boundaries.service';
|
||||
|
||||
const when = addonGate(ADDON_IDS.ROADTRIP);
|
||||
|
||||
@McpController()
|
||||
export class DayBoundariesMcp {
|
||||
constructor(
|
||||
private readonly boundaries: DayBoundariesService,
|
||||
private readonly db: DatabaseService,
|
||||
private readonly auth: AuthService,
|
||||
private readonly guards: McpToolGuardsService,
|
||||
private readonly realtime: RealtimeService,
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
@Tool({
|
||||
name: 'list_day_boundaries', description: 'List manual road trip day endings. They apply only with daily travel times enabled.',
|
||||
inputSchema: { tripId: z.number().int().positive() },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when,
|
||||
})
|
||||
async list({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
return ok({ boundaries: this.boundaries.list(tripId) });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'set_day_boundary',
|
||||
description: 'Override a road trip day ending at a visit or a fraction along the driving leg between consecutive visits. Daily travel times must be enabled in the planner. Fixed visit times stay protected and conflicts are shown. Pass null to restore the automatic day ending.',
|
||||
inputSchema: { tripId: z.number().int().positive(), dayNumber: z.number().int().min(1).max(366), boundary: roadtripDayBoundarySchema.nullable() },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'trips', mode: 'write' }, when,
|
||||
})
|
||||
async save({ tripId, dayNumber, boundary }: { tripId: number; dayNumber: number; boundary: RoadtripDayBoundary | null }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const boundaries = boundary
|
||||
? this.boundaries.save(tripId, { ...boundary, day_number: dayNumber })
|
||||
: this.boundaries.remove(tripId, dayNumber);
|
||||
this.realtime.broadcast(String(tripId), 'roadtripBoundary:changed', { boundaries });
|
||||
return ok({ boundaries });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { HttpException, Injectable } from '@nestjs/common';
|
||||
import type { RoadtripDayBoundary } from '@trek/shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
|
||||
@Injectable()
|
||||
export class DayBoundariesService {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
|
||||
list(tripId: string | number): RoadtripDayBoundary[] {
|
||||
return this.db.all<RoadtripDayBoundary>(
|
||||
'SELECT day_number, from_assignment_id, to_assignment_id, fraction FROM roadtrip_day_boundaries WHERE trip_id = ? ORDER BY day_number', tripId,
|
||||
);
|
||||
}
|
||||
|
||||
save(tripId: string | number, boundary: RoadtripDayBoundary): RoadtripDayBoundary[] {
|
||||
const belongs = (id: number) => this.db.get(
|
||||
'SELECT a.id FROM day_assignments a JOIN days d ON d.id = a.day_id WHERE a.id = ? AND d.trip_id = ?', id, tripId,
|
||||
);
|
||||
if (!belongs(boundary.from_assignment_id) || boundary.to_assignment_id !== null && !belongs(boundary.to_assignment_id)) {
|
||||
throw new HttpException({ error: 'Stop not found' }, 404);
|
||||
}
|
||||
this.db.run(`INSERT INTO roadtrip_day_boundaries (trip_id, day_number, from_assignment_id, to_assignment_id, fraction)
|
||||
VALUES (?, ?, ?, ?, ?) ON CONFLICT (trip_id, day_number) DO UPDATE SET
|
||||
from_assignment_id = excluded.from_assignment_id, to_assignment_id = excluded.to_assignment_id, fraction = excluded.fraction`,
|
||||
tripId, boundary.day_number, boundary.from_assignment_id, boundary.to_assignment_id, boundary.to_assignment_id === null ? 1 : boundary.fraction);
|
||||
return this.list(tripId);
|
||||
}
|
||||
|
||||
remove(tripId: string | number, dayNumber: number): RoadtripDayBoundary[] {
|
||||
this.db.run('DELETE FROM roadtrip_day_boundaries WHERE trip_id = ? AND day_number = ?', tripId, dayNumber);
|
||||
return this.list(tripId);
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,17 @@ import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { RealtimeModule } from '../realtime/realtime.module';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { DayBoundariesController } from './day-boundaries.controller';
|
||||
import { DayBoundariesService } from './day-boundaries.service';
|
||||
import { DayBoundariesMcp } from './day-boundaries.mcp';
|
||||
|
||||
/** Road trip domain (#1797): the points a drive is routed through. Registered in AppModule. */
|
||||
@Module({
|
||||
// McpShared brings the tool guards, Auth the demo check, Permissions the trip guard,
|
||||
// Addons the enabled-check the MCP tools gate on (the controller has @RequireAddon).
|
||||
imports: [McpSharedModule, PermissionsModule, AuthModule, AddonsModule, RealtimeModule],
|
||||
controllers: [RoadtripController],
|
||||
providers: [RoadtripService, RoadtripMcp],
|
||||
controllers: [RoadtripController, DayBoundariesController],
|
||||
providers: [RoadtripService, RoadtripMcp, DayBoundariesService, DayBoundariesMcp],
|
||||
exports: [RoadtripService],
|
||||
})
|
||||
export class RoadtripModule {}
|
||||
|
||||
@@ -610,8 +610,8 @@ export class TripsService {
|
||||
`).all(sourceTripId) as any[];
|
||||
const assignmentMap = new Map<number, number | bigint>();
|
||||
const insertAssignment = this.db.prepare(`
|
||||
INSERT INTO day_assignments (day_id, place_id, order_index, notes, reservation_status, reservation_notes, reservation_datetime, assignment_time, assignment_end_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO day_assignments (day_id, place_id, order_index, notes, reservation_status, reservation_notes, reservation_datetime, assignment_time, assignment_end_time, end_day)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const a of oldAssignments) {
|
||||
const newDayId = dayMap.get(a.day_id);
|
||||
@@ -619,11 +619,21 @@ export class TripsService {
|
||||
if (newDayId && newPlaceId) {
|
||||
const r = insertAssignment.run(newDayId, newPlaceId, a.order_index, a.notes,
|
||||
a.reservation_status, a.reservation_notes, a.reservation_datetime,
|
||||
a.assignment_time, a.assignment_end_time);
|
||||
a.assignment_time, a.assignment_end_time, a.end_day ?? 0);
|
||||
assignmentMap.set(a.id, r.lastInsertRowid);
|
||||
}
|
||||
}
|
||||
|
||||
const oldBoundaries = this.db.prepare('SELECT * FROM roadtrip_day_boundaries WHERE trip_id = ?').all(sourceTripId) as {
|
||||
day_number: number; from_assignment_id: number; to_assignment_id: number | null; fraction: number;
|
||||
}[];
|
||||
const insertBoundary = this.db.prepare('INSERT INTO roadtrip_day_boundaries (trip_id, day_number, from_assignment_id, to_assignment_id, fraction) VALUES (?, ?, ?, ?, ?)');
|
||||
for (const boundary of oldBoundaries) {
|
||||
const from = assignmentMap.get(boundary.from_assignment_id);
|
||||
const to = boundary.to_assignment_id === null ? null : assignmentMap.get(boundary.to_assignment_id);
|
||||
if (from && to !== undefined) insertBoundary.run(newTripId, boundary.day_number, from, to, boundary.fraction);
|
||||
}
|
||||
|
||||
const oldParticipants = this.db.prepare(`
|
||||
SELECT ap.* FROM assignment_participants ap
|
||||
JOIN day_assignments da ON da.id = ap.assignment_id
|
||||
|
||||
@@ -114,6 +114,7 @@ export interface DayAssignment {
|
||||
reservation_datetime?: string | null;
|
||||
assignment_time?: string | null;
|
||||
assignment_end_time?: string | null;
|
||||
end_day?: number;
|
||||
leg_transport_mode?: string | null;
|
||||
incoming_leg_transport_mode?: string | null;
|
||||
created_at?: string;
|
||||
|
||||
Reference in New Issue
Block a user