diff --git a/client/src/components/Admin/PlaceContribPanel.test.tsx b/client/src/components/Admin/PlaceContribPanel.test.tsx index bb5f97007..a29b83ab9 100644 --- a/client/src/components/Admin/PlaceContribPanel.test.tsx +++ b/client/src/components/Admin/PlaceContribPanel.test.tsx @@ -1,4 +1,4 @@ -// FE-ADMIN-CONTRIB-001 to FE-ADMIN-CONTRIB-010 +// FE-ADMIN-CONTRIB-001 to FE-ADMIN-CONTRIB-011 import userEvent from '@testing-library/user-event'; import type { PlaceContribDecisionRequest, PlaceContribState } from '@trek/shared'; import { http, HttpResponse } from 'msw'; @@ -180,4 +180,25 @@ describe('PlaceContribPanel', () => { await within(await screen.findByRole('dialog')).findByText('admin.contribute.prompt.openRegistration') ).toBeInTheDocument(); }); + it('FE-ADMIN-CONTRIB-011: asks again while the key is still being fetched, and the prompt follows', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + let reads = 0; + server.use( + http.get('/api/admin/places-contribute', () => { + reads += 1; + return HttpResponse.json( + reads === 1 ? { ...UNDECIDED, eligible: false, ineligibleReason: 'no_key', showPrompt: false } : UNDECIDED + ); + }) + ); + render(); + expect(await screen.findByText('admin.contribute.ineligible.no_key')).toBeInTheDocument(); + await vi.advanceTimersByTimeAsync(3_000); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(reads).toBe(2); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/client/src/components/Admin/usePlaceContrib.ts b/client/src/components/Admin/usePlaceContrib.ts index 6a029e482..9effb198b 100644 --- a/client/src/components/Admin/usePlaceContrib.ts +++ b/client/src/components/Admin/usePlaceContrib.ts @@ -2,6 +2,9 @@ import type { PlaceContribDecisionRequest, PlaceContribState } from '@trek/share import { useCallback, useEffect, useState } from 'react'; import { placeContribRepo } from '../../repo/placeContribRepo'; +const KEY_RETRIES = 6; +const KEY_RETRY_MS = 3_000; + /** * The admin's decision on sharing place details. Read once when the settings * tab opens, so the prompt can come up without the card being scrolled to; @@ -14,10 +17,25 @@ export function usePlaceContrib() { useEffect(() => { const controller = new AbortController(); - placeContribRepo.state(controller.signal).then(setState, () => { - if (!controller.signal.aborted) setFailed(true); - }); - return () => controller.abort(); + let timer: ReturnType | undefined; + // A fresh installation fetches its key on first use, so the first answer can + // be no_key; asking again a few times lets the prompt appear once it landed. + const read = (tries: number) => { + placeContribRepo.state(controller.signal).then( + (next) => { + setState(next); + if (next.ineligibleReason === 'no_key' && tries > 1) timer = setTimeout(() => read(tries - 1), KEY_RETRY_MS); + }, + () => { + if (!controller.signal.aborted) setFailed(true); + } + ); + }; + read(KEY_RETRIES); + return () => { + controller.abort(); + clearTimeout(timer); + }; }, []); const decide = useCallback(async (body: PlaceContribDecisionRequest): Promise => { diff --git a/server/src/nest/booking-import/booking-import.service.ts b/server/src/nest/booking-import/booking-import.service.ts index 307793811..d8864eb57 100644 --- a/server/src/nest/booking-import/booking-import.service.ts +++ b/server/src/nest/booking-import/booking-import.service.ts @@ -12,6 +12,7 @@ import { imageMimeType } from '../llm-parse/image-input'; import { LlmParseService } from '../llm-parse/llm-parse.service'; import { MapsService } from '../maps/maps.service'; import { PermissionsService } from '../permissions/permissions.service'; +import { PlaceOriginsService } from '../places/place-origins.service'; import { PlacesService } from '../places/places.service'; import { RealtimeService } from '../realtime/realtime.service'; import { ReservationsService } from '../reservations/reservations.service'; @@ -45,6 +46,7 @@ export class BookingImportService { private readonly maps: MapsService, private readonly places: PlacesService, private readonly uow: UnitOfWork, + private readonly origins: PlaceOriginsService, ) {} /** @@ -313,6 +315,7 @@ export class BookingImportService { const created: Reservation[] = []; const confirmGeoCache = new Map(); + const venues: number[] = []; for (const item of items) { try { const { _venue, _accommodation, source: _src, ...reservationData } = item; @@ -417,6 +420,7 @@ export class BookingImportService { }, ); + if (place) venues.push(place.id); if (place) this.realtime.broadcast(tripId, 'place:created', { place }, socketId); this.realtime.broadcast(tripId, 'reservation:created', { reservation }, socketId); if (accommodationCreated) { @@ -434,6 +438,8 @@ export class BookingImportService { } } + // The venues' websites and phones come from the bookings, not from a person: an import, never shared. + if (venues.length > 0) await this.origins.recordImported(venues); return { created }; } } diff --git a/server/src/nest/collections/collections.service.ts b/server/src/nest/collections/collections.service.ts index 42bc1412b..03e078af6 100644 --- a/server/src/nest/collections/collections.service.ts +++ b/server/src/nest/collections/collections.service.ts @@ -7,6 +7,7 @@ import { PermissionsService } from '../permissions/permissions.service'; import { RealtimeService } from '../realtime/realtime.service'; import { isUploadedPlaceImage } from '../places/place-image'; import { PlacesService } from '../places/places.service'; +import { PlaceOriginsService } from '../places/place-origins.service'; import { StorageService } from '../storage/storage.service'; import { Collections } from '../../db/entities/Collections.entity'; import { CollectionsRepository, type CollectionPlaceRow } from '../../db/repositories/Collections.repository'; @@ -130,6 +131,7 @@ export class CollectionsService { @InjectRepository(Users) private readonly users: UsersRepository, private readonly placeImport: PlaceImportService, private readonly places: PlacesService, // the journey hook of every place copyToTrip adds + private readonly origins: PlaceOriginsService, ) {} /** @@ -1186,6 +1188,8 @@ export class CollectionsService { created.push({ id: newPlaceId, source_id: s.id }); } }); + // A copied value is the collection's, not typed in this write: recorded as an import, never shared. + if (created.length > 0) await this.origins.recordImported(created.map(({ id }) => id)); const rows = await Promise.all(created.map(({ id }) => this.tripPlaces.findWithTagsAndRatings(id))); // reads only for (const [i, place] of rows.entries()) { if (place) events.emit('place:created', { place }); // as the place routes send it diff --git a/server/src/nest/places/places.module.ts b/server/src/nest/places/places.module.ts index 7ca8e958f..db125ccc7 100644 --- a/server/src/nest/places/places.module.ts +++ b/server/src/nest/places/places.module.ts @@ -106,6 +106,7 @@ import { MulterModule } from '@nestjs/platform-express'; ], controllers: [PlacesController], providers: [PlacesService, PlacesMcp, PlacesRpc, PlaceOriginsService], - exports: [PlacesService], + // The origin bookkeeping too: the copy from a collection and the booking import write places as well. + exports: [PlacesService, PlaceOriginsService], }) export class PlacesModule {} diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index 8f3fef3fe..2acbac0db 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -491,6 +491,7 @@ export async function createPluginRpcHostParts( usersRepo, buildPlaceImportService(), places, + placeOriginsStub(), ); // After accommodations: a hotel booking writes the stay's day stop through it. const reservations = new ReservationsService( diff --git a/server/tests/unit/nest/booking-import/booking-import.service.test.ts b/server/tests/unit/nest/booking-import/booking-import.service.test.ts index 232a995bf..b8055dee5 100644 --- a/server/tests/unit/nest/booking-import/booking-import.service.test.ts +++ b/server/tests/unit/nest/booking-import/booking-import.service.test.ts @@ -16,6 +16,8 @@ vi.mock('../../../../src/db/database', () => ({ getPlaceWithTags: () => null, })); const permissionsStub = { checkPermission: vi.fn(() => true) }; +// The origin bookkeeping of the venues a confirm creates; its own suite is place-origins.service.test.ts. +const origins = { recordImported: vi.fn(async () => undefined) }; // Runs the callback in place; the tests that care about the boundary wrap it to record it. const uowStub = { transactional: (fn: () => Promise) => fn() }; @@ -58,6 +60,7 @@ function make(opts: { kit?: boolean; ai?: boolean; extract?: any; parse?: any }) maps as never, places as never, uowStub as never, + origins as never, ), extractor, llmParse, @@ -264,6 +267,7 @@ describe('BookingImportService.confirm venue website (#2483)', () => { { geocodeQuery: vi.fn() } as never, places as never, uowStub as never, + origins as never, ); const item = (website?: string) => ({ type: 'restaurant', @@ -284,6 +288,8 @@ describe('BookingImportService.confirm venue website (#2483)', () => { undefined, undefined, ]); + // The venues' details came from the booking: recorded once, as an import, after the loop. + expect(origins.recordImported).toHaveBeenLastCalledWith([7, 7, 7]); }); }); @@ -355,6 +361,7 @@ describe('BookingImportService.confirm writes the booking with its cost', () => { geocodeQuery: vi.fn() } as never, places as never, uow as never, + origins as never, ); return { svc, reservations, budget, places, realtime, order, costEvents }; } diff --git a/server/tests/unit/nest/collections.service.test.ts b/server/tests/unit/nest/collections.service.test.ts index 34ff57c63..4de725bf6 100644 --- a/server/tests/unit/nest/collections.service.test.ts +++ b/server/tests/unit/nest/collections.service.test.ts @@ -120,6 +120,9 @@ const removeIfUnreferenced = (id: string) => photoCache.removeIfUnreferenced(id) // (part B) finished the conversion: `DatabaseService` is GONE from the constructor // entirely (savePlace onward was its last use) — the trip/place/tag/user // repositories below are Task 2's own additions. +// The origin bookkeeping after a copy; its own suite is place-origins.service.test.ts. +const originsForSpy = { recordImported: vi.fn(async () => undefined) }; + beforeAll(async () => { tripsRepoForSpy = await createTestTripsRepo(testDb); permissionsForSpy = new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)); @@ -134,6 +137,7 @@ beforeAll(async () => { await createTestTagsRepo(testDb), await createTestUsersRepo(testDb), buildPlaceImportService(), placesHooks as never, + originsForSpy as never, ); photoCache = new PlacePhotoCacheService( makeStorageFixture('photos/google/').storage, @@ -504,6 +508,8 @@ describe('copyToTrip', () => { const res = await svc.copyToTrip(u.id, { trip_id: trip.id, place_ids: [p1.id, p2.id] }, copyEvents(trip.id)); expect(res.copied).toBe(1); expect(res.skipped.map((s) => s.name)).toEqual(['Pantheon']); + // What was copied is the collection's, so it is recorded as an import, never as typed. + expect(originsForSpy.recordImported).toHaveBeenLastCalledWith(res.created.map((c) => c.id)); const inserted = (await findRow(await orm(), Places, { trip: trip.id, name: 'Colosseum' }))!; expect(inserted.reservation_status).toBe('none'); // itinerary column took the table default