Move places, days and itinerary onto the decorators

Eight more methods, so the registry owns 38 of 113 and rpc-host.ts is under 1000
lines for the first time.

itinerary.* lives in assignments/, not in days/, because AssignmentsModule already
imports DaysModule and putting the handler the other way round would have closed a
cycle. It keeps riding on day_edit, which is what the app gates it on.

The delete path in places carries an ordering constraint that is silent when
broken, so it has two tests of its own: the journey hook has to run BEFORE the row
goes, because journey_entries.source_place_id is ON DELETE SET NULL and afterwards
there is nothing left to detach, and the place id has to be scoped to the trip
before the hook runs at all, because the hook keys on the place alone and would
otherwise detach another trip's entries even though the delete itself is refused.
This commit is contained in:
Maurice
2026-08-09 13:05:52 +02:00
committed by Maurice
parent d58f7aa399
commit c326d39a07
14 changed files with 540 additions and 281 deletions
@@ -5,6 +5,9 @@ import { PermissionsModule } from '../permissions/permissions.module';
import { QueryHelpersModule } from '../query-helpers/query-helpers.module';
import { DayAssignmentsController, AssignmentOpsController } from './assignments.controller';
import { AssignmentsService } from './assignments.service';
import { ItineraryRpc } from './itinerary.rpc';
import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
import { RealtimeModule } from '../realtime/realtime.module';
import { AssignmentsMcp } from './assignments.mcp';
import { AuthModule } from '../auth/auth.module';
@@ -16,9 +19,9 @@ import { AuthModule } from '../auth/auth.module';
*/
@Module({
// DaysModule: AssignmentsMcp injects DaysService for the target-day checks.
imports: [DaysModule, PermissionsModule, QueryHelpersModule, AuthModule, JourneyDomainModule],
imports: [DaysModule, PermissionsModule, QueryHelpersModule, AuthModule, JourneyDomainModule, RealtimeModule, PluginGuardsModule],
controllers: [DayAssignmentsController, AssignmentOpsController],
providers: [AssignmentsService, AssignmentsMcp],
providers: [AssignmentsService, AssignmentsMcp, ItineraryRpc],
exports: [AssignmentsService],
})
export class AssignmentsModule {}
@@ -0,0 +1,61 @@
import { PluginController, PluginMethod } from '../plugins/host/rpc-kit/decorators';
import { PluginGuards } from '../plugins/host/plugin-guards.service';
import { ForbiddenResource } from '../plugins/host/rpc-errors';
import { num, str } from '../plugins/host/rpc-params';
import type { PluginRpcContext } from '../plugins/host/rpc-kit/types';
import { RealtimeService } from '../realtime/realtime.service';
import { AssignmentsService } from './assignments.service';
/** Assigning a place to a day counts as a DAY edit in the app, not an edit of its own. */
const DAY_EDIT_ACTION = 'day_edit';
/**
* The itinerary surface a plugin may reach (#plugins).
*
* Both the day AND the place have to belong to the trip. AssignmentsService does not
* check that itself, the controllers do, so it is reproduced here: without it a
* plugin could cross-link another trip's rows.
*/
@PluginController()
export class ItineraryRpc {
constructor(
private readonly assignments: AssignmentsService,
private readonly realtime: RealtimeService,
private readonly guards: PluginGuards,
) {}
@PluginMethod('itinerary.assign', { permission: 'db:write:itinerary' })
assign(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
const tripId = num(params.tripId, 'tripId');
const dayId = num(params.dayId, 'dayId');
const placeId = num(params.placeId, 'placeId');
const actor = this.guards.requireActor(ctx, 'itinerary');
const notes = params.notes === undefined || params.notes === null ? null : str(params.notes, 'notes');
this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
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);
this.realtime.broadcast(tripId, 'assignment:created', { assignment });
this.assignments.reconcile(tripId);
return assignment;
}
@PluginMethod('itinerary.unassign', { permission: 'db:write:itinerary' })
unassign(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
const tripId = num(params.tripId, 'tripId');
const assignmentId = num(params.assignmentId, 'assignmentId');
const actor = this.guards.requireActor(ctx, 'itinerary');
this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
const existing = this.assignments.getAssignmentForTrip(assignmentId, tripId);
if (!existing) throw new ForbiddenResource(`no assignment ${assignmentId} on trip ${tripId}`);
this.assignments.deleteAssignment(assignmentId);
// The dayId is what the client reducer keys the eviction on. Without it nothing
// leaves the day, so keep the payload shape identical to the REST/MCP delete.
this.realtime.broadcast(tripId, 'assignment:deleted', { assignmentId, dayId: existing.day_id });
// Create, delete, move and time re-mirror the linked journey skeletons afterwards,
// in the controller and the MCP tool alike; reorder, transport and participants do
// not. Without it an open journey keeps the removed place until the next reload.
this.assignments.reconcile(tripId);
return { deleted: true };
}
}
+2 -1
View File
@@ -5,6 +5,7 @@ import { DaysMcp } from './days.mcp';
import { DayNotesController } from './day-notes.controller';
import { DayNotesService } from './day-notes.service';
import { DayNotesRpc } from './day-notes.rpc';
import { DaysRpc } from './days.rpc';
import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
import { RealtimeModule } from '../realtime/realtime.module';
import { DayNotesMcp } from './day-notes.mcp';
@@ -25,7 +26,7 @@ import { AuthModule } from '../auth/auth.module';
@Module({
imports: [PermissionsModule, QueryHelpersModule, PlacesModule, AuthModule, RealtimeModule, PluginGuardsModule],
controllers: [DaysController, DayNotesController],
providers: [DaysService, DaysMcp, DayNotesService, DayNotesMcp, DayNotesRpc],
providers: [DaysService, DaysMcp, DayNotesService, DayNotesMcp, DayNotesRpc, DaysRpc],
exports: [DaysService, DayNotesService],
})
export class DaysModule {}
+66
View File
@@ -0,0 +1,66 @@
import { dayCreateRequestSchema, dayUpdateRequestSchema } from '@trek/shared';
import { PluginController, PluginMethod } from '../plugins/host/rpc-kit/decorators';
import { PluginGuards } from '../plugins/host/plugin-guards.service';
import { BadParams, ForbiddenResource } from '../plugins/host/rpc-errors';
import { num } from '../plugins/host/rpc-params';
import type { PluginRpcContext } from '../plugins/host/rpc-kit/types';
import { RealtimeService } from '../realtime/realtime.service';
import { DaysService } from './days.service';
const DAY_EDIT_ACTION = 'day_edit';
/**
* The day surface a plugin may reach (#plugins). Every write scopes the day to the
* trip before touching it, so a plugin cannot edit another trip's day by naming its
* id. The itinerary half lives in assignments/itinerary.rpc.ts, because
* AssignmentsModule already imports DaysModule and the reverse would be a cycle.
*/
@PluginController()
export class DaysRpc {
constructor(
private readonly days: DaysService,
private readonly realtime: RealtimeService,
private readonly guards: PluginGuards,
) {}
@PluginMethod('days.create', { permission: 'db:write:days' })
create(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
const tripId = num(params.tripId, 'tripId');
const actor = this.guards.requireActor(ctx, 'day');
const parsed = dayCreateRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid day: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
const input = parsed.data as { date?: string; notes?: string };
const day = this.days.create(tripId, input.date, input.notes);
this.realtime.broadcast(tripId, 'day:created', { day });
return day;
}
@PluginMethod('days.update', { permission: 'db:write:days' })
update(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
const tripId = num(params.tripId, 'tripId');
const dayId = num(params.dayId, 'dayId');
const actor = this.guards.requireActor(ctx, 'day');
const parsed = dayUpdateRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid day: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
// getDay scopes the row to the trip before the write touches it.
const current = this.days.getDay(dayId, tripId);
if (!current) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`);
const day = this.days.update(dayId, current, parsed.data as { notes?: string; title?: string | null });
this.realtime.broadcast(tripId, 'day:updated', { day });
return day;
}
@PluginMethod('days.delete', { permission: 'db:write:days' })
delete(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
const tripId = num(params.tripId, 'tripId');
const dayId = num(params.dayId, 'dayId');
const actor = this.guards.requireActor(ctx, 'day');
this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
if (!this.days.getDay(dayId, tripId)) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`);
this.days.remove(dayId);
this.realtime.broadcast(tripId, 'day:deleted', { dayId });
return { deleted: true };
}
}
+5 -2
View File
@@ -2,6 +2,9 @@ import { Module } from '@nestjs/common';
import { JourneyDomainModule } from '../journey/journey-domain.module';
import { PlacesController } from './places.controller';
import { PlacesService } from './places.service';
import { PlacesRpc } from './places.rpc';
import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
import { RealtimeModule } from '../realtime/realtime.module';
import { PlacesMcp } from './places.mcp';
import { PermissionsModule } from '../permissions/permissions.module';
import { AppConfigModule } from '../app-config/app-config.module';
@@ -20,9 +23,9 @@ import { AuthModule } from '../auth/auth.module';
* is no places.bridge.ts: nothing outside the container consumes this domain.
*/
@Module({
imports: [PermissionsModule, QueryHelpersModule, MapsModule, AuthModule, AppConfigModule, UnsplashModule, PlacePhotosModule, JourneyDomainModule],
imports: [PermissionsModule, QueryHelpersModule, MapsModule, AuthModule, AppConfigModule, UnsplashModule, PlacePhotosModule, JourneyDomainModule, RealtimeModule, PluginGuardsModule],
controllers: [PlacesController],
providers: [PlacesService, PlacesMcp],
providers: [PlacesService, PlacesMcp, PlacesRpc],
exports: [PlacesService],
})
export class PlacesModule {}
+98
View File
@@ -0,0 +1,98 @@
import { placeCreateRequestSchema, placeUpdateRequestSchema } from '@trek/shared';
import { PluginController, PluginMethod } from '../plugins/host/rpc-kit/decorators';
import { PluginGuards } from '../plugins/host/plugin-guards.service';
import { BadParams, ForbiddenResource } from '../plugins/host/rpc-errors';
import { num } from '../plugins/host/rpc-params';
import type { PluginRpcContext } from '../plugins/host/rpc-kit/types';
import { RealtimeService } from '../realtime/realtime.service';
import { JourneyDomainService } from '../journey/journey-domain.service';
import { PlacesService } from './places.service';
import type { PlaceCreateInput, PlaceUpdateInput } from './places.service';
const PLACE_EDIT_ACTION = 'place_edit';
/**
* The @trek/shared write schemas carry no string-length caps, so the plugin path
* mirrors the ones the REST controller adds. Without them a plugin could store a
* field the web app refuses with a 400, e.g. a 100k-character place name.
*/
const PLACE_STR_LIMITS: Record<string, number> = { name: 200, description: 2000, address: 500, notes: 2000 };
/**
* The place surface a plugin may reach (#plugins).
*
* Journey mirroring is best-effort on every path that triggers it, in the REST
* controller and the MCP tool alike, so a broken journey link can never turn a
* successful write into an RPC error.
*/
@PluginController()
export class PlacesRpc {
constructor(
private readonly places: PlacesService,
private readonly journey: JourneyDomainService,
private readonly realtime: RealtimeService,
private readonly guards: PluginGuards,
) {}
@PluginMethod('places.create', { permission: 'db:write:places' })
create(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
const tripId = num(params.tripId, 'tripId');
const actor = this.guards.requireActor(ctx, 'place');
const parsed = placeCreateRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid place: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
this.guards.capStrings(parsed.data as Record<string, unknown>, PLACE_STR_LIMITS);
this.guards.requireTripEdit(tripId, actor, PLACE_EDIT_ACTION);
const place = this.places.create(String(tripId), parsed.data as unknown as PlaceCreateInput);
this.realtime.broadcast(tripId, 'place:created', { place });
this.mirrorJourneys(() => this.journey.onPlaceCreated(tripId, place.id));
return place;
}
@PluginMethod('places.update', { permission: 'db:write:places' })
update(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
const tripId = num(params.tripId, 'tripId');
const placeId = num(params.placeId, 'placeId');
const actor = this.guards.requireActor(ctx, 'place');
const parsed = placeUpdateRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid place: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
this.guards.capStrings(parsed.data as Record<string, unknown>, PLACE_STR_LIMITS);
this.guards.requireTripEdit(tripId, actor, PLACE_EDIT_ACTION);
const place = this.places.update(String(tripId), String(placeId), parsed.data as PlaceUpdateInput);
if (place === null) throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`);
this.realtime.broadcast(tripId, 'place:updated', { place });
this.mirrorJourneys(() => this.journey.onPlaceUpdated(placeId));
return place;
}
@PluginMethod('places.delete', { permission: 'db:write:places' })
delete(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
const tripId = num(params.tripId, 'tripId');
const placeId = num(params.placeId, 'placeId');
const actor = this.guards.requireActor(ctx, 'place');
this.guards.requireTripEdit(tripId, actor, PLACE_EDIT_ACTION);
// Scope the id to the trip before anything else: onPlaceDeleted keys on the place
// alone, so an id belonging to a foreign trip would detach THAT trip's journey
// entries even though the delete below refuses it.
if (!this.places.get(String(tripId), String(placeId))) {
throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`);
}
// Ahead of the DELETE, like the REST route and the MCP tool:
// journey_entries.source_place_id is ON DELETE SET NULL, so afterwards the hook
// finds nothing left to detach and the entries linger as orphans.
this.mirrorJourneys(() => this.journey.onPlaceDeleted(placeId));
if (!this.places.remove(String(tripId), String(placeId))) {
throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`);
}
this.realtime.broadcast(tripId, 'place:deleted', { placeId });
return { deleted: true };
}
/** Journey mirroring never fails a write that already succeeded. */
private mirrorJourneys(run: () => void): void {
try {
run();
} catch {
/* non-fatal */
}
}
}
@@ -367,82 +367,11 @@ export class PluginHostDepsFactory {
// without the hooks it keeps the old title/location — or a dead entry — until
// somebody reloads it. ---
canEditPlaces: (tripId, userId) => this.canEditTripAs('place_edit', tripId, userId),
createPlace: (tripId, input) => {
const place = this.places.create(String(tripId), input as unknown as PlaceCreateInput);
this.realtime.broadcast(tripId, 'place:created', { place });
mirrorJourneys(() => this.journey.onPlaceCreated(Number(tripId), place.id));
return place;
},
updatePlace: (tripId, placeId, input) => {
const place = this.places.update(String(tripId), String(placeId), input as PlaceUpdateInput);
if (place === null) throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`);
this.realtime.broadcast(tripId, 'place:updated', { place });
mirrorJourneys(() => this.journey.onPlaceUpdated(Number(placeId)));
return place;
},
deletePlace: (tripId, placeId) => {
// Scope the id to the trip before anything else: onPlaceDeleted keys on the
// place alone, so an id belonging to a foreign trip would detach that trip's
// journey entries even though the delete below refuses it.
if (!this.places.get(String(tripId), String(placeId))) throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`);
// Ahead of the DELETE, like the REST route and the MCP tool: journey_entries
// .source_place_id is ON DELETE SET NULL, so afterwards the hook finds nothing
// left to detach and the entries linger as orphans.
mirrorJourneys(() => this.journey.onPlaceDeleted(Number(placeId)));
const deleted = this.places.remove(String(tripId), String(placeId));
if (!deleted) throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`);
this.realtime.broadcast(tripId, 'place:deleted', { placeId });
return { deleted: true };
},
// --- Days (day_edit). getDay scopes the row to the trip before any write. ---
canEditDays: (tripId, userId) => this.canEditTripAs('day_edit', tripId, userId),
createDay: (tripId, input) => {
const i = input as { date?: string; notes?: string };
const day = this.days.create(tripId, i.date, i.notes);
this.realtime.broadcast(tripId, 'day:created', { day });
return day;
},
updateDay: (tripId, dayId, input) => {
const current = this.days.getDay(dayId, tripId);
if (!current) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`);
const day = this.days.update(dayId, current, input as { notes?: string; title?: string | null });
this.realtime.broadcast(tripId, 'day:updated', { day });
return day;
},
deleteDay: (tripId, dayId) => {
const current = this.days.getDay(dayId, tripId);
if (!current) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`);
this.days.remove(dayId);
this.realtime.broadcast(tripId, 'day:deleted', { dayId });
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 (AssignmentsService doesn't
// self-check this — the controllers do, so we reproduce it here). ---
assignPlaceToDay: (tripId, 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);
this.realtime.broadcast(tripId, 'assignment:created', { assignment });
this.assignments.reconcile(tripId);
return assignment;
},
unassignPlace: (tripId, 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. The dayId is what the client
// reducer keys the eviction on — without it nothing leaves the day.
this.realtime.broadcast(tripId, 'assignment:deleted', { assignmentId, dayId: existing.day_id });
// Create, delete, move and time re-mirror the linked journey skeletons
// afterwards, in the controller and in the MCP tool alike (reorder,
// transport and participants don't). Without it an open journey keeps
// the removed place until the next reload. No sid — a plugin has no
// originating socket.
this.assignments.reconcile(tripId);
return { deleted: true };
},
// --- Trip creation (trip_create; owner = acting user). No broadcast — a new trip
// is only visible to its owner, who refetches (same as the REST POST). ---
canCreateTrip: (userId) => {
+3 -83
View File
@@ -166,18 +166,10 @@ export interface HostDeps {
deleteCost(tripId: number, itemId: number): unknown;
// --- Places (the 'place_edit' permission) ---
canEditPlaces(tripId: number, userId: number): boolean;
createPlace(tripId: number, input: Record<string, unknown>): unknown;
updatePlace(tripId: number, placeId: number, input: Record<string, unknown>): unknown;
deletePlace(tripId: number, placeId: number): unknown;
// --- Days + itinerary (the 'day_edit' permission) ---
canEditDays(tripId: number, userId: number): boolean;
createDay(tripId: number, input: Record<string, unknown>): unknown;
updateDay(tripId: number, dayId: number, input: Record<string, unknown>): unknown;
deleteDay(tripId: number, dayId: number): unknown;
/** Assign a place to a day (both trip-scoped by the wiring); returns the assignment. */
assignPlaceToDay(tripId: number, dayId: number, placeId: number, notes: string | null): unknown;
/** Remove a day-assignment (trip-scoped by the wiring). */
unassignPlace(tripId: number, assignmentId: number): unknown;
// --- Trip (the 'trip_edit' permission) ---
canEditTrip(tripId: number, userId: number): boolean;
updateTrip(tripId: number, userId: number, input: Record<string, unknown>): unknown;
@@ -573,83 +565,11 @@ export class PluginRpcHost {
// trip access + the entity's edit permission for the HOST-bound acting user
// (a job/onLoad has no user, so its writes are refused). The delegating deps
// reuse the real services + broadcast the same events, so the app stays live. ---
if (has('db:write:places')) {
this.methods.set('places.create', (p, uid) => {
const tripId = num(p.tripId, 'tripId');
const actor = this.requireActor(uid, 'place');
const parsed = placeCreateRequestSchema.safeParse(p.input);
if (!parsed.success) throw new BadParams(`invalid place: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
this.capStrings(parsed.data as Record<string, unknown>, PLACE_STR_LIMITS);
this.requireTripEdit(tripId, actor, deps.canEditPlaces);
return deps.createPlace(tripId, parsed.data as Record<string, unknown>);
});
this.methods.set('places.update', (p, uid) => {
const tripId = num(p.tripId, 'tripId');
const placeId = num(p.placeId, 'placeId');
const actor = this.requireActor(uid, 'place');
const parsed = placeUpdateRequestSchema.safeParse(p.input);
if (!parsed.success) throw new BadParams(`invalid place: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
this.capStrings(parsed.data as Record<string, unknown>, PLACE_STR_LIMITS);
this.requireTripEdit(tripId, actor, deps.canEditPlaces);
return deps.updatePlace(tripId, placeId, parsed.data as Record<string, unknown>);
});
this.methods.set('places.delete', (p, uid) => {
const tripId = num(p.tripId, 'tripId');
const placeId = num(p.placeId, 'placeId');
const actor = this.requireActor(uid, 'place');
this.requireTripEdit(tripId, actor, deps.canEditPlaces);
return deps.deletePlace(tripId, placeId);
});
}
// places.* now lives in src/nest/places/places.rpc.ts.
if (has('db:write:days')) {
this.methods.set('days.create', (p, uid) => {
const tripId = num(p.tripId, 'tripId');
const actor = this.requireActor(uid, 'day');
const parsed = dayCreateRequestSchema.safeParse(p.input);
if (!parsed.success) throw new BadParams(`invalid day: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
this.requireTripEdit(tripId, actor, deps.canEditDays);
return deps.createDay(tripId, parsed.data as Record<string, unknown>);
});
this.methods.set('days.update', (p, uid) => {
const tripId = num(p.tripId, 'tripId');
const dayId = num(p.dayId, 'dayId');
const actor = this.requireActor(uid, 'day');
const parsed = dayUpdateRequestSchema.safeParse(p.input);
if (!parsed.success) throw new BadParams(`invalid day: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
this.requireTripEdit(tripId, actor, deps.canEditDays);
return deps.updateDay(tripId, dayId, parsed.data as Record<string, unknown>);
});
this.methods.set('days.delete', (p, uid) => {
const tripId = num(p.tripId, 'tripId');
const dayId = num(p.dayId, 'dayId');
const actor = this.requireActor(uid, 'day');
this.requireTripEdit(tripId, actor, deps.canEditDays);
return deps.deleteDay(tripId, dayId);
});
}
// days.* now lives in src/nest/days/days.rpc.ts.
if (has('db:write:itinerary')) {
// Assigning/removing a place on a day is a DAY edit in the app (day_edit), so
// gate it with canEditDays; the wiring also checks the day AND place belong to
// the trip so a plugin can't cross-link another trip's rows.
this.methods.set('itinerary.assign', (p, uid) => {
const tripId = num(p.tripId, 'tripId');
const dayId = num(p.dayId, 'dayId');
const placeId = num(p.placeId, 'placeId');
const actor = this.requireActor(uid, 'itinerary');
const notes = p.notes === undefined || p.notes === null ? null : str(p.notes, 'notes');
this.requireTripEdit(tripId, actor, deps.canEditDays);
return deps.assignPlaceToDay(tripId, dayId, placeId, notes);
});
this.methods.set('itinerary.unassign', (p, uid) => {
const tripId = num(p.tripId, 'tripId');
const assignmentId = num(p.assignmentId, 'assignmentId');
const actor = this.requireActor(uid, 'itinerary');
this.requireTripEdit(tripId, actor, deps.canEditDays);
return deps.unassignPlace(tripId, assignmentId);
});
}
// itinerary.* now lives in src/nest/assignments/itinerary.rpc.ts.
if (has('db:write:trips')) {
this.methods.set('trips.update', (p, uid) => {