first commit

This commit is contained in:
Nathan FONTEYNE 2026-07-08 11:05:21 +02:00
commit 244cdbedd7
44 changed files with 3386 additions and 0 deletions

37
src/app.js Normal file
View file

@ -0,0 +1,37 @@
const path = require('path');
const express = require('express');
const sessionMiddleware = require('./auth/session');
const authRoutes = require('./auth/routes');
const { attachUser, requireAuth } = require('./auth/middleware');
const apiRouter = require('./routes');
function createApp() {
const app = express();
app.get('/health', (req, res) => res.status(200).json({ status: 'ok' }));
app.use(express.json());
app.use(sessionMiddleware);
app.use('/auth', authRoutes);
app.use(attachUser);
app.use('/api', requireAuth, apiRouter);
app.use(requireAuth, express.static(path.join(__dirname, '../public')));
app.use((req, res) => {
res.status(404).json({ error: 'not_found' });
});
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
console.error(err);
if (req.path.startsWith('/api/')) {
return res.status(500).json({ error: 'internal_error' });
}
res.status(500).send('Une erreur est survenue.');
});
return app;
}
module.exports = createApp;

31
src/auth/middleware.js Normal file
View file

@ -0,0 +1,31 @@
const usersRepo = require('../repositories/usersRepo');
async function attachUser(req, res, next) {
try {
if (req.session.userId) {
req.user = await usersRepo.findById(req.session.userId);
}
next();
} catch (err) {
next(err);
}
}
function requireAuth(req, res, next) {
if (!req.user) {
if (req.path.startsWith('/api/')) {
return res.status(401).json({ error: 'unauthenticated' });
}
return res.redirect(`/auth/login?returnTo=${encodeURIComponent(req.originalUrl)}`);
}
next();
}
function requireAdmin(req, res, next) {
if (!req.user || !req.user.is_admin) {
return res.status(403).json({ error: 'forbidden' });
}
next();
}
module.exports = { attachUser, requireAuth, requireAdmin };

20
src/auth/oidc.js Normal file
View file

@ -0,0 +1,20 @@
const client = require('openid-client');
const config = require('../config');
let oidcConfig = null;
async function initOidc() {
oidcConfig = await client.discovery(
new URL(config.authentikIssuerUrl),
config.oidcClientId,
config.oidcClientSecret
);
return oidcConfig;
}
function getOidcConfig() {
if (!oidcConfig) throw new Error('OIDC not initialized yet');
return oidcConfig;
}
module.exports = { initOidc, getOidcConfig, client };

88
src/auth/routes.js Normal file
View file

@ -0,0 +1,88 @@
const express = require('express');
const { client, getOidcConfig } = require('./oidc');
const usersRepo = require('../repositories/usersRepo');
const config = require('../config');
const asyncHandler = require('../lib/asyncHandler');
const router = express.Router();
router.get(
'/login',
asyncHandler(async (req, res) => {
const oidcConfig = getOidcConfig();
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
const state = client.randomState();
const nonce = client.randomNonce();
req.session.oidc = { codeVerifier, state, nonce };
if (req.query.returnTo) {
req.session.returnTo = req.query.returnTo;
}
const authUrl = client.buildAuthorizationUrl(oidcConfig, {
scope: 'openid profile email groups',
redirect_uri: config.oidcRedirectUri,
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
res.redirect(authUrl.href);
})
);
router.get(
'/callback',
asyncHandler(async (req, res) => {
const oidcConfig = getOidcConfig();
const pending = req.session.oidc;
if (!pending) {
return res.status(400).send('Session de connexion expirée, réessayez.');
}
const currentUrl = new URL(
req.originalUrl,
`${req.protocol}://${req.get('host')}`
);
const tokens = await client.authorizationCodeGrant(oidcConfig, currentUrl, {
pkceCodeVerifier: pending.codeVerifier,
expectedState: pending.state,
expectedNonce: pending.nonce,
});
const claims = tokens.claims();
const groups = Array.isArray(claims.groups) ? claims.groups : [];
const isAdmin = groups.includes(config.adminGroupName);
const user = await usersRepo.upsertFromClaims({
sub: claims.sub,
name: claims.name || claims.preferred_username || claims.email || claims.sub,
email: claims.email || null,
isAdmin,
});
const returnTo = req.session.returnTo || '/';
delete req.session.oidc;
delete req.session.returnTo;
req.session.regenerate((err) => {
if (err) throw err;
req.session.userId = user.id;
req.session.save((saveErr) => {
if (saveErr) throw saveErr;
res.redirect(returnTo);
});
});
})
);
router.get('/logout', (req, res) => {
req.session.destroy(() => {
res.redirect('/');
});
});
module.exports = router;

22
src/auth/session.js Normal file
View file

@ -0,0 +1,22 @@
const session = require('express-session');
const pgSessionFactory = require('connect-pg-simple');
const pool = require('../db/pool');
const config = require('../config');
const PgSession = pgSessionFactory(session);
module.exports = session({
store: new PgSession({
pool,
createTableIfMissing: true,
}),
secret: config.sessionSecret,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: config.nodeEnv === 'production',
maxAge: 1000 * 60 * 60 * 24 * 30,
},
});

21
src/config.js Normal file
View file

@ -0,0 +1,21 @@
require('dotenv').config();
function required(name) {
const value = process.env[name];
if (!value && process.env.NODE_ENV !== 'test') {
throw new Error(`Missing required env var: ${name}`);
}
return value;
}
module.exports = {
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT, 10) || 3000,
databaseUrl: required('DATABASE_URL'),
sessionSecret: required('SESSION_SECRET'),
authentikIssuerUrl: required('AUTHENTIK_ISSUER_URL'),
oidcClientId: required('OIDC_CLIENT_ID'),
oidcClientSecret: required('OIDC_CLIENT_SECRET'),
oidcRedirectUri: required('OIDC_REDIRECT_URI'),
adminGroupName: process.env.ADMIN_GROUP_NAME || 'octane-admins',
};

56
src/db/migrate.js Normal file
View file

@ -0,0 +1,56 @@
const fs = require('fs');
const path = require('path');
const pool = require('./pool');
const MIGRATIONS_DIR = path.join(__dirname, 'migrations');
async function runMigrations() {
const client = await pool.connect();
try {
await client.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
filename TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`);
const { rows } = await client.query('SELECT filename FROM schema_migrations');
const applied = new Set(rows.map((r) => r.filename));
const files = fs
.readdirSync(MIGRATIONS_DIR)
.filter((f) => f.endsWith('.sql'))
.sort();
for (const file of files) {
if (applied.has(file)) continue;
const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), 'utf8');
console.log(`Applying migration: ${file}`);
await client.query('BEGIN');
try {
await client.query(sql);
await client.query('INSERT INTO schema_migrations (filename) VALUES ($1)', [file]);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
}
}
} finally {
client.release();
}
}
if (require.main === module) {
runMigrations()
.then(() => {
console.log('Migrations complete.');
return pool.end();
})
.catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});
}
module.exports = { runMigrations };

View file

@ -0,0 +1,92 @@
-- Users, keyed by Authentik OIDC "sub" claim
CREATE TABLE users (
id SERIAL PRIMARY KEY,
authentik_sub TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
email TEXT,
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Instruments (fixed-ish list)
CREATE TABLE instruments (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
-- Repertoire
CREATE TABLE songs (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
artist TEXT NOT NULL,
notes TEXT,
added_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Per-song, per-instrument tutorial/resource links
CREATE TABLE song_tutorials (
id SERIAL PRIMARY KEY,
song_id INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
instrument_id INTEGER NOT NULL REFERENCES instruments(id) ON DELETE RESTRICT,
url TEXT NOT NULL,
label TEXT,
added_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_song_tutorials_song_id ON song_tutorials(song_id);
-- Song suggestions
CREATE TABLE suggestions (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
artist TEXT,
youtube_url TEXT NOT NULL,
suggested_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'rejected')),
promoted_song_id INTEGER REFERENCES songs(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Votes/comments on suggestions, one per user per suggestion
CREATE TABLE suggestion_votes (
id SERIAL PRIMARY KEY,
suggestion_id INTEGER NOT NULL REFERENCES suggestions(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
vote TEXT NOT NULL CHECK (vote IN ('approve', 'reject')),
comment TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (suggestion_id, user_id)
);
-- Concerts / setlists
CREATE TABLE setlists (
id SERIAL PRIMARY KEY,
name TEXT,
venue TEXT,
concert_date DATE NOT NULL,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Songs within a setlist, ordered, with per-song note and encore flag
CREATE TABLE setlist_songs (
id SERIAL PRIMARY KEY,
setlist_id INTEGER NOT NULL REFERENCES setlists(id) ON DELETE CASCADE,
song_id INTEGER NOT NULL REFERENCES songs(id) ON DELETE RESTRICT,
position INTEGER NOT NULL,
note TEXT,
is_encore BOOLEAN NOT NULL DEFAULT FALSE,
UNIQUE (setlist_id, song_id)
);
CREATE INDEX idx_setlist_songs_setlist_id ON setlist_songs(setlist_id);
CREATE UNIQUE INDEX uq_setlist_position_main
ON setlist_songs(setlist_id, position) WHERE NOT is_encore;
CREATE UNIQUE INDEX uq_setlist_position_encore
ON setlist_songs(setlist_id, position) WHERE is_encore;

View file

@ -0,0 +1,4 @@
INSERT INTO instruments (name) VALUES
('chant'), ('guitare'), ('basse'), ('batterie'), ('clavier'),
('percussions'), ('cuivres'), ('autre')
ON CONFLICT (name) DO NOTHING;

6
src/db/pool.js Normal file
View file

@ -0,0 +1,6 @@
const { Pool } = require('pg');
const config = require('../config');
const pool = new Pool({ connectionString: config.databaseUrl });
module.exports = pool;

3
src/lib/asyncHandler.js Normal file
View file

@ -0,0 +1,3 @@
module.exports = function asyncHandler(fn) {
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
};

28
src/lib/youtube.js Normal file
View file

@ -0,0 +1,28 @@
const YOUTUBE_HOSTS = new Set(['youtube.com', 'www.youtube.com', 'youtu.be', 'm.youtube.com']);
function extractVideoId(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return null;
}
if (!YOUTUBE_HOSTS.has(parsed.hostname)) return null;
if (parsed.hostname === 'youtu.be') {
return parsed.pathname.slice(1) || null;
}
if (parsed.pathname === '/watch') {
return parsed.searchParams.get('v');
}
if (parsed.pathname.startsWith('/embed/')) {
return parsed.pathname.split('/embed/')[1] || null;
}
return null;
}
function isValidYoutubeUrl(url) {
return extractVideoId(url) !== null;
}
module.exports = { extractVideoId, isValidYoutubeUrl };

View file

@ -0,0 +1,8 @@
const pool = require('../db/pool');
async function findAll() {
const { rows } = await pool.query('SELECT id, name FROM instruments ORDER BY name');
return rows;
}
module.exports = { findAll };

View file

@ -0,0 +1,118 @@
const pool = require('../db/pool');
async function findNext() {
const { rows } = await pool.query(
`SELECT id, name, venue, concert_date, created_by, created_at, updated_at
FROM setlists
WHERE concert_date >= CURRENT_DATE
ORDER BY concert_date ASC
LIMIT 1`
);
return rows[0] || null;
}
async function findHistory() {
const { rows } = await pool.query(
`SELECT id, name, venue, concert_date, created_by, created_at, updated_at
FROM setlists
WHERE concert_date < CURRENT_DATE
ORDER BY concert_date DESC`
);
return rows;
}
async function findById(id) {
const { rows } = await pool.query(
`SELECT id, name, venue, concert_date, created_by, created_at, updated_at
FROM setlists WHERE id = $1`,
[id]
);
return rows[0] || null;
}
async function findSongs(setlistId) {
const { rows } = await pool.query(
`SELECT ss.id, ss.setlist_id, ss.song_id, s.title, s.artist, ss.position, ss.note, ss.is_encore
FROM setlist_songs ss
JOIN songs s ON s.id = ss.song_id
WHERE ss.setlist_id = $1
ORDER BY ss.is_encore, ss.position`,
[setlistId]
);
return rows;
}
async function create({ name, venue, concertDate, createdBy }) {
const { rows } = await pool.query(
`INSERT INTO setlists (name, venue, concert_date, created_by)
VALUES ($1, $2, $3, $4)
RETURNING id, name, venue, concert_date, created_by, created_at, updated_at`,
[name || null, venue || null, concertDate, createdBy]
);
return rows[0];
}
async function update(id, { name, venue, concertDate }) {
const { rows } = await pool.query(
`UPDATE setlists SET name = $2, venue = $3, concert_date = $4, updated_at = now()
WHERE id = $1
RETURNING id, name, venue, concert_date, created_by, created_at, updated_at`,
[id, name || null, venue || null, concertDate]
);
return rows[0] || null;
}
async function remove(id) {
await pool.query('DELETE FROM setlists WHERE id = $1', [id]);
}
async function replaceSongs(setlistId, songEntries) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM setlist_songs WHERE setlist_id = $1', [setlistId]);
for (const entry of songEntries) {
await client.query(
`INSERT INTO setlist_songs (setlist_id, song_id, position, note, is_encore)
VALUES ($1, $2, $3, $4, $5)`,
[setlistId, entry.songId, entry.position, entry.note || null, !!entry.isEncore]
);
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
async function addSong(setlistId, { songId, position, note, isEncore }) {
const { rows } = await pool.query(
`INSERT INTO setlist_songs (setlist_id, song_id, position, note, is_encore)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, setlist_id, song_id, position, note, is_encore`,
[setlistId, songId, position, note || null, !!isEncore]
);
return rows[0];
}
async function removeSong(setlistId, setlistSongId) {
await pool.query('DELETE FROM setlist_songs WHERE id = $1 AND setlist_id = $2', [
setlistSongId,
setlistId,
]);
}
module.exports = {
findNext,
findHistory,
findById,
findSongs,
create,
update,
remove,
replaceSongs,
addSong,
removeSong,
};

View file

@ -0,0 +1,86 @@
const pool = require('../db/pool');
async function findAll() {
const { rows } = await pool.query(`
SELECT s.id, s.title, s.artist, s.notes, s.created_at,
COUNT(st.id)::int AS tutorial_count
FROM songs s
LEFT JOIN song_tutorials st ON st.song_id = s.id
GROUP BY s.id
ORDER BY s.title
`);
return rows;
}
async function findById(id) {
const { rows } = await pool.query(
'SELECT id, title, artist, notes, added_by, created_at, updated_at FROM songs WHERE id = $1',
[id]
);
return rows[0] || null;
}
async function create({ title, artist, notes, addedBy }) {
const { rows } = await pool.query(
`INSERT INTO songs (title, artist, notes, added_by)
VALUES ($1, $2, $3, $4)
RETURNING id, title, artist, notes, added_by, created_at, updated_at`,
[title, artist, notes || null, addedBy]
);
return rows[0];
}
async function update(id, { title, artist, notes }) {
const { rows } = await pool.query(
`UPDATE songs SET title = $2, artist = $3, notes = $4, updated_at = now()
WHERE id = $1
RETURNING id, title, artist, notes, added_by, created_at, updated_at`,
[id, title, artist, notes || null]
);
return rows[0] || null;
}
async function remove(id) {
await pool.query('DELETE FROM songs WHERE id = $1', [id]);
}
async function findTutorials(songId) {
const { rows } = await pool.query(
`SELECT st.id, st.song_id, st.instrument_id, i.name AS instrument_name,
st.url, st.label, st.added_by, st.created_at
FROM song_tutorials st
JOIN instruments i ON i.id = st.instrument_id
WHERE st.song_id = $1
ORDER BY i.name`,
[songId]
);
return rows;
}
async function addTutorial(songId, { instrumentId, url, label, addedBy }) {
const { rows } = await pool.query(
`INSERT INTO song_tutorials (song_id, instrument_id, url, label, added_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, song_id, instrument_id, url, label, added_by, created_at`,
[songId, instrumentId, url, label || null, addedBy]
);
return rows[0];
}
async function removeTutorial(songId, tutorialId) {
await pool.query('DELETE FROM song_tutorials WHERE id = $1 AND song_id = $2', [
tutorialId,
songId,
]);
}
module.exports = {
findAll,
findById,
create,
update,
remove,
findTutorials,
addTutorial,
removeTutorial,
};

View file

@ -0,0 +1,127 @@
const pool = require('../db/pool');
async function findAll() {
const { rows } = await pool.query(`
SELECT sg.id, sg.title, sg.artist, sg.youtube_url, 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
FROM suggestions sg
JOIN users u ON u.id = sg.suggested_by
LEFT JOIN suggestion_votes v ON v.suggestion_id = sg.id
GROUP BY sg.id, u.name
ORDER BY sg.created_at DESC
`);
return rows;
}
async function findById(id) {
const { rows } = await pool.query(
`SELECT sg.id, sg.title, sg.artist, sg.youtube_url, 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
WHERE sg.id = $1`,
[id]
);
return rows[0] || null;
}
async function findVotes(suggestionId) {
const { rows } = await pool.query(
`SELECT v.id, v.user_id, u.name AS voter_name, v.vote, v.comment, v.created_at, v.updated_at
FROM suggestion_votes v
JOIN users u ON u.id = v.user_id
WHERE v.suggestion_id = $1
ORDER BY v.created_at`,
[suggestionId]
);
return rows;
}
async function create({ title, artist, youtubeUrl, suggestedBy }) {
const { rows } = await pool.query(
`INSERT INTO suggestions (title, artist, youtube_url, suggested_by)
VALUES ($1, $2, $3, $4)
RETURNING id, title, artist, youtube_url, status, promoted_song_id, created_at, suggested_by`,
[title, artist || null, youtubeUrl, suggestedBy]
);
return rows[0];
}
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, status, promoted_song_id, created_at, suggested_by`,
[id, status]
);
return rows[0] || null;
}
async function remove(id) {
await pool.query('DELETE FROM suggestions WHERE id = $1', [id]);
}
async function upsertVote(suggestionId, userId, { vote, comment }) {
const { rows } = await pool.query(
`INSERT INTO suggestion_votes (suggestion_id, user_id, vote, comment)
VALUES ($1, $2, $3, $4)
ON CONFLICT (suggestion_id, user_id)
DO UPDATE SET vote = $3, comment = $4, updated_at = now()
RETURNING id, suggestion_id, user_id, vote, comment, created_at, updated_at`,
[suggestionId, userId, vote, comment || null]
);
return rows[0];
}
async function removeVote(suggestionId, userId) {
await pool.query('DELETE FROM suggestion_votes WHERE suggestion_id = $1 AND user_id = $2', [
suggestionId,
userId,
]);
}
async function promoteToSong(suggestionId, addedBy) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows: sugRows } = await client.query('SELECT * FROM suggestions WHERE id = $1 FOR UPDATE', [
suggestionId,
]);
const suggestion = sugRows[0];
if (!suggestion) {
await client.query('ROLLBACK');
return null;
}
const { rows: songRows } = await client.query(
`INSERT INTO songs (title, artist, added_by)
VALUES ($1, $2, $3)
RETURNING id, title, artist, notes, added_by, created_at, updated_at`,
[suggestion.title, suggestion.artist || suggestion.title, addedBy]
);
const song = songRows[0];
await client.query(
`UPDATE suggestions SET status = 'approved', promoted_song_id = $2, updated_at = now() WHERE id = $1`,
[suggestionId, song.id]
);
await client.query('COMMIT');
return song;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
module.exports = {
findAll,
findById,
findVotes,
create,
updateStatus,
remove,
upsertVote,
removeVote,
promoteToSong,
};

View file

@ -0,0 +1,23 @@
const pool = require('../db/pool');
async function findById(id) {
const { rows } = await pool.query(
'SELECT id, authentik_sub, name, email, is_admin, created_at FROM users WHERE id = $1',
[id]
);
return rows[0] || null;
}
async function upsertFromClaims({ sub, name, email, isAdmin }) {
const { rows } = await pool.query(
`INSERT INTO users (authentik_sub, name, email, is_admin)
VALUES ($1, $2, $3, $4)
ON CONFLICT (authentik_sub)
DO UPDATE SET name = $2, email = $3, is_admin = $4, updated_at = now()
RETURNING id, authentik_sub, name, email, is_admin, created_at`,
[sub, name, email, isAdmin]
);
return rows[0];
}
module.exports = { findById, upsertFromClaims };

16
src/routes/index.js Normal file
View file

@ -0,0 +1,16 @@
const express = require('express');
const usersRoutes = require('./users');
const instrumentsRoutes = require('./instruments');
const songsRoutes = require('./songs');
const suggestionsRoutes = require('./suggestions');
const setlistsRoutes = require('./setlists');
const router = express.Router();
router.use('/users', usersRoutes);
router.use('/instruments', instrumentsRoutes);
router.use('/songs', songsRoutes);
router.use('/suggestions', suggestionsRoutes);
router.use('/setlists', setlistsRoutes);
module.exports = router;

15
src/routes/instruments.js Normal file
View file

@ -0,0 +1,15 @@
const express = require('express');
const instrumentsRepo = require('../repositories/instrumentsRepo');
const asyncHandler = require('../lib/asyncHandler');
const router = express.Router();
router.get(
'/',
asyncHandler(async (req, res) => {
const instruments = await instrumentsRepo.findAll();
res.json(instruments);
})
);
module.exports = router;

123
src/routes/setlists.js Normal file
View file

@ -0,0 +1,123 @@
const express = require('express');
const setlistsRepo = require('../repositories/setlistsRepo');
const { requireAdmin } = require('../auth/middleware');
const asyncHandler = require('../lib/asyncHandler');
const router = express.Router();
async function withSongs(setlist) {
if (!setlist) return null;
const songs = await setlistsRepo.findSongs(setlist.id);
return { ...setlist, songs };
}
router.get(
'/next',
asyncHandler(async (req, res) => {
const setlist = await setlistsRepo.findNext();
res.json(await withSongs(setlist));
})
);
router.get(
'/history',
asyncHandler(async (req, res) => {
res.json(await setlistsRepo.findHistory());
})
);
router.get(
'/:id',
asyncHandler(async (req, res) => {
const setlist = await setlistsRepo.findById(req.params.id);
if (!setlist) return res.status(404).json({ error: 'not_found' });
res.json(await withSongs(setlist));
})
);
router.post(
'/',
requireAdmin,
asyncHandler(async (req, res) => {
const { name, venue, concertDate } = req.body || {};
if (!concertDate) return res.status(400).json({ error: 'concert_date_required' });
const setlist = await setlistsRepo.create({ name, venue, concertDate, createdBy: req.user.id });
res.status(201).json(setlist);
})
);
router.patch(
'/:id',
requireAdmin,
asyncHandler(async (req, res) => {
const { name, venue, concertDate } = req.body || {};
if (!concertDate) return res.status(400).json({ error: 'concert_date_required' });
const setlist = await setlistsRepo.update(req.params.id, { name, venue, concertDate });
if (!setlist) return res.status(404).json({ error: 'not_found' });
res.json(setlist);
})
);
router.delete(
'/:id',
requireAdmin,
asyncHandler(async (req, res) => {
await setlistsRepo.remove(req.params.id);
res.status(204).end();
})
);
router.put(
'/:id/songs',
requireAdmin,
asyncHandler(async (req, res) => {
const { songs } = req.body || {};
if (!Array.isArray(songs)) return res.status(400).json({ error: 'songs_array_required' });
for (const s of songs) {
if (!s.songId || typeof s.position !== 'number') {
return res.status(400).json({ error: 'invalid_song_entry' });
}
}
try {
await setlistsRepo.replaceSongs(req.params.id, songs);
} catch (err) {
if (err.code === '23505') {
return res.status(409).json({ error: 'duplicate_position', message: 'Deux morceaux ont la même position.' });
}
throw err;
}
const setlist = await setlistsRepo.findById(req.params.id);
res.json(await withSongs(setlist));
})
);
router.post(
'/:id/songs',
requireAdmin,
asyncHandler(async (req, res) => {
const { songId, position, note, isEncore } = req.body || {};
if (!songId || typeof position !== 'number') {
return res.status(400).json({ error: 'invalid_song_entry' });
}
try {
const entry = await setlistsRepo.addSong(req.params.id, { songId, position, note, isEncore });
res.status(201).json(entry);
} catch (err) {
if (err.code === '23505') {
return res.status(409).json({ error: 'duplicate_position', message: 'Ce morceau ou cette position est déjà utilisé.' });
}
throw err;
}
})
);
router.delete(
'/:id/songs/:setlistSongId',
requireAdmin,
asyncHandler(async (req, res) => {
await setlistsRepo.removeSong(req.params.id, req.params.setlistSongId);
res.status(204).end();
})
);
module.exports = router;

99
src/routes/songs.js Normal file
View file

@ -0,0 +1,99 @@
const express = require('express');
const songsRepo = require('../repositories/songsRepo');
const { requireAdmin } = require('../auth/middleware');
const asyncHandler = require('../lib/asyncHandler');
const router = express.Router();
router.get(
'/',
asyncHandler(async (req, res) => {
res.json(await songsRepo.findAll());
})
);
router.get(
'/:id',
asyncHandler(async (req, res) => {
const song = await songsRepo.findById(req.params.id);
if (!song) return res.status(404).json({ error: 'not_found' });
const tutorials = await songsRepo.findTutorials(req.params.id);
res.json({ ...song, tutorials });
})
);
router.post(
'/',
requireAdmin,
asyncHandler(async (req, res) => {
const { title, artist, notes } = req.body || {};
if (!title || !title.trim() || !artist || !artist.trim()) {
return res.status(400).json({ error: 'title_and_artist_required' });
}
const song = await songsRepo.create({ title: title.trim(), artist: artist.trim(), notes, addedBy: req.user.id });
res.status(201).json(song);
})
);
router.patch(
'/:id',
requireAdmin,
asyncHandler(async (req, res) => {
const { title, artist, notes } = req.body || {};
if (!title || !title.trim() || !artist || !artist.trim()) {
return res.status(400).json({ error: 'title_and_artist_required' });
}
const song = await songsRepo.update(req.params.id, { title: title.trim(), artist: artist.trim(), notes });
if (!song) return res.status(404).json({ error: 'not_found' });
res.json(song);
})
);
router.delete(
'/:id',
requireAdmin,
asyncHandler(async (req, res) => {
try {
await songsRepo.remove(req.params.id);
res.status(204).end();
} catch (err) {
if (err.code === '23503') {
return res
.status(409)
.json({ error: 'song_in_use', message: 'Ce morceau est utilisé dans une setlist et ne peut pas être supprimé.' });
}
throw err;
}
})
);
router.post(
'/:id/tutorials',
requireAdmin,
asyncHandler(async (req, res) => {
const { instrumentId, url, label } = req.body || {};
if (!instrumentId || !url || !url.trim()) {
return res.status(400).json({ error: 'instrument_and_url_required' });
}
const song = await songsRepo.findById(req.params.id);
if (!song) return res.status(404).json({ error: 'not_found' });
const tutorial = await songsRepo.addTutorial(req.params.id, {
instrumentId,
url: url.trim(),
label,
addedBy: req.user.id,
});
res.status(201).json(tutorial);
})
);
router.delete(
'/:songId/tutorials/:id',
requireAdmin,
asyncHandler(async (req, res) => {
await songsRepo.removeTutorial(req.params.songId, req.params.id);
res.status(204).end();
})
);
module.exports = router;

101
src/routes/suggestions.js Normal file
View file

@ -0,0 +1,101 @@
const express = require('express');
const suggestionsRepo = require('../repositories/suggestionsRepo');
const { requireAdmin } = require('../auth/middleware');
const { isValidYoutubeUrl } = require('../lib/youtube');
const asyncHandler = require('../lib/asyncHandler');
const router = express.Router();
router.get(
'/',
asyncHandler(async (req, res) => {
res.json(await suggestionsRepo.findAll());
})
);
router.get(
'/:id',
asyncHandler(async (req, res) => {
const suggestion = await suggestionsRepo.findById(req.params.id);
if (!suggestion) return res.status(404).json({ error: 'not_found' });
const votes = await suggestionsRepo.findVotes(req.params.id);
res.json({ ...suggestion, votes });
})
);
router.post(
'/',
asyncHandler(async (req, res) => {
const { title, artist, youtubeUrl } = 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' });
}
const suggestion = await suggestionsRepo.create({
title: title.trim(),
artist: artist ? artist.trim() : null,
youtubeUrl: youtubeUrl.trim(),
suggestedBy: req.user.id,
});
res.status(201).json(suggestion);
})
);
router.patch(
'/:id',
requireAdmin,
asyncHandler(async (req, res) => {
const { status } = req.body || {};
if (!['pending', 'approved', 'rejected'].includes(status)) {
return res.status(400).json({ error: 'invalid_status' });
}
const suggestion = await suggestionsRepo.updateStatus(req.params.id, status);
if (!suggestion) return res.status(404).json({ error: 'not_found' });
res.json(suggestion);
})
);
router.delete(
'/:id',
requireAdmin,
asyncHandler(async (req, res) => {
await suggestionsRepo.remove(req.params.id);
res.status(204).end();
})
);
router.post(
'/:id/vote',
asyncHandler(async (req, res) => {
const { vote, comment } = req.body || {};
if (!['approve', 'reject'].includes(vote)) {
return res.status(400).json({ error: 'invalid_vote' });
}
const suggestion = await suggestionsRepo.findById(req.params.id);
if (!suggestion) return res.status(404).json({ error: 'not_found' });
const result = await suggestionsRepo.upsertVote(req.params.id, req.user.id, { vote, comment });
res.status(200).json(result);
})
);
router.delete(
'/:id/vote',
asyncHandler(async (req, res) => {
await suggestionsRepo.removeVote(req.params.id, req.user.id);
res.status(204).end();
})
);
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' });
res.status(201).json(song);
})
);
module.exports = router;

14
src/routes/users.js Normal file
View file

@ -0,0 +1,14 @@
const express = require('express');
const asyncHandler = require('../lib/asyncHandler');
const router = express.Router();
router.get(
'/me',
asyncHandler(async (req, res) => {
const { id, name, email, is_admin } = req.user;
res.json({ id, name, email, isAdmin: is_admin });
})
);
module.exports = router;

19
src/server.js Normal file
View file

@ -0,0 +1,19 @@
const config = require('./config');
const createApp = require('./app');
const { initOidc } = require('./auth/oidc');
const { runMigrations } = require('./db/migrate');
async function main() {
await runMigrations();
await initOidc();
const app = createApp();
app.listen(config.port, () => {
console.log(`Octane website listening on port ${config.port}`);
});
}
main().catch((err) => {
console.error('Failed to start server:', err);
process.exit(1);
});