feat(admin): requireAdmin middleware + /api/admin routes
- requireAdmin: charge user_roles en DB, accepte admin/super_admin - GET/POST/PATCH/DELETE /api/admin/videos (publiées + non publiées) - GET /api/admin/users avec rôles et abonnement actif - PATCH /api/admin/users/:id/roles (remplacement atomique par slugs) - GET/POST/PATCH /api/admin/plans
This commit is contained in:
@@ -7,6 +7,7 @@ import { AppDataSource } from "./config/data-source";
|
||||
import authRoutes from "./routes/auth.routes";
|
||||
import videoRoutes from "./routes/video.routes";
|
||||
import playlistRoutes from "./routes/playlist.routes";
|
||||
import adminRoutes from "./routes/admin.routes";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -27,6 +28,7 @@ app.get("/api/health", (_req, res) => {
|
||||
app.use("/api/auth", authRoutes);
|
||||
app.use("/api/videos", videoRoutes);
|
||||
app.use("/api/playlists", playlistRoutes);
|
||||
app.use("/api/admin", adminRoutes);
|
||||
|
||||
AppDataSource.initialize()
|
||||
.then(() => {
|
||||
|
||||
37
backend/src/middleware/admin.middleware.ts
Normal file
37
backend/src/middleware/admin.middleware.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Response, NextFunction } from "express";
|
||||
import { AppDataSource } from "../config/data-source";
|
||||
import { UserRole } from "../entities/UserRole";
|
||||
import { AuthenticatedRequest } from "./auth.middleware";
|
||||
|
||||
/**
|
||||
* Middleware requireAdmin — s'exécute APRÈS requireAuth.
|
||||
* Charge les rôles de l'utilisateur depuis la DB et vérifie
|
||||
* la présence du slug "admin" ou "super_admin".
|
||||
* Retourne 403 FORBIDDEN si la condition n'est pas remplie.
|
||||
*/
|
||||
export const requireAdmin = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
const userRoles = await AppDataSource.getRepository(UserRole).find({
|
||||
where: { userId },
|
||||
relations: ["role"],
|
||||
});
|
||||
|
||||
const slugs = userRoles.map((ur) => ur.role.slug);
|
||||
const isAdmin = slugs.includes("admin") || slugs.includes("super_admin");
|
||||
|
||||
if (!isAdmin) {
|
||||
res.status(403).json({ success: false, error: "FORBIDDEN" });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
} catch {
|
||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||
}
|
||||
};
|
||||
345
backend/src/routes/admin.routes.ts
Normal file
345
backend/src/routes/admin.routes.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { AppDataSource } from "../config/data-source";
|
||||
import { Video } from "../entities/Video";
|
||||
import { User } from "../entities/User";
|
||||
import { UserRole } from "../entities/UserRole";
|
||||
import { Role } from "../entities/Role";
|
||||
import { UserSubscription } from "../entities/UserSubscription";
|
||||
import { SubscriptionPlan } from "../entities/SubscriptionPlan";
|
||||
import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware";
|
||||
import { requireAdmin } from "../middleware/admin.middleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Applique requireAuth + requireAdmin sur toutes les routes de ce routeur
|
||||
router.use(requireAuth as unknown as (req: Request, res: Response, next: () => void) => void);
|
||||
router.use(requireAdmin as unknown as (req: Request, res: Response, next: () => void) => void);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VIDEOS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GET /api/admin/videos
|
||||
* Liste toutes les vidéos (publiées et non publiées), tous les champs.
|
||||
*/
|
||||
router.get("/videos", async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const videos = await AppDataSource.getRepository(Video).find({
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
res.json({ success: true, data: { videos } });
|
||||
} catch {
|
||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/videos
|
||||
* Crée une nouvelle vidéo.
|
||||
* Body: { title, description?, thumbnailUrl?, duration?, storageType, storageKey, requiredLevel?, isPublished? }
|
||||
*/
|
||||
router.post("/videos", async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
title,
|
||||
description = null,
|
||||
thumbnailUrl = null,
|
||||
duration = null,
|
||||
storageType,
|
||||
storageKey,
|
||||
requiredLevel = 0,
|
||||
isPublished = false,
|
||||
} = req.body as {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
thumbnailUrl?: string | null;
|
||||
duration?: number | null;
|
||||
storageType: string;
|
||||
storageKey: string;
|
||||
requiredLevel?: number;
|
||||
isPublished?: boolean;
|
||||
};
|
||||
|
||||
if (!title || !storageType || !storageKey) {
|
||||
res.status(400).json({ success: false, error: "MISSING_REQUIRED_FIELDS" });
|
||||
return;
|
||||
}
|
||||
|
||||
const video = AppDataSource.getRepository(Video).create({
|
||||
id: crypto.randomUUID(),
|
||||
title,
|
||||
description,
|
||||
thumbnailUrl,
|
||||
duration,
|
||||
storageType: storageType as Video["storageType"],
|
||||
storageKey,
|
||||
requiredLevel,
|
||||
isPublished,
|
||||
publishedAt: isPublished ? new Date() : null,
|
||||
});
|
||||
|
||||
await AppDataSource.getRepository(Video).save(video);
|
||||
res.status(201).json({ success: true, data: { video } });
|
||||
} catch {
|
||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/videos/:id
|
||||
* Met à jour une vidéo (champs partiels).
|
||||
*/
|
||||
router.patch("/videos/:id", async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const repo = AppDataSource.getRepository(Video);
|
||||
const video = await repo.findOne({ where: { id: req.params.id } });
|
||||
|
||||
if (!video) {
|
||||
res.status(404).json({ success: false, error: "NOT_FOUND" });
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = [
|
||||
"title", "description", "thumbnailUrl", "duration",
|
||||
"storageType", "storageKey", "requiredLevel", "isPublished",
|
||||
] as const;
|
||||
|
||||
for (const field of allowed) {
|
||||
if (field in req.body) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(video as any)[field] = (req.body as Record<string, unknown>)[field];
|
||||
}
|
||||
}
|
||||
|
||||
// Mise à jour de publishedAt si on publie maintenant
|
||||
if (req.body.isPublished === true && !video.publishedAt) {
|
||||
video.publishedAt = new Date();
|
||||
} else if (req.body.isPublished === false) {
|
||||
video.publishedAt = null;
|
||||
}
|
||||
|
||||
await repo.save(video);
|
||||
res.json({ success: true, data: { video } });
|
||||
} catch {
|
||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/videos/:id
|
||||
* Supprime une vidéo.
|
||||
*/
|
||||
router.delete("/videos/:id", async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const repo = AppDataSource.getRepository(Video);
|
||||
const video = await repo.findOne({ where: { id: req.params.id } });
|
||||
|
||||
if (!video) {
|
||||
res.status(404).json({ success: false, error: "NOT_FOUND" });
|
||||
return;
|
||||
}
|
||||
|
||||
await repo.remove(video);
|
||||
res.json({ success: true, data: { deleted: req.params.id } });
|
||||
} catch {
|
||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// USERS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GET /api/admin/users
|
||||
* Liste tous les utilisateurs avec leurs rôles et abonnement actif.
|
||||
*/
|
||||
router.get("/users", async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const users = await AppDataSource.getRepository(User).find({
|
||||
relations: ["userRoles", "userRoles.role", "subscriptions", "subscriptions.plan"],
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
|
||||
const result = users.map((u) => ({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
nickname: u.nickname,
|
||||
isActive: u.isActive,
|
||||
createdAt: u.createdAt,
|
||||
roles: u.userRoles.map((ur) => ({ id: ur.role.id, slug: ur.role.slug, name: ur.role.name })),
|
||||
activeSubscription: u.subscriptions.find((s) => s.status === "active")
|
||||
? {
|
||||
id: u.subscriptions.find((s) => s.status === "active")!.id,
|
||||
status: u.subscriptions.find((s) => s.status === "active")!.status,
|
||||
startsAt: u.subscriptions.find((s) => s.status === "active")!.startsAt,
|
||||
endsAt: u.subscriptions.find((s) => s.status === "active")!.endsAt,
|
||||
plan: u.subscriptions.find((s) => s.status === "active")!.plan,
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
|
||||
res.json({ success: true, data: { users: result } });
|
||||
} catch {
|
||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/users/:id/roles
|
||||
* Assigne des rôles à un utilisateur (remplace les rôles existants).
|
||||
* Body: { roles: string[] } — slugs de rôles
|
||||
*/
|
||||
router.patch("/users/:id/roles", async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { roles } = req.body as { roles: string[] };
|
||||
|
||||
if (!Array.isArray(roles)) {
|
||||
res.status(400).json({ success: false, error: "INVALID_BODY" });
|
||||
return;
|
||||
}
|
||||
|
||||
const userRepo = AppDataSource.getRepository(User);
|
||||
const roleRepo = AppDataSource.getRepository(Role);
|
||||
const userRoleRepo = AppDataSource.getRepository(UserRole);
|
||||
|
||||
const user = await userRepo.findOne({ where: { id: req.params.id } });
|
||||
if (!user) {
|
||||
res.status(404).json({ success: false, error: "NOT_FOUND" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Résoudre les slugs en entités Role
|
||||
const roleEntities = await roleRepo
|
||||
.createQueryBuilder("role")
|
||||
.where("role.slug IN (:...slugs)", { slugs: roles.length > 0 ? roles : ["__none__"] })
|
||||
.getMany();
|
||||
|
||||
if (roleEntities.length !== roles.length) {
|
||||
res.status(400).json({ success: false, error: "INVALID_ROLE_SLUGS" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Supprimer tous les rôles existants pour cet user
|
||||
await userRoleRepo.delete({ userId: req.params.id });
|
||||
|
||||
// Insérer les nouveaux rôles
|
||||
const newUserRoles = roleEntities.map((role) => {
|
||||
const ur = new UserRole();
|
||||
ur.userId = req.params.id;
|
||||
ur.roleId = role.id;
|
||||
return ur;
|
||||
});
|
||||
|
||||
if (newUserRoles.length > 0) {
|
||||
await userRoleRepo.save(newUserRoles);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
userId: req.params.id,
|
||||
roles: roleEntities.map((r) => ({ id: r.id, slug: r.slug, name: r.name })),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SUBSCRIPTION PLANS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GET /api/admin/plans
|
||||
* Liste tous les plans d'abonnement.
|
||||
*/
|
||||
router.get("/plans", async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const plans = await AppDataSource.getRepository(SubscriptionPlan).find({
|
||||
order: { level: "ASC" },
|
||||
});
|
||||
res.json({ success: true, data: { plans } });
|
||||
} catch {
|
||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/plans
|
||||
* Crée un plan d'abonnement.
|
||||
* Body: { slug, name, level, priceInCents, features?, isActive? }
|
||||
*/
|
||||
router.post("/plans", async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
slug,
|
||||
name,
|
||||
level,
|
||||
priceInCents,
|
||||
features = null,
|
||||
isActive = true,
|
||||
} = req.body as {
|
||||
slug: string;
|
||||
name: string;
|
||||
level: number;
|
||||
priceInCents: number;
|
||||
features?: Record<string, unknown> | null;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
if (!slug || !name || level === undefined || priceInCents === undefined) {
|
||||
res.status(400).json({ success: false, error: "MISSING_REQUIRED_FIELDS" });
|
||||
return;
|
||||
}
|
||||
|
||||
const plan = AppDataSource.getRepository(SubscriptionPlan).create({
|
||||
id: crypto.randomUUID(),
|
||||
slug: slug as SubscriptionPlan["slug"],
|
||||
name,
|
||||
level,
|
||||
priceInCents,
|
||||
features,
|
||||
isActive,
|
||||
});
|
||||
|
||||
await AppDataSource.getRepository(SubscriptionPlan).save(plan);
|
||||
res.status(201).json({ success: true, data: { plan } });
|
||||
} catch {
|
||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/plans/:id
|
||||
* Met à jour un plan (isActive, priceInCents, features).
|
||||
*/
|
||||
router.patch("/plans/:id", async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const repo = AppDataSource.getRepository(SubscriptionPlan);
|
||||
const plan = await repo.findOne({ where: { id: req.params.id } });
|
||||
|
||||
if (!plan) {
|
||||
res.status(404).json({ success: false, error: "NOT_FOUND" });
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = ["isActive", "priceInCents", "features"] as const;
|
||||
|
||||
for (const field of allowed) {
|
||||
if (field in req.body) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(plan as any)[field] = (req.body as Record<string, unknown>)[field];
|
||||
}
|
||||
}
|
||||
|
||||
await repo.save(plan);
|
||||
res.json({ success: true, data: { plan } });
|
||||
} catch {
|
||||
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user