Skip to content

ADR 006: ICS Two-Way Sync via Polling Worker

Date: 2026-03-22 Status: Accepted

Note: Retrospective ADR written 2026-04-26. The pattern landed across plans 013 (ICS export, 2026-02-20), 014 (negative export, 2026-02-26), 015 (ICS import, 2026-02-28 → 2026-03-11), and 017 (two-way sync, 2026-03-22 / commit cf0b9d1). The integration approach was chosen up-front in docs/research/calendars/ics-integration-research.md (2026-02-19); this ADR consolidates the rationale for the worker-based runtime.

Context

Partners list on multiple platforms (Airbnb, Booking.com, GetYourGuide) and manage personal commitments in Google Calendar / Outlook. A booking on PO that doesn't reflect in the partner's external calendars — and an external commitment that doesn't block PO availability — produces double-bookings.

Three approaches were on the table (research doc §3):

Approach Direction Mechanism Real-time?
ICS Export (read-only feed) PO → external Static .ics URL No (consumer polls)
ICS Import (one-shot bootstrap) external → PO Manual upload / fetch One-time
ICS Two-Way Polling both Background worker polls external feeds No (~minutes)
Direct API (OAuth) both Google Calendar API + webhooks Yes

Direct API integration via OAuth (the Calendly approach) gives the best UX but adds OAuth flows, per-provider client registrations, refresh-token storage, webhook receivers, and per-provider quirks (Microsoft Graph vs Google Calendar vs Apple). The platform's partner persona — small-to-medium Portuguese tourism businesses, many already comfortable with Airbnb/Booking ICS feeds — does not yet justify that effort.

Decision

Adopt ICS over polling as the cross-platform sync mechanism, in three phases that compose:

  1. Phase 1 — Negative ICS Export (plans 013, 014). PO publishes a per-service / per-template tokenized ICS feed at a stable URL. The "negative" strategy emits OPAQUE VEVENT blocks for unavailable time so external calendars treat PO availability as busy time. Tokens are revocable UUIDs; results are cached per (token_id, range:strategy) with invalidation hooks on template / binding / exception changes.
  2. Phase 2 — ICS Import (plan 015). Partners upload .ics files or provide a remote URL. The parser handles RFC 5545 line unfolding, RRULE decomposition, EXDATE, TZID extraction, and TRANSP→AVAILABLE/UNAVAILABLE mapping. Import is preview-then-confirm so partners cherry-pick rules.
  3. Phase 3 — Two-Way Sync (plan 017). A SyncWorkerService (@nestjs/schedule cron, every minute) polls registered external connections, diffs events against the synced_external_event table by UID + content hash, and creates / updates / deletes partner-scoped UNAVAILABLE exceptions accordingly.

Architecture for Phase 3

SyncWorkerService (@Cron EVERY_MINUTE)
   ↓ raw ICS text (HTTP GET with ETag/If-Modified-Since)
SyncDiffService (pure logic, no I/O)
   ↓ { toCreate, toUpdate, toDelete }
ExceptionsService + SyncService (DB writes)

Key choices within the worker

  • UID-based diff: every VEVENT carries a unique UID (RFC 5545). New events have a UID we don't track; updates have a matching UID with a different content-hash; deletions have a UID we track but the feed dropped.
  • Content hash: SHA-256 prefix of DTSTART + DTEND + SUMMARY + isAllDay. Detects meaningful changes; ignores DESCRIPTION cosmetics.
  • Conditional GET: ETag / If-Modified-Since on every poll. Most polls return 304 Not Modified, saving bandwidth and CPU at the every-minute cadence.
  • Sync window: today → today + 90 days. Historical events ignored; far-future events deferred to subsequent polls.
  • Concurrency guard: in-memory Set<connectionId> prevents overlapping polls on slow feeds.
  • Auto-deactivation: 5 consecutive failures → connection deactivated. Re-activation is an explicit API call.
  • TRANSPARENT events filtered out per RFC 5545 (TRANSP:TRANSPARENT means "this doesn't block time").
  • Partner-level scope (not per-service): one Google Calendar connection blocks across all the partner's services via the existing layered availability model from ADR-001.
  • Cascade deletes: removing a connection cascades to synced_external_event and to its calendar_exception rows via FK CASCADE.

Why ICS over channel-manager APIs

The research doc surveyed Airbnb / Booking.com / GetYourGuide. Channel managers (Bókun, Regiondo, Palisis) provide richer real-time sync but require commercial agreements and per-channel onboarding the platform isn't ready for. ICS is the universal lowest-common-denominator: every relevant external platform exports an ICS feed; every relevant calendar app subscribes to one. The trade is freshness (minutes-to-hours latency) for breadth (works with everything from day one).

Consequences

Positive

  • Universal compatibility: any partner whose external system can export .ics (Airbnb, Booking, Google Calendar, Outlook, Apple, Calendly, etc.) is a viable connection.
  • No OAuth coordination: no per-provider client registrations, no refresh-token storage, no webhook receivers.
  • Pure-logic diff: SyncDiffService is I/O-free, fully unit-testable.
  • Bounded operational cost: ETag caching keeps the polling-every-minute cadence cheap; auto-deactivation prevents dead-feed amplification.
  • Composable phases: export-only worked alone (plan 013-014); import-only worked alone (plan 015); two-way (017) layered on without rewriting the engine.

Negative

  • Latency floor of one minute at best, more in practice (Google Calendar's ICS endpoint is itself stale by minutes-to-hours). Not suitable for high-frequency booking flows.
  • One-occurrence-only for recurring events. Full RRULE expansion was deferred — currently only the first occurrence of a recurring VEVENT becomes an exception. Partners with truly recurring blocked time need to confirm coverage manually.
  • Polling at minute cadence is wasteful when most polls return 304. Mitigated by ETag, but still real load on the calendar-service at scale (per-connection cron).
  • No real-time push: if Google or Microsoft adds CalDAV-push or webhook subscriptions later, the architecture allows a webhook receiver alongside the poller — but that's net-new code.
  • Token revocation responsibility falls on partners: a leaked feed-token URL exposes availability data until the partner regenerates it. Tokens are unauth'd-by-design (so any calendar app can subscribe).

References

  • services/calendar-service/src/modules/sync/SyncWorkerService, SyncDiffService, SyncService
  • services/calendar-service/src/modules/ics/ — RFC 5545 parser/generator
  • docs/research/calendars/ics-integration-research.md — full landscape study (2026-02-19)
  • docs/implementation-plans/017-two-way-ics-sync/human/explanation.md — design decisions for Phase 3 specifically
  • ADR-001 — the layered availability model that exception writes feed into