From 3d56ee0a2ee356750881cf74db1e4ea171ad3539 Mon Sep 17 00:00:00 2001 From: Nathan FONTEYNE Date: Mon, 14 Sep 2026 14:11:30 +0200 Subject: [PATCH] update: update google feed --- src/db/migrations/021_rehearsal_updated_at.sql | 12 ++++++++++++ src/lib/icsFeed.js | 17 +++++++++++++---- src/repositories/rehearsalsRepo.js | 10 +++++++++- src/routes/calendarFeed.js | 2 ++ 4 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 src/db/migrations/021_rehearsal_updated_at.sql diff --git a/src/db/migrations/021_rehearsal_updated_at.sql b/src/db/migrations/021_rehearsal_updated_at.sql new file mode 100644 index 0000000..c34d849 --- /dev/null +++ b/src/db/migrations/021_rehearsal_updated_at.sql @@ -0,0 +1,12 @@ +-- Tracks the last content change of a rehearsal (creation or any vote +-- change), so the ICS feed can emit LAST-MODIFIED / SEQUENCE values that +-- actually move. Without that, calendar apps (Google Calendar in particular) +-- match events by UID and never refresh the description/status of an event +-- they already imported. +ALTER TABLE rehearsals ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT now(); + +UPDATE rehearsals r +SET updated_at = GREATEST( + r.created_at, + COALESCE((SELECT MAX(v.updated_at) FROM rehearsal_votes v WHERE v.rehearsal_id = r.id), r.created_at) +); diff --git a/src/lib/icsFeed.js b/src/lib/icsFeed.js index bafc5b1..2dd4aa4 100644 --- a/src/lib/icsFeed.js +++ b/src/lib/icsFeed.js @@ -42,7 +42,7 @@ function foldLine(line) { // Returns null for a rejected rehearsal — those are dropped entirely from the // shared feed (see buildRehearsalsFeed) rather than shown as tentative/cancelled. -function rehearsalToEvent(rehearsal, { threshold, hostUserId, baseUrl, allUsers }) { +function rehearsalToEvent(rehearsal, { threshold, hostUserId, baseUrl, allUsers, now }) { const status = computeRehearsalStatus(rehearsal.votes, threshold, hostUserId); if (status === 'rejected') return null; const icsStatus = status === 'confirmed' ? 'CONFIRMED' : 'TENTATIVE'; @@ -61,10 +61,19 @@ function rehearsalToEvent(rehearsal, { threshold, hostUserId, baseUrl, allUsers pending.length ? `En attente : ${pending.join(', ')}` : "Tout le monde a voté.", ].join('\n'); + // Calendar apps match events by UID across refreshes and only replace what + // they already imported when LAST-MODIFIED / SEQUENCE moved forward. Both + // are derived from updated_at (bumped on every vote change) so a rehearsal + // whose votes changed after the first import actually gets refreshed. + // DTSTAMP is the feed generation time, as RFC 5545 §3.8.7.2 prescribes for + // METHOD:PUBLISH. + const updatedAt = new Date(rehearsal.updated_at || rehearsal.created_at || now); const lines = [ 'BEGIN:VEVENT', `UID:rehearsal-${rehearsal.id}@octane`, - `DTSTAMP:${formatDateUTC(rehearsal.created_at || new Date())}`, + `DTSTAMP:${formatDateUTC(now)}`, + `LAST-MODIFIED:${formatDateUTC(updatedAt)}`, + `SEQUENCE:${Math.floor(updatedAt.getTime() / 1000)}`, `DTSTART:${formatDateUTC(rehearsal.starts_at)}`, `DTEND:${formatDateUTC(rehearsal.ends_at)}`, `SUMMARY:${escapeText(summary)}`, @@ -77,7 +86,7 @@ function rehearsalToEvent(rehearsal, { threshold, hostUserId, baseUrl, allUsers return lines; } -function buildRehearsalsFeed(rehearsals, { threshold, hostUserId, baseUrl, allUsers }) { +function buildRehearsalsFeed(rehearsals, { threshold, hostUserId, baseUrl, allUsers, now = new Date() }) { const lines = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', @@ -86,7 +95,7 @@ function buildRehearsalsFeed(rehearsals, { threshold, hostUserId, baseUrl, allUs 'METHOD:PUBLISH', 'X-WR-CALNAME:Répétitions Octane', ...rehearsals - .map((r) => rehearsalToEvent(r, { threshold, hostUserId, baseUrl, allUsers })) + .map((r) => rehearsalToEvent(r, { threshold, hostUserId, baseUrl, allUsers, now })) .filter(Boolean) .flat(), 'END:VCALENDAR', diff --git a/src/repositories/rehearsalsRepo.js b/src/repositories/rehearsalsRepo.js index 6a7c7b2..ec52233 100644 --- a/src/repositories/rehearsalsRepo.js +++ b/src/repositories/rehearsalsRepo.js @@ -2,7 +2,7 @@ const pool = require('../db/pool'); async function findUpcoming() { const { rows } = await pool.query(` - SELECT r.id, r.starts_at, r.ends_at, r.location, r.proposed_by, u.name AS proposed_by_name, r.created_at, + SELECT r.id, r.starts_at, r.ends_at, r.location, r.proposed_by, u.name AS proposed_by_name, r.created_at, r.updated_at, COALESCE( json_agg( json_build_object('userId', v.user_id, 'name', vu.name, 'avatarUrl', vu.avatar_url, 'vote', v.vote) @@ -39,6 +39,12 @@ async function remove(id) { await pool.query('DELETE FROM rehearsals WHERE id = $1', [id]); } +// Any vote change bumps rehearsals.updated_at: the ICS feed derives +// LAST-MODIFIED / SEQUENCE from it so subscribed calendars pick up the change. +async function touch(rehearsalId) { + await pool.query('UPDATE rehearsals SET updated_at = now() WHERE id = $1', [rehearsalId]); +} + async function upsertVote(rehearsalId, userId, vote) { const { rows } = await pool.query( `INSERT INTO rehearsal_votes (rehearsal_id, user_id, vote) @@ -48,6 +54,7 @@ async function upsertVote(rehearsalId, userId, vote) { RETURNING id, rehearsal_id, user_id, vote, created_at, updated_at`, [rehearsalId, userId, vote] ); + await touch(rehearsalId); return rows[0]; } @@ -56,6 +63,7 @@ async function removeVote(rehearsalId, userId) { rehearsalId, userId, ]); + await touch(rehearsalId); } module.exports = { findUpcoming, findById, create, remove, upsertVote, removeVote }; diff --git a/src/routes/calendarFeed.js b/src/routes/calendarFeed.js index a3daf42..d260467 100644 --- a/src/routes/calendarFeed.js +++ b/src/routes/calendarFeed.js @@ -26,6 +26,8 @@ router.get( const baseUrl = `${req.protocol}://${req.get('host')}`; res .type('text/calendar; charset=utf-8') + // Never let a proxy/CDN hand a stale copy to a calendar app's refresh. + .set('Cache-Control', 'no-cache, no-store, must-revalidate') .send(buildRehearsalsFeed(rehearsals, { threshold: settings.rehearsalConfirmThreshold, hostUserId: settings.rehearsalHostUserId,