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
|
|
@ -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')));
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
};
|
||||
|
|
|
|||
9
src/db/migrations/008_calendar_feeds.sql
Normal file
9
src/db/migrations/008_calendar_feeds.sql
Normal 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);
|
||||
10
src/db/migrations/009_calendar_settings.sql
Normal file
10
src/db/migrations/009_calendar_settings.sql
Normal 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);
|
||||
1
src/db/migrations/010_calendar_settings_margin.sql
Normal file
1
src/db/migrations/010_calendar_settings_margin.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE calendar_settings ADD COLUMN IF NOT EXISTS margin_minutes SMALLINT NOT NULL DEFAULT 0;
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
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