fix(assistant): taking a proposal needs place rights, and a card's choices wait while a change runs

Plan this checks place_edit like the tour routes, the include choices of a
run or group stay disabled while the chat applies a change, and a follow-up
can say when it applies, so Confirm the pending bookings shows only when the
card holds a pending booking of this trip.
This commit is contained in:
Maurice
2026-10-22 22:46:35 +00:00
parent cf8cf9058d
commit 3eded5f5be
13 changed files with 195 additions and 39 deletions
@@ -129,9 +129,13 @@ export class AssistantController {
return this.foundPlaces.add(user.id, Number(tripId), idParam(threadId), body);
}
/** "Plan this" on a hike or a city walk an answer proposed: a change card that creates it, no model turn. */
/**
* "Plan this" on a hike or a city walk an answer proposed: a change card that creates it, no model turn.
* It holds a new tour, so it asks for the right the tour routes ask for.
*/
@Post('threads/:threadId/proposals')
@HttpCode(200)
@RequirePermission('place_edit')
@ResponseContract(assistantAddFoundPlaceResponseSchema)
takeProposal(
@CurrentUser() user: User,
+35 -2
View File
@@ -11,13 +11,16 @@
* drawing asked for by the server and kept from the model; "Plan this" holds
* the hike as a change card from the stored proposal, refused for an unknown
* proposal and while it waits; applying it runs create_hike without a model
* turn and names the tour in the receipt and on its card.
* turn and names the tour in the receipt and on its card. Taking a proposal
* asks for the right to edit places, like the tour routes: 403 without it,
* 404 outside the trip.
*/
import { buildApp } from '../../src/bootstrap';
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
import { HikePlanService } from '../../src/nest/tours/hike-plan.service';
import { makePlace } from '../helpers/factories/places';
import { setAddonEnabled } from '../helpers/factories/settings';
import { makeTrip } from '../helpers/factories/trips';
import { addTripMember, makeTrip } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { resetRateLimits } from '../helpers/test-db';
import { sessionCookie } from './harness';
@@ -319,4 +322,34 @@ describe('a proposed hike in the chat', () => {
const [receipt] = ofType(decided.chunks, 'data-trek-card');
expect(receipt?.data).toMatchObject({ toolCallId, card: { kind: 'tourRoute', placeId: t.placeId } });
});
it('ASTE2E-TOUR-003: "Plan this" asks for the right to edit places: 403 and nothing held without it, 404 outside the trip', async () => {
const t = await travellerWithPlanner();
const { user: member } = await makeUser(orm);
await addTripMember(orm, t.tripId, member.id);
const { threadId } = await proposeTurn({ userId: member.id, base: t.base });
const path = `${t.base}/threads/${threadId}/proposals`;
const permissions = vi.spyOn(nestApp.get(PermissionsService), 'checkPermission');
permissions.mockImplementation((action: string) => Promise.resolve(action !== 'place_edit'));
try {
const refused = await post(path, member.id, { ...CTX, toolCallId: 'call_hike' });
expect([refused.status, refused.json]).toEqual([403, { error: 'No permission' }]);
expect(permissions).toHaveBeenCalledWith('place_edit', expect.any(String), t.userId, member.id, true);
} finally {
permissions.mockRestore();
}
const shown = await request(nestApp.getHttpServer())
.get(`${t.base}/threads/${threadId}`)
.set('Cookie', sessionCookie(member.id));
const parts = (shown.body.messages as Array<{ parts: AssistantMessagePart[] }>).flatMap((m) => m.parts);
expect(parts.some((part) => part.type === 'tool-call' && part.toolName === 'take_proposal')).toBe(false);
const { user: stranger } = await makeUser(orm);
const outside = await post(path, stranger.id, { ...CTX, toolCallId: 'call_hike' });
expect([outside.status, outside.json]).toEqual([404, { error: 'Trip not found' }]);
// With the right, the same member takes the same proposal.
expect((await post(path, member.id, { ...CTX, toolCallId: 'call_hike' })).status).toBe(200);
});
});
@@ -150,10 +150,11 @@ describe('AssistantController', () => {
expect(() => controller.addFoundPlace(user, '5', 'x', body)).toThrow(DomainError);
});
it('ASTCTL-008: sharing needs the right to write in the trip chat; nothing else here asks for a right', () => {
it('ASTCTL-008: sharing asks for the trip chat right, "Plan this" for editing places; nothing else asks', () => {
const handlers = AssistantController.prototype as unknown as Record<string, object>;
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handlers.share)).toBe('collab_edit');
for (const name of ['chat', 'decide', 'undo', 'addFoundPlace', 'takeProposal']) {
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handlers.takeProposal)).toBe('place_edit');
for (const name of ['chat', 'decide', 'undo', 'addFoundPlace']) {
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handlers[name]), name).toBeUndefined();
}
});