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

150 lines
5.8 KiB
TypeScript

/**
* C115 — pure mapping from a Google Calendar event edit back to the campaign
* activity fields: the reverse of `googleCalendarEventMapping`. The Google
* event is never authoritative — the engine applies these values to the
* activity only when the clock rule says the Google edit is newer, and every
* other field (municipality, tags, locality, description…) stays Teqo-owned.
*
* This module is pure and client-safe; only the engine touches the network.
*/
import {
allDayEndInstantFromExclusive,
allDayStartInstant,
formatAllDayRangeLabel,
isCivilDate,
} from '@/lib/activityAllDay'
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
import type { GoogleRemoteEvent } from '@/lib/googleCalendarEventMapping'
/** Body prefix that marks an activity-update entry as written by the reverse direction. */
export const GOOGLE_REVERSE_EDIT_BODY_PREFIX = 'Google Calendar:'
/** The v1 editable fields (título/horário/cancelamento) — nothing else propagates. */
export type GoogleReverseActivityEdit = {
title?: string
startAt?: string | null
endAt?: string | null
allDay?: boolean | null
}
/** Matches the `title` field cap in the Activity collection — the same bound the Teqo form enforces. */
const ACTIVITY_TITLE_MAX_LENGTH = 160
/**
* D3 — the conflict clock rule: the Google edit wins only when its `updated`
* instant is strictly newer than the activity's last Teqo write, with a small
* tolerance for NTP skew between Google and the database (both are
* NTP-synced). Content equality is checked BEFORE this rule, so repeated
* passes converge without ever reaching it.
*/
export const googleEditIsNewer = (
remoteUpdated: string | undefined,
activityUpdatedAt: string | undefined,
toleranceMs = 2_000,
): boolean => {
if (!remoteUpdated || !activityUpdatedAt) return false
const remoteMs = Date.parse(remoteUpdated)
const localMs = Date.parse(activityUpdatedAt)
if (Number.isNaN(remoteMs) || Number.isNaN(localMs)) return false
return remoteMs > localMs + toleranceMs
}
/**
* The summary carries a `[Município] ` prefix we wrote; the user edits what
* follows it. Only OUR OWN prefix is stripped (matched against the activity's
* municipality name); anything else is the user's title verbatim — and the
* forward direction re-prefixes it consistently. A summary that is ONLY the
* prefix is structural tampering → null → the Teqo re-asserts.
*/
export const googleTitleFromSummary = (
summary: string | undefined,
municipalityName?: string,
): string | null => {
const raw = summary?.trim()
if (!raw) return null
const stripped =
municipalityName && raw.startsWith(`[${municipalityName}]`)
? raw.slice(municipalityName.length + 2).trim()
: raw
if (!stripped) return null
return stripped.length > ACTIVITY_TITLE_MAX_LENGTH
? stripped.slice(0, ACTIVITY_TITLE_MAX_LENGTH)
: stripped
}
const parseInstant = (value: string | undefined): string | null => {
if (!value) return null
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString()
}
/**
* Event start/end → activity schedule. `date` (all-day) maps through the
* `activityAllDay` conventions; `dateTime` (timed) maps by absolute instant.
* Malformed values (missing end, exclusive end not after start, unparseable)
* → null: the Teqo keeps its state and the forward direction re-asserts
* (fail-safe — a bad Google value never corrupts the activity).
*/
export const googleScheduleToActivityFields = (
event: Pick<GoogleRemoteEvent, 'start' | 'end'>,
): Pick<GoogleReverseActivityEdit, 'startAt' | 'endAt' | 'allDay'> | null => {
const start = event.start
if (!start) return null
if (start.date) {
if (!isCivilDate(start.date)) return null
const startAt = allDayStartInstant(start.date)
let endAt = startAt
if (event.end?.date) {
if (!isCivilDate(event.end.date) || event.end.date <= start.date) return null
endAt = allDayEndInstantFromExclusive(event.end.date)
}
return { startAt, endAt, allDay: true }
}
if (start.dateTime) {
const startAt = parseInstant(start.dateTime)
if (!startAt || !event.end?.dateTime) return null
const endAt = parseInstant(event.end.dateTime)
if (!endAt) return null
return { startAt, endAt, allDay: false }
}
return null
}
const scheduleLabelOf = (
schedule: Pick<GoogleReverseActivityEdit, 'startAt' | 'endAt' | 'allDay'>,
): string | null => {
const { startAt, endAt, allDay } = schedule
if (!startAt) return null
if (allDay) return formatAllDayRangeLabel(startAt, endAt ?? null)
const start = formatBahiaDateTimeLabel(startAt)
if (endAt && endAt !== startAt) return `${start} — ${formatBahiaDateTimeLabel(endAt)}`
return start
}
/**
* The audit entry ("com registro"): a concise pt-BR line describing what the
* Google edit changed, prefixed for the feed to attribute it to Google.
*/
export const buildGoogleReverseUpdateBody = (
previous: Pick<GoogleReverseActivityEdit, 'title' | 'startAt' | 'endAt' | 'allDay'>,
next: Pick<GoogleReverseActivityEdit, 'title' | 'startAt' | 'endAt' | 'allDay'>,
): string => {
const parts: string[] = []
if (next.title !== undefined && next.title !== previous.title) {
parts.push(`título alterado para "${next.title}"`)
}
const previousSchedule = scheduleLabelOf(previous)
const nextSchedule = scheduleLabelOf(next)
if (nextSchedule && nextSchedule !== previousSchedule) {
parts.push(`remarcada — antes ${previousSchedule}, agora ${nextSchedule}`)
}
if (parts.length === 0) return `${GOOGLE_REVERSE_EDIT_BODY_PREFIX} compromisso atualizado`
return `${GOOGLE_REVERSE_EDIT_BODY_PREFIX} ${parts.join('; ')}`
}
/** The cancellation record — the reverse direction reuses it for the updates feed. */
export const buildGoogleReverseCancelBody = (): string =>
`${GOOGLE_REVERSE_EDIT_BODY_PREFIX} cancelada`