feat(admin): add add-music api

This commit is contained in:
2026-05-25 19:45:22 +07:00
parent 9d7114e2f1
commit 88dcaea7af
12 changed files with 265 additions and 1 deletions
+1
View File
@@ -17,6 +17,7 @@ app.use(helmet());
app.use(express.json({ limit: '2mb' }));
app.use(cookieParser());
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
app.use('/uploads', express.static('uploads'));
app.use('/api', routes);
app.use((req, res) => {
+30
View File
@@ -0,0 +1,30 @@
import Track from '../models/Track.js';
export const adminController = {
async addTrack(req, res, next) {
try {
const { title, artist, album, genre } = req.body;
if (!title || !artist || !album || !genre) {
return res
.status(400)
.json({ success: false, message: 'All fields are required' });
}
const coverUrl = req.file ? `/uploads/covers/${req.file.filename}` : '';
const track = await Track.create({
title,
artist,
album,
genre,
coverUrl,
createdBy: req.userId,
});
return res.json({ success: true, data: track });
} catch (error) {
return next(error);
}
},
};
+18
View File
@@ -0,0 +1,18 @@
import User from '../models/User.js';
export const requireAdmin = async (req, res, next) => {
try {
if (!req.userId) {
return res.status(401).json({ success: false, message: 'Unauthorized' });
}
const user = await User.findById(req.userId).select('role').lean();
if (!user || user.role !== 'admin') {
return res.status(403).json({ success: false, message: 'Forbidden' });
}
return next();
} catch (error) {
return next(error);
}
};
+43
View File
@@ -0,0 +1,43 @@
import mongoose from 'mongoose';
const trackSchema = new mongoose.Schema(
{
title: {
type: String,
required: true,
trim: true,
maxlength: 120,
},
artist: {
type: String,
required: true,
trim: true,
maxlength: 120,
},
album: {
type: String,
required: true,
trim: true,
maxlength: 120,
},
genre: {
type: String,
required: true,
trim: true,
maxlength: 60,
},
coverUrl: {
type: String,
trim: true,
},
createdBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
},
},
{
timestamps: true,
}
);
export default mongoose.model('Track', trackSchema);
+17
View File
@@ -0,0 +1,17 @@
import { Router } from 'express';
import { adminController } from '../controllers/adminController.js';
import { auth } from '../middlewares/auth.js';
import { requireAdmin } from '../middlewares/roles.js';
import { uploadCover } from '../utils/multer.js';
const router = Router();
router.post(
'/tracks',
auth,
requireAdmin,
uploadCover.single('cover'),
adminController.addTrack
);
export default router;
+2
View File
@@ -1,5 +1,6 @@
import { Router } from 'express';
import authRoutes from './auths.js';
import adminRoutes from './admins.js';
const router = Router();
@@ -14,5 +15,6 @@ router.get('/health', (req, res) => {
});
router.use('/auth', authRoutes);
router.use('/admin', adminRoutes);
export default router;
+2
View File
@@ -1,12 +1,14 @@
import 'dotenv/config';
import app from './app.js';
import { connectDb } from './config/db.js';
import { ensureAdminUser } from './services/adminBootstrap.js';
const port = Number(process.env.PORT) || 3000;
const startServer = async () => {
try {
await connectDb();
await ensureAdminUser();
app.listen(port, () => {
process.stdout.write(`Server listening on port ${port}\n`);
});
+37
View File
@@ -0,0 +1,37 @@
import bcrypt from 'bcryptjs';
import User from '../models/User.js';
const normalize = (value) => value.trim().toLowerCase();
export const ensureAdminUser = async () => {
const username = process.env.ADMIN_USERNAME;
const email = process.env.ADMIN_EMAIL;
const password = process.env.ADMIN_PASSWORD;
if (!username || !email || !password) {
return;
}
const normalizedUsername = normalize(username);
const normalizedEmail = normalize(email);
const existing = await User.findOne({
$or: [{ username: normalizedUsername }, { email: normalizedEmail }],
}).lean();
if (existing) {
if (existing.role !== 'admin') {
await User.updateOne({ _id: existing._id }, { role: 'admin' });
}
return;
}
const passwordHash = await bcrypt.hash(password, 10);
await User.create({
name: 'Admin',
username: normalizedUsername,
email: normalizedEmail,
passwordHash,
role: 'admin',
});
};
+34
View File
@@ -0,0 +1,34 @@
import fs from 'fs';
import path from 'path';
import multer from 'multer';
const coversDir = path.join(process.cwd(), 'uploads', 'covers');
if (!fs.existsSync(coversDir)) {
fs.mkdirSync(coversDir, { recursive: true });
}
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, coversDir);
},
filename: (req, file, cb) => {
const safeName = file.originalname.replace(/\s+/g, '-');
cb(null, `${Date.now()}-${safeName}`);
},
});
const imageFilter = (req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowed.includes(file.mimetype)) {
return cb(new Error('Only JPG, PNG, or WEBP images are allowed'));
}
return cb(null, true);
};
export const uploadCover = multer({
storage,
fileFilter: imageFilter,
limits: { fileSize: 5 * 1024 * 1024 },
});