update: integrate ics calendar

This commit is contained in:
Nathan FONTEYNE 2026-07-09 17:52:08 +02:00
parent b602093246
commit 63fb6cd45a
22 changed files with 845 additions and 193 deletions

View file

@ -0,0 +1,57 @@
const { parisWallClockToUTC } = require('./calendarDates');
// Default rehearsal-availability windows, Europe/Paris local time — used
// whenever no admin-configured slotConfig is passed in (and by the pure-logic
// tests below, so those keep testing this exact default without needing to
// know about the DB-backed override path in calendarRepo/calendarSync).
const DEFAULT_SLOT_CONFIG = {
weekday: { startHour: 18, startMinute: 30, endHour: 21, endMinute: 0 },
weekend: { startHour: 15, startMinute: 0, endHour: 19, endMinute: 0 },
};
const MAX_WEEKS = 3;
// One slot per day for the next `weeks` weeks (capped at 3, same as the rest
// of the calendar feature), starting from today's Paris-local calendar date
// — not the server's own timezone, which may not be Europe/Paris.
function generateSlots(weeks = MAX_WEEKS, slotConfig = DEFAULT_SLOT_CONFIG) {
const days = Math.min(weeks || MAX_WEEKS, MAX_WEEKS) * 7;
const parisToday = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Paris' }).format(new Date());
const [ty, tm, td] = parisToday.split('-').map(Number);
const anchor = Date.UTC(ty, tm - 1, td);
const slots = [];
for (let i = 0; i < days; i++) {
// This is only ever used to walk calendar dates one day at a time, never
// treated as a real instant — the actual Paris-local conversion happens
// below via parisWallClockToUTC, so there's no timezone ambiguity here.
const dayAnchor = new Date(anchor + i * 86400000);
const year = dayAnchor.getUTCFullYear();
const month = dayAnchor.getUTCMonth() + 1;
const day = dayAnchor.getUTCDate();
const isWeekend = dayAnchor.getUTCDay() === 0 || dayAnchor.getUTCDay() === 6;
const spec = isWeekend ? slotConfig.weekend : slotConfig.weekday;
slots.push({
lower: parisWallClockToUTC(year, month, day, spec.startHour, spec.startMinute),
upper: parisWallClockToUTC(year, month, day, spec.endHour, spec.endMinute),
});
}
return slots;
}
// intervals: [{start: Date, end: Date}] — flat, already-expanded busy blocks
// (recurring events expanded, all-day events resolved to a concrete local-day
// span) so this stays a plain half-open-interval overlap check.
function isBusyDuring(intervals, slotLower, slotUpper) {
return intervals.some((interval) => interval.start < slotUpper && interval.end > slotLower);
}
// Widens a slot by `marginMinutes` on each side — used only for the busy/free
// check (to account for travel time between back-to-back calendar events),
// never for the slot that actually gets stored/displayed.
function widenWindow(lower, upper, marginMinutes) {
const marginMs = (marginMinutes || 0) * 60000;
return { lower: new Date(lower.getTime() - marginMs), upper: new Date(upper.getTime() + marginMs) };
}
module.exports = { generateSlots, isBusyDuring, widenWindow, DEFAULT_SLOT_CONFIG };

View file

@ -27,4 +27,42 @@ function dayOfWeekParis(iso) {
return dow === undefined ? date.getDay() || 7 : dow;
}
module.exports = { normalizeISO, slotDateParis, dayOfWeekParis };
// Minutes Europe/Paris is ahead of UTC at the given instant (+60 in winter,
// +120 in summer) — derived by reading the instant's own Paris-local wall
// clock back out and diffing against its UTC wall clock, rather than hardcoding
// DST transition dates (which shift slightly year to year).
function parisOffsetMinutes(date) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'Europe/Paris',
hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
})
.formatToParts(date)
.reduce((acc, p) => {
acc[p.type] = p.value;
return acc;
}, {});
const parisWallAsUTCMillis = Date.UTC(
Number(parts.year), Number(parts.month) - 1, Number(parts.day),
parts.hour === '24' ? 0 : Number(parts.hour), Number(parts.minute), Number(parts.second)
);
return Math.round((parisWallAsUTCMillis - date.getTime()) / 60000);
}
// The reverse of slotDateParis/dayOfWeekParis: given Paris-local wall-clock
// components, returns the UTC instant they represent. DST-aware via a small
// fixed-point iteration (the offset itself depends on the instant, so a naive
// single guess can land a lookup on the wrong side of a transition).
function parisWallClockToUTC(year, month, day, hour, minute) {
let guess = new Date(Date.UTC(year, month - 1, day, hour, minute));
for (let i = 0; i < 2; i++) {
const offsetMinutes = parisOffsetMinutes(guess);
const corrected = new Date(Date.UTC(year, month - 1, day, hour, minute) - offsetMinutes * 60000);
if (corrected.getTime() === guess.getTime()) return corrected;
guess = corrected;
}
return guess;
}
module.exports = { normalizeISO, slotDateParis, dayOfWeekParis, parisWallClockToUTC };

View file

@ -1,23 +0,0 @@
const crypto = require('crypto');
const config = require('../config');
// Protects the two endpoints n8n calls directly (server-to-server, no
// browser session available) — a shared secret header instead of the
// session-based auth the rest of /api uses. Constant-time compare since
// this is a bearer-secret check.
function calendarWebhookAuth(req, res, next) {
const provided = req.header('X-Calendar-Webhook-Secret') || '';
const expected = config.calendarWebhookSecret;
const providedBuf = Buffer.from(provided);
const expectedBuf = Buffer.from(expected);
const valid =
providedBuf.length === expectedBuf.length && crypto.timingSafeEqual(providedBuf, expectedBuf);
if (!valid) {
return res.status(401).json({ error: 'invalid_webhook_secret' });
}
next();
}
module.exports = calendarWebhookAuth;

View file

@ -1,25 +1,22 @@
// In-memory only — resets on process restart, which is fine: it just tracks
// whether the last "refresh availability" run is idle/running/done/failed,
// not data that needs to survive a restart. Shared between the session-gated
// routes (src/routes/calendar.js) and the n8n-facing webhook routes
// (src/routes/calendarWebhooks.js) so both can read/update the same state
// without a require cycle between those two route files.
let state = { status: 'idle', triggeredAt: null, message: null, node: null };
// not data that needs to survive a restart.
let state = { status: 'idle', triggeredAt: null, message: null };
function getState() {
return state;
}
function setRunning() {
state = { status: 'running', triggeredAt: new Date().toISOString(), message: null, node: null };
state = { status: 'running', triggeredAt: new Date().toISOString(), message: null };
}
function setSuccess(message) {
state = { ...state, status: 'success', message: message || null, node: null };
state = { ...state, status: 'success', message: message || null };
}
function setError(message, node) {
state = { ...state, status: 'error', message: message || 'Unknown error', node: node || null };
function setError(message) {
state = { ...state, status: 'error', message: message || 'Unknown error' };
}
module.exports = { getState, setRunning, setSuccess, setError };