Files
itinova-podcasts/server/routers.ts
manus-admin aab11c8308 Initial commit: itinova-podcasts v1
Stack: Node.js/Express + React/Vite + tRPC + MySQL (Drizzle ORM)
Features: Gestion de podcasts, établissements, mots-clés, upload audio S3
Migrations: 0000-0002 (users, etablissements, mots_cles, podcasts, podcast_mots_cles)
2026-04-12 18:34:56 -04:00

343 lines
12 KiB
TypeScript

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<typeof updateUser>[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;