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)
343 lines
12 KiB
TypeScript
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;
|