mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
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:
@@ -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'. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user