mirror of
https://github.com/nfonteyne/octane-website.git
synced 2026-09-03 23:24:48 +02:00
update: integrate ics calendar
This commit is contained in:
parent
b602093246
commit
63fb6cd45a
22 changed files with 845 additions and 193 deletions
57
src/lib/calendarAvailability.js
Normal file
57
src/lib/calendarAvailability.js
Normal 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 };
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue