teqo-backup/src/utilities/calendarFeed.ts

311 lines
10 KiB
TypeScript

import 'server-only'
import { createHash } from 'node:crypto'
import { allDayCivilDateOf, allDayExclusiveEndDate } from '@/lib/activityAllDay'
import { buildActivityDescriptionParts } from '@/lib/activityDescription'
import { escapeICalText, foldICalLine, formatICalDate } from '@/lib/ical'
import type { Activity, CalendarFeed } from '@/payload-types'
import { advisorMunicipalityScopeWhere } from '@/utilities/access/shared'
import type { Payload, Where } from 'payload'
const CALENDAR_NAME = 'Agenda Teqo'
const FEED_LOOKBACK_DAYS = 90
const FEED_LOOKAHEAD_DAYS = 365
// C113: the feed must never be the link that holds stale content — any shared
// cache (CDN, subscriber proxies) may store the response but MUST revalidate
// before reuse (`no-cache`). `X-PUBLISHED-TTL` is the iCal freshness hint
// (RFC 7986; emitted in the VCALENDAR body — Google publishes it as PT1H).
const FEED_CACHE_CONTROL = 'public, no-cache'
const FEED_PUBLISHED_TTL = 'PT1H'
const buildActivityDescription = (activity: Activity, municipalityName?: string): string =>
// iCal escapes newlines as literal `\n` (backslash + n).
buildActivityDescriptionParts(activity, municipalityName).join('\\n')
export const generateICalFeed = (
activities: Activity[],
feedLabel: string,
municipalityNames: Map<number, string>,
): string => {
const lines: string[] = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Teqo//Agenda//PT',
`X-WR-CALNAME:${escapeICalText(feedLabel || CALENDAR_NAME)}`,
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
`X-PUBLISHED-TTL:${FEED_PUBLISHED_TTL}`,
]
for (const activity of activities) {
if (activity.status === 'cancelado') continue
if (!activity.startAt) continue
const municipalityId =
typeof activity.municipality === 'number' ? activity.municipality : activity.municipality?.id
const municipalityName = municipalityId ? municipalityNames.get(municipalityId) : undefined
const dtStart = formatICalDate(activity.startAt)
const dtEnd = activity.endAt ? formatICalDate(activity.endAt) : dtStart
// C104 — all-day commitments export as date-only values (VALUE=DATE) with
// the exclusive end (day after the last day), the iCal all-day convention.
const allDay = Boolean(activity.allDay)
const dtStartLine = allDay
? `DTSTART;VALUE=DATE:${formatICalDate(allDayCivilDateOf(activity.startAt))}`
: `DTSTART:${dtStart}`
const dtEndLine = allDay
? `DTEND;VALUE=DATE:${formatICalDate(allDayExclusiveEndDate(activity.endAt ?? activity.startAt))}`
: `DTEND:${activity.endAt ? dtEnd : dtStart}`
const summary = municipalityName ? `[${municipalityName}] ${activity.title}` : activity.title
lines.push('BEGIN:VEVENT')
lines.push(`UID:${activity.slug}@teqo.jorgesolla.com.br`)
lines.push(dtStartLine)
lines.push(dtEndLine)
lines.push(`SUMMARY:${escapeICalText(summary)}`)
lines.push(`DESCRIPTION:${buildActivityDescription(activity, municipalityName)}`)
lines.push(`DTSTAMP:${formatICalDate(activity.updatedAt || activity.createdAt)}`)
lines.push('END:VEVENT')
}
lines.push('END:VCALENDAR')
return lines.map(foldICalLine).join('\r\n')
}
const computeFeedLastModified = (activities: Activity[], feedUpdatedAt?: string): string => {
const timestamps = [
...activities.flatMap((activity) =>
[activity.updatedAt, activity.createdAt].filter((value): value is string => Boolean(value)),
),
...(feedUpdatedAt ? [feedUpdatedAt] : []),
]
const latest = timestamps.length
? timestamps.reduce((max, value) => (value > max ? value : max))
: new Date().toISOString()
return new Date(latest).toUTCString()
}
// C113: the response contract of the live feed. The ETag (strong, from the
// body hash) is the authoritative validator and the ONLY 304 trigger —
// `If-Modified-Since` alone never answers 304, because deleting an activity
// removes its row and would leave `Last-Modified` stale (a date-based 304
// could hide the removed event). The body hash always changes with content.
export const buildICalFeedResponse = (
icalContent: string,
activities: Activity[],
feed: Pick<CalendarFeed, 'updatedAt'>,
request: Request,
): Response => {
const etag = `"${createHash('sha256').update(icalContent).digest('hex')}"`
const lastModified = computeFeedLastModified(activities, feed.updatedAt)
const ifNoneMatch = request.headers.get('if-none-match')
const matchesEtag =
ifNoneMatch === '*' ||
(ifNoneMatch
?.split(',')
.map((value) => value.trim().replace(/^W\//, ''))
.includes(etag) ??
false)
if (matchesEtag) {
return new Response(null, {
status: 304,
headers: {
'Cache-Control': FEED_CACHE_CONTROL,
ETag: etag,
'Last-Modified': lastModified,
},
})
}
return new Response(icalContent, {
status: 200,
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
'Cache-Control': FEED_CACHE_CONTROL,
ETag: etag,
'Last-Modified': lastModified,
},
})
}
/** The shared activity window (past lookback + future lookahead) of the feed and the Google mirror. */
export const buildActivityWindowRange = (): { rangeStart: string; rangeEnd: string } => {
const now = new Date()
const rangeStart = new Date(now)
rangeStart.setDate(rangeStart.getDate() - FEED_LOOKBACK_DAYS)
const rangeEnd = new Date(now)
rangeEnd.setDate(rangeEnd.getDate() + FEED_LOOKAHEAD_DAYS)
return {
rangeStart: rangeStart.toISOString(),
rangeEnd: rangeEnd.toISOString(),
}
}
/**
* The activity window (past lookback + future lookahead) and its base where
* clauses — shared by the iCal feed and the C114 Google mirror so both
* surfaces keep the SAME horizon by definition. The feed adds its per-feed
* filters (municipality/deputy/tag + advisor scope) on top.
* C165 — `includeCancelled` drops only the `status != cancelado` clause: the
* imported-events pass needs the cancelled activities to re-assert their
* cancellation on the same remote event.
*/
export const buildActivityWindowWhereClauses = (
rangeStart: string,
rangeEnd: string,
options: { includeCancelled?: boolean } = {},
): Where[] => [
{ startAt: { less_than: rangeEnd } },
{
or: [
{ endAt: { greater_than: rangeStart } },
{
and: [{ endAt: { exists: false } }, { startAt: { greater_than_equal: rangeStart } }],
},
],
},
...(options.includeCancelled ? [] : [{ status: { not_equals: 'cancelado' as const } }]),
]
const buildFeedWhere = (
feed: CalendarFeed,
rangeStart: string,
rangeEnd: string,
accessibleMunicipalityIds: number[] | null,
): Where => {
const filters: Where[] = buildActivityWindowWhereClauses(rangeStart, rangeEnd)
if (feed.filterMunicipality) {
const municipalityId =
typeof feed.filterMunicipality === 'number'
? feed.filterMunicipality
: feed.filterMunicipality.id
filters.push({ municipality: { equals: municipalityId } })
}
// Defense in depth (C96): an advisor's feed is intersected with the
// municipality ids the creator currently administers, so a feed (pinned or
// not) stops serving a municipality the advisor was removed from. The creator's
// read scope is re-derived on every request — never trusted from write time.
// `null` = coordinator/candidate/admin (unrestricted).
if (accessibleMunicipalityIds) {
filters.push(advisorMunicipalityScopeWhere('municipality', accessibleMunicipalityIds))
}
if (feed.filterDeputyPresent) {
filters.push({ deputyPresent: { equals: true } })
}
if (feed.filterTag) {
filters.push({ tags: { contains: feed.filterTag } })
}
return { and: filters }
}
export const loadFeedActivities = async (
payload: Payload,
feed: CalendarFeed,
accessibleMunicipalityIds: number[] | null,
): Promise<Activity[]> => {
const { rangeStart, rangeEnd } = buildActivityWindowRange()
const where = buildFeedWhere(feed, rangeStart, rangeEnd, accessibleMunicipalityIds)
// Intentional admin bypass: access is already enforced at the feed level
// (creator's municipality scope is applied to the where clause).
const result = await payload.find({
collection: 'activity',
depth: 1,
limit: 0,
pagination: false,
sort: 'startAt',
where,
overrideAccess: true,
})
return result.docs
}
export const loadMunicipalityNames = async (
payload: Payload,
ids: number[],
): Promise<Map<number, string>> => {
if (ids.length === 0) return new Map()
// Intentional admin bypass: municipality names are public metadata,
// not PII; the feed endpoint already scoped the ids to the creator's access.
const result = await payload.find({
collection: 'municipality',
depth: 0,
limit: 0,
pagination: false,
where: { id: { in: ids } },
overrideAccess: true,
})
const names = new Map<number, string>()
for (const doc of result.docs) {
names.set(doc.id, doc.name)
}
return names
}
export const resolveFeedCreatorAccess = async (
payload: Payload,
feed: CalendarFeed,
): Promise<{
accessible: boolean
municipalityIds: number[] | null
}> => {
const creatorId = typeof feed.createdBy === 'number' ? feed.createdBy : feed.createdBy?.id
if (!creatorId) {
return { accessible: false, municipalityIds: null }
}
try {
// Intentional admin bypass: the feed endpoint has no authenticated user,
// so we must reload the creator to verify their account still exists.
const creator = await payload.findByID({
collection: 'campaignUser',
id: creatorId,
depth: 0,
overrideAccess: true,
})
if (!creator) {
return { accessible: false, municipalityIds: null }
}
if (creator.role === 'coordinator' || creator.role === 'candidate') {
return { accessible: true, municipalityIds: null }
}
if (creator.role === 'advisor') {
// Intentional admin bypass: advisor municipality assignments are
// internal metadata; the feed endpoint re-derives scope from them.
const municipalities = await payload.find({
collection: 'municipality',
depth: 0,
limit: 0,
pagination: false,
where: { advisors: { equals: creatorId } },
overrideAccess: true,
})
return {
accessible: true,
municipalityIds: municipalities.docs.map((m) => m.id),
}
}
return { accessible: false, municipalityIds: null }
} catch {
return { accessible: false, municipalityIds: null }
}
}