mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
464 lines
16 KiB
TypeScript
464 lines
16 KiB
TypeScript
import 'server-only'
|
|
|
|
import { importPKCS8, SignJWT } from 'jose'
|
|
|
|
import type { GoogleRemoteEvent } from '@/lib/googleCalendarEventMapping'
|
|
|
|
/**
|
|
* C114 — thin Calendar API v3 client authenticated either as the campaign's
|
|
* service account (JWT assertion → OAuth2 token → REST) or — C149 — with the
|
|
* OAuth refresh token granted by the "Conectar com o Google" consent
|
|
* (refresh_token grant → access 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
|
|
/** calendarList caps `maxResults` at 250 (unlike events). */
|
|
const CALENDAR_LIST_PAGE_SIZE = 250
|
|
/**
|
|
* 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.
|
|
* Exported for the hook pin test (C114-LOCK: hook < per-hop).
|
|
*/
|
|
export const REQUEST_TIMEOUT_MS = 15_000
|
|
|
|
export type { GoogleRemoteEvent } from '@/lib/googleCalendarEventMapping'
|
|
|
|
export type GoogleCalendarServiceAccountCredentials = {
|
|
clientEmail: string
|
|
privateKey: string
|
|
}
|
|
|
|
/** C149 — the connection created by the OAuth consent (refresh token + app pair). */
|
|
type GoogleCalendarOAuthCredentials = {
|
|
clientId: string
|
|
clientSecret: string
|
|
refreshToken: string
|
|
}
|
|
|
|
/**
|
|
* How the client authenticates. The engine resolves OAuth first (when the
|
|
* campaign connected an account) and falls back to the service account —
|
|
* `googleCalendarSync.readGoogleCalendarAuth`.
|
|
*/
|
|
export type GoogleCalendarAuth =
|
|
| { kind: 'service-account'; credentials: GoogleCalendarServiceAccountCredentials }
|
|
| { kind: 'oauth'; credentials: GoogleCalendarOAuthCredentials }
|
|
|
|
/** 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
|
|
}
|
|
}
|
|
|
|
/**
|
|
* C149 — the OAuth connection is dead (`invalid_grant`: the refresh token was
|
|
* revoked or expired). Reconnectable by the user, unlike an API/config error:
|
|
* the engine records it as the connection `error` state that offers
|
|
* "Reconectar" in one click.
|
|
*/
|
|
export class GoogleCalendarAuthError extends Error {
|
|
constructor(message: string) {
|
|
super(message)
|
|
this.name = 'GoogleCalendarAuthError'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* C150 — one entry of the connected account's calendar list, reduced to what
|
|
* the picker renders. `summary` falls back to the id when Google omits it.
|
|
*/
|
|
export type GoogleCalendarListEntry = {
|
|
id: string
|
|
summary: string
|
|
primary: boolean
|
|
}
|
|
|
|
export type GoogleCalendarClient = {
|
|
/**
|
|
* C150 — the calendars the connected account can WRITE to (`minAccessRole=writer`),
|
|
* for the campaign's primary-calendar picker. The mirror only works on a
|
|
* calendar the account owns/edits, so read-only subscriptions never show up.
|
|
*/
|
|
listCalendars: () => Promise<GoogleCalendarListEntry[]>
|
|
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>
|
|
/**
|
|
* C165 — partial update (`events.patch`) for events the Teqo did NOT create:
|
|
* only the fields the campaign owns go in the body, so the user's
|
|
* description, guests, reminders and Meet link survive an edit made here.
|
|
* `sendUpdates: 'none'` — the campaign's edit is not a broadcast.
|
|
*/
|
|
patchEvent: (
|
|
calendarId: string,
|
|
eventId: string,
|
|
patch: Partial<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
|
|
}
|
|
|
|
/** `Date`'s maximum time value — beyond it `new Date(ms).toISOString()` throws. */
|
|
const MAX_DATE_MS = 8.64e15
|
|
|
|
/**
|
|
* C164 — `events.watch` serializes the int64 `expiration` (epoch millis) as a
|
|
* JSON **string**; reading it as a number made `new Date(...)` invalid and the
|
|
* channel was never persisted. Normalizes at the API boundary so the engine
|
|
* keeps the `number | null` contract: any finite epoch millis inside `Date`'s
|
|
* range is accepted from a number or a string; everything else is `null`,
|
|
* which the engine maps to the TTL fallback. Never throws.
|
|
*/
|
|
export const parseGoogleWatchExpiration = (value: unknown): number | null => {
|
|
const parsed = typeof value === 'string' ? Number(value) : value
|
|
return typeof parsed === 'number' &&
|
|
Number.isFinite(parsed) &&
|
|
parsed > 0 &&
|
|
parsed <= MAX_DATE_MS
|
|
? parsed
|
|
: 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: GoogleCalendarServiceAccountCredentials,
|
|
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)
|
|
}
|
|
|
|
/** Reads the token endpoint's error code without ever surfacing the body. */
|
|
const readOAuthErrorCode = async (response: Response): Promise<string | null> => {
|
|
try {
|
|
const body = (await response.json()) as { error?: unknown }
|
|
return typeof body.error === 'string' ? body.error : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export const createGoogleCalendarClient = (
|
|
auth: GoogleCalendarAuth,
|
|
fetchImpl: FetchLike = fetch,
|
|
hookSignal?: AbortSignal,
|
|
): GoogleCalendarClient => {
|
|
let cachedToken: { value: string; expiresAtMs: number } | null = null
|
|
|
|
const requestSignal = (): AbortSignal => {
|
|
const perHop = AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
return hookSignal ? AbortSignal.any([hookSignal, perHop]) : perHop
|
|
}
|
|
|
|
const parseAccessToken = async (response: Response): Promise<string> => {
|
|
const body = (await response.json().catch(() => null)) as {
|
|
access_token?: unknown
|
|
expires_in?: unknown
|
|
} | null
|
|
const accessToken = typeof body?.access_token === 'string' ? body.access_token : ''
|
|
if (!accessToken) {
|
|
throw new GoogleCalendarApiError('Resposta de autenticação do Google sem token.', 502)
|
|
}
|
|
const expiresIn =
|
|
typeof body?.expires_in === 'number' && Number.isFinite(body.expires_in)
|
|
? body.expires_in
|
|
: GOOGLE_TOKEN_TTL_SECONDS
|
|
cachedToken = {
|
|
value: accessToken,
|
|
expiresAtMs: Date.now() + expiresIn * 1000,
|
|
}
|
|
return cachedToken.value
|
|
}
|
|
|
|
const requestServiceAccountAccessToken = async (
|
|
credentials: GoogleCalendarServiceAccountCredentials,
|
|
): 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: requestSignal(),
|
|
})
|
|
if (!response.ok) {
|
|
throw new GoogleCalendarApiError(
|
|
`Não foi possível autenticar no Google (HTTP ${response.status}).`,
|
|
response.status,
|
|
)
|
|
}
|
|
return parseAccessToken(response)
|
|
}
|
|
|
|
const requestOAuthAccessToken = async (
|
|
credentials: GoogleCalendarOAuthCredentials,
|
|
): Promise<string> => {
|
|
const response = await fetchImpl(GOOGLE_TOKEN_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({
|
|
grant_type: 'refresh_token',
|
|
refresh_token: credentials.refreshToken,
|
|
client_id: credentials.clientId,
|
|
client_secret: credentials.clientSecret,
|
|
}).toString(),
|
|
signal: requestSignal(),
|
|
})
|
|
if (!response.ok) {
|
|
// `invalid_grant` is the user-fixable case (token revoked/expired) and
|
|
// becomes the reconnectable auth error. `invalid_client` (our app
|
|
// credential is wrong) and transient failures stay API errors: telling
|
|
// the user to reconnect would not fix them.
|
|
if ((await readOAuthErrorCode(response)) === 'invalid_grant') {
|
|
throw new GoogleCalendarAuthError(
|
|
'A conexão com o Google expirou ou foi revogada. Reconecte a conta.',
|
|
)
|
|
}
|
|
throw new GoogleCalendarApiError(
|
|
`Não foi possível autenticar no Google (HTTP ${response.status}).`,
|
|
response.status,
|
|
)
|
|
}
|
|
return parseAccessToken(response)
|
|
}
|
|
|
|
const requestAccessToken = (): Promise<string> =>
|
|
auth.kind === 'oauth'
|
|
? requestOAuthAccessToken(auth.credentials)
|
|
: requestServiceAccountAccessToken(auth.credentials)
|
|
|
|
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: requestSignal(),
|
|
})
|
|
|
|
// 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 listCalendars: GoogleCalendarClient['listCalendars'] = async () => {
|
|
const calendars: GoogleCalendarListEntry[] = []
|
|
let pageToken: string | undefined
|
|
|
|
do {
|
|
const params = new URLSearchParams({
|
|
minAccessRole: 'writer',
|
|
// A calendar the operation hid in the Google sidebar is still a valid
|
|
// mirror target — hiding is a UI preference, not a permission.
|
|
showHidden: 'true',
|
|
maxResults: String(CALENDAR_LIST_PAGE_SIZE),
|
|
fields: 'items(id,summary,primary,deleted),nextPageToken',
|
|
})
|
|
if (pageToken) params.set('pageToken', pageToken)
|
|
|
|
const response = await apiFetch(`${GOOGLE_CALENDAR_API_BASE}/users/me/calendarList?${params}`)
|
|
const body = (await response.json()) as {
|
|
items?: Array<{ id?: unknown; summary?: unknown; primary?: unknown; deleted?: unknown }>
|
|
nextPageToken?: string
|
|
}
|
|
for (const item of body.items ?? []) {
|
|
// Deleted calendars linger in the list until Google purges them.
|
|
if (typeof item.id !== 'string' || item.id.length === 0 || item.deleted === true) continue
|
|
calendars.push({
|
|
id: item.id,
|
|
summary:
|
|
typeof item.summary === 'string' && item.summary.length > 0 ? item.summary : item.id,
|
|
primary: item.primary === true,
|
|
})
|
|
}
|
|
pageToken = body.nextPageToken
|
|
} while (pageToken)
|
|
|
|
return calendars
|
|
}
|
|
|
|
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 patchEvent: GoogleCalendarClient['patchEvent'] = async (calendarId, eventId, patch) => {
|
|
await apiFetch(`${eventsUrl(calendarId, eventId)}?sendUpdates=none`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(patch),
|
|
})
|
|
}
|
|
|
|
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?: unknown
|
|
}
|
|
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: parseGoogleWatchExpiration(body.expiration),
|
|
}
|
|
}
|
|
|
|
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 {
|
|
listCalendars,
|
|
listEvents,
|
|
insertEvent,
|
|
updateEvent,
|
|
patchEvent,
|
|
deleteEvent,
|
|
watchEvents,
|
|
stopChannel,
|
|
}
|
|
}
|