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" }); } }); // --------------------------------------------------------------------------- // USER SUBSCRIPTIONS // --------------------------------------------------------------------------- /** * POST /api/admin/users/:id/subscriptions * Assigne un plan à un utilisateur. * Expire l'abonnement actif précédent si existant. * Body: { planId, endsAt? } — endsAt ISO string, null = permanent */ router.post("/users/:id/subscriptions", async (req: Request, res: Response): Promise => { const { planId, endsAt = null } = req.body as { planId?: string; endsAt?: string | null }; if (!planId) { res.status(400).json({ success: false, error: "MISSING_PLAN_ID" }); return; } try { const userRepo = AppDataSource.getRepository(User); const planRepo = AppDataSource.getRepository(SubscriptionPlan); const subRepo = AppDataSource.getRepository(UserSubscription); const [user, plan] = await Promise.all([ userRepo.findOne({ where: { id: req.params.id } }), planRepo.findOne({ where: { id: planId } }), ]); if (!user) { res.status(404).json({ success: false, error: "USER_NOT_FOUND" }); return; } if (!plan) { res.status(404).json({ success: false, error: "PLAN_NOT_FOUND" }); return; } // Expirer l'abonnement actif précédent await subRepo .createQueryBuilder() .update(UserSubscription) .set({ status: "expired" }) .where("userId = :userId AND status = :status", { userId: user.id, status: "active" }) .execute(); const sub = subRepo.create({ id: crypto.randomUUID(), userId: user.id, planId: plan.id, status: "active", startsAt: new Date(), endsAt: endsAt ? new Date(endsAt) : null, }); await subRepo.save(sub); res.status(201).json({ success: true, data: { subscription: { ...sub, plan } } }); } catch { res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); export default router;