feat(collections): bulk-add selected trip places to a list (#1081)

Add a "Save to collection" action to the trip place list's selection bar (next to
bulk category + delete): it opens a list picker and copies every selected place
into the chosen list in one request, instead of one-by-one from each place.

- Server: saveFromTripPlaces (one access check + one WS notify), POST
  places/from-trip-many; dedups by name/coords, skips missing ids, honours force.
- Client: saveFromTripMany api + SaveTripPlacesToListModal; the selection-bar
  button is gated on the collections addon being enabled.
- Copy count / skipped-duplicates toast; strings in all 22 locales.
- Service + controller tests for the bulk path.
This commit is contained in:
Maurice
2026-07-01 17:22:50 +02:00
committed by Maurice
parent 1e0feb93dd
commit 6cdcc82d9c
31 changed files with 305 additions and 2 deletions
@@ -32,6 +32,7 @@ import {
collectionUpdateRequestSchema,
collectionSavePlaceRequestSchema,
collectionSaveFromTripRequestSchema,
collectionSaveFromTripManyRequestSchema,
collectionPlaceUpdateRequestSchema,
collectionSetStatusRequestSchema,
collectionCopyToTripRequestSchema,
@@ -44,6 +45,7 @@ import {
type CollectionUpdateRequest,
type CollectionSavePlaceRequest,
type CollectionSaveFromTripRequest,
type CollectionSaveFromTripManyRequest,
type CollectionPlaceUpdateRequest,
type CollectionSetStatusRequest,
type CollectionCopyToTripRequest,
@@ -122,6 +124,12 @@ export class CollectionsController {
return this.collections.saveFromTripPlace(user.id, body.collection_id, body.source_trip_id, body.source_place_id, body.force);
}
@Post('places/from-trip-many')
@HttpCode(200)
saveFromTripMany(@CurrentUser() user: User, @Body(new ZodValidationPipe(collectionSaveFromTripManyRequestSchema)) body: CollectionSaveFromTripManyRequest) {
return this.collections.saveFromTripPlaces(user.id, body.collection_id, body.source_trip_id, body.source_place_ids, body.force);
}
@Post('places/delete-many')
@HttpCode(200)
deleteMany(@CurrentUser() user: User, @Body('ids') ids: unknown, @Headers('x-socket-id') socketId?: string) {
@@ -28,6 +28,9 @@ export class CollectionsService {
saveFromTripPlace(userId: number, collectionId: number, tripId: number, placeId: number, force?: boolean) {
return svc.saveFromTripPlace(userId, collectionId, tripId, placeId, force);
}
saveFromTripPlaces(userId: number, collectionId: number, tripId: number, placeIds: number[], force?: boolean) {
return svc.saveFromTripPlaces(userId, collectionId, tripId, placeIds, force);
}
updatePlace(userId: number, placeId: number, body: CollectionPlaceUpdateRequest, socketId?: string) { return svc.updatePlace(userId, placeId, body, socketId); }
setStatus(userId: number, placeId: number, status: CollectionStatus, socketId?: string) { return svc.setStatus(userId, placeId, status, socketId); }
deletePlace(userId: number, placeId: number, socketId?: string) { return svc.deletePlace(userId, placeId, socketId); }
+43
View File
@@ -453,6 +453,49 @@ export function saveFromTripPlace(
});
}
/** 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'. */
export function saveFromTripPlaces(
userId: number, collectionId: number, tripId: number, placeIds: number[], force?: boolean,
): { copied: number; skipped: { id: number; name: string }[] } {
assertCanEdit(userId, collectionId);
if (!canAccessTrip(tripId, userId)) httpError(404, 'Trip not found');
const ownerId = ownerOf(collectionId);
const insert = db.prepare(`
INSERT INTO collection_places (
collection_id, owner_id, saved_by, name, description, lat, lng, address,
category_id, price, currency, notes, image_url, google_place_id, google_ftid,
osm_id, website, phone, status, source_trip_id, source_place_id, links
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'idea', ?, ?, NULL)
`);
let copied = 0;
const skipped: { id: number; name: string }[] = [];
for (const placeId of placeIds) {
const p = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Record<string, unknown> | undefined;
if (!p) continue;
const name = p.name as string;
const lat = (p.lat as number | null) ?? null;
const lng = (p.lng as number | null) ?? null;
if (!force && findDuplicateCollectionPlace(collectionId, { name, lat, lng })) {
skipped.push({ id: placeId, name });
continue;
}
insert.run(
collectionId, ownerId, userId,
name, (p.description as string | null) ?? null, lat, lng, (p.address as string | null) ?? null,
(p.category_id as number | null) ?? null, (p.price as number | null) ?? null, (p.currency as string | null) ?? null, (p.notes as string | null) ?? null,
(p.image_url as string | null) ?? null, (p.google_place_id as string | null) ?? null, (p.google_ftid as string | null) ?? null,
(p.osm_id as string | null) ?? null, (p.website as string | null) ?? null, (p.phone as string | null) ?? null,
tripId, placeId,
);
copied++;
}
if (copied > 0) notifyCollectionUsers(collectionId, undefined, 'collections:updated');
return { copied, skipped };
}
export function updatePlace(userId: number, placeId: number, body: import('@trek/shared').CollectionPlaceUpdateRequest, socketId?: string): CollectionPlace {
const currentCollection = collectionIdOfPlace(placeId);
assertCanEdit(userId, currentCollection);