update: add calendar to app

This commit is contained in:
Nathan FONTEYNE 2026-07-08 15:53:25 +02:00
parent b17715eaff
commit 08717c24bc
29 changed files with 1478 additions and 313 deletions

View file

@ -44,3 +44,17 @@ APP_PORT=3000
SPOTIFY_CLIENT_ID= SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET= SPOTIFY_CLIENT_SECRET=
YOUTUBE_API_KEY= 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=

View file

@ -238,10 +238,11 @@ erDiagram
| Page | Accès | Description | | 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)) | | `/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 | | `/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) | | `/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). 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 ## 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. - **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, modère les suggestions (promouvoir une suggestion approuvée en morceau du répertoire, la rejeter, la supprimer). - **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). 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. 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 (lunven 18h3021h, samdim 15h19h) 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<br/>FreeBusy API")]
DB[("Postgres<br/>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: <valeur de 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 ## Prérequis
- Docker + Docker Compose - 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`) | | `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_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) | | `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). 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).

71
public/calendar.html Normal file
View file

@ -0,0 +1,71 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Disponibilités — Octane</title>
<script src="/js/theme-init.js"></script>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header class="app-header">
<nav id="main-nav"></nav>
</header>
<main>
<div class="section-header">
<div>
<h1 style="margin:0">Disponibilités</h1>
<p class="page-subtitle" id="last-checked" style="margin:0.25rem 0 0">Chargement…</p>
</div>
<div style="display:flex; gap:0.5rem;">
<button type="button" class="secondary nav-toggle calendar-filters-toggle" id="btn-filters-toggle">Filtres</button>
<button type="button" id="btn-refresh">Actualiser les disponibilités</button>
</div>
</div>
<div id="error"></div>
<div class="calendar-layout">
<div class="calendar-filters-backdrop" id="filters-backdrop"></div>
<aside class="calendar-filters panel" id="filters-panel">
<div class="section-header" style="margin:0 0 0.5rem">
<h3 style="margin:0">Filtres</h3>
<button type="button" class="secondary icon-btn calendar-filters-close" id="btn-filters-close">&times;</button>
</div>
<p class="note" style="margin:0 0 0.5rem">Personnes incluses</p>
<div id="people-list" class="calendar-people-list"></div>
</aside>
<div class="calendar-main">
<div class="calendar-header-row">
<span>Lun</span><span>Mar</span><span>Mer</span><span>Jeu</span><span>Ven</span><span>Sam</span><span>Dim</span>
</div>
<div class="calendar-grid" id="calendar-grid"></div>
<div class="calendar-legend" id="legend"></div>
</div>
</div>
</main>
<div id="toast" class="toast hidden"></div>
<div id="modal-overlay" class="modal-overlay hidden">
<div class="modal card">
<button type="button" class="icon-btn secondary modal-close" id="modal-close">&times;</button>
<div class="modal-date card-title" id="modal-date"></div>
<div class="modal-time note" id="modal-time"></div>
<div class="modal-section">
<div class="modal-section-title available">Disponibles</div>
<div class="modal-people" id="modal-available"></div>
</div>
<div class="modal-section">
<div class="modal-section-title busy">Indisponibles</div>
<div class="modal-people" id="modal-busy"></div>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/render.js"></script>
<script src="/js/nav.js"></script>
<script src="/js/calendar.js"></script>
</body>
</html>

View file

@ -769,6 +769,182 @@ a.back-link {
a.back-link:hover { color: var(--accent); } 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 ---------- */ /* ---------- Responsive / mobile ---------- */
@media (max-width: 720px) { @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; } .setlist-row .row-actions { flex-basis: 100%; order: 5; justify-content: flex-end; }
.profile-header { flex-direction: column; text-align: center; } .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; }
} }

View file

@ -20,6 +20,7 @@
<script src="/js/api.js"></script> <script src="/js/api.js"></script>
<script src="/js/render.js"></script> <script src="/js/render.js"></script>
<script src="/js/nav.js"></script> <script src="/js/nav.js"></script>
<script src="/js/setlistEditor.js"></script>
<script src="/js/history-detail.js"></script> <script src="/js/history-detail.js"></script>
</body> </body>
</html> </html>

337
public/js/calendar.js Normal file
View file

@ -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 = `<span class="day-num">${date.getDate()}</span>${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 = '<span class="legend-dot legend-dot-all"></span> 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 = `<div class="error-banner">${escapeHtml(message)}</div>`;
}
(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);
}
})();

View file

@ -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 = `<div class="error-banner">${escapeHtml(message)}</div>`;
}
(async function init() { (async function init() {
await initNav('history'); await initNav('history');
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@ -16,31 +7,23 @@ function showError(message) {
container.innerHTML = '<p class="empty">Concert introuvable.</p>'; container.innerHTML = '<p class="empty">Concert introuvable.</p>';
return; 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) => `<li><span class="row-title">${escapeHtml(s.title)}</span> <span class="row-artist">— ${escapeHtml(s.artist)}</span>${s.note ? `<span class="note">${escapeHtml(s.note)}</span>` : ''}</li>`;
container.innerHTML = ` const allSongs = await api.get('/api/songs');
<div class="card">
<div class="card-title">${escapeHtml(setlist.name || 'Concert')}</div> const editor = createSetlistEditor({
<div class="card-subtitle">${escapeHtml(setlist.venue || '')} · ${formatDate(setlist.concert_date)}</div> allSongs,
</div> getSetlist: async () => {
<div class="setlist-section"> try {
<h3>Setlist</h3> return await api.get(`/api/setlists/${id}`);
${main.length
? `<ol class="setlist">${main.map(rowHtml).join('')}</ol>`
: '<p class="empty">Aucun morceau enregistré.</p>'}
</div>
<div class="setlist-section">
<h3>Rappel</h3>
${encore.length
? `<ol class="setlist">${encore.map(rowHtml).join('')}</ol>`
: '<p class="empty">Aucun rappel enregistré.</p>'}
</div>
`;
} catch (err) { } catch (err) {
showError(err.message); return null;
} }
},
emptyMessage: 'Concert introuvable.',
allowDelete: true,
onDeleted: () => {
window.location.href = '/history.html';
},
});
await editor.load();
})(); })();

View file

@ -29,6 +29,7 @@ async function initNav(activePage) {
<a href="/index.html" class="${activePage === 'repertoire' ? 'active' : ''}">Répertoire</a> <a href="/index.html" class="${activePage === 'repertoire' ? 'active' : ''}">Répertoire</a>
<a href="/suggestions.html" class="${activePage === 'suggestions' ? 'active' : ''}">Suggestions</a> <a href="/suggestions.html" class="${activePage === 'suggestions' ? 'active' : ''}">Suggestions</a>
<a href="/setlist.html" class="${activePage === 'setlist' ? 'active' : ''}">Prochain concert</a> <a href="/setlist.html" class="${activePage === 'setlist' ? 'active' : ''}">Prochain concert</a>
<a href="/calendar.html" class="${activePage === 'calendar' ? 'active' : ''}">Disponibilités</a>
<a href="/history.html" class="${activePage === 'history' ? 'active' : ''}">Historique</a> <a href="/history.html" class="${activePage === 'history' ? 'active' : ''}">Historique</a>
</div> </div>
<div class="nav-user"> <div class="nav-user">

View file

@ -1,253 +1,12 @@
let me = null;
let allSongs = [];
let setlist = null;
let mainRows = [];
let encoreRows = [];
let addSongId = null;
function formatDate(dateStr) {
const d = new Date(dateStr);
return d.toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
}
function readOnlyView() {
const main = setlist.songs.filter((s) => !s.is_encore);
const encore = setlist.songs.filter((s) => s.is_encore);
return `
<div class="card">
<div class="card-title">${escapeHtml(setlist.name || 'Concert')}</div>
<div class="card-subtitle">${escapeHtml(setlist.venue || '')} · ${formatDate(setlist.concert_date)}</div>
</div>
<div class="setlist-section">
<h3>Setlist</h3>
${main.length
? `<ol class="setlist">${main.map((s) => `<li><span class="row-title">${escapeHtml(s.title)}</span> <span class="row-artist">— ${escapeHtml(s.artist)}</span>${s.note ? `<span class="note">${escapeHtml(s.note)}</span>` : ''}</li>`).join('')}</ol>`
: '<p class="empty">Aucun morceau pour le moment.</p>'}
</div>
<div class="setlist-section">
<h3>Rappel</h3>
${encore.length
? `<ol class="setlist">${encore.map((s) => `<li><span class="row-title">${escapeHtml(s.title)}</span> <span class="row-artist">— ${escapeHtml(s.artist)}</span>${s.note ? `<span class="note">${escapeHtml(s.note)}</span>` : ''}</li>`).join('')}</ol>`
: '<p class="empty">Aucun morceau de rappel prévu.</p>'}
</div>
<button id="edit-btn" class="secondary" style="margin-top:1rem">Modifier la setlist</button>
`;
}
function renderReadOnly() {
const container = document.getElementById('content');
if (!setlist) {
container.innerHTML = `<p class="empty">Aucun concert à venir.</p>${metaFormTemplate()}`;
attachMetaFormHandler();
return;
}
container.innerHTML = readOnlyView();
const editBtn = document.getElementById('edit-btn');
if (editBtn) editBtn.addEventListener('click', enterEditMode);
}
function metaFormTemplate() {
return `
<div class="panel">
<h3>${setlist ? 'Détails du concert' : 'Créer le prochain concert'}</h3>
<form id="meta-form" class="inline-form">
<label>Nom <input name="name" value="${escapeHtml(setlist?.name || '')}"></label>
<label>Lieu <input name="venue" value="${escapeHtml(setlist?.venue || '')}"></label>
<label>Date <input type="date" name="concertDate" value="${setlist ? setlist.concert_date.slice(0, 10) : ''}" required></label>
<button type="submit">${setlist ? 'Enregistrer' : 'Créer le concert'}</button>
</form>
</div>
`;
}
function usedSongIds() {
return new Set([...mainRows, ...encoreRows].map((r) => r.songId));
}
function availableSongOptions() {
const used = usedSongIds();
const available = allSongs.filter((s) => !used.has(s.id));
if (!available.length) return '<option value="">(tous les morceaux sont déjà dans la setlist)</option>';
return available.map((s) => `<option value="${s.id}">${escapeHtml(s.title)}${escapeHtml(s.artist)}</option>`).join('');
}
function rowTemplate(row, index, section, total) {
return `
<div class="setlist-row" data-section="${section}" data-index="${index}">
<div class="row-index">${index + 1}</div>
<div class="row-main">
<div class="row-title">${escapeHtml(row.title)}</div>
<div class="row-artist">${escapeHtml(row.artist)}</div>
</div>
<input class="row-note-input" data-field="note" placeholder="Note (optionnel)" value="${escapeHtml(row.note || '')}">
<div class="row-actions">
<button type="button" class="secondary icon-btn move-up" ${index === 0 ? 'disabled' : ''} title="Monter">&#8593;</button>
<button type="button" class="secondary icon-btn move-down" ${index === total - 1 ? 'disabled' : ''} title="Descendre">&#8595;</button>
<button type="button" class="secondary icon-btn move-section" title="${section === 'main' ? 'Déplacer en rappel' : 'Déplacer au programme'}">${section === 'main' ? '&#8677; Rappel' : '&#8676; Programme'}</button>
<button type="button" class="danger icon-btn remove-row" title="Retirer">&times;</button>
</div>
</div>
`;
}
function editView() {
return `
${metaFormTemplate()}
${setlist ? `
<div class="panel">
<h3>Ajouter un morceau du répertoire</h3>
<form id="add-song-form" class="inline-form">
<label>Morceau
<select id="add-song-select">${availableSongOptions()}</select>
</label>
<button type="button" id="add-to-main-btn" class="secondary">Ajouter au programme</button>
<button type="button" id="add-to-encore-btn" class="secondary">Ajouter au rappel</button>
</form>
</div>
<div class="setlist-section">
<h3>Programme principal</h3>
<div class="setlist-rows" id="main-rows">
${mainRows.length ? mainRows.map((r, i) => rowTemplate(r, i, 'main', mainRows.length)).join('') : '<p class="empty">Aucun morceau. Ajoutez-en depuis le répertoire ci-dessus.</p>'}
</div>
</div>
<div class="setlist-section">
<h3>Rappel</h3>
<div class="setlist-rows" id="encore-rows">
${encoreRows.length ? encoreRows.map((r, i) => rowTemplate(r, i, 'encore', encoreRows.length)).join('') : '<p class="empty">Aucun morceau de rappel prévu.</p>'}
</div>
</div>
<div class="inline-form" style="margin-top:1.25rem">
<button type="button" id="save-songs-btn">Enregistrer la setlist</button>
<button type="button" id="cancel-edit-btn" class="secondary">Retour</button>
</div>` : ''}
`;
}
function enterEditMode() {
mainRows = setlist.songs
.filter((s) => !s.is_encore)
.map((s) => ({ setlistSongId: s.id, songId: s.song_id, title: s.title, artist: s.artist, note: s.note }));
encoreRows = setlist.songs
.filter((s) => s.is_encore)
.map((s) => ({ setlistSongId: s.id, songId: s.song_id, title: s.title, artist: s.artist, note: s.note }));
renderEdit();
}
function renderEdit() {
const container = document.getElementById('content');
container.innerHTML = editView();
attachMetaFormHandler();
attachEditHandlers();
}
function attachMetaFormHandler() {
const form = document.getElementById('meta-form');
if (!form) return;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const name = form.name.value.trim();
const venue = form.venue.value.trim();
const concertDate = form.concertDate.value;
try {
if (setlist) {
setlist = await api.patch(`/api/setlists/${setlist.id}`, { name, venue, concertDate });
setlist.songs = (await api.get(`/api/setlists/${setlist.id}`)).songs;
} else {
setlist = await api.post('/api/setlists', { name, venue, concertDate });
setlist.songs = [];
}
enterEditMode();
} catch (err) {
showError(err.message);
}
});
}
function rowsForSection(section) {
return section === 'main' ? mainRows : encoreRows;
}
function attachEditHandlers() {
const addSelect = document.getElementById('add-song-select');
const addToMain = document.getElementById('add-to-main-btn');
const addToEncore = document.getElementById('add-to-encore-btn');
if (addSelect) {
if (!addSelect.value) addSongId = null;
addSelect.addEventListener('change', () => { addSongId = addSelect.value ? parseInt(addSelect.value, 10) : null; });
}
if (addToMain) addToMain.addEventListener('click', () => addSongToSection('main', addSelect));
if (addToEncore) addToEncore.addEventListener('click', () => addSongToSection('encore', addSelect));
document.querySelectorAll('.setlist-row').forEach((rowEl) => {
const section = rowEl.dataset.section;
const index = parseInt(rowEl.dataset.index, 10);
const rows = rowsForSection(section);
rowEl.querySelector('.row-note-input').addEventListener('change', (e) => {
rows[index].note = e.target.value;
});
rowEl.querySelector('.move-up')?.addEventListener('click', () => {
if (index === 0) return;
[rows[index - 1], rows[index]] = [rows[index], rows[index - 1]];
renderEdit();
});
rowEl.querySelector('.move-down')?.addEventListener('click', () => {
if (index === rows.length - 1) return;
[rows[index + 1], rows[index]] = [rows[index], rows[index + 1]];
renderEdit();
});
rowEl.querySelector('.move-section').addEventListener('click', () => {
const [row] = rows.splice(index, 1);
if (section === 'main') encoreRows.push(row);
else mainRows.push(row);
renderEdit();
});
rowEl.querySelector('.remove-row').addEventListener('click', () => {
rows.splice(index, 1);
renderEdit();
});
});
const saveBtn = document.getElementById('save-songs-btn');
if (saveBtn) saveBtn.addEventListener('click', onSaveSongs);
const cancelBtn = document.getElementById('cancel-edit-btn');
if (cancelBtn) cancelBtn.addEventListener('click', renderReadOnly);
}
function addSongToSection(section, selectEl) {
const songId = selectEl.value ? parseInt(selectEl.value, 10) : null;
if (!songId) return;
const song = allSongs.find((s) => s.id === songId);
if (!song) return;
const row = { setlistSongId: null, songId: song.id, title: song.title, artist: song.artist, note: '' };
if (section === 'main') mainRows.push(row);
else encoreRows.push(row);
renderEdit();
}
async function onSaveSongs() {
try {
const payload = [
...mainRows.map((r, i) => ({ songId: r.songId, position: i + 1, note: r.note, isEncore: false })),
...encoreRows.map((r, i) => ({ songId: r.songId, position: i + 1, note: r.note, isEncore: true })),
];
setlist = await api.put(`/api/setlists/${setlist.id}/songs`, { songs: payload });
renderReadOnly();
} catch (err) {
showError(err.message);
}
}
function showError(message) {
document.getElementById('error').innerHTML = `<div class="error-banner">${escapeHtml(message)}</div>`;
}
(async function init() { (async function init() {
me = await initNav('setlist'); await initNav('setlist');
allSongs = await api.get('/api/songs'); const allSongs = await api.get('/api/songs');
setlist = await api.get('/api/setlists/next');
renderReadOnly(); const editor = createSetlistEditor({
allSongs,
getSetlist: () => api.get('/api/setlists/next'),
createSetlist: (data) => api.post('/api/setlists', data),
emptyMessage: 'Aucun concert à venir pour le moment.',
});
await editor.load();
})(); })();

278
public/js/setlistEditor.js Normal file
View file

@ -0,0 +1,278 @@
// Shared setlist editor used by both "prochain concert" (setlist.js) and
// "historique" (history-detail.js): choose songs from the repertoire, order
// them, add per-song notes, mark encore songs, save. Parameterized so the
// caller decides how to load/create/delete the underlying setlist.
function createSetlistEditor({
containerId = 'content',
allSongs,
getSetlist,
createSetlist = null,
emptyMessage = 'Aucun concert.',
allowDelete = false,
onDeleted = null,
}) {
let setlist = null;
let mainRows = [];
let encoreRows = [];
function formatDate(dateStr) {
const d = new Date(dateStr);
return d.toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
}
function container() {
return document.getElementById(containerId);
}
function showError(message) {
document.getElementById('error').innerHTML = `<div class="error-banner">${escapeHtml(message)}</div>`;
}
function readOnlyView() {
const main = setlist.songs.filter((s) => !s.is_encore);
const encore = setlist.songs.filter((s) => s.is_encore);
const rowHtml = (s) => `<li><span class="row-title">${escapeHtml(s.title)}</span> <span class="row-artist">— ${escapeHtml(s.artist)}</span>${s.note ? `<span class="note">${escapeHtml(s.note)}</span>` : ''}</li>`;
return `
<div class="card">
<div class="card-title">${escapeHtml(setlist.name || 'Concert')}</div>
<div class="card-subtitle">${escapeHtml(setlist.venue || '')} · ${formatDate(setlist.concert_date)}</div>
</div>
<div class="setlist-section">
<h3>Setlist</h3>
${main.length ? `<ol class="setlist">${main.map(rowHtml).join('')}</ol>` : '<p class="empty">Aucun morceau pour le moment.</p>'}
</div>
<div class="setlist-section">
<h3>Rappel</h3>
${encore.length ? `<ol class="setlist">${encore.map(rowHtml).join('')}</ol>` : '<p class="empty">Aucun morceau de rappel prévu.</p>'}
</div>
<div class="inline-form" style="margin-top:1rem">
<button id="edit-btn" class="secondary">Modifier</button>
${allowDelete ? '<button id="delete-btn" class="danger">Supprimer ce concert</button>' : ''}
</div>
`;
}
function renderReadOnly() {
if (!setlist) {
container().innerHTML = createSetlist
? `<p class="empty">${escapeHtml(emptyMessage)}</p>${metaFormTemplate()}`
: `<p class="empty">${escapeHtml(emptyMessage)}</p>`;
if (createSetlist) attachMetaFormHandler();
return;
}
container().innerHTML = readOnlyView();
document.getElementById('edit-btn').addEventListener('click', enterEditMode);
const deleteBtn = document.getElementById('delete-btn');
if (deleteBtn) deleteBtn.addEventListener('click', onDelete);
}
async function onDelete() {
try {
await api.del(`/api/setlists/${setlist.id}`);
if (onDeleted) onDeleted();
} catch (err) {
showError(err.message);
}
}
function metaFormTemplate() {
return `
<div class="panel">
<h3>${setlist ? 'Détails du concert' : 'Créer le prochain concert'}</h3>
<form id="meta-form" class="inline-form">
<label>Nom <input name="name" value="${escapeHtml(setlist?.name || '')}"></label>
<label>Lieu <input name="venue" value="${escapeHtml(setlist?.venue || '')}"></label>
<label>Date <input type="date" name="concertDate" value="${setlist ? setlist.concert_date.slice(0, 10) : ''}" required></label>
<button type="submit">${setlist ? 'Enregistrer' : 'Créer le concert'}</button>
</form>
</div>
`;
}
function usedSongIds() {
return new Set([...mainRows, ...encoreRows].map((r) => r.songId));
}
function availableSongOptions() {
const used = usedSongIds();
const available = allSongs.filter((s) => !used.has(s.id));
if (!available.length) return '<option value="">(tous les morceaux sont déjà dans la setlist)</option>';
return available.map((s) => `<option value="${s.id}">${escapeHtml(s.title)}${escapeHtml(s.artist)}</option>`).join('');
}
function rowTemplate(row, index, section, total) {
return `
<div class="setlist-row" data-section="${section}" data-index="${index}">
<div class="row-index">${index + 1}</div>
<div class="row-main">
<div class="row-title">${escapeHtml(row.title)}</div>
<div class="row-artist">${escapeHtml(row.artist)}</div>
</div>
<input class="row-note-input" data-field="note" placeholder="Note (optionnel)" value="${escapeHtml(row.note || '')}">
<div class="row-actions">
<button type="button" class="secondary icon-btn move-up" ${index === 0 ? 'disabled' : ''} title="Monter">&#8593;</button>
<button type="button" class="secondary icon-btn move-down" ${index === total - 1 ? 'disabled' : ''} title="Descendre">&#8595;</button>
<button type="button" class="secondary icon-btn move-section" title="${section === 'main' ? 'Déplacer en rappel' : 'Déplacer au programme'}">${section === 'main' ? '&#8677; Rappel' : '&#8676; Programme'}</button>
<button type="button" class="danger icon-btn remove-row" title="Retirer">&times;</button>
</div>
</div>
`;
}
function editView() {
return `
${metaFormTemplate()}
${setlist ? `
<div class="panel">
<h3>Ajouter un morceau du répertoire</h3>
<form id="add-song-form" class="inline-form">
<label>Morceau
<select id="add-song-select">${availableSongOptions()}</select>
</label>
<button type="button" id="add-to-main-btn" class="secondary">Ajouter au programme</button>
<button type="button" id="add-to-encore-btn" class="secondary">Ajouter au rappel</button>
</form>
</div>
<div class="setlist-section">
<h3>Programme principal</h3>
<div class="setlist-rows" id="main-rows">
${mainRows.length ? mainRows.map((r, i) => rowTemplate(r, i, 'main', mainRows.length)).join('') : '<p class="empty">Aucun morceau. Ajoutez-en depuis le répertoire ci-dessus.</p>'}
</div>
</div>
<div class="setlist-section">
<h3>Rappel</h3>
<div class="setlist-rows" id="encore-rows">
${encoreRows.length ? encoreRows.map((r, i) => rowTemplate(r, i, 'encore', encoreRows.length)).join('') : '<p class="empty">Aucun morceau de rappel prévu.</p>'}
</div>
</div>
<div class="inline-form" style="margin-top:1.25rem">
<button type="button" id="save-songs-btn">Enregistrer la setlist</button>
<button type="button" id="cancel-edit-btn" class="secondary">Retour</button>
</div>` : ''}
`;
}
function enterEditMode() {
mainRows = setlist.songs
.filter((s) => !s.is_encore)
.map((s) => ({ setlistSongId: s.id, songId: s.song_id, title: s.title, artist: s.artist, note: s.note }));
encoreRows = setlist.songs
.filter((s) => s.is_encore)
.map((s) => ({ setlistSongId: s.id, songId: s.song_id, title: s.title, artist: s.artist, note: s.note }));
renderEdit();
}
function renderEdit() {
container().innerHTML = editView();
attachMetaFormHandler();
attachEditHandlers();
}
function attachMetaFormHandler() {
const form = document.getElementById('meta-form');
if (!form) return;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const name = form.name.value.trim();
const venue = form.venue.value.trim();
const concertDate = form.concertDate.value;
try {
if (setlist) {
setlist = await api.patch(`/api/setlists/${setlist.id}`, { name, venue, concertDate });
setlist.songs = (await api.get(`/api/setlists/${setlist.id}`)).songs;
} else {
setlist = await createSetlist({ name, venue, concertDate });
setlist.songs = setlist.songs || [];
}
enterEditMode();
} catch (err) {
showError(err.message);
}
});
}
function rowsForSection(section) {
return section === 'main' ? mainRows : encoreRows;
}
function attachEditHandlers() {
const addSelect = document.getElementById('add-song-select');
const addToMain = document.getElementById('add-to-main-btn');
const addToEncore = document.getElementById('add-to-encore-btn');
if (addToMain) addToMain.addEventListener('click', () => addSongToSection('main', addSelect));
if (addToEncore) addToEncore.addEventListener('click', () => addSongToSection('encore', addSelect));
document.querySelectorAll('.setlist-row').forEach((rowEl) => {
const section = rowEl.dataset.section;
const index = parseInt(rowEl.dataset.index, 10);
const rows = rowsForSection(section);
rowEl.querySelector('.row-note-input').addEventListener('change', (e) => {
rows[index].note = e.target.value;
});
rowEl.querySelector('.move-up')?.addEventListener('click', () => {
if (index === 0) return;
[rows[index - 1], rows[index]] = [rows[index], rows[index - 1]];
renderEdit();
});
rowEl.querySelector('.move-down')?.addEventListener('click', () => {
if (index === rows.length - 1) return;
[rows[index + 1], rows[index]] = [rows[index], rows[index + 1]];
renderEdit();
});
rowEl.querySelector('.move-section').addEventListener('click', () => {
const [row] = rows.splice(index, 1);
if (section === 'main') encoreRows.push(row);
else mainRows.push(row);
renderEdit();
});
rowEl.querySelector('.remove-row').addEventListener('click', () => {
rows.splice(index, 1);
renderEdit();
});
});
const saveBtn = document.getElementById('save-songs-btn');
if (saveBtn) saveBtn.addEventListener('click', onSaveSongs);
const cancelBtn = document.getElementById('cancel-edit-btn');
if (cancelBtn) cancelBtn.addEventListener('click', renderReadOnly);
}
function addSongToSection(section, selectEl) {
const songId = selectEl.value ? parseInt(selectEl.value, 10) : null;
if (!songId) return;
const song = allSongs.find((s) => s.id === songId);
if (!song) return;
const row = { setlistSongId: null, songId: song.id, title: song.title, artist: song.artist, note: '' };
if (section === 'main') mainRows.push(row);
else encoreRows.push(row);
renderEdit();
}
async function onSaveSongs() {
try {
const payload = [
...mainRows.map((r, i) => ({ songId: r.songId, position: i + 1, note: r.note, isEncore: false })),
...encoreRows.map((r, i) => ({ songId: r.songId, position: i + 1, note: r.note, isEncore: true })),
];
setlist = await api.put(`/api/setlists/${setlist.id}/songs`, { songs: payload });
renderReadOnly();
} catch (err) {
showError(err.message);
}
}
return {
async load() {
try {
setlist = await getSetlist();
renderReadOnly();
} catch (err) {
showError(err.message);
}
},
};
}

View file

@ -56,6 +56,7 @@ function detailTemplate(s) {
const myVote = s.votes.find((v) => v.user_id === me.id); const myVote = s.votes.find((v) => v.user_id === me.id);
return ` return `
${embed ? `<div class="youtube-embed"><iframe src="${embed}" allowfullscreen></iframe></div>` : `<p><a href="${escapeHtml(s.youtube_url)}" target="_blank" rel="noopener">${escapeHtml(s.youtube_url)}</a></p>`} ${embed ? `<div class="youtube-embed"><iframe src="${embed}" allowfullscreen></iframe></div>` : `<p><a href="${escapeHtml(s.youtube_url)}" target="_blank" rel="noopener">${escapeHtml(s.youtube_url)}</a></p>`}
${s.spotify_url ? `<div class="song-links"><a class="pill-link spotify" href="${escapeHtml(s.spotify_url)}" target="_blank" rel="noopener">&#9835; Spotify</a></div>` : ''}
${s.description ? `<div class="suggestion-note">${escapeHtml(s.description)}</div>` : ''} ${s.description ? `<div class="suggestion-note">${escapeHtml(s.description)}</div>` : ''}
<form class="inline-form vote-form" data-id="${s.id}"> <form class="inline-form vote-form" data-id="${s.id}">
@ -73,14 +74,14 @@ function detailTemplate(s) {
${s.votes.length ? s.votes.map(voteItemTemplate).join('') : '<p class="empty">Aucun vote pour le moment.</p>'} ${s.votes.length ? s.votes.map(voteItemTemplate).join('') : '<p class="empty">Aucun vote pour le moment.</p>'}
</div> </div>
${me.isAdmin ? `
<form class="inline-form admin-actions" data-id="${s.id}"> <form class="inline-form admin-actions" data-id="${s.id}">
<button type="button" class="promote-btn" data-id="${s.id}" ${s.promoted_song_id ? 'disabled' : ''}> <button type="button" class="promote-btn" data-id="${s.id}" ${s.promoted_song_id ? 'disabled' : ''}>
${s.promoted_song_id ? 'Déjà au répertoire' : 'Promouvoir au répertoire'} ${s.promoted_song_id ? 'Déjà au répertoire' : 'Ajouter au répertoire'}
</button> </button>
${me.isAdmin ? `
<button type="button" class="secondary reject-btn" data-id="${s.id}">Marquer rejeté</button> <button type="button" class="secondary reject-btn" data-id="${s.id}">Marquer rejeté</button>
<button type="button" class="danger delete-btn" data-id="${s.id}">Supprimer</button> <button type="button" class="danger delete-btn" data-id="${s.id}">Supprimer</button>` : ''}
</form>` : ''} </form>
`; `;
} }
@ -188,9 +189,10 @@ async function onAddSuggestion(e) {
const title = form.title.value.trim(); const title = form.title.value.trim();
const artist = form.artist.value.trim(); const artist = form.artist.value.trim();
const youtubeUrl = form.youtubeUrl.value.trim(); const youtubeUrl = form.youtubeUrl.value.trim();
const spotifyUrl = form.spotifyUrl.value.trim();
const description = form.description.value.trim(); const description = form.description.value.trim();
try { try {
await api.post('/api/suggestions', { title, artist, youtubeUrl, description }); await api.post('/api/suggestions', { title, artist, youtubeUrl, spotifyUrl, description });
form.reset(); form.reset();
document.getElementById('suggestion-link-status').textContent = ''; document.getElementById('suggestion-link-status').textContent = '';
if (suggestionAutocomplete) suggestionAutocomplete.close(); if (suggestionAutocomplete) suggestionAutocomplete.close();
@ -217,16 +219,20 @@ async function onSuggestionCandidateSelected(candidate) {
document.getElementById('suggestion-artist-input').value = candidate.artist; document.getElementById('suggestion-artist-input').value = candidate.artist;
const statusEl = document.getElementById('suggestion-link-status'); const statusEl = document.getElementById('suggestion-link-status');
statusEl.textContent = 'Recherche du lien YouTube…'; statusEl.textContent = 'Recherche des liens YouTube / Spotify…';
try { try {
const links = await api.get( const links = await api.get(
`/api/music-search/links?title=${encodeURIComponent(candidate.title)}&artist=${encodeURIComponent(candidate.artist)}` `/api/music-search/links?title=${encodeURIComponent(candidate.title)}&artist=${encodeURIComponent(candidate.artist)}`
); );
const youtubeInput = document.getElementById('suggestion-youtube-input'); const youtubeInput = document.getElementById('suggestion-youtube-input');
const spotifyInput = document.getElementById('suggestion-spotify-input');
if (links.youtubeUrl && !youtubeInput.value) youtubeInput.value = links.youtubeUrl; if (links.youtubeUrl && !youtubeInput.value) youtubeInput.value = links.youtubeUrl;
statusEl.textContent = links.youtubeUrl if (links.spotifyUrl && !spotifyInput.value) spotifyInput.value = links.spotifyUrl;
? 'Lien YouTube trouvé automatiquement.'
: 'Aucun lien YouTube trouvé automatiquement — à saisir manuellement.'; 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) { } catch (err) {
statusEl.textContent = ''; statusEl.textContent = '';
} }

View file

@ -20,6 +20,7 @@
<script src="/js/api.js"></script> <script src="/js/api.js"></script>
<script src="/js/render.js"></script> <script src="/js/render.js"></script>
<script src="/js/nav.js"></script> <script src="/js/nav.js"></script>
<script src="/js/setlistEditor.js"></script>
<script src="/js/setlist.js"></script> <script src="/js/setlist.js"></script>
</body> </body>
</html> </html>

View file

@ -31,6 +31,7 @@
</div> </div>
<label>Artiste <input name="artist" id="suggestion-artist-input"></label> <label>Artiste <input name="artist" id="suggestion-artist-input"></label>
<label>Lien YouTube <input name="youtubeUrl" id="suggestion-youtube-input" type="url" required placeholder="https://youtube.com/watch?v=..."></label> <label>Lien YouTube <input name="youtubeUrl" id="suggestion-youtube-input" type="url" required placeholder="https://youtube.com/watch?v=..."></label>
<label>Lien Spotify <input name="spotifyUrl" id="suggestion-spotify-input" type="url" placeholder="https://open.spotify.com/track/..."></label>
<label>Note / détails <textarea name="description" placeholder="Pourquoi ce morceau, difficulté, arrangement envisagé..."></textarea></label> <label>Note / détails <textarea name="description" placeholder="Pourquoi ce morceau, difficulté, arrangement envisagé..."></textarea></label>
<p class="note" id="suggestion-link-status"></p> <p class="note" id="suggestion-link-status"></p>
<button type="submit">Proposer</button> <button type="submit">Proposer</button>

View file

@ -4,6 +4,7 @@ const sessionMiddleware = require('./auth/session');
const authRoutes = require('./auth/routes'); const authRoutes = require('./auth/routes');
const { attachUser, requireAuth } = require('./auth/middleware'); const { attachUser, requireAuth } = require('./auth/middleware');
const apiRouter = require('./routes'); const apiRouter = require('./routes');
const calendarWebhooksRouter = require('./routes/calendarWebhooks');
function createApp() { function createApp() {
const app = express(); const app = express();
@ -19,6 +20,12 @@ function createApp() {
app.use('/auth', authRoutes); app.use('/auth', authRoutes);
app.use(attachUser); 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('/api', requireAuth, apiRouter);
app.use(requireAuth, express.static(path.join(__dirname, '../public'))); app.use(requireAuth, express.static(path.join(__dirname, '../public')));

View file

@ -13,7 +13,11 @@ async function attachUser(req, res, next) {
function requireAuth(req, res, next) { function requireAuth(req, res, next) {
if (!req.user) { 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.status(401).json({ error: 'unauthenticated' });
} }
return res.redirect(`/auth/login?returnTo=${encodeURIComponent(req.originalUrl)}`); return res.redirect(`/auth/login?returnTo=${encodeURIComponent(req.originalUrl)}`);

View file

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

View file

@ -0,0 +1,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);

View file

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

View file

@ -0,0 +1 @@
ALTER TABLE suggestions ADD COLUMN IF NOT EXISTS spotify_url TEXT;

30
src/lib/calendarDates.js Normal file
View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@ const pool = require('../db/pool');
async function findAll() { async function findAll() {
const { rows } = await pool.query(` 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, 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 = 'approve')::int AS approve_count,
COUNT(*) FILTER (WHERE v.vote = 'reject')::int AS reject_count COUNT(*) FILTER (WHERE v.vote = 'reject')::int AS reject_count
@ -17,7 +17,7 @@ async function findAll() {
async function findById(id) { async function findById(id) {
const { rows } = await pool.query( 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 sg.created_at, sg.suggested_by, u.name AS suggested_by_name
FROM suggestions sg FROM suggestions sg
JOIN users u ON u.id = sg.suggested_by JOIN users u ON u.id = sg.suggested_by
@ -39,12 +39,12 @@ async function findVotes(suggestionId) {
return rows; return rows;
} }
async function create({ title, artist, youtubeUrl, description, suggestedBy }) { async function create({ title, artist, youtubeUrl, spotifyUrl, description, suggestedBy }) {
const { rows } = await pool.query( const { rows } = await pool.query(
`INSERT INTO suggestions (title, artist, youtube_url, description, suggested_by) `INSERT INTO suggestions (title, artist, youtube_url, spotify_url, description, suggested_by)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6)
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`,
[title, artist || null, youtubeUrl, description || null, suggestedBy] [title, artist || null, youtubeUrl, spotifyUrl || null, description || null, suggestedBy]
); );
return rows[0]; return rows[0];
} }
@ -52,7 +52,7 @@ async function create({ title, artist, youtubeUrl, description, suggestedBy }) {
async function updateStatus(id, status) { async function updateStatus(id, status) {
const { rows } = await pool.query( const { rows } = await pool.query(
`UPDATE suggestions SET status = $2, updated_at = now() WHERE id = $1 `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] [id, status]
); );
return rows[0] || null; return rows[0] || null;
@ -94,10 +94,17 @@ async function promoteToSong(suggestionId, addedBy) {
return null; return null;
} }
const { rows: songRows } = await client.query( const { rows: songRows } = await client.query(
`INSERT INTO songs (title, artist, notes, youtube_url, added_by) `INSERT INTO songs (title, artist, notes, youtube_url, spotify_url, added_by)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, title, artist, notes, youtube_url, spotify_url, added_by, created_at, updated_at`, 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]; const song = songRows[0];
await client.query( await client.query(

96
src/routes/calendar.js Normal file
View file

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

View file

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

View file

@ -5,6 +5,7 @@ const songsRoutes = require('./songs');
const suggestionsRoutes = require('./suggestions'); const suggestionsRoutes = require('./suggestions');
const setlistsRoutes = require('./setlists'); const setlistsRoutes = require('./setlists');
const musicSearchRoutes = require('./musicSearch'); const musicSearchRoutes = require('./musicSearch');
const calendarRoutes = require('./calendar');
const router = express.Router(); const router = express.Router();
@ -14,5 +15,6 @@ router.use('/songs', songsRoutes);
router.use('/suggestions', suggestionsRoutes); router.use('/suggestions', suggestionsRoutes);
router.use('/setlists', setlistsRoutes); router.use('/setlists', setlistsRoutes);
router.use('/music-search', musicSearchRoutes); router.use('/music-search', musicSearchRoutes);
router.use('/calendar', calendarRoutes);
module.exports = router; module.exports = router;

View file

@ -2,6 +2,7 @@ const express = require('express');
const suggestionsRepo = require('../repositories/suggestionsRepo'); const suggestionsRepo = require('../repositories/suggestionsRepo');
const { requireAdmin } = require('../auth/middleware'); const { requireAdmin } = require('../auth/middleware');
const { isValidYoutubeUrl } = require('../lib/youtube'); const { isValidYoutubeUrl } = require('../lib/youtube');
const { isValidSpotifyUrl } = require('../lib/spotify');
const asyncHandler = require('../lib/asyncHandler'); const asyncHandler = require('../lib/asyncHandler');
const router = express.Router(); const router = express.Router();
@ -26,17 +27,21 @@ router.get(
router.post( router.post(
'/', '/',
asyncHandler(async (req, res) => { 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()) { if (!title || !title.trim() || !youtubeUrl || !youtubeUrl.trim()) {
return res.status(400).json({ error: 'title_and_youtube_url_required' }); return res.status(400).json({ error: 'title_and_youtube_url_required' });
} }
if (!isValidYoutubeUrl(youtubeUrl.trim())) { if (!isValidYoutubeUrl(youtubeUrl.trim())) {
return res.status(400).json({ error: 'invalid_youtube_url' }); 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({ const suggestion = await suggestionsRepo.create({
title: title.trim(), title: title.trim(),
artist: artist ? artist.trim() : null, artist: artist ? artist.trim() : null,
youtubeUrl: youtubeUrl.trim(), youtubeUrl: youtubeUrl.trim(),
spotifyUrl: spotifyUrl ? spotifyUrl.trim() : null,
description: description ? description.trim() : null, description: description ? description.trim() : null,
suggestedBy: req.user.id, suggestedBy: req.user.id,
}); });
@ -91,7 +96,6 @@ router.delete(
router.post( router.post(
'/:id/promote', '/:id/promote',
requireAdmin,
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const song = await suggestionsRepo.promoteToSong(req.params.id, req.user.id); const song = await suggestionsRepo.promoteToSong(req.params.id, req.user.id);
if (!song) return res.status(404).json({ error: 'not_found' }); if (!song) return res.status(404).json({ error: 'not_found' });

View file

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