init: pulseform v2 — Express/Prisma/React/SCSS/R3F stack

This commit is contained in:
2026-03-26 02:55:45 +00:00
commit 2d5030c521
31 changed files with 8131 additions and 0 deletions

6
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
.env
*.local
/src/generated/prisma

2968
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
backend/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "backend",
"version": "1.0.0",
"main": "dist/index.js",
"scripts": {
"dev": "nodemon --watch src --ext ts --exec ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:migrate": "prisma migrate dev",
"db:generate": "prisma generate",
"db:studio": "prisma studio"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@prisma/client": "^7.5.0",
"argon2": "^0.44.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"multer": "^2.1.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.1.0",
"nodemon": "^3.1.14",
"prisma": "^7.5.0",
"ts-node": "^10.9.2",
"typescript": "^6.0.2"
}
}

14
backend/prisma.config.ts Normal file
View File

@@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

View File

@@ -0,0 +1,225 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─── Enums ───────────────────────────────────────────────────────────────────
enum Role {
USER
COACH
ADMIN
}
enum Difficulty {
BEGINNER
INTERMEDIATE
ADVANCED
}
enum GroupRole {
MEMBER
COACH
ADMIN
}
enum FriendStatus {
PENDING
ACCEPTED
REJECTED
}
// ─── User ────────────────────────────────────────────────────────────────────
model User {
id String @id @default(uuid())
username String @unique
email String @unique
password String
role Role @default(USER)
avatar String?
bio String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
programs Program[]
histories History[]
groupMemberships GroupMember[]
sentRequests FriendRequest[] @relation("SentRequests")
receivedRequests FriendRequest[] @relation("ReceivedRequests")
rewards UserReward[]
@@map("users")
}
// ─── Exercise ────────────────────────────────────────────────────────────────
model Exercise {
id String @id @default(uuid())
name String
description String?
difficulty Difficulty @default(BEGINNER)
modelPath String?
muscleGroups String[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
programExercises ProgramExercise[]
historyEntries HistoryEntry[]
@@map("exercises")
}
// ─── Program ─────────────────────────────────────────────────────────────────
model Program {
id String @id @default(uuid())
name String
description String?
isPublic Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
exercises ProgramExercise[]
groups GroupProgram[]
@@map("programs")
}
model ProgramExercise {
id String @id @default(uuid())
sets Int
reps Int?
durationSec Int?
order Int @default(0)
programId String
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
exerciseId String
exercise Exercise @relation(fields: [exerciseId], references: [id], onDelete: Cascade)
@@unique([programId, exerciseId])
@@map("program_exercises")
}
// ─── History ─────────────────────────────────────────────────────────────────
model History {
id String @id @default(uuid())
date DateTime @default(now())
notes String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
entries HistoryEntry[]
@@map("histories")
}
model HistoryEntry {
id String @id @default(uuid())
sets Int
reps Int?
weightKg Float?
durationSec Int?
historyId String
history History @relation(fields: [historyId], references: [id], onDelete: Cascade)
exerciseId String
exercise Exercise @relation(fields: [exerciseId], references: [id], onDelete: Cascade)
@@map("history_entries")
}
// ─── Group ───────────────────────────────────────────────────────────────────
model Group {
id String @id @default(uuid())
name String
description String?
isPublic Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members GroupMember[]
programs GroupProgram[]
@@map("groups")
}
model GroupMember {
role GroupRole @default(MEMBER)
joinedAt DateTime @default(now())
groupId String
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([groupId, userId])
@@map("group_members")
}
model GroupProgram {
addedAt DateTime @default(now())
groupId String
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
programId String
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
@@id([groupId, programId])
@@map("group_programs")
}
// ─── Friends ─────────────────────────────────────────────────────────────────
model FriendRequest {
id String @id @default(uuid())
status FriendStatus @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
senderId String
sender User @relation("SentRequests", fields: [senderId], references: [id], onDelete: Cascade)
receiverId String
receiver User @relation("ReceivedRequests", fields: [receiverId], references: [id], onDelete: Cascade)
@@unique([senderId, receiverId])
@@map("friend_requests")
}
// ─── Rewards ─────────────────────────────────────────────────────────────────
model Reward {
id String @id @default(uuid())
name String
description String
iconPath String?
condition String
users UserReward[]
@@map("rewards")
}
model UserReward {
earnedAt DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
rewardId String
reward Reward @relation(fields: [rewardId], references: [id], onDelete: Cascade)
@@id([userId, rewardId])
@@map("user_rewards")
}

51
backend/src/index.ts Normal file
View File

@@ -0,0 +1,51 @@
import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import { PrismaClient } from "@prisma/client";
const app = express();
const prisma = new PrismaClient();
const PORT = process.env.PORT ?? 4000;
const CLIENT_URL = process.env.CLIENT_URL ?? "http://localhost:5173";
if (!process.env.JWT_SECRET) {
throw new Error("JWT_SECRET manquant — arrêt du serveur.");
}
// ─── Middlewares ──────────────────────────────────────────────────────────────
app.use(
cors({
origin: CLIENT_URL,
credentials: true,
})
);
app.use(express.json());
app.use(cookieParser());
// ─── Static uploads ──────────────────────────────────────────────────────────
app.use("/uploads", express.static("uploads"));
// ─── Routes ──────────────────────────────────────────────────────────────────
// app.use("/api/auth", authRoutes);
// app.use("/api/users", userRoutes);
// app.use("/api/exercises", exerciseRoutes);
// app.use("/api/programs", programRoutes);
// app.use("/api/groups", groupRoutes);
// app.use("/api/friends", friendRoutes);
// app.use("/api/history", historyRoutes);
app.get("/health", (_req, res) => {
res.json({ status: "ok" });
});
// ─── Start ────────────────────────────────────────────────────────────────────
app.listen(PORT, () => {
console.log(`Serveur démarré sur http://localhost:${PORT}`);
});
export { prisma };

View File

@@ -0,0 +1,31 @@
import type { Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import type { AppRequest, AuthPayload } from "../types/context.js";
export function requireAuth(req: AppRequest, res: Response, next: NextFunction): void {
const token = req.cookies?.token as string | undefined;
if (!token) {
res.status(401).json({ message: "Non authentifié." });
return;
}
try {
const secret = process.env.JWT_SECRET as string;
const payload = jwt.verify(token, secret) as AuthPayload;
req.user = payload;
next();
} catch {
res.status(401).json({ message: "Token invalide ou expiré." });
}
}
export function requireRole(...roles: AuthPayload["role"][]) {
return (req: AppRequest, res: Response, next: NextFunction): void => {
if (!req.user || !roles.includes(req.user.role)) {
res.status(403).json({ message: "Accès refusé." });
return;
}
next();
};
}

View File

@@ -0,0 +1,17 @@
import type { Request, Response } from "express";
import type { Role } from "@prisma/client";
export interface AuthPayload {
id: string;
email: string;
role: Role;
}
export interface AppRequest extends Request {
user?: AuthPayload;
}
export interface AppContext {
req: AppRequest;
res: Response;
}

19
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}