fix(vacay): keep guest accounts out of the invite pickers

Guests are trip-scoped accounts. Trip Members excludes them from its picker and
refuses them on the write path, and Collections does the same, but Vacay's two
directories did not: a guest stayed selectable in a plan's Invite User dialog
and in the shared-calendar picker, including after being removed from the trip
it was created for.

The queries are server-side, so the filter belongs there rather than in the
client, which never received the flag to filter on. Both write paths get it too:
the picker only hands out a list, the id comes back from the client, and the
Vacay MCP tools reach the same two service methods.

Fixes #2112
This commit is contained in:
Maurice
2026-08-28 19:37:37 +02:00
committed by Maurice
parent 63a57b9269
commit 71ff191bb4
2 changed files with 42 additions and 2 deletions
+6 -2
View File
@@ -639,7 +639,9 @@ export class VacayService {
sendInvite(planId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number): { error?: string; status?: number } {
if (targetUserId === inviterId) return { error: 'Cannot invite yourself', status: 400 };
const targetUser = this.db.get('SELECT id, username FROM users WHERE id = ?', targetUserId);
// The picker no longer offers guests, but the id arrives from the client, so the
// write path has to refuse them too rather than trust the list it handed out.
const targetUser = this.db.get('SELECT id, username FROM users WHERE id = ? AND COALESCE(is_guest, 0) = 0', targetUserId);
if (!targetUser) return { error: 'User not found', status: 404 };
const existing = this.db.get<{ id: number; status: string }>('SELECT id, status FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?', planId, targetUserId);
@@ -785,6 +787,7 @@ export class VacayService {
return this.db.all(`
SELECT u.id, u.username, u.email FROM users u
WHERE u.id != ?
AND COALESCE(u.is_guest, 0) = 0
AND u.id NOT IN (SELECT user_id FROM vacay_plan_members WHERE plan_id = ?)
AND u.id NOT IN (SELECT user_id FROM vacay_plan_members WHERE status = 'accepted')
AND u.id NOT IN (SELECT owner_id FROM vacay_plans WHERE id IN (
@@ -877,7 +880,7 @@ export class VacayService {
shareCalendar(ownerId: number, ownerEmail: string, targetUserId: number, socketId?: string): { error?: string; status?: number } {
if (targetUserId === ownerId) return { error: 'Cannot share with yourself', status: 400 };
const targetUser = this.db.get('SELECT id FROM users WHERE id = ?', targetUserId);
const targetUser = this.db.get('SELECT id FROM users WHERE id = ? AND COALESCE(is_guest, 0) = 0', targetUserId);
if (!targetUser) return { error: 'User not found', status: 404 };
const existing = this.db.get('SELECT id FROM vacay_shares WHERE owner_id = ? AND user_id = ?', ownerId, targetUserId);
@@ -931,6 +934,7 @@ export class VacayService {
return this.db.all(`
SELECT u.id, u.username FROM users u
WHERE u.id != ?
AND COALESCE(u.is_guest, 0) = 0
AND u.id NOT IN (SELECT user_id FROM vacay_shares WHERE owner_id = ?)
AND u.id NOT IN (
SELECT owner_id FROM vacay_plans WHERE id = ?
@@ -779,6 +779,42 @@ describe('getAvailableUsers', () => {
expect(available.map(u => u.id)).not.toContain(alreadyFused.id);
});
// #2112 — guests are trip-scoped accounts, and every other directory in the app
// already leaves them out. Vacay's two pickers did not, so a guest stayed
// selectable here even after being removed from the trip it was created for.
it('VACAY-SVC-073: guest accounts are not offered in the plan invite picker', () => {
const { user: owner, plan } = setupUserWithPlan();
const { user: guest } = createUser(testDb);
testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id);
const available = svc.getAvailableUsers(owner.id, plan.id) as { id: number }[];
expect(available.map(u => u.id)).not.toContain(guest.id);
});
it('VACAY-SVC-074: guest accounts are not offered in the shared-calendar picker', () => {
const { user: owner } = setupUserWithPlan();
const { user: guest } = createUser(testDb);
testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id);
const available = svc.getShareAvailableUsers(owner.id) as { id: number }[];
expect(available.map(u => u.id)).not.toContain(guest.id);
});
it('VACAY-SVC-075: a guest id sent straight to the write paths is refused', () => {
const { user: owner, plan } = setupUserWithPlan();
const { user: guest } = createUser(testDb);
testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id);
// The picker is only a list. The id comes back from the client, and the MCP
// tools reach the same two methods, so refusing has to happen here.
const invited = svc.sendInvite(plan.id, owner.id, 'owner', 'owner@example.test', guest.id);
expect(invited.error).toBe('User not found');
const shared = svc.shareCalendar(owner.id, 'owner@example.test', guest.id);
expect(shared.error).toBe('User not found');
});
});
// ── getStats ──────────────────────────────────────────────────────────────────