fix(assignments): confine day participants to the trip roster

setParticipants took the ids on trust. The caller is proven to be on the trip and
the assignment is proven to sit on it, but nothing said the same about the ids in
the body, and the read-back joins users — so a stranger's name and avatar came
back to the writer and went out over the assignment:participants broadcast to
everyone else in the room.

Takes the tripId both callers already hold and filters against the roster. Same
drop-rather-than-reject rule as bag members and reservation travellers: the
participants box posts the whole list on every edit, so a trip whose membership
changed underneath it would otherwise be stuck.

ASG-SVC-028 covered a foreign key error rolling the replace back. The filter
takes an unknown id out before the insert can reach it, so that path is gone and
the case now pins what happens instead: a list of nothing but strangers clears,
like the empty list ASG-SVC-018 already pins.
This commit is contained in:
Maurice
2026-08-16 23:01:11 +02:00
committed by Maurice
parent 1eb29f8b77
commit 0337ebbbd2
5 changed files with 52 additions and 23 deletions
@@ -202,7 +202,7 @@ export class AssignmentOpsController {
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
throw new HttpException({ error: 'Assignment not found' }, 404);
}
const participants = this.assignments.setParticipants(id, body.user_ids);
const participants = this.assignments.setParticipants(id, body.user_ids, tripId);
this.assignments.broadcast(tripId, 'assignment:participants', { assignmentId: Number(id), participants }, socketId);
return { participants };
}
@@ -187,7 +187,7 @@ export class AssignmentsMcp {
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
const participants = this.assignments.setParticipants(assignmentId, userIds);
const participants = this.assignments.setParticipants(assignmentId, userIds, tripId);
this.guards.safeBroadcast(tripId, 'assignment:participants', { assignmentId, participants });
return ok({ participants });
}
@@ -283,12 +283,21 @@ export class AssignmentsService {
return this.getAssignmentWithPlace(Number(id));
}
setParticipants(assignmentId: string | number, userIds: number[]) {
/**
* Both callers have already proven the assignment sits on `tripId`; this
* settles the other half, that the ids in the body do too. Off-roster ids drop
* silently rather than 400, matching bag members and reservation travellers —
* the participants box sends the whole list back on every edit, so rejecting
* the request would strand a trip whose membership changed underneath it.
*/
setParticipants(assignmentId: string | number, userIds: number[], tripId: string | number) {
const roster = this.dbs.rosterUserIds(tripId);
const scoped = userIds.filter(id => roster.has(id));
this.dbs.transaction(() => {
this.dbs.run('DELETE FROM assignment_participants WHERE assignment_id = ?', assignmentId);
if (userIds.length > 0) {
if (scoped.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);
for (const userId of scoped) insert.run(assignmentId, userId);
}
});