feat: observability — Winston logging, pagination admin, N+1 playlists
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 27s

This commit is contained in:
2026-03-14 23:21:42 +01:00
parent 31edea9dd9
commit 494206b5b3
6 changed files with 131 additions and 49 deletions

View File

@@ -1,4 +1,5 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import logger from "../utils/logger";
export interface AuthenticatedUser { export interface AuthenticatedUser {
id: string; id: string;
@@ -40,7 +41,7 @@ export const requireAuth = async (
const superOAuthUrl = process.env.SUPER_OAUTH_URL; const superOAuthUrl = process.env.SUPER_OAUTH_URL;
if (!superOAuthUrl) { 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" }); res.status(500).json({ success: false, error: "INTERNAL_ERROR", message: "Auth service not configured" });
return; return;
} }
@@ -65,7 +66,8 @@ export const requireAuth = async (
(req as AuthenticatedRequest).user = data.data.user; (req as AuthenticatedRequest).user = data.data.user;
next(); 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" }); res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE", message: "Authentication service unreachable" });
} }
}; };

View File

@@ -11,6 +11,7 @@ import { UserSubscription } from "../entities/UserSubscription";
import { SubscriptionPlan } from "../entities/SubscriptionPlan"; import { SubscriptionPlan } from "../entities/SubscriptionPlan";
import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware"; import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware";
import { requireAdmin } from "../middleware/admin.middleware"; import { requireAdmin } from "../middleware/admin.middleware";
import logger from "../utils/logger";
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? path.join(process.cwd(), "uploads"); const UPLOADS_DIR = process.env.UPLOADS_DIR ?? path.join(process.cwd(), "uploads");
fs.mkdirSync(UPLOADS_DIR, { recursive: true }); fs.mkdirSync(UPLOADS_DIR, { recursive: true });
@@ -80,14 +81,27 @@ router.post(
/** /**
* GET /api/admin/videos * GET /api/admin/videos
* Liste toutes les vidéos (publiées et non publiées), tous les champs. * 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<void> => { router.get("/videos", async (req: Request, res: Response): Promise<void> => {
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 { try {
const videos = await AppDataSource.getRepository(Video).find({ const [videos, total] = await AppDataSource.getRepository(Video).findAndCount({
order: { createdAt: "DESC" }, order: { createdAt: "DESC" },
skip: (rawPage - 1) * rawLimit,
take: rawLimit,
}); });
res.json({ success: true, data: { videos } }); res.json({ success: true, data: videos, total, page: rawPage, limit: rawLimit });
} catch { } catch (err) {
logger.error("GET /admin/videos — failed to list videos", { err });
res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
} }
}); });
@@ -139,7 +153,8 @@ router.post("/videos", async (req: Request, res: Response): Promise<void> => {
await AppDataSource.getRepository(Video).save(video); await AppDataSource.getRepository(Video).save(video);
res.status(201).json({ success: true, data: { 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" }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
} }
}); });
@@ -179,7 +194,8 @@ router.patch("/videos/:id", async (req: Request, res: Response): Promise<void> =
await repo.save(video); await repo.save(video);
res.json({ success: true, data: { 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" }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
} }
}); });
@@ -200,7 +216,8 @@ router.delete("/videos/:id", async (req: Request, res: Response): Promise<void>
await repo.remove(video); await repo.remove(video);
res.json({ success: true, data: { deleted: req.params.id } }); 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" }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
} }
}); });
@@ -212,15 +229,27 @@ router.delete("/videos/:id", async (req: Request, res: Response): Promise<void>
/** /**
* GET /api/admin/users * GET /api/admin/users
* Liste tous les utilisateurs avec leurs rôles et abonnement actif. * 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<void> => { router.get("/users", async (req: Request, res: Response): Promise<void> => {
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 { try {
const users = await AppDataSource.getRepository(User).find({ const [users, total] = await AppDataSource.getRepository(User).findAndCount({
relations: ["userRoles", "userRoles.role", "subscriptions", "subscriptions.plan"], relations: ["userRoles", "userRoles.role", "subscriptions", "subscriptions.plan"],
order: { createdAt: "DESC" }, order: { createdAt: "DESC" },
skip: (rawPage - 1) * rawLimit,
take: rawLimit,
}); });
const result = users.map((u) => ({ const data = users.map((u) => ({
id: u.id, id: u.id,
email: u.email, email: u.email,
nickname: u.nickname, nickname: u.nickname,
@@ -233,8 +262,9 @@ router.get("/users", async (_req: Request, res: Response): Promise<void> => {
})(), })(),
})); }));
res.json({ success: true, data: { users: result } }); res.json({ success: true, data, total, page: rawPage, limit: rawLimit });
} catch { } catch (err) {
logger.error("GET /admin/users — failed to list users", { err });
res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
} }
}); });
@@ -296,7 +326,8 @@ router.patch("/users/:id/roles", async (req: Request, res: Response): Promise<vo
roles: roleEntities.map((r) => ({ id: r.id, slug: r.slug, name: r.name })), roles: roleEntities.map((r) => ({ 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" }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
} }
}); });
@@ -315,7 +346,8 @@ router.get("/plans", async (_req: Request, res: Response): Promise<void> => {
order: { level: "ASC" }, order: { level: "ASC" },
}); });
res.json({ success: true, data: { plans } }); 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" }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
} }
}); });
@@ -360,7 +392,8 @@ router.post("/plans", async (req: Request, res: Response): Promise<void> => {
await AppDataSource.getRepository(SubscriptionPlan).save(plan); await AppDataSource.getRepository(SubscriptionPlan).save(plan);
res.status(201).json({ success: true, data: { 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" }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
} }
}); });
@@ -390,7 +423,8 @@ router.patch("/plans/:id", async (req: Request, res: Response): Promise<void> =>
await repo.save(plan); await repo.save(plan);
res.json({ success: true, data: { 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" }); 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); await subRepo.save(sub);
res.status(201).json({ success: true, data: { subscription: { ...sub, plan } } }); 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" }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
} }
}); });

View File

@@ -1,5 +1,6 @@
import { Router, Request, Response } from "express"; import { Router, Request, Response } from "express";
import { AppDataSource } from "../config/data-source"; import { AppDataSource } from "../config/data-source";
import logger from "../utils/logger";
import { User } from "../entities/User"; import { User } from "../entities/User";
import { UserSubscription } from "../entities/UserSubscription"; import { UserSubscription } from "../entities/UserSubscription";
import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware"; import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware";
@@ -79,7 +80,8 @@ router.post("/login", async (req: Request, res: Response): Promise<void> => {
res.cookie(REFRESH_COOKIE_NAME, data.data.tokens.refreshToken, REFRESH_COOKIE_OPTIONS); res.cookie(REFRESH_COOKIE_NAME, data.data.tokens.refreshToken, REFRESH_COOKIE_OPTIONS);
} }
res.json({ success: true, data: { user: data.data.user } }); 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" }); res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE" });
} }
}); });
@@ -125,7 +127,8 @@ router.post("/session", async (req: Request, res: Response): Promise<void> => {
res.cookie(COOKIE_NAME, token, COOKIE_OPTIONS); res.cookie(COOKIE_NAME, token, COOKIE_OPTIONS);
res.json({ success: true, data: { user: data.data.user } }); 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" }); res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE" });
} }
}); });
@@ -173,7 +176,8 @@ router.post("/refresh", async (req: Request, res: Response): Promise<void> => {
res.cookie(REFRESH_COOKIE_NAME, data.data.tokens.refreshToken, REFRESH_COOKIE_OPTIONS); res.cookie(REFRESH_COOKIE_NAME, data.data.tokens.refreshToken, REFRESH_COOKIE_OPTIONS);
} }
res.json({ success: true, data: { user: data.data.user ?? null } }); 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" }); res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE" });
} }
}); });
@@ -275,7 +279,8 @@ router.get("/me/optional", async (req: Request, res: Response): Promise<void> =>
} }
res.json({ success: true, data: { user: data.data.user } }); 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 } }); res.json({ success: true, data: { user: null } });
} }
}); });

View File

@@ -6,6 +6,7 @@ import { PlaylistShare } from "../entities/PlaylistShare";
import { User } from "../entities/User"; import { User } from "../entities/User";
import { Video } from "../entities/Video"; import { Video } from "../entities/Video";
import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware"; import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware";
import logger from "../utils/logger";
const router = Router(); const router = Router();
@@ -42,7 +43,8 @@ router.get("/", requireAuth, async (req: Request, res: Response): Promise<void>
shared: shared.map((s) => ({ ...s.playlist, permission: s.permission })), 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" }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
} }
}); });
@@ -78,7 +80,8 @@ router.post("/", requireAuth, async (req: Request, res: Response): Promise<void>
await AppDataSource.getRepository(Playlist).save(playlist); await AppDataSource.getRepository(Playlist).save(playlist);
res.status(201).json({ success: true, data: { 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" }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
} }
}); });
@@ -96,7 +99,7 @@ router.get("/:id", requireAuth, async (req: Request, res: Response): Promise<voi
try { try {
const playlist = await AppDataSource.getRepository(Playlist).findOne({ const playlist = await AppDataSource.getRepository(Playlist).findOne({
where: { id: req.params.id }, where: { id: req.params.id },
relations: ["playlistVideos", "playlistVideos.video", "shares"], relations: ["playlistVideos", "playlistVideos.video"],
}); });
if (!playlist) { if (!playlist) {
@@ -105,8 +108,12 @@ router.get("/:id", requireAuth, async (req: Request, res: Response): Promise<voi
} }
const isOwner = playlist.ownerId === dbUserId; const isOwner = playlist.ownerId === dbUserId;
const share = playlist.shares.find((s) => s.userId === dbUserId && s.status === "active");
const isPublic = playlist.visibility === "public"; 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) { if (!isOwner && !share && !isPublic) {
res.status(403).json({ success: false, error: "FORBIDDEN" }); res.status(403).json({ success: false, error: "FORBIDDEN" });
@@ -125,7 +132,8 @@ router.get("/:id", requireAuth, async (req: Request, res: Response): Promise<voi
permission: isOwner ? "owner" : share?.permission ?? "view", permission: isOwner ? "owner" : share?.permission ?? "view",
}, },
}); });
} catch { } catch (err) {
logger.error("GET /playlists/:id — failed to fetch playlist", { err, id: req.params.id });
res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
} }
}); });
@@ -158,7 +166,8 @@ router.patch("/:id", requireAuth, async (req: Request, res: Response): Promise<v
await repo.save(playlist); await repo.save(playlist);
res.json({ success: true, data: { playlist } }); res.json({ success: true, data: { playlist } });
} catch { } catch (err) {
logger.error("PATCH /playlists/:id — failed to update playlist", { err, id: req.params.id });
res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
} }
}); });
@@ -181,7 +190,8 @@ router.delete("/:id", requireAuth, async (req: Request, res: Response): Promise<
await repo.remove(playlist); await repo.remove(playlist);
res.json({ success: true, data: { deleted: req.params.id } }); res.json({ success: true, data: { deleted: req.params.id } });
} catch { } catch (err) {
logger.error("DELETE /playlists/:id — failed to delete playlist", { err, id: req.params.id });
res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
} }
}); });
@@ -200,15 +210,14 @@ router.post("/:id/videos", requireAuth, async (req: Request, res: Response): Pro
if (!videoId) { res.status(400).json({ success: false, error: "MISSING_VIDEO_ID" }); return; } if (!videoId) { res.status(400).json({ success: false, error: "MISSING_VIDEO_ID" }); return; }
try { try {
const playlist = await AppDataSource.getRepository(Playlist).findOne({ const playlist = await AppDataSource.getRepository(Playlist).findOneBy({ id: req.params.id });
where: { id: req.params.id },
relations: ["shares"],
});
if (!playlist) { res.status(404).json({ success: false, error: "NOT_FOUND" }); return; } if (!playlist) { res.status(404).json({ success: false, error: "NOT_FOUND" }); return; }
const isOwner = playlist.ownerId === dbUserId; 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; } 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); await AppDataSource.getRepository(PlaylistVideo).save(pv);
res.status(201).json({ success: true, data: { playlistVideo: 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" }); 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; } if (!dbUserId) { res.status(401).json({ success: false, error: "USER_NOT_FOUND" }); return; }
try { try {
const playlist = await AppDataSource.getRepository(Playlist).findOne({ const playlist = await AppDataSource.getRepository(Playlist).findOneBy({ id: req.params.id });
where: { id: req.params.id },
relations: ["shares"],
});
if (!playlist) { res.status(404).json({ success: false, error: "NOT_FOUND" }); return; } if (!playlist) { res.status(404).json({ success: false, error: "NOT_FOUND" }); return; }
const isOwner = playlist.ownerId === dbUserId; 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; } 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); await AppDataSource.getRepository(PlaylistVideo).remove(pv);
res.json({ success: true, data: { deleted: req.params.videoId } }); 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" }); 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); await AppDataSource.getRepository(PlaylistShare).save(share);
res.status(201).json({ success: true, data: { 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" }); 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); await AppDataSource.getRepository(PlaylistShare).save(share);
res.json({ success: true, data: { 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" }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
} }
}); });

View File

@@ -1,5 +1,6 @@
import { Router, Request, Response } from "express"; import { Router, Request, Response } from "express";
import { AppDataSource } from "../config/data-source"; import { AppDataSource } from "../config/data-source";
import logger from "../utils/logger";
import { Video } from "../entities/Video"; import { Video } from "../entities/Video";
import { User } from "../entities/User"; import { User } from "../entities/User";
import { requireAuth, AuthenticatedRequest, AuthenticatedUser } from "../middleware/auth.middleware"; import { requireAuth, AuthenticatedRequest, AuthenticatedUser } from "../middleware/auth.middleware";
@@ -51,7 +52,8 @@ router.get("/", async (req: Request, res: Response): Promise<void> => {
})); }));
res.json({ success: true, data: { videos: result } }); 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" }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
} }
}); });
@@ -84,7 +86,8 @@ router.get("/:id", async (req: Request, res: Response): Promise<void> => {
} }
res.json({ success: true, data: { video } }); 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" }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
} }
}); });

View File

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