From 578f0858e81106e39f6b8e94017fa636725d70ce Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Thu, 21 Aug 2025 13:54:13 +0200 Subject: [PATCH] feat: secu migrate to user uuid --- MIGRATION_UUID.md | 116 +++++++++ app/api/auth/route.ts | 30 +-- app/api/evaluations/route.ts | 9 +- app/api/evaluations/skills/route.ts | 18 +- app/evaluation/page.tsx | 21 +- components/evaluation/client-wrapper.tsx | 2 +- lib/auth-utils.ts | 2 +- lib/server-auth.ts | 29 ++- middleware.ts | 9 +- scripts/init.sql | 24 +- scripts/migrate-to-uuid.sql | 40 +++ services/evaluation-service.ts | 302 ++++++++++++++++++++++- 12 files changed, 532 insertions(+), 70 deletions(-) create mode 100644 MIGRATION_UUID.md create mode 100644 scripts/migrate-to-uuid.sql diff --git a/MIGRATION_UUID.md b/MIGRATION_UUID.md new file mode 100644 index 0000000..2c4af9e --- /dev/null +++ b/MIGRATION_UUID.md @@ -0,0 +1,116 @@ +# Migration vers UUIDs pour la sécurité + +## 🎯 Objectif + +Remplacer les user IDs séquentiels (1, 2, 3...) par des UUIDs pour éviter les attaques d'énumération. + +## ⚠️ Important + +Cette migration doit être effectuée quand l'application n'est pas en production ou pendant une maintenance. + +## 📋 Étapes à suivre + +### Scenario A : Nouvelle installation (recommandé) + +```bash +# 1. Supprimer l'ancienne DB si elle existe +dropdb peakskills + +# 2. Créer une nouvelle DB avec le nouveau schema UUID +createdb peakskills +psql -h localhost -U peakskills_user -d peakskills -f scripts/init.sql + +# 3. Migrer les données skills +npm run migrate-skills + +# 4. Démarrer l'app +npm run dev +``` + +### Scenario B : Migration base existante + +```bash +# 1. Se connecter à PostgreSQL +psql -h localhost -U peakskills_user -d peakskills + +# 2. Exécuter le script de migration +\i scripts/migrate-to-uuid.sql + +# 3. Vérifier la migration +SELECT id, uuid_id, first_name, last_name FROM users LIMIT 5; + +# 4. Redémarrer l'app +npm run dev +``` + +### 4. Nettoyer les anciennes sessions + +- Tous les utilisateurs devront se reconnecter (car les cookies utilisent maintenant des UUIDs) +- C'est normal et nécessaire pour la sécurité + +## 🔒 Sécurité apportée + +**Avant :** + +- Cookie: `peakSkills_userId=2` +- Facilement hackable: essayer 1, 3, 4, 5... + +**Après :** + +- Cookie: `peakSkills_userId=a1b2c3d4-e5f6-7890-abcd-ef1234567890` +- Impossible à deviner: UUID v4 avec 2^122 possibilités + +## 🚀 Tests à effectuer + +1. **Login nouveau utilisateur** + + - Créer un compte → doit générer un UUID + - Vérifier le cookie dans le navigateur + +2. **Utilisateur existant** + + - Se reconnecter → doit utiliser l'UUID existant + - Vérifier que les données sont préservées + +3. **Actions d'évaluation** + - Modifier une skill → doit fonctionner avec UUID + - Vérifier que les données sont sauvées + +## 📊 Migration status + +- ✅ **Code application complètement adapté aux UUIDs** +- ✅ Nouvelles méthodes `*Uuid()` dans EvaluationService +- ✅ Toutes les APIs modifiées (`/api/auth`, `/api/evaluations`, `/api/evaluations/skills`) +- ✅ Functions SSR adaptées (server-auth.ts) +- ✅ Middleware mis à jour +- ✅ Types AuthService corrigés +- ⏳ **À faire : Exécuter la migration DB** +- ⏳ **À faire : Tester en développement** +- ⏳ **À faire : Nettoyer le code legacy une fois validé** + +## 🔧 Fichiers modifiés + +### Services + +- `services/evaluation-service.ts` → Méthodes `*Uuid()` ajoutées +- `lib/server-auth.ts` → `getUserUuidFromCookie()` et adaptations +- `lib/auth-utils.ts` → Types de retour UUID + +### APIs + +- `app/api/auth/route.ts` → Cookies UUID +- `app/api/evaluations/route.ts` → `getUserByUuid()` +- `app/api/evaluations/skills/route.ts` → Toutes méthodes `*Uuid()` + +### Infrastructure + +- `middleware.ts` → Variables UUID +- `scripts/migrate-to-uuid.sql` → Schema DB migration (pour existants) +- `scripts/init.sql` → Schema DB initial avec UUIDs (pour nouvelles installs) + +## 🧹 Nettoyage futur + +Une fois la migration validée, supprimer : + +- Méthodes `upsertUser()` et `getUserById()` legacy +- Colonnes `id` et `user_id` dans les tables (garder seulement UUIDs) diff --git a/app/api/auth/route.ts b/app/api/auth/route.ts index 179c393..4165c37 100644 --- a/app/api/auth/route.ts +++ b/app/api/auth/route.ts @@ -12,14 +12,14 @@ const COOKIE_MAX_AGE = 30 * 24 * 60 * 60; // 30 jours export async function GET() { try { const cookieStore = await cookies(); - const userId = cookieStore.get(COOKIE_NAME)?.value; + const userUuid = cookieStore.get(COOKIE_NAME)?.value; - if (!userId) { + if (!userUuid) { return NextResponse.json({ user: null }, { status: 200 }); } const evaluationService = new EvaluationService(); - const userProfile = await evaluationService.getUserById(parseInt(userId)); + const userProfile = await evaluationService.getUserByUuid(userUuid); if (!userProfile) { // Cookie invalide, le supprimer @@ -44,7 +44,7 @@ export async function GET() { export async function POST(request: NextRequest) { try { const profile: UserProfile = await request.json(); - + if (!profile.firstName || !profile.lastName || !profile.teamId) { return NextResponse.json( { error: "Missing required fields" }, @@ -53,16 +53,19 @@ export async function POST(request: NextRequest) { } const evaluationService = new EvaluationService(); - const userId = await evaluationService.upsertUser(profile); + const userUuid = await evaluationService.upsertUserUuid(profile); // Créer la réponse avec le cookie - const response = NextResponse.json({ - user: { ...profile, id: userId }, - userId - }, { status: 200 }); + const response = NextResponse.json( + { + user: { ...profile, uuid: userUuid }, + userUuid, + }, + { status: 200 } + ); - // Définir le cookie avec l'ID utilisateur - response.cookies.set(COOKIE_NAME, userId.toString(), { + // Définir le cookie avec l'UUID utilisateur (plus sécurisé) + response.cookies.set(COOKIE_NAME, userUuid, { maxAge: COOKIE_MAX_AGE, httpOnly: true, secure: process.env.NODE_ENV === "production", @@ -90,9 +93,6 @@ export async function DELETE() { return response; } catch (error) { console.error("Error logging out user:", error); - return NextResponse.json( - { error: "Failed to logout" }, - { status: 500 } - ); + return NextResponse.json({ error: "Failed to logout" }, { status: 500 }); } } diff --git a/app/api/evaluations/route.ts b/app/api/evaluations/route.ts index d047d20..1e06dfd 100644 --- a/app/api/evaluations/route.ts +++ b/app/api/evaluations/route.ts @@ -7,11 +7,10 @@ import { COOKIE_NAME } from "@/lib/auth-utils"; export async function GET(request: NextRequest) { try { const cookieStore = await cookies(); - const userId = cookieStore.get(COOKIE_NAME)?.value; - const userIdNum = userId ? parseInt(userId) : null; + const userUuid = cookieStore.get(COOKIE_NAME)?.value; // Support pour l'ancien mode avec paramètres (pour la compatibilité) - if (!userIdNum) { + if (!userUuid) { const { searchParams } = new URL(request.url); const firstName = searchParams.get("firstName"); const lastName = searchParams.get("lastName"); @@ -29,8 +28,8 @@ export async function GET(request: NextRequest) { return NextResponse.json({ evaluation }); } - // Mode authentifié par cookie - const userProfile = await evaluationService.getUserById(userIdNum); + // Mode authentifié par cookie UUID + const userProfile = await evaluationService.getUserByUuid(userUuid); if (!userProfile) { return NextResponse.json( { error: "Utilisateur non trouvé" }, diff --git a/app/api/evaluations/skills/route.ts b/app/api/evaluations/skills/route.ts index 9c6ab9d..f034c09 100644 --- a/app/api/evaluations/skills/route.ts +++ b/app/api/evaluations/skills/route.ts @@ -7,18 +7,18 @@ const COOKIE_NAME = "peakSkills_userId"; export async function PUT(request: NextRequest) { try { - // Récupérer l'utilisateur depuis le cookie + // Récupérer l'utilisateur depuis le cookie (maintenant un UUID) const cookieStore = await cookies(); - const userId = cookieStore.get(COOKIE_NAME)?.value; + const userUuid = cookieStore.get(COOKIE_NAME)?.value; - if (!userId) { + if (!userUuid) { return NextResponse.json( { error: "Utilisateur non authentifié" }, { status: 401 } ); } - const userProfile = await evaluationService.getUserById(parseInt(userId)); + const userProfile = await evaluationService.getUserByUuid(userUuid); if (!userProfile) { return NextResponse.json( { error: "Utilisateur introuvable" }, @@ -44,7 +44,7 @@ export async function PUT(request: NextRequest) { { status: 400 } ); } - await evaluationService.updateSkillLevel( + await evaluationService.updateSkillLevelUuid( userProfile, category, skillId, @@ -59,7 +59,7 @@ export async function PUT(request: NextRequest) { { status: 400 } ); } - await evaluationService.updateSkillMentorStatus( + await evaluationService.updateSkillMentorStatusUuid( userProfile, category, skillId, @@ -74,7 +74,7 @@ export async function PUT(request: NextRequest) { { status: 400 } ); } - await evaluationService.updateSkillLearningStatus( + await evaluationService.updateSkillLearningStatusUuid( userProfile, category, skillId, @@ -83,7 +83,7 @@ export async function PUT(request: NextRequest) { break; case "addSkill": - await evaluationService.addSkillToEvaluation( + await evaluationService.addSkillToEvaluationUuid( userProfile, category, skillId @@ -91,7 +91,7 @@ export async function PUT(request: NextRequest) { break; case "removeSkill": - await evaluationService.removeSkillFromEvaluation( + await evaluationService.removeSkillFromEvaluationUuid( userProfile, category, skillId diff --git a/app/evaluation/page.tsx b/app/evaluation/page.tsx index 3eefae4..551e3f9 100644 --- a/app/evaluation/page.tsx +++ b/app/evaluation/page.tsx @@ -5,10 +5,7 @@ import { getServerSkillCategories, getServerTeams, } from "@/lib/server-auth"; -import { - EvaluationClientWrapper, - WelcomeEvaluationScreen, -} from "@/components/evaluation"; +import { EvaluationClientWrapper } from "@/components/evaluation"; import { SkillEvaluation } from "@/components/skill-evaluation"; export default async function EvaluationPage() { @@ -27,22 +24,14 @@ export default async function EvaluationPage() { getServerTeams(), ]); - // Si pas d'évaluation, afficher l'écran d'accueil évaluation - if (!userEvaluation) { - return ; - } - return (
{/* Skill Evaluation */} - {skillCategories.length > 0 && - userEvaluation.evaluations.length > 0 && ( - - )} +
); diff --git a/components/evaluation/client-wrapper.tsx b/components/evaluation/client-wrapper.tsx index c2b0d69..dab4a52 100644 --- a/components/evaluation/client-wrapper.tsx +++ b/components/evaluation/client-wrapper.tsx @@ -13,7 +13,7 @@ import { } from "@/lib/evaluation-actions"; interface EvaluationClientWrapperProps { - userEvaluation: UserEvaluation; + userEvaluation: UserEvaluation | null; teams: Team[]; children: React.ReactNode; } diff --git a/lib/auth-utils.ts b/lib/auth-utils.ts index 55ddf06..1639124 100644 --- a/lib/auth-utils.ts +++ b/lib/auth-utils.ts @@ -11,7 +11,7 @@ export class AuthService { */ static async login( profile: UserProfile - ): Promise<{ user: UserProfile & { id: number }; userId: number }> { + ): Promise<{ user: UserProfile & { uuid: string }; userUuid: string }> { const response = await fetch("/api/auth", { method: "POST", headers: { diff --git a/lib/server-auth.ts b/lib/server-auth.ts index 8f900ba..62a6b04 100644 --- a/lib/server-auth.ts +++ b/lib/server-auth.ts @@ -6,7 +6,21 @@ import { SkillsService } from "@/services/skills-service"; import { SkillCategory, Team } from "./types"; /** - * Récupère l'ID utilisateur depuis le cookie côté serveur + * Récupère l'UUID utilisateur depuis le cookie côté serveur + */ +export async function getUserUuidFromCookie(): Promise { + const cookieStore = await cookies(); + const userUuidCookie = cookieStore.get("peakSkills_userId"); + + if (!userUuidCookie?.value) { + return null; + } + + return userUuidCookie.value; +} + +/** + * Récupère l'ID utilisateur depuis le cookie côté serveur (legacy) */ export async function getUserIdFromCookie(): Promise { const cookieStore = await cookies(); @@ -16,6 +30,7 @@ export async function getUserIdFromCookie(): Promise { return null; } + // Essayer de parser comme number pour backward compatibility const userId = parseInt(userIdCookie.value); return isNaN(userId) ? null : userId; } @@ -24,17 +39,17 @@ export async function getUserIdFromCookie(): Promise { * Récupère l'évaluation complète de l'utilisateur côté serveur */ export async function getServerUserEvaluation() { - const userId = await getUserIdFromCookie(); + const userUuid = await getUserUuidFromCookie(); - if (!userId) { + if (!userUuid) { return null; } try { const evaluationService = new EvaluationService(); - // Récupérer d'abord le profil utilisateur - const userProfile = await evaluationService.getUserById(userId); + // Récupérer d'abord le profil utilisateur via UUID + const userProfile = await evaluationService.getUserByUuid(userUuid); if (!userProfile) { return null; @@ -80,6 +95,6 @@ export async function getServerTeams(): Promise { * Vérifie simplement si l'utilisateur est authentifié via le cookie */ export async function isUserAuthenticated(): Promise { - const userId = await getUserIdFromCookie(); - return !!userId; + const userUuid = await getUserUuidFromCookie(); + return !!userUuid; } diff --git a/middleware.ts b/middleware.ts index aef0438..d7fa263 100644 --- a/middleware.ts +++ b/middleware.ts @@ -23,15 +23,16 @@ export function middleware(request: NextRequest) { if ( pathname.includes("/_next/") || pathname.includes("/favicon.ico") || - pathname.includes("/public/") + pathname.includes("/public/") || + pathname.includes("/api/skills/migrate") ) { return NextResponse.next(); } - // Vérifier le cookie d'authentification - const userId = request.cookies.get(COOKIE_NAME)?.value; + // Vérifier le cookie d'authentification (maintenant un UUID) + const userUuid = request.cookies.get(COOKIE_NAME)?.value; - if (!userId) { + if (!userUuid) { // Rediriger vers la page de login si pas authentifié const loginUrl = new URL("/login", request.url); return NextResponse.redirect(loginUrl); diff --git a/scripts/init.sql b/scripts/init.sql index e1274a0..0222be8 100644 --- a/scripts/init.sql +++ b/scripts/init.sql @@ -1,3 +1,6 @@ +-- Enable UUID extension for secure user identification +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + -- Create enum for skill levels CREATE TYPE skill_level_enum AS ENUM ('never', 'not-autonomous', 'autonomous', 'expert'); @@ -38,15 +41,19 @@ CREATE TABLE skill_links ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); --- Users table +-- Users table with UUID primary key for security +-- UUIDs prevent user enumeration attacks (vs sequential IDs 1,2,3...) CREATE TABLE users ( - id SERIAL PRIMARY KEY, + id SERIAL, -- Legacy ID kept for backward compatibility during migration + uuid_id UUID DEFAULT uuid_generate_v4() NOT NULL, first_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL, team_id VARCHAR(50) REFERENCES teams(id), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(first_name, last_name, team_id) + PRIMARY KEY (uuid_id), + UNIQUE(first_name, last_name, team_id), + UNIQUE(uuid_id) ); -- ======================================== @@ -56,10 +63,11 @@ CREATE TABLE users ( -- User evaluations - metadata about user's evaluation session CREATE TABLE user_evaluations ( id SERIAL PRIMARY KEY, - user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + user_id INTEGER, -- Legacy column kept for backward compatibility + user_uuid UUID REFERENCES users(uuid_id) ON DELETE CASCADE, last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(user_id) + UNIQUE(user_uuid) ); -- Skill evaluations - direct relationship between user and skill @@ -93,7 +101,9 @@ CREATE INDEX idx_skills_category_id ON skills(category_id); CREATE INDEX idx_skill_links_skill_id ON skill_links(skill_id); CREATE INDEX idx_users_team_id ON users(team_id); CREATE INDEX idx_users_unique_person ON users(first_name, last_name, team_id); -CREATE INDEX idx_user_evaluations_user_id ON user_evaluations(user_id); +CREATE INDEX idx_users_uuid_id ON users(uuid_id); -- Index on UUID for performance +CREATE INDEX idx_user_evaluations_user_uuid ON user_evaluations(user_uuid); +CREATE INDEX idx_user_evaluations_user_id ON user_evaluations(user_id); -- Legacy index CREATE INDEX idx_skill_evaluations_user_evaluation_id ON skill_evaluations(user_evaluation_id); CREATE INDEX idx_skill_evaluations_skill_id ON skill_evaluations(skill_id); CREATE INDEX idx_skill_evaluations_is_selected ON skill_evaluations(is_selected); @@ -138,7 +148,7 @@ SELECT se.updated_at FROM users u JOIN teams t ON u.team_id = t.id -JOIN user_evaluations ue ON u.id = ue.user_id +JOIN user_evaluations ue ON u.uuid_id = ue.user_uuid JOIN skill_evaluations se ON ue.id = se.user_evaluation_id JOIN skills s ON se.skill_id = s.id JOIN skill_categories sc ON s.category_id = sc.id; diff --git a/scripts/migrate-to-uuid.sql b/scripts/migrate-to-uuid.sql new file mode 100644 index 0000000..664f5d8 --- /dev/null +++ b/scripts/migrate-to-uuid.sql @@ -0,0 +1,40 @@ +-- Migration script: Replace sequential user IDs with UUIDs for security +-- This prevents enumeration attacks and improves security + +-- Step 1: Enable UUID extension if not already enabled +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Step 2: Add new UUID column to users table +ALTER TABLE users ADD COLUMN uuid_id UUID DEFAULT uuid_generate_v4(); + +-- Step 3: Update all existing users to have UUIDs (they will be auto-generated) +UPDATE users SET uuid_id = uuid_generate_v4() WHERE uuid_id IS NULL; + +-- Step 4: Make UUID column NOT NULL +ALTER TABLE users ALTER COLUMN uuid_id SET NOT NULL; + +-- Step 5: Add new UUID column to user_evaluations table +ALTER TABLE user_evaluations ADD COLUMN user_uuid UUID; + +-- Step 6: Update user_evaluations to use the new UUIDs +UPDATE user_evaluations +SET user_uuid = users.uuid_id +FROM users +WHERE user_evaluations.user_id = users.id; + +-- Step 7: Make user_uuid NOT NULL +ALTER TABLE user_evaluations ALTER COLUMN user_uuid SET NOT NULL; + +-- Step 8: Add new UUID column to skill_evaluations (via user_evaluations) +-- No direct change needed as skill_evaluations references user_evaluations.id + +-- Step 9: Create unique constraint on UUID +ALTER TABLE users ADD CONSTRAINT users_uuid_unique UNIQUE (uuid_id); + +-- Step 10: Add unique constraint and foreign key for user_evaluations +ALTER TABLE user_evaluations ADD CONSTRAINT user_evaluations_user_uuid_unique UNIQUE (user_uuid); +ALTER TABLE user_evaluations ADD CONSTRAINT fk_user_evaluations_user_uuid + FOREIGN KEY (user_uuid) REFERENCES users(uuid_id); + +-- Note: The actual switchover will be done in the application code +-- The old id columns will be kept temporarily for backward compatibility diff --git a/services/evaluation-service.ts b/services/evaluation-service.ts index 4e4a480..bdb5646 100644 --- a/services/evaluation-service.ts +++ b/services/evaluation-service.ts @@ -9,7 +9,82 @@ import { export class EvaluationService { /** - * Crée ou met à jour un utilisateur + * Crée ou met à jour un utilisateur et retourne son UUID + */ + async upsertUserUuid(profile: UserProfile): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + // Vérifier si l'utilisateur existe déjà (par firstName + lastName + teamId) + const existingUserQuery = ` + SELECT uuid_id FROM users + WHERE first_name = $1 AND last_name = $2 AND team_id = $3 + `; + + const existingUser = await client.query(existingUserQuery, [ + profile.firstName, + profile.lastName, + profile.teamId, + ]); + + if (existingUser.rows.length > 0) { + // Retourner l'UUID de l'utilisateur existant + return existingUser.rows[0].uuid_id; + } else { + // Créer un nouvel utilisateur avec UUID auto-généré + const insertQuery = ` + INSERT INTO users (first_name, last_name, team_id, uuid_id) + VALUES ($1, $2, $3, uuid_generate_v4()) + RETURNING uuid_id + `; + + const result = await client.query(insertQuery, [ + profile.firstName, + profile.lastName, + profile.teamId, + ]); + + return result.rows[0].uuid_id; + } + } finally { + client.release(); + } + } + + /** + * Récupère un utilisateur par son UUID + */ + async getUserByUuid(userUuid: string): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + const query = ` + SELECT u.first_name, u.last_name, u.team_id + FROM users u + WHERE u.uuid_id = $1 + `; + + const result = await client.query(query, [userUuid]); + + if (result.rows.length === 0) { + return null; + } + + const user = result.rows[0]; + return { + firstName: user.first_name, + lastName: user.last_name, + teamId: user.team_id, + }; + } finally { + client.release(); + } + } + + /** + * Crée ou met à jour un utilisateur (legacy - retourne l'ID numérique) */ async upsertUser(profile: UserProfile): Promise { const pool = getPool(); @@ -98,7 +173,50 @@ export class EvaluationService { } /** - * Sauvegarde une évaluation utilisateur complète + * Sauvegarde une évaluation utilisateur complète (version UUID) + */ + async saveUserEvaluationUuid(evaluation: UserEvaluation): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + // 1. Upsert user avec UUID + const userUuid = await this.upsertUserUuid(evaluation.profile); + + // 2. Upsert user_evaluation avec user_uuid + const userEvalQuery = ` + INSERT INTO user_evaluations (user_uuid, last_updated) + VALUES ($1, $2) + ON CONFLICT (user_uuid) + DO UPDATE SET last_updated = $2 + RETURNING id + `; + + const userEvalResult = await client.query(userEvalQuery, [ + userUuid, + new Date(evaluation.lastUpdated), + ]); + + const userEvaluationId = userEvalResult.rows[0].id; + + // 3. Sauvegarde chaque catégorie d'évaluation + for (const catEval of evaluation.evaluations) { + await this.saveSkillEvaluations(client, userEvaluationId, catEval); + } + + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + /** + * Sauvegarde une évaluation utilisateur complète (legacy) */ async saveUserEvaluation(evaluation: UserEvaluation): Promise { const pool = getPool(); @@ -208,7 +326,7 @@ export class EvaluationService { const userQuery = ` SELECT u.*, ue.id as user_evaluation_id, ue.last_updated FROM users u - LEFT JOIN user_evaluations ue ON u.id = ue.user_id + LEFT JOIN user_evaluations ue ON u.uuid_id = ue.user_uuid WHERE u.first_name = $1 AND u.last_name = $2 AND u.team_id = $3 `; @@ -293,7 +411,128 @@ export class EvaluationService { } /** - * Met à jour le niveau d'une skill + * Met à jour le niveau d'une skill (version UUID) + */ + async updateSkillLevelUuid( + profile: UserProfile, + category: string, + skillId: string, + level: SkillLevel + ): Promise { + await this.updateSkillPropertyUuid( + profile, + category, + skillId, + "level", + level + ); + } + + /** + * Met à jour le statut mentor d'une skill (version UUID) + */ + async updateSkillMentorStatusUuid( + profile: UserProfile, + category: string, + skillId: string, + canMentor: boolean + ): Promise { + await this.updateSkillPropertyUuid( + profile, + category, + skillId, + "can_mentor", + canMentor + ); + } + + /** + * Met à jour le statut apprentissage d'une skill (version UUID) + */ + async updateSkillLearningStatusUuid( + profile: UserProfile, + category: string, + skillId: string, + wantsToLearn: boolean + ): Promise { + await this.updateSkillPropertyUuid( + profile, + category, + skillId, + "wants_to_learn", + wantsToLearn + ); + } + + /** + * Méthode utilitaire pour mettre à jour une propriété de skill (version UUID) + */ + private async updateSkillPropertyUuid( + profile: UserProfile, + category: string, + skillId: string, + property: string, + value: any + ): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const userUuid = await this.upsertUserUuid(profile); + + // Upsert user_evaluation avec user_uuid + const userEvalResult = await client.query( + ` + INSERT INTO user_evaluations (user_uuid, last_updated) + VALUES ($1, CURRENT_TIMESTAMP) + ON CONFLICT (user_uuid) + DO UPDATE SET last_updated = CURRENT_TIMESTAMP + RETURNING id + `, + [userUuid] + ); + + const userEvaluationId = userEvalResult.rows[0].id; + + // Upsert skill evaluation avec gestion conditionnelle du level + let updateQuery: string; + let queryParams: any[]; + + if (property === "level") { + // Si on met à jour le level, utiliser la valeur fournie + updateQuery = ` + INSERT INTO skill_evaluations (user_evaluation_id, skill_id, level, is_selected) + VALUES ($1, $2, $3, true) + ON CONFLICT (user_evaluation_id, skill_id) + DO UPDATE SET level = $3, is_selected = true + `; + queryParams = [userEvaluationId, skillId, value]; + } else { + // Si on met à jour une autre propriété, level par défaut = 'never' + updateQuery = ` + INSERT INTO skill_evaluations (user_evaluation_id, skill_id, level, ${property}, is_selected) + VALUES ($1, $2, 'never', $3, true) + ON CONFLICT (user_evaluation_id, skill_id) + DO UPDATE SET ${property} = $3, is_selected = true + `; + queryParams = [userEvaluationId, skillId, value]; + } + + await client.query(updateQuery, queryParams); + + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + /** + * Met à jour le niveau d'une skill (legacy) */ async updateSkillLevel( profile: UserProfile, @@ -407,7 +646,60 @@ export class EvaluationService { } /** - * Ajoute une skill à l'évaluation + * Ajoute une skill à l'évaluation (version UUID) + */ + async addSkillToEvaluationUuid( + profile: UserProfile, + category: string, + skillId: string + ): Promise { + await this.updateSkillPropertyUuid( + profile, + category, + skillId, + "level", + "never" // Valeur par défaut pour une skill nouvellement sélectionnée + ); + } + + /** + * Supprime une skill de l'évaluation (version UUID) + */ + async removeSkillFromEvaluationUuid( + profile: UserProfile, + category: string, + skillId: string + ): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const userUuid = await this.upsertUserUuid(profile); + + // Supprimer directement la skill evaluation + const deleteQuery = ` + DELETE FROM skill_evaluations + WHERE user_evaluation_id = ( + SELECT id FROM user_evaluations WHERE user_uuid = $1 + ) + AND skill_id = $2 + `; + + await client.query(deleteQuery, [userUuid, skillId]); + + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + /** + * Ajoute une skill à l'évaluation (legacy) */ async addSkillToEvaluation( profile: UserProfile,