# CLAUDE.md — OTA Calendar Sync ("Serene Stay Sync")

## What this project is

A web-based channel/calendar sync application for vacation rental properties (villas,
apartments) in Indonesia. It synchronizes availability calendars between OTAs — **Airbnb**,
**Traveloka**, and **Booking.com** — using iCal feeds, to prevent double bookings.

- **Stack:** Laravel 12 (PHP 8.3+), PostgreSQL 16, running locally on Laragon (Windows,
  project root `F:\htdocs\otasc`)
- **Packages:** `sabre/vobject` (parse iCal), `spatie/icalendar-generator` (generate iCal)
- **Queue:** database driver locally; scheduler runs via `php artisan schedule:work`

## UI source of truth: Stitch design exports

The UI was designed in Google Stitch. **All screens are exported as static HTML/CSS in
`design/stitch/` — treat these files as the authoritative design reference** for layout,
colors, typography, spacing, and component structure when building any view.

Also read `design/stitch/product_requirements_document_calendar_sync_tool.txt` — the
PRD exported from Stitch — before making product/UX decisions.

### Screen map (folder → purpose → implementation target)

| Folder | Purpose | Implements |
|---|---|---|
| `serene_hospitality` | Landing / brand page, overall look & feel | Public landing page, shared design tokens |
| `multi_calendar_dashboard` | Main screen: multi-property calendar, events color-coded by channel | Primary dashboard Blade view (`/dashboard`) reading `calendar_events` |
| `mobile_dashboard` | Mobile layout of the dashboard | Responsive breakpoints of the same dashboard view |
| `connected_channels` | List of channel connections per property with status | Connections index (reads `channel_connections`, shows `last_synced_at`, `is_active`) |
| `add_new_ical_modal` | Modal to add a channel: paste OTA import URL, show our export URL | Create-connection flow (`import_url` input; display `exportUrl()` with copy button) |
| `channel_connection_success` | Confirmation state after adding a channel | Success state of the create-connection flow |
| `sync_activity_logs` | Sync history / activity feed | Log viewer reading `sync_logs` (result, counts, duration, timestamps) |
| *(none — see Known gaps)* | Property management | Properties CRUD (`/properties`) — no Stitch export exists for this; built following the visual language of `connected_channels`/`add_new_ical_modal` instead |

### Rules for using the design exports

- When building or changing a view, **first read the matching folder's HTML/CSS**, then
  implement it as Blade templates. Extract shared styles (colors, fonts, buttons) into a
  common layout/CSS once, on first use — don't duplicate per screen.
- Keep the exported files untouched; they are reference, not served assets.
  Implementation lives in `resources/views/` (+ `public/`/Vite as appropriate).
- If a needed screen/state doesn't exist in the exports, follow the visual language of
  the existing screens and note the gap at the end of this file.
- The `stitch` MCP server is registered in Claude Code but **currently broken upstream**
  (Google schema `$defs` bug — tools fail to load). Prefer these files. Retry the MCP
  occasionally after `claude update`; if it connects and lists tools, it may supersede
  the static exports for fetching fresh designs.

## How the sync works (core design — do not change without discussion)

Two-way sync via iCal:

1. **Import (pull):** Each OTA listing exposes an export `.ics` URL. We poll it every
   1–2 minutes per connection (`channel_connections.sync_interval_minutes`).
2. **Export (they pull us):** We serve a consolidated feed per connection at
   `GET /ical/{export_token}.ics` containing all confirmed events for the property
   **excluding** events that originated from that same channel.
3. **Realtime limits:** iCal is pull-based on both sides. Inbound latency is minimized
   with fast conditional polling; outbound latency depends on the OTA's pull frequency.
   Phase 2 adds inbound email parsing for near-instant inbound detection.

### Sync pipeline detail

- Scheduler ticks `calendar:sync-due` **every minute**; it dispatches a queued
  `SyncChannelConnection` job for each connection whose interval elapsed.
- Jobs are unique per connection (`ShouldBeUnique`) — never stack duplicates.
- Fetch uses `If-None-Match` / `If-Modified-Since`; a 304 or identical SHA-256 body hash
  short-circuits before parsing. Every run writes a `sync_logs` row
  (`changed | not_modified | unchanged_hash | error`).
- **Cancellation = absence:** a previously confirmed UID missing from the feed is marked
  `status='cancelled'` (rows are never deleted; history is kept).
- On fetch error, `last_synced_at` is still updated so a broken feed doesn't hot-loop.

## Data model

- `properties` — id, name, timezone (default `Asia/Makassar`)
- `channel_connections` — property_id, channel (`airbnb`|`traveloka`|`booking_com`), label,
  import_url, export_token (unique, 48 chars, auto-generated on create),
  inbound_token (unique, 32 chars, auto-generated on create — local-part of the
  address hosts forward OTA booking-confirmation emails to), sync_interval_minutes,
  is_active, last_etag, last_modified_header, last_hash, last_synced_at, last_changed_at
- `calendar_events` — channel_connection_id, uid (unique per connection), summary,
  starts_on, ends_on, status (`confirmed`|`cancelled`|`provisional`),
  source (`ical`|`email`|`manual`), raw (JSONB of original VEVENT — also holds
  `description`, `reservation_url`, `phone_last4` extracted from Airbnb's DESCRIPTION field,
  and — once a matching forwarded email is reconciled (Airbnb only, see Known gaps) —
  `guest_name`, `guests`, `total_paid`, `host_payout`), first_seen_at, last_seen_at, cancelled_at
- `sync_logs` — channel_connection_id, result, message, events_upserted,
  events_cancelled, duration_ms, created_at
- `inbound_emails` — channel_connection_id (nullable — null means the recipient token didn't
  match any connection), to_address, from_address, subject, body_text, body_html, headers (JSONB),
  parsed_data (JSONB, nullable — set once `AirbnbEmailParser` recognizes the template),
  calendar_event_id (nullable FK, set once `BookingEmailReconciler` matches it), created_at.

## Critical conventions (bugs happen when these are violated)

1. **`ends_on` is the checkout date and is EXCLUSIVE**, matching iCal `DTEND`.
   Occupied nights = `starts_on` … `ends_on - 1`. Keep this everywhere: DB, UI, exports.
   In calendar UIs, the checkout day renders as free/turnover, not occupied.
2. All dates are property-local dates (`date` columns), derived using
   `properties.timezone`. Timestamps are `timestamptz`.
3. Store the raw VEVENT in `calendar_events.raw` (JSONB) — needed for debugging.
4. Never hard-delete calendar events; cancel them.
5. Export tokens are secrets. Show them only in the add-channel modal / connection
   detail (per the `add_new_ical_modal` design); never log them.

## Key files

- `app/Jobs/SyncChannelConnection.php` — fetch + diff + upsert + cancel + log
- `app/Services/IcalParser.php` — sabre/vobject parsing, DATE vs DATETIME, tz handling
- `app/Console/Commands/SyncDueConnections.php` — `calendar:sync-due {--force}`
- `app/Http/Controllers/ChannelConnectionController.php` — add-channel flow (`/channels`)
- `app/Models/ChannelConnection.php` — `isDueForSync()`
- `routes/console.php` — scheduler tick; `routes/web.php` — `/channels*` routes
- `app/Http/Controllers/IcalExportController.php` — **not yet built** (see Known gaps: export side)
- `design/stitch/` — Stitch UI exports (see screen map above)

## Running locally (Laragon / Windows)

```
php artisan schedule:work        # terminal 1 — scheduler tick
php artisan queue:work --tries=1 # terminal 2 — job worker
# app served by Laragon vhost or php artisan serve
php artisan calendar:sync-due --force   # manual full sync
```

`.env`: `DB_CONNECTION=pgsql`, `QUEUE_CONNECTION=database`.

## Testing guidance

- Feature tests with `Http::fake()` returning fixture `.ics` bodies; assert events
  upserted/cancelled and `sync_logs` results. Fixtures in `tests/Fixtures/`.
- Cover: all-day DATE events, DATETIME with TZID, missing DTEND, cancelled-by-absence,
  304 responses, identical-hash short-circuit.
- NEVER point a live OTA listing's import at a locally generated feed until its output
  has been manually verified — bad feeds block/unblock real dates.

## Roadmap

- [~] **Phase 1:** iCal import (fast polling) done; export feed still missing (see Known gaps);
      sync logs done
- [~] **Phase 1.5 (current):** Build the UI from Stitch exports —
      `multi_calendar_dashboard` done (as a 14-day timeline, see Known gaps), `connected_channels` +
      `add_new_ical_modal` + `channel_connection_success` done (as real pages, see Known gaps),
      then `sync_activity_logs`, then mobile responsiveness (`mobile_dashboard`)
- [~] **Phase 2 (current):** Inbound email capture done (Mailgun webhook, `inbound_emails`, one
      address per connection); Airbnb parsing + reconciliation into `calendar_events.raw` done
      (guest_name/guests/total_paid/host_payout, matched by confirmation code — see Known gaps);
      Traveloka parsing not started; `provisional` events (creating a booking from the email before
      the iCal poll confirms it, for latency) not started — current design enriches an
      already-iCal-confirmed event instead
- [ ] **Phase 3:** Conflict detection + alerts, live updates via Laravel Reverb
- [ ] **Phase 4:** `bookings` table with PostgreSQL `daterange` + GiST exclusion
      constraint as the hard double-booking guard
- [ ] **Later:** more OTAs (Agoda, tiket.com — Booking.com done, see Known gaps), multi-user/teams,
      official API partnerships (Airbnb API, Traveloka TERA, Booking.com Connectivity Partner API)

## Working agreements for AI assistants

- Follow the conventions above; ask before changing sync semantics or schema.
- UI work always starts from the `design/stitch/` exports and the PRD txt.
- New OTA channels are added as connections, not new code paths, unless a channel needs
  feed-specific parsing quirks (put those in `IcalParser` or a dedicated parser class).
- Keep controllers thin; sync logic lives in jobs/services.
- When a durable decision is made (convention, schema, UI pattern), update this file
  in the same task.

## Known gaps / notes

- `design/stitch/product_requirements_document_calendar_sync_tool.txt` (referenced above) is not
  present in the repo. The dashboard was built from `serene_hospitality/DESIGN.md` (design tokens)
  and `multi_calendar_dashboard/code.html` only. Re-check against the PRD once it's added.
- **Property CRUD** (`/properties`, `PropertyController`, `resources/views/properties/`): standard
  create/edit/delete, timezone validated against PHP's real IANA identifier list (Laravel's built-in
  `timezone` validation rule) rather than a hand-maintained list. No Stitch export for this screen
  (see screen map) — built matching `connected_channels`'/`add_new_ical_modal`'s card/form style.
  - **Deleting a property cascades hard-deletes** its `channel_connections`, and transitively their
    `calendar_events` and `sync_logs` (`cascadeOnDelete()` in the original migrations) — a real
    conflict with convention #4 ("never hard-delete calendar events; cancel them"), but that
    convention is about the *sync pipeline* never doing this via cancellation-by-absence, not an
    absolute ban on a deliberate "remove this property entirely" action. Mitigated with a
    browser-`confirm()` warning stating exactly what gets deleted, not a backend block — revisit if
    this app grows real users who'd benefit from a soft-delete/archive instead.
- The Phase 1 schema (`properties`, `channel_connections`, `calendar_events`, `sync_logs`) did not
  exist in the repo yet, despite the roadmap marking Phase 1 done — migrations and models were
  added incrementally (first three for the dashboard, `sync_logs` alongside the import feature),
  matching the "Data model" section above exactly.
- **Import pipeline is implemented**: `app/Services/IcalParser.php` (sabre/vobject; handles
  all-day `DATE` vs `DATE-TIME`+TZID, missing `DTEND`/`DURATION`, explicit `STATUS:CANCELLED`),
  `app/Jobs/SyncChannelConnection.php` (conditional `If-None-Match`/`If-Modified-Since`, SHA-256
  hash short-circuit, diff/upsert, cancel-by-absence, always logs to `sync_logs`), and
  `calendar:sync-due {--force}` (`app/Console/Commands/SyncDueConnections.php`, scheduled
  every-minute in `routes/console.php`). Add-channel UI is `/channels` (index), `/channels/create`
  (form), both under `ChannelConnectionController` — built as real pages rather than the mockups'
  JS modal-over-blurred-background treatment (no client-side modal state needed for a real
  multi-page app); the "success" state is a session-flashed overlay on the index page instead of
  a separate route. Covered by `tests/Feature/SyncChannelConnectionTest.php` (fixtures in
  `tests/Fixtures/*.ics`).
  - **Export side is NOT built.** `ChannelConnection::exportUrl()` and `IcalExportController`
    (`GET /ical/{export_token}.ics`) don't exist yet, so the connected_channels/add-modal mockups'
    "here's our export URL, copy it into the OTA" affordance was deliberately left out rather than
    shown with a dead link. Build this as its own task.
  - `ChannelConnection::isDueForSync()` is a plain PHP/Carbon comparison, not a query scope with
    DB-side interval math — this repo's test suite runs against SQLite (`phpunit.xml`) while local/
    prod is Postgres-only, and Postgres-specific raw SQL (e.g. `interval '1 minute'`) silently
    breaks under SQLite. Keep any future "due" filtering logic in PHP for the same reason, or add
    a real second test-DB setup first.
  - Gotcha for future jobs: don't name a job's promoted constructor property `$connection` —
    `Illuminate\Bus\Queueable` (pulled in via `Illuminate\Foundation\Queue\Queueable`) already
    declares a `$connection` property (the queue connection name) and PHP's property redeclaration
    silently wins in a way that breaks the job at runtime. `SyncChannelConnection` uses
    `$channelConnection`.
  - This machine's PHP (Laragon 8.3) has `pdo_sqlite`/`sqlite3` commented out in `php.ini`, so
    `php artisan test` fails with "could not find driver" out of the box — run with
    `php -d extension=pdo_sqlite -d extension=sqlite3 vendor/bin/phpunit` (or uncomment those two
    lines in `php.ini`) until that's fixed system-wide.
  - **Airbnb blocks requests without a browser-like `User-Agent`** — a bare Guzzle UA gets a `429`
    with an HTML "Service Unavailable" body instead of the feed. `SyncChannelConnection` always
    sends a Chrome UA + `Accept: text/calendar` on every fetch (not just Airbnb's) for this reason;
    don't strip that header. Confirmed against a real Airbnb listing's iCal URL, not just guessed.
  - Airbnb's iCal `SUMMARY` is always the literal string `"Reserved"` — never the guest's name.
    `DESCRIPTION` (when present) has a `Reservation URL:` and `Phone Number (Last 4 Digits):` line;
    `IcalParser` regex-extracts both into `raw.reservation_url` / `raw.phone_last4` (also exposed via
    `CalendarEvent::reservationUrl()` / `guestPhoneLast4()`), and the dashboard booking pill links out
    to the reservation URL when present. This is the most guest-identifying detail Airbnb's calendar
    feed exposes; full guest name/price require the email pipeline below.
- **Booking.com added as a third channel** (`channel = 'booking_com'`, color `#003580` in
  `ChannelConnection::channelColor()` / `--color-channel-booking-com`) — needed **zero** new parsing
  code, confirming the "new OTAs are connections, not new code paths" principle: verified against a
  real Booking.com export URL, standard spec-compliant iCal (all-day `DATE` events,
  `SUMMARY:CLOSED - Not available` — same no-guest-name privacy pattern as Airbnb), no Airbnb-style
  User-Agent blocking. No `DESCRIPTION` field seen in the one real sample checked, so no
  reservation_url/phone_last4 equivalent extracted for it yet — revisit `IcalParser` if a real feed
  turns up one. No email parser for Booking.com confirmation emails yet (`AirbnbEmailParser` only);
  its `inbound_emails` still get captured, just not parsed into guest details.
- **Inbound email capture (Phase 2, partial)**: hosts can forward OTA booking-confirmation emails to
  a per-connection address (`ChannelConnection::inboundAddress()` = `{inbound_token}@` +
  `MAILGUN_INBOUND_DOMAIN`, shown on each card in `/channels`) via a Mailgun Route configured to
  forward to `POST /webhooks/mailgun/inbound` (`MailgunInboundWebhookController`, exempted from CSRF
  in `bootstrap/app.php`). The webhook HMAC-verifies Mailgun's `timestamp`+`token` against
  `MAILGUN_WEBHOOK_SIGNING_KEY` (also rejects timestamps >15 min old, to block replay) before storing
  the email in `inbound_emails`, matched to a connection by the recipient's local-part. Required env:
  `MAILGUN_INBOUND_DOMAIN` (a dedicated subdomain — must NOT be the bare domain if it's also on
  Google Workspace/any other MX, or you'll break real mail delivery there) and
  `MAILGUN_WEBHOOK_SIGNING_KEY` (Mailgun dashboard → Settings → Webhooks, different from the API key).
  Until `MAILGUN_INBOUND_DOMAIN` is set, `inboundAddress()` returns null and the UI hides the
  forwarding-address field entirely; both vars are set in this dev's local `.env` (subdomain
  `sync.hexadigital.id`, routed through a Cloudflare Tunnel at `app.hexadigital.id` → the Laragon
  Nginx vhost `otasc.test:85`, since Mailgun needs a real public URL to POST to — see
  `~/.cloudflared/config.yml`, `httpHostHeader: otasc.test` override needed since Nginx's
  `server_name` doesn't know the tunnel's public hostname). Verified against a real forwarded
  Airbnb confirmation email end-to-end, not just guessed.
  - **Parsing is implemented, Airbnb only**: `app/Services/AirbnbEmailParser.php` handles exactly one
    verified template — Airbnb's "Reservation confirmed" host-notification email (subject contains
    that phrase) — extracting guest_name, checkin/checkout date+time, guest count, confirmation
    code, total paid, and host payout via regex against the labeled plain-text body. Returns null
    (doesn't guess) for any other subject shape; Airbnb has other templates (request-to-book,
    reminders, cancellations) that are unverified and will currently be skipped entirely. Traveloka
    has no parser yet — `ProcessInboundEmail` skips any non-`airbnb` channel connection.
  - **Reconciliation**: `app/Services/BookingEmailReconciler.php` matches a parsed email to its
    `calendar_event` by confirmation code — the same code appears as the last path segment of both
    the email's "Confirmation code" line and the iCal-derived `raw.reservation_url` — then merges
    `guest_name`/`guests`/`total_paid`/`host_payout` into that event's `raw` (exposed via
    `CalendarEvent::guestName()` etc; the dashboard pill shows the real name once matched). This is
    far more reliable than matching by overlapping date ranges. If the email arrives before the
    iCal poll creates the event, `reconcile()` just returns false; `SyncChannelConnection` calls
    `reconcilePending()` after every successful sync to retry any still-unmatched parsed emails for
    that connection.
  - **Bug caught building this**: `SyncChannelConnection::applyDiff()` originally overwrote an
    existing `calendar_events.raw` wholesale from each iCal re-sync, which would have silently
    wiped out email-sourced enrichment on the next poll. Fixed to `array_merge($existing->raw, ...)`
    — any future code touching `calendar_events.raw` must merge, never replace, for the same reason.
  - `ProcessInboundEmail::dispatchSync()` runs synchronously from the webhook (parse + reconcile in
    one request, consistent with `SyncChannelConnection`'s initial-sync pattern) — fine at this
    volume; revisit if inbound email traffic ever grows enough to matter for webhook latency.
  - Real sample captured 2026-07-31 saved as `tests/Fixtures/airbnb_confirmation_email.txt` — the
    time fields use a narrow no-break space (U+202F) before AM/PM (Gmail's rendering), not a regular
    space; `AirbnbEmailParserTest` asserts the literal byte content for exactly this reason.
  - Disclaimer copy on `/channels` and `/channels/create` still describes email enrichment as
    "planned" — now inaccurate for Airbnb connections specifically. Update once this ships to users
    (currently verified in dev only); keep it honest per-channel if Traveloka parsing lags behind.
  - PII/compliance note (raised in conversation, not yet acted on): now that real guest name/phone/
    price actually flows through `calendar_events.raw`, this app is a data processor of third-party
    (guest) PII under GDPR and Indonesia's UU PDP. Get real legal review before/while commercializing
    as a multi-tenant SaaS — don't treat this bullet as that review.
- The dashboard (`/` and `/dashboard`) now follows `multi_calendar_dashboard/code.html` directly:
  one shared 14-day date header, a row per property, booking pills laid out with CSS Grid
  (`grid-column: start / span nights`, computed in `DashboardController::layoutLanes()`), and a
  functional property selector + date-range pager (`?property_id=`, `?start=`).
  - **Guest/bed-count subtitle** in the mockup isn't backed by any field in our schema — replaced
    with the property's connected channel labels instead of inventing occupancy data.
  - **"Projected Rev" stat** is replaced with **"Confirmed Bookings"** (a count) — there's no
    pricing/rate data anywhere in the schema, so a revenue figure would be fabricated.
  - **Conflict / "Double Booking" highlighting** is implemented as a pure view-layer computation
    (`hasConflict` = a property needs more than one lane to fit its non-cancelled events without
    overlapping) — it is **not** the persisted "Conflict detection + alerts" from Phase 3 of the
    roadmap. There's no conflict table, no alerting, and the "Resolve" button from the mockup was
    intentionally not wired up (no defined resolution workflow yet).
  - Sidebar-collapse and booking-pill hover interactions were ported from the export's vanilla JS.
- Traveloka's dashboard color (`#00AA6C`, in `ChannelConnection::channelColor()` and the
  `--color-channel-traveloka` token in `resources/css/app.css`) is a placeholder — the Stitch
  exports only show Airbnb/Booking.com/VRBO colors, not Traveloka. Swap in the real brand color
  if/when Traveloka is added to a Stitch screen.
- Local asset build: this machine's Laragon-bundled Node is v18, but `package.json` pins
  `vite@^8`/`@tailwindcss/vite@^4`, which require Node 20.19+/22.12+. A fresh `npm install` on
  Node 18 also hit a known npm optional-dependency bug (rolldown's platform-specific native
  binding silently fails to install — https://github.com/npm/cli/issues/4828). To build the
  assets for this task, a portable Node 20.18 was used to reinstall `node_modules` and run
  `npm run build`. Install Node 20.19+ (or 22.12+) system-wide so `npm run dev`/`build` work
  without that workaround.
