§ unswayed-backend · Hiring pipeline (Phase 6)
Google Calendar integration
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 whatGET /api/employer/google/statusreports, 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.
- Revoke —
POST /api/employer/google/disconnectrevokes 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
403whose envelope carriesaction: 'reconnect'— a client directive meaning "your connection is dead, run OAuth again". (The global exception filter learned to pass an optionalactionstring 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:
- mobile → the deep-link 302 (always, unchanged);
- web with a configured frontend → the dashboard redirect (unchanged);
- web with no frontend URL → a 200
text/htmlinline 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 asbusy_slots: [{ summary, start, end }](timed events usedateTime, all-day events fall back todate, untitled events havesummary: 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.