fix(trips): confine assignee writes to the people on the trip

Holding packing_edit or todo_edit says you may edit this trip's lists, not that
any user id you name belongs to it. Both updateCategoryAssignees loops wrote the
body's ids straight through, and both end by re-selecting through a JOIN on
users — so an id from anywhere on the instance came back with a display name and
an avatar attached, in the response and in the WebSocket fan-out to the room. The
packing item recipient writes had the same gap.

packing already had tripRosterIds for exactly this and used it only for bag
members; its doc comment said "assigned to a bag", which is probably why the
other three writes grew up without it. The query moves to DatabaseService next to
canAccessTrip, which asks the same question as a predicate, and the helper now
delegates. Every service that needs it already injects DatabaseService.

Off-roster ids drop rather than 400: a copied trip carries assignee ids over
before its members exist, and rejecting the whole request would stall the picker
there. Guests are unaffected — a guest is a trip_members row, so the roster
includes them, which is the point of #1362.
This commit is contained in:
Maurice
2026-08-16 23:00:57 +02:00
committed by Maurice
parent 5bc041cc77
commit 1eb29f8b77
5 changed files with 72 additions and 7 deletions
@@ -56,6 +56,25 @@ export class DatabaseService {
return isOwner(tripId, userId);
}
/**
* The user ids a trip may refer to: its members plus the owner. Guests count —
* a guest is a credential-less users row joined into trip_members, and #1362
* makes it assignable everywhere a real member is.
*
* This is canAccessTrip's question asked as a set rather than a predicate, so
* it lives beside it. Domains that accept user ids in a write body intersect
* against it: holding the permission says you may edit the trip, not that any
* id you name belongs to it. Runs on the connection rather than delegating to
* db/database, because it has no legacy export to stay parity with.
*/
rosterUserIds(tripId: number | string): Set<number> {
const rows = this.all<{ user_id: number }>(
'SELECT user_id FROM trip_members WHERE trip_id = ? UNION SELECT user_id FROM trips WHERE id = ?',
tripId, tripId,
);
return new Set(rows.map(r => r.user_id));
}
getPlaceWithTags(placeId: number | string): PlaceWithTags | null {
return getPlaceWithTags(placeId);
}