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

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;