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