diff --git a/Dockerfile b/Dockerfile
index cbc5331..e711b8d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -5,4 +5,4 @@ RUN npm install --omit=dev
COPY . .
ENV NODE_ENV=production
EXPOSE 3000
-CMD ["sh", "-c", "npm run migrate && node src/server.js"]
+CMD ["node", "src/server.js"]
diff --git a/public/css/style.css b/public/css/style.css
index 903b119..769fd43 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -1528,13 +1528,6 @@ 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 5ed8796..b117c36 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -189,7 +189,6 @@ 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) {
@@ -206,7 +205,6 @@ 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) {
@@ -269,14 +267,6 @@ 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 70becf3..564a3ce 100644 --- a/public/js/calendar.js +++ b/public/js/calendar.js @@ -104,17 +104,11 @@ 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, @@ -207,9 +172,7 @@ 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 c4c1208..ef4a3dd 100644 --- a/src/app.js +++ b/src/app.js @@ -4,7 +4,6 @@ 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(); @@ -20,10 +19,6 @@ 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 deleted file mode 100644 index 5d04b86..0000000 --- a/src/db/migrations/018_rehearsal_ics_token.sql +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 63d408f..0000000 --- a/src/db/migrations/019_rehearsal_confirm_threshold.sql +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 0420071..0000000 --- a/src/lib/icsFeed.js +++ /dev/null @@ -1,91 +0,0 @@ -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 deleted file mode 100644 index 4f908e1..0000000 --- a/src/lib/rehearsalStatus.js +++ /dev/null @@ -1,8 +0,0 @@ -// 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 8d5f9ef..7a656ee 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, rehearsal_confirm_threshold FROM calendar_settings WHERE id = 1' + 'SELECT weekday_start, weekday_end, weekend_start, weekend_end, margin_minutes, concert_start, concert_end FROM calendar_settings WHERE id = 1' ); const row = rows[0]; const weekdayStart = parseTime(row.weekday_start); @@ -219,18 +219,17 @@ 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, rehearsalConfirmThreshold }) { +async function updateSlotSettings({ weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd }) { 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, rehearsal_confirm_threshold = $8 + concert_start = $6, concert_end = $7 WHERE id = 1 - 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] + RETURNING weekday_start, weekday_end, weekend_start, weekend_end, margin_minutes, concert_start, concert_end`, + [weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd] ); return rows[0]; } diff --git a/src/repositories/usersRepo.js b/src/repositories/usersRepo.js index ae1f350..97f59a3 100644 --- a/src/repositories/usersRepo.js +++ b/src/repositories/usersRepo.js @@ -1,4 +1,3 @@ -const crypto = require('crypto'); const pool = require('../db/pool'); const PROFILE_FIELDS = @@ -73,39 +72,4 @@ async function findAllWithActivity() { return rows; } -// 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, -}; +module.exports = { findById, upsertFromClaims, updateDisplayName, getActivityStats, findAllWithActivity }; diff --git a/src/routes/calendar.js b/src/routes/calendar.js index d2ddca8..0754023 100644 --- a/src/routes/calendar.js +++ b/src/routes/calendar.js @@ -187,7 +187,6 @@ router.get( marginMinutes: settings.marginMinutes, concertStart: formatTime(settings.concert.startHour, settings.concert.startMinute), concertEnd: formatTime(settings.concert.endHour, settings.concert.endMinute), - rehearsalConfirmThreshold: settings.rehearsalConfirmThreshold, }); }) ); @@ -214,7 +213,7 @@ router.patch( '/settings', requireAdmin, asyncHandler(async (req, res) => { - const { weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd, rehearsalConfirmThreshold } = req.body || {}; + const { weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd } = req.body || {}; const times = { weekdayStart, weekdayEnd, weekendStart, weekendEnd, concertStart, concertEnd }; for (const [key, value] of Object.entries(times)) { if (!TIME_RE.test(value || '')) { @@ -234,12 +233,8 @@ 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, rehearsalConfirmThreshold: threshold }; + const values = { ...times, marginMinutes: margin }; await calendarRepo.updateSlotSettings(values); res.json(values); }) diff --git a/src/routes/calendarFeed.js b/src/routes/calendarFeed.js deleted file mode 100644 index 98ad226..0000000 --- a/src/routes/calendarFeed.js +++ /dev/null @@ -1,33 +0,0 @@ -// 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 7d58d7a..d8b9835 100644 --- a/src/routes/rehearsals.js +++ b/src/routes/rehearsals.js @@ -1,17 +1,14 @@ 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, settings] = await Promise.all([rehearsalsRepo.findUpcoming(), calendarRepo.getSlotSettings()]); - const threshold = settings.rehearsalConfirmThreshold; + const rehearsals = await rehearsalsRepo.findUpcoming(); res.json( rehearsals.map((r) => ({ id: r.id, @@ -21,8 +18,6 @@ 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 920dca0..a5bf971 100644 --- a/src/routes/users.js +++ b/src/routes/users.js @@ -48,14 +48,6 @@ 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) => {