teqo-backup/src/utilities/googleCalendarSync.ts

1565 lines
58 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'server-only'
import { randomBytes, randomUUID } from 'node:crypto'
import type { Activity, GoogleCalendarSync as GoogleCalendarSyncDoc } from '@/payload-types'
import type { Payload, PayloadRequest, Where } from 'payload'
import {
activityMunicipalityIdOf,
buildGoogleEventPayload,
buildImportedGoogleEventActivity,
buildImportedGoogleEventPatch,
decodeGoogleEventActivityId,
GOOGLE_EVENT_STATUS_CANCELLED,
googleEventContentEquals,
googleEventIdForActivity,
googleImportedEventContentEquals,
googleScheduleToActivityFields,
googleStartEndInstantEquals,
googleTitleFromSummary,
isForeignGoogleEvent,
isImportableGoogleEvent,
type GoogleRemoteEvent,
} from '@/lib/googleCalendarEventMapping'
import {
buildGoogleReverseCancelBody,
buildGoogleReverseUpdateBody,
googleEditIsNewer,
type GoogleReverseActivityEdit,
} from '@/lib/googleCalendarReverseEdit'
import {
buildActivityWindowRange,
buildActivityWindowWhereClauses,
loadMunicipalityNames,
} from '@/utilities/calendarFeed'
import { getCampaignInviteBaseURL } from '@/utilities/campaignInviteOrigin'
import {
createGoogleCalendarClient,
GoogleCalendarApiError,
GoogleCalendarAuthError,
type GoogleCalendarAuth,
type GoogleCalendarClient,
type GoogleCalendarListEntry,
type GoogleCalendarServiceAccountCredentials,
} from '@/utilities/googleCalendarClient'
import {
readGoogleCalendarOAuthCredentials,
type GoogleCalendarOAuthClientCredentials,
} from '@/utilities/googleCalendarOAuth'
/**
* C114+C115 — the campaign↔Google calendar reconciliation engine.
*
* One full pass per run (D3): load the staff-scope activities in the window,
* list the calendar's events in a slightly wider window (incl. cancelled),
* diff by the deterministic event id (`teqo` + base32hex(activityId)) and
* reconcile BOTH directions: the Teqo re-asserts its state (create/update/
* delete — updates only when the content differs, so repeated passes
* converge without touching Google), and a Google edit that is NEWER by the
* clock rule is applied back to the activity (title/schedule/cancellation —
* D2/D3/D4). Canceled and hard-deleted activities drop out of the wanted
* set, which deletes their Google event: Teqo keeps the SoT, Google keeps no
* "canceled" state visible to followers.
*
* Triggers (D4): activity afterChange/afterDelete (relevant fields only),
* agenda-page auto-retry and the manual "Sincronizar agora", the
* `googleCalendarSync` config afterChange (D7: calendarId change / re-enable)
* and — C115 — the public webhook receiving a push notification. Every
* trigger also lazily ensures the push channel (D5). The engine NEVER throws
* into the caller — failures land in the state fields and the status derives
* to `paused`; the channel itself is best-effort and never pauses the mirror.
*
* C149 — the credential is now resolved by `readGoogleCalendarAuth`: the OAuth
* connection (refresh token stored on the config row) wins when present and
* the service account remains the fallback. A dead OAuth connection
* (`GoogleCalendarAuthError`) additionally records `oauthError`, which derives
* the connection `error` state that offers reconnect in one click.
*/
export const GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV = 'GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY'
/**
* C115 — public push-channel surface. The webhook address embeds the channel
* secret (same link-as-credential model as the iCal feed); Google echoes the
* token (the same secret) on every notification, so the route validates URL
* secret + channel id + resource id + token before acting.
*/
const GOOGLE_CALENDAR_WEBHOOK_PATH = '/campanha/agenda/google-webhook/'
/** Google's internal TTL cap (~30 days per the events/watch docs). */
const PUSH_CHANNEL_TTL_SECONDS = 30 * 24 * 60 * 60
/** Renewal lead — a channel is replaced 48h before it would expire. */
const PUSH_CHANNEL_RENEW_LEAD_MS = 48 * 60 * 60 * 1000
/**
* The list window scans further into the past than the push window so a
* canceled/deleted event older than the 90-day lookback is still found and
* deleted (a realistic case: canceling an activity that happened months ago).
*/
const SYNC_DELETE_LOOKBACK_DAYS = 730
const SYNC_LIST_WINDOW_BUFFER_DAYS = 30
/**
* Deadline for the hook-triggered sync only (C114-LOCK, mirrors S11-FOLLOWUP):
* the afterChange/afterDelete hooks run INSIDE the save's transaction, holding
* the `activity` / `googleCalendarSync` row lock for their whole duration — a
* full reconciliation pass does N×RTTs to Google (token → list → N writes →
* watch → stop), each with `REQUEST_TIMEOUT_MS=15s`. Without a hook budget
* the lock is held for 45–75s, blocking concurrent campaign writes and pinning
* a Drizzle pool connection on network I/O (the anti-pattern the Issue calls
* out). The manual / webhook paths have no transaction open and keep the full
* per-hop 15s budget — they are the reliable fallback when the hook aborts.
*/
export const GOOGLE_CALENDAR_SYNC_HOOK_TIMEOUT_MS = 5_000
/**
* The fields the Google event mirrors (title, schedule, location, tags,
* deputy flag, municipality). Task toggles, updates and result records are
* deliberately absent — those never trigger a pass.
*/
const SYNC_RELEVANT_ACTIVITY_FIELDS = [
'title',
'status',
'startAt',
'endAt',
'allDay',
'locality',
'municipality',
'tags',
'deputyPresent',
] as const
export type GoogleCalendarSyncStatus = 'not-configured' | 'disabled' | 'synced' | 'paused'
export type GoogleCalendarConnectionStatus = 'connected' | 'error' | 'not-configured'
export type GoogleCalendarSyncView = {
status: GoogleCalendarSyncStatus
/** C149 — state of the OAuth connection (derived, never stored). */
connection: GoogleCalendarConnectionStatus
/** C149 — the OAuth client env is present, so the connect button can work. */
oauthAvailable: boolean
oauthConnectedAt: string | null
oauthError: string | null
calendarId: string | null
lastSyncedAt: string | null
lastSuccessAt: string | null
lastErrorAt: string | null
lastError: string | null
pushChannelExpiresAt: string | null
pushChannelError: string | null
}
/**
* Pure derivation (never stored): the pill state is a function of the
* credential presence, the configured calendar and the last attempt outcomes.
* `paused` wins while the last attempt failed; any later success recovers.
*/
export const deriveGoogleCalendarSyncStatus = (input: {
hasCredential: boolean
calendarId: string | null | undefined
disabledAt?: string | null | undefined
lastSuccessAt?: string | null | undefined
lastErrorAt?: string | null | undefined
}): GoogleCalendarSyncStatus => {
if (!input.hasCredential || !input.calendarId) return 'not-configured'
if (input.disabledAt) return 'disabled'
if (input.lastErrorAt && (!input.lastSuccessAt || input.lastErrorAt > input.lastSuccessAt)) {
return 'paused'
}
return 'synced'
}
/**
* C149 — pure derivation of the connection state (the intent's literal
* `conectado | erro | não configurado`). An auth error newer than the last
* connection wins (including before any successful connection); a token
* without a newer error is connected; anything else is not configured. The
* state is never stored — reconnect clears the error fields and the state
* recovers by construction.
*/
export const deriveGoogleCalendarConnectionStatus = (input: {
hasRefreshToken: boolean
oauthConnectedAt?: string | null
oauthErrorAt?: string | null
}): GoogleCalendarConnectionStatus => {
// Timestamps are compared as instants, not strings: a value persisted with
// a UTC offset (admin edit, fixture) would invert a lexical comparison.
const errorAt = input.oauthErrorAt ? Date.parse(input.oauthErrorAt) : Number.NaN
const connectedAt = input.oauthConnectedAt ? Date.parse(input.oauthConnectedAt) : Number.NaN
if (!Number.isNaN(errorAt) && (Number.isNaN(connectedAt) || errorAt > connectedAt)) {
return 'error'
}
return input.hasRefreshToken ? 'connected' : 'not-configured'
}
/**
* Reads the service account credential from the environment (base64 of the
* Google Cloud JSON key). Fail-closed: malformed/absent → null, which
* disables the sync entirely without breaking anything else.
*/
export const readGoogleServiceAccountCredentials =
(): GoogleCalendarServiceAccountCredentials | null => {
const raw = process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV]
if (!raw) {
// Note: this function is called without payload context, so we use console.warn
// In production, this warning will appear in server logs
console.warn(
`[GoogleCalendarSync] ${GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV} não está definido no ambiente.`,
)
return null
}
try {
const parsed = JSON.parse(Buffer.from(raw, 'base64').toString('utf8')) as Record<
string,
unknown
>
if (
typeof parsed.client_email === 'string' &&
typeof parsed.private_key === 'string' &&
parsed.private_key.length > 0
) {
return { clientEmail: parsed.client_email, privateKey: parsed.private_key }
}
console.warn(
`[GoogleCalendarSync] ${GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV} está malformado: client_email ou private_key ausente/vazio.`,
)
} catch {
console.warn(
`[GoogleCalendarSync] ${GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV} não é base64 válido ou JSON inválido.`,
)
}
return null
}
/**
* C149 — resolves the credential the engine uses. The OAuth connection (when
* a refresh token is stored AND the client env is present) wins over the
* service account, which stays as the technical fallback. `null` means no
* credential at all — the fail-closed state that skips the sync.
*/
export const readGoogleCalendarAuth = (
doc: GoogleCalendarSyncDoc | null,
oauthClient: GoogleCalendarOAuthClientCredentials | null = readGoogleCalendarOAuthCredentials(),
): GoogleCalendarAuth | null => {
if (doc?.oauthRefreshToken && oauthClient) {
return {
kind: 'oauth',
credentials: {
clientId: oauthClient.clientId,
clientSecret: oauthClient.clientSecret,
refreshToken: doc.oauthRefreshToken,
},
}
}
const serviceAccount = readGoogleServiceAccountCredentials()
return serviceAccount ? { kind: 'service-account', credentials: serviceAccount } : null
}
export const loadGoogleCalendarSyncConfig = async (
payload: Payload,
req?: PayloadRequest,
): Promise<GoogleCalendarSyncDoc | null> => {
// Intentional admin bypass: config/state of the mirror is read by the
// engine and the agenda pill for any staff — access is enforced at the
// action layer, never per-read. The webhook route and the engine rely on
// this bypass for the channel identity fields, which no user path may read.
//
// C149 — single-row read with a preference: the configured row (calendarId
// present) wins, and when none is configured the connection-only row is
// returned (fresh setup: the OAuth connection exists before C150 picks the
// calendar). The collection holds one row operationally; legacy extra docs
// must never shadow the operational calendar — same intentional admin bypass.
const result = await payload.find({
collection: 'googleCalendarSync',
limit: 0,
pagination: false,
depth: 0,
sort: 'createdAt',
overrideAccess: true,
req,
})
const docs = result.docs
return docs.find((doc) => Boolean(doc.calendarId)) ?? docs[0] ?? null
}
const docToView = (
doc: GoogleCalendarSyncDoc | null,
hasCredential: boolean,
oauthAvailable: boolean,
): GoogleCalendarSyncView => ({
status: deriveGoogleCalendarSyncStatus({
hasCredential,
calendarId: doc?.calendarId ?? null,
disabledAt: doc?.disabledAt ?? null,
lastSuccessAt: doc?.lastSuccessAt ?? null,
lastErrorAt: doc?.lastErrorAt ?? null,
}),
connection: deriveGoogleCalendarConnectionStatus({
hasRefreshToken: Boolean(doc?.oauthRefreshToken),
oauthConnectedAt: doc?.oauthConnectedAt ?? null,
oauthErrorAt: doc?.oauthErrorAt ?? null,
}),
oauthAvailable,
oauthConnectedAt: doc?.oauthConnectedAt ?? null,
oauthError: doc?.oauthError ?? null,
calendarId: doc?.calendarId ?? null,
lastSyncedAt: doc?.lastSyncedAt ?? null,
lastSuccessAt: doc?.lastSuccessAt ?? null,
lastErrorAt: doc?.lastErrorAt ?? null,
lastError: doc?.lastError ?? null,
pushChannelExpiresAt: doc?.pushChannelExpiresAt ?? null,
pushChannelError: doc?.pushChannelError ?? null,
})
/** The read model the agenda UI renders — status derived, never stored. */
export const readGoogleCalendarSyncView = async (
payload: Payload,
): Promise<GoogleCalendarSyncView> => {
const doc = await loadGoogleCalendarSyncConfig(payload)
const oauthClient = readGoogleCalendarOAuthCredentials()
return docToView(doc, readGoogleCalendarAuth(doc, oauthClient) !== null, oauthClient !== null)
}
/**
* The official calendar mirrors the FULL staff scope (espelho cheio): no
* advisor intersection — a coordinator-managed calendar, unlike per-feed
* creator scopes. Canceled activities are excluded from the PUSH set (their
* events get deleted); the window is the feed's own (shared contract).
* `activityWhere` narrows the mirrored set (e.g. a scoped mirror or test
* isolation) — production callers omit it and keep the full scope.
*/
const loadSyncActivities = async (
payload: Payload,
req: PayloadRequest | undefined,
activityWhere?: Where,
): Promise<{ activities: Activity[]; municipalityNames: Map<number, string> }> => {
const { rangeStart, rangeEnd } = buildActivityWindowRange()
const result = await payload.find({
collection: 'activity',
depth: 0,
limit: 0,
pagination: false,
sort: 'startAt',
where: {
and: [
...buildActivityWindowWhereClauses(rangeStart, rangeEnd),
...(activityWhere ? [activityWhere] : []),
],
},
// Intentional admin bypass: the official mirror is the whole campaign
// agenda (espelho cheio) — the collection access already gates writers.
overrideAccess: true,
req,
})
const activities = result.docs
const municipalityIds = [
...new Set(
activities
.map((activity) => activityMunicipalityIdOf(activity.municipality))
.filter((id): id is number => id !== undefined),
),
]
const municipalityNames = await loadMunicipalityNames(payload, municipalityIds)
return { activities, municipalityNames }
}
/**
* Ids of activities alive over the WIDER past window — the delete guard set.
* An event older than the push lookback whose activity is still alive (e.g. a
* past `realizado` commitment) must NOT be deleted; canceled/hard-deleted
* activities drop out of this set and their events are cleaned up wherever
* they are in the extended window.
*/
const loadAliveActivityIds = async (
payload: Payload,
req: PayloadRequest | undefined,
timeMin: string,
timeMax: string,
activityWhere?: Where,
): Promise<Set<number>> => {
const result = await payload.find({
collection: 'activity',
depth: 0,
limit: 0,
pagination: false,
where: {
and: [
{ startAt: { less_than: timeMax } },
{
or: [
{ endAt: { greater_than: timeMin } },
{
and: [{ endAt: { exists: false } }, { startAt: { greater_than_equal: timeMin } }],
},
],
},
{ status: { not_equals: 'cancelado' } },
...(activityWhere ? [activityWhere] : []),
],
},
// Intentional admin bypass: same espelho-cheio rationale as above.
overrideAccess: true,
req,
})
return new Set(result.docs.map((activity) => activity.id))
}
/**
* The list window scans the extended past so canceled/deleted events older
* than the 90-day push lookback are still found and removed (canceling an
* activity that happened months ago is a realistic operation).
*/
const buildListWindow = (
rangeStart: string,
rangeEnd: string,
): { timeMin: string; timeMax: string } => {
const bufferMs = SYNC_LIST_WINDOW_BUFFER_DAYS * 86_400_000
const deleteLookbackMs = SYNC_DELETE_LOOKBACK_DAYS * 86_400_000
const nowMs = Date.now()
return {
timeMin: new Date(
Math.min(new Date(rangeStart).getTime() - bufferMs, nowMs - deleteLookbackMs),
).toISOString(),
timeMax: new Date(new Date(rangeEnd).getTime() + bufferMs).toISOString(),
}
}
const deleteRemoteEvent = async (
client: GoogleCalendarClient,
calendarId: string,
eventId: string,
): Promise<void> => {
try {
await client.deleteEvent(calendarId, eventId)
} catch (error) {
// Already gone is the desired end state.
if (!(error instanceof GoogleCalendarApiError && error.status === 404)) throw error
}
}
type SyncCounts = {
created: number
updated: number
deleted: number
reverseEdits: number
/** C165 — foreign events that became activities in this pass. */
imported: number
}
/**
* C115 — the reconciliation's last-seen snapshot (JSONB on the sync row).
* `calendarId` pins WHICH calendar the ids were seen on: after a calendar
* switch the snapshot is stale and treated as empty (fresh mirror), and the
* push channel is re-created for the new calendar (see the channel ensure).
* Only written on SUCCESSFUL passes — a failed event creation is never
* "seen", so the next pass creates instead of cancelling.
*/
type GoogleCalendarSyncSnapshot = { calendarId: string; ids: string[] }
const parseLastSeenSnapshot = (raw: unknown): GoogleCalendarSyncSnapshot | null => {
if (!raw || typeof raw !== 'object') return null
const candidate = raw as { calendarId?: unknown; ids?: unknown }
if (typeof candidate.calendarId !== 'string') return null
if (!Array.isArray(candidate.ids)) return null
const ids = candidate.ids.filter((id): id is string => typeof id === 'string')
return { calendarId: candidate.calendarId, ids }
}
/**
* Appends the audit record ("com registro", D8) to the activity's updates
* feed and applies the reverse values. System write with an admin bypass —
* the authorized Google editor acts through the calendar, never as a
* campaign user; the leader lockdown guard does not apply (no `req.user`).
* The caller's user is STRIPPED on purpose: a pass triggered by a staff save
* must not attribute the record to that staff member nor trip the
* deputy-present reschedule gate.
* `validateActivitySchedule` runs on the values (malformed → the update
* throws → the pass fails → `paused`, and the next pass re-asserts Teqo).
*/
const applyGoogleReverseActivityPatch = async (
payload: Payload,
req: PayloadRequest | undefined,
activity: Activity,
data: {
title?: string
startAt?: string | null
endAt?: string | null
allDay?: boolean | null
status?: 'cancelado'
},
body: string,
): Promise<void> => {
await payload.update({
collection: 'activity',
id: activity.id,
data: {
...data,
updates: [...(activity.updates ?? []), { body, author: null }],
},
depth: 0,
// Intentional admin bypass (the Google editor acts through the calendar,
// never as a campaign user) + the dedicated mutation context that allows
// the title edit without breaking the canonical-slug contract.
overrideAccess: true,
context: { mutationKind: 'googleCalendarSync' },
req: req ? { ...req, user: undefined } : undefined,
})
}
const cancelActivityFromGoogle = async (
payload: Payload,
req: PayloadRequest | undefined,
activity: Activity,
): Promise<void> => {
await applyGoogleReverseActivityPatch(
payload,
req,
activity,
{ status: 'cancelado' },
buildGoogleReverseCancelBody(),
)
}
/**
* The editable v1 fields (D9): title + schedule. `null` per field means the
* remote value is absent/structural (e.g. a summary that is only the
* `[Município] ` prefix) — the Teqo re-asserts those through the forward
* direction on a later pass.
*/
const googleReverseEditOf = (
event: GoogleRemoteEvent,
municipalityName?: string,
): GoogleReverseActivityEdit | null => {
const title = googleTitleFromSummary(event.summary, municipalityName)
const schedule = googleScheduleToActivityFields(event)
if (title === null && !schedule) return null
return { ...(title !== null ? { title } : {}), ...(schedule ?? {}) }
}
/**
* The reverse decision uses the EDITABLE fields only (summary + schedule):
* a description/location change in Google stays Teqo-owned and is re-asserted
* by the forward direction — it must not trigger a reverse edit record.
*/
const googleEditableContentEquals = (
remote: GoogleRemoteEvent,
payload: ReturnType<typeof buildGoogleEventPayload>,
): boolean =>
(remote.summary ?? '') === payload.summary &&
googleStartEndInstantEquals(remote.start, payload.start) &&
googleStartEndInstantEquals(remote.end, payload.end)
type ImportedEventsCounts = Omit<SyncCounts, 'created'>
type ImportedEventsPassInput = {
payload: Payload
req: PayloadRequest | undefined
client: GoogleCalendarClient
calendarId: string
remoteEvents: GoogleRemoteEvent[]
lastSeenIds: Set<string>
remoteEventIds: Set<string>
window: { rangeStart: string; rangeEnd: string }
activityWhere: Where | undefined
}
/**
* C165 — the import/link pass: events on the campaign calendar that the Teqo
* did NOT create (full id decode is null), start on/after the campaign's
* legal start and sit in the mirror window become activities of their own.
*
* The link (`googleEventId` + `googleCalendarId`, system-write) is what keeps
* the pass idempotent and what makes the forward direction patch the SAME
* remote event instead of inserting a second `teqo…` one. Foreign events are
* never deleted: a Teqo cancellation patches `status: cancelled`, a Teqo
* "reopen" patches it back. A previously-seen linked event that vanished from
* Google cancels its activity (same rule as the native mirror); a linked
* activity hard-deleted in the Teqo leaves the event alone (its id stays in
* the last-seen snapshot, so it is never re-imported).
*
* Runs inside `runSyncPass`, sharing its list window, last-seen snapshot and
* CAS write — no second listing, no second snapshot writer.
*/
const runImportedEventsPass = async ({
payload,
req,
client,
calendarId,
remoteEvents,
lastSeenIds,
remoteEventIds,
window,
activityWhere,
}: ImportedEventsPassInput): Promise<ImportedEventsCounts> => {
const linked = await payload.find({
collection: 'activity',
depth: 0,
limit: 0,
pagination: false,
where: {
and: [
{ googleEventId: { exists: true } },
{ googleCalendarId: { equals: calendarId } },
...buildActivityWindowWhereClauses(window.rangeStart, window.rangeEnd, {
includeCancelled: true,
}),
...(activityWhere ? [activityWhere] : []),
],
},
// Intentional admin bypass: same espelho-cheio rationale as loadSyncActivities.
overrideAccess: true,
req,
})
const linkedByEventId = new Map<string, Activity>()
for (const activity of linked.docs) {
if (activity.googleEventId) linkedByEventId.set(activity.googleEventId, activity)
}
let imported = 0
let updated = 0
let deleted = 0
let reverseEdits = 0
for (const event of remoteEvents) {
const eventId = event.id
if (!eventId || !isForeignGoogleEvent(event)) continue
const activity = linkedByEventId.get(eventId)
if (!activity) {
// Previously seen without a link: its imported activity was hard-deleted
// in the Teqo. The id STAYS in the snapshot — dropping it here would
// re-import the same event on the very next pass.
if (lastSeenIds.has(eventId)) {
remoteEventIds.add(eventId)
continue
}
if (!isImportableGoogleEvent(event, window)) continue
const data = buildImportedGoogleEventActivity(event, calendarId)
if (!data) continue
// Pre-check right before the insert: the unique `googleEventId` is the
// lock of last resort (create outside the caller's transaction keeps a
// failed insert from poisoning it), and this read narrows the race —
// the common concurrent case converges here, without a failed write.
const existingLink = await payload.find({
collection: 'activity',
depth: 0,
limit: 1,
pagination: false,
where: { googleEventId: { equals: eventId } },
// Intentional admin bypass: the link is system state.
overrideAccess: true,
req,
})
const winner = existingLink.docs[0]
if (winner) {
linkedByEventId.set(eventId, winner)
remoteEventIds.add(eventId)
continue
}
try {
const created = await payload.create({
collection: 'activity',
data,
depth: 0,
// Intentional admin bypass: the Google editor acts through the
// calendar, and the link fields are system-write by design.
overrideAccess: true,
context: { mutationKind: 'googleCalendarSync' },
// Deliberately OUTSIDE the caller's transaction (no `req`): the
// import is Google-sourced and independent from the save that
// triggered this pass. Sharing its transaction would let a unique
// violation in a concurrent import poison the user's save; here the
// recovery read below runs in its own session.
})
linkedByEventId.set(eventId, created)
imported += 1
} catch (error) {
// A concurrent pass may still have won between the pre-check and the
// insert: converge on the winner; any other failure is real and
// pauses the pass.
const existing = await payload.find({
collection: 'activity',
depth: 0,
limit: 1,
pagination: false,
where: { googleEventId: { equals: eventId } },
// Intentional admin bypass: same rationale as the pre-check above.
overrideAccess: true,
req,
})
if (!existing.docs[0]) throw error
linkedByEventId.set(eventId, existing.docs[0])
}
remoteEventIds.add(eventId)
continue
}
// The linked event is ours to reconcile; it stays in the snapshot even if
// this pass only re-asserts it.
remoteEventIds.add(eventId)
if (activity.status === 'cancelado') {
// Teqo cancelled it: the foreign event is never deleted, only trashed —
// the event continues to exist in the user's Google (and its history).
if (event.status !== GOOGLE_EVENT_STATUS_CANCELLED) {
await client.patchEvent(calendarId, eventId, {
status: GOOGLE_EVENT_STATUS_CANCELLED,
})
deleted += 1
}
continue
}
if (event.status === GOOGLE_EVENT_STATUS_CANCELLED) {
// Same clock rule as the native mirror: a NEWER Google cancellation
// cancels the activity; an older one is re-asserted (reopen).
if (activity.status === 'confirmado') {
if (googleEditIsNewer(event.updated, activity.lastMirroredChangeAt ?? activity.updatedAt)) {
await cancelActivityFromGoogle(payload, req, activity)
reverseEdits += 1
} else {
await client.patchEvent(calendarId, eventId, { status: 'confirmed' })
updated += 1
}
}
continue
}
const importedPatch = buildImportedGoogleEventPatch(activity)
if (googleImportedEventContentEquals(event, importedPatch)) continue
if (
activity.status === 'confirmado' &&
googleEditIsNewer(event.updated, activity.lastMirroredChangeAt ?? activity.updatedAt)
) {
// No `[Município] ` prefix to strip here: imported titles are verbatim.
const reverseEdit = googleReverseEditOf(event, undefined)
if (reverseEdit) {
const body = buildGoogleReverseUpdateBody(activity, reverseEdit)
await applyGoogleReverseActivityPatch(payload, req, activity, reverseEdit, body)
reverseEdits += 1
continue
}
}
// Teqo newer/different: PATCH only the fields the campaign owns, on the
// SAME remote event (never `insertEvent`, never the deterministic id).
await client.patchEvent(calendarId, eventId, importedPatch)
updated += 1
}
for (const [eventId, activity] of linkedByEventId) {
if (remoteEventIds.has(eventId)) continue
// Seen before and now gone: the user permanently removed the event (trash
// still shows as `cancelled`, handled above). Never-seen ids are simply
// out of this pass's listing.
if (!lastSeenIds.has(eventId)) continue
if (activity.status !== 'confirmado') continue
await cancelActivityFromGoogle(payload, req, activity)
reverseEdits += 1
}
return { imported, updated, deleted, reverseEdits }
}
/**
* C115 — one full pass, now BIDIRECTIONAL (D2): for every event of ours the
* diff decides the direction by the clock rule (D3) — Google wins when its
* `updated` is newer, the Teqo re-asserts otherwise; a `cancelled` event is
* the user's cancellation (D4); a previously-seen event that vanished is the
* user's permanent removal. Content equality is checked first, so repeated
* passes converge without touching Google (C114 D3 preserved).
* `activityWhere` narrows the mirrored set (C126 — test isolation); the
* production callers omit it and keep the full staff scope.
*/
const runSyncPass = async (
payload: Payload,
req: PayloadRequest | undefined,
client: GoogleCalendarClient,
calendarId: string,
activityWhere: Where | undefined,
config: GoogleCalendarSyncDoc,
): Promise<SyncCounts> => {
// C115-F1 — captured AFTER the channel ensure (its own writes are part of
// this pass's baseline) and BEFORE reading the inputs it summarizes. This
// ONE read is both the decision basis (snapshot parsed below) and the CAS
// guard of the final write — full rationale on recordLastSeenSnapshot.
const casToken = await loadGoogleCalendarSyncConfig(payload, req)
const { rangeStart, rangeEnd } = buildActivityWindowRange()
const { activities, municipalityNames } = await loadSyncActivities(payload, req, activityWhere)
const listWindow = buildListWindow(rangeStart, rangeEnd)
const remoteEvents = await client.listEvents(calendarId, listWindow)
const aliveActivityIds = await loadAliveActivityIds(
payload,
req,
listWindow.timeMin,
listWindow.timeMax,
activityWhere,
)
// The CAS token doc IS this pass's consistent view — the same read feeds
// the decisions and the guarded write. Deciding on the outer `config`
// instead would let a writer between the two loads leave this pass on a
// stale snapshot while it still wins the CAS (resurrection window).
const snapshot = parseLastSeenSnapshot((casToken ?? config).lastSeenEventIds)
const lastSeenIds =
snapshot?.calendarId === calendarId ? new Set(snapshot.ids) : new Set<string>()
const wantedById = new Map<number, Activity>()
for (const activity of activities) {
wantedById.set(activity.id, activity)
}
let created = 0
let updated = 0
let deleted = 0
let reverseEdits = 0
let imported = 0
// Ids PRESENT in this pass's remote list (the second loop's "exists" check)
// — never removed mid-pass. Ids the engine DELETES are tracked separately so
// the last-seen snapshot excludes them: a trashed event cleaned here must
// not look "previously seen and gone" to the next pass (which would cancel
// the activity it was cleaned for — the reopen-after-cancel rule).
const remoteEventIds = new Set<string>()
const removedEventIds = new Set<string>()
for (const event of remoteEvents) {
// Only OUR deterministic ids are reconciled; foreign events on the
// campaign calendar (e.g. added manually by the owner) stay untouched —
// the full decode, never a prefix match (a manual `teqo3` is foreign).
const activityId = event.id ? decodeGoogleEventActivityId(event.id) : null
if (activityId === null) continue
remoteEventIds.add(event.id!)
const activity = wantedById.get(activityId)
// C115 — a `cancelled` event is the user's cancellation in Google
// (trash). A live `confirmado` commitment is cancelled in the Teqo only
// when the cancel is NEWER than the activity's last mirrored change —
// otherwise the Teqo changed it after the cancel (a reopen): the trashed
// event is cleaned instead, and the next pass re-creates it. History
// (`realizado`) stays untouched; anything dead leaves Google.
if (event.status === GOOGLE_EVENT_STATUS_CANCELLED) {
if (activity?.status === 'confirmado') {
if (googleEditIsNewer(event.updated, activity.lastMirroredChangeAt ?? activity.updatedAt)) {
await cancelActivityFromGoogle(payload, req, activity)
reverseEdits += 1
} else {
await deleteRemoteEvent(client, calendarId, event.id!)
removedEventIds.add(event.id!)
deleted += 1
}
} else if (!activity && !aliveActivityIds.has(activityId)) {
await deleteRemoteEvent(client, calendarId, event.id!)
removedEventIds.add(event.id!)
deleted += 1
}
continue
}
if (!activity) {
// Not in the push window: alive past/future commitments keep their
// event; anything else (canceled, hard-deleted) leaves Google.
if (!aliveActivityIds.has(activityId)) {
await deleteRemoteEvent(client, calendarId, event.id!)
removedEventIds.add(event.id!)
deleted += 1
}
continue
}
const municipalityId = activityMunicipalityIdOf(activity.municipality)
const municipalityName = municipalityId ? municipalityNames.get(municipalityId) : undefined
const googleEvent = buildGoogleEventPayload(activity, municipalityName)
if (googleEventContentEquals(event, googleEvent)) continue
// C115 — the clock rule: a NEWER Google edit of an editable field wins;
// anything else (Teqo newer, structural fields, `realizado` history) is
// re-asserted by the forward direction. The baseline is the last MIRRORED
// change (`lastMirroredChangeAt`), not `updatedAt` — task toggles and
// updates-feed records bump `updatedAt` without ever reaching the mirror.
if (
activity.status === 'confirmado' &&
!googleEditableContentEquals(event, googleEvent) &&
googleEditIsNewer(event.updated, activity.lastMirroredChangeAt ?? activity.updatedAt)
) {
const reverseEdit = googleReverseEditOf(event, municipalityName)
if (reverseEdit) {
const body = buildGoogleReverseUpdateBody(activity, reverseEdit)
await applyGoogleReverseActivityPatch(payload, req, activity, reverseEdit, body)
reverseEdits += 1
continue
}
}
await client.updateEvent(calendarId, event.id!, googleEvent)
updated += 1
}
// C165 — foreign events (id does not decode) are imported/linked here,
// sharing this pass's listing, last-seen snapshot and CAS write.
const importedCounts = await runImportedEventsPass({
payload,
req,
client,
calendarId,
remoteEvents,
lastSeenIds,
remoteEventIds,
window: { rangeStart, rangeEnd },
activityWhere,
})
imported += importedCounts.imported
updated += importedCounts.updated
deleted += importedCounts.deleted
reverseEdits += importedCounts.reverseEdits
for (const [activityId, activity] of wantedById) {
const eventId = googleEventIdForActivity(activityId)
if (remoteEventIds.has(eventId)) continue
// C115 — previously seen and now gone: the user permanently removed the
// event (trash still shows as `cancelled`, handled above). Never seen →
// a fresh mirror (first activation or calendar switch) → create.
if (activity.status === 'confirmado' && lastSeenIds.has(eventId)) {
await cancelActivityFromGoogle(payload, req, activity)
reverseEdits += 1
continue
}
// C165 — imported activities are reconciled by their Google link above;
// a municipality-less activity waits for triage before entering the
// mirror (the original cut: untriaged events are not official agenda).
// The seen-and-gone rule above still applies to a municipality-less
// native activity whose mirrored event was removed in Google.
if (activity.googleEventId || activityMunicipalityIdOf(activity.municipality) === undefined) {
continue
}
const municipalityId = activityMunicipalityIdOf(activity.municipality)
const municipalityName = municipalityId ? municipalityNames.get(municipalityId) : undefined
await client.insertEvent(calendarId, buildGoogleEventPayload(activity, municipalityName))
// The created event NOW exists — the last-seen snapshot must include it,
// or the next pass would treat it as never-created and rebuild it.
remoteEventIds.add(eventId)
created += 1
}
await recordLastSeenSnapshot(payload, req, casToken, {
calendarId,
ids: [...remoteEventIds].filter((id) => !removedEventIds.has(id)),
})
// Only log if there were actual changes or errors
if (created > 0 || updated > 0 || deleted > 0 || reverseEdits > 0 || imported > 0) {
payload.logger.info(
`[GoogleCalendarSync] Passada concluída com mudanças: created=${created}, updated=${updated}, deleted=${deleted}, reverseEdits=${reverseEdits}, imported=${imported}, calendarId=${calendarId}`,
)
}
return { created, updated, deleted, reverseEdits, imported }
}
type SyncStatePatch = Partial<
Pick<
GoogleCalendarSyncDoc,
| 'lastSyncedAt'
| 'lastSuccessAt'
| 'lastErrorAt'
| 'lastError'
| 'oauthErrorAt'
| 'oauthError'
| 'pushChannelId'
| 'pushChannelResourceId'
| 'pushChannelExpiresAt'
| 'pushChannelSecret'
| 'pushChannelError'
>
>
/**
* C115-F1 — records the last-seen snapshot behind an optimistic
* compare-and-swap. The snapshot is READ-MODIFY-WRITE state: two passes can
* overlap (webhook vs activity hook on separate instances), and a stale pass
* recording LAST erases ids it never saw — the user's permanent removal then
* looks "never created" and the next pass rebuilds the event, un-doing the
* removal for good. The write therefore lands only while the sync row still
* carries the `updatedAt` this pass observed before reading its inputs
* (`casToken`, captured at the top of `runSyncPass` — that same doc is also
* the decision basis, keeping decisions and write on one consistent read).
* On a miss the stale view
* is dropped (debug-logged) — never-throw contract: the winner's fresher
* snapshot stands; a merely lagging one self-heals next pass (a present
* event is re-listed, not rebuilt). Residual window: the conditional update
* matches at query time (find-then-update), milliseconds wide against
* passes that run for seconds.
*/
const recordLastSeenSnapshot = async (
payload: Payload,
req: PayloadRequest | undefined,
casToken: GoogleCalendarSyncDoc | null,
snapshot: GoogleCalendarSyncSnapshot,
): Promise<void> => {
if (!casToken) return // the row vanished mid-pass — nothing to record onto
const result = await payload.update({
collection: 'googleCalendarSync',
where: {
and: [{ id: { equals: casToken.id } }, { updatedAt: { equals: casToken.updatedAt } }],
},
data: { lastSeenEventIds: snapshot },
depth: 0,
// Intentional admin bypass: the state fields are system-only by design.
overrideAccess: true,
req,
})
// CAS miss (docs.length === 0): another writer recorded state mid-pass —
// this pass's view lost the race and its snapshot is intentionally dropped.
if (result.docs.length === 0) {
payload.logger.debug(
'googleCalendarSync: snapshot descartado — outra passada gravou estado durante esta passada.',
)
}
}
const recordSyncState = async (
payload: Payload,
req: PayloadRequest | undefined,
patch: SyncStatePatch,
): Promise<void> => {
const doc = await loadGoogleCalendarSyncConfig(payload, req)
if (!doc) return // nothing to record before the first activation
await payload.update({
collection: 'googleCalendarSync',
id: doc.id,
data: patch,
depth: 0,
// Intentional admin bypass: the state fields are system-only by design.
overrideAccess: true,
req,
})
}
/** The webhook route uses this when Google reports the watched calendar is gone. */
export const recordGoogleCalendarSyncError = async (
payload: Payload,
message: string,
): Promise<void> => {
const at = new Date().toISOString()
await recordSyncState(payload, undefined, { lastErrorAt: at, lastError: message.slice(0, 500) })
}
/**
* C149 — persists the OAuth connection (the callback calls this after a
* successful code exchange). Creates the singleton connection row when the
* start action did not (fresh setup); clears every error field so the derived
* connection state recovers to `connected` by construction. The refresh token
* is system state, written with the admin bypass, and never returned here.
*/
export const recordGoogleCalendarOAuthConnection = async (
payload: Payload,
data: { refreshToken: string; scope: string | null },
req?: PayloadRequest,
): Promise<void> => {
const at = new Date().toISOString()
const patch = {
oauthRefreshToken: data.refreshToken,
oauthScope: data.scope,
oauthConnectedAt: at,
oauthErrorAt: null,
oauthError: null,
}
const doc = await loadGoogleCalendarSyncConfig(payload, req)
if (doc) {
await payload.update({
collection: 'googleCalendarSync',
id: doc.id,
data: patch,
depth: 0,
// Intentional admin bypass: the connection is system state, unreadable
// by user paths; the caller checks the actor before getting here.
overrideAccess: true,
req,
})
return
}
await payload.create({
collection: 'googleCalendarSync',
data: patch,
depth: 0,
// Intentional admin bypass: same rationale as above.
overrideAccess: true,
req,
})
}
/**
* C149 — records a failed handshake (code exchange) so the UI shows the
* connection `error` with the reconnect path. Never throws into the callback:
* the failure is already being surfaced by the redirect.
*/
export const recordGoogleCalendarOAuthError = async (
payload: Payload,
message: string,
): Promise<void> => {
const doc = await loadGoogleCalendarSyncConfig(payload)
if (!doc) return // nothing to record onto — the start action creates the row
const at = new Date().toISOString()
await payload.update({
collection: 'googleCalendarSync',
id: doc.id,
data: { oauthErrorAt: at, oauthError: message.slice(0, 500) },
depth: 0,
// Intentional admin bypass: connection state is system-only.
overrideAccess: true,
})
}
/**
* C149 — disconnect: drops the OAuth connection from the Teqo (the UI guides
* revoking the app access on the Google account). The service account, when
* configured, takes over on the next trigger — no sync pass is forced here.
*/
export const clearGoogleCalendarOAuthConnection = async (payload: Payload): Promise<void> => {
const doc = await loadGoogleCalendarSyncConfig(payload)
if (!doc) return
await payload.update({
collection: 'googleCalendarSync',
id: doc.id,
data: {
oauthRefreshToken: null,
oauthScope: null,
oauthConnectedAt: null,
oauthErrorAt: null,
oauthError: null,
},
depth: 0,
// Intentional admin bypass: the disconnect action checks the actor.
overrideAccess: true,
})
}
/** C150 — one calendar option of the primary-calendar picker. */
export type GoogleCalendarPickerOption = GoogleCalendarListEntry
export type GoogleCalendarPickerListResult =
| { ok: true; calendars: GoogleCalendarPickerOption[] }
| { ok: false; reason: 'not-connected' | 'auth-error' | 'api-error'; message: string }
export type GoogleCalendarPrimaryCalendarResult =
| { ok: true; calendarId: string }
| {
ok: false
reason: 'not-connected' | 'auth-error' | 'api-error' | 'not-listed'
message: string
}
const GOOGLE_CALENDAR_PICKER_NOT_CONNECTED_MESSAGE =
'Conecte a conta Google da campanha para listar e escolher o calendário principal.'
const GOOGLE_CALENDAR_PICKER_LIST_FAILED_MESSAGE =
'Não foi possível listar os calendários do Google agora. Tente de novo.'
const GOOGLE_CALENDAR_PICKER_NOT_LISTED_MESSAGE =
'O calendário escolhido não está mais disponível na conta Google conectada. Atualize a lista e escolha de novo.'
/**
* C150 — lists the calendars the connected account can WRITE to. The service
* account is deliberately refused: its own calendar list does not contain the
* calendars shared with it, so it cannot back a picker (the connection is the
* C149 hard dependency of this surface). A dead OAuth connection is recorded
* on the row (same classification as the engine) so the card derives `error`
* and offers Reconnect; every other failure is a transient API error.
*/
const loadWritableGoogleCalendars = async (
payload: Payload,
doc: GoogleCalendarSyncDoc | null,
client?: GoogleCalendarClient,
): Promise<GoogleCalendarPickerListResult> => {
const auth = readGoogleCalendarAuth(doc)
if (auth?.kind !== 'oauth') {
return {
ok: false,
reason: 'not-connected',
message: GOOGLE_CALENDAR_PICKER_NOT_CONNECTED_MESSAGE,
}
}
try {
return {
ok: true,
calendars: await (client ?? createGoogleCalendarClient(auth)).listCalendars(),
}
} catch (error) {
if (error instanceof GoogleCalendarAuthError) {
await recordGoogleCalendarOAuthError(payload, error.message)
return { ok: false, reason: 'auth-error', message: error.message }
}
return { ok: false, reason: 'api-error', message: GOOGLE_CALENDAR_PICKER_LIST_FAILED_MESSAGE }
}
}
/** The read model of the picker: the connected account's writable calendars. */
export const listGoogleCalendarPickerOptions = async (
payload: Payload,
options?: { client?: GoogleCalendarClient },
): Promise<GoogleCalendarPickerListResult> =>
loadWritableGoogleCalendars(payload, await loadGoogleCalendarSyncConfig(payload), options?.client)
/**
* C150 — sets the campaign's primary calendar (`calendarId`), the one the
* mirror writes to. The id is validated server-side against the connected
* account's LIVE writable list before the write, so the campaign surface can
* never point the mirror at an arbitrary string (the Payload admin keeps that
* escape hatch through the field's admin-only access). The write uses the
* admin bypass by design: the caller (campaign action) authorizes the actor
* and this utility owns the value validation. The `afterChange` config hook
* (D7) reconciles the mirror into the new calendar; the old one is left as-is
* (design C114 D7 — no backfill, no retro-cleanup).
*/
export const setCampaignPrimaryCalendarId = async (
payload: Payload,
calendarId: string,
options?: { client?: GoogleCalendarClient },
): Promise<GoogleCalendarPrimaryCalendarResult> => {
const doc = await loadGoogleCalendarSyncConfig(payload)
if (!doc) {
return {
ok: false,
reason: 'not-connected',
message: GOOGLE_CALENDAR_PICKER_NOT_CONNECTED_MESSAGE,
}
}
if (doc.calendarId === calendarId) return { ok: true, calendarId }
const listed = await loadWritableGoogleCalendars(payload, doc, options?.client)
if (!listed.ok) return listed
if (!listed.calendars.some((calendar) => calendar.id === calendarId)) {
return { ok: false, reason: 'not-listed', message: GOOGLE_CALENDAR_PICKER_NOT_LISTED_MESSAGE }
}
await payload.update({
collection: 'googleCalendarSync',
id: doc.id,
data: { calendarId },
depth: 0,
// Intentional admin bypass: `calendarId` is admin-only at the field level;
// the campaign path is authorized at the action and validated above.
overrideAccess: true,
})
return { ok: true, calendarId }
}
/** The push-channel webhook address; the URL secret IS the credential. */
const buildPushChannelAddress = (secret: string): string | null => {
try {
return `${getCampaignInviteBaseURL()}${GOOGLE_CALENDAR_WEBHOOK_PATH}${secret}`
} catch {
return null
}
}
const generateChannelSecret = (): string => randomBytes(32).toString('base64url')
/**
* C115 — D5: keeps a push channel alive on the configured calendar. Lazily
* ensures (create / renew / re-create after a calendar switch) on EVERY pass,
* so the channel renews itself through the existing triggers — no cron.
* Best-effort by contract: a channel failure is recorded in
* `pushChannelError` and NEVER pauses the mirror — the reverse direction also
* runs on every local trigger, the channel only buys immediacy.
*/
const ensureGoogleCalendarPushChannel = async (
payload: Payload,
req: PayloadRequest | undefined,
client: GoogleCalendarClient,
config: GoogleCalendarSyncDoc,
): Promise<void> => {
const calendarId = config.calendarId
if (!calendarId) return
// Fail fast when the site URL is missing (C98 lesson) — the real address is
// built inside the try with the fresh secret.
if (!buildPushChannelAddress('probe')) {
await recordSyncState(payload, req, {
pushChannelError: 'NEXT_PUBLIC_SITE_URL não configurada — impossível registrar o webhook.',
})
return
}
const snapshot = parseLastSeenSnapshot(config.lastSeenEventIds)
const channelWatchesCurrentCalendar = snapshot === null || snapshot.calendarId === calendarId
const expiringSoon =
config.pushChannelExpiresAt &&
new Date(config.pushChannelExpiresAt).getTime() < Date.now() + PUSH_CHANNEL_RENEW_LEAD_MS
const healthy = Boolean(config.pushChannelId && config.pushChannelResourceId) && !expiringSoon
if (healthy && channelWatchesCurrentCalendar) {
if (config.pushChannelError) {
await recordSyncState(payload, req, { pushChannelError: null })
}
// Only log on first successful check or after error recovery
if (config.pushChannelError) {
payload.logger.info(
`[GoogleCalendarSync] Canal de push recuperado: channelId=${config.pushChannelId}`,
)
}
return
}
try {
// A FRESH secret per channel creation (rotation): a leaked webhook URL
// self-heals on the next renewal, and the old channel — stopped below —
// dies with its URL.
const secret = generateChannelSecret()
const address = buildPushChannelAddress(secret)
if (!address) {
await recordSyncState(payload, req, {
pushChannelError: 'NEXT_PUBLIC_SITE_URL não configurada — impossível registrar o webhook.',
})
return
}
const channel = await client.watchEvents(calendarId, {
id: randomUUID(),
address,
token: secret,
ttlSeconds: PUSH_CHANNEL_TTL_SECONDS,
})
// Persist the NEW channel BEFORE stopping the old one: if the stop fails
// the replacement is already recorded (no orphaned live channel, no
// duplicate re-watch on the next pass) — the catch below only records
// the error.
await recordSyncState(payload, req, {
pushChannelId: channel.id,
pushChannelResourceId: channel.resourceId,
pushChannelExpiresAt: channel.expiration
? new Date(channel.expiration).toISOString()
: new Date(Date.now() + PUSH_CHANNEL_TTL_SECONDS * 1000).toISOString(),
pushChannelSecret: secret,
pushChannelError: null,
})
// Best-effort: the old channel is stopped once its replacement is live
// (Google allows an overlap period; duplicated notifications are
// idempotent). A failure here never loses the new channel.
if (config.pushChannelId && config.pushChannelResourceId) {
await client.stopChannel({
id: config.pushChannelId,
resourceId: config.pushChannelResourceId,
})
}
} catch (error) {
// C164 — a `fetch failed` TypeError carries the transport cause (DNS,
// timeout) on `error.cause`; the cause diagnoses an intermittent
// transport failure, so it rides along in `pushChannelError`. The base is
// truncated first: the cause is the diagnostic payload worth keeping.
const baseMessage =
error instanceof Error ? error.message : 'Erro desconhecido ao registrar o webhook do Google.'
const causeSuffix =
error instanceof Error && error.cause instanceof Error
? ` (causa: ${error.cause.message})`
: ''
const message = `${baseMessage.slice(0, 500 - causeSuffix.length)}${causeSuffix}`.slice(0, 500)
payload.logger.error(`[GoogleCalendarSync] Erro ao registrar canal de push: ${message}`)
await recordSyncState(payload, req, { pushChannelError: message })
}
}
/**
* C149 — pure classification for the engine's catch: only a dead OAuth
* connection (`GoogleCalendarAuthError`, i.e. `invalid_grant`) also flips the
* connection to `error`; every other failure stays `paused` without touching
* the connection. Exported for the unit pin (the engine catch is I/O-bound).
*/
export const oauthErrorPatchFor = (
error: unknown,
at: string,
): Pick<GoogleCalendarSyncDoc, 'oauthErrorAt' | 'oauthError'> | null =>
error instanceof GoogleCalendarAuthError
? { oauthErrorAt: at, oauthError: error.message.slice(0, 500) }
: null
export type CampaignCalendarSyncOutcome = SyncCounts & {
status: GoogleCalendarSyncStatus
at: string
}
export type CampaignCalendarSyncOptions = {
reason: 'create' | 'update' | 'delete' | 'config-change' | 'manual' | 'webhook'
client?: GoogleCalendarClient
req?: PayloadRequest
/** C114-LOCK: hook budget that bounds the row lock window (S11 parity). */
signal?: AbortSignal
/** Int tests stub the transport; the channel ensure needs it too — off in tests that don't want it. */
skipChannelEnsure?: boolean
/**
* Narrows the mirrored activity set (push + delete-guard). Production
* callers omit it — the official mirror is the full staff scope. Exists
* for scoped mirrors and test isolation (e.g. a spec that must not see
* activities other parallel specs create in the same database).
*/
activityWhere?: Where
}
export const runCampaignCalendarSync = async (
payload: Payload,
options: CampaignCalendarSyncOptions,
): Promise<CampaignCalendarSyncOutcome> => {
const config = await loadGoogleCalendarSyncConfig(payload, options.req)
const auth = readGoogleCalendarAuth(config)
const at = new Date().toISOString()
if (!auth || !config?.calendarId || config.disabledAt) {
const status = deriveGoogleCalendarSyncStatus({
hasCredential: auth !== null,
calendarId: config?.calendarId ?? null,
disabledAt: config?.disabledAt ?? null,
lastSuccessAt: config?.lastSuccessAt ?? null,
lastErrorAt: config?.lastErrorAt ?? null,
})
payload.logger.warn(
`[GoogleCalendarSync] Sincronização ignorada: credentialsPresent=${auth !== null}, calendarId=${config?.calendarId ?? 'null'}, disabledAt=${config?.disabledAt ?? 'null'}, status=${status}`,
)
return {
status,
created: 0,
updated: 0,
deleted: 0,
reverseEdits: 0,
imported: 0,
at,
}
}
// C114-LOCK: when the engine is injected with a stub `client` (int tests)
// the hook `signal` is not plumbed into that instance — the stub must be
// created with its own signal. Production hooks never inject a client, so
// the `??` branch covers the row-lock path.
const client = options.client ?? createGoogleCalendarClient(auth, undefined, options.signal)
try {
if (!options.skipChannelEnsure) {
await ensureGoogleCalendarPushChannel(payload, options.req, client, config)
}
const counts = await runSyncPass(
payload,
options.req,
client,
config.calendarId,
options.activityWhere,
config,
)
// C149 — a successful pass also clears a previous OAuth error: the
// connection state recovers to `connected` without a manual reconnect.
await recordSyncState(payload, options.req, {
lastSyncedAt: at,
lastSuccessAt: at,
oauthErrorAt: null,
oauthError: null,
})
return { status: 'synced', ...counts, at }
} catch (error) {
const message =
error instanceof Error ? error.message : 'Erro desconhecido ao sincronizar com o Google.'
payload.logger.error(`[GoogleCalendarSync] Erro durante a sincronização: ${message}`)
const patch: SyncStatePatch = {
lastSyncedAt: at,
lastErrorAt: at,
lastError: message.slice(0, 500),
}
// C149 — a dead OAuth connection (`invalid_grant`) additionally flips the
// derived connection state to `error`, which offers reconnect in one click.
Object.assign(patch, oauthErrorPatchFor(error, at) ?? {})
await recordSyncState(payload, options.req, patch)
return {
status: 'paused',
created: 0,
updated: 0,
deleted: 0,
reverseEdits: 0,
imported: 0,
at,
}
}
}
/**
* Normalizes a mirrored field for change detection. The afterChange `doc`
* carries populated relationships (municipality object) while `previousDoc`
* comes depth-0 (numeric id) — comparing raw values would flag EVERY update
* as changed and sync on every task toggle.
*/
const normalizeMirroredField = (doc: Activity | undefined, field: string): unknown => {
const raw = (doc as unknown as Record<string, unknown> | undefined)?.[field]
if (field === 'municipality') {
return activityMunicipalityIdOf(raw as unknown as Activity['municipality']) ?? null
}
if (field === 'tags') {
return Array.isArray(raw) ? [...raw].sort() : raw
}
return raw
}
/**
* Pure guard — the activity hook only runs a sync pass when a field the
* Google event mirrors changed (task toggles, updates and result records
* never trigger). Create/delete always sync.
*/
export const shouldSyncActivityOperation = ({
operation,
doc,
previousDoc,
}: {
operation: 'create' | 'update' | 'delete'
doc: Activity
previousDoc?: Activity
}): boolean => {
if (operation !== 'update') return true
return SYNC_RELEVANT_ACTIVITY_FIELDS.some(
(field) =>
JSON.stringify(normalizeMirroredField(doc, field)) !==
JSON.stringify(normalizeMirroredField(previousDoc, field)),
)
}
/**
* Pure guard — the config hook reconciles on calendarId change or re-enable
* (D7); state-only writes (the engine recording outcomes) never re-trigger,
* which is what breaks the would-be loop.
*/
export const shouldSyncConfigChange = ({
operation,
doc,
previousDoc,
}: {
operation: 'create' | 'update'
doc: GoogleCalendarSyncDoc
previousDoc?: GoogleCalendarSyncDoc
}): boolean => {
if (operation !== 'update') return true
const calendarChanged = (doc.calendarId ?? '') !== (previousDoc?.calendarId ?? '')
const disabledCleared = Boolean(previousDoc?.disabledAt) && !doc.disabledAt
return calendarChanged || disabledCleared
}
/**
* Activity hook (afterChange + afterDelete) and the `googleCalendarSync`
* config hook (D7 auto-resync) live in `googleCalendarSyncHooks.ts` — they
* delegate to the guards above and the engine, and are kept in a separate
* module so tests can mock the engine at the import boundary.
*/