mirror of
https://github.com/nfonteyne/octane-website.git
synced 2026-09-03 23:24:48 +02:00
Compare commits
No commits in common. "b056af78acff3aceb8cb1c3918afc9c35561b4bd" and "fd81b745942530fb36166eabb7174615f5fc8f6a" have entirely different histories.
b056af78ac
...
fd81b74594
16 changed files with 13 additions and 283 deletions
|
|
@ -5,4 +5,4 @@ RUN npm install --omit=dev
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["sh", "-c", "npm run migrate && node src/server.js"]
|
CMD ["node", "src/server.js"]
|
||||||
|
|
|
||||||
|
|
@ -1528,13 +1528,6 @@ button.calendar-filters-close { display: none; }
|
||||||
|
|
||||||
.rehearsal-row:last-child { border-bottom: 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 {
|
.rehearsal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,6 @@ async function loadSlotSettingsForm() {
|
||||||
form.marginMinutes.value = settings.marginMinutes;
|
form.marginMinutes.value = settings.marginMinutes;
|
||||||
form.concertStart.value = settings.concertStart;
|
form.concertStart.value = settings.concertStart;
|
||||||
form.concertEnd.value = settings.concertEnd;
|
form.concertEnd.value = settings.concertEnd;
|
||||||
form.rehearsalConfirmThreshold.value = settings.rehearsalConfirmThreshold;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSaveSlotSettings(e) {
|
async function onSaveSlotSettings(e) {
|
||||||
|
|
@ -206,7 +205,6 @@ async function onSaveSlotSettings(e) {
|
||||||
marginMinutes: parseInt(form.marginMinutes.value, 10),
|
marginMinutes: parseInt(form.marginMinutes.value, 10),
|
||||||
concertStart: form.concertStart.value,
|
concertStart: form.concertStart.value,
|
||||||
concertEnd: form.concertEnd.value,
|
concertEnd: form.concertEnd.value,
|
||||||
rehearsalConfirmThreshold: parseInt(form.rehearsalConfirmThreshold.value, 10),
|
|
||||||
});
|
});
|
||||||
statusEl.textContent = 'Horaires enregistrés.';
|
statusEl.textContent = 'Horaires enregistrés.';
|
||||||
} catch (err) {
|
} 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
|
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 <code>/calendar.html</code> et <code>/concerts.html</code>.
|
pré-remplir les liens « ajouter à mon agenda » sur <code>/calendar.html</code> et <code>/concerts.html</code>.
|
||||||
</p>
|
</p>
|
||||||
<label>Seuil de confirmation d'une répétition (votes « accepter »)
|
|
||||||
<input type="number" name="rehearsalConfirmThreshold" min="1" max="50" required>
|
|
||||||
</label>
|
|
||||||
<p class="note">
|
|
||||||
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).
|
|
||||||
</p>
|
|
||||||
<p class="note" id="slot-settings-status"></p>
|
<p class="note" id="slot-settings-status"></p>
|
||||||
<button type="submit">Enregistrer</button>
|
<button type="submit">Enregistrer</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
function rehearsalInfoHtml(r) {
|
||||||
const linkArgs = { uid: `rehearsal-${r.id}`, title: 'Répétition Octane', startISO: r.startsAt, endISO: r.endsAt, location: r.location };
|
const linkArgs = { uid: `rehearsal-${r.id}`, title: 'Répétition Octane', startISO: r.startsAt, endISO: r.endsAt, location: r.location };
|
||||||
return `
|
return `
|
||||||
<div class="modal-section">
|
<div class="modal-section">
|
||||||
<div class="modal-section-title">${escapeHtml(rehearsalStatusLabel(r))}</div>
|
<div class="modal-section-title">Répétition proposée</div>
|
||||||
<p class="note" style="margin:0.25rem 0 0.5rem">${r.location ? escapeHtml(r.location) + ' · ' : ''}${formatTime(r.startsAt)} – ${formatTime(r.endsAt)} · Proposée par ${escapeHtml(r.proposedByName)}</p>
|
<p class="note" style="margin:0.25rem 0 0.5rem">${r.location ? escapeHtml(r.location) + ' · ' : ''}${formatTime(r.startsAt)} – ${formatTime(r.endsAt)} · Proposée par ${escapeHtml(r.proposedByName)}</p>
|
||||||
${rehearsalAcceptedByHtml(r)}
|
${rehearsalAcceptedByHtml(r)}
|
||||||
<div class="rehearsal-actions">
|
<div class="rehearsal-actions">
|
||||||
|
|
@ -370,9 +364,9 @@ function openModal(date, slot, visible, concert, rehearsal) {
|
||||||
currentModalDate = { date, slot };
|
currentModalDate = { date, slot };
|
||||||
const proposeBtn = document.getElementById('modal-propose-rehearsal-btn');
|
const proposeBtn = document.getElementById('modal-propose-rehearsal-btn');
|
||||||
const availabilitySection = document.getElementById('modal-availability-section');
|
const availabilitySection = document.getElementById('modal-availability-section');
|
||||||
const rehearsalConfirmed = rehearsal && rehearsal.status === 'confirmed';
|
const rehearsalAccepted = rehearsal && rehearsal.votes.some((v) => v.vote === 'accept');
|
||||||
if (slot) {
|
if (slot) {
|
||||||
availabilitySection.style.display = rehearsalConfirmed ? 'none' : '';
|
availabilitySection.style.display = rehearsalAccepted ? 'none' : '';
|
||||||
proposeBtn.style.display = '';
|
proposeBtn.style.display = '';
|
||||||
const alreadyProposed = state.rehearsals.some((r) => isoDate(new Date(r.startsAt)) === isoDate(date));
|
const alreadyProposed = state.rehearsals.some((r) => isoDate(new Date(r.startsAt)) === isoDate(date));
|
||||||
proposeBtn.disabled = alreadyProposed;
|
proposeBtn.disabled = alreadyProposed;
|
||||||
|
|
@ -505,7 +499,6 @@ function rehearsalRowTemplate(r) {
|
||||||
return `
|
return `
|
||||||
<div class="rehearsal-row" data-rehearsal-id="${r.id}">
|
<div class="rehearsal-row" data-rehearsal-id="${r.id}">
|
||||||
<div>
|
<div>
|
||||||
<span class="vote-badge ${r.status === 'confirmed' ? 'accept' : ''}">${escapeHtml(rehearsalStatusLabel(r))}</span>
|
|
||||||
<div class="card-title">${formatDatetime(r.startsAt)} – ${formatTime(r.endsAt)}</div>
|
<div class="card-title">${formatDatetime(r.startsAt)} – ${formatTime(r.endsAt)}</div>
|
||||||
<div class="card-subtitle">${r.location ? `${escapeHtml(r.location)} · ` : ''}Proposée par ${escapeHtml(r.proposedByName)}</div>
|
<div class="card-subtitle">${r.location ? `${escapeHtml(r.location)} · ` : ''}Proposée par ${escapeHtml(r.proposedByName)}</div>
|
||||||
${rehearsalAcceptedByHtml(r)}
|
${rehearsalAcceptedByHtml(r)}
|
||||||
|
|
@ -759,22 +752,7 @@ async function onRemoveMyFeed(feedId) {
|
||||||
await loadSlots();
|
await loadSlots();
|
||||||
await loadLastChecked();
|
await loadLastChecked();
|
||||||
await loadMyFeeds();
|
await loadMyFeeds();
|
||||||
jumpToRehearsalFromUrl();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err.message);
|
showError(err.message);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Supports the "Votez ici" link embedded in the ICS feed (calendar.html?rehearsalId=N):
|
|
||||||
// scroll straight to that rehearsal's row in the list and highlight it briefly.
|
|
||||||
function jumpToRehearsalFromUrl() {
|
|
||||||
const rehearsalId = new URLSearchParams(window.location.search).get('rehearsalId');
|
|
||||||
if (!rehearsalId) return;
|
|
||||||
const row = document.querySelector(`.rehearsal-row[data-rehearsal-id="${rehearsalId}"]`);
|
|
||||||
if (!row) return;
|
|
||||||
const details = row.closest('details');
|
|
||||||
if (details) details.open = true;
|
|
||||||
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
row.classList.add('highlight');
|
|
||||||
setTimeout(() => row.classList.remove('highlight'), 3000);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -78,27 +78,6 @@ async function onRemoveMyFeed(feedId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadIcsFeedUrl() {
|
|
||||||
const { path } = await api.get('/api/users/me/ics-feed-url');
|
|
||||||
const httpsUrl = `${window.location.origin}${path}`;
|
|
||||||
const webcalUrl = `webcal://${window.location.host}${path}`;
|
|
||||||
document.getElementById('ics-feed-url').value = webcalUrl;
|
|
||||||
document.getElementById('ics-feed-open-link').href = webcalUrl;
|
|
||||||
document.getElementById('copy-ics-feed-btn').dataset.url = httpsUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onCopyIcsFeedUrl(e) {
|
|
||||||
const btn = e.target;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(btn.dataset.url);
|
|
||||||
const original = btn.textContent;
|
|
||||||
btn.textContent = 'Copié !';
|
|
||||||
setTimeout(() => { btn.textContent = original; }, 1500);
|
|
||||||
} catch (err) {
|
|
||||||
showError("Impossible de copier le lien : " + err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSaveDisplayName(e) {
|
async function onSaveDisplayName(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const form = e.target;
|
const form = e.target;
|
||||||
|
|
@ -179,20 +158,6 @@ async function onResetDisplayName() {
|
||||||
<p class="note"><a href="/calendar-ics-help.html">Comment trouver mon lien ICS ?</a></p>
|
<p class="note"><a href="/calendar-ics-help.html">Comment trouver mon lien ICS ?</a></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Répétitions dans mon calendrier</h2>
|
|
||||||
<p class="note">
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
<div class="panel">
|
|
||||||
<form class="inline-form" onsubmit="return false">
|
|
||||||
<input id="ics-feed-url" type="text" readonly>
|
|
||||||
<button type="button" class="secondary icon-btn" id="copy-ics-feed-btn">Copier</button>
|
|
||||||
<a id="ics-feed-open-link" class="pill-link">S'abonner</a>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<p class="empty">
|
<p class="empty">
|
||||||
Identité gérée par Authentik — pour changer votre email ou mot de passe,
|
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');
|
const resetBtn = document.getElementById('reset-display-name-btn');
|
||||||
if (resetBtn) resetBtn.addEventListener('click', onResetDisplayName);
|
if (resetBtn) resetBtn.addEventListener('click', onResetDisplayName);
|
||||||
document.getElementById('add-my-feed-form').addEventListener('submit', onAddMyFeed);
|
document.getElementById('add-my-feed-form').addEventListener('submit', onAddMyFeed);
|
||||||
document.getElementById('copy-ics-feed-btn').addEventListener('click', onCopyIcsFeedUrl);
|
|
||||||
await loadMyFeeds();
|
await loadMyFeeds();
|
||||||
await loadIcsFeedUrl();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err.message);
|
showError(err.message);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ const sessionMiddleware = require('./auth/session');
|
||||||
const authRoutes = require('./auth/routes');
|
const authRoutes = require('./auth/routes');
|
||||||
const { attachUser, requireAuth } = require('./auth/middleware');
|
const { attachUser, requireAuth } = require('./auth/middleware');
|
||||||
const apiRouter = require('./routes');
|
const apiRouter = require('./routes');
|
||||||
const calendarFeedRoutes = require('./routes/calendarFeed');
|
|
||||||
|
|
||||||
function createApp() {
|
function createApp() {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
@ -20,10 +19,6 @@ function createApp() {
|
||||||
app.use('/auth', authRoutes);
|
app.use('/auth', authRoutes);
|
||||||
app.use(attachUser);
|
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('/api', requireAuth, apiRouter);
|
||||||
app.use(requireAuth, express.static(path.join(__dirname, '../public')));
|
app.use(requireAuth, express.static(path.join(__dirname, '../public')));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
ALTER TABLE users ADD COLUMN ics_token TEXT UNIQUE;
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
ALTER TABLE calendar_settings ADD COLUMN IF NOT EXISTS rehearsal_confirm_threshold SMALLINT NOT NULL DEFAULT 4;
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -205,7 +205,7 @@ function parseTime(hhmmss) {
|
||||||
|
|
||||||
async function getSlotSettings() {
|
async function getSlotSettings() {
|
||||||
const { rows } = await pool.query(
|
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 row = rows[0];
|
||||||
const weekdayStart = parseTime(row.weekday_start);
|
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 },
|
weekend: { startHour: weekendStart.hour, startMinute: weekendStart.minute, endHour: weekendEnd.hour, endMinute: weekendEnd.minute },
|
||||||
marginMinutes: row.margin_minutes,
|
marginMinutes: row.margin_minutes,
|
||||||
concert: { startHour: concertStart.hour, startMinute: concertStart.minute, endHour: concertEnd.hour, endMinute: concertEnd.minute },
|
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(
|
const { rows } = await pool.query(
|
||||||
`UPDATE calendar_settings
|
`UPDATE calendar_settings
|
||||||
SET weekday_start = $1, weekday_end = $2, weekend_start = $3, weekend_end = $4, margin_minutes = $5,
|
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
|
WHERE id = 1
|
||||||
RETURNING weekday_start, weekday_end, weekend_start, weekend_end, margin_minutes, concert_start, concert_end, rehearsal_confirm_threshold`,
|
RETURNING weekday_start, weekday_end, weekend_start, weekend_end, margin_minutes, concert_start, concert_end`,
|
||||||
[weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd, rehearsalConfirmThreshold]
|
[weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd]
|
||||||
);
|
);
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
const crypto = require('crypto');
|
|
||||||
const pool = require('../db/pool');
|
const pool = require('../db/pool');
|
||||||
|
|
||||||
const PROFILE_FIELDS =
|
const PROFILE_FIELDS =
|
||||||
|
|
@ -73,39 +72,4 @@ async function findAllWithActivity() {
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lazily creates the user's rehearsal-feed secret on first request, rather
|
module.exports = { findById, upsertFromClaims, updateDisplayName, getActivityStats, findAllWithActivity };
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,6 @@ router.get(
|
||||||
marginMinutes: settings.marginMinutes,
|
marginMinutes: settings.marginMinutes,
|
||||||
concertStart: formatTime(settings.concert.startHour, settings.concert.startMinute),
|
concertStart: formatTime(settings.concert.startHour, settings.concert.startMinute),
|
||||||
concertEnd: formatTime(settings.concert.endHour, settings.concert.endMinute),
|
concertEnd: formatTime(settings.concert.endHour, settings.concert.endMinute),
|
||||||
rehearsalConfirmThreshold: settings.rehearsalConfirmThreshold,
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
@ -214,7 +213,7 @@ router.patch(
|
||||||
'/settings',
|
'/settings',
|
||||||
requireAdmin,
|
requireAdmin,
|
||||||
asyncHandler(async (req, res) => {
|
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 };
|
const times = { weekdayStart, weekdayEnd, weekendStart, weekendEnd, concertStart, concertEnd };
|
||||||
for (const [key, value] of Object.entries(times)) {
|
for (const [key, value] of Object.entries(times)) {
|
||||||
if (!TIME_RE.test(value || '')) {
|
if (!TIME_RE.test(value || '')) {
|
||||||
|
|
@ -234,12 +233,8 @@ router.patch(
|
||||||
if (!Number.isInteger(margin) || margin < 0 || margin > 180) {
|
if (!Number.isInteger(margin) || margin < 0 || margin > 180) {
|
||||||
return res.status(400).json({ error: 'invalid_margin_minutes' });
|
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);
|
await calendarRepo.updateSlotSettings(values);
|
||||||
res.json(values);
|
res.json(values);
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1,17 +1,14 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const rehearsalsRepo = require('../repositories/rehearsalsRepo');
|
const rehearsalsRepo = require('../repositories/rehearsalsRepo');
|
||||||
const calendarRepo = require('../repositories/calendarRepo');
|
|
||||||
const discord = require('../lib/discord');
|
const discord = require('../lib/discord');
|
||||||
const asyncHandler = require('../lib/asyncHandler');
|
const asyncHandler = require('../lib/asyncHandler');
|
||||||
const { computeRehearsalStatus } = require('../lib/rehearsalStatus');
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const [rehearsals, settings] = await Promise.all([rehearsalsRepo.findUpcoming(), calendarRepo.getSlotSettings()]);
|
const rehearsals = await rehearsalsRepo.findUpcoming();
|
||||||
const threshold = settings.rehearsalConfirmThreshold;
|
|
||||||
res.json(
|
res.json(
|
||||||
rehearsals.map((r) => ({
|
rehearsals.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
|
@ -21,8 +18,6 @@ router.get(
|
||||||
proposedBy: r.proposed_by,
|
proposedBy: r.proposed_by,
|
||||||
proposedByName: r.proposed_by_name,
|
proposedByName: r.proposed_by_name,
|
||||||
votes: r.votes,
|
votes: r.votes,
|
||||||
status: computeRehearsalStatus(r.votes, threshold),
|
|
||||||
confirmThreshold: threshold,
|
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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(
|
router.patch(
|
||||||
'/me/display-name',
|
'/me/display-name',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue