diff --git a/.env.example b/.env.example index c779814..a047dda 100644 --- a/.env.example +++ b/.env.example @@ -44,3 +44,17 @@ 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 78eb684..82c9bab 100644 --- a/README.md +++ b/README.md @@ -238,10 +238,11 @@ erDiagram | Page | Accès | Description | |---|---|---| | `/index.html` | Tous (lecture et écriture) | Répertoire des morceaux travaillés, liens/vignettes YouTube et Spotify, tutos embarqués par morceau et par instrument. Ajout avec autocomplete titre/artiste + liens auto-trouvés ([détails](#recherche-automatique-de-morceaux)) | -| `/suggestions.html` | Tous | Proposer un morceau (avec lien YouTube embarqué + note libre), voter approuver/rejeter avec commentaire, attribué nominativement | +| `/suggestions.html` | Tous | Proposer un morceau (liens YouTube et Spotify + note libre), voter approuver/rejeter avec commentaire (attribué nominativement), ajouter une suggestion au répertoire | | `/setlist.html` | Tous (lecture et écriture) | Setlist du prochain concert : choix des morceaux du répertoire, ordre, notes, section rappel | -| `/history.html`, `/history-detail.html` | Tous (lecture seule) | Historique des setlists des concerts passés | +| `/history.html`, `/history-detail.html` | Tous (lecture et écriture) | Historique des setlists des concerts passés — modifiable (date, morceaux, ordre, rappel) et supprimable en cas d'erreur | | `/profile.html` | Chacun voit le sien | Profil issu d'Authentik (nom, avatar, groupes) + votre activité (morceaux ajoutés, suggestions, votes) | +| `/calendar.html` | Tous (lecture et écriture) | Disponibilités du groupe pour les 3 prochaines semaines (calendrier, filtres par personne, modale par jour) — [détails](#disponibilités-calendrier) | Le mode par défaut est la consultation ; les pages Répertoire, Setlist et Suggestions sont interactives pour toute personne connectée (chaque action reste attribuée nominativement via Authentik). @@ -249,8 +250,8 @@ Un bouton clair/sombre dans la barre de navigation permet de forcer un thème (m ## Rôles -- **Membre** : tout le monde — consulte, ajoute/modifie/supprime des morceaux du répertoire et leurs tutos, crée/modifie des concerts et leur setlist, propose des suggestions, vote/commente. -- **Admin** : en plus, modère les suggestions (promouvoir une suggestion approuvée en morceau du répertoire, la rejeter, la supprimer). +- **Membre** : tout le monde — consulte, ajoute/modifie/supprime des morceaux du répertoire et leurs tutos, crée/modifie/supprime des concerts (à venir ou passés) et leur setlist, propose des suggestions, vote/commente, ajoute une suggestion au répertoire. +- **Admin** : en plus, rejette ou supprime une suggestion (modération). Le rôle admin n'est volontairement pas plus étendu pour l'instant : son périmètre exact (au-delà de la modération des suggestions) reste ouvert et pourra évoluer. Il n'y a pas de gestion des utilisateurs dans l'application elle-même — Authentik reste la seule source de vérité pour qui a accès et qui est admin (claim `groups`, recalculé à chaque connexion). @@ -269,6 +270,48 @@ Si aucune des deux n'est configurée, l'autocomplete titre/artiste marche quand Si la recherche ne trouve rien (morceau trop obscur, faute de frappe...), rien ne bloque : titre, artiste et liens restent modifiables à la main comme avant. +## 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). + +### Fonctionnement + +```mermaid +flowchart LR + UI["Page /calendar.html"] + API["API /api/calendar/*"] + N8N["Workflow n8n"] + GCal[("Google Calendar
FreeBusy API")] + DB[("Postgres
calendar_*")] + + UI -- "Actualiser" --> API + API -- "déclenche (GET webhook)" --> N8N + N8N -- "interroge" --> GCal + N8N -- "{ slots: [...] }" --> API + API -- "ingère" --> 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: + ``` + Si vous reprenez un workflow n8n existant (depuis l'ancien `octane-calendar`), mettez à jour ses noeuds HTTP pour pointer vers `https://octane.dandrove.com/api/calendar/...` et ajouter ce header. + +### Variables d'environnement + +| 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 vous en avez mis un | +| `CALENDAR_WEBHOOK_SECRET` | **Requis** — générer avec `openssl rand -hex 32`, à renseigner aussi côté n8n (header `X-Calendar-Webhook-Secret`) | + +### 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. + ## Prérequis - Docker + Docker Compose @@ -300,6 +343,9 @@ Le [Démarrage rapide](#démarrage-rapide-serveur-avec-traefik) ci-dessus couvre | `TRAEFIK_NETWORK_NAME` | Nom du réseau Docker externe partagé avec Traefik et Authentik (défaut `traefik-proxy`) | | `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). diff --git a/public/calendar.html b/public/calendar.html new file mode 100644 index 0000000..9db8452 --- /dev/null +++ b/public/calendar.html @@ -0,0 +1,71 @@ + + + + + +Disponibilités — Octane + + + + +
+ +
+
+
+
+

Disponibilités

+

Chargement…

+
+
+ + +
+
+
+ +
+
+ + +
+
+ LunMarMerJeuVenSamDim +
+
+
+
+
+
+ + + + + + + + + + + diff --git a/public/css/style.css b/public/css/style.css index d82c30e..a0c940f 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -769,6 +769,182 @@ a.back-link { a.back-link:hover { color: var(--accent); } +/* ---------- Calendar (availability) ---------- */ + +.calendar-filters-toggle, +.calendar-filters-close { display: none; } + +.calendar-layout { + display: grid; + grid-template-columns: 220px 1fr; + gap: 1.25rem; + align-items: start; +} + +.calendar-filters { position: sticky; top: 76px; } + +.calendar-people-list { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.person-toggle { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.88rem; + cursor: pointer; + padding: 0.2rem 0; +} + +.person-toggle.excluded { opacity: 0.4; } + +.person-dot { + width: 10px; + height: 10px; + border-radius: 999px; + flex: 0 0 auto; +} + +.calendar-filters-backdrop { display: none; } + +.calendar-header-row { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 0.5rem; + color: var(--muted); + font-size: 0.8rem; + font-weight: 600; + text-align: center; + margin-bottom: 0.4rem; +} + +.calendar-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 0.5rem; +} + +.cal-cell { + min-height: 92px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + padding: 0.4rem 0.5rem; + font-size: 0.78rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.cal-cell.today { border-color: var(--accent); border-width: 2px; } +.cal-cell.empty { background: transparent; border-style: dashed; color: var(--muted); } +.cal-cell.has-slot { cursor: pointer; } +.cal-cell.has-slot:hover { border-color: var(--accent); } + +.cell-date { font-weight: 600; color: var(--muted); } +.cell-date .day-num { color: var(--text); font-size: 0.95rem; margin-right: 0.25rem; } + +.no-slot-label { color: var(--muted); font-style: italic; margin-top: auto; } + +.cell-time { color: var(--muted); font-size: 0.72rem; } + +.cell-dots { display: flex; flex-wrap: wrap; gap: 0.2rem; margin-top: auto; } + +.cell-dot { width: 8px; height: 8px; border-radius: 999px; } +.cell-dot.busy { opacity: 0.3; } + +.cell-count { font-size: 0.72rem; color: var(--muted); align-self: flex-end; } + +.cal-cell.all-available { background: var(--success-bg); border-color: var(--success); } +.cal-cell.heat-1 { background: color-mix(in srgb, var(--success-bg) 80%, var(--danger-bg)); } +.cal-cell.heat-2 { background: color-mix(in srgb, var(--success-bg) 55%, var(--danger-bg)); } +.cal-cell.heat-3 { background: color-mix(in srgb, var(--danger-bg) 55%, var(--success-bg)); } +.cal-cell.heat-4 { background: color-mix(in srgb, var(--danger-bg) 80%, var(--success-bg)); } +.cal-cell.heat-5 { background: var(--danger-bg); border-color: var(--danger); } + +.calendar-legend { + display: flex; + flex-wrap: wrap; + gap: 0.9rem; + margin-top: 1rem; + font-size: 0.8rem; + color: var(--muted); +} + +.legend-item { display: flex; align-items: center; gap: 0.4rem; } + +.legend-dot { width: 10px; height: 10px; border-radius: 999px; display: inline-block; } +.legend-dot-all { background: var(--success-bg); border: 1px solid var(--success); } + +/* ---------- Modal & toast (calendar) ---------- */ + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + padding: 1rem; +} + +.modal-overlay.hidden { display: none; } + +.modal { + position: relative; + max-width: 360px; + width: 100%; +} + +.modal-close { + position: absolute; + top: 0.6rem; + right: 0.6rem; +} + +.modal-time { margin-top: 0.1rem; } + +.modal-section { margin-top: 1rem; } + +.modal-section-title { + font-size: 0.8rem; + font-weight: 600; + margin-bottom: 0.4rem; +} + +.modal-section-title.available { color: var(--success); } +.modal-section-title.busy { color: var(--danger); } + +.modal-people { display: flex; flex-direction: column; gap: 0.35rem; } + +.modal-person { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.88rem; +} + +.toast { + position: fixed; + bottom: 1.25rem; + left: 50%; + transform: translateX(-50%); + background: var(--surface); + border: 1px solid var(--border); + box-shadow: var(--shadow); + border-radius: var(--radius-sm); + padding: 0.6rem 1rem; + font-size: 0.88rem; + z-index: 200; + max-width: min(90vw, 420px); +} + +.toast.hidden { display: none; } +.toast.error { border-color: var(--danger); color: var(--danger); } + /* ---------- Responsive / mobile ---------- */ @media (max-width: 720px) { @@ -825,4 +1001,38 @@ a.back-link:hover { color: var(--accent); } .setlist-row .row-actions { flex-basis: 100%; order: 5; justify-content: flex-end; } .profile-header { flex-direction: column; text-align: center; } + + .calendar-filters-close { display: inline-flex; } + + .calendar-layout { grid-template-columns: 1fr; } + + .calendar-filters { + position: fixed; + top: 0; + bottom: 0; + left: 0; + width: 260px; + max-width: 80vw; + z-index: 110; + border-radius: 0; + transform: translateX(-100%); + transition: transform 0.2s ease; + overflow-y: auto; + } + + .calendar-filters.open { transform: translateX(0); } + + .calendar-filters-backdrop { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 105; + } + + .calendar-filters-backdrop.open { display: block; } + + .calendar-header-row span:nth-child(n) { font-size: 0.7rem; } + + .cal-cell { min-height: 64px; font-size: 0.7rem; } } diff --git a/public/history-detail.html b/public/history-detail.html index fff08a2..950f563 100644 --- a/public/history-detail.html +++ b/public/history-detail.html @@ -20,6 +20,7 @@ + diff --git a/public/js/calendar.js b/public/js/calendar.js new file mode 100644 index 0000000..e994726 --- /dev/null +++ b/public/js/calendar.js @@ -0,0 +1,337 @@ +let state = { + people: [], + slots: [], + personIds: [], +}; + +async function loadPeople() { + state.people = await api.get('/api/calendar/people'); + state.personIds = state.people.map((p) => p.id); +} + +async function loadSlots() { + state.slots = await api.get('/api/calendar/slots?weeks=3'); + renderCalendar(); +} + +async function loadLastChecked() { + const { last_checked } = await api.get('/api/calendar/last-checked'); + const el = document.getElementById('last-checked'); + el.textContent = last_checked + ? 'Dernière mise à jour : ' + formatDatetime(last_checked) + : 'Aucune donnée pour le moment — cliquez sur Actualiser'; +} + +function isoDate(d) { + return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); +} + +function shortMonth(d) { + return d.toLocaleDateString('fr-FR', { month: 'short' }); +} + +function formatTime(iso) { + return new Date(iso).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' }); +} + +function formatDatetime(iso) { + return new Date(iso).toLocaleString('fr-FR', { + day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', + }); +} + +function renderCalendar() { + const grid = document.getElementById('calendar-grid'); + grid.innerHTML = ''; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const selectedSet = new Set(state.personIds); + + const slotMap = new Map(); + for (const slot of state.slots) slotMap.set(slot.slot_date, slot); + + // Grid starts on the Monday of the current week so columns align Mon->Sun. + const start = new Date(today); + const dow = start.getDay(); + start.setDate(start.getDate() + (dow === 0 ? -6 : 1 - dow)); + + const daysBeforeToday = Math.round((today - start) / 86400000); + const totalDays = Math.ceil((daysBeforeToday + 21) / 7) * 7; + + for (let i = 0; i < totalDays; i++) { + const date = new Date(start); + date.setDate(start.getDate() + i); + + const isPastDay = date < today; + const isBeyond21 = i >= daysBeforeToday + 21; + const isToday = date.getTime() === today.getTime(); + const slot = slotMap.get(isoDate(date)); + + const cell = document.createElement('div'); + cell.className = 'cal-cell' + (isToday ? ' today' : ''); + + if (isBeyond21) { + cell.classList.add('empty'); + grid.appendChild(cell); + continue; + } + + const dateLabel = document.createElement('div'); + dateLabel.className = 'cell-date'; + dateLabel.innerHTML = `${date.getDate()}${shortMonth(date)}`; + cell.appendChild(dateLabel); + + if (isPastDay) { + cell.classList.add('empty'); + const lbl = document.createElement('div'); + lbl.className = 'no-slot-label'; + lbl.textContent = 'passé'; + cell.appendChild(lbl); + } else if (slot) { + const visible = slot.people.filter((p) => selectedSet.has(p.id)); + const avail = visible.filter((p) => p.is_available); + + cell.classList.add('has-slot'); + if (visible.length > 0) { + if (avail.length === visible.length) { + cell.classList.add('all-available'); + } else { + const unavailRatio = (visible.length - avail.length) / visible.length; + if (unavailRatio <= 0.2) cell.classList.add('heat-1'); + else if (unavailRatio <= 0.4) cell.classList.add('heat-2'); + else if (unavailRatio <= 0.6) cell.classList.add('heat-3'); + else if (unavailRatio <= 0.8) cell.classList.add('heat-4'); + else cell.classList.add('heat-5'); + } + } + + const timeEl = document.createElement('div'); + timeEl.className = 'cell-time'; + timeEl.textContent = formatTime(slot.lower) + '–' + formatTime(slot.upper); + cell.appendChild(timeEl); + + const dotsEl = document.createElement('div'); + dotsEl.className = 'cell-dots'; + for (const person of visible) { + const dot = document.createElement('div'); + dot.className = 'cell-dot' + (person.is_available ? '' : ' busy'); + dot.style.backgroundColor = person.color; + dot.title = person.name + (person.is_available ? ' ✓' : ' ✗'); + dotsEl.appendChild(dot); + } + cell.appendChild(dotsEl); + + cell.addEventListener('click', () => openModal(date, slot, visible)); + + const countEl = document.createElement('div'); + countEl.className = 'cell-count'; + countEl.textContent = avail.length + '/' + visible.length; + cell.appendChild(countEl); + } else { + cell.classList.add('empty'); + const lbl = document.createElement('div'); + lbl.className = 'no-slot-label'; + lbl.textContent = 'aucune donnée'; + cell.appendChild(lbl); + } + + grid.appendChild(cell); + } +} + +function buildFilters() { + const listEl = document.getElementById('people-list'); + for (const person of state.people) { + const row = document.createElement('label'); + row.className = 'person-toggle'; + row.dataset.id = person.id; + + const dot = document.createElement('span'); + dot.className = 'person-dot'; + dot.style.backgroundColor = person.color; + + row.appendChild(dot); + row.appendChild(document.createTextNode(person.name)); + + row.addEventListener('click', () => { + const id = person.id; + if (state.personIds.includes(id)) { + if (state.personIds.length === 1) return; + state.personIds = state.personIds.filter((x) => x !== id); + row.classList.add('excluded'); + } else { + state.personIds.push(id); + row.classList.remove('excluded'); + } + renderCalendar(); + }); + + listEl.appendChild(row); + } + + buildLegend(); + + document.getElementById('btn-refresh').addEventListener('click', async () => { + const btn = document.getElementById('btn-refresh'); + btn.disabled = true; + btn.textContent = 'Actualisation…'; + showToast('Déclenchement du workflow n8n…'); + try { + await api.post('/api/calendar/refresh', {}); + showToast('Workflow en cours — en attente des résultats…'); + pollWorkflowStatus(btn); + } catch (err) { + const message = err.message === 'n8n_not_configured' + ? "L'actualisation automatique n'est pas configurée (n8n)." + : err.message; + showToast(message, true); + resetRefreshButton(btn); + } + }); +} + +function resetRefreshButton(btn) { + btn.disabled = false; + btn.textContent = 'Actualiser les disponibilités'; +} + +function pollWorkflowStatus(btn, maxMs = 180000, intervalMs = 4000) { + const started = Date.now(); + const timer = setInterval(async () => { + if (Date.now() - started > maxMs) { + clearInterval(timer); + showToast('Le workflow a expiré — aucun résultat après 3 minutes', true); + resetRefreshButton(btn); + return; + } + try { + const data = await api.get('/api/calendar/workflow-status'); + if (data.status === 'success') { + clearInterval(timer); + showToast('Calendrier mis à jour !'); + resetRefreshButton(btn); + await loadSlots(); + 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); + resetRefreshButton(btn); + } + } catch (err) { + /* transient, keep polling */ + } + }, intervalMs); +} + +function openModal(date, slot, visible) { + const avail = visible.filter((p) => p.is_available); + const busy = visible.filter((p) => !p.is_available); + + document.getElementById('modal-date').textContent = date.toLocaleDateString('fr-FR', { + weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Paris', + }); + document.getElementById('modal-time').textContent = formatTime(slot.lower) + ' – ' + formatTime(slot.upper); + + const renderPeople = (list, containerId) => { + const el = document.getElementById(containerId); + el.innerHTML = ''; + if (!list.length) { + const em = document.createElement('div'); + em.className = 'modal-empty empty'; + em.textContent = 'Aucune'; + el.appendChild(em); + return; + } + for (const person of list) { + const row = document.createElement('div'); + row.className = 'modal-person'; + const dot = document.createElement('span'); + dot.className = 'person-dot'; + dot.style.backgroundColor = person.color; + row.appendChild(dot); + row.appendChild(document.createTextNode(person.name)); + el.appendChild(row); + } + }; + + renderPeople(avail, 'modal-available'); + renderPeople(busy, 'modal-busy'); + + document.getElementById('modal-overlay').classList.remove('hidden'); +} + +function closeModal() { + document.getElementById('modal-overlay').classList.add('hidden'); +} + +function openFilters() { + document.getElementById('filters-panel').classList.add('open'); + document.getElementById('filters-backdrop').classList.add('open'); +} + +function closeFilters() { + document.getElementById('filters-panel').classList.remove('open'); + document.getElementById('filters-backdrop').classList.remove('open'); +} + +function buildLegend() { + const el = document.getElementById('legend'); + for (const person of state.people) { + const item = document.createElement('div'); + item.className = 'legend-item'; + const dot = document.createElement('span'); + dot.className = 'legend-dot'; + dot.style.backgroundColor = person.color; + item.appendChild(dot); + item.appendChild(document.createTextNode(person.name)); + el.appendChild(item); + } + const allItem = document.createElement('div'); + allItem.className = 'legend-item'; + allItem.innerHTML = ' Tout le monde libre'; + el.appendChild(allItem); +} + +function showToast(msg, isError = false, duration = 5000) { + const el = document.getElementById('toast'); + el.textContent = msg; + el.classList.remove('hidden', 'error'); + if (isError) el.classList.add('error'); + clearTimeout(el._timer); + el._timer = setTimeout(() => el.classList.add('hidden'), duration); +} + +function showError(message) { + document.getElementById('error').innerHTML = `
${escapeHtml(message)}
`; +} + +(async function init() { + await initNav('calendar'); + + document.getElementById('modal-close').addEventListener('click', closeModal); + document.getElementById('modal-overlay').addEventListener('click', (e) => { + if (e.target === e.currentTarget) closeModal(); + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeModal(); + closeFilters(); + } + }); + + document.getElementById('btn-filters-toggle').addEventListener('click', openFilters); + document.getElementById('btn-filters-close').addEventListener('click', closeFilters); + document.getElementById('filters-backdrop').addEventListener('click', closeFilters); + + try { + await loadPeople(); + buildFilters(); + await loadSlots(); + await loadLastChecked(); + } catch (err) { + showError(err.message); + } +})(); diff --git a/public/js/history-detail.js b/public/js/history-detail.js index fe13187..d772f4b 100644 --- a/public/js/history-detail.js +++ b/public/js/history-detail.js @@ -1,12 +1,3 @@ -function formatDate(dateStr) { - const d = new Date(dateStr); - return d.toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); -} - -function showError(message) { - document.getElementById('error').innerHTML = `
${escapeHtml(message)}
`; -} - (async function init() { await initNav('history'); const params = new URLSearchParams(window.location.search); @@ -16,31 +7,23 @@ function showError(message) { container.innerHTML = '

Concert introuvable.

'; return; } - try { - const setlist = await api.get(`/api/setlists/${id}`); - const main = setlist.songs.filter((s) => !s.is_encore); - const encore = setlist.songs.filter((s) => s.is_encore); - const rowHtml = (s) => `
  • ${escapeHtml(s.title)} — ${escapeHtml(s.artist)}${s.note ? `${escapeHtml(s.note)}` : ''}
  • `; - container.innerHTML = ` -
    -
    ${escapeHtml(setlist.name || 'Concert')}
    -
    ${escapeHtml(setlist.venue || '')} · ${formatDate(setlist.concert_date)}
    -
    -
    -

    Setlist

    - ${main.length - ? `
      ${main.map(rowHtml).join('')}
    ` - : '

    Aucun morceau enregistré.

    '} -
    -
    -

    Rappel

    - ${encore.length - ? `
      ${encore.map(rowHtml).join('')}
    ` - : '

    Aucun rappel enregistré.

    '} -
    - `; - } catch (err) { - showError(err.message); - } + const allSongs = await api.get('/api/songs'); + + const editor = createSetlistEditor({ + allSongs, + getSetlist: async () => { + try { + return await api.get(`/api/setlists/${id}`); + } catch (err) { + return null; + } + }, + emptyMessage: 'Concert introuvable.', + allowDelete: true, + onDeleted: () => { + window.location.href = '/history.html'; + }, + }); + await editor.load(); })(); diff --git a/public/js/nav.js b/public/js/nav.js index dd466c0..5af2d6e 100644 --- a/public/js/nav.js +++ b/public/js/nav.js @@ -29,6 +29,7 @@ async function initNav(activePage) { Répertoire Suggestions Prochain concert + Disponibilités Historique - ${me.isAdmin ? ` + ${me.isAdmin ? ` - - ` : ''} + ` : ''} + `; } @@ -188,9 +189,10 @@ async function onAddSuggestion(e) { const title = form.title.value.trim(); const artist = form.artist.value.trim(); const youtubeUrl = form.youtubeUrl.value.trim(); + const spotifyUrl = form.spotifyUrl.value.trim(); const description = form.description.value.trim(); try { - await api.post('/api/suggestions', { title, artist, youtubeUrl, description }); + await api.post('/api/suggestions', { title, artist, youtubeUrl, spotifyUrl, description }); form.reset(); document.getElementById('suggestion-link-status').textContent = ''; if (suggestionAutocomplete) suggestionAutocomplete.close(); @@ -217,16 +219,20 @@ async function onSuggestionCandidateSelected(candidate) { document.getElementById('suggestion-artist-input').value = candidate.artist; const statusEl = document.getElementById('suggestion-link-status'); - statusEl.textContent = 'Recherche du lien YouTube…'; + statusEl.textContent = 'Recherche des liens YouTube / Spotify…'; try { const links = await api.get( `/api/music-search/links?title=${encodeURIComponent(candidate.title)}&artist=${encodeURIComponent(candidate.artist)}` ); const youtubeInput = document.getElementById('suggestion-youtube-input'); + const spotifyInput = document.getElementById('suggestion-spotify-input'); if (links.youtubeUrl && !youtubeInput.value) youtubeInput.value = links.youtubeUrl; - statusEl.textContent = links.youtubeUrl - ? 'Lien YouTube trouvé automatiquement.' - : 'Aucun lien YouTube trouvé automatiquement — à saisir manuellement.'; + if (links.spotifyUrl && !spotifyInput.value) spotifyInput.value = links.spotifyUrl; + + if (links.youtubeUrl && links.spotifyUrl) statusEl.textContent = 'Liens YouTube et Spotify trouvés automatiquement.'; + else if (links.youtubeUrl) statusEl.textContent = 'Lien YouTube trouvé automatiquement. Aucun lien Spotify trouvé — à saisir manuellement si besoin.'; + else if (links.spotifyUrl) statusEl.textContent = 'Lien Spotify trouvé automatiquement. Aucun lien YouTube trouvé — à saisir manuellement si besoin.'; + else statusEl.textContent = 'Aucun lien trouvé automatiquement — vous pouvez les saisir manuellement.'; } catch (err) { statusEl.textContent = ''; } diff --git a/public/setlist.html b/public/setlist.html index e1feca3..c3fa764 100644 --- a/public/setlist.html +++ b/public/setlist.html @@ -20,6 +20,7 @@ + diff --git a/public/suggestions.html b/public/suggestions.html index 10e7f56..66bc714 100644 --- a/public/suggestions.html +++ b/public/suggestions.html @@ -31,6 +31,7 @@ + diff --git a/src/app.js b/src/app.js index ef4a3dd..79d4378 100644 --- a/src/app.js +++ b/src/app.js @@ -4,6 +4,7 @@ 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(); @@ -19,6 +20,12 @@ 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/auth/middleware.js b/src/auth/middleware.js index 622d02b..f101175 100644 --- a/src/auth/middleware.js +++ b/src/auth/middleware.js @@ -13,7 +13,11 @@ async function attachUser(req, res, next) { function requireAuth(req, res, next) { if (!req.user) { - if (req.path.startsWith('/api/')) { + // req.path is relative to the mount prefix here (Express strips '/api' + // for every layer registered via app.use('/api', ...)), so it never + // actually starts with '/api/' — use req.originalUrl, which always holds + // the full request path regardless of mount nesting. + if (req.originalUrl.startsWith('/api/')) { return res.status(401).json({ error: 'unauthenticated' }); } return res.redirect(`/auth/login?returnTo=${encodeURIComponent(req.originalUrl)}`); diff --git a/src/config.js b/src/config.js index db06874..089eb93 100644 --- a/src/config.js +++ b/src/config.js @@ -49,4 +49,13 @@ 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/005_calendar.sql b/src/db/migrations/005_calendar.sql new file mode 100644 index 0000000..f05d29c --- /dev/null +++ b/src/db/migrations/005_calendar.sql @@ -0,0 +1,26 @@ +CREATE TABLE calendar_people ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + color TEXT NOT NULL DEFAULT '#4285f4' +); + +CREATE TABLE calendar_slots ( + id SERIAL PRIMARY KEY, + lower TIMESTAMPTZ NOT NULL, + upper TIMESTAMPTZ NOT NULL, + slot_date DATE NOT NULL, + day_of_week SMALLINT NOT NULL, + UNIQUE (lower, upper) +); + +CREATE TABLE calendar_availability ( + id SERIAL PRIMARY KEY, + slot_id INTEGER NOT NULL REFERENCES calendar_slots(id) ON DELETE CASCADE, + person_id INTEGER NOT NULL REFERENCES calendar_people(id) ON DELETE CASCADE, + is_available BOOLEAN NOT NULL DEFAULT true, + checked_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (slot_id, person_id) +); + +CREATE INDEX idx_calendar_slots_slot_date ON calendar_slots (slot_date); +CREATE INDEX idx_calendar_availability_slot_id ON calendar_availability (slot_id); diff --git a/src/db/migrations/006_calendar_seed_people.sql b/src/db/migrations/006_calendar_seed_people.sql new file mode 100644 index 0000000..85a5b2d --- /dev/null +++ b/src/db/migrations/006_calendar_seed_people.sql @@ -0,0 +1,7 @@ +INSERT INTO calendar_people (name, color) VALUES + ('Nathan', '#4285f4'), + ('Raphaël', '#fbbc05'), + ('Yann', '#34a853'), + ('Jules', '#a142f4'), + ('AK', '#24c1e0') +ON CONFLICT (name) DO NOTHING; diff --git a/src/db/migrations/007_suggestion_spotify.sql b/src/db/migrations/007_suggestion_spotify.sql new file mode 100644 index 0000000..45de943 --- /dev/null +++ b/src/db/migrations/007_suggestion_spotify.sql @@ -0,0 +1 @@ +ALTER TABLE suggestions ADD COLUMN IF NOT EXISTS spotify_url TEXT; diff --git a/src/lib/calendarDates.js b/src/lib/calendarDates.js new file mode 100644 index 0000000..2a8de7a --- /dev/null +++ b/src/lib/calendarDates.js @@ -0,0 +1,30 @@ +// Truncates to minute precision so repeated ingests of "the same" slot land +// on the same instant even though n8n's timestamps carry sub-minute noise +// that differs run to run (the DB's UNIQUE(lower, upper) constraint depends +// on this). Returns a Date (not a string) — pg accepts Date directly for a +// timestamptz column. +function normalizeISO(iso) { + const d = new Date(iso); + d.setSeconds(0, 0); + return d; +} + +// Calendar date (YYYY-MM-DD) of the given instant in Europe/Paris, so e.g. a +// 18:30 CEST slot stays on the correct local day regardless of what UTC day +// it falls on. +function slotDateParis(iso) { + return new Date(iso).toLocaleDateString('en-CA', { timeZone: 'Europe/Paris' }); +} + +const WEEKDAY_TO_NUMBER = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 }; + +// Monday=1..Sunday=7, from the Europe/Paris-local weekday name. Falls back +// to the instant's own (UTC) getDay() if the locale parsing ever fails. +function dayOfWeekParis(iso) { + const date = new Date(iso); + const short = date.toLocaleDateString('en-US', { timeZone: 'Europe/Paris', weekday: 'short' }).slice(0, 3); + const dow = WEEKDAY_TO_NUMBER[short]; + return dow === undefined ? date.getDay() || 7 : dow; +} + +module.exports = { normalizeISO, slotDateParis, dayOfWeekParis }; diff --git a/src/lib/calendarWebhookAuth.js b/src/lib/calendarWebhookAuth.js new file mode 100644 index 0000000..62c4b85 --- /dev/null +++ b/src/lib/calendarWebhookAuth.js @@ -0,0 +1,23 @@ +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 new file mode 100644 index 0000000..e8a9bc4 --- /dev/null +++ b/src/lib/calendarWorkflowState.js @@ -0,0 +1,25 @@ +// 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 }; + +function getState() { + return state; +} + +function setRunning() { + state = { status: 'running', triggeredAt: new Date().toISOString(), message: null, node: null }; +} + +function setSuccess() { + state = { ...state, status: 'success', message: null, node: null }; +} + +function setError(message, node) { + state = { ...state, status: 'error', message: message || 'Unknown error', node: node || null }; +} + +module.exports = { getState, setRunning, setSuccess, setError }; diff --git a/src/repositories/calendarRepo.js b/src/repositories/calendarRepo.js new file mode 100644 index 0000000..fd2490e --- /dev/null +++ b/src/repositories/calendarRepo.js @@ -0,0 +1,125 @@ +const pool = require('../db/pool'); +const { normalizeISO, slotDateParis, dayOfWeekParis } = require('../lib/calendarDates'); + +const COLOR_PALETTE = [ + '#4285f4', '#ea4335', '#fbbc05', '#34a853', + '#a142f4', '#24c1e0', '#ff6d00', '#795548', +]; + +async function getPeople() { + const { rows } = await pool.query('SELECT id, name, color FROM calendar_people ORDER BY id'); + return rows; +} + +// Always called from within the ingestSlots transaction below (needs the +// same client so the color-index count and the insert see a consistent view). +async function upsertPerson(client, name) { + const existing = await client.query('SELECT id FROM calendar_people WHERE name = $1', [name]); + if (existing.rows[0]) return existing.rows[0].id; + + const { rows: countRows } = await client.query('SELECT COUNT(*)::int AS count FROM calendar_people'); + const color = COLOR_PALETTE[countRows[0].count % COLOR_PALETTE.length]; + + const { rows } = await client.query( + `INSERT INTO calendar_people (name, color) VALUES ($1, $2) + ON CONFLICT (name) DO UPDATE SET name = excluded.name + RETURNING id`, + [name, color] + ); + return rows[0].id; +} + +async function ingestSlots(slots) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + for (const slot of slots) { + const lower = normalizeISO(slot.lower); + const upper = normalizeISO(slot.upper); + const slotDate = slotDateParis(slot.lower); + const dayOfWeek = dayOfWeekParis(slot.lower); + + const { rows: slotRows } = await client.query( + `INSERT INTO calendar_slots (lower, upper, slot_date, day_of_week) + VALUES ($1, $2, $3, $4) + ON CONFLICT (lower, upper) DO UPDATE SET + slot_date = excluded.slot_date, + day_of_week = excluded.day_of_week + RETURNING id`, + [lower, upper, slotDate, dayOfWeek] + ); + const slotId = slotRows[0].id; + + // people may arrive as a JSON string from n8n's Set-node serialization. + const people = typeof slot.people === 'string' ? JSON.parse(slot.people) : slot.people || []; + + for (const person of people) { + if (!person || !person.name) continue; + const personId = await upsertPerson(client, person.name); + await client.query( + `INSERT INTO calendar_availability (slot_id, person_id, is_available, checked_at) + VALUES ($1, $2, $3, now()) + ON CONFLICT (slot_id, person_id) DO UPDATE SET + is_available = excluded.is_available, + checked_at = excluded.checked_at`, + [slotId, personId, !!person.available] + ); + } + } + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } +} + +async function getSlots({ minPeople = 1, personIds = null, weeks = 3 } = {}) { + const cappedWeeks = Math.min(weeks || 3, 3); + const now = new Date(); + const end = new Date(now); + end.setDate(end.getDate() + cappedWeeks * 7); + + const nowStr = now.toISOString().substring(0, 10); + const endStr = end.toISOString().substring(0, 10); + const hasPersonFilter = Array.isArray(personIds) && personIds.length > 0; + + const { rows } = await pool.query( + `SELECT + ts.id, ts.lower, ts.upper, ts.slot_date, ts.day_of_week, + filtered.available_count, filtered.total_in_filter, + ( + SELECT json_agg( + json_build_object('id', p.id, 'name', p.name, 'color', p.color, + 'is_available', sa2.is_available) + ORDER BY p.id + ) + FROM calendar_availability sa2 + JOIN calendar_people p ON p.id = sa2.person_id + WHERE sa2.slot_id = ts.id + ) AS people + FROM calendar_slots ts + JOIN ( + SELECT slot_id, + COUNT(*) FILTER (WHERE is_available) AS available_count, + COUNT(*) AS total_in_filter + FROM calendar_availability + WHERE ($3::int[] IS NULL OR person_id = ANY($3::int[])) + GROUP BY slot_id + ) filtered ON filtered.slot_id = ts.id + WHERE ts.slot_date >= $1 AND ts.slot_date <= $2 + AND filtered.available_count >= $4 + ORDER BY ts.lower`, + [nowStr, endStr, hasPersonFilter ? personIds : null, minPeople] + ); + + return rows.map((row) => ({ ...row, people: row.people || [] })); +} + +async function getLastChecked() { + const { rows } = await pool.query('SELECT MAX(checked_at) AS ts FROM calendar_availability'); + return rows[0].ts; +} + +module.exports = { getPeople, ingestSlots, getSlots, getLastChecked }; diff --git a/src/repositories/suggestionsRepo.js b/src/repositories/suggestionsRepo.js index 66798dc..6a85c7f 100644 --- a/src/repositories/suggestionsRepo.js +++ b/src/repositories/suggestionsRepo.js @@ -2,7 +2,7 @@ const pool = require('../db/pool'); async function findAll() { const { rows } = await pool.query(` - SELECT sg.id, sg.title, sg.artist, sg.youtube_url, sg.description, sg.status, sg.promoted_song_id, + SELECT sg.id, sg.title, sg.artist, sg.youtube_url, sg.spotify_url, sg.description, sg.status, sg.promoted_song_id, sg.created_at, sg.suggested_by, u.name AS suggested_by_name, COUNT(*) FILTER (WHERE v.vote = 'approve')::int AS approve_count, COUNT(*) FILTER (WHERE v.vote = 'reject')::int AS reject_count @@ -17,7 +17,7 @@ async function findAll() { async function findById(id) { const { rows } = await pool.query( - `SELECT sg.id, sg.title, sg.artist, sg.youtube_url, sg.description, sg.status, sg.promoted_song_id, + `SELECT sg.id, sg.title, sg.artist, sg.youtube_url, sg.spotify_url, sg.description, sg.status, sg.promoted_song_id, sg.created_at, sg.suggested_by, u.name AS suggested_by_name FROM suggestions sg JOIN users u ON u.id = sg.suggested_by @@ -39,12 +39,12 @@ async function findVotes(suggestionId) { return rows; } -async function create({ title, artist, youtubeUrl, description, suggestedBy }) { +async function create({ title, artist, youtubeUrl, spotifyUrl, description, suggestedBy }) { const { rows } = await pool.query( - `INSERT INTO suggestions (title, artist, youtube_url, description, suggested_by) - VALUES ($1, $2, $3, $4, $5) - RETURNING id, title, artist, youtube_url, description, status, promoted_song_id, created_at, suggested_by`, - [title, artist || null, youtubeUrl, description || null, suggestedBy] + `INSERT INTO suggestions (title, artist, youtube_url, spotify_url, description, suggested_by) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, title, artist, youtube_url, spotify_url, description, status, promoted_song_id, created_at, suggested_by`, + [title, artist || null, youtubeUrl, spotifyUrl || null, description || null, suggestedBy] ); return rows[0]; } @@ -52,7 +52,7 @@ async function create({ title, artist, youtubeUrl, description, suggestedBy }) { async function updateStatus(id, status) { const { rows } = await pool.query( `UPDATE suggestions SET status = $2, updated_at = now() WHERE id = $1 - RETURNING id, title, artist, youtube_url, description, status, promoted_song_id, created_at, suggested_by`, + RETURNING id, title, artist, youtube_url, spotify_url, description, status, promoted_song_id, created_at, suggested_by`, [id, status] ); return rows[0] || null; @@ -94,10 +94,17 @@ async function promoteToSong(suggestionId, addedBy) { return null; } const { rows: songRows } = await client.query( - `INSERT INTO songs (title, artist, notes, youtube_url, added_by) - VALUES ($1, $2, $3, $4, $5) + `INSERT INTO songs (title, artist, notes, youtube_url, spotify_url, added_by) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, title, artist, notes, youtube_url, spotify_url, added_by, created_at, updated_at`, - [suggestion.title, suggestion.artist || suggestion.title, suggestion.description, suggestion.youtube_url, addedBy] + [ + suggestion.title, + suggestion.artist || suggestion.title, + suggestion.description, + suggestion.youtube_url, + suggestion.spotify_url, + addedBy, + ] ); const song = songRows[0]; await client.query( diff --git a/src/routes/calendar.js b/src/routes/calendar.js new file mode 100644 index 0000000..c8e9d83 --- /dev/null +++ b/src/routes/calendar.js @@ -0,0 +1,96 @@ +const express = require('express'); +const calendarRepo = require('../repositories/calendarRepo'); +const workflowState = require('../lib/calendarWorkflowState'); +const config = require('../config'); +const asyncHandler = require('../lib/asyncHandler'); + +const router = express.Router(); + +router.get( + '/people', + asyncHandler(async (req, res) => { + res.json(await calendarRepo.getPeople()); + }) +); + +router.get( + '/slots', + asyncHandler(async (req, res) => { + const minPeople = req.query.min_people !== undefined ? parseInt(req.query.min_people, 10) : 1; + const weeks = req.query.weeks !== undefined ? parseInt(req.query.weeks, 10) : 3; + const personIds = req.query.person_ids + ? req.query.person_ids.split(',').map(Number).filter((n) => !Number.isNaN(n)) + : null; + + const slots = await calendarRepo.getSlots({ minPeople, personIds, weeks }); + res.json(slots); + }) +); + +router.get( + '/last-checked', + asyncHandler(async (req, res) => { + res.json({ last_checked: await calendarRepo.getLastChecked() }); + }) +); + +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', (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; + } + + const data = await response.json(); + if (!Array.isArray(data.slots)) { + workflowState.setError('Unexpected response format from n8n'); + return; + } + + await calendarRepo.ingestSlots(data.slots); + workflowState.setSuccess(); + } 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); + } + })(); +}); + +module.exports = router; diff --git a/src/routes/calendarWebhooks.js b/src/routes/calendarWebhooks.js new file mode 100644 index 0000000..5cac8ac --- /dev/null +++ b/src/routes/calendarWebhooks.js @@ -0,0 +1,42 @@ +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 { + await calendarRepo.ingestSlots(slots); + workflowState.setSuccess(); + res.json({ ok: true, count: slots.length }); + } 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/routes/index.js b/src/routes/index.js index 1917581..4343b45 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -5,6 +5,7 @@ const songsRoutes = require('./songs'); const suggestionsRoutes = require('./suggestions'); const setlistsRoutes = require('./setlists'); const musicSearchRoutes = require('./musicSearch'); +const calendarRoutes = require('./calendar'); const router = express.Router(); @@ -14,5 +15,6 @@ router.use('/songs', songsRoutes); router.use('/suggestions', suggestionsRoutes); router.use('/setlists', setlistsRoutes); router.use('/music-search', musicSearchRoutes); +router.use('/calendar', calendarRoutes); module.exports = router; diff --git a/src/routes/suggestions.js b/src/routes/suggestions.js index 1c91eaf..793d14f 100644 --- a/src/routes/suggestions.js +++ b/src/routes/suggestions.js @@ -2,6 +2,7 @@ const express = require('express'); const suggestionsRepo = require('../repositories/suggestionsRepo'); const { requireAdmin } = require('../auth/middleware'); const { isValidYoutubeUrl } = require('../lib/youtube'); +const { isValidSpotifyUrl } = require('../lib/spotify'); const asyncHandler = require('../lib/asyncHandler'); const router = express.Router(); @@ -26,17 +27,21 @@ router.get( router.post( '/', asyncHandler(async (req, res) => { - const { title, artist, youtubeUrl, description } = req.body || {}; + const { title, artist, youtubeUrl, spotifyUrl, description } = req.body || {}; if (!title || !title.trim() || !youtubeUrl || !youtubeUrl.trim()) { return res.status(400).json({ error: 'title_and_youtube_url_required' }); } if (!isValidYoutubeUrl(youtubeUrl.trim())) { return res.status(400).json({ error: 'invalid_youtube_url' }); } + if (spotifyUrl && !isValidSpotifyUrl(spotifyUrl.trim())) { + return res.status(400).json({ error: 'invalid_spotify_url' }); + } const suggestion = await suggestionsRepo.create({ title: title.trim(), artist: artist ? artist.trim() : null, youtubeUrl: youtubeUrl.trim(), + spotifyUrl: spotifyUrl ? spotifyUrl.trim() : null, description: description ? description.trim() : null, suggestedBy: req.user.id, }); @@ -91,7 +96,6 @@ router.delete( router.post( '/:id/promote', - requireAdmin, asyncHandler(async (req, res) => { const song = await suggestionsRepo.promoteToSong(req.params.id, req.user.id); if (!song) return res.status(404).json({ error: 'not_found' }); diff --git a/test/calendarDates.test.js b/test/calendarDates.test.js new file mode 100644 index 0000000..b4194ea --- /dev/null +++ b/test/calendarDates.test.js @@ -0,0 +1,49 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { normalizeISO, slotDateParis, dayOfWeekParis } = require('../src/lib/calendarDates'); + +test('normalizeISO: collapses sub-minute noise to the same instant', () => { + const a = normalizeISO('2026-06-10T18:30:12.345Z'); + const b = normalizeISO('2026-06-10T18:30:47.001Z'); + assert.equal(a.getTime(), b.getTime()); +}); + +test('normalizeISO: zeroes seconds and milliseconds only, keeps minute/hour/day', () => { + const d = normalizeISO('2026-06-10T18:30:47.001Z'); + assert.equal(d.getUTCSeconds(), 0); + assert.equal(d.getUTCMilliseconds(), 0); + assert.equal(d.getUTCMinutes(), 30); + assert.equal(d.getUTCHours(), 18); +}); + +test('slotDateParis: late-evening UTC instant rolls onto the next Paris-local day (winter, UTC+1)', () => { + assert.equal(slotDateParis('2026-01-15T23:30:00Z'), '2026-01-16'); +}); + +test('slotDateParis: late-evening UTC instant rolls onto the next Paris-local day (summer, UTC+2)', () => { + assert.equal(slotDateParis('2026-07-15T22:30:00Z'), '2026-07-16'); +}); + +test('slotDateParis: spring-forward DST boundary (2026-03-29, CET->CEST at 01:00 UTC)', () => { + // Just before the jump: still CET (UTC+1) -> 01:30 local. + assert.equal(slotDateParis('2026-03-29T00:30:00Z'), '2026-03-29'); + // Just after the jump: already CEST (UTC+2) -> 03:30 local, same calendar day. + assert.equal(slotDateParis('2026-03-29T01:30:00Z'), '2026-03-29'); +}); + +test('slotDateParis: fall-back DST boundary (2026-10-25, CEST->CET at 01:00 UTC)', () => { + // The 01:00-02:00 UTC hour occurs twice in local time (02:xx both times); + // both instants should still resolve to the same calendar day. + assert.equal(slotDateParis('2026-10-25T00:30:00Z'), '2026-10-25'); + assert.equal(slotDateParis('2026-10-25T01:30:00Z'), '2026-10-25'); +}); + +test('dayOfWeekParis: maps Monday..Sunday to 1..7', () => { + assert.equal(dayOfWeekParis('2026-01-16T00:30:00+01:00'), 5); // Friday + assert.equal(dayOfWeekParis('2026-01-11T12:00:00Z'), 7); // Sunday, not 0 +}); + +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); +});