diff --git a/server/src/nest/vacay/vacay.service.ts b/server/src/nest/vacay/vacay.service.ts index 128015624..b27ca05f6 100644 --- a/server/src/nest/vacay/vacay.service.ts +++ b/server/src/nest/vacay/vacay.service.ts @@ -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 = ? diff --git a/server/tests/unit/nest/vacay.service.test.ts b/server/tests/unit/nest/vacay.service.test.ts index fc8b4019f..6c326d0e9 100644 --- a/server/tests/unit/nest/vacay.service.test.ts +++ b/server/tests/unit/nest/vacay.service.test.ts @@ -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 ──────────────────────────────────────────────────────────────────