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, ): 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, 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 => { 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> => { 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() 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 } } }