teqo-backup/src/utilities/googleCalendarClient.ts
Francisco Solla 7629a505e0 feat(C115): edição pelo Google Calendar volta para o Teqo (motor bidirecional + webhook push)
- runSyncPass bidirecional: regra do relógio decide direção (Google mais novo
  aplica título/horário; Teqo re-asserta), cancelamento via showDeleted +
  snapshot lastSeenEventIds, registro no updates feed
- webhook público /campanha/agenda/google-webhook/[secret] fail-closed
  (secret + X-Goog headers constant-time), canal lazy sem cron (ensure em
  toda passada, renovação com id novo + stop do antigo)
- migration hand-written (campos de canal/snapshot em googleCalendarSync);
  mutationKind dedicado libera título via Google sem quebrar slug canônico
- UI: seção 'Edições pelo Google' no dialog + autor 'Google Calendar' no feed
- testes: unit (reverse mapping, validação webhook), int (motor bidirecional
  com stub, 10 cenários), e2e (webhook 404 fail-closed)
2026-08-11 22:38:53 -03:00

264 lines
9.1 KiB
TypeScript

import 'server-only'
import { importPKCS8, SignJWT } from 'jose'
import type { GoogleRemoteEvent } from '@/lib/googleCalendarEventMapping'
/**
* C114 — thin Calendar API v3 client authenticated as the campaign's service
* account (JWT assertion → OAuth2 token → REST). Only the four event endpoints
* the reconciliation engine needs; the token is cached per runtime instance
* and re-minted on 401. The `fetch` implementation is injectable so tests can
* stub the transport without network.
*/
export const GOOGLE_CALENDAR_SCOPE = 'https://www.googleapis.com/auth/calendar'
const GOOGLE_TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token'
const GOOGLE_CALENDAR_API_BASE = 'https://www.googleapis.com/calendar/v3'
const GOOGLE_TOKEN_TTL_SECONDS = 3600
const TOKEN_REFRESH_LEAD_SECONDS = 60
const MAX_LIST_PAGE_SIZE = 2500
/**
* Every outbound call carries a hard timeout so a hanging Google never holds
* the activity write path (the afterChange hook awaits the sync) — Teqo's
* availability cannot depend on Google's.
*/
const REQUEST_TIMEOUT_MS = 15_000
export type { GoogleRemoteEvent } from '@/lib/googleCalendarEventMapping'
export type GoogleCalendarCredentials = {
clientEmail: string
privateKey: string
}
/** Typed transport failure — safe message, never echoes credentials or bodies. */
export class GoogleCalendarApiError extends Error {
readonly status: number
constructor(message: string, status: number) {
super(message)
this.name = 'GoogleCalendarApiError'
this.status = status
}
}
export type GoogleCalendarClient = {
listEvents: (
calendarId: string,
range: { timeMin: string; timeMax: string },
) => Promise<GoogleRemoteEvent[]>
insertEvent: (calendarId: string, event: GoogleRemoteEvent) => Promise<void>
updateEvent: (calendarId: string, eventId: string, event: GoogleRemoteEvent) => Promise<void>
deleteEvent: (calendarId: string, eventId: string) => Promise<void>
/** C115 — push channel for the Google→Teqo direction (events.watch). */
watchEvents: (
calendarId: string,
channel: { id: string; address: string; token: string; ttlSeconds: number },
) => Promise<GoogleWatchChannel>
/** C115 — stops a push channel (channels.stop); used on renewal/calendar change. */
stopChannel: (channel: { id: string; resourceId: string }) => Promise<void>
}
/** The `api#channel` the watch response returns. */
type GoogleWatchChannel = {
id: string
resourceId: string
expiration: number | null
}
export type FetchLike = typeof fetch
/**
* The signed JWT assertion for the OAuth2 token exchange (RFC 7523). Exported
* for tests — `createGoogleCalendarClient` mints and exchanges it internally.
*/
export const buildServiceAccountAssertion = async (
credentials: GoogleCalendarCredentials,
nowSeconds: number,
): Promise<string> => {
const key = await importPKCS8(credentials.privateKey, 'RS256')
return new SignJWT({ scope: GOOGLE_CALENDAR_SCOPE })
.setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
.setIssuer(credentials.clientEmail)
.setAudience(GOOGLE_TOKEN_ENDPOINT)
.setIssuedAt(nowSeconds)
.setExpirationTime(nowSeconds + GOOGLE_TOKEN_TTL_SECONDS)
.sign(key)
}
export const createGoogleCalendarClient = (
credentials: GoogleCalendarCredentials,
fetchImpl: FetchLike = fetch,
): GoogleCalendarClient => {
let cachedToken: { value: string; expiresAtMs: number } | null = null
const requestAccessToken = async (): Promise<string> => {
const nowSeconds = Math.floor(Date.now() / 1000)
const assertion = await buildServiceAccountAssertion(credentials, nowSeconds)
const response = await fetchImpl(GOOGLE_TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion,
}).toString(),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
if (!response.ok) {
throw new GoogleCalendarApiError(
`Não foi possível autenticar no Google (HTTP ${response.status}).`,
response.status,
)
}
const body = (await response.json()) as { access_token?: string; expires_in?: number }
if (!body.access_token) {
throw new GoogleCalendarApiError('Resposta de autenticação do Google sem token.', 502)
}
cachedToken = {
value: body.access_token,
expiresAtMs: Date.now() + (body.expires_in ?? GOOGLE_TOKEN_TTL_SECONDS) * 1000,
}
return cachedToken.value
}
const getAccessToken = async (): Promise<string> => {
if (cachedToken && cachedToken.expiresAtMs > Date.now() + TOKEN_REFRESH_LEAD_SECONDS * 1000) {
return cachedToken.value
}
return requestAccessToken()
}
const apiFetch = async (
url: string,
init: RequestInit = {},
retried = false,
): Promise<Response> => {
const token = await getAccessToken()
const response = await fetchImpl(url, {
...init,
headers: { ...init.headers, Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
// A 401 usually means the cached token expired server-side — re-mint once.
if (response.status === 401 && !retried) {
cachedToken = null
return apiFetch(url, init, true)
}
if (!response.ok) {
throw new GoogleCalendarApiError(
`Google Calendar API respondeu HTTP ${response.status}.`,
response.status,
)
}
return response
}
const eventsUrl = (calendarId: string, ...parts: string[]): string =>
`${GOOGLE_CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events${parts
.map((part) => `/${encodeURIComponent(part)}`)
.join('')}`
const listEvents: GoogleCalendarClient['listEvents'] = async (calendarId, range) => {
const events: GoogleRemoteEvent[] = []
let pageToken: string | undefined
do {
const params = new URLSearchParams({
timeMin: range.timeMin,
timeMax: range.timeMax,
maxResults: String(MAX_LIST_PAGE_SIZE),
singleEvents: 'true',
// C115 — cancelled (trashed) events are the "user cancelled this
// commitment" signal the reverse direction acts on; without it a
// deletion in Google would be invisible to the reconciliation.
showDeleted: 'true',
})
if (pageToken) params.set('pageToken', pageToken)
const response = await apiFetch(`${eventsUrl(calendarId)}?${params.toString()}`)
const body = (await response.json()) as {
items?: GoogleRemoteEvent[]
nextPageToken?: string
}
events.push(...(body.items ?? []))
pageToken = body.nextPageToken
} while (pageToken)
return events
}
const insertEvent: GoogleCalendarClient['insertEvent'] = async (calendarId, event) => {
try {
await apiFetch(eventsUrl(calendarId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event),
})
} catch (error) {
// 409 = the deterministic id already exists (a concurrent pass inserted
// it between our list and this insert) — the idempotent end state, not
// a failure; the next pass converges on the content.
if (!(error instanceof GoogleCalendarApiError && error.status === 409)) throw error
}
}
const updateEvent: GoogleCalendarClient['updateEvent'] = async (calendarId, eventId, event) => {
await apiFetch(eventsUrl(calendarId, eventId), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event),
})
}
const deleteEvent: GoogleCalendarClient['deleteEvent'] = async (calendarId, eventId) => {
await apiFetch(eventsUrl(calendarId, eventId), { method: 'DELETE' })
}
const watchEvents: GoogleCalendarClient['watchEvents'] = async (calendarId, channel) => {
const response = await apiFetch(`${eventsUrl(calendarId)}/watch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: channel.id,
type: 'web_hook',
address: channel.address,
token: channel.token,
params: { ttl: String(channel.ttlSeconds) },
}),
})
const body = (await response.json()) as {
id?: string
resourceId?: string
expiration?: number | null
}
if (!body.id || !body.resourceId) {
throw new GoogleCalendarApiError('O Google não devolveu um canal de notificação válido.', 502)
}
return {
id: body.id,
resourceId: body.resourceId,
expiration: body.expiration ?? null,
}
}
const stopChannel: GoogleCalendarClient['stopChannel'] = async ({ id, resourceId }) => {
try {
await apiFetch(`${GOOGLE_CALENDAR_API_BASE}/channels/stop`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, resourceId }),
})
} catch (error) {
// Already stopped / unknown channel is the desired end state (404).
if (!(error instanceof GoogleCalendarApiError && error.status === 404)) throw error
}
}
return { listEvents, insertEvent, updateEvent, deleteEvent, watchEvents, stopChannel }
}