feat: secu migrate to user uuid

This commit is contained in:
Julien Froidefond
2025-08-21 13:54:13 +02:00
parent ef16c73625
commit 578f0858e8
12 changed files with 532 additions and 70 deletions

116
MIGRATION_UUID.md Normal file
View File

@@ -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)

View File

@@ -12,14 +12,14 @@ const COOKIE_MAX_AGE = 30 * 24 * 60 * 60; // 30 jours
export async function GET() { export async function GET() {
try { try {
const cookieStore = await cookies(); 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 }); return NextResponse.json({ user: null }, { status: 200 });
} }
const evaluationService = new EvaluationService(); const evaluationService = new EvaluationService();
const userProfile = await evaluationService.getUserById(parseInt(userId)); const userProfile = await evaluationService.getUserByUuid(userUuid);
if (!userProfile) { if (!userProfile) {
// Cookie invalide, le supprimer // Cookie invalide, le supprimer
@@ -53,16 +53,19 @@ export async function POST(request: NextRequest) {
} }
const evaluationService = new EvaluationService(); const evaluationService = new EvaluationService();
const userId = await evaluationService.upsertUser(profile); const userUuid = await evaluationService.upsertUserUuid(profile);
// Créer la réponse avec le cookie // Créer la réponse avec le cookie
const response = NextResponse.json({ const response = NextResponse.json(
user: { ...profile, id: userId }, {
userId user: { ...profile, uuid: userUuid },
}, { status: 200 }); userUuid,
},
{ status: 200 }
);
// Définir le cookie avec l'ID utilisateur // Définir le cookie avec l'UUID utilisateur (plus sécurisé)
response.cookies.set(COOKIE_NAME, userId.toString(), { response.cookies.set(COOKIE_NAME, userUuid, {
maxAge: COOKIE_MAX_AGE, maxAge: COOKIE_MAX_AGE,
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
@@ -90,9 +93,6 @@ export async function DELETE() {
return response; return response;
} catch (error) { } catch (error) {
console.error("Error logging out user:", error); console.error("Error logging out user:", error);
return NextResponse.json( return NextResponse.json({ error: "Failed to logout" }, { status: 500 });
{ error: "Failed to logout" },
{ status: 500 }
);
} }
} }

View File

@@ -7,11 +7,10 @@ import { COOKIE_NAME } from "@/lib/auth-utils";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const cookieStore = await cookies(); const cookieStore = await cookies();
const userId = cookieStore.get(COOKIE_NAME)?.value; const userUuid = cookieStore.get(COOKIE_NAME)?.value;
const userIdNum = userId ? parseInt(userId) : null;
// Support pour l'ancien mode avec paramètres (pour la compatibilité) // Support pour l'ancien mode avec paramètres (pour la compatibilité)
if (!userIdNum) { if (!userUuid) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const firstName = searchParams.get("firstName"); const firstName = searchParams.get("firstName");
const lastName = searchParams.get("lastName"); const lastName = searchParams.get("lastName");
@@ -29,8 +28,8 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ evaluation }); return NextResponse.json({ evaluation });
} }
// Mode authentifié par cookie // Mode authentifié par cookie UUID
const userProfile = await evaluationService.getUserById(userIdNum); const userProfile = await evaluationService.getUserByUuid(userUuid);
if (!userProfile) { if (!userProfile) {
return NextResponse.json( return NextResponse.json(
{ error: "Utilisateur non trouvé" }, { error: "Utilisateur non trouvé" },

View File

@@ -7,18 +7,18 @@ const COOKIE_NAME = "peakSkills_userId";
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
try { 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 cookieStore = await cookies();
const userId = cookieStore.get(COOKIE_NAME)?.value; const userUuid = cookieStore.get(COOKIE_NAME)?.value;
if (!userId) { if (!userUuid) {
return NextResponse.json( return NextResponse.json(
{ error: "Utilisateur non authentifié" }, { error: "Utilisateur non authentifié" },
{ status: 401 } { status: 401 }
); );
} }
const userProfile = await evaluationService.getUserById(parseInt(userId)); const userProfile = await evaluationService.getUserByUuid(userUuid);
if (!userProfile) { if (!userProfile) {
return NextResponse.json( return NextResponse.json(
{ error: "Utilisateur introuvable" }, { error: "Utilisateur introuvable" },
@@ -44,7 +44,7 @@ export async function PUT(request: NextRequest) {
{ status: 400 } { status: 400 }
); );
} }
await evaluationService.updateSkillLevel( await evaluationService.updateSkillLevelUuid(
userProfile, userProfile,
category, category,
skillId, skillId,
@@ -59,7 +59,7 @@ export async function PUT(request: NextRequest) {
{ status: 400 } { status: 400 }
); );
} }
await evaluationService.updateSkillMentorStatus( await evaluationService.updateSkillMentorStatusUuid(
userProfile, userProfile,
category, category,
skillId, skillId,
@@ -74,7 +74,7 @@ export async function PUT(request: NextRequest) {
{ status: 400 } { status: 400 }
); );
} }
await evaluationService.updateSkillLearningStatus( await evaluationService.updateSkillLearningStatusUuid(
userProfile, userProfile,
category, category,
skillId, skillId,
@@ -83,7 +83,7 @@ export async function PUT(request: NextRequest) {
break; break;
case "addSkill": case "addSkill":
await evaluationService.addSkillToEvaluation( await evaluationService.addSkillToEvaluationUuid(
userProfile, userProfile,
category, category,
skillId skillId
@@ -91,7 +91,7 @@ export async function PUT(request: NextRequest) {
break; break;
case "removeSkill": case "removeSkill":
await evaluationService.removeSkillFromEvaluation( await evaluationService.removeSkillFromEvaluationUuid(
userProfile, userProfile,
category, category,
skillId skillId

View File

@@ -5,10 +5,7 @@ import {
getServerSkillCategories, getServerSkillCategories,
getServerTeams, getServerTeams,
} from "@/lib/server-auth"; } from "@/lib/server-auth";
import { import { EvaluationClientWrapper } from "@/components/evaluation";
EvaluationClientWrapper,
WelcomeEvaluationScreen,
} from "@/components/evaluation";
import { SkillEvaluation } from "@/components/skill-evaluation"; import { SkillEvaluation } from "@/components/skill-evaluation";
export default async function EvaluationPage() { export default async function EvaluationPage() {
@@ -27,22 +24,14 @@ export default async function EvaluationPage() {
getServerTeams(), getServerTeams(),
]); ]);
// Si pas d'évaluation, afficher l'écran d'accueil évaluation
if (!userEvaluation) {
return <WelcomeEvaluationScreen teams={teams} />;
}
return ( return (
<EvaluationClientWrapper userEvaluation={userEvaluation} teams={teams}> <EvaluationClientWrapper userEvaluation={userEvaluation} teams={teams}>
<div> <div>
{/* Skill Evaluation */} {/* Skill Evaluation */}
{skillCategories.length > 0 &&
userEvaluation.evaluations.length > 0 && (
<SkillEvaluation <SkillEvaluation
categories={skillCategories} categories={skillCategories}
evaluations={userEvaluation.evaluations} evaluations={userEvaluation?.evaluations || []}
/> />
)}
</div> </div>
</EvaluationClientWrapper> </EvaluationClientWrapper>
); );

View File

@@ -13,7 +13,7 @@ import {
} from "@/lib/evaluation-actions"; } from "@/lib/evaluation-actions";
interface EvaluationClientWrapperProps { interface EvaluationClientWrapperProps {
userEvaluation: UserEvaluation; userEvaluation: UserEvaluation | null;
teams: Team[]; teams: Team[];
children: React.ReactNode; children: React.ReactNode;
} }

View File

@@ -11,7 +11,7 @@ export class AuthService {
*/ */
static async login( static async login(
profile: UserProfile profile: UserProfile
): Promise<{ user: UserProfile & { id: number }; userId: number }> { ): Promise<{ user: UserProfile & { uuid: string }; userUuid: string }> {
const response = await fetch("/api/auth", { const response = await fetch("/api/auth", {
method: "POST", method: "POST",
headers: { headers: {

View File

@@ -6,7 +6,21 @@ import { SkillsService } from "@/services/skills-service";
import { SkillCategory, Team } from "./types"; 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<string | null> {
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<number | null> { export async function getUserIdFromCookie(): Promise<number | null> {
const cookieStore = await cookies(); const cookieStore = await cookies();
@@ -16,6 +30,7 @@ export async function getUserIdFromCookie(): Promise<number | null> {
return null; return null;
} }
// Essayer de parser comme number pour backward compatibility
const userId = parseInt(userIdCookie.value); const userId = parseInt(userIdCookie.value);
return isNaN(userId) ? null : userId; return isNaN(userId) ? null : userId;
} }
@@ -24,17 +39,17 @@ export async function getUserIdFromCookie(): Promise<number | null> {
* Récupère l'évaluation complète de l'utilisateur côté serveur * Récupère l'évaluation complète de l'utilisateur côté serveur
*/ */
export async function getServerUserEvaluation() { export async function getServerUserEvaluation() {
const userId = await getUserIdFromCookie(); const userUuid = await getUserUuidFromCookie();
if (!userId) { if (!userUuid) {
return null; return null;
} }
try { try {
const evaluationService = new EvaluationService(); const evaluationService = new EvaluationService();
// Récupérer d'abord le profil utilisateur // Récupérer d'abord le profil utilisateur via UUID
const userProfile = await evaluationService.getUserById(userId); const userProfile = await evaluationService.getUserByUuid(userUuid);
if (!userProfile) { if (!userProfile) {
return null; return null;
@@ -80,6 +95,6 @@ export async function getServerTeams(): Promise<Team[]> {
* Vérifie simplement si l'utilisateur est authentifié via le cookie * Vérifie simplement si l'utilisateur est authentifié via le cookie
*/ */
export async function isUserAuthenticated(): Promise<boolean> { export async function isUserAuthenticated(): Promise<boolean> {
const userId = await getUserIdFromCookie(); const userUuid = await getUserUuidFromCookie();
return !!userId; return !!userUuid;
} }

View File

@@ -23,15 +23,16 @@ export function middleware(request: NextRequest) {
if ( if (
pathname.includes("/_next/") || pathname.includes("/_next/") ||
pathname.includes("/favicon.ico") || pathname.includes("/favicon.ico") ||
pathname.includes("/public/") pathname.includes("/public/") ||
pathname.includes("/api/skills/migrate")
) { ) {
return NextResponse.next(); return NextResponse.next();
} }
// Vérifier le cookie d'authentification // Vérifier le cookie d'authentification (maintenant un UUID)
const userId = request.cookies.get(COOKIE_NAME)?.value; const userUuid = request.cookies.get(COOKIE_NAME)?.value;
if (!userId) { if (!userUuid) {
// Rediriger vers la page de login si pas authentifié // Rediriger vers la page de login si pas authentifié
const loginUrl = new URL("/login", request.url); const loginUrl = new URL("/login", request.url);
return NextResponse.redirect(loginUrl); return NextResponse.redirect(loginUrl);

View File

@@ -1,3 +1,6 @@
-- Enable UUID extension for secure user identification
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Create enum for skill levels -- Create enum for skill levels
CREATE TYPE skill_level_enum AS ENUM ('never', 'not-autonomous', 'autonomous', 'expert'); 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 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 ( 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, first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL,
team_id VARCHAR(50) REFERENCES teams(id), team_id VARCHAR(50) REFERENCES teams(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_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 -- User evaluations - metadata about user's evaluation session
CREATE TABLE user_evaluations ( CREATE TABLE user_evaluations (
id SERIAL PRIMARY KEY, 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, last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id) UNIQUE(user_uuid)
); );
-- Skill evaluations - direct relationship between user and skill -- 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_skill_links_skill_id ON skill_links(skill_id);
CREATE INDEX idx_users_team_id ON users(team_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_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_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_skill_id ON skill_evaluations(skill_id);
CREATE INDEX idx_skill_evaluations_is_selected ON skill_evaluations(is_selected); CREATE INDEX idx_skill_evaluations_is_selected ON skill_evaluations(is_selected);
@@ -138,7 +148,7 @@ SELECT
se.updated_at se.updated_at
FROM users u FROM users u
JOIN teams t ON u.team_id = t.id 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 skill_evaluations se ON ue.id = se.user_evaluation_id
JOIN skills s ON se.skill_id = s.id JOIN skills s ON se.skill_id = s.id
JOIN skill_categories sc ON s.category_id = sc.id; JOIN skill_categories sc ON s.category_id = sc.id;

View File

@@ -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

View File

@@ -9,7 +9,82 @@ import {
export class EvaluationService { 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<string> {
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<UserProfile | null> {
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<number> { async upsertUser(profile: UserProfile): Promise<number> {
const pool = getPool(); 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<void> {
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<void> { async saveUserEvaluation(evaluation: UserEvaluation): Promise<void> {
const pool = getPool(); const pool = getPool();
@@ -208,7 +326,7 @@ export class EvaluationService {
const userQuery = ` const userQuery = `
SELECT u.*, ue.id as user_evaluation_id, ue.last_updated SELECT u.*, ue.id as user_evaluation_id, ue.last_updated
FROM users u 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 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<void> {
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<void> {
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<void> {
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<void> {
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( async updateSkillLevel(
profile: UserProfile, 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<void> {
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<void> {
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( async addSkillToEvaluation(
profile: UserProfile, profile: UserProfile,