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

@ -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 calendarWebhooksRouter = require('./routes/calendarWebhooks');
function createApp() {
const app = express();
@ -20,12 +19,6 @@ function createApp() {
app.use('/auth', authRoutes);
app.use(attachUser);
// Called by n8n directly (server-to-server, no session) — secret-protected
// per-route inside the router itself, not gated by requireAuth. Must be
// mounted before the /api requireAuth line so unmatched paths (e.g.
// GET /api/calendar/people) fall through to the normal session-gated router.
app.use('/api/calendar', calendarWebhooksRouter);
app.use('/api', requireAuth, apiRouter);
app.use(requireAuth, express.static(path.join(__dirname, '../public')));

View file

@ -54,13 +54,4 @@ module.exports = {
spotifyClientId: process.env.SPOTIFY_CLIENT_ID || null,
spotifyClientSecret: process.env.SPOTIFY_CLIENT_SECRET || null,
youtubeApiKey: process.env.YOUTUBE_API_KEY || null,
// Calendar (availability) feature. N8N_* are optional: without them,
// viewing already-ingested availability still works, only the "refresh"
// button (which triggers the n8n workflow) is disabled.
n8nWebhookUrl: process.env.N8N_WEBHOOK_URL || null,
n8nWebhookUser: process.env.N8N_WEBHOOK_USER || null,
n8nWebhookPass: process.env.N8N_WEBHOOK_PASS || null,
// Required: protects the two endpoints n8n calls directly (server-to-server,
// no browser session involved) — see src/lib/calendarWebhookAuth.js.
calendarWebhookSecret: required('CALENDAR_WEBHOOK_SECRET'),
};

View file

@ -0,0 +1,9 @@
CREATE TABLE calendar_feeds (
id SERIAL PRIMARY KEY,
person_id INTEGER NOT NULL REFERENCES calendar_people(id) ON DELETE CASCADE,
label TEXT,
ics_url TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_calendar_feeds_person_id ON calendar_feeds (person_id);

View file

@ -0,0 +1,10 @@
CREATE TABLE calendar_settings (
id SMALLINT PRIMARY KEY DEFAULT 1,
weekday_start TIME NOT NULL DEFAULT '18:30',
weekday_end TIME NOT NULL DEFAULT '21:00',
weekend_start TIME NOT NULL DEFAULT '15:00',
weekend_end TIME NOT NULL DEFAULT '19:00',
CONSTRAINT calendar_settings_single_row CHECK (id = 1)
);
INSERT INTO calendar_settings (id) VALUES (1);

View file

@ -0,0 +1 @@
ALTER TABLE calendar_settings ADD COLUMN IF NOT EXISTS margin_minutes SMALLINT NOT NULL DEFAULT 0;

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 };

View file

@ -158,4 +158,100 @@ async function getLastChecked() {
return rows[0].ts;
}
module.exports = { getPeople, ingestSlots, getSlots, getLastChecked };
// Every registered feed across every person, for the sync job — never
// returned to non-admin API consumers (see getPeople() above, which omits
// ics_url entirely).
async function findAllFeeds() {
const { rows } = await pool.query(`
SELECT f.id, f.person_id, p.name AS person_name, f.label, f.ics_url
FROM calendar_feeds f
JOIN calendar_people p ON p.id = f.person_id
ORDER BY f.person_id, f.id
`);
return rows;
}
async function findFeedsForPerson(personId) {
const { rows } = await pool.query(
'SELECT id, person_id, label, ics_url FROM calendar_feeds WHERE person_id = $1 ORDER BY id',
[personId]
);
return rows;
}
async function addFeed(personId, { label, icsUrl }) {
const { rows } = await pool.query(
`INSERT INTO calendar_feeds (person_id, label, ics_url)
VALUES ($1, $2, $3)
RETURNING id, person_id, label, ics_url`,
[personId, label || null, icsUrl]
);
return rows[0];
}
async function removeFeed(feedId) {
await pool.query('DELETE FROM calendar_feeds WHERE id = $1', [feedId]);
}
// Admin-only variant of getPeople(): includes each person's registered feeds
// (label + ics_url) so an admin can review/edit them. The public getPeople()
// above deliberately never exposes ics_url — it's a secret, effectively
// granting calendar read access to whoever has it.
async function getPeopleForAdmin() {
const people = await getPeople();
const feeds = await findAllFeeds();
const feedsByPerson = new Map();
for (const feed of feeds) {
if (!feedsByPerson.has(feed.person_id)) feedsByPerson.set(feed.person_id, []);
feedsByPerson.get(feed.person_id).push({ id: feed.id, label: feed.label, icsUrl: feed.ics_url });
}
return people.map((p) => ({ ...p, feeds: feedsByPerson.get(p.id) || [] }));
}
// TIME columns come back from pg as 'HH:MM:SS' strings — split into numbers
// so callers (calendarAvailability.generateSlots) get plain {hour, minute}.
function parseTime(hhmmss) {
const [hour, minute] = hhmmss.split(':').map(Number);
return { hour, minute };
}
async function getSlotSettings() {
const { rows } = await pool.query(
'SELECT weekday_start, weekday_end, weekend_start, weekend_end, margin_minutes FROM calendar_settings WHERE id = 1'
);
const row = rows[0];
const weekdayStart = parseTime(row.weekday_start);
const weekdayEnd = parseTime(row.weekday_end);
const weekendStart = parseTime(row.weekend_start);
const weekendEnd = parseTime(row.weekend_end);
return {
weekday: { startHour: weekdayStart.hour, startMinute: weekdayStart.minute, endHour: weekdayEnd.hour, endMinute: weekdayEnd.minute },
weekend: { startHour: weekendStart.hour, startMinute: weekendStart.minute, endHour: weekendEnd.hour, endMinute: weekendEnd.minute },
marginMinutes: row.margin_minutes,
};
}
async function updateSlotSettings({ weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes }) {
const { rows } = await pool.query(
`UPDATE calendar_settings
SET weekday_start = $1, weekday_end = $2, weekend_start = $3, weekend_end = $4, margin_minutes = $5
WHERE id = 1
RETURNING weekday_start, weekday_end, weekend_start, weekend_end, margin_minutes`,
[weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes]
);
return rows[0];
}
module.exports = {
getPeople,
ingestSlots,
getSlots,
getLastChecked,
findAllFeeds,
findFeedsForPerson,
addFeed,
removeFeed,
getPeopleForAdmin,
getSlotSettings,
updateSlotSettings,
};

View file

@ -1,7 +1,8 @@
const express = require('express');
const calendarRepo = require('../repositories/calendarRepo');
const workflowState = require('../lib/calendarWorkflowState');
const config = require('../config');
const calendarSync = require('../services/calendarSync');
const { requireAdmin } = require('../auth/middleware');
const asyncHandler = require('../lib/asyncHandler');
const router = express.Router();
@ -41,64 +42,118 @@ router.get('/workflow-status', (req, res) => {
res.json(workflowState.getState());
});
function webhookHeaders() {
const headers = { 'Content-Type': 'application/json' };
if (config.n8nWebhookUser && config.n8nWebhookPass) {
const token = Buffer.from(`${config.n8nWebhookUser}:${config.n8nWebhookPass}`).toString('base64');
headers.Authorization = `Basic ${token}`;
}
return headers;
router.post(
'/refresh',
asyncHandler(async (req, res) => {
const feeds = await calendarRepo.findAllFeeds();
if (feeds.length === 0) {
return res.status(400).json({ error: 'no_feeds_configured' });
}
if (workflowState.getState().status === 'running') {
return res.status(409).json({ ok: false, error: 'A refresh is already in progress' });
}
workflowState.setRunning();
res.json({ ok: true });
// Fire-and-forget: the browser polls GET /workflow-status for the result.
(async () => {
try {
const summary = await calendarSync.syncAvailability();
workflowState.setSuccess(
`${summary.slotsProcessed} créneaux traités, ${summary.availabilityRows} disponibilités enregistrées ` +
`(${summary.availableTrueCount} dispo / ${summary.availableFalseCount} indispo)` +
(summary.slotsWithNoPeople > 0 ? `${summary.slotsWithNoPeople} créneaux sans aucune personne, voir les logs serveur` : '') +
(summary.failedFeeds > 0 ? `${summary.failedFeeds} calendrier(s) inaccessible(s), voir les logs serveur` : '')
);
} catch (err) {
console.error('[calendar] availability sync failed:', err.message);
workflowState.setError(err.message);
}
})();
})
);
router.get(
'/people/admin',
requireAdmin,
asyncHandler(async (req, res) => {
res.json(await calendarRepo.getPeopleForAdmin());
})
);
router.post(
'/people/:id/feeds',
requireAdmin,
asyncHandler(async (req, res) => {
const { label, icsUrl } = req.body || {};
if (!icsUrl || !icsUrl.trim()) {
return res.status(400).json({ error: 'ics_url_required' });
}
const feed = await calendarRepo.addFeed(parseInt(req.params.id, 10), {
label: label ? label.trim() : null,
icsUrl: icsUrl.trim(),
});
res.status(201).json({ id: feed.id, personId: feed.person_id, label: feed.label, icsUrl: feed.ics_url });
})
);
router.delete(
'/feeds/:feedId',
requireAdmin,
asyncHandler(async (req, res) => {
await calendarRepo.removeFeed(parseInt(req.params.feedId, 10));
res.status(204).end();
})
);
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
router.get(
'/settings',
requireAdmin,
asyncHandler(async (req, res) => {
const settings = await calendarRepo.getSlotSettings();
res.json({
weekdayStart: formatTime(settings.weekday.startHour, settings.weekday.startMinute),
weekdayEnd: formatTime(settings.weekday.endHour, settings.weekday.endMinute),
weekendStart: formatTime(settings.weekend.startHour, settings.weekend.startMinute),
weekendEnd: formatTime(settings.weekend.endHour, settings.weekend.endMinute),
marginMinutes: settings.marginMinutes,
});
})
);
function formatTime(hour, minute) {
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
router.post('/refresh', (req, res) => {
if (!config.n8nWebhookUrl) {
return res.status(400).json({ error: 'n8n_not_configured' });
}
if (workflowState.getState().status === 'running') {
return res.status(409).json({ ok: false, error: 'A refresh is already in progress' });
}
workflowState.setRunning();
res.json({ ok: true });
// Fire-and-forget: the browser polls GET /workflow-status for the result.
(async () => {
const controller = new AbortController();
const watchdog = setTimeout(() => controller.abort(), 5 * 60 * 1000);
try {
const response = await fetch(config.n8nWebhookUrl, {
method: 'GET',
headers: webhookHeaders(),
signal: controller.signal,
});
clearTimeout(watchdog);
if (!response.ok) {
const text = await response.text();
workflowState.setError(`n8n returned ${response.status}: ${text}`);
return;
router.patch(
'/settings',
requireAdmin,
asyncHandler(async (req, res) => {
const { weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes } = req.body || {};
const times = { weekdayStart, weekdayEnd, weekendStart, weekendEnd };
for (const [key, value] of Object.entries(times)) {
if (!TIME_RE.test(value || '')) {
return res.status(400).json({ error: 'invalid_time', field: key });
}
const data = await response.json();
if (!Array.isArray(data.slots)) {
console.error('[calendar] n8n response had no "slots" array. Raw body:', JSON.stringify(data).slice(0, 500));
workflowState.setError('Unexpected response format from n8n');
return;
}
const summary = await calendarRepo.ingestSlots(data.slots);
workflowState.setSuccess(
`${summary.slotsProcessed} créneaux reçus, ${summary.availabilityRows} disponibilités enregistrées ` +
`(${summary.availableTrueCount} dispo / ${summary.availableFalseCount} indispo)` +
(summary.slotsWithNoPeople > 0 ? `${summary.slotsWithNoPeople} créneaux sans aucune personne, voir les logs serveur` : '')
);
} catch (err) {
clearTimeout(watchdog);
const message = err.name === 'AbortError' ? 'n8n did not respond within 5 minutes' : err.message;
console.error('[calendar] n8n refresh failed:', message);
workflowState.setError(message);
}
})();
});
if (weekdayStart >= weekdayEnd) {
return res.status(400).json({ error: 'weekday_start_must_be_before_end' });
}
if (weekendStart >= weekendEnd) {
return res.status(400).json({ error: 'weekend_start_must_be_before_end' });
}
const margin = Number(marginMinutes);
if (!Number.isInteger(margin) || margin < 0 || margin > 180) {
return res.status(400).json({ error: 'invalid_margin_minutes' });
}
const values = { ...times, marginMinutes: margin };
await calendarRepo.updateSlotSettings(values);
res.json(values);
})
);
module.exports = router;

View file

@ -1,46 +0,0 @@
const express = require('express');
const calendarRepo = require('../repositories/calendarRepo');
const workflowState = require('../lib/calendarWorkflowState');
const calendarWebhookAuth = require('../lib/calendarWebhookAuth');
const asyncHandler = require('../lib/asyncHandler');
// Mounted directly in app.js, before the session-gated /api router — these
// two routes are called by n8n itself (server-to-server), so they cannot
// rely on a browser session cookie. The secret check is applied per-route
// here rather than as middleware on the whole /api/calendar mount, so that
// unmatched paths (e.g. GET /api/calendar/people) fall through untouched to
// the normal session-gated router mounted afterwards.
const router = express.Router();
router.post(
'/ingest',
calendarWebhookAuth,
asyncHandler(async (req, res) => {
const slots = Array.isArray(req.body) ? req.body : req.body?.slots;
if (!Array.isArray(slots)) {
return res.status(400).json({ error: 'expected_array_of_slots' });
}
try {
const summary = await calendarRepo.ingestSlots(slots);
workflowState.setSuccess(
`${summary.slotsProcessed} créneaux reçus, ${summary.availabilityRows} disponibilités enregistrées ` +
`(${summary.availableTrueCount} dispo / ${summary.availableFalseCount} indispo)` +
(summary.slotsWithNoPeople > 0 ? `${summary.slotsWithNoPeople} créneaux sans aucune personne, voir les logs serveur` : '')
);
res.json({ ok: true, ...summary });
} catch (err) {
workflowState.setError(err.message);
res.status(500).json({ error: err.message });
}
})
);
router.post('/workflow-error', calendarWebhookAuth, (req, res) => {
const message = req.body?.message || req.body?.error || 'Workflow failed';
const node = req.body?.node || null;
console.error('[calendar] n8n workflow error:', message, node ? `(node: ${node})` : '');
workflowState.setError(message, node);
res.json({ ok: true });
});
module.exports = router;

View file

@ -0,0 +1,115 @@
const ical = require('node-ical');
const calendarRepo = require('../repositories/calendarRepo');
const { generateSlots, isBusyDuring, widenWindow } = require('../lib/calendarAvailability');
const { parisWallClockToUTC } = require('../lib/calendarDates');
const RANGE_DAYS = 22; // slightly more than the 3 weeks generateSlots() covers
const FETCH_TIMEOUT_MS = 15000;
// Fetches and parses one ICS feed, immediately reducing every VEVENT down to
// {start, end} — nothing else (title, description, location, attendees) is
// ever read out of the parsed calendar, so it can't end up logged, stored, or
// returned by an API by mistake. This holds regardless of whether the
// provider's feed itself is busy/free-only or full-detail.
async function fetchBusyIntervals(icsUrl) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
let text;
try {
const response = await fetch(icsUrl, { signal: controller.signal });
if (!response.ok) throw new Error(`ICS fetch failed: ${response.status}`);
text = await response.text();
} finally {
clearTimeout(timeout);
}
const parsed = ical.parseICS(text);
const rangeFrom = new Date();
const rangeTo = new Date(rangeFrom.getTime() + RANGE_DAYS * 86400000);
const intervals = [];
for (const key of Object.keys(parsed)) {
const component = parsed[key];
if (component.type !== 'VEVENT') continue;
if (component.status === 'CANCELLED') continue;
if (component.transparency === 'TRANSPARENT') continue; // "show as free"
if (component.rrule) {
const instances = ical.expandRecurringEvent(component, { from: rangeFrom, to: rangeTo });
for (const instance of instances) intervals.push(toInterval(instance));
} else {
intervals.push(toInterval(component));
}
}
return intervals;
}
function toInterval({ start, end, datetype, isFullDay }) {
if (datetype === 'date' || isFullDay) {
// node-ical builds date-only (VALUE=DATE) values with the server's local
// timezone, not necessarily Europe/Paris — read the calendar date back out
// via local getters (self-consistent within this same process) and
// reconstruct the day span explicitly in Paris time, matching how
// generateSlots() anchors everything else.
const startDay = dateOnlyToParisSpan(start);
const endDay = end ? dateOnlyToParisSpan(end) : new Date(startDay.getTime() + 86400000);
return { start: startDay, end: endDay };
}
return { start: new Date(start), end: end ? new Date(end) : new Date(start) };
}
function dateOnlyToParisSpan(dateOnly) {
const d = new Date(dateOnly);
return parisWallClockToUTC(d.getFullYear(), d.getMonth() + 1, d.getDate(), 0, 0);
}
// Fetches every registered feed, derives per-person busy/free for each of the
// next 3 weeks' slots (using the admin-configured rehearsal hours, falling
// back to calendarAvailability's defaults if none are set), and ingests the
// result via calendarRepo.ingestSlots — the same sink the old n8n-webhook flow
// used to feed. A feed that fails to fetch is logged by id only (never its
// URL or any event content) and that person is simply omitted from this run
// rather than guessing their availability.
async function syncAvailability() {
const [feeds, slotConfig] = await Promise.all([calendarRepo.findAllFeeds(), calendarRepo.getSlotSettings()]);
const byPerson = new Map();
for (const feed of feeds) {
if (!byPerson.has(feed.person_id)) byPerson.set(feed.person_id, { name: feed.person_name });
}
const results = await Promise.allSettled(feeds.map((feed) => fetchBusyIntervals(feed.ics_url)));
const intervalsByPerson = new Map();
let failedFeeds = 0;
results.forEach((result, i) => {
const feed = feeds[i];
if (result.status === 'rejected') {
failedFeeds += 1;
console.warn(`[calendar] failed to fetch feed id=${feed.id} (person id=${feed.person_id}): ${result.reason.message}`);
return;
}
const existing = intervalsByPerson.get(feed.person_id) || [];
intervalsByPerson.set(feed.person_id, existing.concat(result.value));
});
const slots = generateSlots(3, slotConfig);
const slotPayload = slots.map((slot) => {
// The margin only widens the *check* window (to account for travel time
// between back-to-back calendar events) — the slot itself, as stored and
// shown on /calendar.html, stays exactly what the admin configured.
const { lower: checkLower, upper: checkUpper } = widenWindow(slot.lower, slot.upper, slotConfig.marginMinutes);
const people = [];
for (const [personId, info] of byPerson) {
if (!intervalsByPerson.has(personId)) continue; // every feed for this person failed this run
const busy = isBusyDuring(intervalsByPerson.get(personId), checkLower, checkUpper);
people.push({ name: info.name, available: !busy });
}
return { lower: slot.lower.toISOString(), upper: slot.upper.toISOString(), people };
});
const summary = await calendarRepo.ingestSlots(slotPayload);
return { ...summary, failedFeeds };
}
module.exports = { syncAvailability, fetchBusyIntervals };