mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
- lastMirroredChangeAt (activity, migration): clock rule compara contra a última mudança espelhada, não updatedAt (task toggle não silencia edição pendente do Google) - snapshot exclui ids que o motor deletou (removedEventIds separado do remoteEventIds): reopen pós-cancelamento não vira re-cancelamento fantasma - cancelamento do Google respeita o relógio: cancel mais novo cancela; Teqo mais novo (reopen) limpa o trashed e recria - escrita reversa sem req.user: autoria do registro correta + sem gate 403 do deputado; hook de sync pula a própria escrita reversa (sem passada aninhada) - canal: segredo rotaciona por criação; novo canal persistido antes do stop do antigo; identity fields read:false; create admin-only; leitura prefere linha configurada; pre-filter do webhook antes do bootstrap do Payload - testes: all-day ida-e-volta, task-toggle, reopen, slug congelado, e2e dos caminhos 200, render tests do dialog/feed; asserções herméticas a atividades ambiente dos arquivos irmãos (DB compartilhado)
200 lines
7.4 KiB
TypeScript
200 lines
7.4 KiB
TypeScript
/**
|
||
* C114 — pure mapping between campaign activities and Google Calendar events.
|
||
*
|
||
* The Google event id is DETERMINISTIC (`teqo` + base32hex of the activity
|
||
* id): Google ids only allow base32hex characters (lowercase a–v, digits 0–9)
|
||
* — no underscores, no uppercase — and the deterministic id makes the whole
|
||
* reconciliation stateless (upsert by id, content-compare to decide updates).
|
||
* Teqo stays the single source of truth; canceled activities are excluded by
|
||
* the engine (their Google event is deleted), never rendered in Google.
|
||
*
|
||
* This module is pure and client-safe; only the engine/client in
|
||
* `src/utilities/googleCalendarSync.ts` touches the network.
|
||
*/
|
||
import type { Activity } from '@/payload-types'
|
||
|
||
import { allDayCivilDateOf, allDayExclusiveEndDate } from '@/lib/activityAllDay'
|
||
import { buildActivityDescriptionParts } from '@/lib/activityDescription'
|
||
|
||
const GOOGLE_EVENT_ID_PREFIX = 'teqo'
|
||
|
||
/** RFC 2938 base32hex alphabet (0–9, a–v) — the only charset Google event ids accept. */
|
||
const BASE32HEX_ALPHABET = '0123456789abcdefghijklmnopqrstuv'
|
||
|
||
/**
|
||
* Brazil standard time is fixed UTC−03:00 (no DST since 2019), so the offset
|
||
* is a constant — the same invariant `campaignTime` builds on.
|
||
*/
|
||
const BAHIA_FIXED_OFFSET_HOURS = -3
|
||
const BAHIA_FIXED_OFFSET_SUFFIX = '-03:00'
|
||
|
||
/**
|
||
* Google requires `end` strictly after `start` on timed events; an activity
|
||
* without `endAt` (a point-in-time commitment) becomes a 1-hour event — the
|
||
* iCal feed mirrors this as DTEND = DTSTART, which Google cannot represent.
|
||
*/
|
||
const DEFAULT_TIMED_EVENT_DURATION_MINUTES = 60
|
||
|
||
export const googleEventIdForActivity = (activityId: number): string => {
|
||
if (!Number.isInteger(activityId) || activityId < 0) {
|
||
throw new Error('ID de atividade inválido para evento do Google.')
|
||
}
|
||
|
||
let remainder = activityId
|
||
let encoded = ''
|
||
do {
|
||
encoded = BASE32HEX_ALPHABET[remainder % 32] + encoded
|
||
remainder = Math.floor(remainder / 32)
|
||
} while (remainder > 0)
|
||
|
||
return `${GOOGLE_EVENT_ID_PREFIX}${encoded}`
|
||
}
|
||
|
||
/** Inverse of `googleEventIdForActivity` — null for foreign/invalid ids. */
|
||
export const decodeGoogleEventActivityId = (eventId: string): number | null => {
|
||
if (!eventId.startsWith(GOOGLE_EVENT_ID_PREFIX)) return null
|
||
const encoded = eventId.slice(GOOGLE_EVENT_ID_PREFIX.length)
|
||
if (encoded.length === 0) return null
|
||
|
||
let value = 0
|
||
for (const char of encoded) {
|
||
const digit = BASE32HEX_ALPHABET.indexOf(char)
|
||
if (digit === -1) return null
|
||
value = value * 32 + digit
|
||
if (!Number.isSafeInteger(value)) return null
|
||
}
|
||
return value
|
||
}
|
||
|
||
/**
|
||
* UTC ISO instant → RFC 3339 dateTime in Bahia wall time with the fixed
|
||
* −03:00 offset (what the Calendar API expects; the timeZone field stays
|
||
* unnecessary because the offset is explicit).
|
||
*/
|
||
const formatBahiaOffsetDateTime = (iso: string): string => {
|
||
const shifted = new Date(new Date(iso).getTime() + BAHIA_FIXED_OFFSET_HOURS * 3_600_000)
|
||
const parts = shifted.toISOString().slice(0, 19).split('T')
|
||
const [date, time] = parts
|
||
const [year, month, day] = date.split('-')
|
||
const [hour, minute, second] = time.split(':')
|
||
return `${year}-${month}-${day}T${hour}:${minute}:${second}${BAHIA_FIXED_OFFSET_SUFFIX}`
|
||
}
|
||
|
||
const addMinutes = (iso: string, minutes: number): string =>
|
||
new Date(new Date(iso).getTime() + minutes * 60_000).toISOString()
|
||
|
||
export const activityMunicipalityIdOf = (
|
||
municipality: Activity['municipality'],
|
||
): number | undefined => (typeof municipality === 'number' ? municipality : municipality?.id)
|
||
|
||
export type GoogleCalendarEventPayload = {
|
||
id: string
|
||
summary: string
|
||
description: string
|
||
location?: string
|
||
start: { dateTime: string } | { date: string }
|
||
end: { dateTime: string } | { date: string }
|
||
}
|
||
|
||
/**
|
||
* Event description without unnecessary PII: municipality, locality, tags and
|
||
* the deputy flag — the same parts the iCal feed renders, joined with real
|
||
* newlines for Google. No leadership names, no phones, no emails.
|
||
*/
|
||
export const buildGoogleCalendarDescription = (
|
||
activity: Pick<Activity, 'locality' | 'tags' | 'deputyPresent'>,
|
||
municipalityName?: string,
|
||
): string => buildActivityDescriptionParts(activity, municipalityName).join('\n')
|
||
|
||
export const buildGoogleEventPayload = (
|
||
activity: Pick<
|
||
Activity,
|
||
'id' | 'title' | 'startAt' | 'endAt' | 'allDay' | 'locality' | 'tags' | 'deputyPresent'
|
||
>,
|
||
municipalityName?: string,
|
||
): GoogleCalendarEventPayload => {
|
||
// C14 guarantees startAt beyond draft; a null start has no Google shape —
|
||
// fail loudly in the mapping (the engine never feeds one: the window query
|
||
// only returns dated activities).
|
||
if (!activity.startAt) {
|
||
throw new Error('Atividade sem data de início não pode virar evento do Google.')
|
||
}
|
||
|
||
const summary = municipalityName ? `[${municipalityName}] ${activity.title}` : activity.title
|
||
const description = buildGoogleCalendarDescription(activity, municipalityName)
|
||
|
||
const allDay = Boolean(activity.allDay)
|
||
const start = allDay
|
||
? { date: allDayCivilDateOf(activity.startAt) }
|
||
: { dateTime: formatBahiaOffsetDateTime(activity.startAt) }
|
||
const end = allDay
|
||
? { date: allDayExclusiveEndDate(activity.endAt ?? activity.startAt) }
|
||
: {
|
||
dateTime: formatBahiaOffsetDateTime(
|
||
activity.endAt ?? addMinutes(activity.startAt, DEFAULT_TIMED_EVENT_DURATION_MINUTES),
|
||
),
|
||
}
|
||
|
||
const locality = activity.locality?.trim()
|
||
const location = locality || municipalityName || undefined
|
||
|
||
return {
|
||
id: googleEventIdForActivity(activity.id),
|
||
summary,
|
||
description,
|
||
...(location ? { location } : {}),
|
||
start,
|
||
end,
|
||
}
|
||
}
|
||
|
||
/** The remote shape the Calendar API echoes on list/insert — a narrow view of it. */
|
||
export type GoogleRemoteEvent = {
|
||
id?: string
|
||
summary?: string
|
||
description?: string
|
||
location?: string
|
||
status?: string
|
||
updated?: string
|
||
start?: { dateTime?: string; date?: string; timeZone?: string }
|
||
end?: { dateTime?: string; date?: string; timeZone?: string }
|
||
}
|
||
|
||
/** Google's own event lifecycle status — `cancelled` is the trash signal. */
|
||
export const GOOGLE_EVENT_STATUS_CANCELLED = 'cancelled'
|
||
|
||
const startEndInstantEquals = (
|
||
remote: { dateTime?: string; date?: string } | undefined,
|
||
wanted: GoogleCalendarEventPayload['start'],
|
||
): boolean => {
|
||
if (!remote) return false
|
||
if ('dateTime' in wanted) {
|
||
// Compare absolute instants: the calendar's own timezone may echo the
|
||
// dateTime with a different offset — the instant is what we control.
|
||
const remoteMs = Date.parse(remote.dateTime ?? '')
|
||
const wantedMs = Date.parse(wanted.dateTime)
|
||
return !Number.isNaN(remoteMs) && !Number.isNaN(wantedMs) && remoteMs === wantedMs
|
||
}
|
||
return remote.date === wanted.date
|
||
}
|
||
|
||
/**
|
||
* Same instant comparison, exported for the engine's editable-fields check
|
||
* (C115 reverse direction) — single implementation of the compare.
|
||
*/
|
||
export { startEndInstantEquals as googleStartEndInstantEquals }
|
||
|
||
/**
|
||
* True when the remote event already carries exactly what we would write —
|
||
* the decision rule that makes repeated reconciliation passes converge
|
||
* without touching Google (no update churn, no modified-date spam).
|
||
*/
|
||
export const googleEventContentEquals = (
|
||
remote: GoogleRemoteEvent,
|
||
payload: GoogleCalendarEventPayload,
|
||
): boolean =>
|
||
(remote.summary ?? '') === payload.summary &&
|
||
(remote.description ?? '') === payload.description &&
|
||
(remote.location ?? '') === (payload.location ?? '') &&
|
||
startEndInstantEquals(remote.start, payload.start) &&
|
||
startEndInstantEquals(remote.end, payload.end)
|