feat: phase 4 — amis (demande/accepter/refuser) + groupes (créer/inviter/programme)
This commit is contained in:
106
backend/src/controllers/friend.controller.ts
Normal file
106
backend/src/controllers/friend.controller.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
132
backend/src/controllers/group.controller.ts
Normal file
132
backend/src/controllers/group.controller.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import userRoutes from "./routes/user.routes";
|
|||||||
import exerciseRoutes from "./routes/exercise.routes";
|
import exerciseRoutes from "./routes/exercise.routes";
|
||||||
import programRoutes from "./routes/program.routes";
|
import programRoutes from "./routes/program.routes";
|
||||||
import historyRoutes from "./routes/history.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 adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -38,8 +40,8 @@ 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/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) => {
|
app.get("/health", (_req, res) => {
|
||||||
res.json({ status: "ok" });
|
res.json({ status: "ok" });
|
||||||
|
|||||||
12
backend/src/routes/friend.routes.ts
Normal file
12
backend/src/routes/friend.routes.ts
Normal file
@@ -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;
|
||||||
14
backend/src/routes/group.routes.ts
Normal file
14
backend/src/routes/group.routes.ts
Normal file
@@ -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;
|
||||||
9
backend/src/validators/friend.validators.ts
Normal file
9
backend/src/validators/friend.validators.ts
Normal file
@@ -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"]),
|
||||||
|
});
|
||||||
15
backend/src/validators/group.validators.ts
Normal file
15
backend/src/validators/group.validators.ts
Normal file
@@ -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),
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user