diff --git a/client/src/components/Planner/PlaceInspector.test.tsx b/client/src/components/Planner/PlaceInspector.test.tsx index d5266e865..ce7b0131c 100644 --- a/client/src/components/Planner/PlaceInspector.test.tsx +++ b/client/src/components/Planner/PlaceInspector.test.tsx @@ -901,6 +901,13 @@ describe('PlaceInspector', () => { expect(container.querySelector('input[type="file"]')).toBeNull(); }); + it('FE-PLANNER-INSPECTOR-044b: with upload but no files, the files row does not open to nothing', () => { + render(); + const row = screen.getByRole('button', { name: 'Files' }); + expect(row).toBeDisabled(); + expect(row).toHaveAttribute('aria-expanded', 'false'); + }); + // ── Participants section hidden when tripMembers <= 1 ───────────────────── it('FE-PLANNER-INSPECTOR-045: participants section hidden when tripMembers has only 1 member', () => { diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index 33eee24c4..4f7a65c48 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -1189,12 +1189,12 @@ function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpand >
{filesExpanded && placeFiles.length > 0 && (
diff --git a/client/src/types.ts b/client/src/types.ts index 2c07c4bd0..4296d9a61 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -105,6 +105,8 @@ export interface TripFile { reservation_title?: string linked_reservation_ids?: (number | null)[] linked_place_ids?: (number | null)[] + /** Stops in the plan the file is pinned to (MCP's link_trip_file, plugins). */ + linked_assignment_ids?: (number | null)[] /** Served download path — always present on list/create/update responses (formatFile). */ url: string } diff --git a/client/src/utils/placeFiles.ts b/client/src/utils/placeFiles.ts index f06831704..296acd62e 100644 --- a/client/src/utils/placeFiles.ts +++ b/client/src/utils/placeFiles.ts @@ -1,10 +1,12 @@ import type { Reservation, TripFile } from '../types' /** - * The files a place shows: its own, plus the ones attached to a booking that - * hangs on this place or on one of its stops in the plan. A confirmation lives - * on the booking, and the place card was the one screen that could not reach it - * (#2217). + * The files a place shows: its own, the ones pinned to one of its stops in the + * plan, plus the ones attached to a booking that hangs on this place or on one + * of its stops. A confirmation lives on the booking, and the place card was the + * one screen that could not reach it (#2217). A hotel booking reaches its place + * through the stay rather than its own place_id, the same way the card lists + * the booking itself (getPlaceBookings, #2363). */ export function filesForPlace( files: TripFile[] | undefined, @@ -14,11 +16,18 @@ export function filesForPlace( ): TripFile[] { if (placeId == null) return [] const own = (files || []).filter(f => - !f.deleted_at && (String(f.place_id) === String(placeId) || (f.linked_place_ids || []).includes(placeId)), + !f.deleted_at && ( + String(f.place_id) === String(placeId) + || (f.linked_place_ids || []).includes(placeId) + || (f.linked_assignment_ids || []).some(id => id != null && assignmentIds.includes(id)) + ), ) const bookingIds = new Set( (reservations || []) - .filter(r => String(r.place_id) === String(placeId) || (r.assignment_id != null && assignmentIds.includes(r.assignment_id))) + .filter(r => + String(r.place_id) === String(placeId) + || (r.accommodation_place_id != null && String(r.accommodation_place_id) === String(placeId)) + || (r.assignment_id != null && assignmentIds.includes(r.assignment_id))) .map(r => r.id), ) if (bookingIds.size === 0) return own diff --git a/client/tests/unit/utils/placeFiles.test.ts b/client/tests/unit/utils/placeFiles.test.ts index e8f3547a3..79291af96 100644 --- a/client/tests/unit/utils/placeFiles.test.ts +++ b/client/tests/unit/utils/placeFiles.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { filesForPlace } from '../../../src/utils/placeFiles' import type { Reservation, TripFile } from '../../../src/types' -// FE-UTIL-PLACEFILES-001 to FE-UTIL-PLACEFILES-006 +// FE-UTIL-PLACEFILES-001 to FE-UTIL-PLACEFILES-008 const file = (over: Partial): TripFile => ({ id: 1, trip_id: 5, original_name: 'a.pdf', url: '/uploads/a.pdf', @@ -54,4 +54,20 @@ describe('filesForPlace', () => { expect(filesForPlace([gone, bookingFileGone], 7, [booking({ id: 90, place_id: 7 })])).toEqual([]) expect(filesForPlace([file({ id: 10, place_id: 7 })], undefined, [])).toEqual([]) }) + + it('FE-UTIL-PLACEFILES-007: reaches a hotel booking through the stay on the place', () => { + // A hotel booking keeps place_id empty; the place is the stay's (#2363). + const stay = booking({ id: 90, place_id: null, assignment_id: null, accommodation_place_id: 7 }) + const stored = file({ id: 11, reservation_id: 90 }) + const linked = file({ id: 12, linked_reservation_ids: [90] }) + expect(filesForPlace([stored, linked], 7, [stay]).map(f => f.id)).toEqual([11, 12]) + // A stay on another place lends nothing to this one. + expect(filesForPlace([stored], 7, [{ ...stay, accommodation_place_id: 8 }])).toEqual([]) + }) + + it('FE-UTIL-PLACEFILES-008: keeps a file pinned to one of the place\'s stops', () => { + const pinned = file({ id: 13, linked_assignment_ids: [11] }) + expect(filesForPlace([pinned], 7, [], [11]).map(f => f.id)).toEqual([13]) + expect(filesForPlace([pinned], 7, [], [12])).toEqual([]) + }) }) diff --git a/plugin-sdk/src/generated/host-facts.ts b/plugin-sdk/src/generated/host-facts.ts index 05b028044..c6337a994 100644 --- a/plugin-sdk/src/generated/host-facts.ts +++ b/plugin-sdk/src/generated/host-facts.ts @@ -574,6 +574,7 @@ export const PLUGIN_ENTITY_FIELDS = { 'url', 'linked_reservation_ids', 'linked_place_ids', + 'linked_assignment_ids', 'linked_budget_item_ids', ], budgetItem: [ diff --git a/server/src/db/repositories/FileLinks.repository.ts b/server/src/db/repositories/FileLinks.repository.ts index 2a2c99ee5..248d862ea 100644 --- a/server/src/db/repositories/FileLinks.repository.ts +++ b/server/src/db/repositories/FileLinks.repository.ts @@ -25,6 +25,7 @@ export interface FileLinkWithReservationRow extends FileLinkRow { export interface FileLinkTargetRow { file_id: number; reservation_id: number | null; + assignment_id: number | null; place_id: number | null; budget_item_id: number | null; } @@ -64,7 +65,7 @@ export class FileLinksRepository extends TrekRepository { if (file_ids.length === 0) return []; return await this.kysely() .selectFrom('file_links') - .select(['file_id', 'reservation_id', 'place_id', 'budget_item_id']) + .select(['file_id', 'reservation_id', 'assignment_id', 'place_id', 'budget_item_id']) .where('file_id', 'in', file_ids) .execute(); } diff --git a/server/src/nest/files/files.service.ts b/server/src/nest/files/files.service.ts index 348b82c67..74c45a8c1 100644 --- a/server/src/nest/files/files.service.ts +++ b/server/src/nest/files/files.service.ts @@ -349,6 +349,8 @@ export class FilesService { ...formatFile(f), linked_reservation_ids: fileLinks.filter((l) => l.reservation_id).map((l) => l.reservation_id), linked_place_ids: fileLinks.filter((l) => l.place_id).map((l) => l.place_id), + // MCP's link_trip_file and plugins can pin a file to one stop of the plan. + linked_assignment_ids: fileLinks.filter((l) => l.assignment_id).map((l) => l.assignment_id), linked_budget_item_ids: fileLinks.filter((l) => l.budget_item_id).map((l) => l.budget_item_id), }; }); diff --git a/server/src/nest/plugins/protocol/output-contract.ts b/server/src/nest/plugins/protocol/output-contract.ts index d064506af..9e7ce20c3 100644 --- a/server/src/nest/plugins/protocol/output-contract.ts +++ b/server/src/nest/plugins/protocol/output-contract.ts @@ -225,6 +225,7 @@ export const PLUGIN_ENTITY_CONTRACT = { 'url', 'linked_reservation_ids', 'linked_place_ids', + 'linked_assignment_ids', 'linked_budget_item_ids', ], }, diff --git a/server/tests/unit/nest/files.service.test.ts b/server/tests/unit/nest/files.service.test.ts index f3d0250dc..290f104b3 100644 --- a/server/tests/unit/nest/files.service.test.ts +++ b/server/tests/unit/nest/files.service.test.ts @@ -354,6 +354,19 @@ describe('listFiles', () => { const empty = createTrip(testDb, user.id); expect(await svc.listFiles(empty.id, false)).toEqual([]); }); + + it('FILE-SVC-014b: lists the stops a file is pinned to, which MCP and plugins can link', async () => { + const { user, trip } = seedTrip(); + const day = createDay(testDb, trip.id); + const stop = createDayAssignment(testDb, day.id, createPlace(testDb, trip.id).id); + const pinned = await makeFile(trip.id, user.id); + const bare = await makeFile(trip.id, user.id); + await svc.createFileLink(pinned.id, { assignment_id: stop.id }); + + const files = (await svc.listFiles(trip.id, false)) as Record[]; + expect(files.find((f) => f.id === pinned.id).linked_assignment_ids).toEqual([stop.id]); + expect(files.find((f) => f.id === bare.id).linked_assignment_ids).toEqual([]); + }); }); // ── receipts on an expense ──────────────────────────────────────────────────── diff --git a/shared/src/file/file.schema.ts b/shared/src/file/file.schema.ts index a4f7d3ffd..e219336e0 100644 --- a/shared/src/file/file.schema.ts +++ b/shared/src/file/file.schema.ts @@ -76,6 +76,7 @@ export type TripFile = z.infer; export const tripFileListItemSchema = tripFileSchema.extend({ linked_reservation_ids: z.array(z.number()), linked_place_ids: z.array(z.number()), + linked_assignment_ids: z.array(z.number()), linked_budget_item_ids: z.array(z.number()), }); export type TripFileListItem = z.infer;