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)
This commit is contained in:
342
server/routers.ts
Normal file
342
server/routers.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user