mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-15 02:23:07 +00:00
878 lines
31 KiB
TypeScript
878 lines
31 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,
|
|
decodeGoogleEventActivityId,
|
|
GOOGLE_EVENT_STATUS_CANCELLED,
|
|
googleEventContentEquals,
|
|
googleEventIdForActivity,
|
|
googleStartEndInstantEquals,
|
|
type GoogleRemoteEvent,
|
|
} from '@/lib/googleCalendarEventMapping'
|
|
import {
|
|
buildGoogleReverseCancelBody,
|
|
buildGoogleReverseUpdateBody,
|
|
googleEditIsNewer,
|
|
googleScheduleToActivityFields,
|
|
googleTitleFromSummary,
|
|
type GoogleReverseActivityEdit,
|
|
} from '@/lib/googleCalendarReverseEdit'
|
|
import {
|
|
buildActivityWindowRange,
|
|
buildActivityWindowWhereClauses,
|
|
loadMunicipalityNames,
|
|
} from '@/utilities/calendarFeed'
|
|
import { getCampaignInviteBaseURL } from '@/utilities/campaignInviteOrigin'
|
|
import {
|
|
createGoogleCalendarClient,
|
|
GoogleCalendarApiError,
|
|
type GoogleCalendarClient,
|
|
type GoogleCalendarCredentials,
|
|
} from '@/utilities/googleCalendarClient'
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
|
|
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
|
|
/**
|
|
* 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 GoogleCalendarSyncView = {
|
|
status: GoogleCalendarSyncStatus
|
|
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'
|
|
}
|
|
|
|
/**
|
|
* 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 = (): GoogleCalendarCredentials | null => {
|
|
const raw = process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV]
|
|
if (!raw) 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 }
|
|
}
|
|
} catch {
|
|
// malformed credential — treated as absent (fail-closed, no sync attempts)
|
|
}
|
|
return 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 configured row wins: a stray empty
|
|
// row (staff can no longer create one, but legacy rows may exist) must
|
|
// never shadow the operational calendar (single-row invariant). The
|
|
// webhook route and the engine rely on this bypass for the channel
|
|
// identity fields, which no user path may read.
|
|
const result = await payload.find({
|
|
collection: 'googleCalendarSync',
|
|
limit: 1,
|
|
pagination: false,
|
|
depth: 0,
|
|
where: { calendarId: { exists: true } },
|
|
overrideAccess: true,
|
|
req,
|
|
})
|
|
return result.docs[0] ?? null
|
|
}
|
|
|
|
const docToView = (
|
|
doc: GoogleCalendarSyncDoc | null,
|
|
hasCredential: boolean,
|
|
): GoogleCalendarSyncView => ({
|
|
status: deriveGoogleCalendarSyncStatus({
|
|
hasCredential,
|
|
calendarId: doc?.calendarId ?? null,
|
|
disabledAt: doc?.disabledAt ?? null,
|
|
lastSuccessAt: doc?.lastSuccessAt ?? null,
|
|
lastErrorAt: doc?.lastErrorAt ?? 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, credentials] = await Promise.all([
|
|
loadGoogleCalendarSyncConfig(payload),
|
|
readGoogleServiceAccountCredentials(),
|
|
])
|
|
return docToView(doc, credentials !== 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 }
|
|
|
|
/**
|
|
* 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)
|
|
|
|
/**
|
|
* 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> => {
|
|
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,
|
|
)
|
|
|
|
const snapshot = parseLastSeenSnapshot(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
|
|
|
|
// 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 recordSyncState(payload, req, {
|
|
lastSeenEventIds: {
|
|
calendarId,
|
|
ids: [...remoteEventIds].filter((id) => !removedEventIds.has(id)),
|
|
},
|
|
})
|
|
|
|
return { created, updated, deleted, reverseEdits }
|
|
}
|
|
|
|
type SyncStatePatch = Partial<
|
|
Pick<
|
|
GoogleCalendarSyncDoc,
|
|
| 'lastSyncedAt'
|
|
| 'lastSuccessAt'
|
|
| 'lastErrorAt'
|
|
| 'lastError'
|
|
| 'lastSeenEventIds'
|
|
| 'pushChannelId'
|
|
| 'pushChannelResourceId'
|
|
| 'pushChannelExpiresAt'
|
|
| 'pushChannelSecret'
|
|
| 'pushChannelError'
|
|
>
|
|
>
|
|
|
|
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) })
|
|
}
|
|
|
|
/** 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 })
|
|
}
|
|
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) {
|
|
const message =
|
|
error instanceof Error ? error.message : 'Erro desconhecido ao registrar o webhook do Google.'
|
|
await recordSyncState(payload, req, { pushChannelError: message.slice(0, 500) })
|
|
}
|
|
}
|
|
|
|
export type CampaignCalendarSyncOutcome = SyncCounts & {
|
|
status: GoogleCalendarSyncStatus
|
|
at: string
|
|
}
|
|
|
|
export type CampaignCalendarSyncOptions = {
|
|
reason: 'create' | 'update' | 'delete' | 'config-change' | 'manual' | 'webhook'
|
|
client?: GoogleCalendarClient
|
|
req?: PayloadRequest
|
|
/** 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 credentials = readGoogleServiceAccountCredentials()
|
|
const config = await loadGoogleCalendarSyncConfig(payload, options.req)
|
|
const at = new Date().toISOString()
|
|
|
|
if (!credentials || !config?.calendarId || config.disabledAt) {
|
|
return {
|
|
status: deriveGoogleCalendarSyncStatus({
|
|
hasCredential: credentials !== null,
|
|
calendarId: config?.calendarId ?? null,
|
|
disabledAt: config?.disabledAt ?? null,
|
|
lastSuccessAt: config?.lastSuccessAt ?? null,
|
|
lastErrorAt: config?.lastErrorAt ?? null,
|
|
}),
|
|
created: 0,
|
|
updated: 0,
|
|
deleted: 0,
|
|
reverseEdits: 0,
|
|
at,
|
|
}
|
|
}
|
|
|
|
const client = options.client ?? createGoogleCalendarClient(credentials)
|
|
|
|
try {
|
|
if (!options.skipChannelEnsure) {
|
|
await ensureGoogleCalendarPushChannel(payload, options.req, client, config)
|
|
}
|
|
const counts = await runSyncPass(
|
|
payload,
|
|
options.req,
|
|
client,
|
|
config.calendarId,
|
|
options.activityWhere,
|
|
config,
|
|
)
|
|
await recordSyncState(payload, options.req, { lastSyncedAt: at, lastSuccessAt: at })
|
|
return { status: 'synced', ...counts, at }
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : 'Erro desconhecido ao sincronizar com o Google.'
|
|
await recordSyncState(payload, options.req, {
|
|
lastSyncedAt: at,
|
|
lastErrorAt: at,
|
|
lastError: message.slice(0, 500),
|
|
})
|
|
return { status: 'paused', created: 0, updated: 0, deleted: 0, reverseEdits: 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.
|
|
*/
|