mirror of
https://github.com/nfonteyne/octane-website.git
synced 2026-09-03 23:24:48 +02:00
update: add calendar to app
This commit is contained in:
parent
b17715eaff
commit
08717c24bc
29 changed files with 1478 additions and 313 deletions
14
.env.example
14
.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=
|
||||
|
|
|
|||
54
README.md
54
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<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
|
||||
|
||||
- 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).
|
||||
|
||||
|
|
|
|||
71
public/calendar.html
Normal file
71
public/calendar.html
Normal 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">×</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">×</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>
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
<script src="/js/api.js"></script>
|
||||
<script src="/js/render.js"></script>
|
||||
<script src="/js/nav.js"></script>
|
||||
<script src="/js/setlistEditor.js"></script>
|
||||
<script src="/js/history-detail.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
337
public/js/calendar.js
Normal file
337
public/js/calendar.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
|
|
@ -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() {
|
||||
await initNav('history');
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
|
@ -16,31 +7,23 @@ function showError(message) {
|
|||
container.innerHTML = '<p class="empty">Concert introuvable.</p>';
|
||||
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 = `
|
||||
<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 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>
|
||||
`;
|
||||
const allSongs = await api.get('/api/songs');
|
||||
|
||||
const editor = createSetlistEditor({
|
||||
allSongs,
|
||||
getSetlist: async () => {
|
||||
try {
|
||||
return await api.get(`/api/setlists/${id}`);
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
emptyMessage: 'Concert introuvable.',
|
||||
allowDelete: true,
|
||||
onDeleted: () => {
|
||||
window.location.href = '/history.html';
|
||||
},
|
||||
});
|
||||
await editor.load();
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ async function initNav(activePage) {
|
|||
<a href="/index.html" class="${activePage === 'repertoire' ? 'active' : ''}">Répertoire</a>
|
||||
<a href="/suggestions.html" class="${activePage === 'suggestions' ? 'active' : ''}">Suggestions</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>
|
||||
</div>
|
||||
<div class="nav-user">
|
||||
|
|
|
|||
|
|
@ -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">↑</button>
|
||||
<button type="button" class="secondary icon-btn move-down" ${index === total - 1 ? 'disabled' : ''} title="Descendre">↓</button>
|
||||
<button type="button" class="secondary icon-btn move-section" title="${section === 'main' ? 'Déplacer en rappel' : 'Déplacer au programme'}">${section === 'main' ? '⇥ Rappel' : '⇤ Programme'}</button>
|
||||
<button type="button" class="danger icon-btn remove-row" title="Retirer">×</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() {
|
||||
me = await initNav('setlist');
|
||||
allSongs = await api.get('/api/songs');
|
||||
setlist = await api.get('/api/setlists/next');
|
||||
renderReadOnly();
|
||||
await initNav('setlist');
|
||||
const allSongs = await api.get('/api/songs');
|
||||
|
||||
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
278
public/js/setlistEditor.js
Normal 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">↑</button>
|
||||
<button type="button" class="secondary icon-btn move-down" ${index === total - 1 ? 'disabled' : ''} title="Descendre">↓</button>
|
||||
<button type="button" class="secondary icon-btn move-section" title="${section === 'main' ? 'Déplacer en rappel' : 'Déplacer au programme'}">${section === 'main' ? '⇥ Rappel' : '⇤ Programme'}</button>
|
||||
<button type="button" class="danger icon-btn remove-row" title="Retirer">×</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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -56,6 +56,7 @@ function detailTemplate(s) {
|
|||
const myVote = s.votes.find((v) => v.user_id === me.id);
|
||||
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>`}
|
||||
${s.spotify_url ? `<div class="song-links"><a class="pill-link spotify" href="${escapeHtml(s.spotify_url)}" target="_blank" rel="noopener">♫ Spotify</a></div>` : ''}
|
||||
${s.description ? `<div class="suggestion-note">${escapeHtml(s.description)}</div>` : ''}
|
||||
|
||||
<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>'}
|
||||
</div>
|
||||
|
||||
${me.isAdmin ? `
|
||||
<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' : ''}>
|
||||
${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>
|
||||
${me.isAdmin ? `
|
||||
<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>
|
||||
</form>` : ''}
|
||||
<button type="button" class="danger delete-btn" data-id="${s.id}">Supprimer</button>` : ''}
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
|
|
@ -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 = '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
<script src="/js/api.js"></script>
|
||||
<script src="/js/render.js"></script>
|
||||
<script src="/js/nav.js"></script>
|
||||
<script src="/js/setlistEditor.js"></script>
|
||||
<script src="/js/setlist.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
</div>
|
||||
<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 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>
|
||||
<p class="note" id="suggestion-link-status"></p>
|
||||
<button type="submit">Proposer</button>
|
||||
|
|
|
|||
|
|
@ -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')));
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
};
|
||||
|
|
|
|||
26
src/db/migrations/005_calendar.sql
Normal file
26
src/db/migrations/005_calendar.sql
Normal 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);
|
||||
7
src/db/migrations/006_calendar_seed_people.sql
Normal file
7
src/db/migrations/006_calendar_seed_people.sql
Normal 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;
|
||||
1
src/db/migrations/007_suggestion_spotify.sql
Normal file
1
src/db/migrations/007_suggestion_spotify.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE suggestions ADD COLUMN IF NOT EXISTS spotify_url TEXT;
|
||||
30
src/lib/calendarDates.js
Normal file
30
src/lib/calendarDates.js
Normal 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 };
|
||||
23
src/lib/calendarWebhookAuth.js
Normal file
23
src/lib/calendarWebhookAuth.js
Normal 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;
|
||||
25
src/lib/calendarWorkflowState.js
Normal file
25
src/lib/calendarWorkflowState.js
Normal 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 };
|
||||
125
src/repositories/calendarRepo.js
Normal file
125
src/repositories/calendarRepo.js
Normal 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 };
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
96
src/routes/calendar.js
Normal file
96
src/routes/calendar.js
Normal 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;
|
||||
42
src/routes/calendarWebhooks.js
Normal file
42
src/routes/calendarWebhooks.js
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
49
test/calendarDates.test.js
Normal file
49
test/calendarDates.test.js
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue