mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(mcp): add bulk_update_places tool
Apply the same field values to many places in one call instead of one update_place per place — e.g. re-categorising 80 POIs at once. Adds the updatePlacesMany service (one transaction, trip-scoped, partial patch built on updatePlace) and the bulk_update_places MCP tool with the usual demo/access/place_edit guards and a place:updated broadcast per place.
This commit is contained in:
@@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip, db } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import { deletePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace } from '../../services/placeService';
|
||||
import { deletePlacesMany, updatePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace } from '../../services/placeService';
|
||||
import { createAssignment, dayExists } from '../../services/assignmentService';
|
||||
import { onPlaceDeleted } from '../../services/journeyService';
|
||||
import { listCategories } from '../../services/categoryService';
|
||||
@@ -269,4 +269,41 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
||||
return ok({ deleted, count: deleted.length });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'bulk_update_places',
|
||||
{
|
||||
description: 'Update many places in a trip at once, applying the SAME field values to every listed place. Use this for sweeping edits — e.g. re-categorising a batch of POIs (set category_id for 80 places) — in a single call instead of one update_place per place. Only the fields you set are changed; everything else on each place is preserved. Use list_categories for category_id.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
placeIds: z.array(z.number().int().positive()).min(1).max(500).describe('IDs of the places to update (from list_places)'),
|
||||
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories'),
|
||||
price: z.number().optional(),
|
||||
currency: z.string().length(3).optional(),
|
||||
transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(),
|
||||
place_time: z.string().max(50).optional().describe('Scheduled time (e.g. "09:00")'),
|
||||
end_time: z.string().max(50).optional().describe('End time (e.g. "11:00")'),
|
||||
duration_minutes: z.number().int().positive().optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
website: z.string().max(500).optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, placeIds, category_id, price, currency, transport_mode, place_time, end_time, duration_minutes, notes, website, phone, description }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
|
||||
|
||||
const fields = { category_id, price, currency, transport_mode, place_time, end_time, duration_minutes, notes, website, phone, description };
|
||||
if (Object.values(fields).every(v => v === undefined)) {
|
||||
return { content: [{ type: 'text' as const, text: 'Provide at least one field to update.' }], isError: true };
|
||||
}
|
||||
|
||||
const updated = updatePlacesMany(String(tripId), placeIds, fields);
|
||||
for (const place of updated) safeBroadcast(tripId, 'place:updated', { place });
|
||||
return ok({ count: updated.length, updatedIds: updated.map(p => p.id), skipped: placeIds.length - updated.length });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -284,6 +284,33 @@ export function deletePlacesMany(tripId: string, ids: number[]): number[] {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bulk update
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply the same set of fields to many places in a single transaction. Each
|
||||
* place is scoped to the trip and patched via updatePlace, so only the provided
|
||||
* fields change and everything else is preserved. IDs that don't belong to the
|
||||
* trip are skipped. Returns the updated places.
|
||||
*/
|
||||
export function updatePlacesMany(
|
||||
tripId: string,
|
||||
ids: number[],
|
||||
body: Parameters<typeof updatePlace>[2],
|
||||
): NonNullable<ReturnType<typeof updatePlace>>[] {
|
||||
if (ids.length === 0) return [];
|
||||
const updated: NonNullable<ReturnType<typeof updatePlace>>[] = [];
|
||||
const run = db.transaction((list: number[]) => {
|
||||
for (const id of list) {
|
||||
const place = updatePlace(tripId, String(id), body);
|
||||
if (place) updated.push(place);
|
||||
}
|
||||
});
|
||||
run(ids);
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import GPX
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -197,6 +197,64 @@ describe('Tool: update_place', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// bulk_update_places
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: bulk_update_places', () => {
|
||||
it('applies the same field to many places in one call', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const a = createPlace(testDb, trip.id, { name: 'A' });
|
||||
const b = createPlace(testDb, trip.id, { name: 'B' });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'bulk_update_places',
|
||||
arguments: { tripId: trip.id, placeIds: [a.id, b.id], transport_mode: 'walking' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.count).toBe(2);
|
||||
expect([...data.updatedIds].sort()).toEqual([a.id, b.id].sort());
|
||||
expect(data.skipped).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts place:updated for each updated place', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const a = createPlace(testDb, trip.id);
|
||||
const b = createPlace(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
broadcastMock.mockClear();
|
||||
await h.client.callTool({ name: 'bulk_update_places', arguments: { tripId: trip.id, placeIds: [a.id, b.id], notes: 'seen' } });
|
||||
const updates = broadcastMock.mock.calls.filter((c) => c[1] === 'place:updated');
|
||||
expect(updates).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('errors when no update fields are provided', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const a = createPlace(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'bulk_update_places', arguments: { tripId: trip.id, placeIds: [a.id] } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'bulk_update_places', arguments: { tripId: trip.id, placeIds: [place.id], notes: 'x' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_place
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -55,7 +55,7 @@ import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createPlace, createCategory, createTag } from '../../helpers/factories';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, deletePlace, importGpx, importKmlPlaces, importGoogleList, searchPlaceImage } from '../../../src/services/placeService';
|
||||
import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, updatePlacesMany, deletePlace, importGpx, importKmlPlaces, importGoogleList, searchPlaceImage } from '../../../src/services/placeService';
|
||||
|
||||
const GPX_FIXTURE = path.join(__dirname, '../../fixtures/test.gpx');
|
||||
const KML_FIXTURE = path.join(__dirname, '../../fixtures/test.kml');
|
||||
@@ -233,6 +233,49 @@ describe('updatePlace', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── updatePlacesMany ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('updatePlacesMany', () => {
|
||||
it('PLACE-SVC-039 — applies the same fields to many places, preserving the rest', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const a = createPlace(testDb, trip.id, { name: 'A' }) as any;
|
||||
const b = createPlace(testDb, trip.id, { name: 'B' }) as any;
|
||||
const c = createPlace(testDb, trip.id, { name: 'C' }) as any;
|
||||
|
||||
const updated = updatePlacesMany(String(trip.id), [a.id, b.id, c.id], { notes: 'visited', transport_mode: 'walking' });
|
||||
|
||||
expect(updated).toHaveLength(3);
|
||||
for (const p of updated) {
|
||||
expect((p as any).notes).toBe('visited');
|
||||
expect((p as any).transport_mode).toBe('walking');
|
||||
}
|
||||
// Only the provided fields change — names are untouched.
|
||||
expect(updated.map(p => (p as any).name).sort()).toEqual(['A', 'B', 'C']);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-040 — skips ids that are not in the trip and reports the rest', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const other = createTrip(testDb, user.id);
|
||||
const mine = createPlace(testDb, trip.id, { name: 'Mine' }) as any;
|
||||
const foreign = createPlace(testDb, other.id, { name: 'Foreign' }) as any;
|
||||
|
||||
const updated = updatePlacesMany(String(trip.id), [mine.id, foreign.id, 99999], { notes: 'tagged' });
|
||||
|
||||
expect(updated).toHaveLength(1);
|
||||
expect((updated[0] as any).id).toBe(mine.id);
|
||||
// The place from the other trip stays untouched.
|
||||
expect((getPlace(String(other.id), String(foreign.id)) as any).notes).toBeNull();
|
||||
});
|
||||
|
||||
it('PLACE-SVC-041 — returns [] for an empty id list', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
expect(updatePlacesMany(String(trip.id), [], { notes: 'x' })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── deletePlace ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('deletePlace', () => {
|
||||
|
||||
@@ -52,6 +52,7 @@ Requires `places:read` or `places:write` scope.
|
||||
| `list_places` | List places in a trip, optionally filtered by assignment status, category, tag, or search query. |
|
||||
| `create_place` | Add a place with name, coordinates, address, category, notes, website, phone, and optional `google_place_id` / `osm_id`. |
|
||||
| `update_place` | Update any field of an existing place including transport mode, timing, and price. |
|
||||
| `bulk_update_places` | Update many places at once, applying the same field values (e.g. category, price, transport mode) to every listed place in a single call. |
|
||||
| `delete_place` | Remove a place from a trip. Also removes all day assignments. |
|
||||
| `bulk_delete_places` | Delete multiple places by ID. Removes all day assignments. Cannot be undone. |
|
||||
| `import_places_from_url` | Import all places from a publicly shared Google Maps or Naver Maps list URL. |
|
||||
|
||||
Reference in New Issue
Block a user