diff --git a/backend/package.json b/backend/package.json index 89da7a2..bc36707 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,7 +8,8 @@ "start": "node dist/index.js", "db:migrate": "prisma migrate dev", "db:generate": "prisma generate", - "db:studio": "prisma studio" + "db:studio": "prisma studio", + "db:seed": "ts-node src/db/seed.ts" }, "keywords": [], "author": "", diff --git a/backend/src/controllers/exercise.controller.ts b/backend/src/controllers/exercise.controller.ts new file mode 100644 index 0000000..6a0820a --- /dev/null +++ b/backend/src/controllers/exercise.controller.ts @@ -0,0 +1,48 @@ +import type { Response } from "express"; +import { prisma } from "../index"; +import type { AppRequest } from "../types/context"; +import { createExerciseSchema, updateExerciseSchema } from "../validators/exercise.validators"; + +export async function getAll(_req: AppRequest, res: Response): Promise { + const exercises = await prisma.exercise.findMany({ + orderBy: { name: "asc" }, + }); + res.json(exercises); +} + +export async function getOne(req: AppRequest, res: Response): Promise { + const id = req.params.id as string; + const exercise = await prisma.exercise.findUnique({ where: { id } }); + if (!exercise) { + res.status(404).json({ message: "Exercice introuvable." }); + return; + } + res.json(exercise); +} + +export async function create(req: AppRequest, res: Response): Promise { + const parsed = createExerciseSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ message: parsed.error.issues[0].message }); + return; + } + const exercise = await prisma.exercise.create({ data: parsed.data }); + res.status(201).json(exercise); +} + +export async function update(req: AppRequest, res: Response): Promise { + const id = req.params.id as string; + const parsed = updateExerciseSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ message: parsed.error.issues[0].message }); + return; + } + const exercise = await prisma.exercise.update({ where: { id }, data: parsed.data }); + res.json(exercise); +} + +export async function remove(req: AppRequest, res: Response): Promise { + const id = req.params.id as string; + await prisma.exercise.delete({ where: { id } }); + res.status(204).send(); +} diff --git a/backend/src/controllers/program.controller.ts b/backend/src/controllers/program.controller.ts new file mode 100644 index 0000000..00ff27b --- /dev/null +++ b/backend/src/controllers/program.controller.ts @@ -0,0 +1,123 @@ +import type { Response } from "express"; +import { prisma } from "../index"; +import type { AppRequest } from "../types/context"; +import { createProgramSchema, updateProgramSchema } from "../validators/program.validators"; + +export async function getAll(req: AppRequest, res: Response): Promise { + const userId = req.user?.id; + const programs = await prisma.program.findMany({ + where: { + OR: [ + { isPublic: true }, + { authorId: userId }, + ], + }, + include: { + author: { select: { id: true, username: true, avatar: true } }, + exercises: { + include: { exercise: true }, + orderBy: { order: "asc" }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + res.json(programs); +} + +export async function getOne(req: AppRequest, res: Response): Promise { + const id = req.params.id as string; + const userId = req.user?.id; + const program = await prisma.program.findFirst({ + where: { + id, + OR: [{ isPublic: true }, { authorId: userId }], + }, + include: { + author: { select: { id: true, username: true, avatar: true } }, + exercises: { + include: { exercise: true }, + orderBy: { order: "asc" }, + }, + }, + }); + if (!program) { + res.status(404).json({ message: "Programme introuvable." }); + return; + } + res.json(program); +} + +export async function create(req: AppRequest, res: Response): Promise { + const parsed = createProgramSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ message: parsed.error.issues[0].message }); + return; + } + + const { exercises, ...programData } = parsed.data; + + const program = await prisma.program.create({ + data: { + ...programData, + authorId: req.user!.id, + exercises: { + create: exercises.map(({ exerciseId, sets, reps, durationSec, order }) => ({ + exerciseId, + sets, + reps, + durationSec, + order, + })), + }, + }, + include: { + exercises: { + include: { exercise: true }, + orderBy: { order: "asc" }, + }, + }, + }); + res.status(201).json(program); +} + +export async function update(req: AppRequest, res: Response): Promise { + const id = req.params.id as string; + const parsed = updateProgramSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ message: parsed.error.issues[0].message }); + return; + } + + const program = await prisma.program.findFirst({ + where: { id, authorId: req.user!.id }, + }); + if (!program) { + res.status(404).json({ message: "Programme introuvable." }); + return; + } + + const updated = await prisma.program.update({ + where: { id }, + data: parsed.data, + include: { + exercises: { + include: { exercise: true }, + orderBy: { order: "asc" }, + }, + }, + }); + res.json(updated); +} + +export async function remove(req: AppRequest, res: Response): Promise { + const id = req.params.id as string; + const program = await prisma.program.findFirst({ + where: { id, authorId: req.user!.id }, + }); + if (!program) { + res.status(404).json({ message: "Programme introuvable." }); + return; + } + await prisma.program.delete({ where: { id } }); + res.status(204).send(); +} diff --git a/backend/src/db/seed.ts b/backend/src/db/seed.ts new file mode 100644 index 0000000..004b636 --- /dev/null +++ b/backend/src/db/seed.ts @@ -0,0 +1,106 @@ +import "dotenv/config"; +import { PrismaClient } from "@prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; +import argon2 from "argon2"; + +const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); +const prisma = new PrismaClient({ adapter }); + +async function main() { + console.log("🌱 Seed en cours..."); + + // ── Admin ────────────────────────────────────────────────────────────────── + const adminPassword = await argon2.hash("admin1234"); + const admin = await prisma.user.upsert({ + where: { email: "admin@pulseform.app" }, + update: {}, + create: { + username: "admin", + email: "admin@pulseform.app", + password: adminPassword, + role: "ADMIN", + }, + }); + console.log(`βœ… Admin : ${admin.email}`); + + // ── Exercices ────────────────────────────────────────────────────────────── + const exercisesData = [ + { name: "Squat", description: "Flexion des jambes, dos droit.", difficulty: "BEGINNER" as const, muscleGroups: ["Legs", "Glutes"] }, + { name: "Squat sautΓ©", description: "Squat avec impulsion vers le haut.", difficulty: "INTERMEDIATE" as const, muscleGroups: ["Legs", "Glutes"] }, + { name: "Fente avant", description: "Pas en avant, genou Γ  90Β°.", difficulty: "BEGINNER" as const, muscleGroups: ["Legs", "Glutes"] }, + { name: "Pompes", description: "Bras Γ  largeur d'Γ©paules, corps droit.", difficulty: "BEGINNER" as const, muscleGroups: ["Chest", "Arms"] }, + { name: "Pompes diamant", description: "Mains proches, triceps sollicitΓ©s.", difficulty: "INTERMEDIATE" as const, muscleGroups: ["Arms", "Chest"] }, + { name: "Dips", description: "Sur une chaise ou barre parallΓ¨le.", difficulty: "INTERMEDIATE" as const, muscleGroups: ["Arms", "Chest"] }, + { name: "Traction", description: "Barre fixe, prise pronation.", difficulty: "ADVANCED" as const, muscleGroups: ["Back", "Arms"] }, + { name: "Rowing inclinΓ©", description: "HaltΓ¨re, dos Γ  45Β°.", difficulty: "INTERMEDIATE" as const, muscleGroups: ["Back"] }, + { name: "Planche", description: "Corps droit, abdos contractΓ©s.", difficulty: "BEGINNER" as const, muscleGroups: ["Abdominals"] }, + { name: "Crunch", description: "Soulever les Γ©paules, pas le dos.", difficulty: "BEGINNER" as const, muscleGroups: ["Abdominals"] }, + { name: "Mountain climber", description: "Alterner genoux vers la poitrine.", difficulty: "INTERMEDIATE" as const, muscleGroups: ["Abdominals", "Legs"] }, + { name: "Burpee", description: "Pompe + saut, exercice full body.", difficulty: "ADVANCED" as const, muscleGroups: ["Legs", "Chest", "Arms"] }, + ]; + + await prisma.exercise.createMany({ data: exercisesData, skipDuplicates: true }); + const exercises = await prisma.exercise.findMany(); + console.log(`βœ… ${exercises.length} exercices`); + + // ── Programme dΓ©butant ───────────────────────────────────────────────────── + const squat = exercises.find((e) => e.name === "Squat")!; + const pompes = exercises.find((e) => e.name === "Pompes")!; + const planche = exercises.find((e) => e.name === "Planche")!; + const crunch = exercises.find((e) => e.name === "Crunch")!; + const fente = exercises.find((e) => e.name === "Fente avant")!; + + await prisma.program.upsert({ + where: { id: "00000000-0000-0000-0000-000000000001" }, + update: {}, + create: { + id: "00000000-0000-0000-0000-000000000001", + name: "Full Body DΓ©butant", + description: "Programme complet pour commencer sans Γ©quipement.", + isPublic: true, + authorId: admin.id, + exercises: { + create: [ + { exerciseId: squat.id!, sets: 3, reps: 12, order: 0 }, + { exerciseId: pompes.id!, sets: 3, reps: 10, order: 1 }, + { exerciseId: fente.id!, sets: 3, reps: 10, order: 2 }, + { exerciseId: planche.id!, sets: 3, durationSec: 30, order: 3 }, + { exerciseId: crunch.id!, sets: 3, reps: 15, order: 4 }, + ], + }, + }, + }); + console.log("βœ… Programme : Full Body DΓ©butant"); + + // ── Programme intermΓ©diaire ──────────────────────────────────────────────── + const burpee = exercises.find((e) => e.name === "Burpee")!; + const mountain = exercises.find((e) => e.name === "Mountain climber")!; + const squatSaute = exercises.find((e) => e.name === "Squat sautΓ©")!; + + await prisma.program.upsert({ + where: { id: "00000000-0000-0000-0000-000000000002" }, + update: {}, + create: { + id: "00000000-0000-0000-0000-000000000002", + name: "Cardio IntermΓ©diaire", + description: "Circuit cardio sans Γ©quipement, intensitΓ© modΓ©rΓ©e.", + isPublic: true, + authorId: admin.id, + exercises: { + create: [ + { exerciseId: burpee.id!, sets: 4, reps: 8, order: 0 }, + { exerciseId: squatSaute.id!, sets: 4, reps: 12, order: 1 }, + { exerciseId: mountain.id!, sets: 4, durationSec: 30, order: 2 }, + { exerciseId: pompes.id!, sets: 4, reps: 12, order: 3 }, + ], + }, + }, + }); + console.log("βœ… Programme : Cardio IntermΓ©diaire"); + + console.log("πŸŽ‰ Seed terminΓ©."); +} + +main() + .catch((e) => { console.error(e); process.exit(1); }) + .finally(() => prisma.$disconnect()); diff --git a/backend/src/index.ts b/backend/src/index.ts index 421f91a..8802765 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -6,6 +6,8 @@ import { PrismaClient } from "@prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; import authRoutes from "./routes/auth.routes"; import userRoutes from "./routes/user.routes"; +import exerciseRoutes from "./routes/exercise.routes"; +import programRoutes from "./routes/program.routes"; const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); const app = express(); @@ -32,8 +34,8 @@ app.use("/uploads", express.static("uploads")); app.use("/api/auth", authRoutes); app.use("/api/users", userRoutes); -// app.use("/api/exercises", exerciseRoutes); -// app.use("/api/programs", programRoutes); +app.use("/api/exercises", exerciseRoutes); +app.use("/api/programs", programRoutes); // app.use("/api/groups", groupRoutes); // app.use("/api/friends", friendRoutes); // app.use("/api/history", historyRoutes); diff --git a/backend/src/routes/exercise.routes.ts b/backend/src/routes/exercise.routes.ts new file mode 100644 index 0000000..eb61682 --- /dev/null +++ b/backend/src/routes/exercise.routes.ts @@ -0,0 +1,13 @@ +import { Router } from "express"; +import { getAll, getOne, create, update, remove } from "../controllers/exercise.controller"; +import { requireAuth, requireRole } from "../middlewares/auth"; + +const router = Router(); + +router.get("/", requireAuth, getAll); +router.get("/:id", requireAuth, getOne); +router.post("/", requireAuth, requireRole("ADMIN", "COACH"), create); +router.patch("/:id", requireAuth, requireRole("ADMIN", "COACH"), update); +router.delete("/:id", requireAuth, requireRole("ADMIN"), remove); + +export default router; diff --git a/backend/src/routes/program.routes.ts b/backend/src/routes/program.routes.ts new file mode 100644 index 0000000..0509408 --- /dev/null +++ b/backend/src/routes/program.routes.ts @@ -0,0 +1,13 @@ +import { Router } from "express"; +import { getAll, getOne, create, update, remove } from "../controllers/program.controller"; +import { requireAuth } from "../middlewares/auth"; + +const router = Router(); + +router.get("/", requireAuth, getAll); +router.get("/:id", requireAuth, getOne); +router.post("/", requireAuth, create); +router.patch("/:id", requireAuth, update); +router.delete("/:id", requireAuth, remove); + +export default router; diff --git a/backend/src/validators/exercise.validators.ts b/backend/src/validators/exercise.validators.ts new file mode 100644 index 0000000..ef7b082 --- /dev/null +++ b/backend/src/validators/exercise.validators.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const createExerciseSchema = z.object({ + name: z.string().min(2).max(50), + description: z.string().max(300).optional(), + difficulty: z.enum(["BEGINNER", "INTERMEDIATE", "ADVANCED"]).default("BEGINNER"), + muscleGroups: z.array(z.string()).min(1), + modelPath: z.string().optional(), +}); + +export const updateExerciseSchema = createExerciseSchema.partial(); + +export type CreateExerciseInput = z.infer; +export type UpdateExerciseInput = z.infer; diff --git a/backend/src/validators/program.validators.ts b/backend/src/validators/program.validators.ts new file mode 100644 index 0000000..cdbc7ec --- /dev/null +++ b/backend/src/validators/program.validators.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +const programExerciseSchema = z.object({ + exerciseId: z.string().uuid(), + sets: z.number().int().min(1), + reps: z.number().int().min(1).optional(), + durationSec: z.number().int().min(1).optional(), + order: z.number().int().min(0).default(0), +}); + +export const createProgramSchema = z.object({ + name: z.string().min(2).max(50), + description: z.string().max(300).optional(), + isPublic: z.boolean().default(false), + exercises: z.array(programExerciseSchema).min(1), +}); + +export const updateProgramSchema = z.object({ + name: z.string().min(2).max(50).optional(), + description: z.string().max(300).optional(), + isPublic: z.boolean().optional(), +}); + +export type CreateProgramInput = z.infer; +export type UpdateProgramInput = z.infer;