Compare commits

...

2 Commits

Author SHA1 Message Date
5afcad487e docs(backend): add .env.example
Some checks failed
CI/CD — Build & Deploy / Build (push) Failing after 44s
CI/CD — Build & Deploy / Deploy to VPS (push) Has been skipped
2026-03-14 08:01:01 +01:00
7c727aa802 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
2026-03-14 07:46:35 +01:00
4 changed files with 400 additions and 0 deletions

16
backend/.env.example Normal file
View File

@@ -0,0 +1,16 @@
# Server
NODE_ENV=development
PORT=4001
# Database (MySQL)
DB_HOST=localhost
DB_PORT=3306
DB_USER=originsdigital
DB_PASSWORD=
DB_NAME=originsdigital
# SuperOAuth — service d'authentification externe
SUPER_OAUTH_URL=https://superoauth.tetardtek.com
# CORS — URL du frontend autorisé
FRONTEND_URL=http://localhost:5173

View File

@@ -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(() => {

View 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" });
}
};

View 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;