From 63fb6cd45aa3c210459e120789a92c175fce044e Mon Sep 17 00:00:00 2001 From: Nathan FONTEYNE Date: Thu, 9 Jul 2026 17:52:08 +0200 Subject: [PATCH] update: integrate ics calendar --- .env.example | 14 -- README.md | 42 ++--- package-lock.json | 63 +++++++ package.json | 1 + public/css/style.css | 34 ++++ public/js/admin.js | 139 +++++++++++++++ public/js/calendar.js | 5 +- src/app.js | 7 - src/config.js | 9 - src/db/migrations/008_calendar_feeds.sql | 9 + src/db/migrations/009_calendar_settings.sql | 10 ++ .../010_calendar_settings_margin.sql | 1 + src/lib/calendarAvailability.js | 57 ++++++ src/lib/calendarDates.js | 40 ++++- src/lib/calendarWebhookAuth.js | 23 --- src/lib/calendarWorkflowState.js | 15 +- src/repositories/calendarRepo.js | 98 +++++++++- src/routes/calendar.js | 167 ++++++++++++------ src/routes/calendarWebhooks.js | 46 ----- src/services/calendarSync.js | 115 ++++++++++++ test/calendarAvailability.test.js | 114 ++++++++++++ test/calendarDates.test.js | 29 ++- 22 files changed, 845 insertions(+), 193 deletions(-) create mode 100644 src/db/migrations/008_calendar_feeds.sql create mode 100644 src/db/migrations/009_calendar_settings.sql create mode 100644 src/db/migrations/010_calendar_settings_margin.sql create mode 100644 src/lib/calendarAvailability.js delete mode 100644 src/lib/calendarWebhookAuth.js delete mode 100644 src/routes/calendarWebhooks.js create mode 100644 src/services/calendarSync.js create mode 100644 test/calendarAvailability.test.js diff --git a/.env.example b/.env.example index b1d5b71..144cc96 100644 --- a/.env.example +++ b/.env.example @@ -50,17 +50,3 @@ APP_PORT=3000 SPOTIFY_CLIENT_ID= SPOTIFY_CLIENT_SECRET= YOUTUBE_API_KEY= - -# Disponibilités (calendrier). N8N_* sont optionnels : sans eux, la page -# fonctionne quand même en lecture (données déjà ingérées) mais le bouton -# "Actualiser" est désactivé côté serveur (400 n8n_not_configured). -N8N_WEBHOOK_URL= -N8N_WEBHOOK_USER= -N8N_WEBHOOK_PASS= - -# Requis — protège les deux endpoints appelés directement par n8n -# (POST /api/calendar/ingest et /api/calendar/workflow-error), qui ne passent -# pas par la session de connexion. Générer avec `openssl rand -hex 32`, et -# configurer la même valeur dans le header X-Calendar-Webhook-Secret des -# noeuds HTTP du workflow n8n. Voir README "Disponibilités (calendrier)". -CALENDAR_WEBHOOK_SECRET= diff --git a/README.md b/README.md index d089d30..b2cb782 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,7 @@ Une vraie **playlist Spotify** (persistante, sur un compte Spotify) nécessitera ## Disponibilités (calendrier) -La page `/calendar.html` montre, pour les 3 prochaines semaines, les créneaux de répétition (lun–ven 18h30–21h, sam–dim 15h–19h) où chaque membre est disponible — calculé par un workflow **n8n** externe qui interroge Google Calendar (FreeBusy API) de chaque personne. Cette fonctionnalité est une fusion complète de l'ancien projet séparé [octane-calendar](https://github.com/nfonteyne/octane-calendar) dans cette application (même thème, même authentification, un seul déploiement). +La page `/calendar.html` montre, pour les 3 prochaines semaines, les créneaux de répétition où chaque membre est disponible (par défaut lun–ven 18h30–21h, sam–dim 15h–19h — modifiable par un admin depuis `/admin.html`, section "Créneaux de répétition"). Cette section permet aussi de définir une **marge de transport** (en minutes) : la vérification de disponibilité regarde alors un peu avant et un peu après le créneau affiché, pour tenir compte du temps de trajet entre deux évènements du calendrier d'une personne — le créneau lui-même, tel qu'affiché, ne change pas. La disponibilité est déduite directement par l'application, sans service externe : chaque personne peut enregistrer un ou plusieurs calendriers (Google, Outlook, Apple, ou tout autre service exposant un flux ICS/iCal), gérés depuis la même page ("Calendriers des membres"). ### Fonctionnement @@ -286,37 +286,34 @@ La page `/calendar.html` montre, pour les 3 prochaines semaines, les créneaux d flowchart LR UI["Page /calendar.html"] API["API /api/calendar/*"] - N8N["Workflow n8n"] - GCal[("Google Calendar
FreeBusy API")] + Sync["Synchronisation (Node.js)"] + ICS[("Flux ICS de chaque personne
Google / Outlook / Apple / autre")] DB[("Postgres
calendar_*")] UI -- "Actualiser" --> API - API -- "déclenche (GET webhook)" --> N8N - N8N -- "interroge" --> GCal - N8N -- "{ slots: [...] }" --> API - API -- "ingère" --> DB + API -- "déclenche" --> Sync + Sync -- "récupère" --> ICS + Sync -- "disponible / occupé par créneau" --> DB DB -- "lecture" --> API API -- "sert les données" --> UI ``` -- Bouton **"Actualiser les disponibilités"** : appelle `POST /api/calendar/refresh`, qui déclenche le webhook n8n et attend sa réponse en tâche de fond (jusqu'à 5 min), pendant que la page sonde `GET /api/calendar/workflow-status` toutes les 4 secondes. -- Deux endpoints sont appelés **directement par n8n** (pas par le navigateur, donc pas de session possible) : `POST /api/calendar/ingest` (résultats d'une ingestion) et `POST /api/calendar/workflow-error` (notifié par le workflow d'erreur n8n). Ils sont protégés par un secret partagé plutôt que par la connexion Authentik : - ``` - X-Calendar-Webhook-Secret: - ``` - En cas de reprise d'un workflow n8n existant (depuis l'ancien `octane-calendar`), mettre à jour ses noeuds HTTP pour pointer vers `https://octane.dandrove.com/api/calendar/...` et ajouter ce header. +- Bouton **"Actualiser les disponibilités"** : appelle `POST /api/calendar/refresh`, qui récupère chaque flux ICS enregistré et calcule la disponibilité en tâche de fond, pendant que la page sonde `GET /api/calendar/workflow-status` toutes les 4 secondes. +- Une personne peut avoir plusieurs flux (par exemple un calendrier personnel Google et un calendrier professionnel Outlook) : elle est considérée occupée sur un créneau dès qu'un seul de ses flux montre un évènement chevauchant ce créneau. +- Si le flux d'une personne est temporairement inaccessible, cette personne est simplement omise du résultat de cette synchronisation (les données précédentes ne sont pas écrasées par une supposition) — un avertissement est journalisé côté serveur (identifiant de la personne uniquement, jamais l'URL du flux ni le contenu d'un évènement). -### Variables d'environnement +### Confidentialité -| Variable | Description | -|---|---| -| `N8N_WEBHOOK_URL` | URL complète du webhook n8n (production, pas l'URL de test) — **optionnel** : sans elle, la consultation des disponibilités déjà connues fonctionne, seul le bouton "Actualiser" renvoie une erreur explicite | -| `N8N_WEBHOOK_USER` / `N8N_WEBHOOK_PASS` | Identifiants HTTP basic-auth configurés sur le noeud webhook n8n, si le webhook en utilise | -| `CALENDAR_WEBHOOK_SECRET` | **Requis** — générer avec `openssl rand -hex 32`, à renseigner aussi côté n8n (header `X-Calendar-Webhook-Secret`) | +Tous les fournisseurs de calendrier n'offrent pas la même granularité de partage : + +- **Outlook / Office 365** propose un lien de partage "Disponibilité uniquement" — le flux ICS obtenu ne contient alors que des blocs occupé/libre, sans titre ni description. +- **Google Calendar** ("adresse secrète au format iCal") et **Apple iCloud** (calendrier partagé) reflètent en revanche le calendrier complet (titres, descriptions, invités, lieu), simplement protégé par une URL difficile à deviner. + +La garantie de confidentialité ne repose donc pas sur le fournisseur, mais sur le traitement effectué par l'application elle-même : chaque flux est récupéré, réduit immédiatement à un statut occupé/libre par évènement (début, fin, journée entière), puis tout le reste (titre, description, participants, lieu) est abandonné avant que quoi que ce soit ne soit journalisé, stocké ou renvoyé par l'API. Seul un booléen disponible/occupé par créneau et par personne est conservé en base — jamais le contenu des calendriers. ### Personnes suivies -La liste des personnes (et leur couleur) est amorcée en base par la migration `006_calendar_seed_people.sql` (Nathan, Raphaël, Yann, Jules, AK — mêmes noms que dans le workflow n8n d'origine, dont le mapping calendrier Google ↔ nom se fait dans le noeud "Build FreeBusy Request"). De nouvelles personnes apparaissant dans les données ingérées sont ajoutées automatiquement avec une couleur de la palette. +La liste des personnes (et leur couleur) est amorcée en base par la migration `006_calendar_seed_people.sql` (Nathan, Raphaël, Yann, Jules, AK). De nouvelles personnes peuvent être ajoutées directement en base ; leurs flux de calendrier se gèrent ensuite depuis `/admin.html`. ## Prérequis @@ -351,8 +348,6 @@ Le [Démarrage rapide](#démarrage-rapide-serveur-avec-traefik) ci-dessus couvre | `APP_DOMAIN` | Nom de domaine public utilisé par Traefik pour router vers l'app (ex: `octane.dandrove.com`) | | `APP_PORT` | Port hôte utilisé uniquement par `docker-compose.dev.yml` (test local sans Traefik) | | `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` / `YOUTUBE_API_KEY` | Optionnels — voir [Recherche automatique de morceaux](#recherche-automatique-de-morceaux) | -| `N8N_WEBHOOK_URL` / `N8N_WEBHOOK_USER` / `N8N_WEBHOOK_PASS` | Optionnels — voir [Disponibilités (calendrier)](#disponibilités-calendrier) | -| `CALENDAR_WEBHOOK_SECRET` | Requis — voir [Disponibilités (calendrier)](#disponibilités-calendrier) | Les migrations SQL (`src/db/migrations/*.sql`) sont exécutées automatiquement au démarrage du conteneur `app`, de façon idempotente (une table `schema_migrations` garde la trace des fichiers déjà appliqués). @@ -406,7 +401,8 @@ octane-website/ │ ├── auth/ # OIDC (Authentik), session, middleware, routes /auth │ ├── routes/ # routes API /api/* │ ├── repositories/ # accès SQL par table -│ └── lib/ # helpers purs (validation YouTube/Spotify) — couverts par les tests +│ ├── services/ # orchestration avec effets de bord (ex: synchronisation calendrier) +│ └── lib/ # helpers purs (dates, validation YouTube/Spotify...) — couverts par les tests └── public/ ├── *.html # une page par fonctionnalité ├── css/style.css diff --git a/package-lock.json b/package-lock.json index 7c21255..85b6e80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,10 +12,23 @@ "dotenv": "^16.4.5", "express": "^4.21.1", "express-session": "^1.18.1", + "node-ical": "^0.26.1", "openid-client": "^6.1.7", "pg": "^8.13.1" } }, + "node_modules/@js-temporal/polyfill": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz", + "integrity": "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==", + "license": "ISC", + "dependencies": { + "jsbi": "^4.3.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -502,6 +515,12 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/jsbi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.2.tgz", + "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==", + "license": "Apache-2.0" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -586,6 +605,19 @@ "node": ">= 0.6" } }, + "node_modules/node-ical": { + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/node-ical/-/node-ical-0.26.1.tgz", + "integrity": "sha512-KoYLpsz7Ga9lPDpt9vy0iKcgcb/9Ix7ICRZd0csLXMl2lZOSONGj7HrcktJFR7Jid1l44Zu1H4k/1nB04rWPgQ==", + "license": "Apache-2.0", + "dependencies": { + "rrule-temporal": "^1.5.3", + "temporal-polyfill": "^0.3.2" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/oauth4webapi": { "version": "3.8.6", "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", @@ -846,6 +878,16 @@ "node": ">= 0.8" } }, + "node_modules/rrule-temporal": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/rrule-temporal/-/rrule-temporal-1.6.0.tgz", + "integrity": "sha512-tlhiNroletItRVdVP3knk92MCPNdFmPpehQMxf+jSo0CHZpmp1WUsvdVpg2iS++J9+O7/89J64BldN21kbcBqA==", + "license": "MIT", + "dependencies": { + "@js-temporal/polyfill": "^0.5.1", + "temporal-spec": "^1.0.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1013,6 +1055,27 @@ "node": ">= 0.8" } }, + "node_modules/temporal-polyfill": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-0.3.2.tgz", + "integrity": "sha512-TzHthD/heRK947GNiSu3Y5gSPpeUDH34+LESnfsq8bqpFhsB79HFBX8+Z834IVX68P3EUyRPZK5bL/1fh437Eg==", + "license": "MIT", + "dependencies": { + "temporal-spec": "0.3.1" + } + }, + "node_modules/temporal-polyfill/node_modules/temporal-spec": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-0.3.1.tgz", + "integrity": "sha512-B4TUhezh9knfSIMwt7RVggApDRJZo73uZdj8AacL2mZ8RP5KtLianh2MXxL06GN9ESYiIsiuoLQhgVfwe55Yhw==", + "license": "ISC" + }, + "node_modules/temporal-spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-1.0.0.tgz", + "integrity": "sha512-00Ahj1e1ifaERTMOIIGpOCdOo9IEk2m6GGSMedsn9a2SIsGLdOTbmME1Htv6IM82b6VHrzSUTIVc7YHy6hdhFQ==", + "license": "Apache-2.0" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/package.json b/package.json index 3e4e338..02691d5 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dotenv": "^16.4.5", "express": "^4.21.1", "express-session": "^1.18.1", + "node-ical": "^0.26.1", "openid-client": "^6.1.7", "pg": "^8.13.1" } diff --git a/public/css/style.css b/public/css/style.css index 122cdb8..8807b5c 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1323,3 +1323,37 @@ a.back-link:hover { color: var(--accent); } font-size: 0.88rem; white-space: nowrap; } + +.calendar-person-row { + flex-direction: column; + align-items: stretch; + gap: 0.6rem; +} + +.calendar-color-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex: 0 0 auto; +} + +.calendar-feeds { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.feed-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.6rem; + background: var(--surface-alt); + border-radius: var(--radius-sm); + padding: 0.4rem 0.6rem; + font-size: 0.88rem; +} + +.add-feed-form input { + font-size: 0.85rem; +} diff --git a/public/js/admin.js b/public/js/admin.js index 06f1881..e538443 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -35,6 +35,112 @@ function adminUserRowTemplate(u) { `; } +// Shown for a registered feed instead of its full URL — an admin can already +// see the real value via the "add calendar" flow when they set it, but the +// list view doesn't need to keep re-displaying the plaintext on every load. +function maskIcsUrl(url) { + try { + const u = new URL(url); + return `${u.hostname}/••••`; + } catch { + return '••••'; + } +} + +function feedRowTemplate(feed) { + return ` +
+ ${feed.label ? `${escapeHtml(feed.label)} — ` : ''}${escapeHtml(maskIcsUrl(feed.icsUrl))} + +
+ `; +} + +function calendarPersonRowTemplate(person) { + return ` +
+
+ +
${escapeHtml(person.name)}
+
+
+ ${person.feeds.length ? person.feeds.map(feedRowTemplate).join('') : '

Aucun calendrier configuré.

'} +
+ + + +
+
+
+ `; +} + +async function loadCalendarPeople() { + const people = await api.get('/api/calendar/people/admin'); + const container = document.getElementById('calendar-people-list'); + container.innerHTML = people.length + ? people.map(calendarPersonRowTemplate).join('') + : '

Aucune personne suivie pour le moment.

'; + + container.querySelectorAll('.add-feed-form').forEach((form) => { + form.addEventListener('submit', (e) => onAddFeed(e, parseInt(form.dataset.personId, 10))); + }); + container.querySelectorAll('.remove-feed-btn').forEach((btn) => { + btn.addEventListener('click', () => onRemoveFeed(parseInt(btn.dataset.feedId, 10))); + }); +} + +async function onAddFeed(e, personId) { + e.preventDefault(); + const form = e.target; + const label = form.label.value.trim(); + const icsUrl = form.icsUrl.value.trim(); + try { + await api.post(`/api/calendar/people/${personId}/feeds`, { label, icsUrl }); + await loadCalendarPeople(); + } catch (err) { + showError(err.message); + } +} + +async function onRemoveFeed(feedId) { + try { + await api.del(`/api/calendar/feeds/${feedId}`); + await loadCalendarPeople(); + } catch (err) { + showError(err.message); + } +} + +async function loadSlotSettingsForm() { + const settings = await api.get('/api/calendar/settings'); + const form = document.getElementById('slot-settings-form'); + form.weekdayStart.value = settings.weekdayStart; + form.weekdayEnd.value = settings.weekdayEnd; + form.weekendStart.value = settings.weekendStart; + form.weekendEnd.value = settings.weekendEnd; + form.marginMinutes.value = settings.marginMinutes; +} + +async function onSaveSlotSettings(e) { + e.preventDefault(); + const form = e.target; + const statusEl = document.getElementById('slot-settings-status'); + statusEl.textContent = ''; + try { + await api.patch('/api/calendar/settings', { + weekdayStart: form.weekdayStart.value, + weekdayEnd: form.weekdayEnd.value, + weekendStart: form.weekendStart.value, + weekendEnd: form.weekendEnd.value, + marginMinutes: parseInt(form.marginMinutes.value, 10), + }); + statusEl.textContent = 'Horaires enregistrés.'; + } catch (err) { + showError(err.message); + } +} + (async function init() { const me = await initNav('admin'); if (!me.isAdmin) { @@ -68,7 +174,40 @@ function adminUserRowTemplate(u) {
${stats.users.map(adminUserRowTemplate).join('')}
+ +

Créneaux de répétition

+

Horaires utilisés pour calculer les disponibilités sur `/calendar.html`.

+
+
+ + + + + +

+ La marge élargit uniquement la vérification de disponibilité (avant/après le créneau), + pour tenir compte du temps de trajet entre deux évènements — le créneau affiché ne change pas. +

+

+ +
+
+ +

Calendriers des membres

+

+ Chaque personne peut avoir plusieurs calendriers (Google, Outlook, Apple...). L'application ne + conserve jamais le contenu de ces calendriers — seul un statut disponible/occupé par créneau est + déduit et enregistré. +

+
+

Chargement…

+
`; + document.getElementById('slot-settings-form').addEventListener('submit', onSaveSlotSettings); + await loadSlotSettingsForm(); + await loadCalendarPeople(); } catch (err) { showError(err.message); } diff --git a/public/js/calendar.js b/public/js/calendar.js index 4af4b9b..6f301fb 100644 --- a/public/js/calendar.js +++ b/public/js/calendar.js @@ -202,7 +202,7 @@ function pollWorkflowStatus(btn, maxMs = 180000, intervalMs = 4000) { const timer = setInterval(async () => { if (Date.now() - started > maxMs) { clearInterval(timer); - showToast('Le workflow a expiré — aucun résultat après 3 minutes', true); + showToast('La synchronisation a expiré — aucun résultat après 3 minutes', true); resetRefreshButton(btn); return; } @@ -216,8 +216,7 @@ function pollWorkflowStatus(btn, maxMs = 180000, intervalMs = 4000) { await loadLastChecked(); } else if (data.status === 'error') { clearInterval(timer); - const detail = data.node ? ` (nœud : ${data.node})` : ''; - showToast('Erreur du workflow : ' + (data.message || 'inconnue') + detail, true); + showToast('Erreur lors de la synchronisation : ' + (data.message || 'inconnue'), true); resetRefreshButton(btn); } } catch (err) { diff --git a/src/app.js b/src/app.js index 79d4378..ef4a3dd 100644 --- a/src/app.js +++ b/src/app.js @@ -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'))); diff --git a/src/config.js b/src/config.js index b25bad0..4443151 100644 --- a/src/config.js +++ b/src/config.js @@ -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'), }; diff --git a/src/db/migrations/008_calendar_feeds.sql b/src/db/migrations/008_calendar_feeds.sql new file mode 100644 index 0000000..bff5708 --- /dev/null +++ b/src/db/migrations/008_calendar_feeds.sql @@ -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); diff --git a/src/db/migrations/009_calendar_settings.sql b/src/db/migrations/009_calendar_settings.sql new file mode 100644 index 0000000..27a04c6 --- /dev/null +++ b/src/db/migrations/009_calendar_settings.sql @@ -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); diff --git a/src/db/migrations/010_calendar_settings_margin.sql b/src/db/migrations/010_calendar_settings_margin.sql new file mode 100644 index 0000000..3fdc539 --- /dev/null +++ b/src/db/migrations/010_calendar_settings_margin.sql @@ -0,0 +1 @@ +ALTER TABLE calendar_settings ADD COLUMN IF NOT EXISTS margin_minutes SMALLINT NOT NULL DEFAULT 0; diff --git a/src/lib/calendarAvailability.js b/src/lib/calendarAvailability.js new file mode 100644 index 0000000..ba7daf3 --- /dev/null +++ b/src/lib/calendarAvailability.js @@ -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 }; diff --git a/src/lib/calendarDates.js b/src/lib/calendarDates.js index 2a8de7a..c50f1ea 100644 --- a/src/lib/calendarDates.js +++ b/src/lib/calendarDates.js @@ -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 }; diff --git a/src/lib/calendarWebhookAuth.js b/src/lib/calendarWebhookAuth.js deleted file mode 100644 index 62c4b85..0000000 --- a/src/lib/calendarWebhookAuth.js +++ /dev/null @@ -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; diff --git a/src/lib/calendarWorkflowState.js b/src/lib/calendarWorkflowState.js index 3781579..1217bfe 100644 --- a/src/lib/calendarWorkflowState.js +++ b/src/lib/calendarWorkflowState.js @@ -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 }; diff --git a/src/repositories/calendarRepo.js b/src/repositories/calendarRepo.js index aa33623..4c3969c 100644 --- a/src/repositories/calendarRepo.js +++ b/src/repositories/calendarRepo.js @@ -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, +}; diff --git a/src/routes/calendar.js b/src/routes/calendar.js index 8ae8006..240b251 100644 --- a/src/routes/calendar.js +++ b/src/routes/calendar.js @@ -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; diff --git a/src/routes/calendarWebhooks.js b/src/routes/calendarWebhooks.js deleted file mode 100644 index f419c99..0000000 --- a/src/routes/calendarWebhooks.js +++ /dev/null @@ -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; diff --git a/src/services/calendarSync.js b/src/services/calendarSync.js new file mode 100644 index 0000000..9395b54 --- /dev/null +++ b/src/services/calendarSync.js @@ -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 }; diff --git a/test/calendarAvailability.test.js b/test/calendarAvailability.test.js new file mode 100644 index 0000000..4ca3690 --- /dev/null +++ b/test/calendarAvailability.test.js @@ -0,0 +1,114 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { generateSlots, isBusyDuring, widenWindow } = require('../src/lib/calendarAvailability'); + +test('generateSlots: default 3 weeks produces one slot per day for 21 days', () => { + const slots = generateSlots(); + assert.equal(slots.length, 21); +}); + +test('generateSlots: caps weeks at 3 even if a larger value is requested', () => { + const slots = generateSlots(10); + assert.equal(slots.length, 21); +}); + +test('generateSlots: every slot has upper strictly after lower', () => { + for (const slot of generateSlots()) { + assert.ok(slot.upper > slot.lower, `${slot.lower.toISOString()} .. ${slot.upper.toISOString()}`); + } +}); + +test('generateSlots: weekday slots span 18:30-21:00 and weekend slots span 15:00-19:00, Paris-local', () => { + for (const slot of generateSlots()) { + const dow = new Date(slot.lower).toLocaleDateString('en-US', { timeZone: 'Europe/Paris', weekday: 'short' }); + const startLocal = slot.lower.toLocaleTimeString('en-GB', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' }); + const endLocal = slot.upper.toLocaleTimeString('en-GB', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' }); + const isWeekend = dow === 'Sat' || dow === 'Sun'; + if (isWeekend) { + assert.equal(startLocal, '15:00'); + assert.equal(endLocal, '19:00'); + } else { + assert.equal(startLocal, '18:30'); + assert.equal(endLocal, '21:00'); + } + } +}); + +test('isBusyDuring: true when a busy interval overlaps the slot', () => { + const slotLower = new Date('2026-06-10T18:30:00Z'); + const slotUpper = new Date('2026-06-10T21:00:00Z'); + const intervals = [{ start: new Date('2026-06-10T19:00:00Z'), end: new Date('2026-06-10T20:00:00Z') }]; + assert.equal(isBusyDuring(intervals, slotLower, slotUpper), true); +}); + +test('isBusyDuring: false when no interval overlaps the slot', () => { + const slotLower = new Date('2026-06-10T18:30:00Z'); + const slotUpper = new Date('2026-06-10T21:00:00Z'); + const intervals = [{ start: new Date('2026-06-10T10:00:00Z'), end: new Date('2026-06-10T11:00:00Z') }]; + assert.equal(isBusyDuring(intervals, slotLower, slotUpper), false); +}); + +test('isBusyDuring: false for an interval that ends exactly when the slot starts (half-open)', () => { + const slotLower = new Date('2026-06-10T18:30:00Z'); + const slotUpper = new Date('2026-06-10T21:00:00Z'); + const intervals = [{ start: new Date('2026-06-10T17:00:00Z'), end: new Date('2026-06-10T18:30:00Z') }]; + assert.equal(isBusyDuring(intervals, slotLower, slotUpper), false); +}); + +test('isBusyDuring: true for an all-day-style interval spanning the whole day', () => { + const slotLower = new Date('2026-06-10T18:30:00Z'); + const slotUpper = new Date('2026-06-10T21:00:00Z'); + const intervals = [{ start: new Date('2026-06-10T00:00:00Z'), end: new Date('2026-06-11T00:00:00Z') }]; + assert.equal(isBusyDuring(intervals, slotLower, slotUpper), true); +}); + +test('generateSlots: a custom slotConfig overrides the default hours', () => { + const customConfig = { + weekday: { startHour: 10, startMinute: 0, endHour: 11, endMinute: 30 }, + weekend: { startHour: 9, startMinute: 15, endHour: 10, endMinute: 0 }, + }; + for (const slot of generateSlots(3, customConfig)) { + const dow = new Date(slot.lower).toLocaleDateString('en-US', { timeZone: 'Europe/Paris', weekday: 'short' }); + const startLocal = slot.lower.toLocaleTimeString('en-GB', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' }); + const endLocal = slot.upper.toLocaleTimeString('en-GB', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' }); + const isWeekend = dow === 'Sat' || dow === 'Sun'; + if (isWeekend) { + assert.equal(startLocal, '09:15'); + assert.equal(endLocal, '10:00'); + } else { + assert.equal(startLocal, '10:00'); + assert.equal(endLocal, '11:30'); + } + } +}); + +test('widenWindow: expands lower/upper by the margin on each side', () => { + const lower = new Date('2026-06-10T18:30:00Z'); + const upper = new Date('2026-06-10T21:00:00Z'); + const widened = widenWindow(lower, upper, 30); + assert.equal(widened.lower.toISOString(), '2026-06-10T18:00:00.000Z'); + assert.equal(widened.upper.toISOString(), '2026-06-10T21:30:00.000Z'); +}); + +test('widenWindow: zero or missing margin leaves the window unchanged', () => { + const lower = new Date('2026-06-10T18:30:00Z'); + const upper = new Date('2026-06-10T21:00:00Z'); + assert.deepEqual(widenWindow(lower, upper, 0), { lower, upper }); + assert.deepEqual(widenWindow(lower, upper, undefined), { lower, upper }); +}); + +test('widenWindow + isBusyDuring: an event just outside the raw slot but inside the margin marks it busy', () => { + const lower = new Date('2026-06-10T19:00:00Z'); + const upper = new Date('2026-06-10T20:30:00Z'); + // Ends 15 minutes before the slot starts — doesn't overlap the raw slot. + const intervals = [{ start: new Date('2026-06-10T18:30:00Z'), end: new Date('2026-06-10T18:45:00Z') }]; + assert.equal(isBusyDuring(intervals, lower, upper), false); + const { lower: checkLower, upper: checkUpper } = widenWindow(lower, upper, 30); + assert.equal(isBusyDuring(intervals, checkLower, checkUpper), true); +}); + +test('isBusyDuring: empty interval list is never busy', () => { + const slotLower = new Date('2026-06-10T18:30:00Z'); + const slotUpper = new Date('2026-06-10T21:00:00Z'); + assert.equal(isBusyDuring([], slotLower, slotUpper), false); +}); diff --git a/test/calendarDates.test.js b/test/calendarDates.test.js index b4194ea..e93fbf3 100644 --- a/test/calendarDates.test.js +++ b/test/calendarDates.test.js @@ -1,6 +1,6 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); -const { normalizeISO, slotDateParis, dayOfWeekParis } = require('../src/lib/calendarDates'); +const { normalizeISO, slotDateParis, dayOfWeekParis, parisWallClockToUTC } = require('../src/lib/calendarDates'); test('normalizeISO: collapses sub-minute noise to the same instant', () => { const a = normalizeISO('2026-06-10T18:30:12.345Z'); @@ -47,3 +47,30 @@ test('dayOfWeekParis: DST boundary days still resolve to Sunday (7)', () => { assert.equal(dayOfWeekParis('2026-03-29T00:30:00Z'), 7); assert.equal(dayOfWeekParis('2026-10-25T01:30:00Z'), 7); }); + +test('parisWallClockToUTC: winter (CET, UTC+1)', () => { + const d = parisWallClockToUTC(2026, 1, 15, 18, 30); + assert.equal(d.toISOString(), '2026-01-15T17:30:00.000Z'); +}); + +test('parisWallClockToUTC: summer (CEST, UTC+2)', () => { + const d = parisWallClockToUTC(2026, 7, 15, 18, 30); + assert.equal(d.toISOString(), '2026-07-15T16:30:00.000Z'); +}); + +test('parisWallClockToUTC: spring-forward DST boundary (2026-03-29, CET->CEST at 01:00 UTC)', () => { + // Just before the jump: still CET (UTC+1). + assert.equal(parisWallClockToUTC(2026, 3, 29, 1, 30).toISOString(), '2026-03-29T00:30:00.000Z'); + // Same evening, after the jump: already CEST (UTC+2). + assert.equal(parisWallClockToUTC(2026, 3, 29, 18, 30).toISOString(), '2026-03-29T16:30:00.000Z'); +}); + +test('parisWallClockToUTC: fall-back DST boundary (2026-10-25, CEST->CET at 01:00 UTC)', () => { + // Same evening, after the jump: already CET (UTC+1). + assert.equal(parisWallClockToUTC(2026, 10, 25, 18, 30).toISOString(), '2026-10-25T17:30:00.000Z'); +}); + +test('parisWallClockToUTC and slotDateParis round-trip to the same local date', () => { + const d = parisWallClockToUTC(2026, 3, 29, 18, 30); + assert.equal(slotDateParis(d.toISOString()), '2026-03-29'); +});