diff --git a/backend/src/entities/User.ts b/backend/src/entities/User.ts index 56c1035..d162e9a 100644 --- a/backend/src/entities/User.ts +++ b/backend/src/entities/User.ts @@ -22,6 +22,9 @@ export class User { @Column({ type: "varchar", length: 100 }) nickname!: string; + @Column({ type: "varchar", length: 500, nullable: true }) + avatar!: string | null; + @Column({ type: "boolean", default: true }) isActive!: boolean; diff --git a/backend/src/index.ts b/backend/src/index.ts index d5728bb..97d895d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -9,6 +9,7 @@ import videoRoutes from "./routes/video.routes"; import playlistRoutes from "./routes/playlist.routes"; import adminRoutes from "./routes/admin.routes"; import streamRoutes from "./routes/stream.routes"; +import userRoutes from "./routes/user.routes"; dotenv.config(); @@ -39,6 +40,7 @@ app.use("/api/videos", videoRoutes); app.use("/api/playlists", playlistRoutes); app.use("/api/admin", adminRoutes); app.use("/api/stream", streamRoutes); +app.use("/api/users", userRoutes); AppDataSource.initialize() .then(() => { diff --git a/backend/src/migrations/1742000000000-AddUserAvatar.ts b/backend/src/migrations/1742000000000-AddUserAvatar.ts new file mode 100644 index 0000000..654d802 --- /dev/null +++ b/backend/src/migrations/1742000000000-AddUserAvatar.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddUserAvatar1742000000000 implements MigrationInterface { + name = "AddUserAvatar1742000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE users + ADD COLUMN avatar VARCHAR(500) NULL AFTER nickname + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE users DROP COLUMN avatar`); + } +} diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index ede39d7..913f85c 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -1,6 +1,7 @@ import { Router, Request, Response } from "express"; import { AppDataSource } from "../config/data-source"; import { User } from "../entities/User"; +import { UserSubscription } from "../entities/UserSubscription"; import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware"; const router = Router(); @@ -189,17 +190,51 @@ router.post("/logout", (_req: Request, res: Response): void => { /** * GET /api/auth/me - * Retourne l'utilisateur courant (cookie ou Bearer) + ses rôles locaux. + * Retourne l'utilisateur courant + rôles locaux + plan actif + avatar. */ router.get("/me", requireAuth, async (req: Request, res: Response): Promise => { const { user } = req as AuthenticatedRequest; - const localUser = await AppDataSource.getRepository(User) - .findOne({ where: { superOAuthId: user.id }, relations: ["userRoles", "userRoles.role"] }); + const localUser = await AppDataSource.getRepository(User).findOne({ + where: { superOAuthId: user.id }, + relations: ["userRoles", "userRoles.role"], + }); const roles = localUser?.userRoles.map((ur) => ur.role.slug) ?? []; - res.json({ success: true, data: { user: { ...user, roles } } }); + let plan: { slug: string; name: string; level: number } | null = null; + let subscriptionDate: string | null = null; + + if (localUser) { + const now = new Date(); + const activeSub = await AppDataSource.getRepository(UserSubscription) + .createQueryBuilder("sub") + .leftJoinAndSelect("sub.plan", "plan") + .where("sub.userId = :userId", { userId: localUser.id }) + .andWhere("sub.status IN (:...statuses)", { statuses: ["active", "trial"] }) + .andWhere("(sub.endsAt IS NULL OR sub.endsAt > :now)", { now }) + .orderBy("plan.level", "DESC") + .addOrderBy("sub.startsAt", "DESC") + .getOne(); + + if (activeSub) { + plan = { slug: activeSub.plan.slug, name: activeSub.plan.name, level: activeSub.plan.level }; + subscriptionDate = activeSub.startsAt.toISOString(); + } + } + + res.json({ + success: true, + data: { + user: { + ...user, + avatar: localUser?.avatar ?? null, + roles, + plan, + subscriptionDate, + }, + }, + }); }); /** diff --git a/backend/src/routes/user.routes.ts b/backend/src/routes/user.routes.ts new file mode 100644 index 0000000..7e527c2 --- /dev/null +++ b/backend/src/routes/user.routes.ts @@ -0,0 +1,127 @@ +import { Router, Request, Response } from "express"; +import { AppDataSource } from "../config/data-source"; +import { User } from "../entities/User"; +import { UserSubscription } from "../entities/UserSubscription"; +import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware"; + +const router = Router(); + +/** Retourne la souscription active la plus élevée pour un userId local, ou null. */ +async function getActiveSub(userId: string) { + const now = new Date(); + return AppDataSource.getRepository(UserSubscription) + .createQueryBuilder("sub") + .leftJoinAndSelect("sub.plan", "plan") + .where("sub.userId = :userId", { userId }) + .andWhere("sub.status IN (:...statuses)", { statuses: ["active", "trial"] }) + .andWhere("(sub.endsAt IS NULL OR sub.endsAt > :now)", { now }) + .orderBy("plan.level", "DESC") + .addOrderBy("sub.startsAt", "DESC") + .getOne(); +} + +/** + * GET /api/users/me/profile + * Profil complet de l'utilisateur connecté (données locales). + */ +router.get("/me/profile", requireAuth, async (req: Request, res: Response): Promise => { + const { user } = req as AuthenticatedRequest; + + const localUser = await AppDataSource.getRepository(User).findOne({ + where: { superOAuthId: user.id }, + relations: ["userRoles", "userRoles.role"], + }); + + if (!localUser) { + res.status(404).json({ success: false, error: "USER_NOT_FOUND" }); + return; + } + + const roles = localUser.userRoles.map((ur) => ur.role.slug); + const activeSub = await getActiveSub(localUser.id); + + const plan = activeSub + ? { slug: activeSub.plan.slug, name: activeSub.plan.name, level: activeSub.plan.level } + : null; + + const subscription = activeSub + ? { + status: activeSub.status, + startsAt: activeSub.startsAt.toISOString(), + endsAt: activeSub.endsAt ? activeSub.endsAt.toISOString() : null, + } + : null; + + res.json({ + success: true, + data: { + id: localUser.id, + superOAuthId: localUser.superOAuthId, + email: localUser.email, + nickname: localUser.nickname, + avatar: localUser.avatar, + roles, + plan, + subscription, + createdAt: localUser.createdAt.toISOString(), + }, + }); +}); + +/** + * PATCH /api/users/me + * Met à jour nickname et/ou avatar de l'utilisateur connecté. + * Note : le nickname local peut être écrasé au prochain login SuperOAuth. + */ +router.patch("/me", requireAuth, async (req: Request, res: Response): Promise => { + const { user } = req as AuthenticatedRequest; + const { nickname, avatar } = req.body as { nickname?: unknown; avatar?: unknown }; + + // Validation + if (nickname !== undefined) { + if (typeof nickname !== "string" || nickname.trim().length < 2 || nickname.trim().length > 100) { + res.status(400).json({ success: false, error: "INVALID_NICKNAME", message: "nickname must be 2-100 characters" }); + return; + } + } + + if (avatar !== undefined && avatar !== null) { + if (typeof avatar !== "string") { + res.status(400).json({ success: false, error: "INVALID_AVATAR" }); + return; + } + try { + const parsed = new URL(avatar); + if (!["http:", "https:"].includes(parsed.protocol)) { + throw new Error("invalid protocol"); + } + } catch { + res.status(400).json({ success: false, error: "INVALID_AVATAR", message: "avatar must be a valid http/https URL or null" }); + return; + } + } + + const userRepo = AppDataSource.getRepository(User); + const localUser = await userRepo.findOne({ where: { superOAuthId: user.id } }); + + if (!localUser) { + res.status(404).json({ success: false, error: "USER_NOT_FOUND" }); + return; + } + + if (nickname !== undefined) localUser.nickname = (nickname as string).trim(); + if (avatar !== undefined) localUser.avatar = avatar as string | null; + + await userRepo.save(localUser); + + res.json({ + success: true, + data: { + id: localUser.id, + nickname: localUser.nickname, + avatar: localUser.avatar, + }, + }); +}); + +export default router;