fix: calendar attendee RSVP correctness — server-derived status and hide declined invites (#27007)

* fix: let only the attendee set their own calendar RSVP status

set_attendees took each attendee's status from the caller-supplied value, so an
event organiser could set another user's RSVP (for example to 'accepted') on
create or update. RSVP is meant to be self-service: the /events/{id}/rsvp
endpoint already scopes status changes to the calling user.

Derive attendee status server-side instead of from the request. An existing
attendee keeps the status they set via RSVP and a newly added attendee starts
'pending'; any caller-supplied status is ignored. Event edits no longer reset
attendees' existing responses.

Co-authored-by: legobattman <302282032+legobattman@users.noreply.github.com>

* fix: hide declined calendar invites from the attendee view

`get_events_by_range` surfaced every event where the user is an attendee regardless of their RSVP status, so declining an invite left it in the calendar with no way to remove it. Exclude `declined` attendee rows from the attendee branch, so a decline now removes the event from the user's own view while pending, accepted and tentative invitations still surface.

Co-Authored-By: legobattman <302282032+legobattman@users.noreply.github.com>

---------

Co-authored-by: legobattman <302282032+legobattman@users.noreply.github.com>
This commit is contained in:
Classic298 2026-07-24 07:01:16 +02:00 committed by GitHub
parent 48f78ca58d
commit 9b635d8f3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -500,9 +500,12 @@ class CalendarEventTable:
# Filter to requested calendars only
accessible_cal_ids = [c for c in accessible_cal_ids if c in calendar_ids]
# Also get event IDs where user is an attendee
# Also get event IDs where the user is an attendee, excluding invites they declined
attendee_event_ids_result = await db.execute(
select(CalendarEventAttendee.event_id).filter(CalendarEventAttendee.user_id == user_id)
select(CalendarEventAttendee.event_id).filter(
CalendarEventAttendee.user_id == user_id,
CalendarEventAttendee.status != 'declined',
)
)
attendee_event_ids = [r[0] for r in attendee_event_ids_result.all()]
@ -764,22 +767,34 @@ class CalendarEventAttendeeTable:
async def set_attendees(
self, event_id: str, attendees: list[dict], db: Optional[AsyncSession] = None
) -> list[CalendarEventAttendeeModel]:
"""Replace all attendees for an event.
"""Replace all attendees for an event ({user_id, meta?} per dict).
Each dict in attendees: {user_id: str, status?: str, meta?: dict}
RSVP status is the attendee's alone to set (via update_rsvp): an existing
attendee keeps their status, a newly added one starts 'pending'. A
caller-supplied status is ignored so an organiser cannot set it for others.
"""
async with get_async_db_context(db) as db:
existing_status = {
row.user_id: row.status
for row in (
await db.execute(
select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id)
)
).scalars()
}
# Remove existing
await db.execute(delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id))
now = int(time.time_ns())
models = []
for att in attendees:
user_id = att['user_id']
row = CalendarEventAttendee(
id=str(uuid4()),
event_id=event_id,
user_id=att['user_id'],
status=att.get('status', 'pending'),
user_id=user_id,
status=existing_status.get(user_id, 'pending'),
meta=att.get('meta'),
created_at=now,
updated_at=now,