import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { COOKIE_NAME } from "@shared/const"; import { getSessionCookieOptions } from "./_core/cookies"; import { systemRouter } from "./_core/systemRouter"; import { protectedProcedure, publicProcedure, router } from "./_core/trpc"; import { createEtablissement, createLocalUser, createMotCle, createPodcast, deleteEtablissement, deleteMotCle, deletePodcast, deleteUser, getAllEtablissements, getAllMotsCles, getAllUsers, getEtablissementById, getPodcastById, getPodcasts, getPodcastStats, getUserById, getUserByUsername, updateEtablissement, updatePodcast, updateUser, updateUserRole, } from "./db"; import { storagePut } from "./storage"; import { nanoid } from "nanoid"; import bcrypt from "bcryptjs"; import { sdk } from "./_core/sdk"; // ─── Admin guard ─────────────────────────────────────────────────────────────── const adminProcedure = protectedProcedure.use(({ ctx, next }) => { if (ctx.user.role !== "admin") { throw new TRPCError({ code: "FORBIDDEN", message: "Accès réservé aux administrateurs" }); } return next({ ctx }); }); // ─── Établissements ──────────────────────────────────────────────────────────── const etablissementsRouter = router({ list: publicProcedure.query(() => getAllEtablissements()), getById: publicProcedure .input(z.object({ id: z.number() })) .query(({ input }) => getEtablissementById(input.id)), create: adminProcedure .input( z.object({ nom: z.string().min(1).max(255), description: z.string().optional(), logoUrl: z.string().optional(), }) ) .mutation(({ input }) => createEtablissement(input)), update: adminProcedure .input( z.object({ id: z.number(), nom: z.string().min(1).max(255).optional(), description: z.string().optional(), logoUrl: z.string().optional(), actif: z.boolean().optional(), }) ) .mutation(({ input }) => { const { id, ...data } = input; return updateEtablissement(id, data); }), delete: adminProcedure .input(z.object({ id: z.number() })) .mutation(({ input }) => deleteEtablissement(input.id)), }); // ─── Mots-clés ───────────────────────────────────────────────────────────────── const motsClesRouter = router({ list: publicProcedure.query(() => getAllMotsCles()), create: adminProcedure .input(z.object({ label: z.string().min(1).max(100) })) .mutation(({ input }) => createMotCle(input)), delete: adminProcedure .input(z.object({ id: z.number() })) .mutation(({ input }) => deleteMotCle(input.id)), }); // ─── Podcasts ────────────────────────────────────────────────────────────────── const podcastsRouter = router({ list: publicProcedure .input( z.object({ etablissementId: z.number().optional(), motCleIds: z.array(z.number()).optional(), search: z.string().optional(), statut: z.enum(["brouillon", "publie"]).optional(), }) ) .query(({ input }) => getPodcasts(input)), listMine: protectedProcedure .query(({ ctx }) => getPodcasts({ auteurId: ctx.user.id })), getById: publicProcedure .input(z.object({ id: z.number() })) .query(async ({ input }) => { const podcast = await getPodcastById(input.id); if (!podcast) throw new TRPCError({ code: "NOT_FOUND" }); return podcast; }), create: protectedProcedure .input( z.object({ titre: z.string().min(1).max(255), resume: z.string().min(1), etablissementId: z.number(), audioUrl: z.string().optional(), audioKey: z.string().optional(), dureeSecondes: z.number().optional(), statut: z.enum(["brouillon", "publie"]).default("brouillon"), motCleIds: z.array(z.number()).default([]), imageUrl: z.string().optional(), }) ) .mutation(({ input, ctx }) => { const { motCleIds, ...data } = input; return createPodcast({ ...data, auteurId: ctx.user.id }, motCleIds); }), update: protectedProcedure .input( z.object({ id: z.number(), titre: z.string().min(1).max(255).optional(), resume: z.string().min(1).optional(), etablissementId: z.number().optional(), audioUrl: z.string().optional(), audioKey: z.string().optional(), dureeSecondes: z.number().optional(), statut: z.enum(["brouillon", "publie"]).optional(), motCleIds: z.array(z.number()).optional(), imageUrl: z.string().optional(), }) ) .mutation(async ({ input, ctx }) => { const podcast = await getPodcastById(input.id); if (!podcast) throw new TRPCError({ code: "NOT_FOUND" }); // Un utilisateur standard ne peut modifier que ses propres podcasts if (ctx.user.role !== "admin" && podcast.auteurId !== ctx.user.id) { throw new TRPCError({ code: "FORBIDDEN" }); } const { id, motCleIds, ...data } = input; return updatePodcast(id, data, motCleIds); }), delete: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input, ctx }) => { const podcast = await getPodcastById(input.id); if (!podcast) throw new TRPCError({ code: "NOT_FOUND" }); if (ctx.user.role !== "admin" && podcast.auteurId !== ctx.user.id) { throw new TRPCError({ code: "FORBIDDEN" }); } return deletePodcast(input.id); }), stats: protectedProcedure.query(() => getPodcastStats()), }); // ─── Upload audio ────────────────────────────────────────────────────────────── const uploadRouter = router({ getAudioUploadUrl: protectedProcedure .input( z.object({ filename: z.string(), contentType: z.string(), sizeBytes: z.number().max(50 * 1024 * 1024, "Fichier trop volumineux (max 50 Mo)"), }) ) .mutation(async ({ input }) => { const ext = input.filename.split(".").pop() ?? "mp3"; const key = `podcasts/audio/${nanoid()}.${ext}`; return { key, contentType: input.contentType }; }), }); // ─── Users (admin) ───────────────────────────────────────────────────────────── const usersRouter = router({ list: adminProcedure.query(() => getAllUsers()), getById: adminProcedure .input(z.object({ id: z.number() })) .query(({ input }) => getUserById(input.id)), create: adminProcedure .input( z.object({ username: z.string().min(2).max(64), password: z.string().min(6), name: z.string().min(1).max(255), role: z.enum(["user", "admin"]), }) ) .mutation(async ({ input }) => { // Vérifier que le username n'existe pas déjà const existing = await getUserByUsername(input.username); if (existing) { throw new TRPCError({ code: "CONFLICT", message: "Cet identifiant est déjà utilisé" }); } const passwordHash = await bcrypt.hash(input.password, 10); await createLocalUser({ openId: `local-${input.username}-${Date.now()}`, username: input.username, passwordHash, name: input.name, role: input.role, immutable: false, }); return { success: true }; }), update: adminProcedure .input( z.object({ id: z.number(), name: z.string().min(1).max(255).optional(), username: z.string().min(2).max(64).optional(), password: z.string().min(6).optional(), role: z.enum(["user", "admin"]).optional(), }) ) .mutation(async ({ input }) => { const { id, password, ...rest } = input; const updateData: Parameters[1] = { ...rest }; if (password) { updateData.passwordHash = await bcrypt.hash(password, 10); } // Vérifier unicité du username si changé if (rest.username) { const existing = await getUserByUsername(rest.username); if (existing && existing.id !== id) { throw new TRPCError({ code: "CONFLICT", message: "Cet identifiant est déjà utilisé" }); } } try { await updateUser(id, updateData); return { success: true }; } catch (e: any) { throw new TRPCError({ code: "BAD_REQUEST", message: e.message }); } }), delete: adminProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input, ctx }) => { // Empêcher l'admin de se supprimer lui-même if (ctx.user.id === input.id) { throw new TRPCError({ code: "BAD_REQUEST", message: "Vous ne pouvez pas supprimer votre propre compte" }); } try { await deleteUser(input.id); return { success: true }; } catch (e: any) { throw new TRPCError({ code: "BAD_REQUEST", message: e.message }); } }), updateRole: adminProcedure .input(z.object({ userId: z.number(), role: z.enum(["user", "admin"]) })) .mutation(({ input }) => updateUserRole(input.userId, input.role)), }); // ─── App router ──────────────────────────────────────────────────────────────── export const appRouter = router({ system: systemRouter, auth: router({ me: publicProcedure.query((opts) => opts.ctx.user), logout: publicProcedure.mutation(({ ctx }) => { const cookieOptions = getSessionCookieOptions(ctx.req); ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 }); return { success: true } as const; }), /** * Connexion locale par identifiant + mot de passe. * Utilisé pour les comptes internes (ex: adminServPodcast). */ loginLocal: publicProcedure .input( z.object({ username: z.string().min(1), password: z.string().min(1), }) ) .mutation(async ({ input, ctx }) => { const user = await getUserByUsername(input.username); if (!user || !user.passwordHash) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Identifiant ou mot de passe incorrect", }); } const valid = await bcrypt.compare(input.password, user.passwordHash); if (!valid) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Identifiant ou mot de passe incorrect", }); } // Créer une session JWT identique au flux OAuth const token = await sdk.signSession({ openId: user.openId, appId: "local", name: user.name ?? user.username ?? "", }); const cookieOptions = getSessionCookieOptions(ctx.req); ctx.res.cookie(COOKIE_NAME, token, cookieOptions); return { success: true, user } as const; }), }), etablissements: etablissementsRouter, motsCles: motsClesRouter, podcasts: podcastsRouter, upload: uploadRouter, users: usersRouter, }); export type AppRouter = typeof appRouter;