mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +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;
|
||||
|
||||
@@ -83,6 +83,31 @@ function setupAssignmentFixtures(userId: number) {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Create assignment', () => {
|
||||
it('sets a day end for one visit, preserves its times, and clears it again', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { trip, day, place } = setupAssignmentFixtures(user.id);
|
||||
const created = await request(app).post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||
.set('Cookie', authCookie(user.id)).send({ place_id: place.id });
|
||||
const id = created.body.assignment.id;
|
||||
const url = `/api/trips/${trip.id}/assignments/${id}/end-day`;
|
||||
testDb.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run('07:00', id);
|
||||
const changed = await request(app).put(url).set('Cookie', authCookie(user.id)).send({ end_day: true }).expect(200);
|
||||
expect(changed.body.assignment).toMatchObject({ end_day: true, assignment_time: '07:00' });
|
||||
const listed = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(user.id)).expect(200);
|
||||
expect(listed.body.days[0].assignments[0].end_day).toBe(true);
|
||||
const repeatedDay = createDay(testDb, trip.id, { day_number: 2 });
|
||||
const repeated = await request(app).post(`/api/trips/${trip.id}/days/${repeatedDay.id}/assignments`)
|
||||
.set('Cookie', authCookie(user.id)).send({ place_id: place.id }).expect(201);
|
||||
expect(repeated.body.assignment.end_day).toBe(false);
|
||||
await request(app).put(url).set('Cookie', authCookie(user.id)).send({ end_day: 'true' }).expect(400);
|
||||
const foreign = createTrip(testDb, user.id);
|
||||
await request(app).put(`/api/trips/${foreign.id}/assignments/${id}/end-day`).set('Cookie', authCookie(user.id))
|
||||
.send({ end_day: true }).expect(404);
|
||||
const cleared = await request(app).put(url).set('Cookie', authCookie(user.id)).send({ end_day: false }).expect(200);
|
||||
expect(cleared.body.assignment).toMatchObject({ end_day: false, assignment_time: '07:00' });
|
||||
await request(app).put(url).send({ end_day: true }).expect(401);
|
||||
});
|
||||
|
||||
it('ASSIGN-001 — POST creates assignment linking place to day', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { trip, day, place } = setupAssignmentFixtures(user.id);
|
||||
|
||||
@@ -72,6 +72,23 @@ async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: assign_place_to_day', () => {
|
||||
it('sets and clears a visit day end through the shared service', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const visit = createDayAssignment(testDb, day.id, place.id);
|
||||
await withHarness(user.id, async h => {
|
||||
for (const end_day of [true, false]) {
|
||||
const changed = await h.client.callTool({ name: 'set_assignment_end_day', arguments: { tripId: trip.id, assignmentId: visit.id, end_day } });
|
||||
expect(parseToolResult(changed)).toMatchObject({ assignment: { end_day } });
|
||||
}
|
||||
const other = createTrip(testDb, user.id);
|
||||
const refused = await h.client.callTool({ name: 'set_assignment_end_day', arguments: { tripId: other.id, assignmentId: visit.id, end_day: true } });
|
||||
expect(refused.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('assigns a place to a day', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -67,6 +67,18 @@ describe('DayAssignmentsController (parity with the legacy day-assignments route
|
||||
});
|
||||
|
||||
describe('AssignmentOpsController (parity with the per-assignment op routes)', () => {
|
||||
it('scopes explicit day ends to the trip and broadcasts the saved visit', () => {
|
||||
const setEndDay = vi.fn().mockReturnValue({ id: 9, end_day: true });
|
||||
const s = svc({ getAssignmentForTrip: vi.fn().mockReturnValue({ id: 9 }), setEndDay });
|
||||
const controller = new AssignmentOpsController(s);
|
||||
expect(controller.endDay('5', '9', { end_day: true }, 'sock')).toEqual({ assignment: { id: 9, end_day: true } });
|
||||
expect(s.getAssignmentForTrip).toHaveBeenCalledWith('9', '5');
|
||||
expect(setEndDay).toHaveBeenCalledWith('9', true);
|
||||
expect(s.broadcast).toHaveBeenCalledWith('5', 'assignment:updated', { assignment: { id: 9, end_day: true } }, 'sock');
|
||||
vi.mocked(s.getAssignmentForTrip).mockReturnValue(undefined);
|
||||
expect(thrown(() => controller.endDay('5', '9', { end_day: false }))).toEqual({ status: 404, body: { error: 'Assignment not found' } });
|
||||
});
|
||||
|
||||
it('PUT /:id/move 404 assignment, 404 target day, else moves', () => {
|
||||
expect(thrown(() => new AssignmentOpsController(svc({ getAssignmentForTrip: vi.fn().mockReturnValue(undefined) } as Partial<AssignmentsService>)).move(user, '5', '9', { new_day_id: 4 }))).toEqual({ status: 404, body: { error: 'Assignment not found' } });
|
||||
expect(thrown(() => new AssignmentOpsController(svc({ getAssignmentForTrip: vi.fn().mockReturnValue({ day_id: 3 }), dayExists: vi.fn().mockReturnValue(false) } as Partial<AssignmentsService>)).move(user, '5', '9', { new_day_id: 4 }))).toEqual({ status: 404, body: { error: 'Target day not found' } });
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { DayBoundariesService } from '../../../src/nest/roadtrip/day-boundaries.service';
|
||||
import { DayBoundariesController } from '../../../src/nest/roadtrip/day-boundaries.controller';
|
||||
import { DayBoundariesMcp } from '../../../src/nest/roadtrip/day-boundaries.mcp';
|
||||
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import type { McpContext } from '../../../src/nest-mcp';
|
||||
|
||||
const open: Database.Database[] = [];
|
||||
afterEach(() => open.splice(0).forEach(db => db.close()));
|
||||
function setup() {
|
||||
const raw = new Database(':memory:');
|
||||
open.push(raw);
|
||||
raw.pragma('foreign_keys = ON');
|
||||
raw.exec(`CREATE TABLE days (id INTEGER PRIMARY KEY, trip_id INTEGER);
|
||||
CREATE TABLE day_assignments (id INTEGER PRIMARY KEY, day_id INTEGER REFERENCES days(id) ON DELETE CASCADE);
|
||||
CREATE TABLE roadtrip_day_boundaries (trip_id INTEGER, day_number INTEGER, from_assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE,
|
||||
to_assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE, fraction REAL, PRIMARY KEY(trip_id, day_number));
|
||||
INSERT INTO days VALUES (1, 10), (2, 20);
|
||||
INSERT INTO day_assignments VALUES (11, 1), (12, 1), (21, 2);`);
|
||||
const db = {
|
||||
get: (sql: string, ...params: unknown[]) => raw.prepare(sql).get(...params),
|
||||
all: (sql: string, ...params: unknown[]) => raw.prepare(sql).all(...params),
|
||||
run: (sql: string, ...params: unknown[]) => raw.prepare(sql).run(...params),
|
||||
canAccessTrip: vi.fn(() => true),
|
||||
};
|
||||
const service = new DayBoundariesService(db as unknown as DatabaseService);
|
||||
const realtime = { broadcast: vi.fn() };
|
||||
const auth = { isDemoUser: vi.fn(() => false) };
|
||||
const guards = { hasTripPermission: vi.fn(() => true) };
|
||||
const mcp = new DayBoundariesMcp(service, db as unknown as DatabaseService, auth as never, guards as never, realtime as never, {} as never);
|
||||
return { raw, service, controller: new DayBoundariesController(service, realtime as never), mcp, db, auth, guards, realtime };
|
||||
}
|
||||
const boundary = { day_number: 1, from_assignment_id: 11, to_assignment_id: 12, fraction: 0.4 };
|
||||
|
||||
it('replaces one day only, persists place snapping and cascades deleted visits', () => {
|
||||
const { service, raw } = setup();
|
||||
service.save(10, boundary);
|
||||
service.save(10, { ...boundary, day_number: 2, fraction: 0.8 });
|
||||
expect(service.save(10, { ...boundary, to_assignment_id: null })).toEqual([
|
||||
{ ...boundary, to_assignment_id: null, fraction: 1 }, { ...boundary, day_number: 2, fraction: 0.8 },
|
||||
]);
|
||||
expect(service.list(20)).toEqual([]);
|
||||
service.remove(20, 1);
|
||||
expect(service.list(10)).toHaveLength(2);
|
||||
raw.prepare('DELETE FROM day_assignments WHERE id = 12').run();
|
||||
expect(service.list(10)).toEqual([{ ...boundary, to_assignment_id: null, fraction: 1 }]);
|
||||
});
|
||||
|
||||
it('rejects either endpoint from a different trip without writing', () => {
|
||||
const { service } = setup();
|
||||
expect(() => service.save(10, { ...boundary, from_assignment_id: 21 })).toThrow(HttpException);
|
||||
expect(() => service.save(10, { ...boundary, to_assignment_id: 21 })).toThrow(HttpException);
|
||||
expect(service.list(10)).toEqual([]);
|
||||
});
|
||||
|
||||
it('REST changes broadcast the complete state and exclude the saving socket', () => {
|
||||
const { controller, realtime } = setup();
|
||||
controller.save('10', boundary, 'self');
|
||||
expect(realtime.broadcast).toHaveBeenCalledWith('10', 'roadtripBoundary:changed', { boundaries: [boundary] }, 'self');
|
||||
expect(controller.remove('10', 1)).toEqual({ boundaries: [] });
|
||||
});
|
||||
|
||||
it('MCP checks demo, trip access and edit permission before saving', async () => {
|
||||
const s = setup(), ctx = { userId: 1 } as McpContext;
|
||||
const request = { tripId: 10, dayNumber: 1, boundary };
|
||||
s.auth.isDemoUser.mockReturnValue(true);
|
||||
await s.mcp.save(request, ctx);
|
||||
s.auth.isDemoUser.mockReturnValue(false);
|
||||
s.db.canAccessTrip.mockReturnValue(false);
|
||||
await s.mcp.save(request, ctx);
|
||||
s.db.canAccessTrip.mockReturnValue(true);
|
||||
s.guards.hasTripPermission.mockReturnValue(false);
|
||||
await s.mcp.save(request, ctx);
|
||||
expect(s.service.list(10)).toEqual([]);
|
||||
s.guards.hasTripPermission.mockReturnValue(true);
|
||||
await s.mcp.save(request, ctx);
|
||||
expect(s.service.list(10)).toEqual([boundary]);
|
||||
await s.mcp.save({ ...request, boundary: null }, ctx);
|
||||
expect(s.service.list(10)).toEqual([]);
|
||||
});
|
||||
Reference in New Issue
Block a user