update: update google feed
Some checks failed
CI/CD / test (push) Has been cancelled
CI/CD / build-and-push (push) Has been cancelled

This commit is contained in:
Nathan FONTEYNE 2026-09-14 14:11:30 +02:00
parent d0e9dfb62e
commit 3d56ee0a2e
4 changed files with 36 additions and 5 deletions

View file

@ -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)
);

View file

@ -42,7 +42,7 @@ function foldLine(line) {
// Returns null for a rejected rehearsal — those are dropped entirely from the // Returns null for a rejected rehearsal — those are dropped entirely from the
// shared feed (see buildRehearsalsFeed) rather than shown as tentative/cancelled. // 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); const status = computeRehearsalStatus(rehearsal.votes, threshold, hostUserId);
if (status === 'rejected') return null; if (status === 'rejected') return null;
const icsStatus = status === 'confirmed' ? 'CONFIRMED' : 'TENTATIVE'; 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é.", pending.length ? `En attente : ${pending.join(', ')}` : "Tout le monde a voté.",
].join('\n'); ].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 = [ const lines = [
'BEGIN:VEVENT', 'BEGIN:VEVENT',
`UID:rehearsal-${rehearsal.id}@octane`, `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)}`, `DTSTART:${formatDateUTC(rehearsal.starts_at)}`,
`DTEND:${formatDateUTC(rehearsal.ends_at)}`, `DTEND:${formatDateUTC(rehearsal.ends_at)}`,
`SUMMARY:${escapeText(summary)}`, `SUMMARY:${escapeText(summary)}`,
@ -77,7 +86,7 @@ function rehearsalToEvent(rehearsal, { threshold, hostUserId, baseUrl, allUsers
return lines; return lines;
} }
function buildRehearsalsFeed(rehearsals, { threshold, hostUserId, baseUrl, allUsers }) { function buildRehearsalsFeed(rehearsals, { threshold, hostUserId, baseUrl, allUsers, now = new Date() }) {
const lines = [ const lines = [
'BEGIN:VCALENDAR', 'BEGIN:VCALENDAR',
'VERSION:2.0', 'VERSION:2.0',
@ -86,7 +95,7 @@ function buildRehearsalsFeed(rehearsals, { threshold, hostUserId, baseUrl, allUs
'METHOD:PUBLISH', 'METHOD:PUBLISH',
'X-WR-CALNAME:Répétitions Octane', 'X-WR-CALNAME:Répétitions Octane',
...rehearsals ...rehearsals
.map((r) => rehearsalToEvent(r, { threshold, hostUserId, baseUrl, allUsers })) .map((r) => rehearsalToEvent(r, { threshold, hostUserId, baseUrl, allUsers, now }))
.filter(Boolean) .filter(Boolean)
.flat(), .flat(),
'END:VCALENDAR', 'END:VCALENDAR',

View file

@ -2,7 +2,7 @@ const pool = require('../db/pool');
async function findUpcoming() { async function findUpcoming() {
const { rows } = await pool.query(` 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( COALESCE(
json_agg( json_agg(
json_build_object('userId', v.user_id, 'name', vu.name, 'avatarUrl', vu.avatar_url, 'vote', v.vote) 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]); 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) { async function upsertVote(rehearsalId, userId, vote) {
const { rows } = await pool.query( const { rows } = await pool.query(
`INSERT INTO rehearsal_votes (rehearsal_id, user_id, vote) `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`, RETURNING id, rehearsal_id, user_id, vote, created_at, updated_at`,
[rehearsalId, userId, vote] [rehearsalId, userId, vote]
); );
await touch(rehearsalId);
return rows[0]; return rows[0];
} }
@ -56,6 +63,7 @@ async function removeVote(rehearsalId, userId) {
rehearsalId, rehearsalId,
userId, userId,
]); ]);
await touch(rehearsalId);
} }
module.exports = { findUpcoming, findById, create, remove, upsertVote, removeVote }; module.exports = { findUpcoming, findById, create, remove, upsertVote, removeVote };

View file

@ -26,6 +26,8 @@ router.get(
const baseUrl = `${req.protocol}://${req.get('host')}`; const baseUrl = `${req.protocol}://${req.get('host')}`;
res res
.type('text/calendar; charset=utf-8') .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, { .send(buildRehearsalsFeed(rehearsals, {
threshold: settings.rehearsalConfirmThreshold, threshold: settings.rehearsalConfirmThreshold,
hostUserId: settings.rehearsalHostUserId, hostUserId: settings.rehearsalHostUserId,