From 1276b7ab104c8ea89c434f48d99f0a27d396cd51 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Mon, 23 Feb 2026 11:37:03 -0600 Subject: [PATCH 01/48] Update mailer attachments and ics file handling --- backend/src/appointment/controller/mailer.py | 32 +++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/backend/src/appointment/controller/mailer.py b/backend/src/appointment/controller/mailer.py index 6d5be4e4d..00368eae9 100644 --- a/backend/src/appointment/controller/mailer.py +++ b/backend/src/appointment/controller/mailer.py @@ -112,17 +112,16 @@ def build(self): message.set_content(self.text()) message.add_alternative(self.html(), subtype='html') - # add attachment(s) as multimedia parts + # Separate calendar and non-calendar attachments so the ICS can be + # added as a multipart/alternative part instead of a plain attachment. + + # Gmail only renders its calendar invite UI when text/calendar lives + # inside the alternative block alongside text/plain and text/html. + calendar_attachment = None for a in self._attachments(): - # Handle ics files differently than inline images if a.mime_main == 'text' and a.mime_sub == 'calendar': - message.add_attachment(a.data, maintype=a.mime_main, subtype=a.mime_sub, filename=a.filename) - # Fix the header of the attachment - message.get_payload()[-1].replace_header( - 'Content-Type', f'{a.mime_main}/{a.mime_sub}; charset="UTF-8"; method={self.method}' - ) + calendar_attachment = a else: - # Attach it to the html payload message.get_payload()[1].add_related( a.data, a.mime_main, @@ -130,6 +129,23 @@ def build(self): cid=f'<{a.filename}>', ) + if calendar_attachment: + ics_data = calendar_attachment.data + ics_text = ics_data.decode('utf-8') if isinstance(ics_data, bytes) else ics_data + + # Add as an alternative so Gmail renders the accept/decline UI + message.add_alternative(ics_text, subtype='calendar') + message.get_payload()[-1].set_param('method', self.method) + + # Also attach as a downloadable file for clients that prefer it + message.add_attachment( + ics_data, + maintype='text', + subtype='calendar', + filename=calendar_attachment.filename, + ) + message.get_payload()[-1].set_param('method', self.method) + return message def send(self): From 1823cdd5cf3bfe918ae9c79157d24e95ec62f025 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Mon, 23 Feb 2026 11:38:06 -0600 Subject: [PATCH 02/48] Add insert_event method to GoogleClient and format body accordingly --- .../controller/apis/google_client.py | 13 +++ .../src/appointment/controller/calendar.py | 86 +++++++++++++------ 2 files changed, 73 insertions(+), 26 deletions(-) diff --git a/backend/src/appointment/controller/apis/google_client.py b/backend/src/appointment/controller/apis/google_client.py index f839873d6..d8c390177 100644 --- a/backend/src/appointment/controller/apis/google_client.py +++ b/backend/src/appointment/controller/apis/google_client.py @@ -246,6 +246,19 @@ def save_event(self, calendar_id, body, token): return response + def insert_event(self, calendar_id, body, token, send_updates='all'): + response = None + with build('calendar', 'v3', credentials=token, cache_discovery=False) as service: + try: + response = service.events().insert( + calendarId=calendar_id, body=body, sendUpdates=send_updates + ).execute() + except HttpError as e: + logging.warning(f'[google_client.insert_event] Request Error: {e.status_code}/{e.error_details}') + raise EventNotCreatedException() + + return response + def delete_event(self, calendar_id, event_id, token): response = None with build('calendar', 'v3', credentials=token, cache_discovery=False) as service: diff --git a/backend/src/appointment/controller/calendar.py b/backend/src/appointment/controller/calendar.py index 2f3592683..6f6175848 100644 --- a/backend/src/appointment/controller/calendar.py +++ b/backend/src/appointment/controller/calendar.py @@ -268,6 +268,7 @@ def save_event( attendee: schemas.AttendeeBase, organizer: schemas.Subscriber, organizer_email: str, + send_google_notification: bool = False, ) -> schemas.Event: """add a new event to the connected calendar""" @@ -281,26 +282,42 @@ def save_event( if event.location.phone: description.append(l10n('join-phone', {'phone': event.location.phone}, lang=organizer_language)) - body = { - 'iCalUID': event.uuid.hex, - 'summary': event.title, - 'location': event.location.url if event.location.url else None, - 'description': '\n'.join(description), - 'start': {'dateTime': event.start.isoformat()}, - 'end': {'dateTime': event.end.isoformat()}, - 'attendees': [ - {'displayName': organizer.name, 'email': organizer_email, 'responseStatus': 'accepted'}, - {'displayName': attendee.name, 'email': attendee.email, 'responseStatus': 'accepted'}, - ], - 'organizer': { - 'displayName': organizer.name, - 'email': self.remote_calendar_id, - }, - } - - new_event = self.google_client.save_event( - calendar_id=self.remote_calendar_id, body=body, token=self.google_token - ) + if send_google_notification: + body = { + 'summary': event.title, + 'location': event.location.url if event.location.url else None, + 'description': '\n'.join(description), + 'start': {'dateTime': event.start.isoformat()}, + 'end': {'dateTime': event.end.isoformat()}, + 'attendees': [ + {'displayName': attendee.name, 'email': attendee.email, 'responseStatus': 'needsAction'}, + ], + } + + new_event = self.google_client.insert_event( + calendar_id=self.remote_calendar_id, body=body, token=self.google_token + ) + else: + body = { + 'iCalUID': event.uuid.hex, + 'summary': event.title, + 'location': event.location.url if event.location.url else None, + 'description': '\n'.join(description), + 'start': {'dateTime': event.start.isoformat()}, + 'end': {'dateTime': event.end.isoformat()}, + 'attendees': [ + {'displayName': organizer.name, 'email': organizer_email, 'responseStatus': 'accepted'}, + {'displayName': attendee.name, 'email': attendee.email, 'responseStatus': 'accepted'}, + ], + 'organizer': { + 'displayName': organizer.name, + 'email': self.remote_calendar_id, + }, + } + + new_event = self.google_client.save_event( + calendar_id=self.remote_calendar_id, body=body, token=self.google_token + ) # Fill in the external_id so we can delete events later! event.external_id = new_event.get('id') @@ -549,7 +566,12 @@ def list_events(self, start, end): return events def save_event( - self, event: schemas.Event, attendee: schemas.AttendeeBase, organizer: schemas.Subscriber, organizer_email: str + self, + event: schemas.Event, + attendee: schemas.AttendeeBase, + organizer: schemas.Subscriber, + organizer_email: str, + send_google_notification: bool = False, ): """add a new event to the connected calendar""" calendar = self.client.calendar(url=self.url) @@ -611,22 +633,34 @@ def create_vevent( cal = Calendar() cal.add('prodid', '-//thunderbird.net/Thunderbird Appointment//EN') cal.add('version', '2.0') + cal.add('calscale', 'GREGORIAN') cal.add('method', 'CANCEL' if event_status == RemoteEventState.CANCELLED.value else 'REQUEST') org = vCalAddress('MAILTO:' + organizer.preferred_email) org.params['cn'] = vText(organizer.preferred_email) org.params['role'] = vText('CHAIR') + now = datetime.now(UTC) + organizer_domain = ( + organizer.preferred_email.rsplit('@', 1)[-1] + if '@' in organizer.preferred_email + else 'thunderbird.net' + ) + event = Event() - event.add('uid', appointment.uuid.hex) + event.add('uid', f'{appointment.uuid.hex}@{organizer_domain}') event.add('summary', appointment.title) event.add('dtstart', slot.start.replace(tzinfo=timezone.utc)) event.add( 'dtend', slot.start.replace(tzinfo=timezone.utc) + timedelta(minutes=slot.duration), ) - event.add('dtstamp', datetime.now(UTC)) + event.add('dtstamp', now) + event.add('created', now) + event.add('last-modified', now) + event.add('sequence', 0) event.add('status', event_status) + event.add('transp', 'OPAQUE') event['description'] = appointment.details event['organizer'] = org @@ -638,9 +672,9 @@ def create_vevent( else: attendee.params['cn'] = vText(slot.attendee.email) - # Set the attendee status to accepted by default - # since they are the ones who are submitting the request - attendee.params['partstat'] = vText(RemoteEventState.ACCEPTED.value) + attendee.params['cutype'] = vText('INDIVIDUAL') + attendee.params['partstat'] = vText('NEEDS-ACTION') + attendee.params['rsvp'] = vText('TRUE') attendee.params['role'] = vText('REQ-PARTICIPANT') event.add('attendee', attendee) From f965c5db802b556aceb8942c24f56b40b76a7c74 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Mon, 23 Feb 2026 11:38:31 -0600 Subject: [PATCH 03/48] Split logic between Google accounts vs non-Google accounts --- backend/src/appointment/routes/schedule.py | 40 ++++++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/backend/src/appointment/routes/schedule.py b/backend/src/appointment/routes/schedule.py index d8ea440ef..795376e60 100644 --- a/backend/src/appointment/routes/schedule.py +++ b/backend/src/appointment/routes/schedule.py @@ -460,13 +460,19 @@ def request_schedule_availability_slot( ) # create HOLD event in owners calender - event = save_remote_event(event, calendar, subscriber, slot, db, redis, google_client) + use_google_invite = calendar.provider == CalendarProvider.google + + event = save_remote_event( + event, calendar, subscriber, slot, db, redis, google_client, + send_google_notification=use_google_invite, + ) # Add the external id if available if appointment and event.external_id: repo.appointment.update_external_id(db, appointment, event.external_id) - # Sending confirmation pending information email to attendee with HOLD event attached - Tools().send_hold_vevent(background_tasks, slot.appointment, slot, subscriber, slot.attendee) + # When Google handles the invitation via insert(), skip the branded email. + if not use_google_invite: + Tools().send_hold_vevent(background_tasks, slot.appointment, slot, subscriber, slot.attendee) # If no confirmation is needed, directly confirm the booking and send invitation mail else: @@ -661,14 +667,30 @@ def handle_schedule_availability_decision( # Update HOLD event appointment = repo.appointment.update_title(db, slot.appointment_id, title) - event = save_remote_event(event, appointment_calendar, subscriber, slot, db, redis, google_client) + + use_google_invite = calendar.provider == CalendarProvider.google + + # When using Google insert(), we must delete any existing HOLD event first + # because insert() creates a new event (unlike import_() which upserts by iCalUID). + if use_google_invite and appointment and appointment.external_id: + try: + delete_remote_event(appointment.external_id, appointment_calendar, subscriber, db, redis, google_client) + except EventCouldNotBeDeleted: + logging.warning('[schedule] Failed to delete HOLD event before Google insert, continuing anyway') + + event = save_remote_event( + event, appointment_calendar, subscriber, slot, db, redis, google_client, + send_google_notification=use_google_invite, + ) if appointment and event.external_id: repo.appointment.update_external_id(db, appointment, event.external_id) # Book the slot at the end slot = repo.slot.book(db, slot.id) - Tools().send_invitation_vevent(background_tasks, appointment, slot, subscriber, slot.attendee) + # When Google handles the invitation via insert(), skip the branded email. + if not use_google_invite: + Tools().send_invitation_vevent(background_tasks, appointment, slot, subscriber, slot.attendee) return True @@ -711,13 +733,17 @@ def get_remote_connection(calendar, subscriber, db, redis, google_client): return (con, organizer_email) -def save_remote_event(event, calendar, subscriber, slot, db, redis, google_client): +def save_remote_event(event, calendar, subscriber, slot, db, redis, google_client, send_google_notification=False): """Create or update a remote event""" con, organizer_email = get_remote_connection(calendar, subscriber, db, redis, google_client) try: return con.save_event( - event=event, attendee=slot.attendee, organizer=subscriber, organizer_email=organizer_email + event=event, + attendee=slot.attendee, + organizer=subscriber, + organizer_email=organizer_email, + send_google_notification=send_google_notification, ) except EventNotCreatedException: raise EventCouldNotBeAccepted From ce53dc2d0d86da6523474f14b30ef6044ed5250c Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Mon, 23 Feb 2026 11:39:01 -0600 Subject: [PATCH 04/48] Update google connector tests --- backend/test/unit/test_calendar_tools.py | 147 +++++++++++++++++++---- 1 file changed, 122 insertions(+), 25 deletions(-) diff --git a/backend/test/unit/test_calendar_tools.py b/backend/test/unit/test_calendar_tools.py index 1ed2e35a3..06409f972 100644 --- a/backend/test/unit/test_calendar_tools.py +++ b/backend/test/unit/test_calendar_tools.py @@ -227,7 +227,9 @@ def test_attendee_with_name(self, make_google_calendar, make_appointment, make_p assert 'MAILTO:attendee@example.com' in ics_str assert 'CN="John Doe"' in ics_str assert 'ROLE=REQ-PARTICIPANT' in ics_str - assert 'PARTSTAT=ACCEPTED' in ics_str + assert 'PARTSTAT=NEEDS-ACTION' in ics_str + assert 'RSVP=TRUE' in ics_str + assert 'CUTYPE=INDIVIDUAL' in ics_str def test_attendee_without_name_uses_email(self, make_google_calendar, make_appointment, make_pro_subscriber): """Verify attendee is added with email as name when slot has an attendee without a name""" @@ -247,7 +249,9 @@ def test_attendee_without_name_uses_email(self, make_google_calendar, make_appoi assert 'MAILTO:attendee@example.com' in ics_str assert 'CN=attendee@example.com' in ics_str assert 'ROLE=REQ-PARTICIPANT' in ics_str - assert 'PARTSTAT=ACCEPTED' in ics_str + assert 'PARTSTAT=NEEDS-ACTION' in ics_str + assert 'RSVP=TRUE' in ics_str + assert 'CUTYPE=INDIVIDUAL' in ics_str def test_no_attendee(self, make_google_calendar, make_appointment, make_pro_subscriber): """Verify no attendee is added when slot has no attendee""" @@ -441,20 +445,20 @@ def test_respects_weekday_filter(self): assert len(slots_tuesday) == 0 -class TestGoogleConnectorSaveEventLanguage: - """Verify that GoogleConnector.save_event uses the organizer's language - for the event description, not the language from the request context.""" +class TestGoogleConnectorSaveEvent: + """Tests for GoogleConnector.save_event with import_() and insert() paths.""" - def test_description_uses_organizer_language_not_context(self): - """When the request context is German but the organizer speaks English, - the 'join-online' and 'join-phone' strings in the event description - must be in English.""" - - # Set up starlette context with German (simulating a German-speaking bookee) - l10n_plugin = L10n() - l10n_fn = l10n_plugin.get_fluent_with_header('de') + def _make_connector(self, mock_google_client): + connector = GoogleConnector.__new__(GoogleConnector) + connector.google_client = mock_google_client + connector.remote_calendar_id = 'cal@example.com' + connector.google_token = None + connector.subscriber_id = 1 + connector.calendar_id = 1 + connector.redis_instance = None + return connector - # English-speaking organizer + def _make_event_and_organizer(self): organizer = Mock(spec=['name', 'email', 'language']) organizer.name = 'Owner' organizer.email = 'owner@example.com' @@ -471,17 +475,21 @@ def test_description_uses_organizer_language_not_context(self): uuid=uuid.uuid4(), ) - # Mock the GoogleConnector so we can capture the body passed to google_client.save_event + return event, attendee, organizer + + def test_description_uses_organizer_language_not_context(self): + """When the request context is German but the organizer speaks English, + the 'join-online' and 'join-phone' strings in the event description + must be in English.""" + + l10n_plugin = L10n() + l10n_fn = l10n_plugin.get_fluent_with_header('de') + + event, attendee, organizer = self._make_event_and_organizer() + mock_google_client = Mock() mock_google_client.save_event.return_value = {'id': 'mock_event_id'} - - connector = GoogleConnector.__new__(GoogleConnector) - connector.google_client = mock_google_client - connector.remote_calendar_id = 'cal@example.com' - connector.google_token = None - connector.subscriber_id = 1 - connector.calendar_id = 1 - connector.redis_instance = None + connector = self._make_connector(mock_google_client) with request_cycle_context({'l10n': l10n_fn}): connector.save_event( @@ -491,17 +499,105 @@ def test_description_uses_organizer_language_not_context(self): organizer_email='owner@example.com', ) - # Extract the description from the body passed to google_client.save_event call_kwargs = mock_google_client.save_event.call_args body = call_kwargs.kwargs.get('body') or call_kwargs[1].get('body') description = body['description'] - # Must be in English (organizer's language), not German assert 'Join online at:' in description assert 'Join by phone:' in description assert 'Online teilnehmen unter:' not in description assert 'Per Telefon teilnehmen:' not in description + def test_default_uses_import(self): + """By default (send_google_notification=False), save_event uses import_().""" + event, attendee, organizer = self._make_event_and_organizer() + + mock_google_client = Mock() + mock_google_client.save_event.return_value = {'id': 'import_event_id'} + connector = self._make_connector(mock_google_client) + + with request_cycle_context({}): + result = connector.save_event( + event=event, + attendee=attendee, + organizer=organizer, + organizer_email='owner@example.com', + ) + + mock_google_client.save_event.assert_called_once() + mock_google_client.insert_event.assert_not_called() + + body = mock_google_client.save_event.call_args.kwargs.get('body') + assert 'iCalUID' in body + assert result.external_id == 'import_event_id' + + def test_google_notification_uses_insert(self): + """When send_google_notification=True, save_event uses insert_event().""" + event, attendee, organizer = self._make_event_and_organizer() + + mock_google_client = Mock() + mock_google_client.insert_event.return_value = {'id': 'insert_event_id'} + connector = self._make_connector(mock_google_client) + + with request_cycle_context({}): + result = connector.save_event( + event=event, + attendee=attendee, + organizer=organizer, + organizer_email='owner@example.com', + send_google_notification=True, + ) + + mock_google_client.insert_event.assert_called_once() + mock_google_client.save_event.assert_not_called() + + body = mock_google_client.insert_event.call_args.kwargs.get('body') + assert 'iCalUID' not in body + assert result.external_id == 'insert_event_id' + + def test_insert_body_has_attendee_only(self): + """insert() body should only include the bookee, not the organizer.""" + event, attendee, organizer = self._make_event_and_organizer() + + mock_google_client = Mock() + mock_google_client.insert_event.return_value = {'id': 'insert_event_id'} + connector = self._make_connector(mock_google_client) + + with request_cycle_context({}): + connector.save_event( + event=event, + attendee=attendee, + organizer=organizer, + organizer_email='owner@example.com', + send_google_notification=True, + ) + + body = mock_google_client.insert_event.call_args.kwargs.get('body') + attendees = body['attendees'] + assert len(attendees) == 1 + assert attendees[0]['email'] == 'bookee@example.org' + assert attendees[0]['responseStatus'] == 'needsAction' + + def test_insert_body_has_no_organizer_field(self): + """insert() body should not include the custom organizer field.""" + event, attendee, organizer = self._make_event_and_organizer() + + mock_google_client = Mock() + mock_google_client.insert_event.return_value = {'id': 'insert_event_id'} + connector = self._make_connector(mock_google_client) + + with request_cycle_context({}): + connector.save_event( + event=event, + attendee=attendee, + organizer=organizer, + organizer_email='owner@example.com', + send_google_notification=True, + ) + + body = mock_google_client.insert_event.call_args.kwargs.get('body') + assert 'organizer' not in body + class TestVEventTimezoneFallback: """Tests that send_*_vevent methods fall back to UTC for invalid timezones.""" @@ -595,3 +691,4 @@ def test_none_timezone_defaults_to_utc(self, method_name): call_kwargs = bg.add_task.call_args date_arg = call_kwargs.kwargs.get('date') or call_kwargs[1].get('date') assert date_arg.tzinfo is not None + From bba671147329a98b88f9f96f18c5afc6ba2ddaa1 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Tue, 24 Feb 2026 09:37:17 -0600 Subject: [PATCH 05/48] Add google_calendar_channels table and model repo methods --- backend/src/appointment/database/models.py | 21 +++++ .../src/appointment/database/repo/__init__.py | 14 +++- .../database/repo/google_calendar_channel.py | 81 +++++++++++++++++++ ...2c3d4e5f6_add_google_calendar_channels_.py | 33 ++++++++ 4 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 backend/src/appointment/database/repo/google_calendar_channel.py create mode 100644 backend/src/appointment/migrations/versions/2026_02_23_1200-a1b2c3d4e5f6_add_google_calendar_channels_.py diff --git a/backend/src/appointment/database/models.py b/backend/src/appointment/database/models.py index c77f2629a..8bc2bde89 100644 --- a/backend/src/appointment/database/models.py +++ b/backend/src/appointment/database/models.py @@ -235,6 +235,9 @@ class Calendar(Base): 'Appointment', cascade='all,delete', back_populates='calendar' ) schedules: Mapped[list['Schedule']] = relationship('Schedule', cascade='all,delete', back_populates='calendar') + google_channel: Mapped['GoogleCalendarChannel'] = relationship( + 'GoogleCalendarChannel', cascade='all,delete', back_populates='calendar', uselist=False + ) def __str__(self): return f'Calendar: {self.id}' @@ -454,3 +457,21 @@ class ExternalConnections(Base): def __str__(self): return f'External Connection: {self.id}' + + +class GoogleCalendarChannel(Base): + """Tracks Google Calendar push notification channels for real-time RSVP sync.""" + + __tablename__ = 'google_calendar_channels' + + id = Column(Integer, primary_key=True, index=True) + calendar_id = Column(Integer, ForeignKey('calendars.id'), unique=True) + channel_id = Column(String, index=True) + resource_id = Column(String) + expiration = Column(DateTime) + sync_token = Column(String, nullable=True) + + calendar: Mapped[Calendar] = relationship('Calendar', back_populates='google_channel') + + def __str__(self): + return f'GoogleCalendarChannel: {self.channel_id}' diff --git a/backend/src/appointment/database/repo/__init__.py b/backend/src/appointment/database/repo/__init__.py index 4f3cf4667..f305d0344 100644 --- a/backend/src/appointment/database/repo/__init__.py +++ b/backend/src/appointment/database/repo/__init__.py @@ -1 +1,13 @@ -from . import appointment, attendee, availability, calendar, external_connection, schedule, slot, subscriber # noqa: F401 +# ruff: noqa + +from . import ( + appointment, + attendee, + availability, + calendar, + external_connection, + google_calendar_channel, + schedule, + slot, + subscriber, +) diff --git a/backend/src/appointment/database/repo/google_calendar_channel.py b/backend/src/appointment/database/repo/google_calendar_channel.py new file mode 100644 index 000000000..bb50d5b40 --- /dev/null +++ b/backend/src/appointment/database/repo/google_calendar_channel.py @@ -0,0 +1,81 @@ +"""Module: repo.google_calendar_channel + +Repository providing CRUD functions for GoogleCalendarChannel database models. +""" + +from datetime import datetime + +from sqlalchemy.orm import Session +from .. import models + + +def get_by_calendar_id(db: Session, calendar_id: int) -> models.GoogleCalendarChannel | None: + return ( + db.query(models.GoogleCalendarChannel) + .filter(models.GoogleCalendarChannel.calendar_id == calendar_id) + .first() + ) + + +def get_by_channel_id(db: Session, channel_id: str) -> models.GoogleCalendarChannel | None: + return ( + db.query(models.GoogleCalendarChannel) + .filter(models.GoogleCalendarChannel.channel_id == channel_id) + .first() + ) + + +def get_expiring(db: Session, before: datetime) -> list[models.GoogleCalendarChannel]: + return ( + db.query(models.GoogleCalendarChannel) + .filter(models.GoogleCalendarChannel.expiration < before) + .all() + ) + + +def create( + db: Session, + calendar_id: int, + channel_id: str, + resource_id: str, + expiration: datetime, + sync_token: str | None = None, +) -> models.GoogleCalendarChannel: + channel = models.GoogleCalendarChannel( + calendar_id=calendar_id, + channel_id=channel_id, + resource_id=resource_id, + expiration=expiration, + sync_token=sync_token, + ) + db.add(channel) + db.commit() + db.refresh(channel) + return channel + + +def update_sync_token(db: Session, channel: models.GoogleCalendarChannel, sync_token: str): + channel.sync_token = sync_token + db.commit() + db.refresh(channel) + return channel + + +def update_expiration( + db: Session, + channel: models.GoogleCalendarChannel, + new_channel_id: str, + new_resource_id: str, + new_expiration: datetime, +): + channel.channel_id = new_channel_id + channel.resource_id = new_resource_id + channel.expiration = new_expiration + db.commit() + db.refresh(channel) + return channel + + +def delete(db: Session, channel: models.GoogleCalendarChannel): + db.delete(channel) + db.commit() diff --git a/backend/src/appointment/migrations/versions/2026_02_23_1200-a1b2c3d4e5f6_add_google_calendar_channels_.py b/backend/src/appointment/migrations/versions/2026_02_23_1200-a1b2c3d4e5f6_add_google_calendar_channels_.py new file mode 100644 index 000000000..b022b53f7 --- /dev/null +++ b/backend/src/appointment/migrations/versions/2026_02_23_1200-a1b2c3d4e5f6_add_google_calendar_channels_.py @@ -0,0 +1,33 @@ +"""add google_calendar_channels table + +Revision ID: a1b2c3d4e5f6 +Revises: 17792ef315c1 +Create Date: 2026-02-23 12:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a1b2c3d4e5f6' +down_revision = '17792ef315c1' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'google_calendar_channels', + sa.Column('id', sa.Integer, primary_key=True, index=True), + sa.Column('calendar_id', sa.Integer, sa.ForeignKey('calendars.id'), unique=True), + sa.Column('channel_id', sa.String, index=True), + sa.Column('resource_id', sa.String), + sa.Column('expiration', sa.DateTime), + sa.Column('sync_token', sa.String, nullable=True), + ) + + +def downgrade() -> None: + op.drop_table('google_calendar_channels') From 1e212e0a342b85b0943ebdf2febb1fdb1c5f0672 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Tue, 24 Feb 2026 09:39:25 -0600 Subject: [PATCH 06/48] Implement Google Calendar's Push Notifications API methods --- .../controller/apis/google_client.py | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/backend/src/appointment/controller/apis/google_client.py b/backend/src/appointment/controller/apis/google_client.py index d8c390177..40e197aed 100644 --- a/backend/src/appointment/controller/apis/google_client.py +++ b/backend/src/appointment/controller/apis/google_client.py @@ -1,5 +1,6 @@ import logging import os +import uuid from datetime import datetime import sentry_sdk @@ -270,6 +271,96 @@ def delete_event(self, calendar_id, event_id, token): return response + def watch_events(self, calendar_id, webhook_url, token): + """Register a push notification channel for calendar event changes. + Ref: https://developers.google.com/calendar/api/v3/reference/events/watch""" + channel_id = str(uuid.uuid4()) + with build('calendar', 'v3', credentials=token, cache_discovery=False) as service: + try: + response = service.events().watch( + calendarId=calendar_id, + body={ + 'id': channel_id, + 'type': 'web_hook', + 'address': webhook_url, + }, + ).execute() + except HttpError as e: + logging.error(f'[google_client.watch_events] Request Error: {e.status_code}/{e.error_details}') + return None + + return response + + def stop_channel(self, channel_id, resource_id, token): + """Stop a push notification channel. + Ref: https://developers.google.com/calendar/api/v3/reference/channels/stop""" + with build('calendar', 'v3', credentials=token, cache_discovery=False) as service: + try: + service.channels().stop( + body={ + 'id': channel_id, + 'resourceId': resource_id, + }, + ).execute() + except HttpError as e: + logging.warning(f'[google_client.stop_channel] Request Error: {e.status_code}/{e.error_details}') + + def list_events_sync(self, calendar_id, sync_token, token): + """Fetch events that changed since the last sync token. + Ref: https://developers.google.com/calendar/api/v3/reference/events/list (incremental sync)""" + items = [] + next_sync_token = None + + with build('calendar', 'v3', credentials=token, cache_discovery=False) as service: + page_token = None + while True: + try: + params = { + 'calendarId': calendar_id, + 'syncToken': sync_token, + } + if page_token: + params['pageToken'] = page_token + + response = service.events().list(**params).execute() + items += response.get('items', []) + page_token = response.get('nextPageToken') + if not page_token: + next_sync_token = response.get('nextSyncToken') + break + except HttpError as e: + if e.status_code == 410: + logging.info('[google_client.list_events_sync] Sync token expired, full sync needed') + return None, None + logging.warning( + f'[google_client.list_events_sync] Request Error: {e.status_code}/{e.error_details}' + ) + break + + return items, next_sync_token + + def get_initial_sync_token(self, calendar_id, token): + """Perform an initial list to obtain a sync token without fetching all events.""" + with build('calendar', 'v3', credentials=token, cache_discovery=False) as service: + try: + page_token = None + while True: + params = { + 'calendarId': calendar_id, + 'maxResults': 250, + } + if page_token: + params['pageToken'] = page_token + response = service.events().list(**params).execute() + page_token = response.get('nextPageToken') + if not page_token: + return response.get('nextSyncToken') + except HttpError as e: + logging.error( + f'[google_client.get_initial_sync_token] Request Error: {e.status_code}/{e.error_details}' + ) + return None + def sync_calendars(self, db, subscriber_id: int, token, external_connection_id: int | None = None): # Grab all the Google calendars calendars = self.list_calendars(token) From ebb8bedff7af556c81e23edaa282b338dba01bb2 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Tue, 24 Feb 2026 09:45:54 -0600 Subject: [PATCH 07/48] Add POST to /google-calendar as webhook to receive calendar notifications --- backend/src/appointment/routes/webhooks.py | 241 ++++++++++++++++++++- 1 file changed, 239 insertions(+), 2 deletions(-) diff --git a/backend/src/appointment/routes/webhooks.py b/backend/src/appointment/routes/webhooks.py index dc1c8766a..95e3120b2 100644 --- a/backend/src/appointment/routes/webhooks.py +++ b/backend/src/appointment/routes/webhooks.py @@ -1,15 +1,19 @@ +import json import logging import requests import sentry_sdk -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Request, Response, BackgroundTasks +from google.oauth2.credentials import Credentials from sqlalchemy.orm import Session from ..controller import auth, data, zoom from ..controller.apis.fxa_client import FxaClient +from ..controller.apis.google_client import GoogleClient from ..database import repo, models, schemas -from ..dependencies.database import get_db +from ..dependencies.database import get_db, get_redis from ..dependencies.fxa import get_webhook_auth as get_webhook_auth_fxa, get_fxa_client +from ..dependencies.google import get_google_client from ..dependencies.zoom import get_webhook_auth as get_webhook_auth_zoom from ..exceptions.account_api import AccountDeletionSubscriberFail from ..exceptions.fxa_api import MissingRefreshTokenException @@ -110,3 +114,236 @@ def zoom_deauthorization( except Exception as ex: sentry_sdk.capture_exception(ex) logging.error(f'Error disconnecting zoom connection: {ex}') + + +@router.post('/google-calendar') +def google_calendar_notification( + request: Request, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + redis=Depends(get_redis), + google_client: GoogleClient = Depends(get_google_client), +): + """Webhook endpoint for Google Calendar push notifications. + Google sends a POST here whenever events change on a watched calendar.""" + channel_id = request.headers.get('X-Goog-Channel-Id') + resource_state = request.headers.get('X-Goog-Resource-State') + + if not channel_id: + return Response(status_code=200) + + # Google sends a 'sync' notification when the channel is first created; just acknowledge it + if resource_state == 'sync': + return Response(status_code=200) + + channel = repo.google_calendar_channel.get_by_channel_id(db, channel_id) + if not channel: + logging.warning(f'[webhooks.google_calendar] Unknown channel_id: {channel_id}') + return Response(status_code=200) + + calendar = channel.calendar + if not calendar or not calendar.connected: + _teardown_channel(db, channel, google_client) + return Response(status_code=200) + + external_connection = calendar.external_connection + if not external_connection or not external_connection.token: + _teardown_channel(db, channel, google_client) + return Response(status_code=200) + + token = Credentials.from_authorized_user_info( + json.loads(external_connection.token), google_client.SCOPES + ) + + if not channel.sync_token: + sync_token = google_client.get_initial_sync_token(calendar.user, token) + if sync_token: + repo.google_calendar_channel.update_sync_token(db, channel, sync_token) + return Response(status_code=200) + + changed_events, new_sync_token = google_client.list_events_sync( + calendar.user, channel.sync_token, token + ) + + if changed_events is None: + # Sync token expired -- do a full re-sync to get a fresh one + fresh_token = google_client.get_initial_sync_token(calendar.user, token) + if fresh_token: + repo.google_calendar_channel.update_sync_token(db, channel, fresh_token) + return Response(status_code=200) + + if new_sync_token: + repo.google_calendar_channel.update_sync_token(db, channel, new_sync_token) + + background_tasks.add_task( + _process_google_event_changes, + calendar_id=calendar.id, + changed_events=changed_events, + google_client=google_client, + google_token=token, + remote_calendar_id=calendar.user, + ) + + return Response(status_code=200) + + +def _teardown_channel(db: Session, channel: models.GoogleCalendarChannel, google_client: GoogleClient): + """Stop the Google channel and delete the local record.""" + if channel.calendar and channel.calendar.external_connection and channel.calendar.external_connection.token: + try: + token = Credentials.from_authorized_user_info( + json.loads(channel.calendar.external_connection.token), google_client.SCOPES + ) + google_client.stop_channel(channel.channel_id, channel.resource_id, token) + except Exception as e: + logging.warning(f'[webhooks.google_calendar] Failed to stop channel: {e}') + + repo.google_calendar_channel.delete(db, channel) + + +def _process_google_event_changes( + calendar_id: int, + changed_events: list[dict], + google_client: GoogleClient, + google_token, + remote_calendar_id: str, +): + """Process changed events from a Google Calendar push notification. + Runs as a background task to avoid blocking the webhook response.""" + from ..dependencies.database import get_engine_and_session + + _, SessionLocal = get_engine_and_session() + db = SessionLocal() + + try: + for event in changed_events: + google_event_id = event.get('id') + if not google_event_id: + continue + + attendees = event.get('attendees', []) + if not attendees: + continue + + appointment = _find_appointment_by_external_id(db, calendar_id, google_event_id) + if not appointment: + continue + + slot = appointment.slots[0] if appointment.slots else None + if not slot or not slot.attendee: + continue + + attendee_email = slot.attendee.email.lower() + google_attendee = next( + (a for a in attendees if a.get('email', '').lower() == attendee_email), + None, + ) + if not google_attendee: + continue + + response_status = google_attendee.get('responseStatus') + _handle_attendee_rsvp( + db, appointment, slot, response_status, google_client, google_token, remote_calendar_id + ) + except Exception as e: + logging.error(f'[webhooks.google_calendar] Error processing event changes: {e}') + if sentry_sdk.is_initialized(): + sentry_sdk.capture_exception(e) + finally: + db.close() + + +def _find_appointment_by_external_id( + db: Session, calendar_id: int, external_id: str +) -> models.Appointment | None: + """Find an appointment on the given calendar matching the Google event ID.""" + appointments = ( + db.query(models.Appointment) + .filter(models.Appointment.calendar_id == calendar_id) + .all() + ) + for appointment in appointments: + if appointment.external_id == external_id: + return appointment + return None + + +def _handle_attendee_rsvp( + db: Session, + appointment: models.Appointment, + slot: models.Slot, + response_status: str, + google_client: GoogleClient, + google_token, + remote_calendar_id: str, +): + """React to an attendee's RSVP status change from Google Calendar.""" + if response_status == 'declined': + if slot.booking_status in (models.BookingStatus.requested, models.BookingStatus.booked): + slot_update = schemas.SlotUpdate(booking_status=models.BookingStatus.declined) + repo.slot.update(db, slot.id, slot_update) + + if appointment.external_id: + try: + google_client.delete_event(remote_calendar_id, appointment.external_id, google_token) + except Exception: + logging.warning('[webhooks.google_calendar] Failed to delete declined event from Google') + + logging.info( + f'[webhooks.google_calendar] Attendee declined appointment {appointment.id}, ' + f'slot {slot.id} marked as declined' + ) + + elif response_status == 'accepted': + if slot.booking_status == models.BookingStatus.requested: + repo.slot.book(db, slot.id) + repo.appointment.update_status(db, slot.appointment_id, models.AppointmentStatus.closed) + + if appointment.external_id: + try: + google_client.delete_event(remote_calendar_id, appointment.external_id, google_token) + except Exception: + logging.warning('[webhooks.google_calendar] Failed to delete HOLD event after accept') + + _create_confirmed_google_event(db, appointment, slot, google_client, google_token, remote_calendar_id) + + logging.info( + f'[webhooks.google_calendar] Attendee accepted appointment {appointment.id}, ' + f'slot {slot.id} confirmed' + ) + + +def _create_confirmed_google_event( + db: Session, + appointment: models.Appointment, + slot: models.Slot, + google_client: GoogleClient, + google_token, + remote_calendar_id: str, +): + """Create a confirmed Google Calendar event after an attendee accepts a HOLD.""" + from datetime import timedelta, timezone + + description_parts = [appointment.details or ''] + body = { + 'summary': appointment.title, + 'location': appointment.location_url if appointment.location_url else None, + 'description': '\n'.join(description_parts), + 'start': {'dateTime': slot.start.replace(tzinfo=timezone.utc).isoformat()}, + 'end': {'dateTime': (slot.start.replace(tzinfo=timezone.utc) + timedelta(minutes=slot.duration)).isoformat()}, + 'attendees': [ + { + 'displayName': slot.attendee.name, + 'email': slot.attendee.email, + 'responseStatus': 'accepted', + }, + ], + } + + try: + new_event = google_client.insert_event( + calendar_id=remote_calendar_id, body=body, token=google_token + ) + repo.appointment.update_external_id(db, appointment, new_event.get('id')) + except Exception as e: + logging.error(f'[webhooks.google_calendar] Failed to create confirmed event: {e}') From b7452bc51a7ed47da4cb968e1fb4c9a91df9e9f4 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Tue, 24 Feb 2026 09:53:00 -0600 Subject: [PATCH 08/48] Setup watch channels when connecting a google account --- backend/src/appointment/routes/google.py | 96 ++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/backend/src/appointment/routes/google.py b/backend/src/appointment/routes/google.py index 0826f5956..d802d87f0 100644 --- a/backend/src/appointment/routes/google.py +++ b/backend/src/appointment/routes/google.py @@ -1,7 +1,11 @@ +import json +import logging import os +from datetime import datetime, timezone from fastapi import APIRouter, Depends, Request from fastapi.responses import RedirectResponse +from google.oauth2.credentials import Credentials from ..controller.apis.google_client import GoogleClient from ..database import repo, schemas, models @@ -130,6 +134,8 @@ def google_callback( if error_occurred: return google_callback_error(is_setup, l10n('google-sync-fail')) + _setup_watch_channels(db, google_client, creds, subscriber.id, external_connection.id) + # Redirect non-setup subscribers back to the setup page if not is_setup: return RedirectResponse(f'{os.getenv("FRONTEND_URL", "http://localhost:8090")}/setup') @@ -151,6 +157,7 @@ def disconnect_account( request_body: schemas.DisconnectGoogleAccountRequest, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber), + google_client: GoogleClient = Depends(get_google_client), ): """Disconnects a google account. Removes associated data from our services and deletes the connection details.""" google_connection = subscriber.get_external_connection(ExternalConnectionType.google, request_body.type_id) @@ -162,6 +169,9 @@ def disconnect_account( if schedule.calendar and schedule.calendar.external_connection_id == google_connection.id: raise ConnectionContainsDefaultCalendarException() + # Tear down watch channels before deleting calendars + _teardown_watch_channels(db, google_client, google_connection) + # Remove all of the google calendars on their given google connection repo.calendar.delete_by_subscriber_and_provider( db, subscriber.id, provider=models.CalendarProvider.google, external_connection_id=google_connection.id @@ -177,3 +187,89 @@ def disconnect_account( repo.external_connection.delete_by_type(db, subscriber.id, google_connection.type, google_connection.type_id) return True + + +def _get_webhook_url() -> str | None: + """Build the Google Calendar webhook URL from the backend URL.""" + backend_url = os.getenv('BACKEND_URL') + if not backend_url: + return None + return f'{backend_url}/webhooks/google-calendar' + + +def _setup_watch_channels( + db: Session, + google_client: GoogleClient, + creds, + subscriber_id: int, + external_connection_id: int, +): + """Register push notification channels for all Google calendars under this connection.""" + webhook_url = _get_webhook_url() + if not webhook_url: + logging.warning('[google] BACKEND_URL not set, skipping watch channel setup') + return + + calendars = repo.calendar.get_by_subscriber(db, subscriber_id) + for calendar in calendars: + if calendar.provider != models.CalendarProvider.google: + continue + if calendar.external_connection_id != external_connection_id: + continue + if not calendar.connected: + continue + + existing = repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) + if existing: + continue + + try: + response = google_client.watch_events(calendar.user, webhook_url, creds) + if response: + expiration_ms = int(response.get('expiration', 0)) + expiration_dt = datetime.fromtimestamp(expiration_ms / 1000, tz=timezone.utc) + + sync_token = google_client.get_initial_sync_token(calendar.user, creds) + + repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id=response['id'], + resource_id=response['resourceId'], + expiration=expiration_dt, + sync_token=sync_token, + ) + except Exception as e: + logging.warning(f'[google] Failed to set up watch channel for calendar {calendar.id}: {e}') + + +def _teardown_watch_channels( + db: Session, + google_client: GoogleClient, + google_connection: models.ExternalConnections, +): + """Stop and remove all watch channels for calendars under this Google connection.""" + if not google_connection or not google_connection.token: + return + + token = None + if google_client: + try: + token = Credentials.from_authorized_user_info( + json.loads(google_connection.token), google_client.SCOPES + ) + except (json.JSONDecodeError, Exception) as e: + logging.warning(f'[google] Could not parse token for channel teardown: {e}') + + for calendar in google_connection.calendars: + channel = repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) + if not channel: + continue + + if google_client and token: + try: + google_client.stop_channel(channel.channel_id, channel.resource_id, token) + except Exception as e: + logging.warning(f'[google] Failed to stop channel {channel.channel_id}: {e}') + + repo.google_calendar_channel.delete(db, channel) From 1b8d5a87971c56334abddb9099bf7ea57c353671 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Tue, 24 Feb 2026 10:05:55 -0600 Subject: [PATCH 09/48] Move google calendar watch methods into its own controller --- .../appointment/controller/google_watch.py | 166 ++++++++++++++++++ backend/src/appointment/routes/google.py | 95 +--------- backend/src/appointment/routes/webhooks.py | 23 +-- 3 files changed, 176 insertions(+), 108 deletions(-) create mode 100644 backend/src/appointment/controller/google_watch.py diff --git a/backend/src/appointment/controller/google_watch.py b/backend/src/appointment/controller/google_watch.py new file mode 100644 index 000000000..90c88f1c2 --- /dev/null +++ b/backend/src/appointment/controller/google_watch.py @@ -0,0 +1,166 @@ +"""Shared helpers for managing Google Calendar push notification (watch) channels.""" + +import json +import logging +import os +from datetime import datetime, timezone + +from google.oauth2.credentials import Credentials +from sqlalchemy.orm import Session + +from .apis.google_client import GoogleClient +from ..database import repo, models + + +def get_webhook_url() -> str | None: + """Build the Google Calendar webhook URL from the backend URL.""" + backend_url = os.getenv('BACKEND_URL') + if not backend_url: + return None + return f'{backend_url}/webhooks/google-calendar' + + +def get_google_token(google_client: GoogleClient, external_connection: models.ExternalConnections): + """Build Google Credentials from an external connection's stored token.""" + return Credentials.from_authorized_user_info( + json.loads(external_connection.token), google_client.SCOPES + ) + + +def setup_watch_channel(db: Session, google_client: GoogleClient, calendar: models.Calendar): + """Register a push notification channel for a single Google calendar. + No-ops if a channel already exists or prerequisites are missing.""" + if not google_client or calendar.provider != models.CalendarProvider.google: + return + + webhook_url = get_webhook_url() + if not webhook_url: + logging.warning('[google_watch] BACKEND_URL not set, skipping watch channel setup') + return + + existing = repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) + if existing: + return + + external_connection = calendar.external_connection + if not external_connection or not external_connection.token: + return + + try: + token = get_google_token(google_client, external_connection) + except (json.JSONDecodeError, Exception) as e: + logging.warning(f'[google_watch] Could not parse token for calendar {calendar.id}: {e}') + return + + try: + response = google_client.watch_events(calendar.user, webhook_url, token) + if response: + expiration_ms = int(response.get('expiration', 0)) + expiration_dt = datetime.fromtimestamp(expiration_ms / 1000, tz=timezone.utc) + + sync_token = google_client.get_initial_sync_token(calendar.user, token) + + repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id=response['id'], + resource_id=response['resourceId'], + expiration=expiration_dt, + sync_token=sync_token, + ) + except Exception as e: + logging.warning(f'[google_watch] Failed to set up watch channel for calendar {calendar.id}: {e}') + + +def teardown_watch_channel(db: Session, google_client: GoogleClient | None, calendar: models.Calendar): + """Stop and delete the watch channel for a single Google calendar.""" + channel = repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) + if not channel: + return + + if google_client and calendar.external_connection and calendar.external_connection.token: + try: + token = get_google_token(google_client, calendar.external_connection) + google_client.stop_channel(channel.channel_id, channel.resource_id, token) + except Exception as e: + logging.warning(f'[google_watch] Failed to stop channel {channel.channel_id}: {e}') + + repo.google_calendar_channel.delete(db, channel) + + +def setup_watch_channels_for_connection( + db: Session, + google_client: GoogleClient, + creds, + subscriber_id: int, + external_connection_id: int, +): + """Register push notification channels for all connected Google calendars under a connection.""" + webhook_url = get_webhook_url() + if not webhook_url: + logging.warning('[google_watch] BACKEND_URL not set, skipping watch channel setup') + return + + calendars = repo.calendar.get_by_subscriber(db, subscriber_id) + for calendar in calendars: + if calendar.provider != models.CalendarProvider.google: + continue + if calendar.external_connection_id != external_connection_id: + continue + if not calendar.connected: + continue + + existing = repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) + if existing: + continue + + try: + response = google_client.watch_events(calendar.user, webhook_url, creds) + if response: + expiration_ms = int(response.get('expiration', 0)) + expiration_dt = datetime.fromtimestamp(expiration_ms / 1000, tz=timezone.utc) + + sync_token = google_client.get_initial_sync_token(calendar.user, creds) + + repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id=response['id'], + resource_id=response['resourceId'], + expiration=expiration_dt, + sync_token=sync_token, + ) + except Exception as e: + logging.warning(f'[google_watch] Failed to set up watch channel for calendar {calendar.id}: {e}') + + +def teardown_watch_channels_for_connection( + db: Session, + google_client: GoogleClient | None, + google_connection: models.ExternalConnections, +): + """Stop and remove all watch channels for calendars under a Google connection.""" + if not google_connection or not google_connection.token: + return + + token = None + if google_client: + try: + token = Credentials.from_authorized_user_info( + json.loads(google_connection.token), google_client.SCOPES + ) + except (json.JSONDecodeError, Exception) as e: + logging.warning(f'[google_watch] Could not parse token for channel teardown: {e}') + + for calendar in google_connection.calendars: + channel = repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) + if not channel: + continue + + if google_client and token: + try: + google_client.stop_channel(channel.channel_id, channel.resource_id, token) + except Exception as e: + logging.warning(f'[google_watch] Failed to stop channel {channel.channel_id}: {e}') + + repo.google_calendar_channel.delete(db, channel) diff --git a/backend/src/appointment/routes/google.py b/backend/src/appointment/routes/google.py index d802d87f0..ebb3a8a75 100644 --- a/backend/src/appointment/routes/google.py +++ b/backend/src/appointment/routes/google.py @@ -1,13 +1,10 @@ -import json -import logging import os -from datetime import datetime, timezone from fastapi import APIRouter, Depends, Request from fastapi.responses import RedirectResponse -from google.oauth2.credentials import Credentials from ..controller.apis.google_client import GoogleClient +from ..controller.google_watch import setup_watch_channels_for_connection, teardown_watch_channels_for_connection from ..database import repo, schemas, models from sqlalchemy.orm import Session @@ -134,7 +131,7 @@ def google_callback( if error_occurred: return google_callback_error(is_setup, l10n('google-sync-fail')) - _setup_watch_channels(db, google_client, creds, subscriber.id, external_connection.id) + setup_watch_channels_for_connection(db, google_client, creds, subscriber.id, external_connection.id) # Redirect non-setup subscribers back to the setup page if not is_setup: @@ -170,7 +167,7 @@ def disconnect_account( raise ConnectionContainsDefaultCalendarException() # Tear down watch channels before deleting calendars - _teardown_watch_channels(db, google_client, google_connection) + teardown_watch_channels_for_connection(db, google_client, google_connection) # Remove all of the google calendars on their given google connection repo.calendar.delete_by_subscriber_and_provider( @@ -187,89 +184,3 @@ def disconnect_account( repo.external_connection.delete_by_type(db, subscriber.id, google_connection.type, google_connection.type_id) return True - - -def _get_webhook_url() -> str | None: - """Build the Google Calendar webhook URL from the backend URL.""" - backend_url = os.getenv('BACKEND_URL') - if not backend_url: - return None - return f'{backend_url}/webhooks/google-calendar' - - -def _setup_watch_channels( - db: Session, - google_client: GoogleClient, - creds, - subscriber_id: int, - external_connection_id: int, -): - """Register push notification channels for all Google calendars under this connection.""" - webhook_url = _get_webhook_url() - if not webhook_url: - logging.warning('[google] BACKEND_URL not set, skipping watch channel setup') - return - - calendars = repo.calendar.get_by_subscriber(db, subscriber_id) - for calendar in calendars: - if calendar.provider != models.CalendarProvider.google: - continue - if calendar.external_connection_id != external_connection_id: - continue - if not calendar.connected: - continue - - existing = repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) - if existing: - continue - - try: - response = google_client.watch_events(calendar.user, webhook_url, creds) - if response: - expiration_ms = int(response.get('expiration', 0)) - expiration_dt = datetime.fromtimestamp(expiration_ms / 1000, tz=timezone.utc) - - sync_token = google_client.get_initial_sync_token(calendar.user, creds) - - repo.google_calendar_channel.create( - db, - calendar_id=calendar.id, - channel_id=response['id'], - resource_id=response['resourceId'], - expiration=expiration_dt, - sync_token=sync_token, - ) - except Exception as e: - logging.warning(f'[google] Failed to set up watch channel for calendar {calendar.id}: {e}') - - -def _teardown_watch_channels( - db: Session, - google_client: GoogleClient, - google_connection: models.ExternalConnections, -): - """Stop and remove all watch channels for calendars under this Google connection.""" - if not google_connection or not google_connection.token: - return - - token = None - if google_client: - try: - token = Credentials.from_authorized_user_info( - json.loads(google_connection.token), google_client.SCOPES - ) - except (json.JSONDecodeError, Exception) as e: - logging.warning(f'[google] Could not parse token for channel teardown: {e}') - - for calendar in google_connection.calendars: - channel = repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) - if not channel: - continue - - if google_client and token: - try: - google_client.stop_channel(channel.channel_id, channel.resource_id, token) - except Exception as e: - logging.warning(f'[google] Failed to stop channel {channel.channel_id}: {e}') - - repo.google_calendar_channel.delete(db, channel) diff --git a/backend/src/appointment/routes/webhooks.py b/backend/src/appointment/routes/webhooks.py index 95e3120b2..e25b05192 100644 --- a/backend/src/appointment/routes/webhooks.py +++ b/backend/src/appointment/routes/webhooks.py @@ -10,6 +10,7 @@ from ..controller import auth, data, zoom from ..controller.apis.fxa_client import FxaClient from ..controller.apis.google_client import GoogleClient +from ..controller.google_watch import teardown_watch_channel from ..database import repo, models, schemas from ..dependencies.database import get_db, get_redis from ..dependencies.fxa import get_webhook_auth as get_webhook_auth_fxa, get_fxa_client @@ -142,13 +143,16 @@ def google_calendar_notification( return Response(status_code=200) calendar = channel.calendar - if not calendar or not calendar.connected: - _teardown_channel(db, channel, google_client) + if not calendar: + repo.google_calendar_channel.delete(db, channel) + return Response(status_code=200) + if not calendar.connected: + teardown_watch_channel(db, google_client, calendar) return Response(status_code=200) external_connection = calendar.external_connection if not external_connection or not external_connection.token: - _teardown_channel(db, channel, google_client) + teardown_watch_channel(db, google_client, calendar) return Response(status_code=200) token = Credentials.from_authorized_user_info( @@ -187,19 +191,6 @@ def google_calendar_notification( return Response(status_code=200) -def _teardown_channel(db: Session, channel: models.GoogleCalendarChannel, google_client: GoogleClient): - """Stop the Google channel and delete the local record.""" - if channel.calendar and channel.calendar.external_connection and channel.calendar.external_connection.token: - try: - token = Credentials.from_authorized_user_info( - json.loads(channel.calendar.external_connection.token), google_client.SCOPES - ) - google_client.stop_channel(channel.channel_id, channel.resource_id, token) - except Exception as e: - logging.warning(f'[webhooks.google_calendar] Failed to stop channel: {e}') - - repo.google_calendar_channel.delete(db, channel) - def _process_google_event_changes( calendar_id: int, From 6aea4a1ff54263b6b353ac89868703f2d3c17d23 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Tue, 24 Feb 2026 10:06:27 -0600 Subject: [PATCH 10/48] Setup and teadown watch channels when calendar connection changes --- backend/src/appointment/routes/api.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/src/appointment/routes/api.py b/backend/src/appointment/routes/api.py index 7492784fc..842e39312 100644 --- a/backend/src/appointment/routes/api.py +++ b/backend/src/appointment/routes/api.py @@ -16,6 +16,7 @@ # authentication from ..controller.calendar import CalDavConnector, Tools, GoogleConnector +from ..controller.google_watch import setup_watch_channel, teardown_watch_channel from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Request, Response, Query from ..controller.apis.google_client import GoogleClient from ..controller.auth import signed_url_by_subscriber, schedule_slugs_by_subscriber, user_links_by_subscriber @@ -270,6 +271,7 @@ def change_my_calendar_connection( id: int, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber), + google_client: GoogleClient = Depends(get_google_client), ): """endpoint to update an existing calendar connection for authenticated subscriber note this function handles both disconnect and connect (the double route is not a typo.)""" @@ -285,6 +287,13 @@ def change_my_calendar_connection( cal = repo.calendar.update_connection(db=db, calendar_id=id, is_connected=connect) except HTTPException as e: raise HTTPException(status_code=e.status_code, detail=e.detail) + + if cal.provider == CalendarProvider.google: + if connect: + setup_watch_channel(db, google_client, cal) + else: + teardown_watch_channel(db, google_client, cal) + return schemas.CalendarOut(id=cal.id, title=cal.title, color=cal.color, connected=cal.connected) From 034d50c9e56c1620b7fca0c735206749cfe18b88 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Tue, 24 Feb 2026 10:09:20 -0600 Subject: [PATCH 11/48] Add backfill (to be ran once) and renew (periodically) to maintain expiring channels --- .../commands/backfill_google_channels.py | 91 +++++++++++++++++++ .../commands/renew_google_channels.py | 84 +++++++++++++++++ backend/src/appointment/routes/commands.py | 20 +++- 3 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 backend/src/appointment/commands/backfill_google_channels.py create mode 100644 backend/src/appointment/commands/renew_google_channels.py diff --git a/backend/src/appointment/commands/backfill_google_channels.py b/backend/src/appointment/commands/backfill_google_channels.py new file mode 100644 index 000000000..0813b4c0f --- /dev/null +++ b/backend/src/appointment/commands/backfill_google_channels.py @@ -0,0 +1,91 @@ +"""One-off command to set up Google Calendar watch channels for existing connected calendars.""" + +import json +import logging +from datetime import datetime, timezone + +from google.oauth2.credentials import Credentials + +from ..controller.google_watch import get_webhook_url +from ..database import repo, models +from ..dependencies.database import get_engine_and_session +from ..dependencies.google import get_google_client +from ..main import _common_setup + + +def run(): + _common_setup() + google_client = get_google_client() + + _, SessionLocal = get_engine_and_session() + db = SessionLocal() + + webhook_url = get_webhook_url() + if not webhook_url: + print('BACKEND_URL not set, aborting.') + db.close() + return + + # Find all connected Google calendars that don't yet have a watch channel + all_calendars = db.query(models.Calendar).filter( + models.Calendar.provider == models.CalendarProvider.google, + models.Calendar.connected == True, # noqa: E712 + ).all() + + candidates = [] + for cal in all_calendars: + existing = repo.google_calendar_channel.get_by_calendar_id(db, cal.id) + if not existing: + candidates.append(cal) + + print(f'Found {len(candidates)} connected Google calendar(s) without a watch channel.') + + created = 0 + skipped = 0 + failed = 0 + + for calendar in candidates: + ext_conn = calendar.external_connection + if not ext_conn or not ext_conn.token: + print(f' Calendar {calendar.id}: no external connection or token, skipping.') + skipped += 1 + continue + + try: + token = Credentials.from_authorized_user_info( + json.loads(ext_conn.token), google_client.SCOPES + ) + except Exception as e: + print(f' Calendar {calendar.id}: failed to parse token ({e}), skipping.') + skipped += 1 + continue + + try: + response = google_client.watch_events(calendar.user, webhook_url, token) + if not response: + print(f' Calendar {calendar.id}: watch_events returned no response.') + failed += 1 + continue + + expiration_ms = int(response.get('expiration', 0)) + expiration_dt = datetime.fromtimestamp(expiration_ms / 1000, tz=timezone.utc) + + sync_token = google_client.get_initial_sync_token(calendar.user, token) + + repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id=response['id'], + resource_id=response['resourceId'], + expiration=expiration_dt, + sync_token=sync_token, + ) + created += 1 + print(f' Calendar {calendar.id}: channel created (expires {expiration_dt}).') + except Exception as e: + print(f' Calendar {calendar.id}: failed ({e}).') + logging.error(f'[backfill_google_channels] Error for calendar {calendar.id}: {e}') + failed += 1 + + db.close() + print(f'\nBackfill complete: {created} created, {skipped} skipped, {failed} failed.') diff --git a/backend/src/appointment/commands/renew_google_channels.py b/backend/src/appointment/commands/renew_google_channels.py new file mode 100644 index 000000000..2c8c3a6c7 --- /dev/null +++ b/backend/src/appointment/commands/renew_google_channels.py @@ -0,0 +1,84 @@ +"""Renew expiring Google Calendar push notification channels. + +Run periodically (e.g., daily) to ensure channels don't expire. +Google channels typically last ~7 days, so daily renewal keeps a buffer. +""" + +import json +import logging +from datetime import datetime, timedelta, timezone + +from google.oauth2.credentials import Credentials + +from ..controller.google_watch import get_webhook_url +from ..database import repo +from ..dependencies.database import get_engine_and_session +from ..dependencies.google import get_google_client +from ..main import _common_setup + + +def run(): + _common_setup() + google_client = get_google_client() + + _, SessionLocal = get_engine_and_session() + db = SessionLocal() + + webhook_url = get_webhook_url() + if not webhook_url: + logging.error('[renew_google_channels] BACKEND_URL not set, aborting') + db.close() + return + + # Renew channels that expire within the next 24 hours + threshold = datetime.now(tz=timezone.utc) + timedelta(hours=24) + channels = repo.google_calendar_channel.get_expiring(db, before=threshold) + + renewed = 0 + failed = 0 + + for channel in channels: + calendar = channel.calendar + if not calendar or not calendar.connected: + repo.google_calendar_channel.delete(db, channel) + continue + + external_connection = calendar.external_connection + if not external_connection or not external_connection.token: + repo.google_calendar_channel.delete(db, channel) + continue + + token = Credentials.from_authorized_user_info( + json.loads(external_connection.token), google_client.SCOPES + ) + + # Stop the old channel + try: + google_client.stop_channel(channel.channel_id, channel.resource_id, token) + except Exception as e: + logging.warning(f'[renew_google_channels] Failed to stop old channel {channel.channel_id}: {e}') + + # Create a new channel + try: + response = google_client.watch_events(calendar.user, webhook_url, token) + if response: + expiration_ms = int(response.get('expiration', 0)) + expiration_dt = datetime.fromtimestamp(expiration_ms / 1000, tz=timezone.utc) + + repo.google_calendar_channel.update_expiration( + db, + channel, + new_channel_id=response['id'], + new_resource_id=response['resourceId'], + new_expiration=expiration_dt, + ) + renewed += 1 + else: + repo.google_calendar_channel.delete(db, channel) + failed += 1 + except Exception as e: + logging.error(f'[renew_google_channels] Failed to renew channel for calendar {calendar.id}: {e}') + failed += 1 + + db.close() + print(f'Channel renewal complete: {renewed} renewed, {failed} failed, {len(channels)} total processed') diff --git a/backend/src/appointment/routes/commands.py b/backend/src/appointment/routes/commands.py index fcb7aa04e..e88e1d9b1 100644 --- a/backend/src/appointment/routes/commands.py +++ b/backend/src/appointment/routes/commands.py @@ -4,7 +4,7 @@ import os import typer -from ..commands import update_db, download_legal, setup, generate_documentation_pages +from ..commands import update_db, download_legal, setup, generate_documentation_pages, renew_google_channels, backfill_google_channels router = typer.Typer() @@ -44,3 +44,21 @@ def generate_docs(): @router.command('setup') def setup_app(): setup.run() + + +@router.command('renew-google-channels') +def renew_channels(): + try: + with cron_lock('renew_google_channels'): + renew_google_channels.run() + except FileExistsError: + print('renew-google-channels is already running, skipping.') + + +@router.command('backfill-google-channels') +def backfill_channels(): + try: + with cron_lock('backfill_google_channels'): + backfill_google_channels.run() + except FileExistsError: + print('backfill-google-channels is already running, skipping.') From 12ae74d9ba9dfa884536f70afb2f921dac06c891 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Tue, 24 Feb 2026 15:24:55 -0600 Subject: [PATCH 12/48] Add tests --- .../appointment/controller/google_watch.py | 10 +- backend/test/conftest.py | 2 +- .../test_google_calendar_webhook.py | 231 +++++++++ backend/test/unit/test_google_webhook.py | 445 ++++++++++++++++++ 4 files changed, 685 insertions(+), 3 deletions(-) create mode 100644 backend/test/integration/test_google_calendar_webhook.py create mode 100644 backend/test/unit/test_google_webhook.py diff --git a/backend/src/appointment/controller/google_watch.py b/backend/src/appointment/controller/google_watch.py index 90c88f1c2..b0d7c5c2a 100644 --- a/backend/src/appointment/controller/google_watch.py +++ b/backend/src/appointment/controller/google_watch.py @@ -13,7 +13,7 @@ def get_webhook_url() -> str | None: - """Build the Google Calendar webhook URL from the backend URL.""" + """Build the Google Calendar webhook URL from the backend URL, requires https.""" backend_url = os.getenv('BACKEND_URL') if not backend_url: return None @@ -152,7 +152,13 @@ def teardown_watch_channels_for_connection( except (json.JSONDecodeError, Exception) as e: logging.warning(f'[google_watch] Could not parse token for channel teardown: {e}') - for calendar in google_connection.calendars: + calendars = ( + db.query(models.Calendar) + .filter(models.Calendar.external_connection_id == google_connection.id) + .all() + ) + + for calendar in calendars: channel = repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) if not channel: continue diff --git a/backend/test/conftest.py b/backend/test/conftest.py index 71632facb..1a5796b8f 100644 --- a/backend/test/conftest.py +++ b/backend/test/conftest.py @@ -50,7 +50,7 @@ def list_calendars(self): return [schemas.CalendarConnectionOut(url=TEST_CALDAV_URL, user=TEST_CALDAV_USER)] @staticmethod - def save_event(self, event, attendee, organizer, organizer_email): + def save_event(self, event, attendee, organizer, organizer_email, send_google_notification=False): return event @staticmethod diff --git a/backend/test/integration/test_google_calendar_webhook.py b/backend/test/integration/test_google_calendar_webhook.py new file mode 100644 index 000000000..d73db0c43 --- /dev/null +++ b/backend/test/integration/test_google_calendar_webhook.py @@ -0,0 +1,231 @@ +"""Integration tests for the Google Calendar webhook endpoint and watch channel lifecycle.""" + +import json +import os +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock + +from appointment.controller.apis.google_client import GoogleClient +from appointment.database import models, repo +from appointment.dependencies import google as google_dep + +from defines import auth_headers, TEST_USER_ID + + +class TestGoogleCalendarWebhook: + def test_sync_notification_returns_200(self, with_client): + """Google sends a sync notification when a channel is first created.""" + response = with_client.post( + '/webhooks/google-calendar', + headers={ + 'X-Goog-Channel-Id': 'some-channel-id', + 'X-Goog-Resource-State': 'sync', + }, + ) + assert response.status_code == 200 + + def test_missing_channel_id_returns_200(self, with_client): + response = with_client.post( + '/webhooks/google-calendar', + headers={ + 'X-Goog-Resource-State': 'exists', + }, + ) + assert response.status_code == 200 + + def test_unknown_channel_id_returns_200(self, with_client): + response = with_client.post( + '/webhooks/google-calendar', + headers={ + 'X-Goog-Channel-Id': 'unknown-channel-id', + 'X-Goog-Resource-State': 'exists', + }, + ) + assert response.status_code == 200 + + def test_disconnected_calendar_triggers_teardown( + self, with_db, with_client, make_pro_subscriber, make_google_calendar, make_external_connections + ): + """If the calendar is no longer connected, the channel should be cleaned up.""" + subscriber = make_pro_subscriber() + google_creds = json.dumps({ + 'token': 'fake-token', + 'refresh_token': 'fake-refresh', + 'client_id': 'fake-client-id', + 'client_secret': 'fake-secret', + }) + ext_conn = make_external_connections( + subscriber.id, + type=models.ExternalConnectionType.google, + token=google_creds, + ) + calendar = make_google_calendar( + subscriber_id=subscriber.id, + connected=False, + external_connection_id=ext_conn.id, + ) + + with with_db() as db: + repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id='disconnected-cal-channel', + resource_id='res-disconnected', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + sync_token='some-sync-token', + ) + + response = with_client.post( + '/webhooks/google-calendar', + headers={ + 'X-Goog-Channel-Id': 'disconnected-cal-channel', + 'X-Goog-Resource-State': 'exists', + }, + ) + assert response.status_code == 200 + + with with_db() as db: + assert repo.google_calendar_channel.get_by_channel_id(db, 'disconnected-cal-channel') is None + + def test_valid_notification_returns_200( + self, with_db, with_client, make_pro_subscriber, make_google_calendar, make_external_connections + ): + """A valid notification for a connected calendar should return 200 and update the sync token.""" + mock_google_client = MagicMock(spec=GoogleClient) + mock_google_client.SCOPES = GoogleClient.SCOPES + mock_google_client.list_events_sync.return_value = ([], 'new-sync-token') + with_client.app.dependency_overrides[google_dep.get_google_client] = lambda: mock_google_client + + subscriber = make_pro_subscriber() + google_creds = json.dumps({ + 'token': 'fake-token', + 'refresh_token': 'fake-refresh', + 'client_id': 'fake-client-id', + 'client_secret': 'fake-secret', + }) + ext_conn = make_external_connections( + subscriber.id, + type=models.ExternalConnectionType.google, + token=google_creds, + ) + calendar = make_google_calendar( + subscriber_id=subscriber.id, + connected=True, + external_connection_id=ext_conn.id, + ) + + with with_db() as db: + repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id='valid-channel', + resource_id='res-valid', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + sync_token='initial-sync-token', + ) + + response = with_client.post( + '/webhooks/google-calendar', + headers={ + 'X-Goog-Channel-Id': 'valid-channel', + 'X-Goog-Resource-State': 'exists', + }, + ) + assert response.status_code == 200 + + with with_db() as db: + channel = repo.google_calendar_channel.get_by_channel_id(db, 'valid-channel') + assert channel is not None + assert channel.sync_token == 'new-sync-token' + + +class TestCalendarConnectWatchChannel: + """Tests that connecting/disconnecting a Google calendar sets up/tears down watch channels.""" + + def _make_mock_google_client(self): + mock = MagicMock(spec=GoogleClient) + mock.SCOPES = GoogleClient.SCOPES + mock.watch_events.return_value = { + 'id': 'auto-channel-id', + 'resourceId': 'auto-resource-id', + 'expiration': str(int((datetime.now(tz=timezone.utc) + timedelta(days=7)).timestamp() * 1000)), + } + mock.get_initial_sync_token.return_value = 'auto-sync-token' + return mock + + def test_connect_google_calendar_creates_channel( + self, with_db, with_client, make_google_calendar, make_external_connections + ): + mock_gc = self._make_mock_google_client() + with_client.app.dependency_overrides[google_dep.get_google_client] = lambda: mock_gc + + google_creds = json.dumps({ + 'token': 'fake-token', + 'refresh_token': 'fake-refresh', + 'client_id': 'fake-client-id', + 'client_secret': 'fake-secret', + }) + ext_conn = make_external_connections( + TEST_USER_ID, type=models.ExternalConnectionType.google, token=google_creds, + ) + calendar = make_google_calendar( + subscriber_id=TEST_USER_ID, connected=False, external_connection_id=ext_conn.id, + ) + + os.environ['BACKEND_URL'] = 'http://localhost:5000' + + response = with_client.post(f'/cal/{calendar.id}/connect', headers=auth_headers) + assert response.status_code == 200, response.text + assert response.json()['connected'] is True + + with with_db() as db: + channel = repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) + assert channel is not None + assert channel.channel_id == 'auto-channel-id' + assert channel.sync_token == 'auto-sync-token' + + def test_disconnect_google_calendar_removes_channel( + self, with_db, with_client, make_google_calendar, make_external_connections + ): + mock_gc = self._make_mock_google_client() + with_client.app.dependency_overrides[google_dep.get_google_client] = lambda: mock_gc + + google_creds = json.dumps({ + 'token': 'fake-token', + 'refresh_token': 'fake-refresh', + 'client_id': 'fake-client-id', + 'client_secret': 'fake-secret', + }) + ext_conn = make_external_connections( + TEST_USER_ID, type=models.ExternalConnectionType.google, token=google_creds, + ) + calendar = make_google_calendar( + subscriber_id=TEST_USER_ID, connected=True, external_connection_id=ext_conn.id, + ) + + with with_db() as db: + repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id='to-be-torn-down', + resource_id='res-teardown', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + ) + + response = with_client.post(f'/cal/{calendar.id}/disconnect', headers=auth_headers) + assert response.status_code == 200, response.text + assert response.json()['connected'] is False + + with with_db() as db: + assert repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) is None + mock_gc.stop_channel.assert_called_once() + + def test_connect_caldav_calendar_no_channel(self, with_db, with_client, make_caldav_calendar): + """Connecting a CalDAV calendar should not create a watch channel.""" + calendar = make_caldav_calendar(connected=False) + + response = with_client.post(f'/cal/{calendar.id}/connect', headers=auth_headers) + assert response.status_code == 200, response.text + + with with_db() as db: + assert repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) is None diff --git a/backend/test/unit/test_google_webhook.py b/backend/test/unit/test_google_webhook.py new file mode 100644 index 000000000..beb55638b --- /dev/null +++ b/backend/test/unit/test_google_webhook.py @@ -0,0 +1,445 @@ +"""Unit tests for the Google Calendar webhook processing logic.""" + +import json +import os +from datetime import datetime, timedelta, timezone +from unittest.mock import Mock + +from appointment.controller.google_watch import setup_watch_channel, teardown_watch_channel +from appointment.database import models, repo +from appointment.routes.webhooks import _find_appointment_by_external_id, _handle_attendee_rsvp + + +class TestFindAppointmentByExternalId: + def test_finds_matching_appointment(self, with_db, make_google_calendar, make_appointment): + calendar = make_google_calendar(connected=True) + appointment = make_appointment(calendar_id=calendar.id, slots=None) + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment.id) + db_appointment.external_id = 'google-event-123' + db.commit() + + result = _find_appointment_by_external_id(db, calendar.id, 'google-event-123') + assert result is not None + assert result.id == appointment.id + + def test_returns_none_for_no_match(self, with_db, make_google_calendar, make_appointment): + calendar = make_google_calendar(connected=True) + make_appointment(calendar_id=calendar.id, slots=None) + + with with_db() as db: + result = _find_appointment_by_external_id(db, calendar.id, 'nonexistent-event') + assert result is None + + def test_scoped_to_calendar(self, with_db, make_google_calendar, make_appointment, make_pro_subscriber): + """Appointments on other calendars should not be found.""" + sub = make_pro_subscriber() + cal1 = make_google_calendar(subscriber_id=sub.id, connected=True) + cal2 = make_google_calendar(subscriber_id=sub.id, connected=True) + appt = make_appointment(calendar_id=cal1.id, slots=None) + + with with_db() as db: + db_appt = repo.appointment.get(db, appt.id) + db_appt.external_id = 'event-on-cal1' + db.commit() + + assert _find_appointment_by_external_id(db, cal1.id, 'event-on-cal1') is not None + assert _find_appointment_by_external_id(db, cal2.id, 'event-on-cal1') is None + + +class TestHandleAttendeeRsvp: + def _make_test_objects(self, with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot): + calendar = make_google_calendar(connected=True) + attendee = make_attendee(email='bookee@example.com', name='Bookee') + appointment = make_appointment( + calendar_id=calendar.id, + status=models.AppointmentStatus.opened, + slots=None, + ) + make_appointment_slot( + appointment_id=appointment.id, + booking_status=models.BookingStatus.requested, + attendee_id=attendee.id, + ) + + with with_db() as db: + db_appt = repo.appointment.get(db, appointment.id) + db_appt.external_id = 'google-event-456' + db.commit() + + # Retrieve slot ID from DB to avoid detached instance issues + slot_id = db_appt.slots[0].id if db_appt.slots else None + + return calendar, appointment.id, slot_id, attendee + + def test_decline_marks_slot_declined( + self, with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot + ): + calendar, appointment_id, slot_id, attendee = self._make_test_objects( + with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot + ) + + mock_client = Mock() + mock_client.delete_event = Mock() + mock_token = Mock() + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment_id) + db_slot = repo.slot.get(db, slot_id) + + _handle_attendee_rsvp( + db, db_appointment, db_slot, 'declined', mock_client, mock_token, calendar.user + ) + + db.refresh(db_slot) + assert db_slot.booking_status == models.BookingStatus.declined + mock_client.delete_event.assert_called_once() + + def test_accept_confirms_requested_slot( + self, with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot + ): + calendar, appointment_id, slot_id, attendee = self._make_test_objects( + with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot + ) + + mock_client = Mock() + mock_client.delete_event = Mock() + mock_client.insert_event = Mock(return_value={'id': 'new-confirmed-event-id'}) + mock_token = Mock() + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment_id) + db_slot = repo.slot.get(db, slot_id) + + _handle_attendee_rsvp( + db, db_appointment, db_slot, 'accepted', mock_client, mock_token, calendar.user + ) + + db.refresh(db_slot) + assert db_slot.booking_status == models.BookingStatus.booked + + db.refresh(db_appointment) + assert db_appointment.status == models.AppointmentStatus.closed + assert db_appointment.external_id == 'new-confirmed-event-id' + + def test_accept_noop_for_already_booked( + self, with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot + ): + calendar = make_google_calendar(connected=True) + attendee = make_attendee(email='bookee@example.com') + appointment = make_appointment( + calendar_id=calendar.id, + status=models.AppointmentStatus.closed, + slots=None, + ) + make_appointment_slot( + appointment_id=appointment.id, + booking_status=models.BookingStatus.booked, + attendee_id=attendee.id, + ) + + mock_client = Mock() + mock_token = Mock() + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment.id) + db_slot = db_appointment.slots[0] + + _handle_attendee_rsvp( + db, db_appointment, db_slot, 'accepted', mock_client, mock_token, calendar.user + ) + + db.refresh(db_slot) + assert db_slot.booking_status == models.BookingStatus.booked + mock_client.insert_event.assert_not_called() + + def test_needsaction_is_ignored( + self, with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot + ): + calendar, appointment_id, slot_id, attendee = self._make_test_objects( + with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot + ) + + mock_client = Mock() + mock_token = Mock() + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment_id) + db_slot = repo.slot.get(db, slot_id) + + _handle_attendee_rsvp( + db, db_appointment, db_slot, 'needsAction', mock_client, mock_token, calendar.user + ) + + db.refresh(db_slot) + assert db_slot.booking_status == models.BookingStatus.requested + + +class TestGoogleCalendarChannelRepo: + def test_create_and_get_by_channel_id(self, with_db, make_google_calendar): + calendar = make_google_calendar(connected=True) + + with with_db() as db: + channel = repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id='test-channel-abc', + resource_id='test-resource-xyz', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + ) + assert channel.id is not None + + found = repo.google_calendar_channel.get_by_channel_id(db, 'test-channel-abc') + assert found is not None + assert found.id == channel.id + + def test_get_by_calendar_id(self, with_db, make_google_calendar): + calendar = make_google_calendar(connected=True) + + with with_db() as db: + repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id='channel-for-cal', + resource_id='resource-for-cal', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + ) + + found = repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) + assert found is not None + assert found.channel_id == 'channel-for-cal' + + def test_update_sync_token(self, with_db, make_google_calendar): + calendar = make_google_calendar(connected=True) + + with with_db() as db: + channel = repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id='ch-sync-test', + resource_id='res-sync-test', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + ) + assert channel.sync_token is None + + updated = repo.google_calendar_channel.update_sync_token(db, channel, 'new-sync-token-123') + assert updated.sync_token == 'new-sync-token-123' + + def test_get_expiring(self, with_db, make_google_calendar, make_pro_subscriber): + sub = make_pro_subscriber() + cal1 = make_google_calendar(subscriber_id=sub.id, connected=True) + cal2 = make_google_calendar(subscriber_id=sub.id, connected=True) + + now = datetime.now(tz=timezone.utc) + with with_db() as db: + repo.google_calendar_channel.create( + db, + calendar_id=cal1.id, + channel_id='expiring-soon', + resource_id='res-1', + expiration=now + timedelta(hours=12), + ) + repo.google_calendar_channel.create( + db, + calendar_id=cal2.id, + channel_id='not-expiring', + resource_id='res-2', + expiration=now + timedelta(days=5), + ) + + threshold = now + timedelta(hours=24) + expiring = repo.google_calendar_channel.get_expiring(db, before=threshold) + assert len(expiring) == 1 + assert expiring[0].channel_id == 'expiring-soon' + + def test_delete(self, with_db, make_google_calendar): + calendar = make_google_calendar(connected=True) + + with with_db() as db: + channel = repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id='to-delete', + resource_id='res-del', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + ) + + repo.google_calendar_channel.delete(db, channel) + + assert repo.google_calendar_channel.get_by_channel_id(db, 'to-delete') is None + + def test_cascade_delete_with_calendar(self, with_db, make_google_calendar): + """Channel should be deleted when its calendar is deleted.""" + calendar = make_google_calendar(connected=True) + + with with_db() as db: + repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id='cascade-test', + resource_id='res-cascade', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + ) + + repo.calendar.delete(db, calendar.id) + + assert repo.google_calendar_channel.get_by_channel_id(db, 'cascade-test') is None + + +class TestSetupWatchChannel: + def test_creates_channel_for_google_calendar( + self, with_db, make_google_calendar, make_external_connections, make_pro_subscriber + ): + subscriber = make_pro_subscriber() + google_creds = json.dumps({ + 'token': 'fake-token', + 'refresh_token': 'fake-refresh', + 'client_id': 'fake-client-id', + 'client_secret': 'fake-secret', + }) + ext_conn = make_external_connections( + subscriber.id, type=models.ExternalConnectionType.google, token=google_creds, + ) + calendar = make_google_calendar( + subscriber_id=subscriber.id, connected=True, external_connection_id=ext_conn.id, + ) + + mock_client = Mock() + mock_client.SCOPES = ['https://www.googleapis.com/auth/calendar.events'] + mock_client.watch_events.return_value = { + 'id': 'new-channel-id', + 'resourceId': 'new-resource-id', + 'expiration': str(int((datetime.now(tz=timezone.utc) + timedelta(days=7)).timestamp() * 1000)), + } + mock_client.get_initial_sync_token.return_value = 'initial-sync-token' + + os.environ['BACKEND_URL'] = 'http://localhost:5000' + + with with_db() as db: + db_cal = repo.calendar.get(db, calendar.id) + setup_watch_channel(db, mock_client, db_cal) + + channel = repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) + assert channel is not None + assert channel.channel_id == 'new-channel-id' + assert channel.sync_token == 'initial-sync-token' + + def test_noop_if_channel_already_exists( + self, with_db, make_google_calendar, make_external_connections, make_pro_subscriber + ): + subscriber = make_pro_subscriber() + google_creds = json.dumps({ + 'token': 'fake-token', + 'refresh_token': 'fake-refresh', + 'client_id': 'fake-client-id', + 'client_secret': 'fake-secret', + }) + ext_conn = make_external_connections( + subscriber.id, type=models.ExternalConnectionType.google, token=google_creds, + ) + calendar = make_google_calendar( + subscriber_id=subscriber.id, connected=True, external_connection_id=ext_conn.id, + ) + + with with_db() as db: + repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id='existing-channel', + resource_id='existing-resource', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + ) + + mock_client = Mock() + + with with_db() as db: + db_cal = repo.calendar.get(db, calendar.id) + setup_watch_channel(db, mock_client, db_cal) + + mock_client.watch_events.assert_not_called() + + def test_noop_for_caldav_calendar(self, with_db, make_caldav_calendar): + calendar = make_caldav_calendar(connected=True) + mock_client = Mock() + + with with_db() as db: + db_cal = repo.calendar.get(db, calendar.id) + setup_watch_channel(db, mock_client, db_cal) + + mock_client.watch_events.assert_not_called() + + def test_noop_if_no_google_client(self, with_db, make_google_calendar): + calendar = make_google_calendar(connected=True) + + with with_db() as db: + db_cal = repo.calendar.get(db, calendar.id) + setup_watch_channel(db, None, db_cal) + + assert repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) is None + + +class TestTeardownWatchChannel: + def test_removes_existing_channel( + self, with_db, make_google_calendar, make_external_connections, make_pro_subscriber + ): + subscriber = make_pro_subscriber() + google_creds = json.dumps({ + 'token': 'fake-token', + 'refresh_token': 'fake-refresh', + 'client_id': 'fake-client-id', + 'client_secret': 'fake-secret', + }) + ext_conn = make_external_connections( + subscriber.id, type=models.ExternalConnectionType.google, token=google_creds, + ) + calendar = make_google_calendar( + subscriber_id=subscriber.id, connected=True, external_connection_id=ext_conn.id, + ) + + with with_db() as db: + repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id='teardown-channel', + resource_id='teardown-resource', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + ) + + mock_client = Mock() + mock_client.SCOPES = ['https://www.googleapis.com/auth/calendar.events'] + + with with_db() as db: + db_cal = repo.calendar.get(db, calendar.id) + teardown_watch_channel(db, mock_client, db_cal) + + mock_client.stop_channel.assert_called_once() + assert repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) is None + + def test_noop_if_no_channel(self, with_db, make_google_calendar): + calendar = make_google_calendar(connected=True) + mock_client = Mock() + + with with_db() as db: + db_cal = repo.calendar.get(db, calendar.id) + teardown_watch_channel(db, mock_client, db_cal) + + mock_client.stop_channel.assert_not_called() + + def test_deletes_record_even_without_google_client(self, with_db, make_google_calendar): + calendar = make_google_calendar(connected=True) + + with with_db() as db: + repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id='orphan-channel', + resource_id='orphan-resource', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + ) + + with with_db() as db: + db_cal = repo.calendar.get(db, calendar.id) + teardown_watch_channel(db, None, db_cal) + + assert repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) is None From 106fc7ec39223ce57a7d14abd40d80c55cb0b36c Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Wed, 25 Feb 2026 09:44:09 -0600 Subject: [PATCH 13/48] Fix lint --- backend/src/appointment/routes/commands.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/src/appointment/routes/commands.py b/backend/src/appointment/routes/commands.py index e88e1d9b1..0f3d376ba 100644 --- a/backend/src/appointment/routes/commands.py +++ b/backend/src/appointment/routes/commands.py @@ -4,7 +4,14 @@ import os import typer -from ..commands import update_db, download_legal, setup, generate_documentation_pages, renew_google_channels, backfill_google_channels +from ..commands import ( + update_db, + download_legal, + setup, + generate_documentation_pages, + renew_google_channels, + backfill_google_channels, +) router = typer.Typer() From 2e85bf1f8f07a7ca0f1cf9514058b917bc7a1504 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Fri, 6 Mar 2026 16:08:34 -0600 Subject: [PATCH 14/48] Create google events as tentative and add patch_event method --- .../controller/apis/google_client.py | 30 +++++++++++++++++-- .../src/appointment/controller/calendar.py | 19 ++++++++++-- .../src/appointment/exceptions/calendar.py | 5 ++++ 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/backend/src/appointment/controller/apis/google_client.py b/backend/src/appointment/controller/apis/google_client.py index 40e197aed..30bf46825 100644 --- a/backend/src/appointment/controller/apis/google_client.py +++ b/backend/src/appointment/controller/apis/google_client.py @@ -13,7 +13,12 @@ from ...database.models import CalendarProvider from ...database.schemas import CalendarConnection from ...defines import DATETIMEFMT -from ...exceptions.calendar import EventNotCreatedException, EventNotDeletedException, FreeBusyTimeException +from ...exceptions.calendar import ( + EventNotCreatedException, + EventNotDeletedException, + EventNotPatchedException, + FreeBusyTimeException +) from ...exceptions.google_api import GoogleScopeChanged, GoogleInvalidCredentials @@ -260,11 +265,30 @@ def insert_event(self, calendar_id, body, token, send_updates='all'): return response - def delete_event(self, calendar_id, event_id, token): + def patch_event(self, calendar_id, event_id, body, token, send_updates='all'): response = None with build('calendar', 'v3', credentials=token, cache_discovery=False) as service: try: - response = service.events().delete(calendarId=calendar_id, eventId=event_id).execute() + response = ( + service.events() + .patch(calendarId=calendar_id, eventId=event_id, body=body, sendUpdates=send_updates) + .execute() + ) + except HttpError as e: + logging.warning(f'[google_client.patch_event] Request Error: {e.status_code}/{e.error_details}') + raise EventNotPatchedException() + + return response + + def delete_event(self, calendar_id, event_id, token, send_updates='none'): + response = None + with build('calendar', 'v3', credentials=token, cache_discovery=False) as service: + try: + response = ( + service.events() + .delete(calendarId=calendar_id, eventId=event_id, sendUpdates=send_updates) + .execute() + ) except HttpError as e: logging.warning(f'[google_client.delete_event] Request Error: {e.status_code}/{e.error_details}') raise EventNotDeletedException() diff --git a/backend/src/appointment/controller/calendar.py b/backend/src/appointment/controller/calendar.py index 6f6175848..9f1ba88a3 100644 --- a/backend/src/appointment/controller/calendar.py +++ b/backend/src/appointment/controller/calendar.py @@ -289,6 +289,7 @@ def save_event( 'description': '\n'.join(description), 'start': {'dateTime': event.start.isoformat()}, 'end': {'dateTime': event.end.isoformat()}, + 'status': 'tentative', 'attendees': [ {'displayName': attendee.name, 'email': attendee.email, 'responseStatus': 'needsAction'}, ], @@ -326,9 +327,21 @@ def save_event( return event - def delete_event(self, uid: str): + def confirm_event(self, event_id: str): + """Patch a tentative event to confirmed status, notifying attendees.""" + self.google_client.patch_event( + calendar_id=self.remote_calendar_id, + event_id=event_id, + body={'status': 'confirmed'}, + token=self.google_token, + ) + self.bust_cached_events() + + def delete_event(self, uid: str, send_updates: str = 'none'): """Delete remote event of given external_id""" - self.google_client.delete_event(calendar_id=self.remote_calendar_id, event_id=uid, token=self.google_token) + self.google_client.delete_event( + calendar_id=self.remote_calendar_id, event_id=uid, token=self.google_token, send_updates=send_updates + ) self.bust_cached_events() def delete_events(self, start): @@ -593,7 +606,7 @@ def save_event( return event - def delete_event(self, uid: str): + def delete_event(self, uid: str, send_updates: str = 'none'): """Delete remote event of given uid""" event = self.client.calendar(url=self.url).event_by_uid(uid) event.delete() diff --git a/backend/src/appointment/exceptions/calendar.py b/backend/src/appointment/exceptions/calendar.py index da4688ae9..5fe49c9f4 100644 --- a/backend/src/appointment/exceptions/calendar.py +++ b/backend/src/appointment/exceptions/calendar.py @@ -4,6 +4,11 @@ class EventNotCreatedException(Exception): pass +class EventNotPatchedException(Exception): + """Raise if an event cannot be patched on a remote calendar""" + + pass + class EventNotDeletedException(Exception): """Raise if an event cannot be deleted on a remote calendar""" From 8573587d00bfe0ebab6c6d951cf80f5dd22c3f69 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Fri, 6 Mar 2026 17:17:15 -0600 Subject: [PATCH 15/48] Skip HOLD event for Google events, and confirm through event through Calendar API --- backend/src/appointment/routes/schedule.py | 62 +++++++++++----------- 1 file changed, 30 insertions(+), 32 deletions(-) diff --git a/backend/src/appointment/routes/schedule.py b/backend/src/appointment/routes/schedule.py index 795376e60..069409319 100644 --- a/backend/src/appointment/routes/schedule.py +++ b/backend/src/appointment/routes/schedule.py @@ -398,7 +398,12 @@ def request_schedule_availability_slot( # Create a pending appointment owner_language = subscriber.language if subscriber.language is not None else FALLBACK_LOCALE - prefix = f'{l10n("event-hold-prefix", lang=owner_language)} ' if schedule.booking_confirmation else '' + use_google_invite = calendar.provider == CalendarProvider.google + + # Google invites skip the HOLD step — the event is created directly and + # Google sends the invite to the subscriber for accept/decline. + use_hold = schedule.booking_confirmation and not use_google_invite + prefix = f'{l10n("event-hold-prefix", lang=owner_language)} ' if use_hold else '' title = Tools.default_event_title(slot, subscriber, prefix) status = models.AppointmentStatus.opened if schedule.booking_confirmation else models.AppointmentStatus.closed @@ -432,7 +437,6 @@ def request_schedule_availability_slot( # If bookings are configured to be confirmed by the owner for this schedule, # Create HOLD event in owners calender and send emails to owner for confirmation and attendee for information if schedule.booking_confirmation: - # Sending confirmation email to owner background_tasks.add_task( send_confirmation_email, url=url, @@ -445,7 +449,6 @@ def request_schedule_availability_slot( lang=subscriber.language, ) - # Create remote HOLD event event = schemas.Event( title=title, start=slot.start.replace(tzinfo=timezone.utc), @@ -459,9 +462,6 @@ def request_schedule_availability_slot( uuid=slot.appointment.uuid if slot.appointment else None, ) - # create HOLD event in owners calender - use_google_invite = calendar.provider == CalendarProvider.google - event = save_remote_event( event, calendar, subscriber, slot, db, redis, google_client, send_google_notification=use_google_invite, @@ -470,7 +470,6 @@ def request_schedule_availability_slot( if appointment and event.external_id: repo.appointment.update_external_id(db, appointment, event.external_id) - # When Google handles the invitation via insert(), skip the branded email. if not use_google_invite: Tools().send_hold_vevent(background_tasks, slot.appointment, slot, subscriber, slot.attendee) @@ -582,11 +581,15 @@ def handle_schedule_availability_decision( db.add(appointment) appointment_calendar = appointment.calendar + use_google_invite = calendar.provider == CalendarProvider.google + # TODO: Check booking expiration date # check if request was denied if confirmed is False: - # send rejection information to bookee - Tools().send_reject_vevent(background_tasks, appointment, slot, subscriber, slot.attendee) + # For non-Google calendars, send branded rejection email to the bookee. + # For Google, the delete with sendUpdates handles the cancellation notification. + if not use_google_invite: + Tools().send_reject_vevent(background_tasks, appointment, slot, subscriber, slot.attendee) # mark the slot as BookingStatus.declined slot_update = schemas.SlotUpdate(booking_status=models.BookingStatus.declined) @@ -595,7 +598,10 @@ def handle_schedule_availability_decision( # Delete remote HOLD event if existing if appointment: uuid = slot.appointment.external_id if slot.appointment.external_id else str(slot.appointment.uuid) - delete_remote_event(uuid, appointment_calendar, subscriber, db, redis, google_client) + send_updates = 'all' if use_google_invite else 'none' + delete_remote_event( + uuid, appointment_calendar, subscriber, db, redis, google_client, send_updates=send_updates + ) return True @@ -668,27 +674,21 @@ def handle_schedule_availability_decision( # Update HOLD event appointment = repo.appointment.update_title(db, slot.appointment_id, title) - use_google_invite = calendar.provider == CalendarProvider.google - - # When using Google insert(), we must delete any existing HOLD event first - # because insert() creates a new event (unlike import_() which upserts by iCalUID). - if use_google_invite and appointment and appointment.external_id: - try: - delete_remote_event(appointment.external_id, appointment_calendar, subscriber, db, redis, google_client) - except EventCouldNotBeDeleted: - logging.warning('[schedule] Failed to delete HOLD event before Google insert, continuing anyway') - - event = save_remote_event( - event, appointment_calendar, subscriber, slot, db, redis, google_client, - send_google_notification=use_google_invite, - ) - if appointment and event.external_id: - repo.appointment.update_external_id(db, appointment, event.external_id) + if use_google_invite: + # Patch the tentative event to confirmed; Google notifies the bookee. + if appointment and appointment.external_id: + con, _ = get_remote_connection(appointment_calendar, subscriber, db, redis, google_client) + con.confirm_event(appointment.external_id) + else: + event = save_remote_event( + event, appointment_calendar, subscriber, slot, db, redis, google_client, + ) + if appointment and event.external_id: + repo.appointment.update_external_id(db, appointment, event.external_id) # Book the slot at the end slot = repo.slot.book(db, slot.id) - # When Google handles the invitation via insert(), skip the branded email. if not use_google_invite: Tools().send_invitation_vevent(background_tasks, appointment, slot, subscriber, slot.attendee) @@ -749,13 +749,11 @@ def save_remote_event(event, calendar, subscriber, slot, db, redis, google_clien raise EventCouldNotBeAccepted -def delete_remote_event(uid: str, calendar, subscriber, db, redis, google_client): - """Delete a remote event - if is_hold: remove an event from remote calendar - """ +def delete_remote_event(uid: str, calendar, subscriber, db, redis, google_client, send_updates: str = 'none'): + """Delete a remote event from the connected calendar.""" con, _ = get_remote_connection(calendar, subscriber, db, redis, google_client) try: - con.delete_event(uid=uid) + con.delete_event(uid=uid, send_updates=send_updates) except EventNotDeletedException: raise EventCouldNotBeDeleted From d8c1b682d666c9bc67d0c882e5dc726906d38fd2 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Fri, 6 Mar 2026 17:17:45 -0600 Subject: [PATCH 16/48] Only care about the declined in webhook --- backend/src/appointment/routes/webhooks.py | 56 ++-------------------- 1 file changed, 5 insertions(+), 51 deletions(-) diff --git a/backend/src/appointment/routes/webhooks.py b/backend/src/appointment/routes/webhooks.py index e25b05192..6f52fcfb4 100644 --- a/backend/src/appointment/routes/webhooks.py +++ b/backend/src/appointment/routes/webhooks.py @@ -286,55 +286,9 @@ def _handle_attendee_rsvp( ) elif response_status == 'accepted': - if slot.booking_status == models.BookingStatus.requested: - repo.slot.book(db, slot.id) - repo.appointment.update_status(db, slot.appointment_id, models.AppointmentStatus.closed) - - if appointment.external_id: - try: - google_client.delete_event(remote_calendar_id, appointment.external_id, google_token) - except Exception: - logging.warning('[webhooks.google_calendar] Failed to delete HOLD event after accept') - - _create_confirmed_google_event(db, appointment, slot, google_client, google_token, remote_calendar_id) - - logging.info( - f'[webhooks.google_calendar] Attendee accepted appointment {appointment.id}, ' - f'slot {slot.id} confirmed' - ) - - -def _create_confirmed_google_event( - db: Session, - appointment: models.Appointment, - slot: models.Slot, - google_client: GoogleClient, - google_token, - remote_calendar_id: str, -): - """Create a confirmed Google Calendar event after an attendee accepts a HOLD.""" - from datetime import timedelta, timezone - - description_parts = [appointment.details or ''] - body = { - 'summary': appointment.title, - 'location': appointment.location_url if appointment.location_url else None, - 'description': '\n'.join(description_parts), - 'start': {'dateTime': slot.start.replace(tzinfo=timezone.utc).isoformat()}, - 'end': {'dateTime': (slot.start.replace(tzinfo=timezone.utc) + timedelta(minutes=slot.duration)).isoformat()}, - 'attendees': [ - { - 'displayName': slot.attendee.name, - 'email': slot.attendee.email, - 'responseStatus': 'accepted', - }, - ], - } - - try: - new_event = google_client.insert_event( - calendar_id=remote_calendar_id, body=body, token=google_token + # The bookee accepted the tentative invite, but the subscriber must + # still confirm via the branded email or app UI. Just log it for now. + logging.info( + f'[webhooks.google_calendar] Attendee accepted appointment {appointment.id}, ' + f'slot {slot.id} — awaiting subscriber confirmation' ) - repo.appointment.update_external_id(db, appointment, new_event.get('id')) - except Exception as e: - logging.error(f'[webhooks.google_calendar] Failed to create confirmed event: {e}') From acfa2d2a567f55efc2a48b798b60a23a4fffa968 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Fri, 6 Mar 2026 17:17:55 -0600 Subject: [PATCH 17/48] Update tests --- backend/test/conftest.py | 2 +- backend/test/unit/test_google_webhook.py | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/backend/test/conftest.py b/backend/test/conftest.py index 1a5796b8f..c27900c85 100644 --- a/backend/test/conftest.py +++ b/backend/test/conftest.py @@ -54,7 +54,7 @@ def save_event(self, event, attendee, organizer, organizer_email, send_google_no return event @staticmethod - def delete_event(self, uid): + def delete_event(self, uid, send_updates='none'): return True @staticmethod diff --git a/backend/test/unit/test_google_webhook.py b/backend/test/unit/test_google_webhook.py index beb55638b..5eab0f1fb 100644 --- a/backend/test/unit/test_google_webhook.py +++ b/backend/test/unit/test_google_webhook.py @@ -96,16 +96,16 @@ def test_decline_marks_slot_declined( assert db_slot.booking_status == models.BookingStatus.declined mock_client.delete_event.assert_called_once() - def test_accept_confirms_requested_slot( + def test_accept_does_not_auto_book( self, with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot ): + """Bookee accepting the tentative invite should not auto-book; + the subscriber must still confirm via the branded email or app UI.""" calendar, appointment_id, slot_id, attendee = self._make_test_objects( with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot ) mock_client = Mock() - mock_client.delete_event = Mock() - mock_client.insert_event = Mock(return_value={'id': 'new-confirmed-event-id'}) mock_token = Mock() with with_db() as db: @@ -117,11 +117,10 @@ def test_accept_confirms_requested_slot( ) db.refresh(db_slot) - assert db_slot.booking_status == models.BookingStatus.booked + assert db_slot.booking_status == models.BookingStatus.requested db.refresh(db_appointment) - assert db_appointment.status == models.AppointmentStatus.closed - assert db_appointment.external_id == 'new-confirmed-event-id' + assert db_appointment.status == models.AppointmentStatus.opened def test_accept_noop_for_already_booked( self, with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot From 46e1f47bdac84581aa68f44870cd1cd6910e25f0 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Mon, 9 Mar 2026 11:11:30 -0600 Subject: [PATCH 18/48] Only create watch channels for default connected calendars --- .../commands/backfill_google_channels.py | 10 +++++++- backend/src/appointment/routes/api.py | 8 ------- backend/src/appointment/routes/schedule.py | 24 ++++++++++++++++++- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/backend/src/appointment/commands/backfill_google_channels.py b/backend/src/appointment/commands/backfill_google_channels.py index 0813b4c0f..2c0b3ed70 100644 --- a/backend/src/appointment/commands/backfill_google_channels.py +++ b/backend/src/appointment/commands/backfill_google_channels.py @@ -26,10 +26,18 @@ def run(): db.close() return - # Find all connected Google calendars that don't yet have a watch channel + # Find connected Google calendars that are the default in a schedule + # and don't yet have a watch channel + schedule_calendar_ids = { + s.calendar_id + for s in db.query(models.Schedule).all() + if s.calendar_id is not None + } + all_calendars = db.query(models.Calendar).filter( models.Calendar.provider == models.CalendarProvider.google, models.Calendar.connected == True, # noqa: E712 + models.Calendar.id.in_(schedule_calendar_ids), ).all() candidates = [] diff --git a/backend/src/appointment/routes/api.py b/backend/src/appointment/routes/api.py index 842e39312..e057e7c0e 100644 --- a/backend/src/appointment/routes/api.py +++ b/backend/src/appointment/routes/api.py @@ -16,7 +16,6 @@ # authentication from ..controller.calendar import CalDavConnector, Tools, GoogleConnector -from ..controller.google_watch import setup_watch_channel, teardown_watch_channel from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Request, Response, Query from ..controller.apis.google_client import GoogleClient from ..controller.auth import signed_url_by_subscriber, schedule_slugs_by_subscriber, user_links_by_subscriber @@ -271,7 +270,6 @@ def change_my_calendar_connection( id: int, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber), - google_client: GoogleClient = Depends(get_google_client), ): """endpoint to update an existing calendar connection for authenticated subscriber note this function handles both disconnect and connect (the double route is not a typo.)""" @@ -288,12 +286,6 @@ def change_my_calendar_connection( except HTTPException as e: raise HTTPException(status_code=e.status_code, detail=e.detail) - if cal.provider == CalendarProvider.google: - if connect: - setup_watch_channel(db, google_client, cal) - else: - teardown_watch_channel(db, google_client, cal) - return schemas.CalendarOut(id=cal.id, title=cal.title, color=cal.color, connected=cal.connected) diff --git a/backend/src/appointment/routes/schedule.py b/backend/src/appointment/routes/schedule.py index 069409319..bdda1d29f 100644 --- a/backend/src/appointment/routes/schedule.py +++ b/backend/src/appointment/routes/schedule.py @@ -11,6 +11,7 @@ from ..controller.calendar import CalDavConnector, Tools, GoogleConnector from ..controller.apis.google_client import GoogleClient +from ..controller.google_watch import setup_watch_channel, teardown_watch_channel from ..controller.auth import signed_url_by_subscriber from ..database import repo, schemas, models from ..database.models import ( @@ -137,6 +138,7 @@ def create_calendar_schedule( schedule: schemas.ScheduleValidationIn, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber), + google_client: GoogleClient = Depends(get_google_client), ): """endpoint to add a new schedule for a given calendar""" if not repo.calendar.exists(db, calendar_id=schedule.calendar_id): @@ -157,6 +159,8 @@ def create_calendar_schedule( repo.schedule.hard_delete(db, db_schedule.id) raise validation.ScheduleCreationException() + _sync_watch_channels(db, google_client, subscriber, schedule.calendar_id) + return db_schedule @@ -188,6 +192,7 @@ def update_schedule( schedule: schemas.ScheduleValidationIn, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber), + google_client: GoogleClient = Depends(get_google_client), ): """endpoint to update an existing schedule for authenticated subscriber""" if not repo.schedule.exists(db, schedule_id=id): @@ -210,7 +215,24 @@ def update_schedule( if schedule.use_custom_availabilities and not repo.schedule.all_availability_is_valid(schedule): raise validation.InvalidAvailabilityException() - return repo.schedule.update(db=db, schedule=schedule, schedule_id=id) + result = repo.schedule.update(db=db, schedule=schedule, schedule_id=id) + + _sync_watch_channels(db, google_client, subscriber, schedule.calendar_id) + + return result + + +def _sync_watch_channels(db: Session, google_client: GoogleClient, subscriber: Subscriber, default_calendar_id: int): + """Ensure a watch channel exists only for the schedule's default Google calendar. + Tears down channels on any other Google calendars for this subscriber.""" + calendars = repo.calendar.get_by_subscriber(db, subscriber.id) + for cal in calendars: + if cal.provider != CalendarProvider.google: + continue + if cal.id == default_calendar_id: + setup_watch_channel(db, google_client, cal) + else: + teardown_watch_channel(db, google_client, cal) @router.post('/public/availability', response_model=schemas.AppointmentOut, tags=['no-cache']) From 7ad57f87e14eb29938bf1864167bbde5a41ca1b7 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Mon, 9 Mar 2026 11:33:25 -0600 Subject: [PATCH 19/48] Add tests for backfill and renew channel commands --- backend/test/unit/test_commands.py | 362 ++++++++++++++++++++++++++++- 1 file changed, 361 insertions(+), 1 deletion(-) diff --git a/backend/test/unit/test_commands.py b/backend/test/unit/test_commands.py index 713c891c6..6cc71c841 100644 --- a/backend/test/unit/test_commands.py +++ b/backend/test/unit/test_commands.py @@ -1,7 +1,11 @@ +import json import os - import pytest +from datetime import datetime, timedelta, timezone +from unittest.mock import Mock, patch + +from appointment.database import models, repo from appointment.routes.commands import cron_lock @@ -29,3 +33,359 @@ def test_cron_lock(): # Remove the lock file we manually created os.remove(test_lock_file_name) + + +def _make_google_token(): + return json.dumps( + { + 'token': 'fake-access-token', + 'refresh_token': 'fake-refresh-token', + 'token_uri': 'https://oauth2.googleapis.com/token', + 'client_id': 'fake-client-id', + 'client_secret': 'fake-client-secret', + } + ) + + +class TestBackfillGoogleChannels: + """Tests that the backfill command only creates watch channels + for connected Google calendars that are the default in a schedule.""" + + def test_skips_google_calendar_without_schedule(self, with_db, make_google_calendar, make_external_connections): + """A connected Google calendar not used by any schedule should be skipped.""" + ext = make_external_connections( + subscriber_id=1, + type=models.ExternalConnectionType.google, + token=_make_google_token(), + ) + make_google_calendar(connected=True, external_connection_id=ext.id) + + mock_google_client = Mock() + mock_google_client.SCOPES = ['https://www.googleapis.com/auth/calendar'] + + with patch('appointment.commands.backfill_google_channels._common_setup'): + with patch( + 'appointment.commands.backfill_google_channels.get_google_client', return_value=mock_google_client + ): + with patch( + 'appointment.commands.backfill_google_channels.get_webhook_url', + return_value='https://example.com/webhook', + ): + with patch( + 'appointment.commands.backfill_google_channels.get_engine_and_session', + return_value=(None, with_db), + ): + from appointment.commands.backfill_google_channels import run + + run() + + mock_google_client.watch_events.assert_not_called() + + def test_creates_channel_for_schedule_calendar( + self, with_db, make_google_calendar, make_schedule, make_external_connections + ): + """A connected Google calendar used by a schedule should get a watch channel.""" + ext = make_external_connections( + subscriber_id=1, + type=models.ExternalConnectionType.google, + token=_make_google_token(), + ) + cal = make_google_calendar(connected=True, external_connection_id=ext.id) + make_schedule(calendar_id=cal.id, active=True) + + mock_google_client = Mock() + mock_google_client.SCOPES = ['https://www.googleapis.com/auth/calendar'] + mock_google_client.watch_events.return_value = { + 'id': 'channel-123', + 'resourceId': 'resource-456', + 'expiration': str(int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp() * 1000)), + } + mock_google_client.get_initial_sync_token.return_value = 'sync-token-abc' + + with patch('appointment.commands.backfill_google_channels._common_setup'): + with patch( + 'appointment.commands.backfill_google_channels.get_google_client', return_value=mock_google_client + ): + with patch( + 'appointment.commands.backfill_google_channels.get_webhook_url', + return_value='https://example.com/webhook', + ): + with patch( + 'appointment.commands.backfill_google_channels.get_engine_and_session', + return_value=(None, with_db), + ): + from appointment.commands.backfill_google_channels import run + + run() + + mock_google_client.watch_events.assert_called_once() + + with with_db() as db: + channel = repo.google_calendar_channel.get_by_calendar_id(db, cal.id) + assert channel is not None + assert channel.channel_id == 'channel-123' + assert channel.resource_id == 'resource-456' + assert channel.sync_token == 'sync-token-abc' + + def test_skips_calendar_with_existing_channel( + self, with_db, make_google_calendar, make_schedule, make_external_connections + ): + """A schedule calendar that already has a watch channel should be skipped.""" + ext = make_external_connections( + subscriber_id=1, + type=models.ExternalConnectionType.google, + token=_make_google_token(), + ) + cal = make_google_calendar(connected=True, external_connection_id=ext.id) + make_schedule(calendar_id=cal.id, active=True) + + with with_db() as db: + repo.google_calendar_channel.create( + db, + calendar_id=cal.id, + channel_id='existing-channel', + resource_id='existing-resource', + expiration=datetime(2030, 1, 1, tzinfo=timezone.utc), + sync_token='existing-sync', + ) + + mock_google_client = Mock() + mock_google_client.SCOPES = ['https://www.googleapis.com/auth/calendar'] + + with patch('appointment.commands.backfill_google_channels._common_setup'): + with patch( + 'appointment.commands.backfill_google_channels.get_google_client', return_value=mock_google_client + ): + with patch( + 'appointment.commands.backfill_google_channels.get_webhook_url', + return_value='https://example.com/webhook', + ): + with patch( + 'appointment.commands.backfill_google_channels.get_engine_and_session', + return_value=(None, with_db), + ): + from appointment.commands.backfill_google_channels import run + + run() + + mock_google_client.watch_events.assert_not_called() + + def test_skips_disconnected_google_calendar( + self, with_db, make_google_calendar, make_schedule, make_external_connections + ): + """A disconnected Google calendar should be skipped even if it's in a schedule. + It shouldn't be possible to disconnect a default calendar from a schedule but just in case.""" + ext = make_external_connections( + subscriber_id=1, + type=models.ExternalConnectionType.google, + token=_make_google_token(), + ) + cal = make_google_calendar(connected=False, external_connection_id=ext.id) + make_schedule(calendar_id=cal.id, active=True) + + mock_google_client = Mock() + mock_google_client.SCOPES = ['https://www.googleapis.com/auth/calendar'] + + with patch('appointment.commands.backfill_google_channels._common_setup'): + with patch( + 'appointment.commands.backfill_google_channels.get_google_client', return_value=mock_google_client + ): + with patch( + 'appointment.commands.backfill_google_channels.get_webhook_url', + return_value='https://example.com/webhook', + ): + with patch( + 'appointment.commands.backfill_google_channels.get_engine_and_session', + return_value=(None, with_db), + ): + from appointment.commands.backfill_google_channels import run + + run() + + mock_google_client.watch_events.assert_not_called() + + +class TestRenewGoogleChannels: + """Tests that the renew command correctly refreshes expiring channels.""" + + MODULE = 'appointment.commands.renew_google_channels' + + def _run_renew(self, with_db, mock_google_client): + with patch(f'{self.MODULE}._common_setup'): + with patch(f'{self.MODULE}.get_google_client', return_value=mock_google_client): + with patch(f'{self.MODULE}.get_webhook_url', return_value='https://example.com/webhook'): + with patch(f'{self.MODULE}.get_engine_and_session', return_value=(None, with_db)): + from appointment.commands.renew_google_channels import run + + run() + + def _create_expiring_channel(self, with_db, calendar_id, hours_until_expiry=12): + """Create a channel that expires within the renewal threshold (24h).""" + with with_db() as db: + return repo.google_calendar_channel.create( + db, + calendar_id=calendar_id, + channel_id='old-channel-id', + resource_id='old-resource-id', + expiration=datetime.now(tz=timezone.utc) + timedelta(hours=hours_until_expiry), + sync_token='old-sync-token', + ) + + def test_renews_expiring_channel( + self, with_db, make_google_calendar, make_external_connections + ): + """An expiring channel gets renewed with new ids and expiration.""" + ext = make_external_connections( + subscriber_id=1, + type=models.ExternalConnectionType.google, + token=_make_google_token(), + ) + cal = make_google_calendar(connected=True, external_connection_id=ext.id) + self._create_expiring_channel(with_db, cal.id) + + new_expiration_ms = int(datetime(2030, 6, 1, tzinfo=timezone.utc).timestamp() * 1000) + mock_google_client = Mock() + mock_google_client.SCOPES = ['https://www.googleapis.com/auth/calendar'] + mock_google_client.watch_events.return_value = { + 'id': 'new-channel-id', + 'resourceId': 'new-resource-id', + 'expiration': str(new_expiration_ms), + } + + self._run_renew(with_db, mock_google_client) + + mock_google_client.stop_channel.assert_called_once() + mock_google_client.watch_events.assert_called_once() + + with with_db() as db: + updated = repo.google_calendar_channel.get_by_calendar_id(db, cal.id) + assert updated is not None + assert updated.channel_id == 'new-channel-id' + assert updated.resource_id == 'new-resource-id' + expected = datetime.fromtimestamp(new_expiration_ms / 1000, tz=timezone.utc) + assert updated.expiration.replace(tzinfo=None) == expected.replace(tzinfo=None) + + def test_skips_channel_not_yet_expiring( + self, with_db, make_google_calendar, make_external_connections + ): + """A channel that expires beyond the 24-hour threshold is left alone.""" + ext = make_external_connections( + subscriber_id=1, + type=models.ExternalConnectionType.google, + token=_make_google_token(), + ) + cal = make_google_calendar(connected=True, external_connection_id=ext.id) + + with with_db() as db: + repo.google_calendar_channel.create( + db, + calendar_id=cal.id, + channel_id='healthy-channel', + resource_id='healthy-resource', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=5), + sync_token='healthy-sync', + ) + + mock_google_client = Mock() + mock_google_client.SCOPES = ['https://www.googleapis.com/auth/calendar'] + + self._run_renew(with_db, mock_google_client) + + mock_google_client.watch_events.assert_not_called() + mock_google_client.stop_channel.assert_not_called() + + with with_db() as db: + channel = repo.google_calendar_channel.get_by_calendar_id(db, cal.id) + assert channel is not None + assert channel.channel_id == 'healthy-channel' + + def test_deletes_channel_for_disconnected_calendar( + self, with_db, make_google_calendar, make_external_connections + ): + """If the calendar is disconnected, the channel record should be removed.""" + ext = make_external_connections( + subscriber_id=1, + type=models.ExternalConnectionType.google, + token=_make_google_token(), + ) + cal = make_google_calendar(connected=False, external_connection_id=ext.id) + self._create_expiring_channel(with_db, cal.id) + + mock_google_client = Mock() + mock_google_client.SCOPES = ['https://www.googleapis.com/auth/calendar'] + + self._run_renew(with_db, mock_google_client) + + mock_google_client.watch_events.assert_not_called() + + with with_db() as db: + assert repo.google_calendar_channel.get_by_calendar_id(db, cal.id) is None + + def test_deletes_channel_when_no_external_connection( + self, with_db, make_google_calendar + ): + """If the calendar has no external connection, the channel record should be removed.""" + cal = make_google_calendar(connected=True) + self._create_expiring_channel(with_db, cal.id) + + mock_google_client = Mock() + mock_google_client.SCOPES = ['https://www.googleapis.com/auth/calendar'] + + self._run_renew(with_db, mock_google_client) + + mock_google_client.watch_events.assert_not_called() + + with with_db() as db: + assert repo.google_calendar_channel.get_by_calendar_id(db, cal.id) is None + + def test_deletes_channel_when_watch_returns_none( + self, with_db, make_google_calendar, make_external_connections + ): + """If watch_events returns None, the channel record should be cleaned up.""" + ext = make_external_connections( + subscriber_id=1, + type=models.ExternalConnectionType.google, + token=_make_google_token(), + ) + cal = make_google_calendar(connected=True, external_connection_id=ext.id) + self._create_expiring_channel(with_db, cal.id) + + mock_google_client = Mock() + mock_google_client.SCOPES = ['https://www.googleapis.com/auth/calendar'] + mock_google_client.watch_events.return_value = None + + self._run_renew(with_db, mock_google_client) + + with with_db() as db: + assert repo.google_calendar_channel.get_by_calendar_id(db, cal.id) is None + + def test_still_renews_if_stop_channel_fails( + self, with_db, make_google_calendar, make_external_connections + ): + """Failure to stop the old channel should not prevent renewal.""" + ext = make_external_connections( + subscriber_id=1, + type=models.ExternalConnectionType.google, + token=_make_google_token(), + ) + cal = make_google_calendar(connected=True, external_connection_id=ext.id) + self._create_expiring_channel(with_db, cal.id) + + new_expiration_ms = int(datetime(2030, 6, 1, tzinfo=timezone.utc).timestamp() * 1000) + mock_google_client = Mock() + mock_google_client.SCOPES = ['https://www.googleapis.com/auth/calendar'] + mock_google_client.stop_channel.side_effect = Exception('Google API error') + mock_google_client.watch_events.return_value = { + 'id': 'new-channel-id', + 'resourceId': 'new-resource-id', + 'expiration': str(new_expiration_ms), + } + + self._run_renew(with_db, mock_google_client) + + mock_google_client.watch_events.assert_called_once() + + with with_db() as db: + updated = repo.google_calendar_channel.get_by_calendar_id(db, cal.id) + assert updated is not None + assert updated.channel_id == 'new-channel-id' From a92940325a08e3142b5e06efee76667f07560e21 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Mon, 9 Mar 2026 11:57:07 -0600 Subject: [PATCH 20/48] Fix tests regarding setup and teardown of watch channels during calendar connection --- .../test_google_calendar_webhook.py | 47 ++++++------------- 1 file changed, 15 insertions(+), 32 deletions(-) diff --git a/backend/test/integration/test_google_calendar_webhook.py b/backend/test/integration/test_google_calendar_webhook.py index d73db0c43..1784e65cc 100644 --- a/backend/test/integration/test_google_calendar_webhook.py +++ b/backend/test/integration/test_google_calendar_webhook.py @@ -1,7 +1,6 @@ """Integration tests for the Google Calendar webhook endpoint and watch channel lifecycle.""" import json -import os from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock @@ -140,25 +139,14 @@ def test_valid_notification_returns_200( class TestCalendarConnectWatchChannel: - """Tests that connecting/disconnecting a Google calendar sets up/tears down watch channels.""" - - def _make_mock_google_client(self): - mock = MagicMock(spec=GoogleClient) - mock.SCOPES = GoogleClient.SCOPES - mock.watch_events.return_value = { - 'id': 'auto-channel-id', - 'resourceId': 'auto-resource-id', - 'expiration': str(int((datetime.now(tz=timezone.utc) + timedelta(days=7)).timestamp() * 1000)), - } - mock.get_initial_sync_token.return_value = 'auto-sync-token' - return mock - - def test_connect_google_calendar_creates_channel( + """Watch channels are managed at the schedule level, not on connect/disconnect. + These tests verify that connecting/disconnecting a calendar does NOT touch watch channels.""" + + def test_connect_google_calendar_does_not_create_channel( self, with_db, with_client, make_google_calendar, make_external_connections ): - mock_gc = self._make_mock_google_client() - with_client.app.dependency_overrides[google_dep.get_google_client] = lambda: mock_gc - + """Connecting a Google calendar alone should not create a watch channel. + Channels are created when the calendar is set as default in a schedule.""" google_creds = json.dumps({ 'token': 'fake-token', 'refresh_token': 'fake-refresh', @@ -172,24 +160,18 @@ def test_connect_google_calendar_creates_channel( subscriber_id=TEST_USER_ID, connected=False, external_connection_id=ext_conn.id, ) - os.environ['BACKEND_URL'] = 'http://localhost:5000' - response = with_client.post(f'/cal/{calendar.id}/connect', headers=auth_headers) assert response.status_code == 200, response.text assert response.json()['connected'] is True with with_db() as db: - channel = repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) - assert channel is not None - assert channel.channel_id == 'auto-channel-id' - assert channel.sync_token == 'auto-sync-token' + assert repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) is None - def test_disconnect_google_calendar_removes_channel( + def test_disconnect_google_calendar_does_not_remove_channel( self, with_db, with_client, make_google_calendar, make_external_connections ): - mock_gc = self._make_mock_google_client() - with_client.app.dependency_overrides[google_dep.get_google_client] = lambda: mock_gc - + """Disconnecting a Google calendar should not tear down its watch channel. + Channels are managed when the schedule's default calendar changes.""" google_creds = json.dumps({ 'token': 'fake-token', 'refresh_token': 'fake-refresh', @@ -207,8 +189,8 @@ def test_disconnect_google_calendar_removes_channel( repo.google_calendar_channel.create( db, calendar_id=calendar.id, - channel_id='to-be-torn-down', - resource_id='res-teardown', + channel_id='should-remain', + resource_id='res-remain', expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), ) @@ -217,8 +199,9 @@ def test_disconnect_google_calendar_removes_channel( assert response.json()['connected'] is False with with_db() as db: - assert repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) is None - mock_gc.stop_channel.assert_called_once() + channel = repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) + assert channel is not None + assert channel.channel_id == 'should-remain' def test_connect_caldav_calendar_no_channel(self, with_db, with_client, make_caldav_calendar): """Connecting a CalDAV calendar should not create a watch channel.""" From 195cfa5d8b66bbddcc82a2971370509c3540da44 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Mon, 9 Mar 2026 11:57:29 -0600 Subject: [PATCH 21/48] Fix schedule tests to not depend on a real clock anymore --- backend/test/integration/test_schedule.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/backend/test/integration/test_schedule.py b/backend/test/integration/test_schedule.py index 8bdd238b7..c2b146d00 100644 --- a/backend/test/integration/test_schedule.py +++ b/backend/test/integration/test_schedule.py @@ -569,11 +569,10 @@ def get_busy_time(self, remote_calendar_ids, start, end): slots = data['slots'] # Based off the earliest_booking our earliest slot is tomorrow at 9:00am - # Note: this should be in PDT (Pacific Daylight Time) + # 2024-03-04 is before spring-forward (Mar 10), so offset is PST (-08:00) assert slots[0]['start'] == '2024-03-04T09:00:00-08:00' - # Based off the farthest_booking our latest slot is 4:30pm - # Note: This should be in PDT (Pacific Daylight Time) - # Note2: The schedule ends at 0 UTC (or 16-07:00) so the last slot is 30 mins before that. + # 2024-03-15 is after spring-forward, so offset is PDT (-07:00) + # The schedule ends at 01:00 UTC (= 17:00 local) so the last slot starts at 16:30 assert slots[-1]['start'] == '2024-03-15T16:30:00-07:00' # Check availability over a year from now From a6e85a0ae0cbc614ab114863d9ac339fcce01a4c Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Mon, 9 Mar 2026 14:35:27 -0600 Subject: [PATCH 22/48] Remove unnecessary setup_watch_channels_for_connection method --- .../appointment/controller/google_watch.py | 46 ------------------- backend/src/appointment/routes/google.py | 4 +- 2 files changed, 1 insertion(+), 49 deletions(-) diff --git a/backend/src/appointment/controller/google_watch.py b/backend/src/appointment/controller/google_watch.py index b0d7c5c2a..7e256d218 100644 --- a/backend/src/appointment/controller/google_watch.py +++ b/backend/src/appointment/controller/google_watch.py @@ -88,52 +88,6 @@ def teardown_watch_channel(db: Session, google_client: GoogleClient | None, cale repo.google_calendar_channel.delete(db, channel) -def setup_watch_channels_for_connection( - db: Session, - google_client: GoogleClient, - creds, - subscriber_id: int, - external_connection_id: int, -): - """Register push notification channels for all connected Google calendars under a connection.""" - webhook_url = get_webhook_url() - if not webhook_url: - logging.warning('[google_watch] BACKEND_URL not set, skipping watch channel setup') - return - - calendars = repo.calendar.get_by_subscriber(db, subscriber_id) - for calendar in calendars: - if calendar.provider != models.CalendarProvider.google: - continue - if calendar.external_connection_id != external_connection_id: - continue - if not calendar.connected: - continue - - existing = repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) - if existing: - continue - - try: - response = google_client.watch_events(calendar.user, webhook_url, creds) - if response: - expiration_ms = int(response.get('expiration', 0)) - expiration_dt = datetime.fromtimestamp(expiration_ms / 1000, tz=timezone.utc) - - sync_token = google_client.get_initial_sync_token(calendar.user, creds) - - repo.google_calendar_channel.create( - db, - calendar_id=calendar.id, - channel_id=response['id'], - resource_id=response['resourceId'], - expiration=expiration_dt, - sync_token=sync_token, - ) - except Exception as e: - logging.warning(f'[google_watch] Failed to set up watch channel for calendar {calendar.id}: {e}') - - def teardown_watch_channels_for_connection( db: Session, google_client: GoogleClient | None, diff --git a/backend/src/appointment/routes/google.py b/backend/src/appointment/routes/google.py index ebb3a8a75..179674d69 100644 --- a/backend/src/appointment/routes/google.py +++ b/backend/src/appointment/routes/google.py @@ -4,7 +4,7 @@ from fastapi.responses import RedirectResponse from ..controller.apis.google_client import GoogleClient -from ..controller.google_watch import setup_watch_channels_for_connection, teardown_watch_channels_for_connection +from ..controller.google_watch import teardown_watch_channels_for_connection from ..database import repo, schemas, models from sqlalchemy.orm import Session @@ -131,8 +131,6 @@ def google_callback( if error_occurred: return google_callback_error(is_setup, l10n('google-sync-fail')) - setup_watch_channels_for_connection(db, google_client, creds, subscriber.id, external_connection.id) - # Redirect non-setup subscribers back to the setup page if not is_setup: return RedirectResponse(f'{os.getenv("FRONTEND_URL", "http://localhost:8090")}/setup') From 257c3a7b70dcdde8f3f4d73663aa066a45738039 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Mon, 9 Mar 2026 14:45:02 -0600 Subject: [PATCH 23/48] Revert changes to the ics and mailer --- .../src/appointment/controller/calendar.py | 7 +--- backend/src/appointment/controller/mailer.py | 32 +++++-------------- backend/src/appointment/routes/api.py | 1 - 3 files changed, 9 insertions(+), 31 deletions(-) diff --git a/backend/src/appointment/controller/calendar.py b/backend/src/appointment/controller/calendar.py index 9f1ba88a3..32722e4e1 100644 --- a/backend/src/appointment/controller/calendar.py +++ b/backend/src/appointment/controller/calendar.py @@ -654,14 +654,9 @@ def create_vevent( org.params['role'] = vText('CHAIR') now = datetime.now(UTC) - organizer_domain = ( - organizer.preferred_email.rsplit('@', 1)[-1] - if '@' in organizer.preferred_email - else 'thunderbird.net' - ) event = Event() - event.add('uid', f'{appointment.uuid.hex}@{organizer_domain}') + event.add('uid', appointment.uuid.hex) event.add('summary', appointment.title) event.add('dtstart', slot.start.replace(tzinfo=timezone.utc)) event.add( diff --git a/backend/src/appointment/controller/mailer.py b/backend/src/appointment/controller/mailer.py index 00368eae9..6d5be4e4d 100644 --- a/backend/src/appointment/controller/mailer.py +++ b/backend/src/appointment/controller/mailer.py @@ -112,16 +112,17 @@ def build(self): message.set_content(self.text()) message.add_alternative(self.html(), subtype='html') - # Separate calendar and non-calendar attachments so the ICS can be - # added as a multipart/alternative part instead of a plain attachment. - - # Gmail only renders its calendar invite UI when text/calendar lives - # inside the alternative block alongside text/plain and text/html. - calendar_attachment = None + # add attachment(s) as multimedia parts for a in self._attachments(): + # Handle ics files differently than inline images if a.mime_main == 'text' and a.mime_sub == 'calendar': - calendar_attachment = a + message.add_attachment(a.data, maintype=a.mime_main, subtype=a.mime_sub, filename=a.filename) + # Fix the header of the attachment + message.get_payload()[-1].replace_header( + 'Content-Type', f'{a.mime_main}/{a.mime_sub}; charset="UTF-8"; method={self.method}' + ) else: + # Attach it to the html payload message.get_payload()[1].add_related( a.data, a.mime_main, @@ -129,23 +130,6 @@ def build(self): cid=f'<{a.filename}>', ) - if calendar_attachment: - ics_data = calendar_attachment.data - ics_text = ics_data.decode('utf-8') if isinstance(ics_data, bytes) else ics_data - - # Add as an alternative so Gmail renders the accept/decline UI - message.add_alternative(ics_text, subtype='calendar') - message.get_payload()[-1].set_param('method', self.method) - - # Also attach as a downloadable file for clients that prefer it - message.add_attachment( - ics_data, - maintype='text', - subtype='calendar', - filename=calendar_attachment.filename, - ) - message.get_payload()[-1].set_param('method', self.method) - return message def send(self): diff --git a/backend/src/appointment/routes/api.py b/backend/src/appointment/routes/api.py index e057e7c0e..7492784fc 100644 --- a/backend/src/appointment/routes/api.py +++ b/backend/src/appointment/routes/api.py @@ -285,7 +285,6 @@ def change_my_calendar_connection( cal = repo.calendar.update_connection(db=db, calendar_id=id, is_connected=connect) except HTTPException as e: raise HTTPException(status_code=e.status_code, detail=e.detail) - return schemas.CalendarOut(id=cal.id, title=cal.title, color=cal.color, connected=cal.connected) From a46d3f20bf2e735da31f6bac6468a5444002a0ae Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Mon, 9 Mar 2026 15:14:03 -0600 Subject: [PATCH 24/48] Add Subscriber to attendee list with NeedsAction if booking_confirmation --- .../controller/apis/google_client.py | 10 +++++ .../src/appointment/controller/calendar.py | 32 +++++++++++++--- backend/src/appointment/routes/schedule.py | 38 ++++++++++++++----- 3 files changed, 66 insertions(+), 14 deletions(-) diff --git a/backend/src/appointment/controller/apis/google_client.py b/backend/src/appointment/controller/apis/google_client.py index 30bf46825..c5d1e6041 100644 --- a/backend/src/appointment/controller/apis/google_client.py +++ b/backend/src/appointment/controller/apis/google_client.py @@ -241,6 +241,16 @@ def list_events(self, calendar_id, time_min, time_max, token): return items + def get_event(self, calendar_id, event_id, token): + """Retrieve a single event by ID. + Ref: https://developers.google.com/calendar/api/v3/reference/events/get""" + with build('calendar', 'v3', credentials=token, cache_discovery=False) as service: + try: + return service.events().get(calendarId=calendar_id, eventId=event_id).execute() + except HttpError as e: + logging.warning(f'[google_client.get_event] Request Error: {e.status_code}/{e.error_details}') + return None + def save_event(self, calendar_id, body, token): response = None with build('calendar', 'v3', credentials=token, cache_discovery=False) as service: diff --git a/backend/src/appointment/controller/calendar.py b/backend/src/appointment/controller/calendar.py index 32722e4e1..d95947153 100644 --- a/backend/src/appointment/controller/calendar.py +++ b/backend/src/appointment/controller/calendar.py @@ -269,6 +269,7 @@ def save_event( organizer: schemas.Subscriber, organizer_email: str, send_google_notification: bool = False, + booking_confirmation: bool = False, ) -> schemas.Event: """add a new event to the connected calendar""" @@ -283,16 +284,22 @@ def save_event( description.append(l10n('join-phone', {'phone': event.location.phone}, lang=organizer_language)) if send_google_notification: + attendees = [ + {'displayName': attendee.name, 'email': attendee.email, 'responseStatus': 'needsAction'}, + ] + if booking_confirmation: + attendees.insert(0, { + 'displayName': organizer.name, 'email': organizer_email, 'responseStatus': 'needsAction', + }) + body = { 'summary': event.title, 'location': event.location.url if event.location.url else None, 'description': '\n'.join(description), 'start': {'dateTime': event.start.isoformat()}, 'end': {'dateTime': event.end.isoformat()}, - 'status': 'tentative', - 'attendees': [ - {'displayName': attendee.name, 'email': attendee.email, 'responseStatus': 'needsAction'}, - ], + 'status': 'tentative' if booking_confirmation else 'confirmed', + 'attendees': attendees, } new_event = self.google_client.insert_event( @@ -329,10 +336,24 @@ def save_event( def confirm_event(self, event_id: str): """Patch a tentative event to confirmed status, notifying attendees.""" + body = {'status': 'confirmed'} + + event = self.google_client.get_event( + calendar_id=self.remote_calendar_id, + event_id=event_id, + token=self.google_token, + ) + if event and event.get('attendees'): + attendees = event['attendees'] + for att in attendees: + if att.get('self'): + att['responseStatus'] = 'accepted' + body['attendees'] = attendees + self.google_client.patch_event( calendar_id=self.remote_calendar_id, event_id=event_id, - body={'status': 'confirmed'}, + body=body, token=self.google_token, ) self.bust_cached_events() @@ -585,6 +606,7 @@ def save_event( organizer: schemas.Subscriber, organizer_email: str, send_google_notification: bool = False, + booking_confirmation: bool = False, ): """add a new event to the connected calendar""" calendar = self.client.calendar(url=self.url) diff --git a/backend/src/appointment/routes/schedule.py b/backend/src/appointment/routes/schedule.py index bdda1d29f..dff481ae3 100644 --- a/backend/src/appointment/routes/schedule.py +++ b/backend/src/appointment/routes/schedule.py @@ -82,10 +82,7 @@ def is_this_a_valid_booking_time(schedule: models.Schedule, booking_slot: schema booking_slot_end = booking_slot_start + timedelta(minutes=schedule.slot_duration) if schedule.use_custom_availabilities: - custom_availabilities = [ - a for a in schedule.availabilities - if a.day_of_week.value == iso_weekday - ] + custom_availabilities = [a for a in schedule.availabilities if a.day_of_week.value == iso_weekday] if not custom_availabilities: return False @@ -96,8 +93,7 @@ def is_this_a_valid_booking_time(schedule: models.Schedule, booking_slot: schema add_day = 1 if availability.end_time <= availability.start_time else 0 start_datetime = ( - datetime.combine(today, availability.start_time, tzinfo=schedule_tzinfo) - + schedule.timezone_offset + datetime.combine(today, availability.start_time, tzinfo=schedule_tzinfo) + schedule.timezone_offset ) end_datetime = ( datetime.combine(today, availability.end_time, tzinfo=schedule_tzinfo) @@ -485,8 +481,15 @@ def request_schedule_availability_slot( ) event = save_remote_event( - event, calendar, subscriber, slot, db, redis, google_client, + event, + calendar, + subscriber, + slot, + db, + redis, + google_client, send_google_notification=use_google_invite, + booking_confirmation=schedule.booking_confirmation, ) # Add the external id if available if appointment and event.external_id: @@ -703,7 +706,13 @@ def handle_schedule_availability_decision( con.confirm_event(appointment.external_id) else: event = save_remote_event( - event, appointment_calendar, subscriber, slot, db, redis, google_client, + event, + appointment_calendar, + subscriber, + slot, + db, + redis, + google_client, ) if appointment and event.external_id: repo.appointment.update_external_id(db, appointment, event.external_id) @@ -755,7 +764,17 @@ def get_remote_connection(calendar, subscriber, db, redis, google_client): return (con, organizer_email) -def save_remote_event(event, calendar, subscriber, slot, db, redis, google_client, send_google_notification=False): +def save_remote_event( + event, + calendar, + subscriber, + slot, + db, + redis, + google_client, + send_google_notification=False, + booking_confirmation=False, +): """Create or update a remote event""" con, organizer_email = get_remote_connection(calendar, subscriber, db, redis, google_client) @@ -766,6 +785,7 @@ def save_remote_event(event, calendar, subscriber, slot, db, redis, google_clien organizer=subscriber, organizer_email=organizer_email, send_google_notification=send_google_notification, + booking_confirmation=booking_confirmation, ) except EventNotCreatedException: raise EventCouldNotBeAccepted From d60de30423f03c3dd2a735f2ca65425a1b68431d Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Tue, 10 Mar 2026 09:42:05 -0600 Subject: [PATCH 25/48] Subscribers actions in Google Calendar should backtrack the booking status as well --- backend/src/appointment/routes/webhooks.py | 108 ++++++++++++++++++--- 1 file changed, 96 insertions(+), 12 deletions(-) diff --git a/backend/src/appointment/routes/webhooks.py b/backend/src/appointment/routes/webhooks.py index 6f52fcfb4..7148811a3 100644 --- a/backend/src/appointment/routes/webhooks.py +++ b/backend/src/appointment/routes/webhooks.py @@ -212,10 +212,6 @@ def _process_google_event_changes( if not google_event_id: continue - attendees = event.get('attendees', []) - if not attendees: - continue - appointment = _find_appointment_by_external_id(db, calendar_id, google_event_id) if not appointment: continue @@ -224,6 +220,24 @@ def _process_google_event_changes( if not slot or not slot.attendee: continue + # Subscriber deleted/cancelled the event from Google Calendar + if event.get('status') == 'cancelled': + _handle_event_cancelled(db, appointment, slot) + continue + + attendees = event.get('attendees', []) + if not attendees: + continue + + # Check if the subscriber (calendar owner) responded via Google Calendar + self_attendee = next((a for a in attendees if a.get('self')), None) + if self_attendee: + _handle_subscriber_rsvp( + db, appointment, slot, self_attendee.get('responseStatus'), + google_client, google_token, remote_calendar_id, + ) + + # Check if the bookee responded via Google Calendar attendee_email = slot.attendee.email.lower() google_attendee = next( (a for a in attendees if a.get('email', '').lower() == attendee_email), @@ -233,7 +247,7 @@ def _process_google_event_changes( continue response_status = google_attendee.get('responseStatus') - _handle_attendee_rsvp( + _handle_bookee_rsvp( db, appointment, slot, response_status, google_client, google_token, remote_calendar_id ) except Exception as e: @@ -259,7 +273,79 @@ def _find_appointment_by_external_id( return None -def _handle_attendee_rsvp( +def _handle_event_cancelled( + db: Session, + appointment: models.Appointment, + slot: models.Slot, +): + """Handle a Google Calendar event that was deleted/cancelled by the subscriber.""" + if slot.booking_status not in (models.BookingStatus.requested, models.BookingStatus.booked): + return + + slot_update = schemas.SlotUpdate(booking_status=models.BookingStatus.cancelled) + repo.slot.update(db, slot.id, slot_update) + + logging.info( + f'[webhooks.google_calendar] Event cancelled for appointment {appointment.id}, ' + f'slot {slot.id} marked as cancelled' + ) + + +def _handle_subscriber_rsvp( + db: Session, + appointment: models.Appointment, + slot: models.Slot, + response_status: str, + google_client: GoogleClient, + google_token, + remote_calendar_id: str, +): + """React to the subscriber (calendar owner) accepting/declining via Google Calendar.""" + if response_status == 'accepted': + if ( + appointment.status != models.AppointmentStatus.opened + or slot.booking_status != models.BookingStatus.requested + ): + return + + repo.appointment.update_status(db, appointment.id, models.AppointmentStatus.closed) + repo.slot.book(db, slot.id) + + if appointment.external_id: + try: + google_client.patch_event( + remote_calendar_id, appointment.external_id, + {'status': 'confirmed'}, google_token, + ) + except Exception: + logging.warning('[webhooks.google_calendar] Failed to confirm event in Google') + + logging.info( + f'[webhooks.google_calendar] Subscriber confirmed appointment {appointment.id} ' + f'via Google Calendar, slot {slot.id} booked' + ) + + elif response_status == 'declined': + if slot.booking_status in (models.BookingStatus.requested, models.BookingStatus.booked): + slot_update = schemas.SlotUpdate(booking_status=models.BookingStatus.declined) + repo.slot.update(db, slot.id, slot_update) + + if appointment.external_id: + try: + google_client.delete_event( + remote_calendar_id, appointment.external_id, google_token, + send_updates='all', + ) + except Exception: + logging.warning('[webhooks.google_calendar] Failed to delete declined event from Google') + + logging.info( + f'[webhooks.google_calendar] Subscriber declined appointment {appointment.id} ' + f'via Google Calendar, slot {slot.id} marked as declined' + ) + + +def _handle_bookee_rsvp( db: Session, appointment: models.Appointment, slot: models.Slot, @@ -268,7 +354,7 @@ def _handle_attendee_rsvp( google_token, remote_calendar_id: str, ): - """React to an attendee's RSVP status change from Google Calendar.""" + """React to the bookee's RSVP status change from Google Calendar.""" if response_status == 'declined': if slot.booking_status in (models.BookingStatus.requested, models.BookingStatus.booked): slot_update = schemas.SlotUpdate(booking_status=models.BookingStatus.declined) @@ -281,14 +367,12 @@ def _handle_attendee_rsvp( logging.warning('[webhooks.google_calendar] Failed to delete declined event from Google') logging.info( - f'[webhooks.google_calendar] Attendee declined appointment {appointment.id}, ' + f'[webhooks.google_calendar] Bookee declined appointment {appointment.id}, ' f'slot {slot.id} marked as declined' ) elif response_status == 'accepted': - # The bookee accepted the tentative invite, but the subscriber must - # still confirm via the branded email or app UI. Just log it for now. logging.info( - f'[webhooks.google_calendar] Attendee accepted appointment {appointment.id}, ' - f'slot {slot.id} — awaiting subscriber confirmation' + f'[webhooks.google_calendar] Bookee accepted appointment {appointment.id}, ' + f'slot {slot.id}' ) From 18f1c675ec6ebb9434fd15d3991ed4d67d894737 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Tue, 10 Mar 2026 09:42:35 -0600 Subject: [PATCH 26/48] Add tests for Susbcribers actions in Google Calendar --- backend/test/conftest.py | 10 +- .../test_google_calendar_webhook.py | 197 +++++++++++++++++- backend/test/unit/test_google_webhook.py | 12 +- 3 files changed, 211 insertions(+), 8 deletions(-) diff --git a/backend/test/conftest.py b/backend/test/conftest.py index c27900c85..9d34cf3f9 100644 --- a/backend/test/conftest.py +++ b/backend/test/conftest.py @@ -50,7 +50,15 @@ def list_calendars(self): return [schemas.CalendarConnectionOut(url=TEST_CALDAV_URL, user=TEST_CALDAV_USER)] @staticmethod - def save_event(self, event, attendee, organizer, organizer_email, send_google_notification=False): + def save_event( + self, + event, + attendee, + organizer, + organizer_email, + send_google_notification=False, + booking_confirmation=False, + ): return event @staticmethod diff --git a/backend/test/integration/test_google_calendar_webhook.py b/backend/test/integration/test_google_calendar_webhook.py index 1784e65cc..0993258d0 100644 --- a/backend/test/integration/test_google_calendar_webhook.py +++ b/backend/test/integration/test_google_calendar_webhook.py @@ -4,9 +4,12 @@ from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock +import pytest + from appointment.controller.apis.google_client import GoogleClient -from appointment.database import models, repo +from appointment.database import models, repo, schemas from appointment.dependencies import google as google_dep +from appointment.routes.webhooks import _handle_subscriber_rsvp, _handle_bookee_rsvp, _handle_event_cancelled from defines import auth_headers, TEST_USER_ID @@ -212,3 +215,195 @@ def test_connect_caldav_calendar_no_channel(self, with_db, with_client, make_cal with with_db() as db: assert repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) is None + + +class TestGoogleCalendarEventRsvp: + """Tests for subscriber and bookee RSVP handling via the Google Calendar webhook.""" + + BOOKEE_EMAIL = 'bookee@example.com' + GOOGLE_EVENT_ID = 'google-event-123' + REMOTE_CALENDAR_ID = 'cal@google.com' + + @pytest.fixture + def rsvp_setup( + self, with_db, + make_pro_subscriber, make_google_calendar, make_external_connections, + make_appointment, make_attendee, make_appointment_slot, + ): + """Create an opened appointment with a requested slot, linked to a Google calendar.""" + subscriber = make_pro_subscriber() + calendar = make_google_calendar(subscriber_id=subscriber.id, connected=True) + attendee = make_attendee(email=self.BOOKEE_EMAIL, name='Bookee') + appointment = make_appointment( + calendar_id=calendar.id, + status=models.AppointmentStatus.opened, + slots=None, + ) + make_appointment_slot( + appointment_id=appointment.id, + attendee_id=attendee.id, + booking_status=models.BookingStatus.requested, + booking_tkn='test-token', + ) + + with with_db() as db: + repo.appointment.update_external_id_by_id(db, appointment.id, self.GOOGLE_EVENT_ID) + + mock_google_client = MagicMock(spec=GoogleClient) + mock_google_token = MagicMock() + + return mock_google_client, mock_google_token, appointment + + def _reload(self, with_db, appointment_id): + """Reload appointment and its first slot from a fresh session.""" + with with_db() as db: + appt = repo.appointment.get(db, appointment_id) + slot = appt.slots[0] + return appt, slot + + def test_subscriber_accepts_confirms_booking(self, with_db, rsvp_setup): + """When the subscriber accepts a tentative event via Google Calendar, + the appointment should be closed and the slot booked.""" + mock_google_client, mock_token, appointment = rsvp_setup + + with with_db() as db: + appt = repo.appointment.get(db, appointment.id) + slot = appt.slots[0] + _handle_subscriber_rsvp( + db, appt, slot, 'accepted', + mock_google_client, mock_token, self.REMOTE_CALENDAR_ID, + ) + + appt, slot = self._reload(with_db, appointment.id) + assert appt.status == models.AppointmentStatus.closed + assert slot.booking_status == models.BookingStatus.booked + + mock_google_client.patch_event.assert_called_once_with( + self.REMOTE_CALENDAR_ID, self.GOOGLE_EVENT_ID, + {'status': 'confirmed'}, mock_token, + ) + + def test_subscriber_accepts_already_closed_is_noop(self, with_db, rsvp_setup): + """If the appointment is already closed, a subscriber accept should not change anything.""" + mock_google_client, mock_token, appointment = rsvp_setup + + with with_db() as db: + repo.appointment.update_status(db, appointment.id, models.AppointmentStatus.closed) + + with with_db() as db: + appt = repo.appointment.get(db, appointment.id) + slot = appt.slots[0] + _handle_subscriber_rsvp( + db, appt, slot, 'accepted', + mock_google_client, mock_token, self.REMOTE_CALENDAR_ID, + ) + + _, slot = self._reload(with_db, appointment.id) + assert slot.booking_status == models.BookingStatus.requested + mock_google_client.patch_event.assert_not_called() + + def test_bookee_accepts_does_not_confirm(self, with_db, rsvp_setup): + """When the bookee accepts, the appointment should remain opened and the slot requested.""" + mock_google_client, mock_token, appointment = rsvp_setup + + with with_db() as db: + appt = repo.appointment.get(db, appointment.id) + slot = appt.slots[0] + _handle_bookee_rsvp( + db, appt, slot, 'accepted', + mock_google_client, mock_token, self.REMOTE_CALENDAR_ID, + ) + + appt, slot = self._reload(with_db, appointment.id) + assert appt.status == models.AppointmentStatus.opened + assert slot.booking_status == models.BookingStatus.requested + mock_google_client.patch_event.assert_not_called() + + def test_subscriber_declines_marks_slot_declined(self, with_db, rsvp_setup): + """When the subscriber declines via Google Calendar, the slot should be + marked as declined and the event deleted with notifications.""" + mock_google_client, mock_token, appointment = rsvp_setup + + with with_db() as db: + appt = repo.appointment.get(db, appointment.id) + slot = appt.slots[0] + _handle_subscriber_rsvp( + db, appt, slot, 'declined', + mock_google_client, mock_token, self.REMOTE_CALENDAR_ID, + ) + + _, slot = self._reload(with_db, appointment.id) + assert slot.booking_status == models.BookingStatus.declined + + mock_google_client.delete_event.assert_called_once_with( + self.REMOTE_CALENDAR_ID, self.GOOGLE_EVENT_ID, mock_token, + send_updates='all', + ) + + def test_subscriber_declines_already_declined_is_noop(self, with_db, rsvp_setup): + """If the slot is already declined, a subscriber decline should not call delete again.""" + mock_google_client, mock_token, appointment = rsvp_setup + + with with_db() as db: + slot_update = models.BookingStatus.declined + repo.slot.update(db, repo.appointment.get(db, appointment.id).slots[0].id, + schemas.SlotUpdate(booking_status=slot_update)) + + with with_db() as db: + appt = repo.appointment.get(db, appointment.id) + slot = appt.slots[0] + _handle_subscriber_rsvp( + db, appt, slot, 'declined', + mock_google_client, mock_token, self.REMOTE_CALENDAR_ID, + ) + + mock_google_client.delete_event.assert_not_called() + + def test_bookee_declines_marks_slot_declined(self, with_db, rsvp_setup): + """When the bookee declines, the slot should be marked as declined and the event deleted.""" + mock_google_client, mock_token, appointment = rsvp_setup + + with with_db() as db: + appt = repo.appointment.get(db, appointment.id) + slot = appt.slots[0] + _handle_bookee_rsvp( + db, appt, slot, 'declined', + mock_google_client, mock_token, self.REMOTE_CALENDAR_ID, + ) + + _, slot = self._reload(with_db, appointment.id) + assert slot.booking_status == models.BookingStatus.declined + mock_google_client.delete_event.assert_called_once() + + def test_event_cancelled_marks_slot_declined(self, with_db, rsvp_setup): + """When the subscriber deletes the event from Google Calendar, + the slot should be marked as cancelled.""" + _, _, appointment = rsvp_setup + + with with_db() as db: + appt = repo.appointment.get(db, appointment.id) + slot = appt.slots[0] + _handle_event_cancelled(db, appt, slot) + + _, slot = self._reload(with_db, appointment.id) + assert slot.booking_status == models.BookingStatus.cancelled + + def test_event_cancelled_already_cancelled_is_noop(self, with_db, rsvp_setup): + """If the slot is already cancelled, a cancelled event should not change anything.""" + _, _, appointment = rsvp_setup + + with with_db() as db: + appt = repo.appointment.get(db, appointment.id) + repo.slot.update( + db, appt.slots[0].id, + schemas.SlotUpdate(booking_status=models.BookingStatus.cancelled), + ) + + with with_db() as db: + appt = repo.appointment.get(db, appointment.id) + slot = appt.slots[0] + assert slot.booking_status == models.BookingStatus.cancelled + _handle_event_cancelled(db, appt, slot) + + _, slot = self._reload(with_db, appointment.id) + assert slot.booking_status == models.BookingStatus.cancelled diff --git a/backend/test/unit/test_google_webhook.py b/backend/test/unit/test_google_webhook.py index 5eab0f1fb..d0a3fddf2 100644 --- a/backend/test/unit/test_google_webhook.py +++ b/backend/test/unit/test_google_webhook.py @@ -7,7 +7,7 @@ from appointment.controller.google_watch import setup_watch_channel, teardown_watch_channel from appointment.database import models, repo -from appointment.routes.webhooks import _find_appointment_by_external_id, _handle_attendee_rsvp +from appointment.routes.webhooks import _find_appointment_by_external_id, _handle_bookee_rsvp class TestFindAppointmentByExternalId: @@ -48,7 +48,7 @@ def test_scoped_to_calendar(self, with_db, make_google_calendar, make_appointmen assert _find_appointment_by_external_id(db, cal2.id, 'event-on-cal1') is None -class TestHandleAttendeeRsvp: +class TestHandleBookeeRsvp: def _make_test_objects(self, with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot): calendar = make_google_calendar(connected=True) attendee = make_attendee(email='bookee@example.com', name='Bookee') @@ -88,7 +88,7 @@ def test_decline_marks_slot_declined( db_appointment = repo.appointment.get(db, appointment_id) db_slot = repo.slot.get(db, slot_id) - _handle_attendee_rsvp( + _handle_bookee_rsvp( db, db_appointment, db_slot, 'declined', mock_client, mock_token, calendar.user ) @@ -112,7 +112,7 @@ def test_accept_does_not_auto_book( db_appointment = repo.appointment.get(db, appointment_id) db_slot = repo.slot.get(db, slot_id) - _handle_attendee_rsvp( + _handle_bookee_rsvp( db, db_appointment, db_slot, 'accepted', mock_client, mock_token, calendar.user ) @@ -145,7 +145,7 @@ def test_accept_noop_for_already_booked( db_appointment = repo.appointment.get(db, appointment.id) db_slot = db_appointment.slots[0] - _handle_attendee_rsvp( + _handle_bookee_rsvp( db, db_appointment, db_slot, 'accepted', mock_client, mock_token, calendar.user ) @@ -167,7 +167,7 @@ def test_needsaction_is_ignored( db_appointment = repo.appointment.get(db, appointment_id) db_slot = repo.slot.get(db, slot_id) - _handle_attendee_rsvp( + _handle_bookee_rsvp( db, db_appointment, db_slot, 'needsAction', mock_client, mock_token, calendar.user ) From eb0356912e3dcf5f2f2ce812999e54654254ede6 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Tue, 10 Mar 2026 10:14:27 -0600 Subject: [PATCH 27/48] Adding the organizer in the google event regardless of booking confirmation --- backend/src/appointment/controller/calendar.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/backend/src/appointment/controller/calendar.py b/backend/src/appointment/controller/calendar.py index d95947153..f4d51456b 100644 --- a/backend/src/appointment/controller/calendar.py +++ b/backend/src/appointment/controller/calendar.py @@ -284,13 +284,16 @@ def save_event( description.append(l10n('join-phone', {'phone': event.location.phone}, lang=organizer_language)) if send_google_notification: - attendees = [ - {'displayName': attendee.name, 'email': attendee.email, 'responseStatus': 'needsAction'}, - ] if booking_confirmation: - attendees.insert(0, { - 'displayName': organizer.name, 'email': organizer_email, 'responseStatus': 'needsAction', - }) + attendees = [ + {'displayName': organizer.name, 'email': organizer_email, 'responseStatus': 'needsAction'}, + {'displayName': attendee.name, 'email': attendee.email, 'responseStatus': 'needsAction'}, + ] + else: + attendees = [ + {'displayName': organizer.name, 'email': organizer_email, 'responseStatus': 'accepted'}, + {'displayName': attendee.name, 'email': attendee.email, 'responseStatus': 'needsAction'}, + ] body = { 'summary': event.title, From e7d32e681b87af7836a2348e7c3d48dff761b612 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Tue, 10 Mar 2026 10:15:00 -0600 Subject: [PATCH 28/48] Also make a remote event when there is no need to require booking confirmation --- backend/src/appointment/routes/schedule.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/src/appointment/routes/schedule.py b/backend/src/appointment/routes/schedule.py index dff481ae3..0d88ca5c0 100644 --- a/backend/src/appointment/routes/schedule.py +++ b/backend/src/appointment/routes/schedule.py @@ -700,10 +700,18 @@ def handle_schedule_availability_decision( appointment = repo.appointment.update_title(db, slot.appointment_id, title) if use_google_invite: - # Patch the tentative event to confirmed; Google notifies the bookee. if appointment and appointment.external_id: + # Patch the tentative hold event to confirmed; Google notifies the bookee. con, _ = get_remote_connection(appointment_calendar, subscriber, db, redis, google_client) con.confirm_event(appointment.external_id) + else: + # No hold event exists (booking_confirmation was false); create a confirmed event directly. + event = save_remote_event( + event, appointment_calendar, subscriber, slot, db, redis, google_client, + send_google_notification=True, booking_confirmation=False, + ) + if appointment and event.external_id: + repo.appointment.update_external_id(db, appointment, event.external_id) else: event = save_remote_event( event, From be50f5f4fcb5d8300974b22fc78d6e0c92f84940 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Tue, 10 Mar 2026 10:15:14 -0600 Subject: [PATCH 29/48] More test coverage --- backend/test/unit/test_calendar_tools.py | 51 ++++- .../unit/test_handle_availability_decision.py | 183 ++++++++++++++++++ 2 files changed, 226 insertions(+), 8 deletions(-) create mode 100644 backend/test/unit/test_handle_availability_decision.py diff --git a/backend/test/unit/test_calendar_tools.py b/backend/test/unit/test_calendar_tools.py index 06409f972..8dcc958d9 100644 --- a/backend/test/unit/test_calendar_tools.py +++ b/backend/test/unit/test_calendar_tools.py @@ -555,8 +555,8 @@ def test_google_notification_uses_insert(self): assert 'iCalUID' not in body assert result.external_id == 'insert_event_id' - def test_insert_body_has_attendee_only(self): - """insert() body should only include the bookee, not the organizer.""" + def test_insert_body_has_no_organizer_field(self): + """insert() body should not include the custom organizer field.""" event, attendee, organizer = self._make_event_and_organizer() mock_google_client = Mock() @@ -573,17 +573,44 @@ def test_insert_body_has_attendee_only(self): ) body = mock_google_client.insert_event.call_args.kwargs.get('body') + assert 'organizer' not in body + + def test_booking_confirmation_true_creates_tentative_with_both_needsaction(self): + """With booking_confirmation=True, event is tentative and both + organizer and bookee are listed as needsAction.""" + event, attendee, organizer = self._make_event_and_organizer() + + mock_google_client = Mock() + mock_google_client.insert_event.return_value = {'id': 'tentative_id'} + connector = self._make_connector(mock_google_client) + + with request_cycle_context({}): + connector.save_event( + event=event, + attendee=attendee, + organizer=organizer, + organizer_email='owner@example.com', + send_google_notification=True, + booking_confirmation=True, + ) + + body = mock_google_client.insert_event.call_args.kwargs.get('body') + assert body['status'] == 'tentative' + attendees = body['attendees'] - assert len(attendees) == 1 - assert attendees[0]['email'] == 'bookee@example.org' + assert len(attendees) == 2 + assert attendees[0]['email'] == 'owner@example.com' assert attendees[0]['responseStatus'] == 'needsAction' + assert attendees[1]['email'] == 'bookee@example.org' + assert attendees[1]['responseStatus'] == 'needsAction' - def test_insert_body_has_no_organizer_field(self): - """insert() body should not include the custom organizer field.""" + def test_booking_confirmation_false_creates_confirmed_with_organizer_accepted(self): + """With booking_confirmation=False, event is confirmed and the + organizer is accepted while the bookee is needsAction.""" event, attendee, organizer = self._make_event_and_organizer() mock_google_client = Mock() - mock_google_client.insert_event.return_value = {'id': 'insert_event_id'} + mock_google_client.insert_event.return_value = {'id': 'confirmed_id'} connector = self._make_connector(mock_google_client) with request_cycle_context({}): @@ -593,10 +620,18 @@ def test_insert_body_has_no_organizer_field(self): organizer=organizer, organizer_email='owner@example.com', send_google_notification=True, + booking_confirmation=False, ) body = mock_google_client.insert_event.call_args.kwargs.get('body') - assert 'organizer' not in body + assert body['status'] == 'confirmed' + + attendees = body['attendees'] + assert len(attendees) == 2 + assert attendees[0]['email'] == 'owner@example.com' + assert attendees[0]['responseStatus'] == 'accepted' + assert attendees[1]['email'] == 'bookee@example.org' + assert attendees[1]['responseStatus'] == 'needsAction' class TestVEventTimezoneFallback: diff --git a/backend/test/unit/test_handle_availability_decision.py b/backend/test/unit/test_handle_availability_decision.py new file mode 100644 index 000000000..1387ec12b --- /dev/null +++ b/backend/test/unit/test_handle_availability_decision.py @@ -0,0 +1,183 @@ +"""Unit tests for handle_schedule_availability_decision Google calendar paths.""" + +from datetime import datetime, timedelta, timezone +from unittest.mock import Mock, MagicMock, patch + +from appointment.database import models, repo, schemas +from appointment.routes.schedule import handle_schedule_availability_decision + + +class TestHandleScheduleAvailabilityDecisionGoogle: + """Tests for the Google invite branches in handle_schedule_availability_decision.""" + + def _make_google_calendar(self): + cal = Mock() + cal.provider = models.CalendarProvider.google + cal.user = 'cal@google.com' + return cal + + def _make_schedule(self): + schedule = Mock() + schedule.location_url = 'https://meet.example.com' + schedule.meeting_link_provider = models.MeetingLinkProviderType.none + schedule.details = 'Test details' + schedule.name = 'Test Schedule' + return schedule + + def _make_subscriber(self): + subscriber = Mock() + subscriber.name = 'Subscriber' + subscriber.timezone = 'UTC' + subscriber.preferred_email = 'sub@example.com' + subscriber.language = 'en' + return subscriber + + def _setup( + self, with_db, make_google_calendar, make_appointment, + make_attendee, make_appointment_slot, has_external_id=True, + ): + calendar = make_google_calendar(connected=True) + attendee = make_attendee(email='bookee@example.com', name='Bookee') + appointment = make_appointment( + calendar_id=calendar.id, + status=models.AppointmentStatus.opened, + slots=None, + ) + make_appointment_slot( + appointment_id=appointment.id, + attendee_id=attendee.id, + booking_status=models.BookingStatus.requested, + booking_tkn='test-token', + ) + + if has_external_id: + with with_db() as db: + repo.appointment.update_external_id_by_id(db, appointment.id, 'google-event-123') + + return calendar, appointment + + @patch('appointment.routes.schedule.get_remote_connection') + def test_confirm_patches_existing_hold_event( + self, mock_get_remote_connection, + with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot, + ): + """When a hold event exists (has external_id), confirm_event should be called.""" + calendar, appointment = self._setup( + with_db, make_google_calendar, make_appointment, + make_attendee, make_appointment_slot, has_external_id=True, + ) + + mock_connector = MagicMock() + mock_get_remote_connection.return_value = (mock_connector, 'sub@example.com') + + google_calendar = self._make_google_calendar() + schedule = self._make_schedule() + subscriber = self._make_subscriber() + background_tasks = MagicMock() + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment.id) + slot = db_appointment.slots[0] + db.add(slot) + db.add(slot.attendee) + + handle_schedule_availability_decision( + True, google_calendar, schedule, subscriber, slot, + db, None, None, background_tasks, + ) + + db.refresh(slot) + assert slot.booking_status == models.BookingStatus.booked + + mock_connector.confirm_event.assert_called_once_with('google-event-123') + + @patch('appointment.routes.schedule.save_remote_event') + def test_confirm_creates_event_when_no_hold_exists( + self, mock_save_remote_event, + with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot, + ): + """When no hold event exists (no external_id), save_remote_event should be called + with send_google_notification=True and booking_confirmation=False.""" + calendar, appointment = self._setup( + with_db, make_google_calendar, make_appointment, + make_attendee, make_appointment_slot, has_external_id=False, + ) + + mock_event = schemas.Event( + title='Test', + start=datetime.now(tz=timezone.utc), + end=datetime.now(tz=timezone.utc) + timedelta(minutes=30), + description='', + location=schemas.EventLocation(url=None), + external_id='new-google-event-456', + ) + mock_save_remote_event.return_value = mock_event + + google_calendar = self._make_google_calendar() + schedule = self._make_schedule() + subscriber = self._make_subscriber() + background_tasks = MagicMock() + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment.id) + slot = db_appointment.slots[0] + db.add(slot) + db.add(slot.attendee) + + handle_schedule_availability_decision( + True, google_calendar, schedule, subscriber, slot, + db, None, None, background_tasks, + ) + + db.refresh(slot) + assert slot.booking_status == models.BookingStatus.booked + + mock_save_remote_event.assert_called_once() + call_kwargs = mock_save_remote_event.call_args + assert call_kwargs.kwargs.get('send_google_notification') is True + assert call_kwargs.kwargs.get('booking_confirmation') is False + + with with_db() as db: + appt = repo.appointment.get(db, appointment.id) + assert appt.external_id == 'new-google-event-456' + + @patch('appointment.routes.schedule.save_remote_event') + def test_confirm_creates_event_does_not_send_branded_vevent( + self, mock_save_remote_event, + with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot, + ): + """For Google invites, the branded vevent email should NOT be sent + (Google handles notifications via sendUpdates).""" + calendar, appointment = self._setup( + with_db, make_google_calendar, make_appointment, + make_attendee, make_appointment_slot, has_external_id=False, + ) + + mock_event = schemas.Event( + title='Test', + start=datetime.now(tz=timezone.utc), + end=datetime.now(tz=timezone.utc) + timedelta(minutes=30), + description='', + location=schemas.EventLocation(url=None), + ) + mock_save_remote_event.return_value = mock_event + + google_calendar = self._make_google_calendar() + schedule = self._make_schedule() + subscriber = self._make_subscriber() + background_tasks = MagicMock() + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment.id) + slot = db_appointment.slots[0] + db.add(slot) + db.add(slot.attendee) + + handle_schedule_availability_decision( + True, google_calendar, schedule, subscriber, slot, + db, None, None, background_tasks, + ) + + for call in background_tasks.add_task.call_args_list: + func = call[0][0] if call[0] else call.kwargs.get('func') + assert 'send_invitation' not in getattr(func, '__name__', '') From 547b9c9bbf798b8496c6afc2f2f2fb5ca7949bc3 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Tue, 10 Mar 2026 10:24:55 -0600 Subject: [PATCH 30/48] Add GOOGLE_INVITE_ENABLED env var to control feature --- backend/.env.example | 2 ++ backend/.env.test | 1 + backend/src/appointment/defines.py | 10 ++++++++++ backend/src/appointment/routes/schedule.py | 6 +++--- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 204695090..f5c625677 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -10,6 +10,8 @@ APP_ENV=dev APP_ADMIN_ALLOW_LIST= APP_SETUP APP_ALLOW_FIRST_TIME_REGISTER= +# Enable Google Calendar native invites (True/False). Defaults based on APP_ENV if unset. +GOOGLE_INVITE_ENABLED= # -- BACKEND -- BACKEND_URL=http://localhost:5000 diff --git a/backend/.env.test b/backend/.env.test index dbd9223ba..ae3577261 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -70,6 +70,7 @@ SIGNED_SECRET=test-secret-pls-ignore SENTRY_DSN= # Possible values: prod, dev, test APP_ENV=test +GOOGLE_INVITE_ENABLED=True # Possible values: password, fxa AUTH_SCHEME=password diff --git a/backend/src/appointment/defines.py b/backend/src/appointment/defines.py index 8d322d02d..36b6205c4 100644 --- a/backend/src/appointment/defines.py +++ b/backend/src/appointment/defines.py @@ -35,6 +35,16 @@ BASE_PATH = f'{sys.modules["appointment"].__path__[0]}' +def google_invite_enabled() -> bool: + """Check if Google Calendar native invites are enabled. + Explicit env var takes precedence; otherwise enabled for all envs except prod.""" + explicit = os.getenv('GOOGLE_INVITE_ENABLED') + if explicit is not None: + return explicit.lower() in ('true', '1', 'yes') + app_env = os.getenv('APP_ENV', APP_ENV_DEV) + return app_env != APP_ENV_PROD + + # This has to be lazy loaded because the env vars are not available at import time in main.py @cache def get_long_base_sign_url(): diff --git a/backend/src/appointment/routes/schedule.py b/backend/src/appointment/routes/schedule.py index 0d88ca5c0..6e0572713 100644 --- a/backend/src/appointment/routes/schedule.py +++ b/backend/src/appointment/routes/schedule.py @@ -29,7 +29,7 @@ from datetime import datetime, timedelta, timezone from zoneinfo import ZoneInfo -from ..defines import FALLBACK_LOCALE +from ..defines import FALLBACK_LOCALE, google_invite_enabled from ..dependencies.zoom import get_zoom_client from ..exceptions import validation from ..exceptions.calendar import EventNotCreatedException, EventNotDeletedException @@ -416,7 +416,7 @@ def request_schedule_availability_slot( # Create a pending appointment owner_language = subscriber.language if subscriber.language is not None else FALLBACK_LOCALE - use_google_invite = calendar.provider == CalendarProvider.google + use_google_invite = calendar.provider == CalendarProvider.google and google_invite_enabled() # Google invites skip the HOLD step — the event is created directly and # Google sends the invite to the subscriber for accept/decline. @@ -606,7 +606,7 @@ def handle_schedule_availability_decision( db.add(appointment) appointment_calendar = appointment.calendar - use_google_invite = calendar.provider == CalendarProvider.google + use_google_invite = calendar.provider == CalendarProvider.google and google_invite_enabled() # TODO: Check booking expiration date # check if request was denied From ce5f105b3e6984d187e1c6e9594305eb2b10aaf8 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Tue, 10 Mar 2026 10:25:07 -0600 Subject: [PATCH 31/48] Add tests for GOOGLE_INVITE_ENABLED fallback into branded emails --- .../unit/test_handle_availability_decision.py | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/backend/test/unit/test_handle_availability_decision.py b/backend/test/unit/test_handle_availability_decision.py index 1387ec12b..2e129e197 100644 --- a/backend/test/unit/test_handle_availability_decision.py +++ b/backend/test/unit/test_handle_availability_decision.py @@ -181,3 +181,50 @@ def test_confirm_creates_event_does_not_send_branded_vevent( for call in background_tasks.add_task.call_args_list: func = call[0][0] if call[0] else call.kwargs.get('func') assert 'send_invitation' not in getattr(func, '__name__', '') + + @patch('appointment.routes.schedule.save_remote_event') + @patch.dict('os.environ', {'GOOGLE_INVITE_ENABLED': 'False'}) + def test_flag_disabled_uses_non_google_path( + self, mock_save_remote_event, + with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot, + ): + """When GOOGLE_INVITE_ENABLED is False, a Google calendar should fall back + to the import/branded-email path (send_google_notification defaults to False).""" + calendar, appointment = self._setup( + with_db, make_google_calendar, make_appointment, + make_attendee, make_appointment_slot, has_external_id=False, + ) + + mock_event = schemas.Event( + title='Test', + start=datetime.now(tz=timezone.utc), + end=datetime.now(tz=timezone.utc) + timedelta(minutes=30), + description='', + location=schemas.EventLocation(url=None), + external_id='import-event-789', + ) + mock_save_remote_event.return_value = mock_event + + google_calendar = self._make_google_calendar() + schedule = self._make_schedule() + subscriber = self._make_subscriber() + background_tasks = MagicMock() + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment.id) + slot = db_appointment.slots[0] + db.add(slot) + db.add(slot.attendee) + + handle_schedule_availability_decision( + True, google_calendar, schedule, subscriber, slot, + db, None, None, background_tasks, + ) + + db.refresh(slot) + assert slot.booking_status == models.BookingStatus.booked + + mock_save_remote_event.assert_called_once() + call_kwargs = mock_save_remote_event.call_args + assert call_kwargs.kwargs.get('send_google_notification', False) is False + assert call_kwargs.kwargs.get('booking_confirmation', False) is False From 508bb6fb47e2c1532727b4af07ae39d31b98b003 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Tue, 10 Mar 2026 15:35:24 -0600 Subject: [PATCH 32/48] Add or create Zoom meeting link to the invite for Google Invite flows --- .../src/appointment/controller/calendar.py | 29 +++++++-- backend/src/appointment/routes/schedule.py | 3 +- backend/src/appointment/routes/webhooks.py | 63 ++++++++++++++++++- 3 files changed, 86 insertions(+), 9 deletions(-) diff --git a/backend/src/appointment/controller/calendar.py b/backend/src/appointment/controller/calendar.py index f4d51456b..a89af27c6 100644 --- a/backend/src/appointment/controller/calendar.py +++ b/backend/src/appointment/controller/calendar.py @@ -337,17 +337,36 @@ def save_event( return event - def confirm_event(self, event_id: str): - """Patch a tentative event to confirmed status, notifying attendees.""" + def confirm_event( + self, event_id: str, event: schemas.Event = None, organizer_language: str = None, + ): + """Patch a tentative event to confirmed status, notifying attendees. + + When *event* is provided the patch also updates summary, location, + and description so that a newly-created meeting link is visible on + the Google Calendar event. + """ body = {'status': 'confirmed'} - event = self.google_client.get_event( + if event: + body['summary'] = event.title + if event.location and event.location.url: + body['location'] = event.location.url + + lang = organizer_language or FALLBACK_LOCALE + description = [event.description] if event.description else [] + if event.location and event.location.url: + description.append(l10n('join-online', {'url': event.location.url}, lang=lang)) + if description: + body['description'] = '\n'.join(description) + + remote_event = self.google_client.get_event( calendar_id=self.remote_calendar_id, event_id=event_id, token=self.google_token, ) - if event and event.get('attendees'): - attendees = event['attendees'] + if remote_event and remote_event.get('attendees'): + attendees = remote_event['attendees'] for att in attendees: if att.get('self'): att['responseStatus'] = 'accepted' diff --git a/backend/src/appointment/routes/schedule.py b/backend/src/appointment/routes/schedule.py index 6e0572713..6b0c68b28 100644 --- a/backend/src/appointment/routes/schedule.py +++ b/backend/src/appointment/routes/schedule.py @@ -703,7 +703,8 @@ def handle_schedule_availability_decision( if appointment and appointment.external_id: # Patch the tentative hold event to confirmed; Google notifies the bookee. con, _ = get_remote_connection(appointment_calendar, subscriber, db, redis, google_client) - con.confirm_event(appointment.external_id) + owner_language = subscriber.language if subscriber.language is not None else FALLBACK_LOCALE + con.confirm_event(appointment.external_id, event=event, organizer_language=owner_language) else: # No hold event exists (booking_confirmation was false); create a confirmed event directly. event = save_remote_event( diff --git a/backend/src/appointment/routes/webhooks.py b/backend/src/appointment/routes/webhooks.py index 7148811a3..7d86a8859 100644 --- a/backend/src/appointment/routes/webhooks.py +++ b/backend/src/appointment/routes/webhooks.py @@ -10,14 +10,18 @@ from ..controller import auth, data, zoom from ..controller.apis.fxa_client import FxaClient from ..controller.apis.google_client import GoogleClient +from ..controller.calendar import Tools from ..controller.google_watch import teardown_watch_channel from ..database import repo, models, schemas +from ..database.models import MeetingLinkProviderType +from ..defines import FALLBACK_LOCALE from ..dependencies.database import get_db, get_redis from ..dependencies.fxa import get_webhook_auth as get_webhook_auth_fxa, get_fxa_client from ..dependencies.google import get_google_client -from ..dependencies.zoom import get_webhook_auth as get_webhook_auth_zoom +from ..dependencies.zoom import get_zoom_client, get_webhook_auth as get_webhook_auth_zoom from ..exceptions.account_api import AccountDeletionSubscriberFail from ..exceptions.fxa_api import MissingRefreshTokenException +from ..l10n import l10n router = APIRouter() @@ -291,6 +295,33 @@ def _handle_event_cancelled( ) +def _create_zoom_meeting_link( + db: Session, + slot: models.Slot, + subscriber: models.Subscriber, + title: str, +) -> str | None: + """Try to create a Zoom meeting link and persist it on the slot. + + Returns the join URL on success, or ``None`` on any failure. + """ + try: + zoom_client = get_zoom_client(subscriber) + response = zoom_client.create_meeting(title, slot.start.isoformat(), slot.duration, subscriber.timezone) + if 'id' in response: + join_url = zoom_client.get_meeting(response['id'])['join_url'] + slot.meeting_link_id = response['id'] + slot.meeting_link_url = join_url + db.add(slot) + db.commit() + return join_url + except Exception as err: + logging.error(f'[webhooks.google_calendar] Zoom meeting creation error: {err}') + if sentry_sdk.is_initialized(): + sentry_sdk.capture_exception(err) + return None + + def _handle_subscriber_rsvp( db: Session, appointment: models.Appointment, @@ -312,10 +343,36 @@ def _handle_subscriber_rsvp( repo.slot.book(db, slot.id) if appointment.external_id: + subscriber = appointment.calendar.owner + title = Tools.default_event_title(slot, subscriber) + repo.appointment.update_title(db, appointment.id, title) + + location_url = appointment.location_url + if appointment.meeting_link_provider == MeetingLinkProviderType.zoom: + location_url = _create_zoom_meeting_link(db, slot, subscriber, title) or location_url + + owner_lang = subscriber.language if subscriber.language else FALLBACK_LOCALE + body = {'status': 'confirmed', 'summary': title} + + if location_url: + body['location'] = location_url + + description = [appointment.details or ''] + if location_url: + description.append(l10n('join-online', {'url': location_url}, lang=owner_lang)) + + body['description'] = '\n'.join(description) + try: + remote_event = google_client.get_event(remote_calendar_id, appointment.external_id, google_token) + if remote_event and remote_event.get('attendees'): + for att in remote_event['attendees']: + if att.get('self'): + att['responseStatus'] = 'accepted' + body['attendees'] = remote_event['attendees'] + google_client.patch_event( - remote_calendar_id, appointment.external_id, - {'status': 'confirmed'}, google_token, + remote_calendar_id, appointment.external_id, body, google_token, ) except Exception: logging.warning('[webhooks.google_calendar] Failed to confirm event in Google') From 72651fd903dadfab31cafcde6b90cf5d8036fe32 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Tue, 10 Mar 2026 15:36:28 -0600 Subject: [PATCH 33/48] Update test suite with new coverage for Zoom meeting links --- .../test_google_calendar_webhook.py | 23 ++- backend/test/unit/test_google_webhook.py | 169 +++++++++++++++++- .../unit/test_handle_availability_decision.py | 8 +- 3 files changed, 192 insertions(+), 8 deletions(-) diff --git a/backend/test/integration/test_google_calendar_webhook.py b/backend/test/integration/test_google_calendar_webhook.py index 0993258d0..1fcab888f 100644 --- a/backend/test/integration/test_google_calendar_webhook.py +++ b/backend/test/integration/test_google_calendar_webhook.py @@ -263,8 +263,15 @@ def _reload(self, with_db, appointment_id): def test_subscriber_accepts_confirms_booking(self, with_db, rsvp_setup): """When the subscriber accepts a tentative event via Google Calendar, - the appointment should be closed and the slot booked.""" + the appointment should be closed, the slot booked, and the event + patched with status, summary, location, and description.""" mock_google_client, mock_token, appointment = rsvp_setup + mock_google_client.get_event.return_value = { + 'attendees': [ + {'email': 'owner@example.com', 'self': True, 'responseStatus': 'needsAction'}, + {'email': self.BOOKEE_EMAIL, 'responseStatus': 'needsAction'}, + ] + } with with_db() as db: appt = repo.appointment.get(db, appointment.id) @@ -278,10 +285,16 @@ def test_subscriber_accepts_confirms_booking(self, with_db, rsvp_setup): assert appt.status == models.AppointmentStatus.closed assert slot.booking_status == models.BookingStatus.booked - mock_google_client.patch_event.assert_called_once_with( - self.REMOTE_CALENDAR_ID, self.GOOGLE_EVENT_ID, - {'status': 'confirmed'}, mock_token, - ) + mock_google_client.patch_event.assert_called_once() + call_args = mock_google_client.patch_event.call_args + assert call_args.args[0] == self.REMOTE_CALENDAR_ID + assert call_args.args[1] == self.GOOGLE_EVENT_ID + body = call_args.args[2] + assert body['status'] == 'confirmed' + assert 'summary' in body + assert 'description' in body + owner_att = next(a for a in body['attendees'] if a.get('self')) + assert owner_att['responseStatus'] == 'accepted' def test_subscriber_accepts_already_closed_is_noop(self, with_db, rsvp_setup): """If the appointment is already closed, a subscriber accept should not change anything.""" diff --git a/backend/test/unit/test_google_webhook.py b/backend/test/unit/test_google_webhook.py index d0a3fddf2..53a297f98 100644 --- a/backend/test/unit/test_google_webhook.py +++ b/backend/test/unit/test_google_webhook.py @@ -3,11 +3,15 @@ import json import os from datetime import datetime, timedelta, timezone -from unittest.mock import Mock +from unittest.mock import Mock, patch from appointment.controller.google_watch import setup_watch_channel, teardown_watch_channel from appointment.database import models, repo -from appointment.routes.webhooks import _find_appointment_by_external_id, _handle_bookee_rsvp +from appointment.routes.webhooks import ( + _find_appointment_by_external_id, + _handle_bookee_rsvp, + _handle_subscriber_rsvp, +) class TestFindAppointmentByExternalId: @@ -175,6 +179,167 @@ def test_needsaction_is_ignored( assert db_slot.booking_status == models.BookingStatus.requested +class TestHandleSubscriberRsvp: + """Tests for _handle_subscriber_rsvp, focused on meeting-link and event-update behaviour.""" + + def _make_test_objects( + self, with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot, + meeting_link_provider=models.MeetingLinkProviderType.none, + location_url='https://meet.example.com', + ): + calendar = make_google_calendar(connected=True) + attendee = make_attendee(email='bookee@example.com', name='Bookee') + appointment = make_appointment( + calendar_id=calendar.id, + status=models.AppointmentStatus.opened, + location_url=location_url, + meeting_link_provider=meeting_link_provider, + slots=None, + ) + make_appointment_slot( + appointment_id=appointment.id, + booking_status=models.BookingStatus.requested, + attendee_id=attendee.id, + ) + + with with_db() as db: + db_appt = repo.appointment.get(db, appointment.id) + db_appt.external_id = 'google-event-789' + db.commit() + slot_id = db_appt.slots[0].id + + return calendar, appointment.id, slot_id + + def test_accept_patches_event_with_location_and_title( + self, with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot + ): + """Accepting via Google should patch the event with summary, location, and description.""" + calendar, appointment_id, slot_id = self._make_test_objects( + with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot, + ) + + mock_client = Mock() + mock_client.get_event.return_value = { + 'attendees': [ + {'email': 'owner@example.com', 'self': True, 'responseStatus': 'needsAction'}, + {'email': 'bookee@example.com', 'responseStatus': 'needsAction'}, + ] + } + mock_token = Mock() + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment_id) + db_slot = repo.slot.get(db, slot_id) + + _handle_subscriber_rsvp( + db, db_appointment, db_slot, 'accepted', + mock_client, mock_token, calendar.user, + ) + + mock_client.patch_event.assert_called_once() + patch_body = mock_client.patch_event.call_args.args[2] + assert patch_body['status'] == 'confirmed' + assert 'summary' in patch_body + assert patch_body.get('location') == 'https://meet.example.com' + assert 'https://meet.example.com' in patch_body.get('description', '') + + @patch('appointment.routes.webhooks._create_zoom_meeting_link') + def test_accept_creates_zoom_link_when_configured( + self, mock_create_zoom, + with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot, + ): + """When meeting_link_provider is zoom, a Zoom link should be created and used as location.""" + mock_create_zoom.return_value = 'https://zoom.us/j/123456' + + calendar, appointment_id, slot_id = self._make_test_objects( + with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot, + meeting_link_provider=models.MeetingLinkProviderType.zoom, + ) + + mock_client = Mock() + mock_client.get_event.return_value = {'attendees': []} + mock_token = Mock() + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment_id) + db_slot = repo.slot.get(db, slot_id) + + _handle_subscriber_rsvp( + db, db_appointment, db_slot, 'accepted', + mock_client, mock_token, calendar.user, + ) + + mock_create_zoom.assert_called_once() + patch_body = mock_client.patch_event.call_args.args[2] + assert patch_body.get('location') == 'https://zoom.us/j/123456' + + def test_accept_sets_organizer_response_to_accepted( + self, with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot + ): + """The organizer (self) attendee responseStatus should be set to accepted.""" + calendar, appointment_id, slot_id = self._make_test_objects( + with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot, + ) + + mock_client = Mock() + mock_client.get_event.return_value = { + 'attendees': [ + {'email': 'owner@example.com', 'self': True, 'responseStatus': 'needsAction'}, + {'email': 'bookee@example.com', 'responseStatus': 'needsAction'}, + ] + } + mock_token = Mock() + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment_id) + db_slot = repo.slot.get(db, slot_id) + + _handle_subscriber_rsvp( + db, db_appointment, db_slot, 'accepted', + mock_client, mock_token, calendar.user, + ) + + patch_body = mock_client.patch_event.call_args.args[2] + owner_att = next(a for a in patch_body['attendees'] if a.get('self')) + assert owner_att['responseStatus'] == 'accepted' + + def test_accept_noop_when_already_closed( + self, with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot + ): + """If the appointment is already closed, accepting again should be a no-op.""" + calendar = make_google_calendar(connected=True) + attendee = make_attendee(email='bookee@example.com', name='Bookee') + appointment = make_appointment( + calendar_id=calendar.id, + status=models.AppointmentStatus.closed, + slots=None, + ) + make_appointment_slot( + appointment_id=appointment.id, + booking_status=models.BookingStatus.booked, + attendee_id=attendee.id, + ) + + with with_db() as db: + db_appt = repo.appointment.get(db, appointment.id) + db_appt.external_id = 'already-confirmed-event' + db.commit() + + mock_client = Mock() + mock_token = Mock() + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment.id) + db_slot = db_appointment.slots[0] + + _handle_subscriber_rsvp( + db, db_appointment, db_slot, 'accepted', + mock_client, mock_token, calendar.user, + ) + + mock_client.patch_event.assert_not_called() + + class TestGoogleCalendarChannelRepo: def test_create_and_get_by_channel_id(self, with_db, make_google_calendar): calendar = make_google_calendar(connected=True) diff --git a/backend/test/unit/test_handle_availability_decision.py b/backend/test/unit/test_handle_availability_decision.py index 2e129e197..2ed2cb402 100644 --- a/backend/test/unit/test_handle_availability_decision.py +++ b/backend/test/unit/test_handle_availability_decision.py @@ -89,7 +89,13 @@ def test_confirm_patches_existing_hold_event( db.refresh(slot) assert slot.booking_status == models.BookingStatus.booked - mock_connector.confirm_event.assert_called_once_with('google-event-123') + mock_connector.confirm_event.assert_called_once() + call_args = mock_connector.confirm_event.call_args + assert call_args.args[0] == 'google-event-123' + event_arg = call_args.kwargs.get('event') + assert isinstance(event_arg, schemas.Event) + assert event_arg.location.url == 'https://meet.example.com' + assert call_args.kwargs.get('organizer_language') == 'en' @patch('appointment.routes.schedule.save_remote_event') def test_confirm_creates_event_when_no_hold_exists( From 096abbbf60f6a93a16b3bcda053a221c653d75a1 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Tue, 10 Mar 2026 16:35:32 -0600 Subject: [PATCH 34/48] Attempt to fix CodeQL false positive in test --- backend/test/unit/test_google_webhook.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/test/unit/test_google_webhook.py b/backend/test/unit/test_google_webhook.py index 53a297f98..86d0a0d0e 100644 --- a/backend/test/unit/test_google_webhook.py +++ b/backend/test/unit/test_google_webhook.py @@ -241,7 +241,9 @@ def test_accept_patches_event_with_location_and_title( assert patch_body['status'] == 'confirmed' assert 'summary' in patch_body assert patch_body.get('location') == 'https://meet.example.com' - assert 'https://meet.example.com' in patch_body.get('description', '') + + desc_lines = patch_body.get('description', '').split('\n') + assert any(line.endswith('https://meet.example.com') for line in desc_lines) @patch('appointment.routes.webhooks._create_zoom_meeting_link') def test_accept_creates_zoom_link_when_configured( From 022371c4c9ae6bb613cf7e9e297264835005c623 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Thu, 26 Mar 2026 10:45:38 -0600 Subject: [PATCH 35/48] No need for a helper function to check for GOOGLE_INVITE_ENABLED --- backend/src/appointment/defines.py | 10 ---------- backend/src/appointment/routes/schedule.py | 8 +++++--- pulumi/config.prod.yaml | 2 ++ pulumi/config.stage.yaml | 2 ++ 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/backend/src/appointment/defines.py b/backend/src/appointment/defines.py index 36b6205c4..8d322d02d 100644 --- a/backend/src/appointment/defines.py +++ b/backend/src/appointment/defines.py @@ -35,16 +35,6 @@ BASE_PATH = f'{sys.modules["appointment"].__path__[0]}' -def google_invite_enabled() -> bool: - """Check if Google Calendar native invites are enabled. - Explicit env var takes precedence; otherwise enabled for all envs except prod.""" - explicit = os.getenv('GOOGLE_INVITE_ENABLED') - if explicit is not None: - return explicit.lower() in ('true', '1', 'yes') - app_env = os.getenv('APP_ENV', APP_ENV_DEV) - return app_env != APP_ENV_PROD - - # This has to be lazy loaded because the env vars are not available at import time in main.py @cache def get_long_base_sign_url(): diff --git a/backend/src/appointment/routes/schedule.py b/backend/src/appointment/routes/schedule.py index 6b0c68b28..47bc5bcff 100644 --- a/backend/src/appointment/routes/schedule.py +++ b/backend/src/appointment/routes/schedule.py @@ -29,7 +29,7 @@ from datetime import datetime, timedelta, timezone from zoneinfo import ZoneInfo -from ..defines import FALLBACK_LOCALE, google_invite_enabled +from ..defines import FALLBACK_LOCALE from ..dependencies.zoom import get_zoom_client from ..exceptions import validation from ..exceptions.calendar import EventNotCreatedException, EventNotDeletedException @@ -416,7 +416,9 @@ def request_schedule_availability_slot( # Create a pending appointment owner_language = subscriber.language if subscriber.language is not None else FALLBACK_LOCALE - use_google_invite = calendar.provider == CalendarProvider.google and google_invite_enabled() + use_google_invite = ( + calendar.provider == CalendarProvider.google and os.getenv('GOOGLE_INVITE_ENABLED') == 'True' + ) # Google invites skip the HOLD step — the event is created directly and # Google sends the invite to the subscriber for accept/decline. @@ -606,7 +608,7 @@ def handle_schedule_availability_decision( db.add(appointment) appointment_calendar = appointment.calendar - use_google_invite = calendar.provider == CalendarProvider.google and google_invite_enabled() + use_google_invite = calendar.provider == CalendarProvider.google and os.getenv('GOOGLE_INVITE_ENABLED') == 'True' # TODO: Check booking expiration date # check if request was denied diff --git a/pulumi/config.prod.yaml b/pulumi/config.prod.yaml index b6e24d20d..1319e93c9 100644 --- a/pulumi/config.prod.yaml +++ b/pulumi/config.prod.yaml @@ -301,6 +301,8 @@ resources: value: https://thundermail.com - name: TB_ACCOUNTS_HOST value: https://accounts.tb.pro + - name: GOOGLE_INVITE_ENABLED + value: "False" tb:autoscale:EcsServiceAutoscaler: backend: diff --git a/pulumi/config.stage.yaml b/pulumi/config.stage.yaml index aaa8bf32e..9ace47eea 100644 --- a/pulumi/config.stage.yaml +++ b/pulumi/config.stage.yaml @@ -302,6 +302,8 @@ resources: value: https://stage-thundermail.com - name: TB_ACCOUNTS_HOST value: https://accounts-stage.tb.pro + - name: GOOGLE_INVITE_ENABLED + value: "True" tb:autoscale:EcsServiceAutoscaler: backend: From 00b940e51eca6eeb9605de9980df6d34a8bc40e8 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Thu, 26 Mar 2026 10:47:42 -0600 Subject: [PATCH 36/48] Consolidate 200 successful response in a variable --- backend/src/appointment/routes/webhooks.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/backend/src/appointment/routes/webhooks.py b/backend/src/appointment/routes/webhooks.py index 7d86a8859..63dbff360 100644 --- a/backend/src/appointment/routes/webhooks.py +++ b/backend/src/appointment/routes/webhooks.py @@ -134,30 +134,29 @@ def google_calendar_notification( channel_id = request.headers.get('X-Goog-Channel-Id') resource_state = request.headers.get('X-Goog-Resource-State') - if not channel_id: - return Response(status_code=200) + success_response = Response(status_code=200) # Google sends a 'sync' notification when the channel is first created; just acknowledge it - if resource_state == 'sync': - return Response(status_code=200) + if not channel_id or resource_state == 'sync': + return success_response channel = repo.google_calendar_channel.get_by_channel_id(db, channel_id) if not channel: logging.warning(f'[webhooks.google_calendar] Unknown channel_id: {channel_id}') - return Response(status_code=200) + return success_response calendar = channel.calendar if not calendar: repo.google_calendar_channel.delete(db, channel) - return Response(status_code=200) + return success_response if not calendar.connected: teardown_watch_channel(db, google_client, calendar) - return Response(status_code=200) + return success_response external_connection = calendar.external_connection if not external_connection or not external_connection.token: teardown_watch_channel(db, google_client, calendar) - return Response(status_code=200) + return success_response token = Credentials.from_authorized_user_info( json.loads(external_connection.token), google_client.SCOPES @@ -167,7 +166,7 @@ def google_calendar_notification( sync_token = google_client.get_initial_sync_token(calendar.user, token) if sync_token: repo.google_calendar_channel.update_sync_token(db, channel, sync_token) - return Response(status_code=200) + return success_response changed_events, new_sync_token = google_client.list_events_sync( calendar.user, channel.sync_token, token @@ -178,7 +177,7 @@ def google_calendar_notification( fresh_token = google_client.get_initial_sync_token(calendar.user, token) if fresh_token: repo.google_calendar_channel.update_sync_token(db, channel, fresh_token) - return Response(status_code=200) + return success_response if new_sync_token: repo.google_calendar_channel.update_sync_token(db, channel, new_sync_token) @@ -192,7 +191,7 @@ def google_calendar_notification( remote_calendar_id=calendar.user, ) - return Response(status_code=200) + return success_response From ec36a80763f817656c6b139489a374d6c1ecf731 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Thu, 26 Mar 2026 11:23:33 -0600 Subject: [PATCH 37/48] Remove early success return in webhook in case the channel don't have a sync_token --- backend/src/appointment/routes/webhooks.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/src/appointment/routes/webhooks.py b/backend/src/appointment/routes/webhooks.py index 63dbff360..384e320fb 100644 --- a/backend/src/appointment/routes/webhooks.py +++ b/backend/src/appointment/routes/webhooks.py @@ -166,7 +166,6 @@ def google_calendar_notification( sync_token = google_client.get_initial_sync_token(calendar.user, token) if sync_token: repo.google_calendar_channel.update_sync_token(db, channel, sync_token) - return success_response changed_events, new_sync_token = google_client.list_events_sync( calendar.user, channel.sync_token, token From 3ff14c2282352be3eb6d3f27f18dc8050f11f701 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Thu, 26 Mar 2026 12:20:29 -0600 Subject: [PATCH 38/48] Share code for creating zoom meeting link --- backend/src/appointment/controller/zoom.py | 35 +++++++++++++++++- backend/src/appointment/routes/schedule.py | 43 +++------------------- backend/src/appointment/routes/webhooks.py | 30 +-------------- 3 files changed, 40 insertions(+), 68 deletions(-) diff --git a/backend/src/appointment/controller/zoom.py b/backend/src/appointment/controller/zoom.py index 8a3fe6257..4b0a939ec 100644 --- a/backend/src/appointment/controller/zoom.py +++ b/backend/src/appointment/controller/zoom.py @@ -1,7 +1,11 @@ +import logging + +import sentry_sdk from sqlalchemy.orm import Session -from appointment.database import repo, models -from appointment.database.models import ExternalConnectionType +from ..database import repo, models +from ..database.models import ExternalConnectionType +from ..dependencies.zoom import get_zoom_client def update_schedules_meeting_link_provider(db: Session, subscriber_id: int) -> bool: @@ -14,6 +18,33 @@ def update_schedules_meeting_link_provider(db: Session, subscriber_id: int) -> b return True +def create_meeting_link( + db: Session, + slot: models.Slot, + subscriber: models.Subscriber, + title: str, +) -> str | None: + """Create a Zoom meeting and persist the link on the slot. + + Returns the join URL on success, or ``None`` on any failure. + """ + try: + zoom_client = get_zoom_client(subscriber) + response = zoom_client.create_meeting(title, slot.start.isoformat(), slot.duration, subscriber.timezone) + if 'id' in response: + join_url = zoom_client.get_meeting(response['id'])['join_url'] + slot.meeting_link_id = response['id'] + slot.meeting_link_url = join_url + db.add(slot) + db.commit() + return join_url + except Exception as err: + logging.error(f'[zoom] Zoom meeting creation error: {err}') + if sentry_sdk.is_initialized(): + sentry_sdk.capture_exception(err) + return None + + def disconnect(db: Session, subscriber_id: int, type_id: str) -> bool: """Disconnects a zoom external connection from a given subscriber id and zoom type id""" repo.external_connection.delete_by_type(db, subscriber_id, ExternalConnectionType.zoom, type_id) diff --git a/backend/src/appointment/routes/schedule.py b/backend/src/appointment/routes/schedule.py index 47bc5bcff..aeaac4c42 100644 --- a/backend/src/appointment/routes/schedule.py +++ b/backend/src/appointment/routes/schedule.py @@ -3,12 +3,10 @@ import os import zoneinfo -from oauthlib.oauth2 import OAuth2Error -from requests import HTTPError from sentry_sdk import capture_exception -from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session +from ..controller import zoom from ..controller.calendar import CalDavConnector, Tools, GoogleConnector from ..controller.apis.google_client import GoogleClient from ..controller.google_watch import setup_watch_channel, teardown_watch_channel @@ -30,7 +28,6 @@ from zoneinfo import ZoneInfo from ..defines import FALLBACK_LOCALE -from ..dependencies.zoom import get_zoom_client from ..exceptions import validation from ..exceptions.calendar import EventNotCreatedException, EventNotDeletedException from ..exceptions.misc import UnexpectedBehaviourWarning @@ -644,46 +641,16 @@ def handle_schedule_availability_decision( # If needed: Create a zoom meeting link for this booking if schedule.meeting_link_provider == MeetingLinkProviderType.zoom: - try: - zoom_client = get_zoom_client(subscriber) - response = zoom_client.create_meeting(title, slot.start.isoformat(), slot.duration, subscriber.timezone) - if 'id' in response: - location_url = zoom_client.get_meeting(response['id'])['join_url'] - slot.meeting_link_id = response['id'] - slot.meeting_link_url = location_url - - db.add(slot) - db.commit() - except HTTPError as err: # Not fatal, just a bummer - logging.error('Zoom meeting creation error: ', err) - - # Ensure sentry captures the error too! - if os.getenv('SENTRY_DSN') != '': - capture_exception(err) - - # Notify the organizer that the meeting link could not be created! - background_tasks.add_task( - send_zoom_meeting_failed_email, - to=subscriber.preferred_email, - appointment_title=schedule.name, - lang=subscriber.language, - ) - except OAuth2Error as err: - logging.error('OAuth flow error during zoom meeting creation: ', err) - if os.getenv('SENTRY_DSN') != '': - capture_exception(err) - - # Notify the organizer that the meeting link could not be created! + zoom_url = zoom.create_meeting_link(db, slot, subscriber, title) + if zoom_url: + location_url = zoom_url + else: background_tasks.add_task( send_zoom_meeting_failed_email, to=subscriber.preferred_email, appointment_title=schedule.name, lang=subscriber.language, ) - except SQLAlchemyError as err: # Not fatal, but could make things tricky - logging.error('Failed to save the zoom meeting link to the appointment: ', err) - if os.getenv('SENTRY_DSN') != '': - capture_exception(err) event = schemas.Event( title=title, diff --git a/backend/src/appointment/routes/webhooks.py b/backend/src/appointment/routes/webhooks.py index 384e320fb..685c3f1ad 100644 --- a/backend/src/appointment/routes/webhooks.py +++ b/backend/src/appointment/routes/webhooks.py @@ -18,7 +18,7 @@ from ..dependencies.database import get_db, get_redis from ..dependencies.fxa import get_webhook_auth as get_webhook_auth_fxa, get_fxa_client from ..dependencies.google import get_google_client -from ..dependencies.zoom import get_zoom_client, get_webhook_auth as get_webhook_auth_zoom +from ..dependencies.zoom import get_webhook_auth as get_webhook_auth_zoom from ..exceptions.account_api import AccountDeletionSubscriberFail from ..exceptions.fxa_api import MissingRefreshTokenException from ..l10n import l10n @@ -293,32 +293,6 @@ def _handle_event_cancelled( ) -def _create_zoom_meeting_link( - db: Session, - slot: models.Slot, - subscriber: models.Subscriber, - title: str, -) -> str | None: - """Try to create a Zoom meeting link and persist it on the slot. - - Returns the join URL on success, or ``None`` on any failure. - """ - try: - zoom_client = get_zoom_client(subscriber) - response = zoom_client.create_meeting(title, slot.start.isoformat(), slot.duration, subscriber.timezone) - if 'id' in response: - join_url = zoom_client.get_meeting(response['id'])['join_url'] - slot.meeting_link_id = response['id'] - slot.meeting_link_url = join_url - db.add(slot) - db.commit() - return join_url - except Exception as err: - logging.error(f'[webhooks.google_calendar] Zoom meeting creation error: {err}') - if sentry_sdk.is_initialized(): - sentry_sdk.capture_exception(err) - return None - def _handle_subscriber_rsvp( db: Session, @@ -347,7 +321,7 @@ def _handle_subscriber_rsvp( location_url = appointment.location_url if appointment.meeting_link_provider == MeetingLinkProviderType.zoom: - location_url = _create_zoom_meeting_link(db, slot, subscriber, title) or location_url + location_url = zoom.create_meeting_link(db, slot, subscriber, title) or location_url owner_lang = subscriber.language if subscriber.language else FALLBACK_LOCALE body = {'status': 'confirmed', 'summary': title} From 522e1710578133bf0a433a9ee35d942d62bebf8f Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Thu, 26 Mar 2026 12:28:22 -0600 Subject: [PATCH 39/48] Update tests --- backend/test/integration/test_schedule.py | 2 +- backend/test/unit/test_google_webhook.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/test/integration/test_schedule.py b/backend/test/integration/test_schedule.py index c2b146d00..ac7c24f05 100644 --- a/backend/test/integration/test_schedule.py +++ b/backend/test/integration/test_schedule.py @@ -819,7 +819,7 @@ def bust_cached_events(self, all_calendars=False): def mock_get_zoom_client(*args, **kwargs): return mock_zoom_client - monkeypatch.setattr('appointment.routes.schedule.get_zoom_client', mock_get_zoom_client) + monkeypatch.setattr('appointment.controller.zoom.get_zoom_client', mock_get_zoom_client) response = with_client.put( '/schedule/public/availability/request', diff --git a/backend/test/unit/test_google_webhook.py b/backend/test/unit/test_google_webhook.py index 86d0a0d0e..8324c60ab 100644 --- a/backend/test/unit/test_google_webhook.py +++ b/backend/test/unit/test_google_webhook.py @@ -245,7 +245,7 @@ def test_accept_patches_event_with_location_and_title( desc_lines = patch_body.get('description', '').split('\n') assert any(line.endswith('https://meet.example.com') for line in desc_lines) - @patch('appointment.routes.webhooks._create_zoom_meeting_link') + @patch('appointment.controller.zoom.create_meeting_link') def test_accept_creates_zoom_link_when_configured( self, mock_create_zoom, with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot, From eb12551216fa219a33543c96a62e686d723d0dca Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Wed, 1 Apr 2026 11:15:11 -0600 Subject: [PATCH 40/48] Add GOOGLE_CHANNEL_TTL_IN_SECONDS to safeguard against unexpected API changes on defaults --- backend/.env.example | 1 + backend/.env.test | 1 + backend/src/appointment/controller/apis/google_client.py | 8 ++++++++ 3 files changed, 10 insertions(+) diff --git a/backend/.env.example b/backend/.env.example index f5c625677..a6423959d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -97,6 +97,7 @@ GOOGLE_AUTH_CLIENT_ID= GOOGLE_AUTH_SECRET= GOOGLE_AUTH_PROJECT_ID= GOOGLE_AUTH_CALLBACK=http://localhost:5000/google/callback +GOOGLE_CHANNEL_TTL_IN_SECONDS=604800 # 7 days # -- Zoom API -- ZOOM_API_ENABLED=False diff --git a/backend/.env.test b/backend/.env.test index ae3577261..70348c8bd 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -53,6 +53,7 @@ GOOGLE_AUTH_CLIENT_ID= GOOGLE_AUTH_SECRET= GOOGLE_AUTH_PROJECT_ID= GOOGLE_AUTH_CALLBACK=http://localhost:5000/google/callback +GOOGLE_CHANNEL_TTL_IN_SECONDS=604800 # 7 days # -- TB ACCOUNTS -- TB_ACCOUNTS_CALDAV_URL=https://stage-thundermail.com diff --git a/backend/src/appointment/controller/apis/google_client.py b/backend/src/appointment/controller/apis/google_client.py index c5d1e6041..e70bc89e7 100644 --- a/backend/src/appointment/controller/apis/google_client.py +++ b/backend/src/appointment/controller/apis/google_client.py @@ -309,6 +309,8 @@ def watch_events(self, calendar_id, webhook_url, token): """Register a push notification channel for calendar event changes. Ref: https://developers.google.com/calendar/api/v3/reference/events/watch""" channel_id = str(uuid.uuid4()) + ttl = os.getenv('GOOGLE_CHANNEL_TTL_IN_SECONDS', 604800) # 7 days + with build('calendar', 'v3', credentials=token, cache_discovery=False) as service: try: response = service.events().watch( @@ -317,11 +319,17 @@ def watch_events(self, calendar_id, webhook_url, token): 'id': channel_id, 'type': 'web_hook', 'address': webhook_url, + 'params': { + 'ttl': str(ttl) + } }, ).execute() except HttpError as e: logging.error(f'[google_client.watch_events] Request Error: {e.status_code}/{e.error_details}') return None + except Exception as e: + logging.error(f'[google_client.watch_events] Error: {e}') + return None return response From 49ecaa0f21f77f90ebf3adeb10bb1e391fe9ddc5 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Wed, 1 Apr 2026 11:15:41 -0600 Subject: [PATCH 41/48] Better query for google calendars in a schedule --- .../src/appointment/commands/backfill_google_channels.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/backend/src/appointment/commands/backfill_google_channels.py b/backend/src/appointment/commands/backfill_google_channels.py index 2c0b3ed70..5bc8ff169 100644 --- a/backend/src/appointment/commands/backfill_google_channels.py +++ b/backend/src/appointment/commands/backfill_google_channels.py @@ -28,11 +28,7 @@ def run(): # Find connected Google calendars that are the default in a schedule # and don't yet have a watch channel - schedule_calendar_ids = { - s.calendar_id - for s in db.query(models.Schedule).all() - if s.calendar_id is not None - } + schedule_calendar_ids = db.query(models.Schedule).filter(models.Schedule.calendar_id is None).all() all_calendars = db.query(models.Calendar).filter( models.Calendar.provider == models.CalendarProvider.google, From 3b25ac05ca71493d2b1db12be335ca684361ae52 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Wed, 1 Apr 2026 11:16:04 -0600 Subject: [PATCH 42/48] Only sync watch channels if GOOGLE_INVITE_ENABLED is True --- backend/src/appointment/routes/schedule.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/src/appointment/routes/schedule.py b/backend/src/appointment/routes/schedule.py index aeaac4c42..3107a6aab 100644 --- a/backend/src/appointment/routes/schedule.py +++ b/backend/src/appointment/routes/schedule.py @@ -152,7 +152,8 @@ def create_calendar_schedule( repo.schedule.hard_delete(db, db_schedule.id) raise validation.ScheduleCreationException() - _sync_watch_channels(db, google_client, subscriber, schedule.calendar_id) + if os.getenv('GOOGLE_INVITE_ENABLED') == 'True': + _sync_watch_channels(db, google_client, subscriber, schedule.calendar_id) return db_schedule @@ -210,7 +211,8 @@ def update_schedule( result = repo.schedule.update(db=db, schedule=schedule, schedule_id=id) - _sync_watch_channels(db, google_client, subscriber, schedule.calendar_id) + if os.getenv('GOOGLE_INVITE_ENABLED') == 'True': + _sync_watch_channels(db, google_client, subscriber, schedule.calendar_id) return result From a9af4b1e826aa22dc1f2dc40572d426cde9ac14d Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Wed, 1 Apr 2026 14:37:21 -0600 Subject: [PATCH 43/48] Add state to the google_calendar_channels table, populate it and check when receiving webhook calls --- .../commands/backfill_google_channels.py | 5 +- .../commands/renew_google_channels.py | 5 +- .../controller/apis/google_client.py | 21 ++++--- .../appointment/controller/google_watch.py | 5 +- backend/src/appointment/database/models.py | 1 + .../database/repo/google_calendar_channel.py | 4 ++ ...4d5e6f7a8_add_state_to_google_calendar_.py | 29 ++++++++++ backend/src/appointment/routes/webhooks.py | 5 ++ .../test_google_calendar_webhook.py | 55 +++++++++++++++++++ 9 files changed, 118 insertions(+), 12 deletions(-) create mode 100644 backend/src/appointment/migrations/versions/2026_04_01_1200-b3c4d5e6f7a8_add_state_to_google_calendar_.py diff --git a/backend/src/appointment/commands/backfill_google_channels.py b/backend/src/appointment/commands/backfill_google_channels.py index 5bc8ff169..89d56bf35 100644 --- a/backend/src/appointment/commands/backfill_google_channels.py +++ b/backend/src/appointment/commands/backfill_google_channels.py @@ -2,6 +2,7 @@ import json import logging +import uuid from datetime import datetime, timezone from google.oauth2.credentials import Credentials @@ -65,7 +66,8 @@ def run(): continue try: - response = google_client.watch_events(calendar.user, webhook_url, token) + state = str(uuid.uuid4()) + response = google_client.watch_events(calendar.user, webhook_url, token, state=state) if not response: print(f' Calendar {calendar.id}: watch_events returned no response.') failed += 1 @@ -82,6 +84,7 @@ def run(): channel_id=response['id'], resource_id=response['resourceId'], expiration=expiration_dt, + state=state, sync_token=sync_token, ) created += 1 diff --git a/backend/src/appointment/commands/renew_google_channels.py b/backend/src/appointment/commands/renew_google_channels.py index 2c8c3a6c7..eaf736deb 100644 --- a/backend/src/appointment/commands/renew_google_channels.py +++ b/backend/src/appointment/commands/renew_google_channels.py @@ -6,6 +6,7 @@ import json import logging +import uuid from datetime import datetime, timedelta, timezone from google.oauth2.credentials import Credentials @@ -60,7 +61,8 @@ def run(): # Create a new channel try: - response = google_client.watch_events(calendar.user, webhook_url, token) + new_state = str(uuid.uuid4()) + response = google_client.watch_events(calendar.user, webhook_url, token, state=new_state) if response: expiration_ms = int(response.get('expiration', 0)) expiration_dt = datetime.fromtimestamp(expiration_ms / 1000, tz=timezone.utc) @@ -71,6 +73,7 @@ def run(): new_channel_id=response['id'], new_resource_id=response['resourceId'], new_expiration=expiration_dt, + new_state=new_state, ) renewed += 1 else: diff --git a/backend/src/appointment/controller/apis/google_client.py b/backend/src/appointment/controller/apis/google_client.py index e70bc89e7..1240d091f 100644 --- a/backend/src/appointment/controller/apis/google_client.py +++ b/backend/src/appointment/controller/apis/google_client.py @@ -305,24 +305,27 @@ def delete_event(self, calendar_id, event_id, token, send_updates='none'): return response - def watch_events(self, calendar_id, webhook_url, token): + def watch_events(self, calendar_id, webhook_url, token, state: str): """Register a push notification channel for calendar event changes. Ref: https://developers.google.com/calendar/api/v3/reference/events/watch""" channel_id = str(uuid.uuid4()) ttl = os.getenv('GOOGLE_CHANNEL_TTL_IN_SECONDS', 604800) # 7 days + body = { + 'id': channel_id, + 'type': 'web_hook', + 'address': webhook_url, + 'token': state, + 'params': { + 'ttl': str(ttl) + }, + } + with build('calendar', 'v3', credentials=token, cache_discovery=False) as service: try: response = service.events().watch( calendarId=calendar_id, - body={ - 'id': channel_id, - 'type': 'web_hook', - 'address': webhook_url, - 'params': { - 'ttl': str(ttl) - } - }, + body=body, ).execute() except HttpError as e: logging.error(f'[google_client.watch_events] Request Error: {e.status_code}/{e.error_details}') diff --git a/backend/src/appointment/controller/google_watch.py b/backend/src/appointment/controller/google_watch.py index 7e256d218..c9d66dc21 100644 --- a/backend/src/appointment/controller/google_watch.py +++ b/backend/src/appointment/controller/google_watch.py @@ -3,6 +3,7 @@ import json import logging import os +import uuid from datetime import datetime, timezone from google.oauth2.credentials import Credentials @@ -53,7 +54,8 @@ def setup_watch_channel(db: Session, google_client: GoogleClient, calendar: mode return try: - response = google_client.watch_events(calendar.user, webhook_url, token) + state = str(uuid.uuid4()) + response = google_client.watch_events(calendar.user, webhook_url, token, state=state) if response: expiration_ms = int(response.get('expiration', 0)) expiration_dt = datetime.fromtimestamp(expiration_ms / 1000, tz=timezone.utc) @@ -66,6 +68,7 @@ def setup_watch_channel(db: Session, google_client: GoogleClient, calendar: mode channel_id=response['id'], resource_id=response['resourceId'], expiration=expiration_dt, + state=state, sync_token=sync_token, ) except Exception as e: diff --git a/backend/src/appointment/database/models.py b/backend/src/appointment/database/models.py index 8bc2bde89..56afdcfcb 100644 --- a/backend/src/appointment/database/models.py +++ b/backend/src/appointment/database/models.py @@ -470,6 +470,7 @@ class GoogleCalendarChannel(Base): resource_id = Column(String) expiration = Column(DateTime) sync_token = Column(String, nullable=True) + state = Column(encrypted_type(String, length=36), nullable=True) calendar: Mapped[Calendar] = relationship('Calendar', back_populates='google_channel') diff --git a/backend/src/appointment/database/repo/google_calendar_channel.py b/backend/src/appointment/database/repo/google_calendar_channel.py index bb50d5b40..f919a3f66 100644 --- a/backend/src/appointment/database/repo/google_calendar_channel.py +++ b/backend/src/appointment/database/repo/google_calendar_channel.py @@ -39,6 +39,7 @@ def create( channel_id: str, resource_id: str, expiration: datetime, + state: str, sync_token: str | None = None, ) -> models.GoogleCalendarChannel: channel = models.GoogleCalendarChannel( @@ -47,6 +48,7 @@ def create( resource_id=resource_id, expiration=expiration, sync_token=sync_token, + state=state, ) db.add(channel) db.commit() @@ -67,10 +69,12 @@ def update_expiration( new_channel_id: str, new_resource_id: str, new_expiration: datetime, + new_state: str, ): channel.channel_id = new_channel_id channel.resource_id = new_resource_id channel.expiration = new_expiration + channel.state = new_state db.commit() db.refresh(channel) return channel diff --git a/backend/src/appointment/migrations/versions/2026_04_01_1200-b3c4d5e6f7a8_add_state_to_google_calendar_.py b/backend/src/appointment/migrations/versions/2026_04_01_1200-b3c4d5e6f7a8_add_state_to_google_calendar_.py new file mode 100644 index 000000000..02b61b529 --- /dev/null +++ b/backend/src/appointment/migrations/versions/2026_04_01_1200-b3c4d5e6f7a8_add_state_to_google_calendar_.py @@ -0,0 +1,29 @@ +"""add state to google_calendar_channels + +Revision ID: b3c4d5e6f7a8 +Revises: d9c5594694c5 +Create Date: 2026-04-01 12:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + +from appointment.database.models import encrypted_type + +# revision identifiers, used by Alembic. +revision = 'b3c4d5e6f7a8' +down_revision = 'd9c5594694c5' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + 'google_calendar_channels', + sa.Column('state', encrypted_type(sa.String, length=36), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column('google_calendar_channels', 'state') diff --git a/backend/src/appointment/routes/webhooks.py b/backend/src/appointment/routes/webhooks.py index 685c3f1ad..71ed350b3 100644 --- a/backend/src/appointment/routes/webhooks.py +++ b/backend/src/appointment/routes/webhooks.py @@ -145,6 +145,11 @@ def google_calendar_notification( logging.warning(f'[webhooks.google_calendar] Unknown channel_id: {channel_id}') return success_response + incoming_state = request.headers.get('X-Goog-Channel-Token') + if incoming_state != channel.state: + logging.warning(f'[webhooks.google_calendar] State mismatch for channel {channel_id}') + return success_response + calendar = channel.calendar if not calendar: repo.google_calendar_channel.delete(db, channel) diff --git a/backend/test/integration/test_google_calendar_webhook.py b/backend/test/integration/test_google_calendar_webhook.py index 1fcab888f..fcea880cf 100644 --- a/backend/test/integration/test_google_calendar_webhook.py +++ b/backend/test/integration/test_google_calendar_webhook.py @@ -45,6 +45,54 @@ def test_unknown_channel_id_returns_200(self, with_client): ) assert response.status_code == 200 + def test_state_mismatch_is_rejected( + self, with_db, with_client, make_pro_subscriber, make_google_calendar, make_external_connections + ): + """A notification with a mismatched state token should be silently ignored.""" + subscriber = make_pro_subscriber() + google_creds = json.dumps({ + 'token': 'fake-token', + 'refresh_token': 'fake-refresh', + 'client_id': 'fake-client-id', + 'client_secret': 'fake-secret', + }) + ext_conn = make_external_connections( + subscriber.id, + type=models.ExternalConnectionType.google, + token=google_creds, + ) + calendar = make_google_calendar( + subscriber_id=subscriber.id, + connected=True, + external_connection_id=ext_conn.id, + ) + + with with_db() as db: + repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id='state-channel', + resource_id='res-state', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + state='correct-state-token', + sync_token='some-sync-token', + ) + + response = with_client.post( + '/webhooks/google-calendar', + headers={ + 'X-Goog-Channel-Id': 'state-channel', + 'X-Goog-Resource-State': 'exists', + 'X-Goog-Channel-Token': 'wrong-state-token', + }, + ) + assert response.status_code == 200 + + with with_db() as db: + channel = repo.google_calendar_channel.get_by_channel_id(db, 'state-channel') + assert channel is not None + assert channel.sync_token == 'some-sync-token' + def test_disconnected_calendar_triggers_teardown( self, with_db, with_client, make_pro_subscriber, make_google_calendar, make_external_connections ): @@ -67,6 +115,7 @@ def test_disconnected_calendar_triggers_teardown( external_connection_id=ext_conn.id, ) + channel_state = 'disconnected-state' with with_db() as db: repo.google_calendar_channel.create( db, @@ -74,6 +123,7 @@ def test_disconnected_calendar_triggers_teardown( channel_id='disconnected-cal-channel', resource_id='res-disconnected', expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + state=channel_state, sync_token='some-sync-token', ) @@ -82,6 +132,7 @@ def test_disconnected_calendar_triggers_teardown( headers={ 'X-Goog-Channel-Id': 'disconnected-cal-channel', 'X-Goog-Resource-State': 'exists', + 'X-Goog-Channel-Token': channel_state, }, ) assert response.status_code == 200 @@ -116,6 +167,7 @@ def test_valid_notification_returns_200( external_connection_id=ext_conn.id, ) + channel_state = 'test-state-token' with with_db() as db: repo.google_calendar_channel.create( db, @@ -123,6 +175,7 @@ def test_valid_notification_returns_200( channel_id='valid-channel', resource_id='res-valid', expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + state=channel_state, sync_token='initial-sync-token', ) @@ -131,6 +184,7 @@ def test_valid_notification_returns_200( headers={ 'X-Goog-Channel-Id': 'valid-channel', 'X-Goog-Resource-State': 'exists', + 'X-Goog-Channel-Token': channel_state, }, ) assert response.status_code == 200 @@ -195,6 +249,7 @@ def test_disconnect_google_calendar_does_not_remove_channel( channel_id='should-remain', resource_id='res-remain', expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + state='remain-state', ) response = with_client.post(f'/cal/{calendar.id}/disconnect', headers=auth_headers) From a8de1db34f9e12ea903fcc6421c45c52792c731e Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Wed, 1 Apr 2026 15:11:36 -0600 Subject: [PATCH 44/48] Better returns and erroring logs --- .../appointment/controller/google_watch.py | 46 ++++++++++++------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/backend/src/appointment/controller/google_watch.py b/backend/src/appointment/controller/google_watch.py index c9d66dc21..1d588d0f9 100644 --- a/backend/src/appointment/controller/google_watch.py +++ b/backend/src/appointment/controller/google_watch.py @@ -23,35 +23,42 @@ def get_webhook_url() -> str | None: def get_google_token(google_client: GoogleClient, external_connection: models.ExternalConnections): """Build Google Credentials from an external connection's stored token.""" + if not external_connection.token: + return None + return Credentials.from_authorized_user_info( json.loads(external_connection.token), google_client.SCOPES ) -def setup_watch_channel(db: Session, google_client: GoogleClient, calendar: models.Calendar): +def setup_watch_channel(db: Session, google_client: GoogleClient, calendar: models.Calendar) -> bool: """Register a push notification channel for a single Google calendar. - No-ops if a channel already exists or prerequisites are missing.""" + Returns True if a channel exists (or was created), False on failure.""" if not google_client or calendar.provider != models.CalendarProvider.google: - return + return False webhook_url = get_webhook_url() if not webhook_url: logging.warning('[google_watch] BACKEND_URL not set, skipping watch channel setup') - return + return False existing = repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) if existing: - return + return True external_connection = calendar.external_connection if not external_connection or not external_connection.token: - return + return False try: token = get_google_token(google_client, external_connection) except (json.JSONDecodeError, Exception) as e: - logging.warning(f'[google_watch] Could not parse token for calendar {calendar.id}: {e}') - return + logging.error(f'[google_watch] Could not parse token for calendar {calendar.id}: {e}') + return False + + if not token: + logging.error(f'[google_watch] Missing token for calendar {calendar.id}') + return False try: state = str(uuid.uuid4()) @@ -73,22 +80,31 @@ def setup_watch_channel(db: Session, google_client: GoogleClient, calendar: mode ) except Exception as e: logging.warning(f'[google_watch] Failed to set up watch channel for calendar {calendar.id}: {e}') + return False + + return True -def teardown_watch_channel(db: Session, google_client: GoogleClient | None, calendar: models.Calendar): - """Stop and delete the watch channel for a single Google calendar.""" +def teardown_watch_channel(db: Session, google_client: GoogleClient | None, calendar: models.Calendar) -> bool: + """Stop and delete the watch channel for a single Google calendar. + Returns True if the channel was deleted, False if there was nothing to remove.""" channel = repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) if not channel: - return + return False if google_client and calendar.external_connection and calendar.external_connection.token: try: token = get_google_token(google_client, calendar.external_connection) - google_client.stop_channel(channel.channel_id, channel.resource_id, token) + + if not token: + logging.error(f'[google_watch] Missing token for channel {channel.channel_id}') + else: + google_client.stop_channel(channel.channel_id, channel.resource_id, token) except Exception as e: logging.warning(f'[google_watch] Failed to stop channel {channel.channel_id}: {e}') repo.google_calendar_channel.delete(db, channel) + return True def teardown_watch_channels_for_connection( @@ -103,11 +119,9 @@ def teardown_watch_channels_for_connection( token = None if google_client: try: - token = Credentials.from_authorized_user_info( - json.loads(google_connection.token), google_client.SCOPES - ) + token = get_google_token(google_client, google_connection) except (json.JSONDecodeError, Exception) as e: - logging.warning(f'[google_watch] Could not parse token for channel teardown: {e}') + logging.error(f'[google_watch] Could not parse token for channel teardown: {e}') calendars = ( db.query(models.Calendar) From 9e669f9250a55b5441ee98290b25ccafea14830e Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Wed, 1 Apr 2026 15:14:18 -0600 Subject: [PATCH 45/48] Make db query do the filtering instead of looping myself --- backend/src/appointment/routes/webhooks.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/backend/src/appointment/routes/webhooks.py b/backend/src/appointment/routes/webhooks.py index 71ed350b3..e7840723e 100644 --- a/backend/src/appointment/routes/webhooks.py +++ b/backend/src/appointment/routes/webhooks.py @@ -269,15 +269,14 @@ def _find_appointment_by_external_id( db: Session, calendar_id: int, external_id: str ) -> models.Appointment | None: """Find an appointment on the given calendar matching the Google event ID.""" - appointments = ( + return ( db.query(models.Appointment) - .filter(models.Appointment.calendar_id == calendar_id) - .all() + .filter( + models.Appointment.calendar_id == calendar_id, + models.Appointment.external_id == external_id, + ) + .first() ) - for appointment in appointments: - if appointment.external_id == external_id: - return appointment - return None def _handle_event_cancelled( From 24b59ddeaee4176352fe75f4a035513fd3d58372 Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Wed, 1 Apr 2026 15:14:39 -0600 Subject: [PATCH 46/48] Update tests --- backend/test/unit/test_commands.py | 8 ++++++ backend/test/unit/test_google_webhook.py | 32 ++++++++++++++++++------ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/backend/test/unit/test_commands.py b/backend/test/unit/test_commands.py index 6cc71c841..8254e8f2e 100644 --- a/backend/test/unit/test_commands.py +++ b/backend/test/unit/test_commands.py @@ -126,6 +126,7 @@ def test_creates_channel_for_schedule_calendar( assert channel.channel_id == 'channel-123' assert channel.resource_id == 'resource-456' assert channel.sync_token == 'sync-token-abc' + assert channel.state is not None def test_skips_calendar_with_existing_channel( self, with_db, make_google_calendar, make_schedule, make_external_connections @@ -146,6 +147,7 @@ def test_skips_calendar_with_existing_channel( channel_id='existing-channel', resource_id='existing-resource', expiration=datetime(2030, 1, 1, tzinfo=timezone.utc), + state='existing-state', sync_token='existing-sync', ) @@ -228,6 +230,7 @@ def _create_expiring_channel(self, with_db, calendar_id, hours_until_expiry=12): channel_id='old-channel-id', resource_id='old-resource-id', expiration=datetime.now(tz=timezone.utc) + timedelta(hours=hours_until_expiry), + state='old-state', sync_token='old-sync-token', ) @@ -264,6 +267,8 @@ def test_renews_expiring_channel( assert updated.resource_id == 'new-resource-id' expected = datetime.fromtimestamp(new_expiration_ms / 1000, tz=timezone.utc) assert updated.expiration.replace(tzinfo=None) == expected.replace(tzinfo=None) + assert updated.state is not None + assert updated.state != 'old-state' def test_skips_channel_not_yet_expiring( self, with_db, make_google_calendar, make_external_connections @@ -283,6 +288,7 @@ def test_skips_channel_not_yet_expiring( channel_id='healthy-channel', resource_id='healthy-resource', expiration=datetime.now(tz=timezone.utc) + timedelta(days=5), + state='healthy-state', sync_token='healthy-sync', ) @@ -389,3 +395,5 @@ def test_still_renews_if_stop_channel_fails( updated = repo.google_calendar_channel.get_by_calendar_id(db, cal.id) assert updated is not None assert updated.channel_id == 'new-channel-id' + assert updated.state is not None + assert updated.state != 'old-state' diff --git a/backend/test/unit/test_google_webhook.py b/backend/test/unit/test_google_webhook.py index 8324c60ab..fa4564a2c 100644 --- a/backend/test/unit/test_google_webhook.py +++ b/backend/test/unit/test_google_webhook.py @@ -353,6 +353,7 @@ def test_create_and_get_by_channel_id(self, with_db, make_google_calendar): channel_id='test-channel-abc', resource_id='test-resource-xyz', expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + state='test-state-abc', ) assert channel.id is not None @@ -370,6 +371,7 @@ def test_get_by_calendar_id(self, with_db, make_google_calendar): channel_id='channel-for-cal', resource_id='resource-for-cal', expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + state='state-for-cal', ) found = repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) @@ -386,6 +388,7 @@ def test_update_sync_token(self, with_db, make_google_calendar): channel_id='ch-sync-test', resource_id='res-sync-test', expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + state='state-sync-test', ) assert channel.sync_token is None @@ -405,6 +408,7 @@ def test_get_expiring(self, with_db, make_google_calendar, make_pro_subscriber): channel_id='expiring-soon', resource_id='res-1', expiration=now + timedelta(hours=12), + state='state-expiring', ) repo.google_calendar_channel.create( db, @@ -412,6 +416,7 @@ def test_get_expiring(self, with_db, make_google_calendar, make_pro_subscriber): channel_id='not-expiring', resource_id='res-2', expiration=now + timedelta(days=5), + state='state-not-expiring', ) threshold = now + timedelta(hours=24) @@ -429,6 +434,7 @@ def test_delete(self, with_db, make_google_calendar): channel_id='to-delete', resource_id='res-del', expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + state='state-del', ) repo.google_calendar_channel.delete(db, channel) @@ -446,6 +452,7 @@ def test_cascade_delete_with_calendar(self, with_db, make_google_calendar): channel_id='cascade-test', resource_id='res-cascade', expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + state='state-cascade', ) repo.calendar.delete(db, calendar.id) @@ -484,12 +491,14 @@ def test_creates_channel_for_google_calendar( with with_db() as db: db_cal = repo.calendar.get(db, calendar.id) - setup_watch_channel(db, mock_client, db_cal) + result = setup_watch_channel(db, mock_client, db_cal) + assert result is True channel = repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) assert channel is not None assert channel.channel_id == 'new-channel-id' assert channel.sync_token == 'initial-sync-token' + assert channel.state is not None def test_noop_if_channel_already_exists( self, with_db, make_google_calendar, make_external_connections, make_pro_subscriber @@ -515,14 +524,16 @@ def test_noop_if_channel_already_exists( channel_id='existing-channel', resource_id='existing-resource', expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + state='existing-state', ) mock_client = Mock() with with_db() as db: db_cal = repo.calendar.get(db, calendar.id) - setup_watch_channel(db, mock_client, db_cal) + result = setup_watch_channel(db, mock_client, db_cal) + assert result is True mock_client.watch_events.assert_not_called() def test_noop_for_caldav_calendar(self, with_db, make_caldav_calendar): @@ -531,8 +542,9 @@ def test_noop_for_caldav_calendar(self, with_db, make_caldav_calendar): with with_db() as db: db_cal = repo.calendar.get(db, calendar.id) - setup_watch_channel(db, mock_client, db_cal) + result = setup_watch_channel(db, mock_client, db_cal) + assert result is False mock_client.watch_events.assert_not_called() def test_noop_if_no_google_client(self, with_db, make_google_calendar): @@ -540,8 +552,9 @@ def test_noop_if_no_google_client(self, with_db, make_google_calendar): with with_db() as db: db_cal = repo.calendar.get(db, calendar.id) - setup_watch_channel(db, None, db_cal) + result = setup_watch_channel(db, None, db_cal) + assert result is False assert repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) is None @@ -570,6 +583,7 @@ def test_removes_existing_channel( channel_id='teardown-channel', resource_id='teardown-resource', expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + state='teardown-state', ) mock_client = Mock() @@ -577,8 +591,9 @@ def test_removes_existing_channel( with with_db() as db: db_cal = repo.calendar.get(db, calendar.id) - teardown_watch_channel(db, mock_client, db_cal) + result = teardown_watch_channel(db, mock_client, db_cal) + assert result is True mock_client.stop_channel.assert_called_once() assert repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) is None @@ -588,8 +603,9 @@ def test_noop_if_no_channel(self, with_db, make_google_calendar): with with_db() as db: db_cal = repo.calendar.get(db, calendar.id) - teardown_watch_channel(db, mock_client, db_cal) + result = teardown_watch_channel(db, mock_client, db_cal) + assert result is False mock_client.stop_channel.assert_not_called() def test_deletes_record_even_without_google_client(self, with_db, make_google_calendar): @@ -602,10 +618,12 @@ def test_deletes_record_even_without_google_client(self, with_db, make_google_ca channel_id='orphan-channel', resource_id='orphan-resource', expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + state='orphan-state', ) with with_db() as db: db_cal = repo.calendar.get(db, calendar.id) - teardown_watch_channel(db, None, db_cal) + result = teardown_watch_channel(db, None, db_cal) + assert result is True assert repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) is None From 1db272eff93a5ccd1d4a7f93e16ac35411716e1c Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Wed, 1 Apr 2026 15:23:56 -0600 Subject: [PATCH 47/48] Add renewal of google channels to Celery --- backend/src/appointment/celery_app.py | 7 +++++++ backend/src/appointment/tasks/__init__.py | 1 + backend/src/appointment/tasks/google.py | 14 ++++++++++++++ 3 files changed, 22 insertions(+) create mode 100644 backend/src/appointment/tasks/google.py diff --git a/backend/src/appointment/celery_app.py b/backend/src/appointment/celery_app.py index 6444da263..917bf8e31 100644 --- a/backend/src/appointment/celery_app.py +++ b/backend/src/appointment/celery_app.py @@ -40,6 +40,9 @@ def create_celery_app() -> Celery: sentry_sdk.set_extra('CELERY_RESULT_EXPIRES', result_expires) sentry_sdk.set_extra('CELERY_TASK_ALWAYS_EAGER', task_always_eager) + google_channel_ttl = float(os.getenv('GOOGLE_CHANNEL_TTL_IN_SECONDS', 604800)) + google_channel_renew_interval = google_channel_ttl - 86400 # 1 day buffer + app = Celery('appointment') app.config_from_object({ @@ -59,6 +62,10 @@ def create_celery_app() -> Celery: 'task': 'appointment.tasks.health.heartbeat', 'schedule': 60.0, }, + 'renew-google-channels': { + 'task': 'appointment.tasks.google.renew_google_channels', + 'schedule': google_channel_renew_interval, + }, }, 'beat_schedule_filename': 'celerybeat-appointment-schedule', }) diff --git a/backend/src/appointment/tasks/__init__.py b/backend/src/appointment/tasks/__init__.py index af4ae4da0..aeb62618f 100644 --- a/backend/src/appointment/tasks/__init__.py +++ b/backend/src/appointment/tasks/__init__.py @@ -1 +1,2 @@ from appointment.tasks.health import * # noqa: F401,F403 +from appointment.tasks.google import * # noqa: F401,F403 diff --git a/backend/src/appointment/tasks/google.py b/backend/src/appointment/tasks/google.py new file mode 100644 index 000000000..501040ffa --- /dev/null +++ b/backend/src/appointment/tasks/google.py @@ -0,0 +1,14 @@ +import logging + +from appointment.celery_app import celery + +log = logging.getLogger(__name__) + + +@celery.task +def renew_google_channels(): + from appointment.commands.renew_google_channels import run + + log.info('Starting Google Calendar channel renewal') + run() + log.info('Google Calendar channel renewal complete') From 29c348db228ee3e8c5b3107142d4a05d59f4ecfe Mon Sep 17 00:00:00 2001 From: Davi Nakano Date: Wed, 1 Apr 2026 15:32:25 -0600 Subject: [PATCH 48/48] (Re)fixing the schedule_calendar_ids query --- backend/src/appointment/commands/backfill_google_channels.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/src/appointment/commands/backfill_google_channels.py b/backend/src/appointment/commands/backfill_google_channels.py index 89d56bf35..42904d4f5 100644 --- a/backend/src/appointment/commands/backfill_google_channels.py +++ b/backend/src/appointment/commands/backfill_google_channels.py @@ -29,7 +29,10 @@ def run(): # Find connected Google calendars that are the default in a schedule # and don't yet have a watch channel - schedule_calendar_ids = db.query(models.Schedule).filter(models.Schedule.calendar_id is None).all() + schedule_calendar_ids = [ + s.calendar_id for s in + db.query(models.Schedule.calendar_id).filter(models.Schedule.calendar_id != None).all() # noqa: E711 + ] all_calendars = db.query(models.Calendar).filter( models.Calendar.provider == models.CalendarProvider.google,