diff --git a/server/src/nest/trips/trips.controller.ts b/server/src/nest/trips/trips.controller.ts index f275ec582..345dde26a 100644 --- a/server/src/nest/trips/trips.controller.ts +++ b/server/src/nest/trips/trips.controller.ts @@ -375,7 +375,7 @@ export class TripsController { if (!trip) { throw new HttpException({ error: 'Trip not found' }, 404); } - return this.trips.bundle(id, trip); + return this.trips.bundle(id, trip, user.id); } @Get(':id/export.ics') diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index 7435deb24..7dae9783e 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -130,14 +130,16 @@ export class TripsService { } /** Aggregates every trip sub-collection for offline caching (legacy /:id/bundle). */ - bundle(tripId: string, trip: { user_id: number }) { + bundle(tripId: string, trip: { user_id: number }, viewerId: number) { const { days } = listDays(tripId); const { owner, members } = this.listMembers(tripId, trip.user_id); return { trip, days, places: listPlaces(String(tripId), {}), - packingItems: this.packing.listItems(tripId), + // Scope to the requesting member so other members' private packing items + // (#858) never land in this viewer's offline cache. + packingItems: this.packing.listItems(tripId, viewerId), todoItems: this.todo.listItems(tripId), budgetItems: listBudgetItems(tripId), reservations: listReservations(tripId), diff --git a/server/tests/e2e/trips.e2e.test.ts b/server/tests/e2e/trips.e2e.test.ts index f6bccac3e..42a455df2 100644 --- a/server/tests/e2e/trips.e2e.test.ts +++ b/server/tests/e2e/trips.e2e.test.ts @@ -25,12 +25,15 @@ const { db } = vi.hoisted(() => { name TEXT NOT NULL, checked INTEGER NOT NULL DEFAULT 0, category TEXT, sort_order INTEGER NOT NULL DEFAULT 0, due_date TEXT, description TEXT, assigned_user_id INTEGER, priority INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); - // bundle()'s packingItems now runs PackingService's real SQL (DI-injected, no mock). + // bundle()'s packingItems now runs PackingService's real SQL (DI-injected, no + // mock) — viewer-scoped (#858), so the recipients table must exist too. tmp.exec(`CREATE TABLE packing_items (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT NOT NULL, checked INTEGER DEFAULT 0, category TEXT, sort_order INTEGER DEFAULT 0, weight_grams INTEGER, bag_id INTEGER, quantity INTEGER NOT NULL DEFAULT 1, is_private INTEGER NOT NULL DEFAULT 0, owner_id INTEGER, updated_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); + tmp.exec(`CREATE TABLE packing_item_recipients (item_id INTEGER NOT NULL, user_id INTEGER NOT NULL, + PRIMARY KEY (item_id, user_id));`); return { db: tmp }; }); diff --git a/server/tests/integration/trips.test.ts b/server/tests/integration/trips.test.ts index 869ee1c6b..3bc442b13 100644 --- a/server/tests/integration/trips.test.ts +++ b/server/tests/integration/trips.test.ts @@ -1245,4 +1245,19 @@ describe('Trip bundle', () => { expect(res.status).toBe(401); }); + + it('BUNDLE-006 — packingItems are scoped to the viewer: another member\'s private item stays out (#858)', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id); + testDb.prepare('INSERT INTO packing_items (trip_id, name, checked, sort_order) VALUES (?, ?, 0, 0)').run(trip.id, 'Tent'); + testDb.prepare('INSERT INTO packing_items (trip_id, name, checked, sort_order, is_private, owner_id) VALUES (?, ?, 0, 1, 1, ?)').run(trip.id, 'Secret gift', owner.id); + + const ownerView = await request(app).get(`/api/trips/${trip.id}/bundle`).set('Cookie', authCookie(owner.id)); + expect(ownerView.body.packingItems.map((i: { name: string }) => i.name).sort()).toEqual(['Secret gift', 'Tent']); + + const memberView = await request(app).get(`/api/trips/${trip.id}/bundle`).set('Cookie', authCookie(member.id)); + expect(memberView.body.packingItems.map((i: { name: string }) => i.name)).toEqual(['Tent']); + }); }); diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index 4e3c3782f..5674fecf2 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -40,7 +40,8 @@ import type { PackingService } from '../../../src/nest/packing/packing.service'; import { rebaseTripCurrency } from '../../../src/services/budgetService'; const todoStub = { listItems: () => [] } as unknown as TodoService; -const packingStub = { listItems: () => [] } as unknown as PackingService; +const packingListItems = vi.fn(() => []); +const packingStub = { listItems: packingListItems } as unknown as PackingService; function svc() { return new TripsService(new DatabaseService(dbConn), todoStub, packingStub); } beforeEach(() => vi.clearAllMocks()); @@ -99,14 +100,16 @@ describe('TripsService (wrapper delegation + bundle/copy/notify helpers)', () => expect(dbMock.prepare).toHaveBeenCalledWith(expect.stringContaining('SELECT * FROM trips t')); }); - it('bundle aggregates every sub-collection + the member list', () => { - const result = svc().bundle('9', { user_id: 1 }); + it('bundle aggregates every sub-collection + the member list, scoping packing to the viewer (#858)', () => { + const result = svc().bundle('9', { user_id: 1 }, 7); expect(result).toMatchObject({ trip: { user_id: 1 }, days: [1], places: [], members: [{ id: 1 }] }); + // The viewer id is forwarded so other members' private items stay out of the offline cache. + expect(packingListItems).toHaveBeenCalledWith('9', 7); }); it('bundle tolerates a null member list', () => { tripSvc.listMembers.mockReturnValueOnce({ owner: { id: 1 }, members: null }); - const result = svc().bundle('9', { user_id: 1 }); + const result = svc().bundle('9', { user_id: 1 }, 1); expect(result).toMatchObject({ members: [{ id: 1 }] }); });