diff --git a/Dockerfile b/Dockerfile
index e711b8d..cbc5331 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -5,4 +5,4 @@ RUN npm install --omit=dev
COPY . .
ENV NODE_ENV=production
EXPOSE 3000
-CMD ["node", "src/server.js"]
+CMD ["sh", "-c", "npm run migrate && node src/server.js"]
diff --git a/public/css/style.css b/public/css/style.css
index 769fd43..903b119 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -1528,6 +1528,13 @@ button.calendar-filters-close { display: none; }
.rehearsal-row:last-child { border-bottom: none; }
+.rehearsal-row.highlight {
+ outline: 2px solid var(--accent);
+ outline-offset: 4px;
+ border-radius: 4px;
+ transition: outline-color 3s ease;
+}
+
.rehearsal-actions {
display: flex;
align-items: center;
diff --git a/public/js/admin.js b/public/js/admin.js
index b117c36..5ed8796 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -189,6 +189,7 @@ async function loadSlotSettingsForm() {
form.marginMinutes.value = settings.marginMinutes;
form.concertStart.value = settings.concertStart;
form.concertEnd.value = settings.concertEnd;
+ form.rehearsalConfirmThreshold.value = settings.rehearsalConfirmThreshold;
}
async function onSaveSlotSettings(e) {
@@ -205,6 +206,7 @@ async function onSaveSlotSettings(e) {
marginMinutes: parseInt(form.marginMinutes.value, 10),
concertStart: form.concertStart.value,
concertEnd: form.concertEnd.value,
+ rehearsalConfirmThreshold: parseInt(form.rehearsalConfirmThreshold.value, 10),
});
statusEl.textContent = 'Horaires enregistrés.';
} catch (err) {
@@ -267,6 +269,14 @@ async function onSaveSlotSettings(e) {
Comme la date d'un concert n'a pas d'heure enregistrée, ces horaires sont utilisés pour
pré-remplir les liens « ajouter à mon agenda » sur /calendar.html et /concerts.html.
+ Une répétition proposée reste une « suggestion » tant que ce nombre de votes « accepter » + n'est pas atteint ; elle passe ensuite en répétition confirmée (dans l'application et dans le + flux ICS). +
diff --git a/public/js/calendar.js b/public/js/calendar.js index 564a3ce..70becf3 100644 --- a/public/js/calendar.js +++ b/public/js/calendar.js @@ -104,11 +104,17 @@ function concertLinksHtml(concert) { `; } +function rehearsalStatusLabel(r) { + if (r.status === 'confirmed') return 'Répétition confirmée'; + const accepted = r.votes.filter((v) => v.vote === 'accept').length; + return `Suggestion de répétition (${accepted}/${r.confirmThreshold} votes)`; +} + function rehearsalInfoHtml(r) { const linkArgs = { uid: `rehearsal-${r.id}`, title: 'Répétition Octane', startISO: r.startsAt, endISO: r.endsAt, location: r.location }; return `${r.location ? escapeHtml(r.location) + ' · ' : ''}${formatTime(r.startsAt)} – ${formatTime(r.endsAt)} · Proposée par ${escapeHtml(r.proposedByName)}
${rehearsalAcceptedByHtml(r)}+ Abonnez-vous à ce lien depuis Google Calendar, Apple Calendar ou Outlook pour voir les répétitions + (proposées et confirmées) directement dans votre calendrier personnel, mises à jour automatiquement. + Ce lien est personnel, ne le partagez pas. +
+Identité gérée par Authentik — pour changer votre email ou mot de passe, @@ -172,7 +207,9 @@ async function onResetDisplayName() { const resetBtn = document.getElementById('reset-display-name-btn'); if (resetBtn) resetBtn.addEventListener('click', onResetDisplayName); document.getElementById('add-my-feed-form').addEventListener('submit', onAddMyFeed); + document.getElementById('copy-ics-feed-btn').addEventListener('click', onCopyIcsFeedUrl); await loadMyFeeds(); + await loadIcsFeedUrl(); } catch (err) { showError(err.message); } diff --git a/src/app.js b/src/app.js index ef4a3dd..c4c1208 100644 --- a/src/app.js +++ b/src/app.js @@ -4,6 +4,7 @@ const sessionMiddleware = require('./auth/session'); const authRoutes = require('./auth/routes'); const { attachUser, requireAuth } = require('./auth/middleware'); const apiRouter = require('./routes'); +const calendarFeedRoutes = require('./routes/calendarFeed'); function createApp() { const app = express(); @@ -19,6 +20,10 @@ function createApp() { app.use('/auth', authRoutes); app.use(attachUser); + // Unauthenticated: subscribed to directly by calendar apps via a secret + // per-user token, not the browser session (see routes/calendarFeed.js). + app.use('/calendar/feed', calendarFeedRoutes); + app.use('/api', requireAuth, apiRouter); app.use(requireAuth, express.static(path.join(__dirname, '../public'))); diff --git a/src/db/migrations/018_rehearsal_ics_token.sql b/src/db/migrations/018_rehearsal_ics_token.sql new file mode 100644 index 0000000..5d04b86 --- /dev/null +++ b/src/db/migrations/018_rehearsal_ics_token.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN ics_token TEXT UNIQUE; diff --git a/src/db/migrations/019_rehearsal_confirm_threshold.sql b/src/db/migrations/019_rehearsal_confirm_threshold.sql new file mode 100644 index 0000000..63d408f --- /dev/null +++ b/src/db/migrations/019_rehearsal_confirm_threshold.sql @@ -0,0 +1 @@ +ALTER TABLE calendar_settings ADD COLUMN IF NOT EXISTS rehearsal_confirm_threshold SMALLINT NOT NULL DEFAULT 4; diff --git a/src/lib/icsFeed.js b/src/lib/icsFeed.js new file mode 100644 index 0000000..0420071 --- /dev/null +++ b/src/lib/icsFeed.js @@ -0,0 +1,91 @@ +const { computeRehearsalStatus } = require('./rehearsalStatus'); + +// Builds a text/calendar feed of rehearsals for subscription (webcal://) in +// external calendar apps. Timestamps are emitted in UTC (`...Z`) so no +// VTIMEZONE block is needed — every client renders them in its own zone. +function formatDateUTC(date) { + return new Date(date).toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; +} + +// Per RFC 5545 §3.3.11: backslash-escape commas, semicolons and backslashes, +// and turn newlines into the literal `\n` escape sequence. +function escapeText(text) { + return String(text) + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\r?\n/g, '\\n'); +} + +// Per RFC 5545 §3.1: content lines longer than 75 octets must be folded — +// split across multiple physical lines joined by CRLF + a single leading +// space. Without this, some clients (observed with Google Calendar) silently +// drop the whole property instead of just displaying it unwrapped. The split +// is done on UTF-8 byte boundaries (not JS string length) so multi-byte +// characters (e.g. accented names) are never cut in half. +function foldLine(line) { + const bytes = Buffer.from(line, 'utf8'); + if (bytes.length <= 75) return line; + + const chunks = []; + let start = 0; + let limit = 75; + while (start < bytes.length) { + let end = Math.min(start + limit, bytes.length); + while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end--; + chunks.push(bytes.slice(start, end).toString('utf8')); + start = end; + limit = 74; // continuation lines reserve 1 octet for the leading space + } + return chunks.map((chunk, i) => (i === 0 ? chunk : ` ${chunk}`)).join('\r\n'); +} + +function rehearsalToEvent(rehearsal, { threshold, baseUrl, allUsers }) { + const status = computeRehearsalStatus(rehearsal.votes, threshold); + const icsStatus = status === 'confirmed' ? 'CONFIRMED' : 'TENTATIVE'; + const summary = status === 'confirmed' ? 'Répétition' : 'Répétition (proposition)'; + const voteUrl = `${baseUrl}/calendar.html?rehearsalId=${rehearsal.id}`; + + const accepted = rehearsal.votes.filter((v) => v.vote === 'accept').map((v) => v.name); + const refused = rehearsal.votes.filter((v) => v.vote === 'reject').map((v) => v.name); + const votedUserIds = new Set(rehearsal.votes.map((v) => v.userId)); + const pending = allUsers.filter((u) => !votedUserIds.has(u.id)).map((u) => u.name); + + const description = [ + `Votez ici : ${voteUrl}`, + accepted.length ? `Ont accepté : ${accepted.join(', ')}` : "Personne n'a encore accepté.", + refused.length ? `Ont refusé : ${refused.join(', ')}` : "Personne n'a refusé.", + pending.length ? `En attente : ${pending.join(', ')}` : "Tout le monde a voté.", + ].join('\n'); + + const lines = [ + 'BEGIN:VEVENT', + `UID:rehearsal-${rehearsal.id}@octane`, + `DTSTAMP:${formatDateUTC(rehearsal.created_at || new Date())}`, + `DTSTART:${formatDateUTC(rehearsal.starts_at)}`, + `DTEND:${formatDateUTC(rehearsal.ends_at)}`, + `SUMMARY:${escapeText(summary)}`, + `STATUS:${icsStatus}`, + `DESCRIPTION:${escapeText(description)}`, + `URL:${voteUrl}`, + ]; + if (rehearsal.location) lines.push(`LOCATION:${escapeText(rehearsal.location)}`); + lines.push('END:VEVENT'); + return lines; +} + +function buildRehearsalsFeed(rehearsals, { threshold, baseUrl, allUsers }) { + const lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Octane//Rehearsals//FR', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + 'X-WR-CALNAME:Répétitions Octane', + ...rehearsals.flatMap((r) => rehearsalToEvent(r, { threshold, baseUrl, allUsers })), + 'END:VCALENDAR', + ]; + return lines.map(foldLine).join('\r\n'); +} + +module.exports = { buildRehearsalsFeed }; diff --git a/src/lib/rehearsalStatus.js b/src/lib/rehearsalStatus.js new file mode 100644 index 0000000..4f908e1 --- /dev/null +++ b/src/lib/rehearsalStatus.js @@ -0,0 +1,8 @@ +// Shared between the API (rehearsals list) and the ICS feed so both agree on +// when a proposed rehearsal counts as confirmed. +function computeRehearsalStatus(votes, threshold) { + const acceptCount = votes.filter((v) => v.vote === 'accept').length; + return acceptCount >= threshold ? 'confirmed' : 'suggested'; +} + +module.exports = { computeRehearsalStatus }; diff --git a/src/repositories/calendarRepo.js b/src/repositories/calendarRepo.js index 7a656ee..8d5f9ef 100644 --- a/src/repositories/calendarRepo.js +++ b/src/repositories/calendarRepo.js @@ -205,7 +205,7 @@ function parseTime(hhmmss) { async function getSlotSettings() { const { rows } = await pool.query( - 'SELECT weekday_start, weekday_end, weekend_start, weekend_end, margin_minutes, concert_start, concert_end FROM calendar_settings WHERE id = 1' + 'SELECT weekday_start, weekday_end, weekend_start, weekend_end, margin_minutes, concert_start, concert_end, rehearsal_confirm_threshold FROM calendar_settings WHERE id = 1' ); const row = rows[0]; const weekdayStart = parseTime(row.weekday_start); @@ -219,17 +219,18 @@ async function getSlotSettings() { weekend: { startHour: weekendStart.hour, startMinute: weekendStart.minute, endHour: weekendEnd.hour, endMinute: weekendEnd.minute }, marginMinutes: row.margin_minutes, concert: { startHour: concertStart.hour, startMinute: concertStart.minute, endHour: concertEnd.hour, endMinute: concertEnd.minute }, + rehearsalConfirmThreshold: row.rehearsal_confirm_threshold, }; } -async function updateSlotSettings({ weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd }) { +async function updateSlotSettings({ weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd, rehearsalConfirmThreshold }) { const { rows } = await pool.query( `UPDATE calendar_settings SET weekday_start = $1, weekday_end = $2, weekend_start = $3, weekend_end = $4, margin_minutes = $5, - concert_start = $6, concert_end = $7 + concert_start = $6, concert_end = $7, rehearsal_confirm_threshold = $8 WHERE id = 1 - RETURNING weekday_start, weekday_end, weekend_start, weekend_end, margin_minutes, concert_start, concert_end`, - [weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd] + RETURNING weekday_start, weekday_end, weekend_start, weekend_end, margin_minutes, concert_start, concert_end, rehearsal_confirm_threshold`, + [weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd, rehearsalConfirmThreshold] ); return rows[0]; } diff --git a/src/repositories/usersRepo.js b/src/repositories/usersRepo.js index 97f59a3..ae1f350 100644 --- a/src/repositories/usersRepo.js +++ b/src/repositories/usersRepo.js @@ -1,3 +1,4 @@ +const crypto = require('crypto'); const pool = require('../db/pool'); const PROFILE_FIELDS = @@ -72,4 +73,39 @@ async function findAllWithActivity() { return rows; } -module.exports = { findById, upsertFromClaims, updateDisplayName, getActivityStats, findAllWithActivity }; +// Lazily creates the user's rehearsal-feed secret on first request, rather +// than at signup — most users will never subscribe to the ICS feed. +async function ensureIcsToken(userId) { + const { rows } = await pool.query('SELECT ics_token FROM users WHERE id = $1', [userId]); + if (rows[0] && rows[0].ics_token) return rows[0].ics_token; + + const token = crypto.randomBytes(24).toString('hex'); + const { rows: updated } = await pool.query( + 'UPDATE users SET ics_token = $2 WHERE id = $1 RETURNING ics_token', + [userId, token] + ); + return updated[0].ics_token; +} + +async function findByIcsToken(token) { + const { rows } = await pool.query(`SELECT ${PROFILE_FIELDS} FROM users WHERE ics_token = $1`, [token]); + return rows[0] || null; +} + +// Minimal id/name list — used to work out who *hasn't* voted on a rehearsal +// yet (the ICS feed's "en attente" list), so no need for the full profile. +async function findAllNames() { + const { rows } = await pool.query('SELECT id, name FROM users ORDER BY name'); + return rows; +} + +module.exports = { + findById, + upsertFromClaims, + updateDisplayName, + getActivityStats, + findAllWithActivity, + ensureIcsToken, + findByIcsToken, + findAllNames, +}; diff --git a/src/routes/calendar.js b/src/routes/calendar.js index 0754023..d2ddca8 100644 --- a/src/routes/calendar.js +++ b/src/routes/calendar.js @@ -187,6 +187,7 @@ router.get( marginMinutes: settings.marginMinutes, concertStart: formatTime(settings.concert.startHour, settings.concert.startMinute), concertEnd: formatTime(settings.concert.endHour, settings.concert.endMinute), + rehearsalConfirmThreshold: settings.rehearsalConfirmThreshold, }); }) ); @@ -213,7 +214,7 @@ router.patch( '/settings', requireAdmin, asyncHandler(async (req, res) => { - const { weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd } = req.body || {}; + const { weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd, rehearsalConfirmThreshold } = req.body || {}; const times = { weekdayStart, weekdayEnd, weekendStart, weekendEnd, concertStart, concertEnd }; for (const [key, value] of Object.entries(times)) { if (!TIME_RE.test(value || '')) { @@ -233,8 +234,12 @@ router.patch( if (!Number.isInteger(margin) || margin < 0 || margin > 180) { return res.status(400).json({ error: 'invalid_margin_minutes' }); } + const threshold = Number(rehearsalConfirmThreshold); + if (!Number.isInteger(threshold) || threshold < 1 || threshold > 50) { + return res.status(400).json({ error: 'invalid_rehearsal_confirm_threshold' }); + } - const values = { ...times, marginMinutes: margin }; + const values = { ...times, marginMinutes: margin, rehearsalConfirmThreshold: threshold }; await calendarRepo.updateSlotSettings(values); res.json(values); }) diff --git a/src/routes/calendarFeed.js b/src/routes/calendarFeed.js new file mode 100644 index 0000000..98ad226 --- /dev/null +++ b/src/routes/calendarFeed.js @@ -0,0 +1,33 @@ +// Public (unauthenticated) route: calendar apps subscribing to a webcal:// +// link don't send our session cookie, so this can't sit behind requireAuth. +// The per-user token in the URL is the only access control — see +// usersRepo.ensureIcsToken / findByIcsToken. +const express = require('express'); +const usersRepo = require('../repositories/usersRepo'); +const rehearsalsRepo = require('../repositories/rehearsalsRepo'); +const calendarRepo = require('../repositories/calendarRepo'); +const { buildRehearsalsFeed } = require('../lib/icsFeed'); +const asyncHandler = require('../lib/asyncHandler'); + +const router = express.Router(); + +router.get( + '/rehearsals/:token.ics', + asyncHandler(async (req, res) => { + const user = await usersRepo.findByIcsToken(req.params.token); + if (!user) return res.status(404).send('Not found'); + + const [rehearsals, settings, allUsers] = await Promise.all([ + rehearsalsRepo.findUpcoming(), + calendarRepo.getSlotSettings(), + usersRepo.findAllNames(), + ]); + // req.protocol honors X-Forwarded-Proto here — see app.set('trust proxy', 1) in app.js. + const baseUrl = `${req.protocol}://${req.get('host')}`; + res + .type('text/calendar; charset=utf-8') + .send(buildRehearsalsFeed(rehearsals, { threshold: settings.rehearsalConfirmThreshold, baseUrl, allUsers })); + }) +); + +module.exports = router; diff --git a/src/routes/rehearsals.js b/src/routes/rehearsals.js index d8b9835..7d58d7a 100644 --- a/src/routes/rehearsals.js +++ b/src/routes/rehearsals.js @@ -1,14 +1,17 @@ const express = require('express'); const rehearsalsRepo = require('../repositories/rehearsalsRepo'); +const calendarRepo = require('../repositories/calendarRepo'); const discord = require('../lib/discord'); const asyncHandler = require('../lib/asyncHandler'); +const { computeRehearsalStatus } = require('../lib/rehearsalStatus'); const router = express.Router(); router.get( '/', asyncHandler(async (req, res) => { - const rehearsals = await rehearsalsRepo.findUpcoming(); + const [rehearsals, settings] = await Promise.all([rehearsalsRepo.findUpcoming(), calendarRepo.getSlotSettings()]); + const threshold = settings.rehearsalConfirmThreshold; res.json( rehearsals.map((r) => ({ id: r.id, @@ -18,6 +21,8 @@ router.get( proposedBy: r.proposed_by, proposedByName: r.proposed_by_name, votes: r.votes, + status: computeRehearsalStatus(r.votes, threshold), + confirmThreshold: threshold, })) ); }) diff --git a/src/routes/users.js b/src/routes/users.js index a5bf971..920dca0 100644 --- a/src/routes/users.js +++ b/src/routes/users.js @@ -48,6 +48,14 @@ router.get( }) ); +router.get( + '/me/ics-feed-url', + asyncHandler(async (req, res) => { + const token = await usersRepo.ensureIcsToken(req.user.id); + res.json({ path: `/calendar/feed/rehearsals/${token}.ics` }); + }) +); + router.patch( '/me/display-name', asyncHandler(async (req, res) => {