fix(packing): bag totals survive reconnects and MCP bag deletes (#2191)

Deleting a bag over MCP moves every figure, the items fall into the unassigned pile, yet it sent no totals ping; it pings the room now like REST and the plugin RPC do. The client refetches totals after a room re-join and when coming back online, through the same coalescing window. The plugin SDK types learn the create fields the routes accept since #2154, on both hand-kept declarations.
This commit is contained in:
Maurice
2026-09-02 19:00:49 +02:00
committed by Maurice
parent 9cc427d156
commit c907e0d5e5
6 changed files with 98 additions and 7 deletions
+3
View File
@@ -363,6 +363,9 @@ export class PackingMcp {
// { bagId } matches the REST route and the plugin host (the legacy
// registrar's { id } was the odd one out).
this.guards.safeBroadcast(tripId, 'packing:bag-deleted', { bagId });
// packing_items.bag_id is ON DELETE SET NULL, so the contents land in the
// unassigned pile and both numbers move (#2191), as on REST and plugin RPC.
this.packing.broadcastBagTotals(String(tripId));
return ok({ success: true });
}
@@ -91,7 +91,7 @@ export interface PluginContext {
/** A trip's packing items (hydrated bags/assignees). Needs 'db:read:packing'. */
list(tripId: number): Promise<unknown[]>;
/** Add a packing item (owner = acting user). Needs 'db:write:packing' + 'packing_edit'. */
create(tripId: number, input: { name: string; category?: string; checked?: boolean; is_private?: boolean; visibility?: 'common' | 'personal' | 'shared'; recipient_ids?: number[] }): Promise<unknown>;
create(tripId: number, input: { name: string; category?: string; checked?: boolean; weight_grams?: number | null; bag_id?: number | null; quantity?: number; is_private?: boolean; visibility?: 'common' | 'personal' | 'shared'; recipient_ids?: number[] }): Promise<unknown>;
/** Update a packing item. Needs 'db:write:packing' + 'packing_edit'. */
update(tripId: number, itemId: number, input: Record<string, unknown>): Promise<unknown>;
/** Delete a packing item. Needs 'db:write:packing' + 'packing_edit'. */
@@ -103,7 +103,7 @@ export interface PluginContext {
* (intentional — bags are the write-side structure; packing.list is the read surface).
*/
listBags(tripId: number): Promise<unknown[]>;
createBag(tripId: number, input: { name: string; color?: string }): Promise<unknown>;
createBag(tripId: number, input: { name: string; color?: string; weight_limit_grams?: number }): Promise<unknown>;
updateBag(tripId: number, bagId: number, input: Record<string, unknown>): Promise<unknown>;
deleteBag(tripId: number, bagId: number): Promise<{ deleted: boolean }>;
setBagMembers(tripId: number, bagId: number, userIds: number[]): Promise<unknown>;
@@ -256,6 +256,27 @@ describe('Tool: delete_packing_bag', () => {
});
});
it('pings the room to re-read the bag weights (#2191)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'Carry-on', '#000000');
const bagId = r.lastInsertRowid as number;
testDb.prepare('INSERT INTO packing_items (trip_id, name, category, checked, bag_id, weight_grams) VALUES (?, ?, ?, 0, ?, ?)')
.run(trip.id, 'Tent', 'Camping', bagId, 3000);
await withHarness(user.id, async (h) => {
await h.client.callTool({
name: 'delete_packing_bag',
arguments: { tripId: trip.id, bagId },
});
// bag_id is ON DELETE SET NULL, so the 3 kg move into the unassigned pile:
// both numbers change and every client has to re-read them, exactly as on
// the REST route and the plugin RPC.
expect(broadcastMock).toHaveBeenCalledWith(String(trip.id), 'packing:bag-totals', {}, undefined);
expect(broadcastMock).toHaveBeenCalledTimes(2);
expect(testDb.prepare('SELECT bag_id FROM packing_items WHERE trip_id = ?').get(trip.id)).toEqual({ bag_id: null });
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);