6 Commits

12 changed files with 426 additions and 3 deletions

View File

@@ -0,0 +1,116 @@
import type { Response } from "express";
import { prisma } from "../index";
import type { AppRequest } from "../types/context";
import { friendRequestSchema, friendResponseSchema } from "../validators/friend.validators";
import { checkAndGrantRewards } from "../services/reward.service";
// 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 } },
},
});
// Déclenche les récompenses pour les deux parties si accepté
if (parsed.data.status === "ACCEPTED") {
await Promise.all([
checkAndGrantRewards(req.user!.id),
checkAndGrantRewards(updated.sender.id),
]);
}
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);
}

View 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);
}

View File

@@ -6,6 +6,7 @@ import {
completeHistorySchema, completeHistorySchema,
startHistorySchema, startHistorySchema,
} from "../validators/history.validators"; } from "../validators/history.validators";
import { checkAndGrantRewards } from "../services/reward.service";
// Démarre une session d'entraînement // Démarre une session d'entraînement
export async function start(req: AppRequest, res: Response): Promise<void> { export async function start(req: AppRequest, res: Response): Promise<void> {
@@ -91,7 +92,8 @@ export async function complete(req: AppRequest, res: Response): Promise<void> {
}, },
}); });
res.json(completed); const newRewards = await checkAndGrantRewards(req.user!.id);
res.json({ ...completed, newRewards });
} }
// Historique de l'utilisateur connecté // Historique de l'utilisateur connecté

View File

@@ -0,0 +1,19 @@
import type { Response } from "express";
import { prisma } from "../index";
import type { AppRequest } from "../types/context";
// Liste toutes les récompenses disponibles
export async function getAll(_req: AppRequest, res: Response): Promise<void> {
const rewards = await prisma.reward.findMany({ orderBy: { name: "asc" } });
res.json(rewards);
}
// Récompenses débloquées par l'utilisateur connecté
export async function getMyRewards(req: AppRequest, res: Response): Promise<void> {
const rewards = await prisma.userReward.findMany({
where: { userId: req.user!.id },
include: { reward: true },
orderBy: { earnedAt: "desc" },
});
res.json(rewards);
}

View File

@@ -98,6 +98,20 @@ async function main() {
}); });
console.log("✅ Programme : Cardio Intermédiaire"); console.log("✅ Programme : Cardio Intermédiaire");
// ── Récompenses ───────────────────────────────────────────────────────────
const rewardsData = [
{ name: "Premier pas", description: "Terminer son premier programme.", condition: "first_program" },
{ name: "Régulier", description: "Terminer 5 programmes.", condition: "five_programs" },
{ name: "Acharné", description: "Terminer 10 programmes.", condition: "ten_programs" },
{ name: "Premier ami", description: "Avoir son premier ami.", condition: "first_friend" },
{ name: "Populaire", description: "Avoir 5 amis.", condition: "five_friends" },
{ name: "Esprit d'équipe", description: "Rejoindre un groupe.", condition: "first_group" },
{ name: "Créateur", description: "Créer son premier programme.", condition: "first_program_created" },
];
await prisma.reward.createMany({ data: rewardsData, skipDuplicates: true });
console.log(`${rewardsData.length} récompenses`);
console.log("🎉 Seed terminé."); console.log("🎉 Seed terminé.");
} }

View File

@@ -9,6 +9,9 @@ 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";
import rewardRoutes from "./routes/reward.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 +41,9 @@ 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.use("/api/rewards", rewardRoutes);
app.get("/health", (_req, res) => { app.get("/health", (_req, res) => {
res.json({ status: "ok" }); res.json({ status: "ok" });

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

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

View File

@@ -0,0 +1,10 @@
import { Router } from "express";
import { getAll, getMyRewards } from "../controllers/reward.controller";
import { requireAuth } from "../middlewares/auth";
const router = Router();
router.get("/", requireAuth, getAll);
router.get("/me", requireAuth, getMyRewards);
export default router;

View File

@@ -0,0 +1,76 @@
import { prisma } from "../index";
// Conditions disponibles — à étendre librement
const CONDITIONS: Record<string, (userId: string) => Promise<boolean>> = {
first_program: async (userId) => {
const count = await prisma.history.count({
where: { userId, completedAt: { not: null } },
});
return count >= 1;
},
five_programs: async (userId) => {
const count = await prisma.history.count({
where: { userId, completedAt: { not: null } },
});
return count >= 5;
},
ten_programs: async (userId) => {
const count = await prisma.history.count({
where: { userId, completedAt: { not: null } },
});
return count >= 10;
},
first_friend: async (userId) => {
const count = await prisma.friendRequest.count({
where: {
status: "ACCEPTED",
OR: [{ senderId: userId }, { receiverId: userId }],
},
});
return count >= 1;
},
five_friends: async (userId) => {
const count = await prisma.friendRequest.count({
where: {
status: "ACCEPTED",
OR: [{ senderId: userId }, { receiverId: userId }],
},
});
return count >= 5;
},
first_group: async (userId) => {
const count = await prisma.groupMember.count({ where: { userId } });
return count >= 1;
},
first_program_created: async (userId) => {
const count = await prisma.program.count({ where: { authorId: userId } });
return count >= 1;
},
};
// Vérifie et attribue toutes les récompenses débloquées pour un utilisateur
export async function checkAndGrantRewards(userId: string): Promise<string[]> {
const allRewards = await prisma.reward.findMany();
const earned = await prisma.userReward.findMany({
where: { userId },
select: { rewardId: true },
});
const earnedIds = new Set(earned.map((r) => r.rewardId));
const newlyEarned: string[] = [];
for (const reward of allRewards) {
if (earnedIds.has(reward.id!)) continue;
const checker = CONDITIONS[reward.condition];
if (!checker) continue;
const unlocked = await checker(userId);
if (unlocked) {
await prisma.userReward.create({ data: { userId, rewardId: reward.id! } });
newlyEarned.push(reward.name);
}
}
return newlyEarned;
}

View 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"]),
});

View 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),
});