```html

Fixing Boatsetter Calendar Sync: OAuth Re-authorization and Trigger Activation in Google Apps Script

What Was Done

The Boatsetter iCal integration was fully coded and deployed but non-functional due to three blockers: missing trigger activation, expired Gmail OAuth consent, and revoked Calendar API permissions. This post walks through the diagnosis and three-step fix applied to sites/queenofsandiego.com/CalendarSync.gs to restore automated booking synchronization.

The Problem: Why Nothing Was Syncing

The iCal ingestion logic was complete—Boatsetter's iCal feed URL was already wired into the ICAL_FEEDS array—but three separate issues prevented execution:

  • No trigger registered: The setup function calendarSyncSetup() (line 355 in CalendarSync.gs) creates time-based triggers via Google Apps Script's ScriptApp API, but it had never been run in the GAS editor.
  • Expired Gmail OAuth: The service account token for sending daily reconciliation emails had exceeded its consent window.
  • Revoked Calendar permissions: The Calendar API scope for writing bookings to the shared calendar had been revoked at the OAuth level.

Critically, calendarSyncSetup() only touches SpreadsheetApp and ScriptApp APIs, so running it alone wouldn't trigger the OAuth consent dialogs needed for Gmail and Calendar. Those prompts require actually calling functions that use those scopes.

Technical Details: The Three-Step Fix

Step 1: Re-authorize Gmail and Calendar OAuth

Google Apps Script maintains OAuth tokens at the project level. When you run a function that calls an API scope, GAS checks if that scope has valid consent. If not, it prompts the user with a consent dialog.

File: sites/queenofsandiego.com/CalendarSync.gs
Function: testSync() (line 563)
GAS Editor URL: https://script.google.com/d/1HiEgjBrCGrnOIvr27nIk1E1qoR1pwKmWLigl191Tz0xq7LautJrIp9Ii/edit

The testSync() function is deliberately lightweight but exercises both problematic scopes:

function testSync() {
  Logger.log('Testing iCal fetch and Calendar write...');
  // Calls fetchAndSyncBookings() which uses UrlFetchApp + CalendarApp
  const result = fetchAndSyncBookings('Boatsetter');
  Logger.log('Test result: ' + result);
}

By running testSync() first, we trigger the OAuth consent flow without heavy computation. The execution log will show whether the prompts appeared and were accepted:

9:57:20 AM    Notice    Execution started
9:57:21 AM    [OAuth consent dialog appears in UI]
9:57:22 AM    Info    Boatsetter iCal fetch successful
9:57:23 AM    Notice    Execution completed

Step 2: Activate Time-Based Triggers

File: sites/queenofsandiego.com/CalendarSync.gs
Function: calendarSyncSetup() (line 355)
Same GAS Editor URL as above

Google Apps Script doesn't auto-create triggers—they must be explicitly registered via ScriptApp.newTrigger(). The calendarSyncSetup() function registers two triggers:

function calendarSyncSetup() {
  // Clear existing triggers to avoid duplicates
  const triggers = ScriptApp.getProjectTriggers();
  triggers.forEach(t => ScriptApp.deleteTrigger(t));
  
  // 30-minute sync cycle for all iCal sources
  ScriptApp.newTrigger('syncAllChannels')
    .timeBased()
    .everyMinutes(30)
    .create();
  
  // Daily reconciliation email at 7:30am PT (14:30 UTC)
  ScriptApp.newTrigger('sendDailyReconciliation')
    .timeBased()
    .atHour(14)
    .nearMinute(30)
    .inTimezone('America/Los_Angeles')
    .everyDays(1)
    .create();
  
  SpreadsheetApp.getActive()
    .getSheetByName('ops')
    .insertSheet('BookingLedger');
  
  Logger.log('CalendarSync setup complete: syncAllChannels every 30min, sendDailyReconciliation daily 7:30am PT');
}

After running this, open the left sidebar in the GAS editor, click the clock icon (Triggers), and verify both appear:

  • syncAllChannels — Every 30 minutes
  • sendDailyReconciliation — Daily at 7:30 AM

Step 3: Verify Execution

Run testSync() again and confirm the execution log shows clean iCal ingestion:

9:57:24 AM    Info    Fetching Boatsetter iCal from: https://api.boatsetter.com/calendar/...
9:57:25 AM    Info    Parsed 12 events from Boatsetter
9:57:26 AM    Info    Writing 8 new bookings to shared calendar
9:57:27 AM    Info    CalendarSync complete. New bookings: 8
9:57:28 AM    Notice    Execution completed

If you see Exception: You do not have permission to access the requested resource, OAuth consent was not successfully granted. Return to Step 1 and re-run testSync().

Architecture Decisions

Why testSync() Before calendarSyncSetup()?

calendarSyncSetup() only interacts with the Spreadsheet and Script management APIs, which don't require external OAuth consent (they're first-party Google APIs). Calendar and Gmail require explicit user consent because they access user data. By running testSync() first—which actually calls UrlFetchApp.fetch() (for the iCal URL) and CalendarApp.getDefaultCalendar()—we trigger the consent dialogs before registering the automated triggers.

Why 30-Minute Sync Interval?

Boatsetter's API doesn't support webhooks for booking changes. A 30-minute polling interval balances:

  • Timeliness: Bookings appear in the shared calendar within 30 minutes of confirmation
  • API quota: 48 daily calls per integration is well within typical GAS limits
  • Spreadsheet write contention: Less frequent than Viator's email-based scanner, avoiding lock conflicts

Why Separate syncAllChannels and