mirror of
https://github.com/nfonteyne/octane-website.git
synced 2026-11-03 06:59:32 +01:00
update: update google feed
This commit is contained in:
parent
d0e9dfb62e
commit
3d56ee0a2e
4 changed files with 36 additions and 5 deletions
12
src/db/migrations/021_rehearsal_updated_at.sql
Normal file
12
src/db/migrations/021_rehearsal_updated_at.sql
Normal 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)
|
||||
);
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue