Skip to main content

Acceptance Spec — Realtor Calendar Write / "Book the Showing" (MVP)

  • Status: Proposed (MVP) — 2026-07-03
  • Product: WiseAI Realtor System → AI front office (Aria) + /realtor/app/calendar
  • Owner surface: /realtor/app/settings (connect) + /realtor/app/calendar (CalendarTasksBody, the calendarConnected seam) + Aria's booking tool
  • Priority: NOW #1 (highest capture→book leverage on the integrations roadmap — see AUDIT_realtor_integrations_strategy_2026-07-03.md §5)
  • Related: ai-front-desk, realtor-sms-messaging-mvp, realtor-webhook-emitter-mvp

⚠️ Booking step revised (2026-07-03): §5's "book a firm event immediately + invite the buyer" is superseded by realtor-showing-scheduling-mvp.md — a showing is two-sided (listing side must agree) and one slot inside a capacity window, not a firm unilateral booking. The connect + free/busy + event-write plumbing in this spec is correct and reused; only the booking semantics change to held→confirm. Gate the shipped co-pilot "Confirm" behavior before demoing to Team Beckett.

1. Why (problem)

Today Aria can capture a showing but cannot book one. When a lead asks to see a property, Aria creates a re_tasks row of type='showing' (a request) and hands off — the agent then manually enters it into their real calendar. The internal /realtor/app/calendar view already renders tasks/deals/anniversaries and is plumbed with a calendarConnected prop, hardcoded false (verified in CalendarTasksBody). This MVP wires that seam: connect Google Calendar / Microsoft Outlook so a confirmed showing becomes a real calendar event with the lead invited. This is the literal "book the showing" moment — the single change that most turns Aria from a message-taker into a front desk.

2. Scope

In: per-agent OAuth connect to Google Calendar (Google Calendar API) and Microsoft Outlook (Microsoft Graph Calendar); read free/busy to avoid double-booking; create / reschedule / cancel a calendar event for a confirmed showing, with the lead as an invitee; store the external event id back on the re_tasks row; flip the real calendarConnected state in the UI. Disconnect (token revoke).

Out (explicit non-goals): full two-way sync of all the agent's events into our app; recurring events; providers beyond Google/Microsoft; Apple/iCloud; Calendly (a later, on-request fallback — native calendar supersedes it); Aria booking a slot without a human/lead confirming (bridge rule §6); editing events created outside our app.

3. Data shape

New per-tenant/per-agent connection table (re_* convention, RE spine only):

re_calendar_connections
id uuid PK
business_id uuid NOT NULL FK
agent_id uuid NOT NULL FK → local_business_agents(id) -- per agent, not per tenant
provider text NOT NULL -- 'google' | 'microsoft'
calendar_id text NOT NULL -- target calendar ('primary' default)
access_token_enc bytea NOT NULL -- ENCRYPTED at rest (never plaintext) — see §4
refresh_token_enc bytea NOT NULL -- ENCRYPTED at rest
scopes text[] NOT NULL -- least-privilege (calendar.events + freebusy)
token_expires_at timestamptz
status text NOT NULL DEFAULT 'active' -- 'active' | 'revoked' | 'error'
connected_at timestamptz NOT NULL DEFAULT now()
last_synced_at timestamptz
UNIQUE (agent_id, provider)

The booked event is linked back on the existing task (no new column): re_tasks.metadata.calendar = { provider, event_id, html_link, invitee_email } for a type='showing' task. Cancel/reschedule reads that pointer.

4. Security & credential model (load-bearing)

  • Tokens are encrypted at rest*_enc columns hold envelope-encrypted values (app-layer encryption or Supabase Vault), never a readable OAuth token in a plain column. Follow knowledge/runbooks/key-rotation-procedure.md for the encryption key surface. A DB read of the row must not expose a usable token.
  • Least-privilege scopes: Google calendar.events + calendar.freebusy only (not full calendar); Microsoft Graph Calendars.ReadWrite for the signed-in agent only. No org-wide scopes.
  • OAuth callback is a dedicated route (/api/realtor/calendar/oauth/callback), state-parameter CSRF-protected, binds the returned token to the authenticated realtor identity + account (never trust an account_id from the OAuth state alone; resolve it from the session).
  • Disconnect revokes the provider token AND deletes the stored tokens (status → revoked); a revoked/expired token fails closed (Aria falls back to capture-only, never silently drops the showing).
  • Per memory feedback_realtor_client_calls_need_account_id_query: every authed /realtor/app/* client fetch to these /api/realtor/* routes MUST send ?account_id= in the query (middleware strips the header) or it 401s.
  • Per memory feedback_per_account_config_writes_must_upsert: writing the connection/config row UPSERTs on the natural key (agent_id, provider), never .update().eq() — a freshly-provisioned agent may lack the row and a 0-row UPDATE silently no-ops while reporting success.

5. Booking flow (states)

  1. Connect: agent clicks Connect Google/Outlook in /realtor/app/settings → OAuth consent → callback stores the encrypted tokens → the Integrations hub card and the /realtor/app/calendar calendarConnected state flip to connected only after a real free/busy probe call succeeds (honest-status-hub rule §7).
  2. Aria proposes a slot: for a type='showing' task with a confirmed date/time, Aria (autopilot) or the agent (co-pilot) triggers Book. We read free/busy; if the slot is busy, Aria offers the nearest free alternatives instead of double-booking.
  3. Create event: create a calendar event on the agent's calendar_id with: title Showing — <address>, the lead as an invitee (their email, if consented), the property address, and a description stating "Arranged by Aria (AI) — will confirm." Save {provider,event_id,html_link} to re_tasks.metadata.calendar; set task status='scheduled'.
  4. Reschedule / cancel: editing or cancelling the showing task mirrors to the external event via the stored event_id; a cancel removes the event and notifies the invitee.
  5. Error / expired token: surface a reconnect prompt; the showing stays captured as a task (never lost).

6. AI-Bridge guardrail (bridge-safe by design)

Aria books a real human's calendar; she never runs the showing and never finalizes without a human in the loop. The event description always discloses it was AI-arranged and that the licensed agent will confirm. Aria may propose and hold a slot; the confirmation to the lead is a human action (or an explicit agent-approved autopilot confirmation). Aria never negotiates access, gives property advice, or commits the agent to anything beyond a calendar hold. This keeps the feature inside knowledge/architecture/ai-bridge-principle.md.

7. Honest status hub

Per the audit's standing rule (reaction to the PR #1091 "dead Connect button" class): the Integrations/Settings card flips from "Not available yet" to "Connected" ONLY when a stored token exists AND a live free/busy probe returns 200 for that agent. A stored-but-failing connection reads "Reconnect needed," never a green "Connected" it can't back up.

8. Acceptance criteria

  1. Connect round-trip (both providers): an agent connects Google and, separately, Outlook; tokens are persisted encrypted (a raw DB read shows no usable token); the card flips to Connected only after a successful free/busy probe.
  2. Book creates a real event: booking a type='showing' task creates an event on the agent's calendar with the correct title/address/time, the lead invited, and the AI-arranged disclosure in the description; re_tasks.metadata.calendar.event_id is populated and the task is status='scheduled'.
  3. Free/busy respected: booking into a busy slot is refused with alternative free slots offered (no double-book).
  4. Reschedule/cancel mirror: moving the task moves the event; cancelling removes it and the invitee is notified.
  5. Disconnect fails closed: after disconnect, a book attempt does NOT throw or silently drop — it falls back to capture-only and prompts reconnect.
  6. account_id + UPSERT: all client calls carry ?account_id=; the connection row is UPSERTed (a brand-new agent with no prior row connects successfully — no silent 0-row no-op).
  7. Least privilege: granted scopes are exactly calendar.events+freebusy (Google) / Calendars.ReadWrite (Microsoft) — no broader scope requested.
  8. Bridge: every created event carries the "Arranged by Aria (AI) — will confirm" disclosure; Aria never books without an available real slot + human/lead confirmation path.

9. Verification (deployed URL)

  • Drive the real host (wiseaiagency.com/realtor/app) with the TeamMoelker showcase account: connect a throwaway Google/Microsoft test account, book a seeded type='showing' task, and assert (a) the event appears in that test calendar via the provider API, (b) re_tasks.metadata.calendar.event_id matches, (c) the card shows Connected only post-probe. Sample state at ≥2 timepoints (task open pre-book, scheduled post-book) — behavior, not DOM presence.
  • Negative: revoke the token provider-side → the next book attempt shows Reconnect, not a 500, and the task remains captured.
  • Confirm encrypted-at-rest by reading the row directly (no plaintext token).