diff --git a/backend/prisma/migrations/20260326034915_add_history_program_completed/migration.sql b/backend/prisma/migrations/20260326034915_add_history_program_completed/migration.sql new file mode 100644 index 0000000..92e884a --- /dev/null +++ b/backend/prisma/migrations/20260326034915_add_history_program_completed/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index bbbf2ce..d55c643 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -88,6 +88,7 @@ model Program { exercises ProgramExercise[] groups GroupProgram[] + histories History[] @@map("programs") } @@ -111,12 +112,15 @@ model ProgramExercise { // ─── History ───────────────────────────────────────────────────────────────── model History { - id String @id @default(uuid()) - date DateTime @default(now()) - notes String? + id String @id @default(uuid()) + date DateTime @default(now()) + completedAt DateTime? + notes String? - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + programId String + program Program @relation(fields: [programId], references: [id], onDelete: Cascade) entries HistoryEntry[] diff --git a/backend/src/controllers/history.controller.ts b/backend/src/controllers/history.controller.ts new file mode 100644 index 0000000..e877d37 --- /dev/null +++ b/backend/src/controllers/history.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 8802765..42a7665 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -8,6 +8,7 @@ import authRoutes from "./routes/auth.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 app = express(); @@ -36,9 +37,9 @@ app.use("/api/auth", authRoutes); app.use("/api/users", userRoutes); app.use("/api/exercises", exerciseRoutes); app.use("/api/programs", programRoutes); +app.use("/api/history", historyRoutes); // app.use("/api/groups", groupRoutes); // app.use("/api/friends", friendRoutes); -// app.use("/api/history", historyRoutes); app.get("/health", (_req, res) => { res.json({ status: "ok" }); diff --git a/backend/src/routes/history.routes.ts b/backend/src/routes/history.routes.ts new file mode 100644 index 0000000..dbf4099 --- /dev/null +++ b/backend/src/routes/history.routes.ts @@ -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; diff --git a/backend/src/validators/history.validators.ts b/backend/src/validators/history.validators.ts new file mode 100644 index 0000000..8f5f51b --- /dev/null +++ b/backend/src/validators/history.validators.ts @@ -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; +export type AddEntryInput = z.infer; +export type CompleteHistoryInput = z.infer; diff --git a/backend/src/validators/program.validators.ts b/backend/src/validators/program.validators.ts index cdbc7ec..d67fc98 100644 --- a/backend/src/validators/program.validators.ts +++ b/backend/src/validators/program.validators.ts @@ -1,7 +1,7 @@ import { z } from "zod"; const programExerciseSchema = z.object({ - exerciseId: z.string().uuid(), + 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(),