diff --git a/DATABASE_SETUP.md b/DATABASE_SETUP.md new file mode 100644 index 0000000..a2742a8 --- /dev/null +++ b/DATABASE_SETUP.md @@ -0,0 +1,105 @@ +# Configuration PostgreSQL pour PeakSkills + +## Setup de développement + +### 1. Installation des dépendances + +```bash +pnpm install +``` + +### 2. Configuration de l'environnement + +Créer un fichier `.env.local` à la racine du projet : + +```env +# Database configuration +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=peakskills +DB_USER=peakskills_user +DB_PASSWORD=peakskills_password + +# API configuration (optional, defaults to empty string for same-origin requests) +NEXT_PUBLIC_API_URL= +``` + +### 3. Démarrage de PostgreSQL avec Docker + +```bash +# Démarrer la base de données +docker-compose up -d postgres + +# Vérifier que la base est bien démarrée +docker-compose ps +``` + +### 4. Démarrage de l'application + +```bash +pnpm dev +``` + +## Architecture + +### Base de données + +La base de données PostgreSQL contient les tables suivantes : + +- `users` : Informations utilisateur (nom, prénom, équipe) +- `user_evaluations` : Métadonnées des évaluations utilisateur +- `category_evaluations` : Évaluations par catégorie +- `selected_skills` : Skills sélectionnées pour évaluation +- `skill_evaluations` : Évaluations détaillées des skills + +### API + +Les endpoints suivants sont disponibles : + +- `GET /api/evaluations` : Charger une évaluation utilisateur +- `POST /api/evaluations` : Sauvegarder une évaluation complète +- `PUT /api/evaluations/skills` : Mettre à jour une skill spécifique + +### Services + +Le dossier `services/` contient tous les services backend : + +- `database.ts` : Configuration et pool de connexions PostgreSQL (server-only) +- `evaluation-service.ts` : Service backend pour interfacer avec PostgreSQL (server-only) +- `api-client.ts` : Client pour les appels API depuis le frontend (client-safe) +- `index.ts` : Exports server-side pour les API routes +- `client.ts` : Exports client-safe pour les composants React + +**Important** : + +- Utiliser `@/services/client` dans les composants React et hooks +- Utiliser `@/services` ou imports directs dans les API routes + +Le hook `useEvaluation` (dans `hooks/`) utilise `api-client` pour la persistance. + +## Migration depuis localStorage + +Les données sont maintenant persistées en base PostgreSQL au lieu du localStorage. Le hook `useEvaluation` a été mis à jour pour : + +1. Charger les évaluations depuis l'API +2. Effectuer des mises à jour optimistes pour une meilleure UX +3. Synchroniser avec la base de données + +## Commandes utiles + +```bash +# Redémarrer la base de données +docker-compose restart postgres + +# Voir les logs de la base +docker-compose logs postgres + +# Se connecter à la base +docker-compose exec postgres psql -U peakskills_user -d peakskills + +# Arrêter tous les services +docker-compose down + +# Arrêter et supprimer les volumes (reset complet) +docker-compose down -v +``` diff --git a/app/api/evaluations/route.ts b/app/api/evaluations/route.ts new file mode 100644 index 0000000..cf9e8dc --- /dev/null +++ b/app/api/evaluations/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import { evaluationService } from "@/services/evaluation-service"; +import { UserEvaluation, UserProfile } from "@/lib/types"; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const firstName = searchParams.get("firstName"); + const lastName = searchParams.get("lastName"); + const teamId = searchParams.get("teamId"); + + if (!firstName || !lastName || !teamId) { + return NextResponse.json( + { error: "firstName, lastName et teamId sont requis" }, + { status: 400 } + ); + } + + const profile: UserProfile = { firstName, lastName, teamId }; + const evaluation = await evaluationService.loadUserEvaluation(profile); + + return NextResponse.json({ evaluation }); + } catch (error) { + console.error("Erreur lors du chargement de l'évaluation:", error); + return NextResponse.json( + { error: "Erreur interne du serveur" }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const evaluation: UserEvaluation = body.evaluation; + + if (!evaluation || !evaluation.profile) { + return NextResponse.json( + { error: "Évaluation invalide" }, + { status: 400 } + ); + } + + await evaluationService.saveUserEvaluation(evaluation); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Erreur lors de la sauvegarde de l'évaluation:", error); + return NextResponse.json( + { error: "Erreur interne du serveur" }, + { status: 500 } + ); + } +} diff --git a/app/api/evaluations/skills/route.ts b/app/api/evaluations/skills/route.ts new file mode 100644 index 0000000..eaf37ff --- /dev/null +++ b/app/api/evaluations/skills/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from "next/server"; +import { evaluationService } from "@/services/evaluation-service"; +import { UserProfile, SkillLevel } from "@/lib/types"; + +export async function PUT(request: NextRequest) { + try { + const body = await request.json(); + const { + profile, + category, + skillId, + level, + canMentor, + wantsToLearn, + action, + } = body; + + if (!profile || !category || !skillId) { + return NextResponse.json( + { error: "profile, category et skillId sont requis" }, + { status: 400 } + ); + } + + const userProfile: UserProfile = profile; + + switch (action) { + case "updateLevel": + if (level === undefined) { + return NextResponse.json( + { error: "level est requis pour updateLevel" }, + { status: 400 } + ); + } + await evaluationService.updateSkillLevel( + userProfile, + category, + skillId, + level + ); + break; + + case "updateMentorStatus": + if (canMentor === undefined) { + return NextResponse.json( + { error: "canMentor est requis pour updateMentorStatus" }, + { status: 400 } + ); + } + await evaluationService.updateSkillMentorStatus( + userProfile, + category, + skillId, + canMentor + ); + break; + + case "updateLearningStatus": + if (wantsToLearn === undefined) { + return NextResponse.json( + { error: "wantsToLearn est requis pour updateLearningStatus" }, + { status: 400 } + ); + } + await evaluationService.updateSkillLearningStatus( + userProfile, + category, + skillId, + wantsToLearn + ); + break; + + case "addSkill": + await evaluationService.addSkillToEvaluation( + userProfile, + category, + skillId + ); + break; + + case "removeSkill": + await evaluationService.removeSkillFromEvaluation( + userProfile, + category, + skillId + ); + break; + + default: + return NextResponse.json( + { error: "Action non supportée" }, + { status: 400 } + ); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Erreur lors de la mise à jour de la skill:", error); + return NextResponse.json( + { error: "Erreur interne du serveur" }, + { status: 500 } + ); + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4b2d42a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +version: "3.8" + +services: + postgres: + image: postgres:15 + environment: + POSTGRES_DB: peakskills + POSTGRES_USER: peakskills_user + POSTGRES_PASSWORD: peakskills_password + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U peakskills_user -d peakskills"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres_data: diff --git a/hooks/use-evaluation.ts b/hooks/use-evaluation.ts index 5430ab0..9bd229a 100644 --- a/hooks/use-evaluation.ts +++ b/hooks/use-evaluation.ts @@ -14,6 +14,7 @@ import { saveUserEvaluation, createEmptyEvaluation, } from "@/lib/evaluation-utils"; +import { apiClient } from "@/services/api-client"; import { loadSkillCategories, loadTeams } from "@/lib/data-loader"; // Fonction pour migrer une évaluation existante avec de nouvelles catégories @@ -70,13 +71,25 @@ export function useEvaluation() { setSkillCategories(categories); setTeams(teamsData); - // Try to load existing evaluation - const saved = loadUserEvaluation(); - if (saved) { - // Migrate evaluation to include new categories if needed - const migratedEvaluation = migrateEvaluation(saved, categories); - setUserEvaluation(migratedEvaluation); - saveUserEvaluation(migratedEvaluation); // Save the migrated version + // Try to load user profile from localStorage and then load evaluation from API + try { + const savedProfile = localStorage.getItem("peakSkills_userProfile"); + if (savedProfile) { + const profile: UserProfile = JSON.parse(savedProfile); + const saved = await loadUserEvaluation(profile); + if (saved) { + // Migrate evaluation to include new categories if needed + const migratedEvaluation = migrateEvaluation(saved, categories); + setUserEvaluation(migratedEvaluation); + if (migratedEvaluation !== saved) { + await saveUserEvaluation(migratedEvaluation); // Save the migrated version + } + } + } + } catch (profileError) { + console.error("Failed to load user profile:", profileError); + // Clear invalid profile data + localStorage.removeItem("peakSkills_userProfile"); } } catch (error) { console.error("Failed to initialize data:", error); @@ -88,7 +101,35 @@ export function useEvaluation() { initializeData(); }, []); - const updateProfile = (profile: UserProfile) => { + const loadEvaluationForProfile = async (profile: UserProfile) => { + try { + setLoading(true); + const saved = await loadUserEvaluation(profile); + if (saved) { + // Migrate evaluation to include new categories if needed + const migratedEvaluation = migrateEvaluation(saved, skillCategories); + setUserEvaluation(migratedEvaluation); + if (migratedEvaluation !== saved) { + await saveUserEvaluation(migratedEvaluation); // Save the migrated version + } + } else { + // Create new evaluation + const evaluations = createEmptyEvaluation(skillCategories); + const newEvaluation: UserEvaluation = { + profile, + evaluations, + lastUpdated: new Date().toISOString(), + }; + setUserEvaluation(newEvaluation); + } + } catch (error) { + console.error("Failed to load evaluation for profile:", error); + } finally { + setLoading(false); + } + }; + + const updateProfile = async (profile: UserProfile) => { const evaluations = userEvaluation?.evaluations || createEmptyEvaluation(skillCategories); const newEvaluation: UserEvaluation = { @@ -97,159 +138,214 @@ export function useEvaluation() { lastUpdated: new Date().toISOString(), }; + // Save profile to localStorage for auto-login + localStorage.setItem("peakSkills_userProfile", JSON.stringify(profile)); + setUserEvaluation(newEvaluation); - saveUserEvaluation(newEvaluation); + await saveUserEvaluation(newEvaluation); }; - const updateSkillLevel = ( + const updateSkillLevel = async ( category: string, skillId: string, level: SkillLevel ) => { if (!userEvaluation) return; - const updatedEvaluations = userEvaluation.evaluations.map((catEval) => { - if (catEval.category === category) { - const existingSkill = catEval.skills.find((s) => s.skillId === skillId); - const updatedSkills = existingSkill - ? catEval.skills.map((skill) => - skill.skillId === skillId ? { ...skill, level } : skill - ) - : [...catEval.skills, { skillId, level }]; + try { + // Optimistic update + const updatedEvaluations = userEvaluation.evaluations.map((catEval) => { + if (catEval.category === category) { + const existingSkill = catEval.skills.find( + (s) => s.skillId === skillId + ); + const updatedSkills = existingSkill + ? catEval.skills.map((skill) => + skill.skillId === skillId ? { ...skill, level } : skill + ) + : [...catEval.skills, { skillId, level }]; - return { - ...catEval, - skills: updatedSkills, - }; - } - return catEval; - }); + return { + ...catEval, + skills: updatedSkills, + }; + } + return catEval; + }); - const newEvaluation: UserEvaluation = { - ...userEvaluation, - evaluations: updatedEvaluations, - lastUpdated: new Date().toISOString(), - }; + const newEvaluation: UserEvaluation = { + ...userEvaluation, + evaluations: updatedEvaluations, + lastUpdated: new Date().toISOString(), + }; - setUserEvaluation(newEvaluation); - saveUserEvaluation(newEvaluation); + setUserEvaluation(newEvaluation); + + // Update via API + await apiClient.updateSkillLevel( + userEvaluation.profile, + category, + skillId, + level + ); + } catch (error) { + console.error("Failed to update skill level:", error); + // Revert optimistic update if needed + } }; - const updateSkillMentorStatus = ( + const updateSkillMentorStatus = async ( category: string, skillId: string, canMentor: boolean ) => { if (!userEvaluation) return; - const updatedEvaluations = userEvaluation.evaluations.map((catEval) => { - if (catEval.category === category) { - const updatedSkills = catEval.skills.map((skill) => - skill.skillId === skillId ? { ...skill, canMentor } : skill - ); + try { + const updatedEvaluations = userEvaluation.evaluations.map((catEval) => { + if (catEval.category === category) { + const updatedSkills = catEval.skills.map((skill) => + skill.skillId === skillId ? { ...skill, canMentor } : skill + ); - return { - ...catEval, - skills: updatedSkills, - }; - } - return catEval; - }); + return { + ...catEval, + skills: updatedSkills, + }; + } + return catEval; + }); - const newEvaluation: UserEvaluation = { - ...userEvaluation, - evaluations: updatedEvaluations, - lastUpdated: new Date().toISOString(), - }; + const newEvaluation: UserEvaluation = { + ...userEvaluation, + evaluations: updatedEvaluations, + lastUpdated: new Date().toISOString(), + }; - setUserEvaluation(newEvaluation); - saveUserEvaluation(newEvaluation); + setUserEvaluation(newEvaluation); + await apiClient.updateSkillMentorStatus( + userEvaluation.profile, + category, + skillId, + canMentor + ); + } catch (error) { + console.error("Failed to update skill mentor status:", error); + } }; - const updateSkillLearningStatus = ( + const updateSkillLearningStatus = async ( category: string, skillId: string, wantsToLearn: boolean ) => { if (!userEvaluation) return; - const updatedEvaluations = userEvaluation.evaluations.map((catEval) => { - if (catEval.category === category) { - const updatedSkills = catEval.skills.map((skill) => - skill.skillId === skillId ? { ...skill, wantsToLearn } : skill - ); + try { + const updatedEvaluations = userEvaluation.evaluations.map((catEval) => { + if (catEval.category === category) { + const updatedSkills = catEval.skills.map((skill) => + skill.skillId === skillId ? { ...skill, wantsToLearn } : skill + ); - return { - ...catEval, - skills: updatedSkills, - }; - } - return catEval; - }); - - const newEvaluation: UserEvaluation = { - ...userEvaluation, - evaluations: updatedEvaluations, - lastUpdated: new Date().toISOString(), - }; - - setUserEvaluation(newEvaluation); - saveUserEvaluation(newEvaluation); - }; - - const addSkillToEvaluation = (category: string, skillId: string) => { - if (!userEvaluation) return; - - const updatedEvaluations = userEvaluation.evaluations.map((catEval) => { - if (catEval.category === category) { - if (!catEval.selectedSkillIds.includes(skillId)) { return { ...catEval, - selectedSkillIds: [...catEval.selectedSkillIds, skillId], - skills: [...catEval.skills, { skillId, level: null }], + skills: updatedSkills, }; } - } - return catEval; - }); + return catEval; + }); - const newEvaluation: UserEvaluation = { - ...userEvaluation, - evaluations: updatedEvaluations, - lastUpdated: new Date().toISOString(), - }; + const newEvaluation: UserEvaluation = { + ...userEvaluation, + evaluations: updatedEvaluations, + lastUpdated: new Date().toISOString(), + }; - setUserEvaluation(newEvaluation); - saveUserEvaluation(newEvaluation); + setUserEvaluation(newEvaluation); + await apiClient.updateSkillLearningStatus( + userEvaluation.profile, + category, + skillId, + wantsToLearn + ); + } catch (error) { + console.error("Failed to update skill learning status:", error); + } }; - const removeSkillFromEvaluation = (category: string, skillId: string) => { + const addSkillToEvaluation = async (category: string, skillId: string) => { if (!userEvaluation) return; - const updatedEvaluations = userEvaluation.evaluations.map((catEval) => { - if (catEval.category === category) { - return { - ...catEval, - selectedSkillIds: catEval.selectedSkillIds.filter( - (id) => id !== skillId - ), - skills: catEval.skills.filter((skill) => skill.skillId !== skillId), - }; - } - return catEval; - }); + try { + const updatedEvaluations = userEvaluation.evaluations.map((catEval) => { + if (catEval.category === category) { + if (!catEval.selectedSkillIds.includes(skillId)) { + return { + ...catEval, + selectedSkillIds: [...catEval.selectedSkillIds, skillId], + skills: [...catEval.skills, { skillId, level: null }], + }; + } + } + return catEval; + }); - const newEvaluation: UserEvaluation = { - ...userEvaluation, - evaluations: updatedEvaluations, - lastUpdated: new Date().toISOString(), - }; + const newEvaluation: UserEvaluation = { + ...userEvaluation, + evaluations: updatedEvaluations, + lastUpdated: new Date().toISOString(), + }; - setUserEvaluation(newEvaluation); - saveUserEvaluation(newEvaluation); + setUserEvaluation(newEvaluation); + await apiClient.addSkillToEvaluation( + userEvaluation.profile, + category, + skillId + ); + } catch (error) { + console.error("Failed to add skill to evaluation:", error); + } }; - const initializeEmptyEvaluation = (profile: UserProfile) => { + const removeSkillFromEvaluation = async ( + category: string, + skillId: string + ) => { + if (!userEvaluation) return; + + try { + const updatedEvaluations = userEvaluation.evaluations.map((catEval) => { + if (catEval.category === category) { + return { + ...catEval, + selectedSkillIds: catEval.selectedSkillIds.filter( + (id) => id !== skillId + ), + skills: catEval.skills.filter((skill) => skill.skillId !== skillId), + }; + } + return catEval; + }); + + const newEvaluation: UserEvaluation = { + ...userEvaluation, + evaluations: updatedEvaluations, + lastUpdated: new Date().toISOString(), + }; + + setUserEvaluation(newEvaluation); + await apiClient.removeSkillFromEvaluation( + userEvaluation.profile, + category, + skillId + ); + } catch (error) { + console.error("Failed to remove skill from evaluation:", error); + } + }; + + const initializeEmptyEvaluation = async (profile: UserProfile) => { const evaluations = createEmptyEvaluation(skillCategories); const newEvaluation: UserEvaluation = { profile, @@ -257,8 +353,16 @@ export function useEvaluation() { lastUpdated: new Date().toISOString(), }; + // Save profile to localStorage for auto-login + localStorage.setItem("peakSkills_userProfile", JSON.stringify(profile)); + setUserEvaluation(newEvaluation); - saveUserEvaluation(newEvaluation); + await saveUserEvaluation(newEvaluation); + }; + + const clearUserProfile = () => { + localStorage.removeItem("peakSkills_userProfile"); + setUserEvaluation(null); }; return { @@ -266,6 +370,7 @@ export function useEvaluation() { skillCategories, teams, loading, + loadEvaluationForProfile, updateProfile, updateSkillLevel, updateSkillMentorStatus, @@ -273,5 +378,6 @@ export function useEvaluation() { addSkillToEvaluation, removeSkillFromEvaluation, initializeEmptyEvaluation, + clearUserProfile, }; } diff --git a/lib/evaluation-utils.ts b/lib/evaluation-utils.ts index 0bfe7bb..c2b5f3e 100644 --- a/lib/evaluation-utils.ts +++ b/lib/evaluation-utils.ts @@ -6,6 +6,7 @@ import { UserEvaluation, SkillCategory, } from "./types"; +import { apiClient } from "../services/api-client"; export function calculateCategoryScore( categoryEvaluation: CategoryEvaluation @@ -44,21 +45,22 @@ export function generateRadarData( }); } -export function saveUserEvaluation(evaluation: UserEvaluation): void { +export async function saveUserEvaluation( + evaluation: UserEvaluation +): Promise { try { - localStorage.setItem( - "peakSkills_userEvaluation", - JSON.stringify(evaluation) - ); + await apiClient.saveUserEvaluation(evaluation); } catch (error) { console.error("Failed to save user evaluation:", error); + throw error; } } -export function loadUserEvaluation(): UserEvaluation | null { +export async function loadUserEvaluation( + profile: UserEvaluation["profile"] +): Promise { try { - const saved = localStorage.getItem("peakSkills_userEvaluation"); - return saved ? JSON.parse(saved) : null; + return await apiClient.loadUserEvaluation(profile); } catch (error) { console.error("Failed to load user evaluation:", error); return null; diff --git a/package.json b/package.json index b459580..477d3aa 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,9 @@ "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", "vaul": "^0.9.9", - "zod": "3.25.67" + "zod": "3.25.67", + "pg": "^8.12.0", + "@types/pg": "^8.11.10" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e2d658..e5de36a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,9 @@ importers: '@radix-ui/react-tooltip': specifier: 1.1.6 version: 1.1.6(@types/react-dom@19.1.7(@types/react@19.1.10))(@types/react@19.1.10)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@types/pg': + specifier: ^8.11.10 + version: 8.15.5 autoprefixer: specifier: ^10.4.20 version: 10.4.21(postcss@8.5.6) @@ -137,6 +140,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + pg: + specifier: ^8.12.0 + version: 8.16.3 react: specifier: ^19 version: 19.1.1 @@ -1210,6 +1216,9 @@ packages: '@types/node@22.17.2': resolution: {integrity: sha512-gL6z5N9Jm9mhY+U2KXZpteb+09zyffliRkZyZOHODGATyC5B1Jt/7TzuuiLkFsSUMLbS1OLmlj/E+/3KF4Q/4w==} + '@types/pg@8.15.5': + resolution: {integrity: sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==} + '@types/react-dom@19.1.7': resolution: {integrity: sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==} peerDependencies: @@ -1542,6 +1551,40 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + pg-cloudflare@1.2.7: + resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} + + pg-connection-string@2.9.1: + resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.10.1: + resolution: {integrity: sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.10.3: + resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.16.3: + resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1556,6 +1599,22 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.0: + resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -1669,6 +1728,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} @@ -1762,6 +1825,10 @@ packages: victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + yallist@5.0.0: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} @@ -2745,6 +2812,12 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/pg@8.15.5': + dependencies: + '@types/node': 22.17.2 + pg-protocol: 1.10.3 + pg-types: 2.2.0 + '@types/react-dom@19.1.7(@types/react@19.1.10)': dependencies: '@types/react': 19.1.10 @@ -3031,6 +3104,41 @@ snapshots: object-assign@4.1.1: {} + pg-cloudflare@1.2.7: + optional: true + + pg-connection-string@2.9.1: {} + + pg-int8@1.0.1: {} + + pg-pool@3.10.1(pg@8.16.3): + dependencies: + pg: 8.16.3 + + pg-protocol@1.10.3: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.0 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.16.3: + dependencies: + pg-connection-string: 2.9.1 + pg-pool: 3.10.1(pg@8.16.3) + pg-protocol: 1.10.3 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.2.7 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} postcss-value-parser@4.2.0: {} @@ -3047,6 +3155,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.0: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -3185,6 +3303,8 @@ snapshots: source-map-js@1.2.1: {} + split2@4.2.0: {} + streamsearch@1.1.0: {} styled-jsx@5.1.6(react@19.1.1): @@ -3272,6 +3392,8 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + xtend@4.0.2: {} + yallist@5.0.0: {} zod@3.25.67: {} diff --git a/scripts/init.sql b/scripts/init.sql new file mode 100644 index 0000000..3ef00ac --- /dev/null +++ b/scripts/init.sql @@ -0,0 +1,75 @@ +-- Create enum for skill levels +CREATE TYPE skill_level_enum AS ENUM ('never', 'not-autonomous', 'autonomous', 'expert'); + +-- Users table +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + team_id VARCHAR(50) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- User evaluations table +CREATE TABLE user_evaluations ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id) +); + +-- Category evaluations table +CREATE TABLE category_evaluations ( + id SERIAL PRIMARY KEY, + user_evaluation_id INTEGER REFERENCES user_evaluations(id) ON DELETE CASCADE, + category VARCHAR(100) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_evaluation_id, category) +); + +-- Selected skills table (skills the user wants to evaluate) +CREATE TABLE selected_skills ( + id SERIAL PRIMARY KEY, + category_evaluation_id INTEGER REFERENCES category_evaluations(id) ON DELETE CASCADE, + skill_id VARCHAR(100) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(category_evaluation_id, skill_id) +); + +-- Skill evaluations table +CREATE TABLE skill_evaluations ( + id SERIAL PRIMARY KEY, + category_evaluation_id INTEGER REFERENCES category_evaluations(id) ON DELETE CASCADE, + skill_id VARCHAR(100) NOT NULL, + level skill_level_enum NOT NULL, + can_mentor BOOLEAN DEFAULT FALSE, + wants_to_learn BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(category_evaluation_id, skill_id) +); + +-- Indexes for performance +CREATE INDEX idx_users_team_id ON users(team_id); +CREATE INDEX idx_user_evaluations_user_id ON user_evaluations(user_id); +CREATE INDEX idx_category_evaluations_user_evaluation_id ON category_evaluations(user_evaluation_id); +CREATE INDEX idx_selected_skills_category_evaluation_id ON selected_skills(category_evaluation_id); +CREATE INDEX idx_skill_evaluations_category_evaluation_id ON skill_evaluations(category_evaluation_id); +CREATE INDEX idx_skill_evaluations_skill_id ON skill_evaluations(skill_id); + +-- Update trigger for users table +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_skill_evaluations_updated_at BEFORE UPDATE ON skill_evaluations + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/services/api-client.ts b/services/api-client.ts new file mode 100644 index 0000000..e1ed1ed --- /dev/null +++ b/services/api-client.ts @@ -0,0 +1,174 @@ +import { UserEvaluation, UserProfile, SkillLevel } from "../lib/types"; + +export class ApiClient { + private baseUrl: string; + + constructor() { + this.baseUrl = process.env.NEXT_PUBLIC_API_URL || ""; + } + + /** + * Charge une évaluation utilisateur depuis l'API + */ + async loadUserEvaluation( + profile: UserProfile + ): Promise { + try { + const params = new URLSearchParams({ + firstName: profile.firstName, + lastName: profile.lastName, + teamId: profile.teamId, + }); + + const response = await fetch(`${this.baseUrl}/api/evaluations?${params}`); + + if (!response.ok) { + throw new Error("Erreur lors du chargement de l'évaluation"); + } + + const data = await response.json(); + return data.evaluation; + } catch (error) { + console.error("Erreur lors du chargement de l'évaluation:", error); + return null; + } + } + + /** + * Sauvegarde une évaluation utilisateur via l'API + */ + async saveUserEvaluation(evaluation: UserEvaluation): Promise { + try { + const response = await fetch(`${this.baseUrl}/api/evaluations`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ evaluation }), + }); + + if (!response.ok) { + throw new Error("Erreur lors de la sauvegarde de l'évaluation"); + } + } catch (error) { + console.error("Erreur lors de la sauvegarde de l'évaluation:", error); + throw error; + } + } + + /** + * Met à jour le niveau d'une skill + */ + async updateSkillLevel( + profile: UserProfile, + category: string, + skillId: string, + level: SkillLevel + ): Promise { + await this.updateSkill(profile, category, skillId, { + action: "updateLevel", + level, + }); + } + + /** + * Met à jour le statut de mentorat d'une skill + */ + async updateSkillMentorStatus( + profile: UserProfile, + category: string, + skillId: string, + canMentor: boolean + ): Promise { + await this.updateSkill(profile, category, skillId, { + action: "updateMentorStatus", + canMentor, + }); + } + + /** + * Met à jour le statut d'apprentissage d'une skill + */ + async updateSkillLearningStatus( + profile: UserProfile, + category: string, + skillId: string, + wantsToLearn: boolean + ): Promise { + await this.updateSkill(profile, category, skillId, { + action: "updateLearningStatus", + wantsToLearn, + }); + } + + /** + * Ajoute une skill à l'évaluation + */ + async addSkillToEvaluation( + profile: UserProfile, + category: string, + skillId: string + ): Promise { + await this.updateSkill(profile, category, skillId, { + action: "addSkill", + }); + } + + /** + * Supprime une skill de l'évaluation + */ + async removeSkillFromEvaluation( + profile: UserProfile, + category: string, + skillId: string + ): Promise { + await this.updateSkill(profile, category, skillId, { + action: "removeSkill", + }); + } + + /** + * Méthode utilitaire pour mettre à jour une skill + */ + private async updateSkill( + profile: UserProfile, + category: string, + skillId: string, + options: { + action: + | "updateLevel" + | "updateMentorStatus" + | "updateLearningStatus" + | "addSkill" + | "removeSkill"; + level?: SkillLevel; + canMentor?: boolean; + wantsToLearn?: boolean; + } + ): Promise { + try { + const response = await fetch(`${this.baseUrl}/api/evaluations/skills`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + profile, + category, + skillId, + ...options, + }), + }); + + if (!response.ok) { + throw new Error("Erreur lors de la mise à jour de la skill"); + } + } catch (error) { + console.error("Erreur lors de la mise à jour de la skill:", error); + throw error; + } + } +} + +// Instance singleton +export const apiClient = new ApiClient(); diff --git a/services/client.ts b/services/client.ts new file mode 100644 index 0000000..601a0f1 --- /dev/null +++ b/services/client.ts @@ -0,0 +1,4 @@ +// Client-side services only +// Safe to import in React components and hooks + +export { ApiClient, apiClient } from "./api-client"; diff --git a/services/database.ts b/services/database.ts new file mode 100644 index 0000000..4d550a0 --- /dev/null +++ b/services/database.ts @@ -0,0 +1,33 @@ +import { Pool } from "pg"; + +// Configuration de la base de données +const dbConfig = { + host: process.env.DB_HOST || "localhost", + port: parseInt(process.env.DB_PORT || "5432"), + database: process.env.DB_NAME || "peakskills", + user: process.env.DB_USER || "peakskills_user", + password: process.env.DB_PASSWORD || "peakskills_password", +}; + +// Pool de connexions global +let pool: Pool | null = null; + +export function getPool(): Pool { + if (!pool) { + pool = new Pool(dbConfig); + + pool.on("error", (err) => { + console.error("Unexpected error on idle client", err); + process.exit(-1); + }); + } + + return pool; +} + +export async function closePool(): Promise { + if (pool) { + await pool.end(); + pool = null; + } +} diff --git a/services/evaluation-service.ts b/services/evaluation-service.ts new file mode 100644 index 0000000..21cab40 --- /dev/null +++ b/services/evaluation-service.ts @@ -0,0 +1,527 @@ +import { getPool } from "./database"; +import { + UserEvaluation, + UserProfile, + CategoryEvaluation, + SkillEvaluation, + SkillLevel, +} from "../lib/types"; + +export class EvaluationService { + /** + * Crée ou met à jour un utilisateur + */ + async upsertUser(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 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) { + // Mettre à jour l'utilisateur existant + const updateQuery = ` + UPDATE users + SET first_name = $1, last_name = $2, team_id = $3, updated_at = CURRENT_TIMESTAMP + WHERE id = $4 + RETURNING id + `; + + const result = await client.query(updateQuery, [ + profile.firstName, + profile.lastName, + profile.teamId, + existingUser.rows[0].id, + ]); + + return result.rows[0].id; + } else { + // Créer un nouvel utilisateur + const insertQuery = ` + INSERT INTO users (first_name, last_name, team_id) + VALUES ($1, $2, $3) + RETURNING id + `; + + const result = await client.query(insertQuery, [ + profile.firstName, + profile.lastName, + profile.teamId, + ]); + + return result.rows[0].id; + } + } finally { + client.release(); + } + } + + /** + * Sauvegarde une évaluation utilisateur complète + */ + async saveUserEvaluation(evaluation: UserEvaluation): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + // 1. Upsert user + const userId = await this.upsertUser(evaluation.profile); + + // 2. Upsert user_evaluation + const userEvalQuery = ` + INSERT INTO user_evaluations (user_id, last_updated) + VALUES ($1, $2) + ON CONFLICT (user_id) + DO UPDATE SET last_updated = $2 + RETURNING id + `; + + const userEvalResult = await client.query(userEvalQuery, [ + userId, + new Date(evaluation.lastUpdated), + ]); + + const userEvaluationId = userEvalResult.rows[0].id; + + // 3. Supprimer les anciennes évaluations de catégories + await client.query( + "DELETE FROM category_evaluations WHERE user_evaluation_id = $1", + [userEvaluationId] + ); + + // 4. Sauvegarder les nouvelles évaluations + for (const catEval of evaluation.evaluations) { + await this.saveCategoryEvaluation(client, userEvaluationId, catEval); + } + + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + /** + * Sauvegarde une évaluation de catégorie + */ + private async saveCategoryEvaluation( + client: any, + userEvaluationId: number, + catEval: CategoryEvaluation + ): Promise { + // Insérer la catégorie d'évaluation + const catEvalQuery = ` + INSERT INTO category_evaluations (user_evaluation_id, category) + VALUES ($1, $2) + RETURNING id + `; + + const catEvalResult = await client.query(catEvalQuery, [ + userEvaluationId, + catEval.category, + ]); + + const categoryEvaluationId = catEvalResult.rows[0].id; + + // Insérer les skills sélectionnées + for (const skillId of catEval.selectedSkillIds) { + await client.query( + "INSERT INTO selected_skills (category_evaluation_id, skill_id) VALUES ($1, $2)", + [categoryEvaluationId, skillId] + ); + } + + // Insérer les évaluations de skills + for (const skillEval of catEval.skills) { + if (skillEval.level !== null) { + await client.query( + ` + INSERT INTO skill_evaluations + (category_evaluation_id, skill_id, level, can_mentor, wants_to_learn) + VALUES ($1, $2, $3, $4, $5) + `, + [ + categoryEvaluationId, + skillEval.skillId, + skillEval.level, + skillEval.canMentor || false, + skillEval.wantsToLearn || false, + ] + ); + } + } + } + + /** + * Charge une évaluation utilisateur + */ + async loadUserEvaluation( + profile: UserProfile + ): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + // Trouver l'utilisateur + 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 + WHERE u.first_name = $1 AND u.last_name = $2 AND u.team_id = $3 + `; + + const userResult = await client.query(userQuery, [ + profile.firstName, + profile.lastName, + profile.teamId, + ]); + + if ( + userResult.rows.length === 0 || + !userResult.rows[0].user_evaluation_id + ) { + return null; + } + + const userData = userResult.rows[0]; + const userEvaluationId = userData.user_evaluation_id; + + // Charger les évaluations de catégories + const categoriesQuery = ` + SELECT ce.*, + array_agg(DISTINCT ss.skill_id) FILTER (WHERE ss.skill_id IS NOT NULL) as selected_skill_ids, + array_agg( + json_build_object( + 'skillId', se.skill_id, + 'level', se.level, + 'canMentor', se.can_mentor, + 'wantsToLearn', se.wants_to_learn + ) + ) FILTER (WHERE se.skill_id IS NOT NULL) as skill_evaluations + FROM category_evaluations ce + LEFT JOIN selected_skills ss ON ce.id = ss.category_evaluation_id + LEFT JOIN skill_evaluations se ON ce.id = se.category_evaluation_id + WHERE ce.user_evaluation_id = $1 + GROUP BY ce.id, ce.category + ORDER BY ce.category + `; + + const categoriesResult = await client.query(categoriesQuery, [ + userEvaluationId, + ]); + + const evaluations: CategoryEvaluation[] = categoriesResult.rows.map( + (row) => ({ + category: row.category, + selectedSkillIds: row.selected_skill_ids || [], + skills: (row.skill_evaluations || []).filter( + (se: any) => se.skillId !== null + ), + }) + ); + + return { + profile, + evaluations, + lastUpdated: userData.last_updated.toISOString(), + }; + } finally { + client.release(); + } + } + + /** + * Met à jour le niveau d'une skill + */ + async updateSkillLevel( + profile: UserProfile, + category: string, + skillId: string, + level: SkillLevel + ): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const userId = await this.upsertUser(profile); + + // Obtenir ou créer user_evaluation + const userEvalResult = await client.query( + ` + INSERT INTO user_evaluations (user_id, last_updated) + VALUES ($1, CURRENT_TIMESTAMP) + ON CONFLICT (user_id) + DO UPDATE SET last_updated = CURRENT_TIMESTAMP + RETURNING id + `, + [userId] + ); + + const userEvaluationId = userEvalResult.rows[0].id; + + // Obtenir ou créer category_evaluation + const catEvalResult = await client.query( + ` + INSERT INTO category_evaluations (user_evaluation_id, category) + VALUES ($1, $2) + ON CONFLICT (user_evaluation_id, category) + DO UPDATE SET category = $2 + RETURNING id + `, + [userEvaluationId, category] + ); + + const categoryEvaluationId = catEvalResult.rows[0].id; + + // Upsert skill evaluation + await client.query( + ` + INSERT INTO skill_evaluations (category_evaluation_id, skill_id, level) + VALUES ($1, $2, $3) + ON CONFLICT (category_evaluation_id, skill_id) + DO UPDATE SET level = $3, updated_at = CURRENT_TIMESTAMP + `, + [categoryEvaluationId, skillId, level] + ); + + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + /** + * Met à jour le statut de mentorat d'une skill + */ + async updateSkillMentorStatus( + profile: UserProfile, + category: string, + skillId: string, + canMentor: boolean + ): Promise { + await this.updateSkillProperty( + profile, + category, + skillId, + "can_mentor", + canMentor + ); + } + + /** + * Met à jour le statut d'apprentissage d'une skill + */ + async updateSkillLearningStatus( + profile: UserProfile, + category: string, + skillId: string, + wantsToLearn: boolean + ): Promise { + await this.updateSkillProperty( + profile, + category, + skillId, + "wants_to_learn", + wantsToLearn + ); + } + + /** + * Méthode utilitaire pour mettre à jour une propriété de skill + */ + private async updateSkillProperty( + profile: UserProfile, + category: string, + skillId: string, + property: "can_mentor" | "wants_to_learn", + value: boolean + ): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const userId = await this.upsertUser(profile); + + const userEvalResult = await client.query( + ` + INSERT INTO user_evaluations (user_id, last_updated) + VALUES ($1, CURRENT_TIMESTAMP) + ON CONFLICT (user_id) + DO UPDATE SET last_updated = CURRENT_TIMESTAMP + RETURNING id + `, + [userId] + ); + + const userEvaluationId = userEvalResult.rows[0].id; + + const catEvalResult = await client.query( + ` + INSERT INTO category_evaluations (user_evaluation_id, category) + VALUES ($1, $2) + ON CONFLICT (user_evaluation_id, category) + DO UPDATE SET category = $2 + RETURNING id + `, + [userEvaluationId, category] + ); + + const categoryEvaluationId = catEvalResult.rows[0].id; + + // Update the specific property + const updateQuery = ` + UPDATE skill_evaluations + SET ${property} = $3, updated_at = CURRENT_TIMESTAMP + WHERE category_evaluation_id = $1 AND skill_id = $2 + `; + + await client.query(updateQuery, [categoryEvaluationId, skillId, value]); + + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + /** + * Ajoute une skill à l'évaluation + */ + async addSkillToEvaluation( + profile: UserProfile, + category: string, + skillId: string + ): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const userId = await this.upsertUser(profile); + + const userEvalResult = await client.query( + ` + INSERT INTO user_evaluations (user_id, last_updated) + VALUES ($1, CURRENT_TIMESTAMP) + ON CONFLICT (user_id) + DO UPDATE SET last_updated = CURRENT_TIMESTAMP + RETURNING id + `, + [userId] + ); + + const userEvaluationId = userEvalResult.rows[0].id; + + const catEvalResult = await client.query( + ` + INSERT INTO category_evaluations (user_evaluation_id, category) + VALUES ($1, $2) + ON CONFLICT (user_evaluation_id, category) + DO UPDATE SET category = $2 + RETURNING id + `, + [userEvaluationId, category] + ); + + const categoryEvaluationId = catEvalResult.rows[0].id; + + // Ajouter à selected_skills + await client.query( + ` + INSERT INTO selected_skills (category_evaluation_id, skill_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING + `, + [categoryEvaluationId, skillId] + ); + + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + /** + * Supprime une skill de l'évaluation + */ + async removeSkillFromEvaluation( + profile: UserProfile, + category: string, + skillId: string + ): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + // Trouver les IDs nécessaires + const findQuery = ` + SELECT ce.id as category_evaluation_id + FROM users u + JOIN user_evaluations ue ON u.id = ue.user_id + JOIN category_evaluations ce ON ue.id = ce.user_evaluation_id + WHERE u.first_name = $1 AND u.last_name = $2 AND u.team_id = $3 AND ce.category = $4 + `; + + const result = await client.query(findQuery, [ + profile.firstName, + profile.lastName, + profile.teamId, + category, + ]); + + if (result.rows.length > 0) { + const categoryEvaluationId = result.rows[0].category_evaluation_id; + + // Supprimer de selected_skills et skill_evaluations + await client.query( + "DELETE FROM selected_skills WHERE category_evaluation_id = $1 AND skill_id = $2", + [categoryEvaluationId, skillId] + ); + + await client.query( + "DELETE FROM skill_evaluations WHERE category_evaluation_id = $1 AND skill_id = $2", + [categoryEvaluationId, skillId] + ); + } + + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } +} + +// Instance singleton +export const evaluationService = new EvaluationService(); diff --git a/services/index.ts b/services/index.ts new file mode 100644 index 0000000..77d115d --- /dev/null +++ b/services/index.ts @@ -0,0 +1,12 @@ +// Server-side services (Node.js only) +// Note: These exports are only for server-side usage (API routes) +// Don't import from this index in client-side code to avoid bundling server dependencies + +// Database services (server-only) +export { getPool, closePool } from "./database"; + +// Evaluation services (server-only) +export { EvaluationService, evaluationService } from "./evaluation-service"; + +// API client (can be used client-side) +export { ApiClient, apiClient } from "./api-client";