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, thecalendarConnectedseam) + 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 —
*_enccolumns hold envelope-encrypted values (app-layer encryption or Supabase Vault), never a readable OAuth token in a plain column. Followknowledge/runbooks/key-rotation-procedure.mdfor the encryption key surface. A DB read of the row must not expose a usable token. - Least-privilege scopes: Google
calendar.events+calendar.freebusyonly (not fullcalendar); Microsoft GraphCalendars.ReadWritefor 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 anaccount_idfrom 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)
- 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/calendarcalendarConnectedstate flip to connected only after a real free/busy probe call succeeds (honest-status-hub rule §7). - 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. - Create event: create a calendar event on the agent's
calendar_idwith: titleShowing — <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}tore_tasks.metadata.calendar; set taskstatus='scheduled'. - 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. - 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
- 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.
- 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_idis populated and the task isstatus='scheduled'. - Free/busy respected: booking into a busy slot is refused with alternative free slots offered (no double-book).
- Reschedule/cancel mirror: moving the task moves the event; cancelling removes it and the invitee is notified.
- Disconnect fails closed: after disconnect, a book attempt does NOT throw or silently drop — it falls back to capture-only and prompts reconnect.
- 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). - Least privilege: granted scopes are exactly
calendar.events+freebusy(Google) /Calendars.ReadWrite(Microsoft) — no broader scope requested. - 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 seededtype='showing'task, and assert (a) the event appears in that test calendar via the provider API, (b)re_tasks.metadata.calendar.event_idmatches, (c) the card shows Connected only post-probe. Sample state at ≥2 timepoints (taskopenpre-book,scheduledpost-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).