feat(collections): import a whole trip's places into a list (#1903)

* feat(collections): preview which of a trip's places a list already holds

The bulk import endpoint has been there since the select-mode copy, but nothing
calls it and it only reports duplicates after the fact. A dialog needs the verdict
up front, so /:id/importable/:tripId returns the trip's places with the duplicate
check the import itself applies, plus whether any day holds the place.

* feat(collections): import a whole trip's places into a list

Two steps: pick a trip, then tick its places. Places already on the list are
greyed out and unpickable, and the ones no day holds start selected — those are
the plans a trip left behind, which is the reason to import at all. Sorted so a
mostly-saved trip reads as the two that are left rather than a wall of grey.

* feat(collections): reach the trip import from the filter row and an empty list

* i18n(collections): translate the trip import into all locales

* fix(collections): stop the label dialog's buttons wrapping mid-word

Three actions in a max-w-sm footer wrapped into two lines each. The assign
button also painted white text on the accent, which disappears on a light one.

* docs(collections): document the trip import
This commit is contained in:
Maurice
2026-08-12 11:54:36 +02:00
committed by GitHub
parent b0fd73681b
commit b842d74a5d
37 changed files with 1286 additions and 11 deletions
@@ -356,6 +356,14 @@ export class CollectionsController {
return this.collections.setCollectionCover(user.id, Number(id), coverUrl, socketId);
}
/** Preview for the bulk trip import: the trip's places plus, per place, the same
* duplicate verdict the import itself applies. Read-only, so the dialog can grey out
* what would be skipped instead of reporting it afterwards. */
@Get(':id/importable/:tripId')
importable(@CurrentUser() user: User, @Param('id') id: string, @Param('tripId') tripId: string) {
return this.collections.importablePlaces(user.id, Number(id), Number(tripId));
}
@Get(':id')
get(@CurrentUser() user: User, @Param('id') id: string) {
return this.collections.getCollection(user.id, Number(id));
@@ -21,6 +21,7 @@ import type {
CollectionPlaceUpdateRequest,
CollectionStatus,
CollectionLabel,
CollectionImportablesResponse,
} from '@trek/shared';
import { NotificationsService } from '../notifications/notifications.service';
@@ -567,6 +568,46 @@ export class CollectionsService {
}, socketId);
}
/** The trip's places as offered to the bulk import, each already carrying the verdict
* saveFromTripPlaces would reach for it. Reusing findDuplicateCollectionPlace is the
* whole point: a row the dialog shows as new can never come back as `skipped`, and a
* greyed-out one is exactly a row the import would refuse. Re-deriving that rule in the
* client would be a second copy of it, free to drift.
*
* `scheduled` is false for places no day holds. Those are what a trip leaves behind and
* what this import exists for, so the dialog pre-selects them. */
importablePlaces(userId: number, collectionId: number, tripId: number): CollectionImportablesResponse {
this.assertCanEdit(userId, collectionId);
if (!this.db.canAccessTrip(tripId, userId)) httpError(404, 'Trip not found');
// One row per place: a place can sit on several days, so the day columns resolve to the
// earliest one rather than multiplying the place out across its assignments.
const rows = this.db.all<{
place_id: number; name: string; address: string | null; lat: number | null; lng: number | null;
category_id: number | null; image_url: string | null; day_number: number | null; date: string | null;
}>(`
SELECT p.id AS place_id, p.name, p.address, p.lat, p.lng, p.category_id, p.image_url,
(SELECT MIN(d.day_number) FROM day_assignments da
JOIN days d ON d.id = da.day_id
WHERE da.place_id = p.id AND d.trip_id = p.trip_id) AS day_number,
(SELECT d.date FROM day_assignments da
JOIN days d ON d.id = da.day_id
WHERE da.place_id = p.id AND d.trip_id = p.trip_id
ORDER BY d.day_number ASC LIMIT 1) AS date
FROM places p
WHERE p.trip_id = ?
ORDER BY p.name COLLATE NOCASE
`, tripId);
return {
places: rows.map(r => ({
...r,
already_in_list: this.findDuplicateCollectionPlace(collectionId, { name: r.name, lat: r.lat, lng: r.lng }) != null,
scheduled: r.day_number != null,
})),
};
}
/** Bulk copy of several trip places into a list in one shot — one access check,
* one WS notify (vs saving each place individually). Mirrors saveFromTripPlace's
* field mapping + dedup; skips duplicates unless force. Status starts at 'idea'. */
+52 -1
View File
@@ -37,7 +37,7 @@ vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { createUser, createTrip, createCategory } from '../helpers/factories';
import { createUser, createTrip, createCategory, createDay, createPlace, createDayAssignment } from '../helpers/factories';
import { CollectionsModule } from '../../src/nest/collections/collections.module';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
@@ -248,6 +248,57 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () =>
expect(res.status).toBe(404);
});
// ── import preview ───────────────────────────────────────────────────────
it('COLLECTIONS-E2E-070: the preview marks scheduled places and carries the day they sit on', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Rome ideas' })).body;
const trip = createTrip(db as never, ownerId);
const day = createDay(db as never, trip.id, { day_number: 2, date: '2026-05-02' });
const planned = createPlace(db as never, trip.id, { name: 'Colosseum', lat: 41.89, lng: 12.49 });
createPlace(db as never, trip.id, { name: 'Testaccio Market', lat: 41.87, lng: 12.47 });
createDayAssignment(db as never, day.id, planned.id);
const res = await request(server).get(`/api/addons/collections/${col.id}/importable/${trip.id}`).set('Cookie', sessionCookie(ownerId));
expect(res.status).toBe(200);
const byName = Object.fromEntries(res.body.places.map((p: { name: string }) => [p.name, p]));
expect(byName['Colosseum'].scheduled).toBe(true);
expect(byName['Colosseum'].day_number).toBe(2);
expect(byName['Colosseum'].date).toBe('2026-05-02');
// The one no day holds — what the import is for, so the dialog can pre-select it.
expect(byName['Testaccio Market'].scheduled).toBe(false);
expect(byName['Testaccio Market'].day_number).toBeNull();
});
it('COLLECTIONS-E2E-071: the preview verdict is the one the import then acts on', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Paris ideas' })).body;
const trip = createTrip(db as never, ownerId);
const a = createPlace(db as never, trip.id, { name: 'Musée Rodin', lat: 48.85, lng: 2.31 });
const b = createPlace(db as never, trip.id, { name: 'Rue Cler', lat: 48.85, lng: 2.30 });
// Save one of them first, so the list already holds it.
await request(server).post('/api/addons/collections/places')
.set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, name: 'Musée Rodin', lat: 48.85, lng: 2.31 });
const preview = await request(server).get(`/api/addons/collections/${col.id}/importable/${trip.id}`).set('Cookie', sessionCookie(ownerId));
const flagged = preview.body.places.filter((p: { already_in_list: boolean }) => p.already_in_list).map((p: { name: string }) => p.name);
expect(flagged).toEqual(['Musée Rodin']);
// Importing both must skip exactly what the preview greyed out, and copy the rest.
const imported = await request(server).post('/api/addons/collections/places/from-trip-many')
.set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, source_trip_id: trip.id, source_place_ids: [a.id, b.id] });
expect(imported.status).toBe(200);
expect(imported.body.copied).toBe(1);
expect(imported.body.skipped.map((s: { name: string }) => s.name)).toEqual(['Musée Rodin']);
});
it('COLLECTIONS-E2E-072: a trip the caller cannot see is a 404, and so is a foreign list', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Private' })).body;
const foreignTrip = createTrip(db as never, otherId);
expect((await request(server).get(`/api/addons/collections/${col.id}/importable/${foreignTrip.id}`).set('Cookie', sessionCookie(ownerId))).status).toBe(404);
expect((await request(server).get(`/api/addons/collections/${col.id}/importable/${tripId}`).set('Cookie', sessionCookie(otherId))).status).toBe(404);
});
// ── delete ───────────────────────────────────────────────────────────────
it('COLLECTIONS-E2E-050: owner deletes; non-owner member cannot (403)', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Doomed' })).body;