diff --git a/backend/src/middleware/auth.middleware.ts b/backend/src/middleware/auth.middleware.ts index 11e3723..fa9d573 100644 --- a/backend/src/middleware/auth.middleware.ts +++ b/backend/src/middleware/auth.middleware.ts @@ -1,4 +1,5 @@ import { Request, Response, NextFunction } from "express"; +import logger from "../utils/logger"; export interface AuthenticatedUser { id: string; @@ -40,7 +41,7 @@ export const requireAuth = async ( const superOAuthUrl = process.env.SUPER_OAUTH_URL; if (!superOAuthUrl) { - console.error("SUPER_OAUTH_URL not configured"); + logger.error("SUPER_OAUTH_URL not configured"); res.status(500).json({ success: false, error: "INTERNAL_ERROR", message: "Auth service not configured" }); return; } @@ -65,7 +66,8 @@ export const requireAuth = async ( (req as AuthenticatedRequest).user = data.data.user; next(); - } catch { + } catch (err) { + logger.error("requireAuth — auth service unreachable", { err }); res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE", message: "Authentication service unreachable" }); } }; diff --git a/backend/src/routes/admin.routes.ts b/backend/src/routes/admin.routes.ts index e2b2445..f59a0d1 100644 --- a/backend/src/routes/admin.routes.ts +++ b/backend/src/routes/admin.routes.ts @@ -11,6 +11,7 @@ import { UserSubscription } from "../entities/UserSubscription"; import { SubscriptionPlan } from "../entities/SubscriptionPlan"; import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware"; import { requireAdmin } from "../middleware/admin.middleware"; +import logger from "../utils/logger"; const UPLOADS_DIR = process.env.UPLOADS_DIR ?? path.join(process.cwd(), "uploads"); fs.mkdirSync(UPLOADS_DIR, { recursive: true }); @@ -80,14 +81,27 @@ router.post( /** * GET /api/admin/videos * Liste toutes les vidéos (publiées et non publiées), tous les champs. + * Query: ?page=1&limit=20 */ -router.get("/videos", async (_req: Request, res: Response): Promise => { +router.get("/videos", async (req: Request, res: Response): Promise => { + const rawPage = Number(req.query.page ?? 1); + const rawLimit = Number(req.query.limit ?? 20); + + if (!Number.isInteger(rawPage) || rawPage < 1 || + !Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > 100) { + res.status(400).json({ success: false, error: "INVALID_PAGINATION" }); + return; + } + try { - const videos = await AppDataSource.getRepository(Video).find({ + const [videos, total] = await AppDataSource.getRepository(Video).findAndCount({ order: { createdAt: "DESC" }, + skip: (rawPage - 1) * rawLimit, + take: rawLimit, }); - res.json({ success: true, data: { videos } }); - } catch { + res.json({ success: true, data: videos, total, page: rawPage, limit: rawLimit }); + } catch (err) { + logger.error("GET /admin/videos — failed to list videos", { err }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); @@ -139,7 +153,8 @@ router.post("/videos", async (req: Request, res: Response): Promise => { await AppDataSource.getRepository(Video).save(video); res.status(201).json({ success: true, data: { video } }); - } catch { + } catch (err) { + logger.error("POST /admin/videos — failed to create video", { err }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); @@ -179,7 +194,8 @@ router.patch("/videos/:id", async (req: Request, res: Response): Promise = await repo.save(video); res.json({ success: true, data: { video } }); - } catch { + } catch (err) { + logger.error("PATCH /admin/videos/:id — failed to update video", { err, id: req.params.id }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); @@ -200,7 +216,8 @@ router.delete("/videos/:id", async (req: Request, res: Response): Promise await repo.remove(video); res.json({ success: true, data: { deleted: req.params.id } }); - } catch { + } catch (err) { + logger.error("DELETE /admin/videos/:id — failed to delete video", { err, id: req.params.id }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); @@ -212,15 +229,27 @@ router.delete("/videos/:id", async (req: Request, res: Response): Promise /** * GET /api/admin/users * Liste tous les utilisateurs avec leurs rôles et abonnement actif. + * Query: ?page=1&limit=20 */ -router.get("/users", async (_req: Request, res: Response): Promise => { +router.get("/users", async (req: Request, res: Response): Promise => { + const rawPage = Number(req.query.page ?? 1); + const rawLimit = Number(req.query.limit ?? 20); + + if (!Number.isInteger(rawPage) || rawPage < 1 || + !Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > 100) { + res.status(400).json({ success: false, error: "INVALID_PAGINATION" }); + return; + } + try { - const users = await AppDataSource.getRepository(User).find({ + const [users, total] = await AppDataSource.getRepository(User).findAndCount({ relations: ["userRoles", "userRoles.role", "subscriptions", "subscriptions.plan"], order: { createdAt: "DESC" }, + skip: (rawPage - 1) * rawLimit, + take: rawLimit, }); - const result = users.map((u) => ({ + const data = users.map((u) => ({ id: u.id, email: u.email, nickname: u.nickname, @@ -228,13 +257,14 @@ router.get("/users", async (_req: Request, res: Response): Promise => { createdAt: u.createdAt, roles: u.userRoles.map((ur) => ({ id: ur.role.id, slug: ur.role.slug, name: ur.role.name })), activeSubscription: (() => { - const sub = u.subscriptions.find((s) => s.status === "active"); - return sub ? { id: sub.id, status: sub.status, startsAt: sub.startsAt, endsAt: sub.endsAt, plan: sub.plan } : null; - })(), + const sub = u.subscriptions.find((s) => s.status === "active"); + return sub ? { id: sub.id, status: sub.status, startsAt: sub.startsAt, endsAt: sub.endsAt, plan: sub.plan } : null; + })(), })); - res.json({ success: true, data: { users: result } }); - } catch { + res.json({ success: true, data, total, page: rawPage, limit: rawLimit }); + } catch (err) { + logger.error("GET /admin/users — failed to list users", { err }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); @@ -296,7 +326,8 @@ router.patch("/users/:id/roles", async (req: Request, res: Response): Promise ({ id: r.id, slug: r.slug, name: r.name })), }, }); - } catch { + } catch (err) { + logger.error("PATCH /admin/users/:id/roles — failed to assign roles", { err, id: req.params.id }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); @@ -315,7 +346,8 @@ router.get("/plans", async (_req: Request, res: Response): Promise => { order: { level: "ASC" }, }); res.json({ success: true, data: { plans } }); - } catch { + } catch (err) { + logger.error("GET /admin/plans — failed to list plans", { err }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); @@ -360,7 +392,8 @@ router.post("/plans", async (req: Request, res: Response): Promise => { await AppDataSource.getRepository(SubscriptionPlan).save(plan); res.status(201).json({ success: true, data: { plan } }); - } catch { + } catch (err) { + logger.error("POST /admin/plans — failed to create plan", { err }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); @@ -390,7 +423,8 @@ router.patch("/plans/:id", async (req: Request, res: Response): Promise => await repo.save(plan); res.json({ success: true, data: { plan } }); - } catch { + } catch (err) { + logger.error("PATCH /admin/plans/:id — failed to update plan", { err, id: req.params.id }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); @@ -445,7 +479,8 @@ router.post("/users/:id/subscriptions", async (req: Request, res: Response): Pro await subRepo.save(sub); res.status(201).json({ success: true, data: { subscription: { ...sub, plan } } }); - } catch { + } catch (err) { + logger.error("POST /admin/users/:id/subscriptions — failed to assign subscription", { err, id: req.params.id }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index 913f85c..216e202 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -1,5 +1,6 @@ import { Router, Request, Response } from "express"; import { AppDataSource } from "../config/data-source"; +import logger from "../utils/logger"; import { User } from "../entities/User"; import { UserSubscription } from "../entities/UserSubscription"; import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware"; @@ -79,7 +80,8 @@ router.post("/login", async (req: Request, res: Response): Promise => { res.cookie(REFRESH_COOKIE_NAME, data.data.tokens.refreshToken, REFRESH_COOKIE_OPTIONS); } res.json({ success: true, data: { user: data.data.user } }); - } catch { + } catch (err) { + logger.error("POST /auth/login — auth service unavailable", { err }); res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE" }); } }); @@ -125,7 +127,8 @@ router.post("/session", async (req: Request, res: Response): Promise => { res.cookie(COOKIE_NAME, token, COOKIE_OPTIONS); res.json({ success: true, data: { user: data.data.user } }); - } catch { + } catch (err) { + logger.error("POST /auth/session — auth service unavailable", { err }); res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE" }); } }); @@ -173,7 +176,8 @@ router.post("/refresh", async (req: Request, res: Response): Promise => { res.cookie(REFRESH_COOKIE_NAME, data.data.tokens.refreshToken, REFRESH_COOKIE_OPTIONS); } res.json({ success: true, data: { user: data.data.user ?? null } }); - } catch { + } catch (err) { + logger.error("POST /auth/refresh — auth service unavailable", { err }); res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE" }); } }); @@ -275,7 +279,8 @@ router.get("/me/optional", async (req: Request, res: Response): Promise => } res.json({ success: true, data: { user: data.data.user } }); - } catch { + } catch (err) { + logger.error("GET /auth/me/optional — auth service unavailable", { err }); res.json({ success: true, data: { user: null } }); } }); diff --git a/backend/src/routes/playlist.routes.ts b/backend/src/routes/playlist.routes.ts index 71bb493..77a95c1 100644 --- a/backend/src/routes/playlist.routes.ts +++ b/backend/src/routes/playlist.routes.ts @@ -6,6 +6,7 @@ import { PlaylistShare } from "../entities/PlaylistShare"; import { User } from "../entities/User"; import { Video } from "../entities/Video"; import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware"; +import logger from "../utils/logger"; const router = Router(); @@ -42,7 +43,8 @@ router.get("/", requireAuth, async (req: Request, res: Response): Promise shared: shared.map((s) => ({ ...s.playlist, permission: s.permission })), }, }); - } catch { + } catch (err) { + logger.error("GET /playlists — failed to list playlists", { err }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); @@ -78,7 +80,8 @@ router.post("/", requireAuth, async (req: Request, res: Response): Promise await AppDataSource.getRepository(Playlist).save(playlist); res.status(201).json({ success: true, data: { playlist } }); - } catch { + } catch (err) { + logger.error("POST /playlists — failed to create playlist", { err }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); @@ -96,7 +99,7 @@ router.get("/:id", requireAuth, async (req: Request, res: Response): Promise s.userId === dbUserId && s.status === "active"); const isPublic = playlist.visibility === "public"; + const share = (!isOwner && !isPublic) + ? await AppDataSource.getRepository(PlaylistShare).findOne({ + where: { playlistId: playlist.id, userId: dbUserId, status: "active" }, + }) + : null; if (!isOwner && !share && !isPublic) { res.status(403).json({ success: false, error: "FORBIDDEN" }); @@ -125,7 +132,8 @@ router.get("/:id", requireAuth, async (req: Request, res: Response): Promise s.userId === dbUserId && s.status === "active" && s.permission === "edit"); + const canEdit = isOwner || !!(await AppDataSource.getRepository(PlaylistShare).findOne({ + where: { playlistId: playlist.id, userId: dbUserId, status: "active", permission: "edit" }, + })); if (!canEdit) { res.status(403).json({ success: false, error: "FORBIDDEN" }); return; } @@ -226,7 +235,8 @@ router.post("/:id/videos", requireAuth, async (req: Request, res: Response): Pro await AppDataSource.getRepository(PlaylistVideo).save(pv); res.status(201).json({ success: true, data: { playlistVideo: pv } }); - } catch { + } catch (err) { + logger.error("POST /playlists/:id/videos — failed to add video", { err, id: req.params.id }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); @@ -241,15 +251,14 @@ router.delete("/:id/videos/:videoId", requireAuth, async (req: Request, res: Res if (!dbUserId) { res.status(401).json({ success: false, error: "USER_NOT_FOUND" }); return; } try { - const playlist = await AppDataSource.getRepository(Playlist).findOne({ - where: { id: req.params.id }, - relations: ["shares"], - }); + const playlist = await AppDataSource.getRepository(Playlist).findOneBy({ id: req.params.id }); if (!playlist) { res.status(404).json({ success: false, error: "NOT_FOUND" }); return; } const isOwner = playlist.ownerId === dbUserId; - const canEdit = isOwner || playlist.shares.some((s) => s.userId === dbUserId && s.status === "active" && s.permission === "edit"); + const canEdit = isOwner || !!(await AppDataSource.getRepository(PlaylistShare).findOne({ + where: { playlistId: playlist.id, userId: dbUserId, status: "active", permission: "edit" }, + })); if (!canEdit) { res.status(403).json({ success: false, error: "FORBIDDEN" }); return; } @@ -262,7 +271,8 @@ router.delete("/:id/videos/:videoId", requireAuth, async (req: Request, res: Res await AppDataSource.getRepository(PlaylistVideo).remove(pv); res.json({ success: true, data: { deleted: req.params.videoId } }); - } catch { + } catch (err) { + logger.error("DELETE /playlists/:id/videos/:videoId — failed to remove video", { err, id: req.params.id, videoId: req.params.videoId }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); @@ -305,7 +315,8 @@ router.post("/:id/share", requireAuth, async (req: Request, res: Response): Prom await AppDataSource.getRepository(PlaylistShare).save(share); res.status(201).json({ success: true, data: { share } }); - } catch { + } catch (err) { + logger.error("POST /playlists/:id/share — failed to create share", { err, id: req.params.id }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); @@ -343,7 +354,8 @@ router.patch("/:id/share/:shareId", requireAuth, async (req: Request, res: Respo await AppDataSource.getRepository(PlaylistShare).save(share); res.json({ success: true, data: { share } }); - } catch { + } catch (err) { + logger.error("PATCH /playlists/:id/share/:shareId — failed to update share", { err, id: req.params.id, shareId: req.params.shareId }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); diff --git a/backend/src/routes/video.routes.ts b/backend/src/routes/video.routes.ts index dea1d67..5dad459 100644 --- a/backend/src/routes/video.routes.ts +++ b/backend/src/routes/video.routes.ts @@ -1,5 +1,6 @@ import { Router, Request, Response } from "express"; import { AppDataSource } from "../config/data-source"; +import logger from "../utils/logger"; import { Video } from "../entities/Video"; import { User } from "../entities/User"; import { requireAuth, AuthenticatedRequest, AuthenticatedUser } from "../middleware/auth.middleware"; @@ -51,7 +52,8 @@ router.get("/", async (req: Request, res: Response): Promise => { })); res.json({ success: true, data: { videos: result } }); - } catch { + } catch (err) { + logger.error("GET /videos — failed to list videos", { err }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); @@ -84,7 +86,8 @@ router.get("/:id", async (req: Request, res: Response): Promise => { } res.json({ success: true, data: { video } }); - } catch { + } catch (err) { + logger.error("GET /videos/:id — failed to fetch video", { err, id: req.params.id }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); } }); diff --git a/backend/src/utils/logger.ts b/backend/src/utils/logger.ts new file mode 100644 index 0000000..26b8207 --- /dev/null +++ b/backend/src/utils/logger.ts @@ -0,0 +1,25 @@ +import winston from "winston"; + +const { combine, timestamp, json, colorize, printf } = winston.format; + +const devFormat = combine( + colorize(), + timestamp({ format: "HH:mm:ss" }), + printf(({ level, message, timestamp: ts, ...meta }) => { + const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : ""; + return `${ts} [${level}] ${message}${metaStr}`; + }) +); + +const prodFormat = combine( + timestamp(), + json() +); + +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL ?? "info", + format: process.env.NODE_ENV === "production" ? prodFormat : devFormat, + transports: [new winston.transports.Console()], +}); + +export default logger;