mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
chore: move assignments backend to proper DI and harden its writes
This commit is contained in:
+41
@@ -0,0 +1,41 @@
|
||||
Recommended order
|
||||
|
||||
Wave 1 — pilots (establish the template on easy wins)
|
||||
|
||||
1. tagService (26 lines, zero deps, only consumers: TagsModule + 1 MCP tool) — the ideal first migration. Small enough that the whole pattern fits in one PR: move the SQL into TagsService with injected DatabaseService, retire the
|
||||
legacy file, convert its tests to overrideProvider(DATABASE_CONNECTION).
|
||||
2. categoryService (31 lines, same shape) — immediate repeat to confirm the template.
|
||||
3. todoService (120 lines, classic CRUD with transactions + broadcast) — first "full-shaped" domain: prepared statements, db.transaction, WebSocket broadcast, 3 MCP tools. After this one, the recipe is settled for everything else.
|
||||
|
||||
The pilots deliberately include MCP consumers, because they force the one open design decision early: MCP tools run outside the Nest container, so either the migrated module keeps thin function exports delegating to a shared instance,
|
||||
or the MCP layer gets a handle to the Nest app (app.get(TodoService)). Better to settle that on a 26-line service than on authService.
|
||||
|
||||
Wave 2 — cross-cutting leverage (parallel to wave 3, high payoff)
|
||||
|
||||
4. permissions (149 lines, zero deps, 17 Nest consumers) → an injectable PermissionsService. Every module wrapper already calls checkPermission; this converts the single most-imported seam in one move.
|
||||
5. auditLog (171 lines, 13 Nest consumers) → AuditService.
|
||||
6. tripAccess (9 lines) — don't migrate, delete: it's a wrapper around canAccessTrip, which DatabaseService now exposes. Absorb its 11 call sites opportunistically.
|
||||
|
||||
Wave 3 — domain services with existing modules, low fan-in
|
||||
|
||||
7. dayNoteService (done 2026-07), tripInviteService (done 2026-07), assignmentService (done 2026-07), shareService, settingsService, fileService, collabService, packingService (done 2026-07) — each maps 1:1 to a Nest module that's already a thin delegate; none is imported by more than one
|
||||
other legacy service.
|
||||
|
||||
Wave 4 — the coupled cluster (order matters here)
|
||||
|
||||
8. dayService → budgetService → reservationService in that order: reservations depend on both (budget-item linking, day resolution), so migrating the dependencies first lets ReservationsService inject the finished versions instead of
|
||||
importing legacy functions.
|
||||
9. Then placeService (7 internal deps, mostly on things migrated by now) and tripService (10 deps — the biggest hub; last in this wave, since nearly everything it needs will already be injectable).
|
||||
|
||||
Wave 5 — the heavyweights, last
|
||||
|
||||
10. adminService (14 Nest + 11 MCP consumers), journeyService/atlasService/vacayService/collectionsService/mapsService (big but self-contained), and finally the auth stack (authService at 1494 lines with 18 MCP consumers, plus
|
||||
oauthService/oidcService/passkeyService) — highest risk, most consumers, security-sensitive; do it when the pattern is thoroughly proven. backupService also stays late: it owns closeDb/reinitialize lifecycle, which is deliberately
|
||||
outside the DI wrapper.
|
||||
|
||||
Two special cases to keep in mind: airportService is lazily required by db/database.ts at boot (the flight-endpoint backfill) — whenever it migrates, that call should move into Nest's bootstrap instead, or you recreate the circular
|
||||
import; and notifications/notificationService are imported by 6+ other services, so like the wave-4 cluster they should migrate before their dependents or keep function shims in the interim.
|
||||
|
||||
The through-line: each finished migration lets that domain's tests drop vi.mock('db/database') for overrideProvider(DATABASE_CONNECTION), so the mock seam shrinks wave by wave and disappears with the auth stack. Want me to start on
|
||||
tagService as the pilot?
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { registerAssignmentTools } from './tools/assignments';
|
||||
import { registerJourneyTools } from './tools/journey';
|
||||
import { registerReservationTools } from './tools/reservations';
|
||||
import { registerMapsWeatherTools } from './tools/mapsWeather';
|
||||
@@ -33,7 +32,8 @@ export function registerTools(server: McpServer, userId: number, scopes: string[
|
||||
|
||||
registerDayTools(server, userId, scopes);
|
||||
|
||||
registerAssignmentTools(server, userId, scopes);
|
||||
// The assignment tools moved to the DI-discovered src/nest/assignments/
|
||||
// assignments.mcp.ts (@McpController, attached via the nest-mcp registry below).
|
||||
|
||||
registerMapsWeatherTools(server, userId, scopes);
|
||||
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
dayExists, placeExists, createAssignment, assignmentExistsInDay,
|
||||
deleteAssignment, reorderAssignments, getAssignmentForTrip, updateTime,
|
||||
moveAssignment,
|
||||
getParticipants as getAssignmentParticipants,
|
||||
setParticipants as setAssignmentParticipants,
|
||||
} from '../../services/assignmentService';
|
||||
import { getDay } from '../../services/dayService';
|
||||
import { reconcileTripSkeletons } from '../../services/journeyService';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
||||
} from './_shared';
|
||||
import { canRead, canWrite } from '../scopes';
|
||||
|
||||
export function registerAssignmentTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
const R = canRead(scopes, 'places');
|
||||
const W = canWrite(scopes, 'places');
|
||||
|
||||
// --- ASSIGNMENTS ---
|
||||
|
||||
if (W) server.registerTool(
|
||||
'assign_place_to_day',
|
||||
{
|
||||
description: 'Assign a place to a specific day in a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
placeId: z.number().int().positive(),
|
||||
notes: z.string().max(500).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, dayId, placeId, notes }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
if (!placeExists(placeId, tripId)) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
const assignment = createAssignment(dayId, placeId, notes || null);
|
||||
safeBroadcast(tripId, 'assignment:created', { assignment });
|
||||
try { reconcileTripSkeletons(tripId); } catch { /* non-fatal */ }
|
||||
return ok({ assignment });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'unassign_place',
|
||||
{
|
||||
description: 'Remove a place assignment from a day.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
assignmentId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, dayId, assignmentId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
if (!assignmentExistsInDay(assignmentId, dayId, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
|
||||
deleteAssignment(assignmentId);
|
||||
safeBroadcast(tripId, 'assignment:deleted', { assignmentId, dayId });
|
||||
try { reconcileTripSkeletons(tripId); } catch { /* non-fatal */ }
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'update_assignment_time',
|
||||
{
|
||||
description: 'Set the start and/or end time for a place assignment on a day (e.g. "09:00", "11:30"). Pass null to clear a time.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
assignmentId: z.number().int().positive(),
|
||||
place_time: z.string().max(50).nullable().optional().describe('Start time (e.g. "09:00"), or null to clear'),
|
||||
end_time: z.string().max(50).nullable().optional().describe('End time (e.g. "11:00"), or null to clear'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, assignmentId, place_time, end_time }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
const existing = getAssignmentForTrip(assignmentId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
|
||||
const assignment = updateTime(
|
||||
assignmentId,
|
||||
place_time !== undefined ? place_time : (existing as any).assignment_time,
|
||||
end_time !== undefined ? end_time : (existing as any).assignment_end_time
|
||||
);
|
||||
safeBroadcast(tripId, 'assignment:updated', { assignment });
|
||||
try { reconcileTripSkeletons(tripId); } catch { /* non-fatal */ }
|
||||
return ok({ assignment });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'move_assignment',
|
||||
{
|
||||
description: 'Move a place assignment to a different day.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
assignmentId: z.number().int().positive(),
|
||||
newDayId: z.number().int().positive(),
|
||||
oldDayId: z.number().int().positive(),
|
||||
orderIndex: z.number().int().min(0).optional().default(0),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, assignmentId, newDayId, oldDayId, orderIndex }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
if (!getAssignmentForTrip(assignmentId, tripId)) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
|
||||
if (!getDay(newDayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
const result = moveAssignment(assignmentId, newDayId, orderIndex ?? 0, oldDayId);
|
||||
safeBroadcast(tripId, 'assignment:moved', { assignment: result.assignment, oldDayId: result.oldDayId });
|
||||
try { reconcileTripSkeletons(tripId); } catch { /* non-fatal */ }
|
||||
return ok({ assignment: result.assignment });
|
||||
}
|
||||
);
|
||||
|
||||
if (R) server.registerTool(
|
||||
'get_assignment_participants',
|
||||
{
|
||||
description: 'Get the list of users participating in a specific place assignment.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
assignmentId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ tripId, assignmentId }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!getAssignmentForTrip(assignmentId, tripId)) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
|
||||
const participants = getAssignmentParticipants(assignmentId);
|
||||
return ok({ participants });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'set_assignment_participants',
|
||||
{
|
||||
description: 'Set the participants for a place assignment (replaces current list).',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
assignmentId: z.number().int().positive(),
|
||||
userIds: z.array(z.number().int().positive()).describe('User IDs to set as participants; empty array clears all'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, assignmentId, userIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
if (!getAssignmentForTrip(assignmentId, tripId)) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
|
||||
const participants = setAssignmentParticipants(assignmentId, userIds);
|
||||
safeBroadcast(tripId, 'assignment:participants', { assignmentId, participants });
|
||||
return ok({ participants });
|
||||
}
|
||||
);
|
||||
|
||||
// --- REORDER ---
|
||||
|
||||
if (W) server.registerTool(
|
||||
'reorder_day_assignments',
|
||||
{
|
||||
description: 'Reorder places within a day by providing the assignment IDs in the desired order.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
assignmentIds: z.array(z.number().int().positive()).min(1).max(200).describe('Assignment IDs in desired display order'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, dayId, assignmentIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
if (!getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
reorderAssignments(dayId, assignmentIds);
|
||||
safeBroadcast(tripId, 'assignment:reordered', { dayId, assignmentIds });
|
||||
return ok({ success: true, dayId, order: assignmentIds });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
import { canAccessTrip, db } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import { deletePlacesMany, updatePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace, ratePlace } from '../../services/placeService';
|
||||
import { createAssignment, dayExists } from '../../services/assignmentService';
|
||||
import { createAssignment, dayExists } from '../../nest/assignments/assignments.bridge';
|
||||
import { onPlaceDeleted, reconcileTripSkeletons } from '../../services/journeyService';
|
||||
import { searchPlaces } from '../../services/mapsService';
|
||||
import {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '../../services/reservationService';
|
||||
import { linkBudgetItemToReservation } from '../../services/budgetService';
|
||||
import { getDay } from '../../services/dayService';
|
||||
import { placeExists, getAssignmentForTrip } from '../../services/assignmentService';
|
||||
import { placeExists, getAssignmentForTrip } from '../../nest/assignments/assignments.bridge';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
|
||||
@@ -27,7 +27,8 @@ mount to Nest and leaves the sibling trip routes (days, places, ...) on Express.
|
||||
categories, tags, notifications, atlas.
|
||||
- **Phase 2 (trip sub-domains):** vacay (addon), packing, todo.
|
||||
- **DI-native services (legacy `src/services/*` deleted):** tags, categories,
|
||||
todo, packing, day-notes — see the migration recipe below.
|
||||
todo, packing, day-notes, trip-invite, assignments — see the migration recipe
|
||||
below.
|
||||
|
||||
## Cross-cutting Foundation pieces
|
||||
|
||||
@@ -106,8 +107,12 @@ migration needing **no bridge at all**: after its three tools + resource moved
|
||||
to `day-notes.mcp.ts` and the plugin host injection, nothing outside the
|
||||
container consumed it); trip-invite followed (the smallest port: no MCP
|
||||
surface, no plugin-host import and no bridge — the SQL folded straight into
|
||||
`trip-invite.service.ts`). Repeat these steps per
|
||||
service (next up: assignmentService). This is a **pure relocation** — byte-identical
|
||||
`trip-invite.service.ts`); assignments followed (a 7-tool `assignments.mcp.ts`,
|
||||
the plugin-host swap, and a bridge kept only for the two legacy registrars —
|
||||
places and reservations — that borrow its existence checks; the batch loaders
|
||||
stay in `services/queryHelpers.ts`, shared with the unmigrated day/share/place
|
||||
services). Repeat these steps per
|
||||
service (next up: shareService). This is a **pure relocation** — byte-identical
|
||||
SQL, statuses, bodies, and error strings. The plugin RPC host is **no longer a
|
||||
bridge consumer**: since Option A of `src/nest/plugins/DI-MIGRATION.md` it
|
||||
injects domain services via `PluginHostDepsFactory`, so a migrated domain adds
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { db } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { AssignmentsService } from './assignments.service';
|
||||
|
||||
/**
|
||||
* Non-Nest entry point for the assignments domain — for code running OUTSIDE
|
||||
* the Nest container (the legacy places and reservations MCP registrars in
|
||||
* src/mcp/tools/places.ts and src/mcp/tools/reservations.ts; the assignment
|
||||
* MCP tools moved to the DI-discovered assignments.mcp.ts, and the plugin RPC
|
||||
* host injects AssignmentsService via PluginHostDepsFactory). Exports only the
|
||||
* legacy services/assignmentService names still consumed outside the
|
||||
* container, 1:1, so repointing a consumer is an import-path-only diff. Inside
|
||||
* the container, inject AssignmentsService instead. Delete this file when
|
||||
* those legacy consumers migrate.
|
||||
*
|
||||
* Unlike todo/packing, the instance is built lazily on first call, NOT at
|
||||
* module scope: this bridge sits on the mcp tools fan-out while its service
|
||||
* has a deep legacy-services import closure, so a module-level
|
||||
* `new AssignmentsService` would crash with an uninitialized class binding if
|
||||
* a services→mcp import edge ever forms a cycle through here (one existed via
|
||||
* adminService importing the ../mcp barrel until that was rerouted to
|
||||
* mcp/sessionManager). By the time any bridge function is called, all modules
|
||||
* have finished evaluating. (`db` is the reinitialize-proof Proxy onto the
|
||||
* shared better-sqlite3 singleton.)
|
||||
*/
|
||||
let instance: AssignmentsService | undefined;
|
||||
function assignments(): AssignmentsService {
|
||||
return (instance ??= new AssignmentsService(new DatabaseService(db)));
|
||||
}
|
||||
|
||||
export function createAssignment(dayId: string | number, placeId: string | number, notes: string | null) {
|
||||
return assignments().createAssignment(dayId, placeId, notes);
|
||||
}
|
||||
|
||||
export function dayExists(dayId: string | number, tripId: string | number) {
|
||||
return assignments().dayExists(dayId, tripId);
|
||||
}
|
||||
|
||||
export function placeExists(placeId: string | number, tripId: string | number) {
|
||||
return assignments().placeExists(placeId, tripId);
|
||||
}
|
||||
|
||||
export function getAssignmentForTrip(id: string | number, tripId: string | number) {
|
||||
return assignments().getAssignmentForTrip(id, tripId);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
McpController, Tool, type McpContext,
|
||||
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, errorResult, ok,
|
||||
} from '@trek/nest-mcp';
|
||||
import { z } from 'zod';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import { getDay } from '../../services/dayService';
|
||||
import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import { AssignmentsService } from './assignments.service';
|
||||
|
||||
/**
|
||||
* Assignment MCP surface — ported 1:1 from the legacy registrar in
|
||||
* src/mcp/tools/assignments.ts (identical names, descriptions, schemas,
|
||||
* annotations, error/payload shapes and broadcasts). The legacy `if (R)` /
|
||||
* `if (W)` registration gates on the 'places' scope group map to the
|
||||
* declarative access markers (resolved by trekMcpAccessPolicy); there is no
|
||||
* addon gate, so no `when`. The target-day check on move keeps its legacy
|
||||
* dayService.getDay call path.
|
||||
*/
|
||||
@McpController()
|
||||
export class AssignmentsMcp {
|
||||
constructor(private readonly assignments: AssignmentsService) {}
|
||||
|
||||
@Tool({
|
||||
name: 'assign_place_to_day',
|
||||
description: 'Assign a place to a specific day in a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
placeId: z.number().int().positive(),
|
||||
notes: z.string().max(500).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
access: { group: 'places', mode: 'write' },
|
||||
})
|
||||
async assignPlaceToDay(
|
||||
{ tripId, dayId, placeId, notes }: { tripId: number; dayId: number; placeId: number; notes?: string },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!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.');
|
||||
const assignment = this.assignments.createAssignment(dayId, placeId, notes || null);
|
||||
safeBroadcast(tripId, 'assignment:created', { assignment });
|
||||
this.assignments.reconcile(tripId);
|
||||
return ok({ assignment });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'unassign_place',
|
||||
description: 'Remove a place assignment from a day.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
assignmentId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
access: { group: 'places', mode: 'write' },
|
||||
})
|
||||
async unassignPlace(
|
||||
{ tripId, dayId, assignmentId }: { tripId: number; dayId: number; assignmentId: number },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!this.assignments.assignmentExistsInDay(assignmentId, dayId, tripId))
|
||||
return errorResult('Assignment not found.');
|
||||
this.assignments.deleteAssignment(assignmentId);
|
||||
safeBroadcast(tripId, 'assignment:deleted', { assignmentId, dayId });
|
||||
this.assignments.reconcile(tripId);
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'update_assignment_time',
|
||||
description: 'Set the start and/or end time for a place assignment on a day (e.g. "09:00", "11:30"). Pass null to clear a time.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
assignmentId: z.number().int().positive(),
|
||||
place_time: z.string().max(50).nullable().optional().describe('Start time (e.g. "09:00"), or null to clear'),
|
||||
end_time: z.string().max(50).nullable().optional().describe('End time (e.g. "11:00"), or null to clear'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'places', mode: 'write' },
|
||||
})
|
||||
async updateAssignmentTime(
|
||||
{ tripId, assignmentId, place_time, end_time }: {
|
||||
tripId: number; assignmentId: number; place_time?: string | null; end_time?: string | null;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const existing = this.assignments.getAssignmentForTrip(assignmentId, tripId);
|
||||
if (!existing) return errorResult('Assignment not found.');
|
||||
const assignment = this.assignments.updateTime(
|
||||
assignmentId,
|
||||
place_time !== undefined ? place_time : existing.assignment_time,
|
||||
end_time !== undefined ? end_time : existing.assignment_end_time
|
||||
);
|
||||
safeBroadcast(tripId, 'assignment:updated', { assignment });
|
||||
this.assignments.reconcile(tripId);
|
||||
return ok({ assignment });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'move_assignment',
|
||||
description: 'Move a place assignment to a different day.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
assignmentId: z.number().int().positive(),
|
||||
newDayId: z.number().int().positive(),
|
||||
oldDayId: z.number().int().positive().optional().describe('Deprecated and ignored — the server derives the source day from the assignment'),
|
||||
orderIndex: z.number().int().min(0).optional().default(0),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'places', mode: 'write' },
|
||||
})
|
||||
async moveAssignment(
|
||||
{ tripId, assignmentId, newDayId, orderIndex }: {
|
||||
tripId: number; assignmentId: number; newDayId: number; oldDayId?: number; orderIndex?: number;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
|
||||
if (!getDay(newDayId, tripId)) return errorResult('Day not found.');
|
||||
const result = this.assignments.moveAssignment(assignmentId, newDayId, orderIndex ?? 0);
|
||||
safeBroadcast(tripId, 'assignment:moved', { assignment: result.assignment, oldDayId: result.oldDayId });
|
||||
this.assignments.reconcile(tripId);
|
||||
return ok({ assignment: result.assignment });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_assignment_participants',
|
||||
description: 'Get the list of users participating in a specific place assignment.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
assignmentId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'places', mode: 'read' },
|
||||
})
|
||||
async getAssignmentParticipants(
|
||||
{ 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);
|
||||
return ok({ participants });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'set_assignment_participants',
|
||||
description: 'Set the participants for a place assignment (replaces current list).',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
assignmentId: z.number().int().positive(),
|
||||
userIds: z.array(z.number().int().positive()).describe('User IDs to set as participants; empty array clears all'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'places', mode: 'write' },
|
||||
})
|
||||
async setAssignmentParticipants(
|
||||
{ tripId, assignmentId, userIds }: { tripId: number; assignmentId: number; userIds: number[] },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!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);
|
||||
safeBroadcast(tripId, 'assignment:participants', { assignmentId, participants });
|
||||
return ok({ participants });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'reorder_day_assignments',
|
||||
description: 'Reorder places within a day by providing the assignment IDs in the desired order.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
assignmentIds: z.array(z.number().int().positive()).min(1).max(200).describe('Assignment IDs in desired display order'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'places', mode: 'write' },
|
||||
})
|
||||
async reorderDayAssignments(
|
||||
{ tripId, dayId, assignmentIds }: { tripId: number; dayId: number; assignmentIds: number[] },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!getDay(dayId, tripId)) return errorResult('Day not found.');
|
||||
this.assignments.reorderAssignments(dayId, assignmentIds);
|
||||
safeBroadcast(tripId, 'assignment:reordered', { dayId, assignmentIds });
|
||||
return ok({ success: true, dayId, order: assignmentIds });
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DayAssignmentsController, AssignmentOpsController } from './assignments.controller';
|
||||
import { AssignmentsService } from './assignments.service';
|
||||
import { AssignmentsMcp } from './assignments.mcp';
|
||||
|
||||
/**
|
||||
* Assignments domain (S7 — Phase 2 trip sub-domain). The day-assignments mount
|
||||
* sits under the /api/trips/:tripId/days prefix (S6); the per-assignment ops use
|
||||
* the /api/trips/:tripId/assignments prefix.
|
||||
* the /api/trips/:tripId/assignments prefix. AssignmentsService is exported for
|
||||
* the plugin RPC host (PluginHostDepsFactory injects it).
|
||||
*/
|
||||
@Module({
|
||||
controllers: [DayAssignmentsController, AssignmentOpsController],
|
||||
providers: [AssignmentsService],
|
||||
providers: [AssignmentsService, AssignmentsMcp],
|
||||
exports: [AssignmentsService],
|
||||
})
|
||||
export class AssignmentsModule {}
|
||||
|
||||
@@ -2,23 +2,30 @@ import { Injectable } from '@nestjs/common';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
import type { User } from '../../types';
|
||||
import * as svc from '../../services/assignmentService';
|
||||
import { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace } from '../../services/queryHelpers';
|
||||
import type { AssignmentRow, DayAssignment, User } from '../../types';
|
||||
import { reconcileTripSkeletons } from '../../services/journeyService';
|
||||
|
||||
type Trip = { user_id: number };
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing assignment service. Trip access mirrors
|
||||
* the requireTripAccess middleware (canAccessTrip); mutations use 'day_edit'.
|
||||
* The SQL, the move/reorder logic and the journey skeleton reconcile reuse the
|
||||
* legacy code unchanged.
|
||||
* Assignments domain service — owns the day-assignment SQL (relocated from the
|
||||
* legacy services/assignmentService.ts, then hardened: every multi-statement
|
||||
* write runs in a transaction, moveAssignment derives the source day from the
|
||||
* row instead of trusting the caller, empty-string times clear like null, and
|
||||
* single-assignment reads embed the same compact tag projection the list path
|
||||
* uses). Trip access mirrors the requireTripAccess middleware (canAccessTrip);
|
||||
* mutations use 'day_edit'. The batch tag/participant loaders stay in
|
||||
* services/queryHelpers (shared with the unmigrated day/share/place services).
|
||||
* Non-Nest consumers (the legacy places/reservations MCP registrars) go
|
||||
* through assignments.bridge.ts; the plugin RPC host and the assignments MCP
|
||||
* tools inject this class.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AssignmentsService {
|
||||
constructor(private readonly dbs: DatabaseService) {}
|
||||
|
||||
verifyTripAccess(tripId: string, userId: number) {
|
||||
verifyTripAccess(tripId: string | number, userId: number) {
|
||||
return this.dbs.canAccessTrip(Number(tripId), userId) as Trip | null | undefined;
|
||||
}
|
||||
|
||||
@@ -30,64 +37,247 @@ export class AssignmentsService {
|
||||
broadcast(tripId, event, payload, socketId);
|
||||
}
|
||||
|
||||
dayExists(dayId: string, tripId: string) {
|
||||
return svc.dayExists(dayId, tripId);
|
||||
}
|
||||
|
||||
placeExists(placeId: unknown, tripId: string) {
|
||||
return svc.placeExists(placeId as never, tripId);
|
||||
}
|
||||
|
||||
listDayAssignments(dayId: string) {
|
||||
return svc.listDayAssignments(dayId);
|
||||
}
|
||||
|
||||
createAssignment(dayId: string, placeId: unknown, notes?: string | null) {
|
||||
return svc.createAssignment(dayId, placeId as never, notes as never);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-mirror the trip's day-assigned places onto every linked journey's skeleton
|
||||
* suggestions. Called after any assignment mutation (create/delete/move/time) so
|
||||
* the journey stays in sync. Non-fatal, like the route's try/catch.
|
||||
*/
|
||||
reconcile(tripId: string, socketId?: string): void {
|
||||
reconcile(tripId: string | number, socketId?: string): void {
|
||||
try { reconcileTripSkeletons(Number(tripId), socketId); } catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
assignmentExistsInDay(id: string, dayId: string, tripId: string) {
|
||||
return svc.assignmentExistsInDay(id, dayId, tripId);
|
||||
private getAssignmentWithPlace(assignmentId: number | bigint) {
|
||||
const a = this.dbs.get<AssignmentRow>(`
|
||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||
COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||
p.duration_minutes, p.notes as place_notes,
|
||||
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE da.id = ?
|
||||
`, assignmentId);
|
||||
|
||||
if (!a) return null;
|
||||
|
||||
// Same compact tag projection as listDayAssignments, so an assignment has
|
||||
// one wire shape regardless of which read path produced it.
|
||||
const tags = loadTagsByPlaceIds([a.place_id], { compact: true })[a.place_id] || [];
|
||||
|
||||
const participants = this.dbs.all(`
|
||||
SELECT ap.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM assignment_participants ap
|
||||
JOIN users u ON ap.user_id = u.id
|
||||
WHERE ap.assignment_id = ?
|
||||
`, a.id);
|
||||
|
||||
return {
|
||||
id: a.id,
|
||||
day_id: a.day_id,
|
||||
place_id: a.place_id,
|
||||
order_index: a.order_index,
|
||||
notes: a.notes,
|
||||
assignment_time: a.assignment_time ?? null,
|
||||
assignment_end_time: a.assignment_end_time ?? null,
|
||||
leg_transport_mode: a.leg_transport_mode ?? null,
|
||||
participants,
|
||||
created_at: a.created_at,
|
||||
place: {
|
||||
id: a.place_id,
|
||||
name: a.place_name,
|
||||
description: a.place_description,
|
||||
lat: a.lat,
|
||||
lng: a.lng,
|
||||
address: a.address,
|
||||
category_id: a.category_id,
|
||||
price: a.price,
|
||||
currency: a.place_currency,
|
||||
place_time: a.place_time,
|
||||
end_time: a.end_time,
|
||||
duration_minutes: a.duration_minutes,
|
||||
notes: a.place_notes,
|
||||
image_url: a.image_url,
|
||||
transport_mode: a.transport_mode,
|
||||
google_place_id: a.google_place_id,
|
||||
google_ftid: a.google_ftid,
|
||||
osm_id: a.osm_id,
|
||||
website: a.website,
|
||||
phone: a.phone,
|
||||
category: a.category_id ? {
|
||||
id: a.category_id,
|
||||
name: a.category_name,
|
||||
color: a.category_color,
|
||||
icon: a.category_icon,
|
||||
} : null,
|
||||
tags,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
deleteAssignment(id: string): void {
|
||||
svc.deleteAssignment(id);
|
||||
listDayAssignments(dayId: string | number) {
|
||||
const assignments = this.dbs.all<AssignmentRow>(`
|
||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||
COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||
p.duration_minutes, p.notes as place_notes,
|
||||
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE da.day_id = ?
|
||||
ORDER BY da.order_index ASC, da.created_at ASC
|
||||
`, dayId);
|
||||
|
||||
const placeIds = [...new Set(assignments.map(a => a.place_id))];
|
||||
const tagsByPlaceId = loadTagsByPlaceIds(placeIds, { compact: true });
|
||||
|
||||
const assignmentIds = assignments.map(a => a.id);
|
||||
const participantsByAssignment = loadParticipantsByAssignmentIds(assignmentIds);
|
||||
|
||||
return assignments.map(a => {
|
||||
return formatAssignmentWithPlace(a, tagsByPlaceId[a.place_id] || [], participantsByAssignment[a.id] || []);
|
||||
});
|
||||
}
|
||||
|
||||
reorderAssignments(dayId: string, orderedIds: number[]): void {
|
||||
svc.reorderAssignments(dayId, orderedIds as never);
|
||||
dayExists(dayId: string | number, tripId: string | number) {
|
||||
return !!this.dbs.get('SELECT id FROM days WHERE id = ? AND trip_id = ?', dayId, tripId);
|
||||
}
|
||||
|
||||
getAssignmentForTrip(id: string, tripId: string) {
|
||||
return svc.getAssignmentForTrip(id, tripId);
|
||||
placeExists(placeId: unknown, tripId: string | number) {
|
||||
return !!this.dbs.get('SELECT id FROM places WHERE id = ? AND trip_id = ?', placeId, tripId);
|
||||
}
|
||||
|
||||
moveAssignment(id: string, newDayId: unknown, orderIndex: number | undefined, oldDayId: unknown) {
|
||||
return svc.moveAssignment(id, newDayId as never, orderIndex as never, oldDayId as never);
|
||||
createAssignment(dayId: string | number, placeId: unknown, notes?: string | null) {
|
||||
const result = this.dbs.transaction(() => {
|
||||
const maxOrder = this.dbs.get<{ max: number | null }>('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?', dayId)!;
|
||||
const orderIndex = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||
|
||||
return this.dbs.run(
|
||||
'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, ?, ?)',
|
||||
dayId, placeId, orderIndex, notes || null
|
||||
);
|
||||
});
|
||||
|
||||
return this.getAssignmentWithPlace(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
getParticipants(id: string) {
|
||||
return svc.getParticipants(id);
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
updateTime(id: string, placeTime: unknown, endTime: unknown) {
|
||||
return svc.updateTime(id, placeTime as never, endTime as never);
|
||||
deleteAssignment(id: string | number): void {
|
||||
this.dbs.run('DELETE FROM day_assignments WHERE id = ?', id);
|
||||
}
|
||||
|
||||
setLegTransportMode(id: string, mode: string | null) {
|
||||
return svc.setLegTransportMode(id, mode);
|
||||
reorderAssignments(dayId: string | number, orderedIds: number[]): void {
|
||||
const update = this.dbs.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ? AND day_id = ?');
|
||||
this.dbs.transaction(() => {
|
||||
orderedIds.forEach((id: number, index: number) => {
|
||||
update.run(index, id, dayId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setParticipants(id: string, userIds: number[]) {
|
||||
return svc.setParticipants(id, userIds);
|
||||
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
|
||||
WHERE da.id = ? AND d.trip_id = ?
|
||||
`, id, tripId);
|
||||
}
|
||||
|
||||
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 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;
|
||||
});
|
||||
const updated = this.getAssignmentWithPlace(Number(id));
|
||||
return { assignment: updated, oldDayId };
|
||||
}
|
||||
|
||||
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
|
||||
JOIN users u ON ap.user_id = u.id
|
||||
WHERE ap.assignment_id = ?
|
||||
`, assignmentId);
|
||||
}
|
||||
|
||||
updateTime(id: string | number, placeTime: unknown, endTime: unknown) {
|
||||
this.dbs.transaction(() => {
|
||||
// Falsy times (null, undefined, '') all clear the override — an empty
|
||||
// string is a clear, not a stored value.
|
||||
this.dbs.run('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?',
|
||||
placeTime || null, endTime || null, id);
|
||||
|
||||
// Auto-sort: reorder timed assignments chronologically within the day
|
||||
if (placeTime) {
|
||||
const assignment = this.dbs.get<{ day_id: number }>('SELECT day_id FROM day_assignments WHERE id = ?', id);
|
||||
if (assignment) {
|
||||
const dayAssignments = this.dbs.all<{ id: number; effective_time: string | null }>(`
|
||||
SELECT da.id, COALESCE(da.assignment_time, p.place_time) as effective_time
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
WHERE da.day_id = ?
|
||||
ORDER BY da.order_index ASC
|
||||
`, assignment.day_id);
|
||||
|
||||
// Separate timed and untimed, sort timed by time
|
||||
const timed = dayAssignments.filter(a => a.effective_time).sort((a, b) => {
|
||||
const ta = a.effective_time!.includes(':') ? a.effective_time! : '99:99';
|
||||
const tb = b.effective_time!.includes(':') ? b.effective_time! : '99:99';
|
||||
return ta.localeCompare(tb);
|
||||
});
|
||||
const untimed = dayAssignments.filter(a => !a.effective_time);
|
||||
|
||||
// Interleave: timed in chronological order, untimed keep relative position
|
||||
const reordered = [...timed, ...untimed];
|
||||
const update = this.dbs.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ?');
|
||||
reordered.forEach((a, i) => update.run(i, a.id));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return this.getAssignmentWithPlace(Number(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the travel mode of the leg leaving this stop (#1281). null clears the
|
||||
* override so the leg falls back to the day's default_transport_mode. This is
|
||||
* 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) {
|
||||
this.dbs.run('UPDATE day_assignments SET leg_transport_mode = ? WHERE id = ?', mode ?? null, id);
|
||||
return this.getAssignmentWithPlace(Number(id));
|
||||
}
|
||||
|
||||
setParticipants(assignmentId: string | number, userIds: number[]) {
|
||||
this.dbs.transaction(() => {
|
||||
this.dbs.run('DELETE FROM assignment_participants WHERE assignment_id = ?', assignmentId);
|
||||
if (userIds.length > 0) {
|
||||
const insert = this.dbs.prepare('INSERT OR IGNORE INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)');
|
||||
for (const userId of userIds) insert.run(assignmentId, userId);
|
||||
}
|
||||
});
|
||||
|
||||
return this.dbs.all(`
|
||||
SELECT ap.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM assignment_participants ap
|
||||
JOIN users u ON ap.user_id = u.id
|
||||
WHERE ap.assignment_id = ?
|
||||
`, assignmentId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,9 @@ import for one injected service (+ module export/import) — **no more bridge
|
||||
files for the plugin host, ever**. The factory's import list shrinks
|
||||
monotonically as the recipe in `src/nest/README.md` proceeds (packing and
|
||||
day-notes swapped in 2026-07; trip-invite migrated without touching the
|
||||
factory — it was never imported here; next factory swap: assignmentService).
|
||||
factory — it was never imported here; assignments swapped in 2026-07; next
|
||||
factory swap: whichever Wave-3 domain the factory imports migrates next —
|
||||
shareService/fileService/collabService are all imported here).
|
||||
|
||||
### Test impact (as landed)
|
||||
|
||||
@@ -108,7 +110,8 @@ factory — it was never imported here; next factory swap: assignmentService).
|
||||
`PluginRpcHost` directly with hand-built `HostDeps`).
|
||||
- `tests/unit/plugins/plugin-host-deps.factory.test.ts` (was
|
||||
`create-rpc-host.test.ts`) — the six DI-domain path mocks became constructor
|
||||
stubs; the ~25 legacy-service path mocks remain; a file-local shim keeps the
|
||||
stubs (nine stubs as of the 2026-07 assignments migration); the ~25
|
||||
legacy-service path mocks remain; a file-local shim keeps the
|
||||
historical `createRealRpcHost(id, granted)` call sites and supplies a default
|
||||
no-op router.
|
||||
- Bridge delegation tests (TAG-SVC-016..020, CAT-SVC-016) — deleted with the
|
||||
@@ -216,9 +219,9 @@ none of it is throwaway.
|
||||
1. ~~**Now / opportunistically — Option A.**~~ **Done (2026-07).** Injectable
|
||||
factory, tags/categories bridges deleted, wiring test rewritten against
|
||||
stubs (see §2).
|
||||
2. **Per domain, as the service migrations proceed** (`packingService` and
|
||||
`dayNoteService` done 2026-07; `tripInviteService` done 2026-07 with no
|
||||
factory impact — it was never imported here; `assignmentService` next, per
|
||||
2. **Per domain, as the service migrations proceed** (`packingService`,
|
||||
`dayNoteService` and `assignmentService` done 2026-07; `tripInviteService`
|
||||
done 2026-07 with no factory impact — it was never imported here; next per
|
||||
`src/nest/README.md`): migrate the
|
||||
service to DI as usual; the factory swaps one legacy import for one
|
||||
injected service. Optionally pilot `tags.rpc.ts` here — tags is small and
|
||||
|
||||
@@ -21,7 +21,6 @@ import { checkPermission } from '../../../services/permissions';
|
||||
import { listTrips, updateTrip, createTrip, removeMember as removeTripMemberSvc, NotFoundError, ValidationError } from '../../../services/tripService';
|
||||
import { createPlace, updatePlace, deletePlace } from '../../../services/placeService';
|
||||
import { createDay, getDay, updateDay, deleteDay, listDays, listAccommodations, validateAccommodationRefs, createAccommodation as createAccommodationSvc, getAccommodation, updateAccommodation as updateAccommodationSvc, deleteAccommodation as deleteAccommodationSvc } from '../../../services/dayService';
|
||||
import { createAssignment, deleteAssignment, dayExists, placeExists, getAssignmentForTrip } from '../../../services/assignmentService';
|
||||
import { isAddonEnabled } from '../../../services/adminService';
|
||||
import { isDemoEmail } from '../../../services/demo';
|
||||
import { ADDON_IDS } from '../../../addons';
|
||||
@@ -36,6 +35,7 @@ import { CategoriesService } from '../../categories/categories.service';
|
||||
import { TodoService } from '../../todo/todo.service';
|
||||
import { PackingService } from '../../packing/packing.service';
|
||||
import { DayNotesService } from '../../days/day-notes.service';
|
||||
import { AssignmentsService } from '../../assignments/assignments.service';
|
||||
import { notifyBookingChange } from '../../../services/reservationService';
|
||||
import { PluginRpcHost, ForbiddenResource, BadParams } from './rpc-host';
|
||||
import { appendAudit } from './plugin-audit';
|
||||
@@ -150,10 +150,10 @@ export interface PluginCallRouter {
|
||||
* `plugin:{id}:{event}` so a plugin can't forge a core event.
|
||||
*
|
||||
* DI-native domain services (budget, reservations, tags, categories, todo,
|
||||
* packing, day-notes, oauth) are constructor-injected; legacy `services/*`
|
||||
* domains stay plain function imports until their own migration lands
|
||||
* (DI-MIGRATION.md), at which point each swaps one import for one injected
|
||||
* service.
|
||||
* packing, day-notes, assignments, oauth) are constructor-injected; legacy
|
||||
* `services/*` domains stay plain function imports until their own migration
|
||||
* lands (DI-MIGRATION.md), at which point each swaps one import for one
|
||||
* injected service.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PluginHostDepsFactory {
|
||||
@@ -166,6 +166,7 @@ export class PluginHostDepsFactory {
|
||||
private readonly packing: PackingService,
|
||||
private readonly oauth: PluginOAuthService,
|
||||
private readonly dayNotes: DayNotesService,
|
||||
private readonly assignments: AssignmentsService,
|
||||
) {}
|
||||
|
||||
create(id: string, granted: ReadonlySet<string>, router: PluginCallRouter): PluginRpcHost {
|
||||
@@ -490,19 +491,22 @@ export class PluginHostDepsFactory {
|
||||
return { deleted: true };
|
||||
},
|
||||
// --- Itinerary (day_edit). Both the day AND the place must belong to the trip,
|
||||
// so a plugin can't cross-link another trip's rows (assignmentService doesn't
|
||||
// so a plugin can't cross-link another trip's rows (AssignmentsService doesn't
|
||||
// self-check this — the controllers do, so we reproduce it here). ---
|
||||
assignPlaceToDay: (tripId, dayId, placeId, notes) => {
|
||||
if (!dayExists(dayId, tripId)) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`);
|
||||
if (!placeExists(placeId, tripId)) throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`);
|
||||
const assignment = createAssignment(dayId, placeId, notes);
|
||||
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}`);
|
||||
const assignment = this.assignments.createAssignment(dayId, placeId, notes);
|
||||
broadcast(tripId, 'assignment:created', { assignment });
|
||||
return assignment;
|
||||
},
|
||||
unassignPlace: (tripId, assignmentId) => {
|
||||
if (!getAssignmentForTrip(assignmentId, tripId)) throw new ForbiddenResource(`no assignment ${assignmentId} on trip ${tripId}`);
|
||||
deleteAssignment(assignmentId);
|
||||
broadcast(tripId, 'assignment:deleted', { assignmentId });
|
||||
const existing = this.assignments.getAssignmentForTrip(assignmentId, tripId);
|
||||
if (!existing) throw new ForbiddenResource(`no assignment ${assignmentId} on trip ${tripId}`);
|
||||
this.assignments.deleteAssignment(assignmentId);
|
||||
// Same payload shape as the REST/MCP delete, so client stores can
|
||||
// evict the assignment from the right day.
|
||||
broadcast(tripId, 'assignment:deleted', { assignmentId, dayId: existing.day_id });
|
||||
return { deleted: true };
|
||||
},
|
||||
// --- Trip creation (trip_create; owner = acting user). No broadcast — a new trip
|
||||
|
||||
@@ -31,6 +31,7 @@ import { ReservationsModule } from '../reservations/reservations.module';
|
||||
import { TodoModule } from '../todo/todo.module';
|
||||
import { PackingModule } from '../packing/packing.module';
|
||||
import { DaysModule } from '../days/days.module';
|
||||
import { AssignmentsModule } from '../assignments/assignments.module';
|
||||
|
||||
/**
|
||||
* Plugin system (#plugins). M0 read side + M2 isolated runtime + M3 frontend:
|
||||
@@ -42,7 +43,7 @@ import { DaysModule } from '../days/days.module';
|
||||
@Module({
|
||||
// The DI-native domain services the plugin host wiring injects
|
||||
// (PluginHostDepsFactory); DatabaseModule is @Global, so not listed.
|
||||
imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule],
|
||||
imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, AssignmentsModule],
|
||||
controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController, PlaceDetailsController, TripWarningsController, ViewContributionsController, TripCardContributionsController, PluginPhotosController, PluginCalendarController, MapMarkersController, MapLayersController, PluginRoutesController, DayScheduleController, PdfSectionsController, AtlasLayersController, JournalEntryRowsController, PluginUserSettingsController, PluginOAuthController, PluginActivityController],
|
||||
providers: [PluginsService, PluginRuntimeService, PluginRegistryService, PluginOAuthService, PluginHostDepsFactory],
|
||||
// Exported so the admin addon-toggle handler can cascade-disable plugins whose
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
import { db } from '../db/database';
|
||||
import { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace } from './queryHelpers';
|
||||
import { AssignmentRow, DayAssignment } from '../types';
|
||||
|
||||
export function getAssignmentWithPlace(assignmentId: number | bigint) {
|
||||
const a = db.prepare(`
|
||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||
COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||
p.duration_minutes, p.notes as place_notes,
|
||||
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE da.id = ?
|
||||
`).get(assignmentId) as AssignmentRow | undefined;
|
||||
|
||||
if (!a) return null;
|
||||
|
||||
const tags = db.prepare(`
|
||||
SELECT t.* FROM tags t
|
||||
JOIN place_tags pt ON t.id = pt.tag_id
|
||||
WHERE pt.place_id = ?
|
||||
`).all(a.place_id);
|
||||
|
||||
const participants = db.prepare(`
|
||||
SELECT ap.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM assignment_participants ap
|
||||
JOIN users u ON ap.user_id = u.id
|
||||
WHERE ap.assignment_id = ?
|
||||
`).all(a.id);
|
||||
|
||||
return {
|
||||
id: a.id,
|
||||
day_id: a.day_id,
|
||||
place_id: a.place_id,
|
||||
order_index: a.order_index,
|
||||
notes: a.notes,
|
||||
assignment_time: a.assignment_time ?? null,
|
||||
assignment_end_time: a.assignment_end_time ?? null,
|
||||
leg_transport_mode: a.leg_transport_mode ?? null,
|
||||
participants,
|
||||
created_at: a.created_at,
|
||||
place: {
|
||||
id: a.place_id,
|
||||
name: a.place_name,
|
||||
description: a.place_description,
|
||||
lat: a.lat,
|
||||
lng: a.lng,
|
||||
address: a.address,
|
||||
category_id: a.category_id,
|
||||
price: a.price,
|
||||
currency: a.place_currency,
|
||||
place_time: a.place_time,
|
||||
end_time: a.end_time,
|
||||
duration_minutes: a.duration_minutes,
|
||||
notes: a.place_notes,
|
||||
image_url: a.image_url,
|
||||
transport_mode: a.transport_mode,
|
||||
google_place_id: a.google_place_id,
|
||||
google_ftid: a.google_ftid,
|
||||
osm_id: a.osm_id,
|
||||
website: a.website,
|
||||
phone: a.phone,
|
||||
category: a.category_id ? {
|
||||
id: a.category_id,
|
||||
name: a.category_name,
|
||||
color: a.category_color,
|
||||
icon: a.category_icon,
|
||||
} : null,
|
||||
tags,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function listDayAssignments(dayId: string | number) {
|
||||
const assignments = db.prepare(`
|
||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||
COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||
p.duration_minutes, p.notes as place_notes,
|
||||
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE da.day_id = ?
|
||||
ORDER BY da.order_index ASC, da.created_at ASC
|
||||
`).all(dayId) as AssignmentRow[];
|
||||
|
||||
const placeIds = [...new Set(assignments.map(a => a.place_id))];
|
||||
const tagsByPlaceId = loadTagsByPlaceIds(placeIds, { compact: true });
|
||||
|
||||
const assignmentIds = assignments.map(a => a.id);
|
||||
const participantsByAssignment = loadParticipantsByAssignmentIds(assignmentIds);
|
||||
|
||||
return assignments.map(a => {
|
||||
return formatAssignmentWithPlace(a, tagsByPlaceId[a.place_id] || [], participantsByAssignment[a.id] || []);
|
||||
});
|
||||
}
|
||||
|
||||
export function dayExists(dayId: string | number, tripId: string | number) {
|
||||
return !!db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
|
||||
}
|
||||
|
||||
export function placeExists(placeId: string | number, tripId: string | number) {
|
||||
return !!db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId);
|
||||
}
|
||||
|
||||
export function createAssignment(dayId: string | number, placeId: string | number, notes: string | null) {
|
||||
const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?').get(dayId) as { max: number | null };
|
||||
const orderIndex = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, ?, ?)'
|
||||
).run(dayId, placeId, orderIndex, notes || null);
|
||||
|
||||
return getAssignmentWithPlace(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
export function assignmentExistsInDay(id: string | number, dayId: string | number, tripId: string | number) {
|
||||
return !!db.prepare(
|
||||
'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 = ?'
|
||||
).get(id, dayId, tripId);
|
||||
}
|
||||
|
||||
export function deleteAssignment(id: string | number) {
|
||||
db.prepare('DELETE FROM day_assignments WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
export function reorderAssignments(dayId: string | number, orderedIds: number[]) {
|
||||
const update = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ? AND day_id = ?');
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
orderedIds.forEach((id: number, index: number) => {
|
||||
update.run(index, id, dayId);
|
||||
});
|
||||
db.exec('COMMIT');
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function getAssignmentForTrip(id: string | number, tripId: string | number) {
|
||||
return db.prepare(`
|
||||
SELECT da.* FROM day_assignments da
|
||||
JOIN days d ON da.day_id = d.id
|
||||
WHERE da.id = ? AND d.trip_id = ?
|
||||
`).get(id, tripId) as DayAssignment | undefined;
|
||||
}
|
||||
|
||||
export function moveAssignment(id: string | number, newDayId: string | number, orderIndex: number, oldDayId: number) {
|
||||
db.prepare('UPDATE day_assignments SET day_id = ?, order_index = ? WHERE id = ?').run(newDayId, orderIndex || 0, id);
|
||||
const updated = getAssignmentWithPlace(Number(id));
|
||||
return { assignment: updated, oldDayId };
|
||||
}
|
||||
|
||||
export function getParticipants(assignmentId: string | number) {
|
||||
return db.prepare(`
|
||||
SELECT ap.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM assignment_participants ap
|
||||
JOIN users u ON ap.user_id = u.id
|
||||
WHERE ap.assignment_id = ?
|
||||
`).all(assignmentId);
|
||||
}
|
||||
|
||||
export function updateTime(id: string | number, placeTime: string | null, endTime: string | null) {
|
||||
db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?')
|
||||
.run(placeTime ?? null, endTime ?? null, id);
|
||||
|
||||
// Auto-sort: reorder timed assignments chronologically within the day
|
||||
if (placeTime) {
|
||||
const assignment = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(id) as { day_id: number } | undefined;
|
||||
if (assignment) {
|
||||
const dayAssignments = db.prepare(`
|
||||
SELECT da.id, COALESCE(da.assignment_time, p.place_time) as effective_time
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
WHERE da.day_id = ?
|
||||
ORDER BY da.order_index ASC
|
||||
`).all(assignment.day_id) as { id: number; effective_time: string | null }[];
|
||||
|
||||
// Separate timed and untimed, sort timed by time
|
||||
const timed = dayAssignments.filter(a => a.effective_time).sort((a, b) => {
|
||||
const ta = a.effective_time!.includes(':') ? a.effective_time! : '99:99';
|
||||
const tb = b.effective_time!.includes(':') ? b.effective_time! : '99:99';
|
||||
return ta.localeCompare(tb);
|
||||
});
|
||||
const untimed = dayAssignments.filter(a => !a.effective_time);
|
||||
|
||||
// Interleave: timed in chronological order, untimed keep relative position
|
||||
const reordered = [...timed, ...untimed];
|
||||
const update = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ?');
|
||||
reordered.forEach((a, i) => update.run(i, a.id));
|
||||
}
|
||||
}
|
||||
|
||||
return getAssignmentWithPlace(Number(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the travel mode of the leg leaving this stop (#1281). null clears the
|
||||
* override so the leg falls back to the day's default_transport_mode. This is
|
||||
* sticky by design: changing the whole-day default never touches a leg that
|
||||
* carries its own explicit mode.
|
||||
*/
|
||||
export function setLegTransportMode(id: string | number, mode: string | null) {
|
||||
db.prepare('UPDATE day_assignments SET leg_transport_mode = ? WHERE id = ?').run(mode ?? null, id);
|
||||
return getAssignmentWithPlace(Number(id));
|
||||
}
|
||||
|
||||
export function setParticipants(assignmentId: string | number, userIds: number[]) {
|
||||
db.prepare('DELETE FROM assignment_participants WHERE assignment_id = ?').run(assignmentId);
|
||||
if (userIds.length > 0) {
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)');
|
||||
for (const userId of userIds) insert.run(assignmentId, userId);
|
||||
}
|
||||
|
||||
return db.prepare(`
|
||||
SELECT ap.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM assignment_participants ap
|
||||
JOIN users u ON ap.user_id = u.id
|
||||
WHERE ap.assignment_id = ?
|
||||
`).all(assignmentId);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createTestRegistry, type McpRegistry } from '@trek/nest-mcp';
|
||||
import { db } from '../../src/db/database';
|
||||
import { trekMcpAccessPolicy } from '../../src/mcp/nest-mcp-policy';
|
||||
import { AssignmentsMcp } from '../../src/nest/assignments/assignments.mcp';
|
||||
import { AssignmentsService } from '../../src/nest/assignments/assignments.service';
|
||||
import { CategoriesMcp } from '../../src/nest/categories/categories.mcp';
|
||||
import { CategoriesService } from '../../src/nest/categories/categories.service';
|
||||
import { DatabaseService } from '../../src/nest/database/database.service';
|
||||
@@ -29,6 +31,7 @@ export function createMcpTestRegistry(): McpRegistry {
|
||||
new TodoMcp(new TodoService(dbService)),
|
||||
new PackingMcp(new PackingService(dbService)),
|
||||
new DayNotesMcp(new DayNotesService(dbService)),
|
||||
new AssignmentsMcp(new AssignmentsService(dbService)),
|
||||
],
|
||||
{ accessPolicy: trekMcpAccessPolicy },
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { CategoriesService } from '../../src/nest/categories/categories.service'
|
||||
import { TodoService } from '../../src/nest/todo/todo.service';
|
||||
import { PackingService } from '../../src/nest/packing/packing.service';
|
||||
import { DayNotesService } from '../../src/nest/days/day-notes.service';
|
||||
import { AssignmentsService } from '../../src/nest/assignments/assignments.service';
|
||||
|
||||
/**
|
||||
* Hand-wired counterpart of the PluginsModule DI graph for no-Nest tests
|
||||
@@ -27,6 +28,7 @@ export function createHostDepsFactory(dbs: DatabaseService): PluginHostDepsFacto
|
||||
new PackingService(dbs),
|
||||
new PluginOAuthService(dbs),
|
||||
new DayNotesService(dbs),
|
||||
new AssignmentsService(dbs),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Unit tests for the DI-native AssignmentsService — ASG-SVC-001 through
|
||||
* ASG-SVC-028 (written for the DI migration: the legacy
|
||||
* services/assignmentService.ts had no dedicated unit suite, so these pin the
|
||||
* relocated SQL — MAX(order_index)+1 ordering, post-write re-selects, the
|
||||
* time-driven auto-sort with its '99:99' sentinel — plus the hardening that
|
||||
* followed the move: transactional writes, the DB-derived move source day,
|
||||
* empty-string time clearing, the unified compact tag projection, and the
|
||||
* assignments.bridge delegation). Uses a real in-memory SQLite DB so SQL
|
||||
* logic is exercised faithfully.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
// ── DB setup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: unknown, userId: number) =>
|
||||
db.prepare(`
|
||||
SELECT t.id, t.user_id FROM trips t
|
||||
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
|
||||
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
|
||||
`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: unknown, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-secret',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember, createDay, createPlace, createDayAssignment, createTag } from '../../helpers/factories';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
|
||||
import { createAssignment, dayExists, placeExists, getAssignmentForTrip } from '../../../src/nest/assignments/assignments.bridge';
|
||||
|
||||
const svc = new AssignmentsService(new DatabaseService(testDb));
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
/** owner + trip + one day + one place — the minimal itinerary fixture. */
|
||||
function fixture() {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Louvre' });
|
||||
return { user, trip, day, place };
|
||||
}
|
||||
|
||||
// ── verifyTripAccess / canEdit ────────────────────────────────────────────────
|
||||
|
||||
describe('verifyTripAccess', () => {
|
||||
it('ASG-SVC-001: returns trip for owner', () => {
|
||||
const { user, trip } = fixture();
|
||||
const result = svc.verifyTripAccess(trip.id, user.id);
|
||||
expect(result).toBeDefined();
|
||||
expect((result as { user_id: number }).user_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('ASG-SVC-002: returns falsy for non-member', () => {
|
||||
const { trip } = fixture();
|
||||
const { user: stranger } = createUser(testDb);
|
||||
expect(svc.verifyTripAccess(trip.id, stranger.id)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('canEdit', () => {
|
||||
it('ASG-SVC-003: owner and member pass day_edit (default trip_member)', () => {
|
||||
const { user, trip } = fixture();
|
||||
const { user: member } = createUser(testDb);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
expect(svc.canEdit({ user_id: trip.user_id }, user as never)).toBe(true);
|
||||
expect(svc.canEdit({ user_id: trip.user_id }, member as never)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── existence checks ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('dayExists / placeExists / assignmentExistsInDay', () => {
|
||||
it('ASG-SVC-004: dayExists is trip-scoped', () => {
|
||||
const { trip, day } = fixture();
|
||||
const { user: other } = createUser(testDb);
|
||||
const otherTrip = createTrip(testDb, other.id);
|
||||
expect(svc.dayExists(day.id, trip.id)).toBe(true);
|
||||
expect(svc.dayExists(day.id, otherTrip.id)).toBe(false);
|
||||
expect(svc.dayExists(9999, trip.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('ASG-SVC-005: placeExists is trip-scoped', () => {
|
||||
const { trip, place } = fixture();
|
||||
const { user: other } = createUser(testDb);
|
||||
const otherTrip = createTrip(testDb, other.id);
|
||||
expect(svc.placeExists(place.id, trip.id)).toBe(true);
|
||||
expect(svc.placeExists(place.id, otherTrip.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('ASG-SVC-006: assignmentExistsInDay requires matching day AND trip', () => {
|
||||
const { trip, day, place } = fixture();
|
||||
const otherDay = createDay(testDb, trip.id);
|
||||
const a = createDayAssignment(testDb, day.id, place.id);
|
||||
expect(svc.assignmentExistsInDay(a.id, day.id, trip.id)).toBe(true);
|
||||
expect(svc.assignmentExistsInDay(a.id, otherDay.id, trip.id)).toBe(false);
|
||||
expect(svc.assignmentExistsInDay(a.id, day.id, trip.id + 1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── createAssignment ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('createAssignment', () => {
|
||||
it('ASG-SVC-007: first assignment gets order_index 0, then MAX+1 appends', () => {
|
||||
const { day, place } = fixture();
|
||||
const first = svc.createAssignment(day.id, place.id, null);
|
||||
const second = svc.createAssignment(day.id, place.id, null);
|
||||
expect(first!.order_index).toBe(0);
|
||||
expect(second!.order_index).toBe(1);
|
||||
});
|
||||
|
||||
it('ASG-SVC-008: empty-string notes coerce to null (`notes || null`)', () => {
|
||||
const { day, place } = fixture();
|
||||
const a = svc.createAssignment(day.id, place.id, '');
|
||||
expect(a!.notes).toBeNull();
|
||||
});
|
||||
|
||||
it('ASG-SVC-009: returns the re-selected nested shape with place, category and compact tags', () => {
|
||||
const { user, trip, day, place } = fixture();
|
||||
const tag = createTag(testDb, user.id, { name: 'museum' });
|
||||
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tag.id);
|
||||
const a = svc.createAssignment(day.id, place.id, 'skip the line');
|
||||
expect(a).toMatchObject({
|
||||
day_id: day.id,
|
||||
place_id: place.id,
|
||||
notes: 'skip the line',
|
||||
assignment_time: null,
|
||||
assignment_end_time: null,
|
||||
leg_transport_mode: null,
|
||||
participants: [],
|
||||
place: { id: place.id, name: 'Louvre' },
|
||||
});
|
||||
// The same compact tag projection as listDayAssignments — id/name/color/
|
||||
// created_at only, no user_id — so both read paths share one wire shape.
|
||||
expect(a!.place.tags).toEqual([{ id: tag.id, name: 'museum', color: '#10b981', created_at: (tag as { created_at?: string }).created_at }]);
|
||||
void trip;
|
||||
});
|
||||
});
|
||||
|
||||
// ── listDayAssignments ────────────────────────────────────────────────────────
|
||||
|
||||
describe('listDayAssignments', () => {
|
||||
it('ASG-SVC-010: orders by order_index and formats with compact tags + participants', () => {
|
||||
const { user, trip, day, place } = fixture();
|
||||
const second = createPlace(testDb, trip.id, { name: 'Orsay' });
|
||||
const tag = createTag(testDb, user.id, { name: 'art' });
|
||||
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tag.id);
|
||||
const a1 = createDayAssignment(testDb, day.id, place.id, { order_index: 1 });
|
||||
const a2 = createDayAssignment(testDb, day.id, second.id, { order_index: 0 });
|
||||
svc.setParticipants(a1.id, [user.id]);
|
||||
|
||||
const list = svc.listDayAssignments(day.id);
|
||||
expect(list.map(a => a.id)).toEqual([a2.id, a1.id]);
|
||||
// Compact tag projection (id/name/color/created_at only — no user_id key).
|
||||
expect(list[1].place.tags).toEqual([{ id: tag.id, name: 'art', color: '#10b981', created_at: (tag as { created_at?: string }).created_at }]);
|
||||
expect(list[1].participants).toEqual([{ user_id: user.id, username: user.username, avatar: null }]);
|
||||
expect(list[0].participants).toEqual([]);
|
||||
expect(list[0].place.tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('ASG-SVC-011: returns [] for an empty day', () => {
|
||||
const { day } = fixture();
|
||||
expect(svc.listDayAssignments(day.id)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── delete / reorder ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('deleteAssignment / reorderAssignments', () => {
|
||||
it('ASG-SVC-012: deleteAssignment removes the row', () => {
|
||||
const { day, place } = fixture();
|
||||
const a = createDayAssignment(testDb, day.id, place.id);
|
||||
svc.deleteAssignment(a.id);
|
||||
expect(testDb.prepare('SELECT id FROM day_assignments WHERE id = ?').get(a.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ASG-SVC-013: reorderAssignments applies positional order, scoped to the day', () => {
|
||||
const { trip, day, place } = fixture();
|
||||
const otherDay = createDay(testDb, trip.id);
|
||||
const a1 = createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
|
||||
const a2 = createDayAssignment(testDb, day.id, place.id, { order_index: 1 });
|
||||
const foreign = createDayAssignment(testDb, otherDay.id, place.id, { order_index: 5 });
|
||||
|
||||
svc.reorderAssignments(day.id, [a2.id, a1.id, foreign.id]);
|
||||
|
||||
const order = (id: number) => (testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index;
|
||||
expect(order(a2.id)).toBe(0);
|
||||
expect(order(a1.id)).toBe(1);
|
||||
// The day-scoped WHERE leaves another day's assignment untouched.
|
||||
expect(order(foreign.id)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getAssignmentForTrip / moveAssignment ─────────────────────────────────────
|
||||
|
||||
describe('getAssignmentForTrip', () => {
|
||||
it('ASG-SVC-014: returns the raw row for the trip, undefined cross-trip', () => {
|
||||
const { trip, day, place } = fixture();
|
||||
const a = createDayAssignment(testDb, day.id, place.id);
|
||||
expect(svc.getAssignmentForTrip(a.id, trip.id)).toMatchObject({ id: a.id, day_id: day.id });
|
||||
expect(svc.getAssignmentForTrip(a.id, trip.id + 1)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveAssignment', () => {
|
||||
it('ASG-SVC-015: moves to the new day, re-selects the nested shape, derives oldDayId from the row', () => {
|
||||
const { trip, day, place } = fixture();
|
||||
const target = createDay(testDb, trip.id);
|
||||
const a = createDayAssignment(testDb, day.id, place.id);
|
||||
const result = svc.moveAssignment(a.id, target.id, 2);
|
||||
expect(result.assignment).toMatchObject({ id: a.id, day_id: target.id, order_index: 2 });
|
||||
expect(result.oldDayId).toBe(day.id);
|
||||
});
|
||||
|
||||
it('ASG-SVC-016: nullish orderIndex coerces to 0; explicit 0 stays 0', () => {
|
||||
const { trip, day, place } = fixture();
|
||||
const target = createDay(testDb, trip.id);
|
||||
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 7 });
|
||||
expect(svc.moveAssignment(a.id, target.id, undefined).assignment!.order_index).toBe(0);
|
||||
expect(svc.moveAssignment(a.id, day.id, null).assignment!.order_index).toBe(0);
|
||||
expect(svc.moveAssignment(a.id, target.id, 0).assignment!.order_index).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── participants ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getParticipants / setParticipants', () => {
|
||||
it('ASG-SVC-017: setParticipants replaces the list and returns the joined rows', () => {
|
||||
const { user, day, place } = fixture();
|
||||
const { user: peer } = createUser(testDb);
|
||||
const a = createDayAssignment(testDb, day.id, place.id);
|
||||
svc.setParticipants(a.id, [user.id]);
|
||||
const replaced = svc.setParticipants(a.id, [peer.id]);
|
||||
expect(replaced).toEqual([{ user_id: peer.id, username: peer.username, avatar: null }]);
|
||||
expect(svc.getParticipants(a.id)).toEqual(replaced);
|
||||
});
|
||||
|
||||
it('ASG-SVC-018: empty array clears all participants', () => {
|
||||
const { user, day, place } = fixture();
|
||||
const a = createDayAssignment(testDb, day.id, place.id);
|
||||
svc.setParticipants(a.id, [user.id]);
|
||||
expect(svc.setParticipants(a.id, [])).toEqual([]);
|
||||
expect(svc.getParticipants(a.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ASG-SVC-019: username COALESCEs display_name over username', () => {
|
||||
const { user, day, place } = fixture();
|
||||
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Fancy Name', user.id);
|
||||
const a = createDayAssignment(testDb, day.id, place.id);
|
||||
const rows = svc.setParticipants(a.id, [user.id]);
|
||||
expect(rows).toEqual([{ user_id: user.id, username: 'Fancy Name', avatar: null }]);
|
||||
});
|
||||
|
||||
it('ASG-SVC-020: duplicate user ids collapse via INSERT OR IGNORE', () => {
|
||||
const { user, day, place } = fixture();
|
||||
const a = createDayAssignment(testDb, day.id, place.id);
|
||||
const rows = svc.setParticipants(a.id, [user.id, user.id]);
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ASG-SVC-028: a failing insert rolls back the whole replace (delete included)', () => {
|
||||
const { user, day, place } = fixture();
|
||||
const a = createDayAssignment(testDb, day.id, place.id);
|
||||
svc.setParticipants(a.id, [user.id]);
|
||||
// 999999 violates the users FK — the transaction must undo the delete too.
|
||||
expect(() => svc.setParticipants(a.id, [999999])).toThrow();
|
||||
expect(svc.getParticipants(a.id)).toEqual([{ user_id: user.id, username: user.username, avatar: null }]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateTime / setLegTransportMode ──────────────────────────────────────────
|
||||
|
||||
describe('updateTime', () => {
|
||||
it('ASG-SVC-021: persists both times and re-selects the nested shape', () => {
|
||||
const { day, place } = fixture();
|
||||
const a = createDayAssignment(testDb, day.id, place.id);
|
||||
const updated = svc.updateTime(a.id, '09:00', '11:30');
|
||||
expect(updated).toMatchObject({ id: a.id, assignment_time: '09:00', assignment_end_time: '11:30' });
|
||||
});
|
||||
|
||||
it('ASG-SVC-022: a truthy place_time auto-sorts the day chronologically (untimed keep relative position)', () => {
|
||||
const { day, place } = fixture();
|
||||
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
|
||||
const b = createDayAssignment(testDb, day.id, place.id, { order_index: 1 });
|
||||
const c = createDayAssignment(testDb, day.id, place.id, { order_index: 2 });
|
||||
|
||||
svc.updateTime(c.id, '08:00', null);
|
||||
const order = (id: number) => (testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index;
|
||||
expect([order(c.id), order(a.id), order(b.id)]).toEqual([0, 1, 2]);
|
||||
|
||||
svc.updateTime(a.id, '07:00', null);
|
||||
expect([order(a.id), order(c.id), order(b.id)]).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('ASG-SVC-023: clearing with null persists but skips the auto-sort (falsy gate)', () => {
|
||||
const { day, place } = fixture();
|
||||
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
|
||||
const b = createDayAssignment(testDb, day.id, place.id, { order_index: 1 });
|
||||
svc.updateTime(b.id, '06:00', null); // sorts b first
|
||||
const updated = svc.updateTime(b.id, null, null); // clear — no re-sort
|
||||
expect(updated!.assignment_time).toBeNull();
|
||||
const order = (id: number) => (testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index;
|
||||
expect(order(b.id)).toBe(0);
|
||||
expect(order(a.id)).toBe(1);
|
||||
});
|
||||
|
||||
it('ASG-SVC-027: an empty-string time clears the override like null (and skips the auto-sort)', () => {
|
||||
const { day, place } = fixture();
|
||||
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
|
||||
const b = createDayAssignment(testDb, day.id, place.id, { order_index: 1 });
|
||||
svc.updateTime(b.id, '06:00', '07:00'); // sorts b first
|
||||
const updated = svc.updateTime(b.id, '', '');
|
||||
expect(updated!.assignment_time).toBeNull();
|
||||
expect(updated!.assignment_end_time).toBeNull();
|
||||
expect(testDb.prepare('SELECT assignment_time FROM day_assignments WHERE id = ?').get(b.id)).toEqual({ assignment_time: null });
|
||||
const order = (id: number) => (testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index;
|
||||
expect(order(b.id)).toBe(0);
|
||||
expect(order(a.id)).toBe(1);
|
||||
});
|
||||
|
||||
it('ASG-SVC-024: a time without ":" sorts last among timed via the 99:99 sentinel', () => {
|
||||
const { day, place } = fixture();
|
||||
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
|
||||
const b = createDayAssignment(testDb, day.id, place.id, { order_index: 1 });
|
||||
svc.updateTime(a.id, 'morning', null); // no colon → '99:99' in the sort
|
||||
svc.updateTime(b.id, '13:00', null);
|
||||
const order = (id: number) => (testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index;
|
||||
expect(order(b.id)).toBe(0);
|
||||
expect(order(a.id)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLegTransportMode', () => {
|
||||
it('ASG-SVC-025: sets and clears the leg override (sticky per #1281)', () => {
|
||||
const { day, place } = fixture();
|
||||
const a = createDayAssignment(testDb, day.id, place.id);
|
||||
expect(svc.setLegTransportMode(a.id, 'cycling')!.leg_transport_mode).toBe('cycling');
|
||||
expect(svc.setLegTransportMode(a.id, null)!.leg_transport_mode).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── bridge delegation ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('assignments.bridge', () => {
|
||||
it('ASG-SVC-026: bridge exports delegate to the same SQL over the shared connection', () => {
|
||||
const { trip, day, place } = fixture();
|
||||
expect(dayExists(day.id, trip.id)).toBe(true);
|
||||
expect(placeExists(place.id, trip.id)).toBe(true);
|
||||
const created = createAssignment(day.id, place.id, null);
|
||||
expect(created).toMatchObject({ day_id: day.id, place_id: place.id, order_index: 0 });
|
||||
expect(getAssignmentForTrip(created!.id, trip.id)).toMatchObject({ id: created!.id, day_id: day.id });
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,8 @@
|
||||
* per-plugin data db is cached, a granted db:own call works through the wired
|
||||
* host, and trip broadcasts are force-namespaced to plugin:{id}:{event}.
|
||||
* DI-native domains (budget/reservations/tags/categories/todo/packing/
|
||||
* day-notes/oauth) are constructor-injected stubs; legacy services/* domains
|
||||
* stay path-mocked until their own DI migration lands.
|
||||
* day-notes/assignments/oauth) are constructor-injected stubs; legacy
|
||||
* services/* domains stay path-mocked until their own DI migration lands.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
@@ -109,13 +109,14 @@ vi.mock('../../../src/services/dayService', () => ({
|
||||
updateAccommodation: vi.fn((id: number, _existing: unknown, fields: Record<string, unknown>) => ({ id: Number(id), ...fields })),
|
||||
deleteAccommodation: vi.fn((id: number) => (Number(id) === 61 ? { linkedReservationId: 40, deletedBudgetItemId: 9 } : { linkedReservationId: null, deletedBudgetItemId: null })),
|
||||
}));
|
||||
vi.mock('../../../src/services/assignmentService', () => ({
|
||||
// Assignments is a constructor-injected stub (same behaviors as the old path mock).
|
||||
const assignmentsStub = {
|
||||
createAssignment: vi.fn((dayId: number, placeId: number, notes: string | null) => ({ id: 30, day_id: dayId, place_id: placeId, notes })),
|
||||
deleteAssignment: vi.fn(),
|
||||
dayExists: vi.fn((dayId: number) => dayId === 3),
|
||||
placeExists: vi.fn((placeId: number) => placeId === 7),
|
||||
getAssignmentForTrip: vi.fn((id: number) => (id === 99 ? undefined : { id })),
|
||||
}));
|
||||
getAssignmentForTrip: vi.fn((id: number) => (id === 99 ? undefined : { id, day_id: 3 })),
|
||||
} as unknown as AssignmentsService;
|
||||
// The extra named exports satisfy the real reservations.service.ts module (now
|
||||
// loaded un-mocked as a factory constructor dep); its instance is stubbed, so
|
||||
// these are never called.
|
||||
@@ -235,6 +236,9 @@ const reservationsStub = {
|
||||
syncBudgetOnUpdate() {},
|
||||
} as unknown as ReservationsService;
|
||||
vi.mock('../../../src/services/journeyService', () => ({
|
||||
// Consumed by the real assignments.service.ts module (loaded un-mocked as a
|
||||
// factory constructor dep); the instance is stubbed, so this is never called.
|
||||
reconcileTripSkeletons: vi.fn(),
|
||||
listJourneys: vi.fn((uid: number) => [{ id: 1, owner: uid }]),
|
||||
// journeyId 88 = no access (listEntries self-gates to null); else returns entries
|
||||
listEntries: vi.fn((journeyId: number, uid: number) => (journeyId === 88 ? null : [{ id: 10, journey_id: journeyId, author_id: uid }])),
|
||||
@@ -293,12 +297,13 @@ import type { CategoriesService } from '../../../src/nest/categories/categories.
|
||||
import type { TodoService } from '../../../src/nest/todo/todo.service';
|
||||
import type { PackingService } from '../../../src/nest/packing/packing.service';
|
||||
import type { DayNotesService } from '../../../src/nest/days/day-notes.service';
|
||||
import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
|
||||
import type { PluginOAuthService } from '../../../src/nest/plugins/plugin-oauth.service';
|
||||
|
||||
// The factory under test, wired exactly like PluginsModule does — but with the
|
||||
// DI-native domain services replaced by the stubs above. The shim keeps the
|
||||
// ~45 historical call sites unchanged and supplies a default no-op router.
|
||||
const factory = new PluginHostDepsFactory(budgetStub, reservationsStub, tagsStub, categoriesStub, todoStub, packingStub, oauthStub, dayNotesStub);
|
||||
const factory = new PluginHostDepsFactory(budgetStub, reservationsStub, tagsStub, categoriesStub, todoStub, packingStub, oauthStub, dayNotesStub, assignmentsStub);
|
||||
const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: () => {} };
|
||||
const createRealRpcHost = (id: string, granted: ReadonlySet<string>, router: PluginCallRouter = stubRouter) => factory.create(id, granted, router);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user