2 Commits

14 changed files with 528 additions and 9 deletions

View File

@@ -8,7 +8,8 @@
"start": "node dist/index.js", "start": "node dist/index.js",
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:studio": "prisma studio" "db:studio": "prisma studio",
"db:seed": "ts-node src/db/seed.ts"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",

View File

@@ -0,0 +1,12 @@
/*
Warnings:
- Added the required column `programId` to the `histories` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "histories" ADD COLUMN "completedAt" TIMESTAMP(3),
ADD COLUMN "programId" TEXT NOT NULL;
-- AddForeignKey
ALTER TABLE "histories" ADD CONSTRAINT "histories_programId_fkey" FOREIGN KEY ("programId") REFERENCES "programs"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -88,6 +88,7 @@ model Program {
exercises ProgramExercise[] exercises ProgramExercise[]
groups GroupProgram[] groups GroupProgram[]
histories History[]
@@map("programs") @@map("programs")
} }
@@ -113,10 +114,13 @@ model ProgramExercise {
model History { model History {
id String @id @default(uuid()) id String @id @default(uuid())
date DateTime @default(now()) date DateTime @default(now())
completedAt DateTime?
notes String? notes String?
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
programId String
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
entries HistoryEntry[] entries HistoryEntry[]

View File

@@ -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<void> {
const exercises = await prisma.exercise.findMany({
orderBy: { name: "asc" },
});
res.json(exercises);
}
export async function getOne(req: AppRequest, res: Response): Promise<void> {
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<void> {
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<void> {
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<void> {
const id = req.params.id as string;
await prisma.exercise.delete({ where: { id } });
res.status(204).send();
}

View File

@@ -0,0 +1,123 @@
import type { Response } from "express";
import { prisma } from "../index";
import type { AppRequest } from "../types/context";
import {
addEntrySchema,
completeHistorySchema,
startHistorySchema,
} from "../validators/history.validators";
// Démarre une session d'entraînement
export async function start(req: AppRequest, res: Response): Promise<void> {
const parsed = startHistorySchema.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: parsed.data.programId,
OR: [{ isPublic: true }, { authorId: req.user!.id }],
},
});
if (!program) {
res.status(404).json({ message: "Programme introuvable." });
return;
}
const history = await prisma.history.create({
data: {
userId: req.user!.id,
programId: parsed.data.programId,
},
include: { entries: true },
});
res.status(201).json(history);
}
// Ajoute un exercice complété à la session
export async function addEntry(req: AppRequest, res: Response): Promise<void> {
const historyId = req.params.id as string;
const parsed = addEntrySchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ message: parsed.error.issues[0].message });
return;
}
const history = await prisma.history.findFirst({
where: { id: historyId, userId: req.user!.id, completedAt: null },
});
if (!history) {
res.status(404).json({ message: "Session introuvable ou déjà terminée." });
return;
}
const entry = await prisma.historyEntry.create({
data: { historyId, ...parsed.data },
include: { exercise: true },
});
res.status(201).json(entry);
}
// Termine la session
export async function complete(req: AppRequest, res: Response): Promise<void> {
const historyId = req.params.id as string;
const parsed = completeHistorySchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ message: parsed.error.issues[0].message });
return;
}
const history = await prisma.history.findFirst({
where: { id: historyId, userId: req.user!.id, completedAt: null },
include: { entries: true },
});
if (!history) {
res.status(404).json({ message: "Session introuvable ou déjà terminée." });
return;
}
const completed = await prisma.history.update({
where: { id: historyId },
data: {
completedAt: new Date(),
notes: parsed.data.notes,
},
include: {
entries: { include: { exercise: true } },
},
});
res.json(completed);
}
// Historique de l'utilisateur connecté
export async function getMyHistory(req: AppRequest, res: Response): Promise<void> {
const histories = await prisma.history.findMany({
where: { userId: req.user!.id },
include: {
entries: { include: { exercise: true } },
},
orderBy: { date: "desc" },
});
res.json(histories);
}
// Détail d'une session
export async function getOne(req: AppRequest, res: Response): Promise<void> {
const id = req.params.id as string;
const history = await prisma.history.findFirst({
where: { id, userId: req.user!.id },
include: {
entries: { include: { exercise: true } },
},
});
if (!history) {
res.status(404).json({ message: "Session introuvable." });
return;
}
res.json(history);
}

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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();
}

106
backend/src/db/seed.ts Normal file
View File

@@ -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());

View File

@@ -6,6 +6,9 @@ import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg"; import { PrismaPg } from "@prisma/adapter-pg";
import authRoutes from "./routes/auth.routes"; import authRoutes from "./routes/auth.routes";
import userRoutes from "./routes/user.routes"; import userRoutes from "./routes/user.routes";
import exerciseRoutes from "./routes/exercise.routes";
import programRoutes from "./routes/program.routes";
import historyRoutes from "./routes/history.routes";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const app = express(); const app = express();
@@ -32,11 +35,11 @@ app.use("/uploads", express.static("uploads"));
app.use("/api/auth", authRoutes); app.use("/api/auth", authRoutes);
app.use("/api/users", userRoutes); app.use("/api/users", userRoutes);
// app.use("/api/exercises", exerciseRoutes); app.use("/api/exercises", exerciseRoutes);
// app.use("/api/programs", programRoutes); app.use("/api/programs", programRoutes);
app.use("/api/history", historyRoutes);
// app.use("/api/groups", groupRoutes); // app.use("/api/groups", groupRoutes);
// app.use("/api/friends", friendRoutes); // app.use("/api/friends", friendRoutes);
// app.use("/api/history", historyRoutes);
app.get("/health", (_req, res) => { app.get("/health", (_req, res) => {
res.json({ status: "ok" }); res.json({ status: "ok" });

View File

@@ -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;

View File

@@ -0,0 +1,13 @@
import { Router } from "express";
import { start, addEntry, complete, getMyHistory, getOne } from "../controllers/history.controller";
import { requireAuth } from "../middlewares/auth";
const router = Router();
router.get("/", requireAuth, getMyHistory);
router.get("/:id", requireAuth, getOne);
router.post("/", requireAuth, start);
router.post("/:id/entries", requireAuth, addEntry);
router.patch("/:id/complete", requireAuth, complete);
export default router;

View File

@@ -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;

View File

@@ -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<typeof createExerciseSchema>;
export type UpdateExerciseInput = z.infer<typeof updateExerciseSchema>;

View File

@@ -0,0 +1,21 @@
import { z } from "zod";
export const startHistorySchema = z.object({
programId: z.string().min(1),
});
export const addEntrySchema = z.object({
exerciseId: z.string().min(1),
sets: z.number().int().min(1),
reps: z.number().int().min(1).optional(),
weightKg: z.number().min(0).optional(),
durationSec: z.number().int().min(1).optional(),
});
export const completeHistorySchema = z.object({
notes: z.string().max(500).optional(),
});
export type StartHistoryInput = z.infer<typeof startHistorySchema>;
export type AddEntryInput = z.infer<typeof addEntrySchema>;
export type CompleteHistoryInput = z.infer<typeof completeHistorySchema>;

View File

@@ -0,0 +1,25 @@
import { z } from "zod";
const programExerciseSchema = z.object({
exerciseId: z.string().min(1),
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<typeof createProgramSchema>;
export type UpdateProgramInput = z.infer<typeof updateProgramSchema>;