What the calendar integration is today, how a connection actually works, what the onboarding wizard does now, and where the next phase is headed. Written for the agent picking this up, by the one who built the last stretch.
2026-09-16 · Google + Microsoft calendars, all three rungs · onboarding wizard in one straight line · last two PRs merging
Calendars work on both vendors at every level, and onboarding runs in one straight line. A company's shared calendar, a building's calendar, and a leasing agent's own calendar can each be Microsoft or Google. Clara books tours on any of them. The one thing we do not have is Gmail: Clara cannot read or send a company's Google mail.
| Rung | What it is | Where you see it | Microsoft | |
|---|---|---|---|---|
| Company | One shared calendar (and, on Microsoft, the shared leasing mailbox) for the whole company. The default shape for a new customer. | Onboarding · Channels step (company shape) · Organization Settings card | mailbox + calendar | calendar only |
| Building | A calendar (and mailbox) per property, for companies run per-building team. | Onboarding · Channels step (per-building shape) · Property Settings | live | live |
| Person | A leasing agent's own calendar. Tours go in a person's day, not a building's. | Onboarding · Your calendar step · first sign-in screen · Profile → My calendar | live | live |
Every rung on every vendor is the same four-step shape. Learn it once.
/api/integrations/outlook/callback and /api/integrations/google-calendar/callback. Adding a rung never touches a vendor console.org-connect-state.ts, person-connect-state.ts, one shared signer in signed-oauth-state.ts). A forged, replayed or tampered link is refused before any code is exchanged and lands on one fixed page. The building lane still uses a bare state; it names a property that is re-checked against the caller's scope.ChannelAttachment rows keyed by node (ORG#, PERSON#) and kind (calendar, mailbox), with provider: 'microsoft' | 'google'. Connecting supersedes the live hook rather than adding a second calendar. Disconnecting ends the hook and keeps the row as audit.fresh-channel-token.ts branches on provider; a revoked Google grant marks the row "reconnect needed" with plain copy. Clara's tour code reads a calendar's provider off the row (getOrgCalendarAccess, getPersonCalendarAccess) and never assumes Outlook.| Piece | Where | Notes |
|---|---|---|
| Google sign-in | Google Cloud project propflow-login, PropFlow org, hello@ account | Better Auth. Env: GOOGLE_LOGIN_CLIENT_ID / _SECRET. Moved off a personal account on 2026-09-16. |
| Google integrations | Google Cloud project propflow-integrations-508806, same org | Calendar API and Gmail API enabled. Env: GOOGLE_INTEGRATIONS_CLIENT_ID / _SECRET. One registered redirect URI: the Google calendar callback above. Verified; no ID verification was required. |
| Legacy Google client | GOOGLE_CLIENT_ID / _SECRET | The vendor-quote poller's old project. Not part of calendars. Leave it. |
| Microsoft | One app registration (Fede's earlier in-house move) | Env: MICROSOFT_CLIENT_ID / _SECRET. Company connects can hit the admin-approval wall; the vault carries a pending_admin_approval status and a silent re-ask. Google has no such wall. |
| Deploy | Vercel, project propflowai | Merge to main is the production deploy. A green main run promotes; check the Production deployment's SHA before telling anyone something is live. |
| Gmail scopes | gmail.send is cheap. gmail.readonly is a restricted scope and needs Google's CASA review, unless a customer's own Workspace admin allowlists our app, which exempts us. Nothing in code uses either today. |
/onboarding/channels resolves the company's shape once and renders it in place. The old addresses (company-microsoft, company-channels) are redirects that carry the query string, so consent links minted before still land.onboardingCompletedAt on the User row (or a completed ONBOARDING#<userId> record) is terminal: an admin who has it is never routed back in. calendarWelcomeOfferedAt is stamped when the Your-calendar step renders, so the post-wizard first-sign-in screen never asks a finished admin twice.wizard-steps.ts tracks only the two company steps anyone can do once for everyone (AppFolio, company mailbox). Your calendar and teammates are the personal tail, reached once no company step is left. Do not add a per-person step to the company tracker; it was tried, and skipping it looped people back into the wizard forever.centralized (company shape). The admin customer page has a dropdown to flip a company to per-building team./welcome/calendar); the wizard's Your-calendar step is its twin for the admin.<company>@propflow.io) so onboarding is never blocked on mail permissions. The choice between PropFlow's address, the customer's own domain, and PMS-only moves out of onboarding into settings, presented as a mockup of what the customer's email would look like rather than a "what's your email?" prompt.@propflowai.co address is a platform admin: it skips onboarding and sees every company. To walk the wizard you need a customer identity in a test company.demo AppFolio database, so the AppFolio step can be completed without real credentials.There is no button for this yet. The least-resistance path is a script run from a session, dry-run by default:
npm run reset:onboarding-walk -- --email admin@testco.example # shows what would change
npm run reset:onboarding-walk -- --email admin@testco.example --apply --confirm-table=propflow-prod
npm run reset:onboarding-walk -- --email ... --disconnect --apply --confirm-table=propflow-prod # also ends channel hooks + disables the AppFolio credential
Ships in #8919. Without --disconnect it clears only the completion stamps (the user row's two dates and the onboarding record), so the wizard opens on whichever steps are still outstanding. With it, the company's mailbox and calendar hooks and the admin's own calendar hook are ended and the AppFolio credential is disabled, so the walk starts from the AppFolio step. Imported properties are left alone; the import is an upsert and re-running it is safe. To wipe properties too, use reset-org-sync-for-reimport.ts, which is heavier and requires sync to be paused first.
| PR | What it did |
|---|---|
| #8841 | Google sign-in moved to a company-owned Google Cloud project. |
| #8845 | One multi-provider integration row, used everywhere a vendor is chosen. |
| #8878 | The channel vault refreshes Google tokens through Google; company and person rungs admit a Google row. |
| #8883 | A centralized company connects its shared Google calendar; "Calendar connected" tone in settings and onboarding. |
| #8884 | Teammates step: email-only invites, standard role select, one ending. |
| #8895 | The Microsoft-named onboarding step renamed vendor-neutral; old address redirects. |
| #8897 | A leasing agent connects their own Google calendar (first sign-in screen and My calendar). |
| #8902 | One Channels step: the company shape renders in place; both shapes continue into the wizard. |
| #8906 | Google's account chooser on the person lane, so "connect a different account" can. |
| #8911 | Your calendar as wizard step 5 of 6, with the offer stamped on render. |
| #8914 | Person-calendar links signed and session-bound on both vendors. |
org-connect-state wholesale. A new export read at import time by anything the callback imports kills them on load. That is why the shared max-age lives in the signer module.npm run generate:route-manifest; the wizard route list, the step counter, the middleware's private-segment list and the activity step map are all pinned by tests and all have to move together.origin/main.Companion pages: Google Calendar, Three Rungs (the plan this executed) · Google connect flow · New customer onboarding experience.