feat: observability — Winston logging, pagination admin, N+1 playlists
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 27s
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 27s
This commit is contained in:
@@ -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" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 } });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
25
backend/src/utils/logger.ts
Normal file
25
backend/src/utils/logger.ts
Normal 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;
|
||||||
Reference in New Issue
Block a user