CODEX // oaoisme wiki

§ unswayed-backend · Hiring pipeline (Phase 6)

Google Calendar integration

updated 2026-06-10

Google Calendar integration

Interview scheduling needs two things from an employer's calendar: when they're busy (so the app can propose interview slots) and a Google Meet link (to paste into the interview invite). §43 delivers both, behind an OAuth connection the employer makes once.

The shape: a port with one Google seam

All of it lives in src/hiring-pipeline/google-calendar/. The controllers are thin; everything real happens in GoogleCalendarService, which implements the abstract CalendarPort ({ provide: CalendarPort, useClass: GoogleCalendarService }). The service is built on google-auth-library alone — no googleapis SDK. The two Calendar REST calls (list events, insert event with a Meet conference) go through OAuth2Client.request, which means the entire Google surface mocks at one seam: fake the OAuth2Client and you can drive every flow, which is exactly what both the unit suite and the e2e suite do (jest.mock('google-auth-library')).

When GOOGLE_CALENDAR_CLIENT_ID/SECRET are unset, every calendar route degrades to the same 403 an unconnected employer gets — the app boots and the whole test pyramid runs with zero network.

The token lifecycle

Tokens live on the Phase 3 Employer row (google_access_token, google_refresh_token, google_token_expires_at — the columns were modelled back then, hidden from every resource). The service owns four moments:

  • Store — the OAuth code exchange (POST /api/employer/google/callback, or the web callback below) saves all three fields. "Connected" means a refresh token exists — that's what GET /api/employer/google/status reports, with the raw stored expiry.
  • Silent refresh — when a request finds the access token expired, the service refreshes with the stored refresh token, persists the rotated credentials, and carries on. The caller never sees it.
  • RevokePOST /api/employer/google/disconnect revokes at Google best-effort (a revoke failure still proceeds) and nulls all three columns.
  • Clear-on-403 — if Google answers a calendar call with a permission error, the service clears the stored tokens and throws a 403 whose envelope carries action: 'reconnect' — a client directive meaning "your connection is dead, run OAuth again". (The global exception filter learned to pass an optional action string through the error envelope for exactly this.)

The OAuth dance — and the bare web callback

GET /api/employer/google/auth-url builds the consent URL: full Calendar scope, access_type=offline + prompt=consent (both required to get a refresh token every time), and a base64-encoded JSON state of { uid, plt } — the employer's user id and the platform (mobile/web).

That state blob exists because of the second entry point: GET /google/calendar/callback — a bare web route, no auth, outside /api (the only such feature route in the app; configure-app.ts excludes it from the global prefix, because Google redirects the browser there and the path must match what's registered in Google Cloud). The callback can't read a JWT, so it resolves the trusted employer from state.uid, exchanges the code, stores the tokens, and answers with a 302: mobile → com.unswayed://google-calendar/success (or /error?reason=…), web → {frontend}/dashboard?calendar=connected (or ?calendar=error). Every expected failure — missing code/state, undecodable state, unknown employer, failed exchange — maps to a frozen reason string and the error redirect; the route never throws.

The inline-HTML fallback (no frontend URL configured)

Where does the web browser go when neither GOOGLE_CALENDAR_FRONTEND_URL nor FRONTEND_URL is set? Originally the config carried a hard-coded https://unswayed.com fallback — which silently bounced users of a credentials-only deployment to a host that might not serve them. Since ADR-0031 the callback uses the same three-way split as the LinkedIn web callback:

  1. mobile → the deep-link 302 (always, unchanged);
  2. web with a configured frontend → the dashboard redirect (unchanged);
  3. web with no frontend URL → a 200 text/html inline landing page.

The pages live in callback-page.ts as pure functions — a success variant ("Google Calendar connected… you can close this window") and a failure variant carrying the machine reason. They are deliberately boring: one self-contained HTML document, inline styles only, never any script, and every dynamic value HTML-escaped so a Google- or exception-originated reason string can't inject markup. googleCalendarConfig.frontendUrl is now string | null, and null is the signal — there is no magic default host anywhere anymore.

If a third web callback ever appears, the redirect | html target union + page shell used here and in linkedin-oauth.service.ts is the pattern to extract and share.

The two working endpoints

  • GET /api/employer/google/available-slots?date_from&date_to — lists the employer's primary-calendar events in the window as busy_slots: [{ summary, start, end }] (timed events use dateTime, all-day events fall back to date, untitled events have summary: null). It's the busy list — the client inverts it to offer free slots.
  • POST /api/employer/google/generate-meet-link — creates a calendar event with a Meet conference and returns { meeting_link, event_id, event_link }.

The legacy quirk both keep: the Meet link is generated by a separate endpoint and manually pasted into the interview action's meeting_link field — there's no server-side join between the generated event and the JobInterview it's for. The wire contract freezes that flow; persisting event_id on the interview row is the flagged improvement if reschedule/cancel ever lands.

Testing it

The unit suite mocks the OAuth2Client class and covers the token lifecycle exhaustively; callback-page.spec.ts pins the fallback pages (incl. the escaping) and google-calendar.config.spec.ts the env-fallback chain. The e2e suite (test/google-calendar.e2e-spec.ts) does the same mock at the file level, then drives the real HTTP routes through the real service — asserting the auth-url's scope/state contents, the stored tokens after a callback, the silent-refresh persistence, the clear-on-403 + action:'reconnect' envelope, and every redirect target of the bare web callback. The inline-HTML fallback gets its own separately-booted app (the suite deletes the frontend env vars before a second bootApp, since the config factory reads them at boot) asserting the 200 HTML success/failure pages and that mobile keeps its 302. Set the GOOGLE_CALENDAR_* env vars at the very top of the spec file, before the app import, so the config factory sees them.