place-contrib: record origins for collection copies and booking imports

A place copied from a collection and a venue a booking import creates now
record their details as an import, so a later edit compares against what was
really written. Dawarich and the background enrichment write none of the
shareable fields, so they need nothing.

The admin panel asks again for a few seconds while a fresh installation is
still fetching its key, so the prompt appears without a reload.
This commit is contained in:
Maurice
2026-10-30 19:55:55 +01:00
parent 3f2dec6c47
commit 71cd6c2de4
8 changed files with 70 additions and 6 deletions
@@ -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(<PlaceContribPanel />);
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();
}
});
});
+22 -4
View File
@@ -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<typeof setTimeout> | 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<PlaceContribState> => {
@@ -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<string, { lat: number; lng: number } | null>();
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 };
}
}
@@ -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
+2 -1
View File
@@ -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 {}
+1
View File
@@ -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(
@@ -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: <T>(fn: () => Promise<T>) => 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 };
}
@@ -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