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