mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
1565 lines
58 KiB
TypeScript
1565 lines
58 KiB
TypeScript
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.
|
||
*/
|