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
115
src/services/calendarSync.js
Normal file
115
src/services/calendarSync.js
Normal 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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue