mirror of
https://github.com/nfonteyne/octane-website.git
synced 2026-09-03 23:24:48 +02:00
first commit
This commit is contained in:
commit
244cdbedd7
44 changed files with 3386 additions and 0 deletions
37
src/app.js
Normal file
37
src/app.js
Normal 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
31
src/auth/middleware.js
Normal 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
20
src/auth/oidc.js
Normal 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
88
src/auth/routes.js
Normal 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
22
src/auth/session.js
Normal 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
21
src/config.js
Normal 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
56
src/db/migrate.js
Normal 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 };
|
||||
92
src/db/migrations/001_init.sql
Normal file
92
src/db/migrations/001_init.sql
Normal 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;
|
||||
4
src/db/migrations/002_seed_instruments.sql
Normal file
4
src/db/migrations/002_seed_instruments.sql
Normal 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
6
src/db/pool.js
Normal 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
3
src/lib/asyncHandler.js
Normal 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
28
src/lib/youtube.js
Normal 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 };
|
||||
8
src/repositories/instrumentsRepo.js
Normal file
8
src/repositories/instrumentsRepo.js
Normal 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 };
|
||||
118
src/repositories/setlistsRepo.js
Normal file
118
src/repositories/setlistsRepo.js
Normal 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,
|
||||
};
|
||||
86
src/repositories/songsRepo.js
Normal file
86
src/repositories/songsRepo.js
Normal 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,
|
||||
};
|
||||
127
src/repositories/suggestionsRepo.js
Normal file
127
src/repositories/suggestionsRepo.js
Normal 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,
|
||||
};
|
||||
23
src/repositories/usersRepo.js
Normal file
23
src/repositories/usersRepo.js
Normal 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
16
src/routes/index.js
Normal 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
15
src/routes/instruments.js
Normal 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
123
src/routes/setlists.js
Normal 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
99
src/routes/songs.js
Normal 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
101
src/routes/suggestions.js
Normal 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
14
src/routes/users.js
Normal 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
19
src/server.js
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue