diff --git a/backend/src/controllers/friend.controller.ts b/backend/src/controllers/friend.controller.ts new file mode 100644 index 0000000..e6b426b --- /dev/null +++ b/backend/src/controllers/friend.controller.ts @@ -0,0 +1,106 @@ +import type { Response } from "express"; +import { prisma } from "../index"; +import type { AppRequest } from "../types/context"; +import { friendRequestSchema, friendResponseSchema } from "../validators/friend.validators"; + +// Envoyer une demande d'ami +export async function sendRequest(req: AppRequest, res: Response): Promise { + const parsed = friendRequestSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ message: parsed.error.issues[0].message }); + return; + } + + const { receiverId } = parsed.data; + const senderId = req.user!.id; + + if (senderId === receiverId) { + res.status(400).json({ message: "Impossible de s'ajouter soi-même." }); + return; + } + + const receiver = await prisma.user.findUnique({ where: { id: receiverId } }); + if (!receiver) { + res.status(404).json({ message: "Utilisateur introuvable." }); + return; + } + + const existing = await prisma.friendRequest.findFirst({ + where: { + OR: [ + { senderId, receiverId }, + { senderId: receiverId, receiverId: senderId }, + ], + }, + }); + if (existing) { + res.status(409).json({ message: "Demande déjà existante." }); + return; + } + + const request = await prisma.friendRequest.create({ + data: { senderId, receiverId }, + include: { + receiver: { select: { id: true, username: true, avatar: true } }, + }, + }); + res.status(201).json(request); +} + +// Répondre à une demande (accepter/refuser) +export async function respondToRequest(req: AppRequest, res: Response): Promise { + const id = req.params.id as string; + const parsed = friendResponseSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ message: parsed.error.issues[0].message }); + return; + } + + const request = await prisma.friendRequest.findFirst({ + where: { id, receiverId: req.user!.id, status: "PENDING" }, + }); + if (!request) { + res.status(404).json({ message: "Demande introuvable." }); + return; + } + + const updated = await prisma.friendRequest.update({ + where: { id }, + data: { status: parsed.data.status }, + include: { + sender: { select: { id: true, username: true, avatar: true } }, + }, + }); + res.json(updated); +} + +// Liste des amis (demandes acceptées) +export async function getFriends(req: AppRequest, res: Response): Promise { + const userId = req.user!.id; + const requests = await prisma.friendRequest.findMany({ + where: { + status: "ACCEPTED", + OR: [{ senderId: userId }, { receiverId: userId }], + }, + include: { + sender: { select: { id: true, username: true, avatar: true } }, + receiver: { select: { id: true, username: true, avatar: true } }, + }, + }); + + const friends = requests.map((r) => + r.senderId === userId ? r.receiver : r.sender + ); + res.json(friends); +} + +// Demandes reçues en attente +export async function getPendingRequests(req: AppRequest, res: Response): Promise { + const requests = await prisma.friendRequest.findMany({ + where: { receiverId: req.user!.id, status: "PENDING" }, + include: { + sender: { select: { id: true, username: true, avatar: true } }, + }, + }); + res.json(requests); +} diff --git a/backend/src/controllers/group.controller.ts b/backend/src/controllers/group.controller.ts new file mode 100644 index 0000000..0957a28 --- /dev/null +++ b/backend/src/controllers/group.controller.ts @@ -0,0 +1,132 @@ +import type { Response } from "express"; +import { prisma } from "../index"; +import type { AppRequest } from "../types/context"; +import { addGroupProgramSchema, createGroupSchema, inviteMemberSchema } from "../validators/group.validators"; + +// Créer un groupe +export async function create(req: AppRequest, res: Response): Promise { + const parsed = createGroupSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ message: parsed.error.issues[0].message }); + return; + } + + const group = await prisma.group.create({ + data: { + ...parsed.data, + members: { + create: { userId: req.user!.id, role: "ADMIN" }, + }, + }, + include: { members: { include: { user: { select: { id: true, username: true, avatar: true } } } } }, + }); + res.status(201).json(group); +} + +// Mes groupes +export async function getMyGroups(req: AppRequest, res: Response): Promise { + const groups = await prisma.group.findMany({ + where: { members: { some: { userId: req.user!.id } } }, + include: { + members: { include: { user: { select: { id: true, username: true, avatar: true } } } }, + programs: { include: { program: true } }, + }, + }); + res.json(groups); +} + +// Détail d'un groupe +export async function getOne(req: AppRequest, res: Response): Promise { + const id = req.params.id as string; + const group = await prisma.group.findFirst({ + where: { + id, + OR: [{ isPublic: true }, { members: { some: { userId: req.user!.id } } }], + }, + include: { + members: { include: { user: { select: { id: true, username: true, avatar: true } } } }, + programs: { include: { program: { include: { exercises: { include: { exercise: true } } } } } }, + }, + }); + if (!group) { + res.status(404).json({ message: "Groupe introuvable." }); + return; + } + res.json(group); +} + +// Inviter un membre +export async function inviteMember(req: AppRequest, res: Response): Promise { + const groupId = req.params.id as string; + const parsed = inviteMemberSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ message: parsed.error.issues[0].message }); + return; + } + + // Vérifier que l'appelant est ADMIN ou COACH du groupe + const membership = await prisma.groupMember.findUnique({ + where: { groupId_userId: { groupId, userId: req.user!.id } }, + }); + if (!membership || membership.role === "MEMBER") { + res.status(403).json({ message: "Accès refusé." }); + return; + } + + const already = await prisma.groupMember.findUnique({ + where: { groupId_userId: { groupId, userId: parsed.data.userId } }, + }); + if (already) { + res.status(409).json({ message: "Déjà membre du groupe." }); + return; + } + + const member = await prisma.groupMember.create({ + data: { groupId, userId: parsed.data.userId, role: "MEMBER" }, + include: { user: { select: { id: true, username: true, avatar: true } } }, + }); + res.status(201).json(member); +} + +// Quitter un groupe +export async function leaveGroup(req: AppRequest, res: Response): Promise { + const groupId = req.params.id as string; + const userId = req.user!.id; + + const membership = await prisma.groupMember.findUnique({ + where: { groupId_userId: { groupId, userId } }, + }); + if (!membership) { + res.status(404).json({ message: "Tu n'es pas membre de ce groupe." }); + return; + } + + await prisma.groupMember.delete({ + where: { groupId_userId: { groupId, userId } }, + }); + res.status(204).send(); +} + +// Ajouter un programme au groupe +export async function addProgram(req: AppRequest, res: Response): Promise { + const groupId = req.params.id as string; + const parsed = addGroupProgramSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ message: parsed.error.issues[0].message }); + return; + } + + const membership = await prisma.groupMember.findUnique({ + where: { groupId_userId: { groupId, userId: req.user!.id } }, + }); + if (!membership || membership.role === "MEMBER") { + res.status(403).json({ message: "Accès refusé." }); + return; + } + + const entry = await prisma.groupProgram.create({ + data: { groupId, programId: parsed.data.programId }, + include: { program: true }, + }); + res.status(201).json(entry); +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 42a7665..7747036 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -9,6 +9,8 @@ import userRoutes from "./routes/user.routes"; import exerciseRoutes from "./routes/exercise.routes"; import programRoutes from "./routes/program.routes"; import historyRoutes from "./routes/history.routes"; +import friendRoutes from "./routes/friend.routes"; +import groupRoutes from "./routes/group.routes"; const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); const app = express(); @@ -38,8 +40,8 @@ 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/friends", friendRoutes); +app.use("/api/groups", groupRoutes); app.get("/health", (_req, res) => { res.json({ status: "ok" }); diff --git a/backend/src/routes/friend.routes.ts b/backend/src/routes/friend.routes.ts new file mode 100644 index 0000000..b32abb0 --- /dev/null +++ b/backend/src/routes/friend.routes.ts @@ -0,0 +1,12 @@ +import { Router } from "express"; +import { sendRequest, respondToRequest, getFriends, getPendingRequests } from "../controllers/friend.controller"; +import { requireAuth } from "../middlewares/auth"; + +const router = Router(); + +router.get("/", requireAuth, getFriends); +router.get("/pending", requireAuth, getPendingRequests); +router.post("/", requireAuth, sendRequest); +router.patch("/:id", requireAuth, respondToRequest); + +export default router; diff --git a/backend/src/routes/group.routes.ts b/backend/src/routes/group.routes.ts new file mode 100644 index 0000000..ba3b760 --- /dev/null +++ b/backend/src/routes/group.routes.ts @@ -0,0 +1,14 @@ +import { Router } from "express"; +import { create, getMyGroups, getOne, inviteMember, leaveGroup, addProgram } from "../controllers/group.controller"; +import { requireAuth } from "../middlewares/auth"; + +const router = Router(); + +router.get("/", requireAuth, getMyGroups); +router.get("/:id", requireAuth, getOne); +router.post("/", requireAuth, create); +router.post("/:id/members", requireAuth, inviteMember); +router.post("/:id/programs", requireAuth, addProgram); +router.delete("/:id/leave", requireAuth, leaveGroup); + +export default router; diff --git a/backend/src/validators/friend.validators.ts b/backend/src/validators/friend.validators.ts new file mode 100644 index 0000000..239a2fe --- /dev/null +++ b/backend/src/validators/friend.validators.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const friendRequestSchema = z.object({ + receiverId: z.string().min(1), +}); + +export const friendResponseSchema = z.object({ + status: z.enum(["ACCEPTED", "REJECTED"]), +}); diff --git a/backend/src/validators/group.validators.ts b/backend/src/validators/group.validators.ts new file mode 100644 index 0000000..83ac140 --- /dev/null +++ b/backend/src/validators/group.validators.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const createGroupSchema = z.object({ + name: z.string().min(2).max(50), + description: z.string().max(300).optional(), + isPublic: z.boolean().default(false), +}); + +export const inviteMemberSchema = z.object({ + userId: z.string().min(1), +}); + +export const addGroupProgramSchema = z.object({ + programId: z.string().min(1), +});