diff --git a/backend/src/index.ts b/backend/src/index.ts index 8d235dd..4c7fe4e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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(() => { diff --git a/backend/src/middleware/admin.middleware.ts b/backend/src/middleware/admin.middleware.ts new file mode 100644 index 0000000..58f0236 --- /dev/null +++ b/backend/src/middleware/admin.middleware.ts @@ -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 => { + 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" }); + } +}; diff --git a/backend/src/routes/admin.routes.ts b/backend/src/routes/admin.routes.ts new file mode 100644 index 0000000..c20b501 --- /dev/null +++ b/backend/src/routes/admin.routes.ts @@ -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 => { + 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 => { + 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 => { + 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)[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 => { + 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 => { + 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 => { + 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 => { + 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 => { + try { + const { + slug, + name, + level, + priceInCents, + features = null, + isActive = true, + } = req.body as { + slug: string; + name: string; + level: number; + priceInCents: number; + features?: Record | 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 => { + 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)[field]; + } + } + + await repo.save(plan); + res.json({ success: true, data: { plan } }); + } catch { + res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); + } +}); + +export default router;