From 72b653de19481f9a1927b04a79ceae47acca8964 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Thu, 21 Aug 2025 09:55:35 +0200 Subject: [PATCH] feat: refactor skills API and database schema - Replaced file-based skill category loading with API calls in the GET and POST methods of the skills route. - Added new `SkillsService` for handling skill category operations. - Updated SQL initialization script to create `skill_categories`, `skills`, and `skill_links` tables with appropriate relationships. - Enhanced `ApiClient` with methods for loading skill categories and creating new skills, improving API interaction. - Introduced error handling for skill category creation and loading processes. --- app/api/skills/[categoryId]/route.ts | 55 ++++++ app/api/skills/migrate/route.ts | 52 ++++++ app/api/skills/route.ts | 53 +++--- lib/data-loader.ts | 9 + lib/skill-file-loader.ts | 94 ++++++++++ scripts/init.sql | 36 ++++ scripts/migrate-skills.ts | 54 ++++++ services/api-client.ts | 134 +++++++++++++- services/index.ts | 3 + services/skills-service.ts | 253 +++++++++++++++++++++++++++ 10 files changed, 715 insertions(+), 28 deletions(-) create mode 100644 app/api/skills/[categoryId]/route.ts create mode 100644 app/api/skills/migrate/route.ts create mode 100644 lib/skill-file-loader.ts create mode 100644 scripts/migrate-skills.ts create mode 100644 services/skills-service.ts diff --git a/app/api/skills/[categoryId]/route.ts b/app/api/skills/[categoryId]/route.ts new file mode 100644 index 0000000..47e744e --- /dev/null +++ b/app/api/skills/[categoryId]/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; +import { SkillsService } from "@/services"; + +interface RouteParams { + params: { + categoryId: string; + }; +} + +export async function GET(request: Request, { params }: RouteParams) { + try { + const skills = await SkillsService.getSkillsByCategory(params.categoryId); + return NextResponse.json(skills); + } catch (error) { + console.error("Error loading skills by category:", error); + return NextResponse.json( + { error: "Failed to load skills by category" }, + { status: 500 } + ); + } +} + +export async function POST(request: Request, { params }: RouteParams) { + try { + const skillData: { + id: string; + name: string; + description: string; + icon?: string; + links: string[]; + } = await request.json(); + + // Validate required fields + if (!skillData.id || !skillData.name || !skillData.description) { + return NextResponse.json( + { error: "Missing required fields: id, name, description" }, + { status: 400 } + ); + } + + await SkillsService.createSkill({ + ...skillData, + categoryId: params.categoryId, + links: skillData.links || [], + }); + + return NextResponse.json({ success: true }, { status: 201 }); + } catch (error) { + console.error("Error creating skill:", error); + return NextResponse.json( + { error: "Failed to create skill" }, + { status: 500 } + ); + } +} diff --git a/app/api/skills/migrate/route.ts b/app/api/skills/migrate/route.ts new file mode 100644 index 0000000..fa44811 --- /dev/null +++ b/app/api/skills/migrate/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; +import { SkillsService } from "@/services"; +import { loadSkillCategoriesFromFiles } from "@/lib/skill-file-loader"; + +export async function POST() { + try { + console.log("🚀 Starting skills migration via API..."); + + // Load all skill categories from JSON files + const skillCategories = loadSkillCategoriesFromFiles(); + + console.log(`📊 Found ${skillCategories.length} categories`); + + const totalSkills = skillCategories.reduce( + (sum, cat) => sum + cat.skills.length, + 0 + ); + console.log(`🎯 Total skills to migrate: ${totalSkills}`); + + // Bulk insert into database + await SkillsService.bulkInsertSkillsFromJSON(skillCategories); + + console.log("✅ Skills migration completed successfully!"); + + // Verify the migration + const categoriesFromDb = await SkillsService.getSkillCategories(); + const totalSkillsInDb = categoriesFromDb.reduce( + (sum, cat) => sum + cat.skills.length, + 0 + ); + + return NextResponse.json({ + success: true, + message: "Skills migration completed successfully", + stats: { + categoriesMigrated: skillCategories.length, + skillsMigrated: totalSkills, + categoriesInDb: categoriesFromDb.length, + skillsInDb: totalSkillsInDb, + }, + }); + } catch (error) { + console.error("❌ Migration failed:", error); + return NextResponse.json( + { + error: "Failed to migrate skills", + details: error instanceof Error ? error.message : "Unknown error", + }, + { status: 500 } + ); + } +} diff --git a/app/api/skills/route.ts b/app/api/skills/route.ts index 413b75f..2c3395c 100644 --- a/app/api/skills/route.ts +++ b/app/api/skills/route.ts @@ -1,35 +1,10 @@ import { NextResponse } from "next/server"; -import fs from "fs"; -import path from "path"; +import { SkillsService } from "@/services"; import { SkillCategory } from "@/lib/types"; export async function GET() { try { - const dataDir = path.join(process.cwd(), "data", "skills"); - - const categories = [ - "frontend", - "backend", - "devops", - "mobile", - "data", - "cloud", - "security", - "design", - ]; - - const skillCategories: SkillCategory[] = []; - - for (const category of categories) { - const filePath = path.join(dataDir, `${category}.json`); - - if (fs.existsSync(filePath)) { - const fileContent = fs.readFileSync(filePath, "utf-8"); - const data = JSON.parse(fileContent); - skillCategories.push(data); - } - } - + const skillCategories = await SkillsService.getSkillCategories(); return NextResponse.json(skillCategories); } catch (error) { console.error("Error loading skills:", error); @@ -39,3 +14,27 @@ export async function GET() { ); } } + +export async function POST(request: Request) { + try { + const categoryData: { id: string; name: string; icon: string } = + await request.json(); + + // Validate required fields + if (!categoryData.id || !categoryData.name || !categoryData.icon) { + return NextResponse.json( + { error: "Missing required fields: id, name, icon" }, + { status: 400 } + ); + } + + await SkillsService.createSkillCategory(categoryData); + return NextResponse.json({ success: true }, { status: 201 }); + } catch (error) { + console.error("Error creating skill category:", error); + return NextResponse.json( + { error: "Failed to create skill category" }, + { status: 500 } + ); + } +} diff --git a/lib/data-loader.ts b/lib/data-loader.ts index b833bb9..8edd323 100644 --- a/lib/data-loader.ts +++ b/lib/data-loader.ts @@ -13,6 +13,15 @@ export async function loadSkillCategories(): Promise { } } +/** + * Load skill categories from local files (fallback or development mode) + * This is a client-side safe alternative that still uses the API + * For server-side file loading, use loadSkillCategoriesFromFiles from skill-file-loader + */ +export async function loadSkillCategoriesFromAPI(): Promise { + return loadSkillCategories(); +} + export async function loadTeams(): Promise { try { const response = await fetch("/api/teams"); diff --git a/lib/skill-file-loader.ts b/lib/skill-file-loader.ts new file mode 100644 index 0000000..0c7670c --- /dev/null +++ b/lib/skill-file-loader.ts @@ -0,0 +1,94 @@ +import fs from "fs"; +import path from "path"; +import { SkillCategory } from "./types"; + +/** + * Load all skill categories from JSON files in the data/skills directory + */ +export function loadSkillCategoriesFromFiles(): SkillCategory[] { + const dataDir = path.join(process.cwd(), "data", "skills"); + const skillCategories: SkillCategory[] = []; + + try { + // Read all JSON files in the skills directory + const files = fs + .readdirSync(dataDir) + .filter((file) => file.endsWith(".json")); + console.log(`📁 Found ${files.length} JSON files: ${files.join(", ")}`); + + // Load all JSON files + for (const file of files) { + const filePath = path.join(dataDir, file); + const categoryName = path.basename(file, ".json"); + + try { + console.log(`📖 Loading ${file}...`); + const fileContent = fs.readFileSync(filePath, "utf-8"); + const data = JSON.parse(fileContent); + + // Validate that it's a proper SkillCategory + if (data.category && data.icon && Array.isArray(data.skills)) { + skillCategories.push(data); + } else { + console.warn(`⚠️ Invalid skill category format in ${file}`); + } + } catch (error) { + console.error(`❌ Error loading ${file}:`, error); + // Continue with other files instead of failing completely + } + } + + console.log(`📊 Successfully loaded ${skillCategories.length} categories`); + return skillCategories; + } catch (error) { + console.error("❌ Error reading skills directory:", error); + return []; + } +} + +/** + * Get the list of available skill category files + */ +export function getSkillCategoryFiles(): string[] { + try { + const dataDir = path.join(process.cwd(), "data", "skills"); + return fs + .readdirSync(dataDir) + .filter((file) => file.endsWith(".json")) + .map((file) => path.basename(file, ".json")); + } catch (error) { + console.error("❌ Error reading skills directory:", error); + return []; + } +} + +/** + * Load a specific skill category by filename + */ +export function loadSkillCategoryFromFile( + categoryName: string +): SkillCategory | null { + try { + const dataDir = path.join(process.cwd(), "data", "skills"); + const filePath = path.join(dataDir, `${categoryName}.json`); + + if (!fs.existsSync(filePath)) { + console.warn(`⚠️ Skill category file not found: ${categoryName}.json`); + return null; + } + + const fileContent = fs.readFileSync(filePath, "utf-8"); + const data = JSON.parse(fileContent); + + // Validate that it's a proper SkillCategory + if (data.category && data.icon && Array.isArray(data.skills)) { + return data; + } else { + console.warn(`⚠️ Invalid skill category format in ${categoryName}.json`); + return null; + } + } catch (error) { + console.error(`❌ Error loading skill category ${categoryName}:`, error); + return null; + } +} diff --git a/scripts/init.sql b/scripts/init.sql index eae442b..a6d921e 100644 --- a/scripts/init.sql +++ b/scripts/init.sql @@ -10,6 +10,34 @@ CREATE TABLE teams ( updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +-- Skill categories table +CREATE TABLE skill_categories ( + id VARCHAR(50) PRIMARY KEY, + name VARCHAR(100) NOT NULL, + icon VARCHAR(100) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Skills table +CREATE TABLE skills ( + id VARCHAR(100) PRIMARY KEY, + name VARCHAR(200) NOT NULL, + description TEXT, + icon VARCHAR(100), + category_id VARCHAR(50) REFERENCES skill_categories(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Skill links table +CREATE TABLE skill_links ( + id SERIAL PRIMARY KEY, + skill_id VARCHAR(100) REFERENCES skills(id) ON DELETE CASCADE, + url TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + -- Users table CREATE TABLE users ( id SERIAL PRIMARY KEY, @@ -73,6 +101,8 @@ INSERT INTO teams (id, name, direction) VALUES -- Indexes for performance CREATE INDEX idx_teams_direction ON teams(direction); +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_user_evaluations_user_id ON user_evaluations(user_id); CREATE INDEX idx_category_evaluations_user_evaluation_id ON category_evaluations(user_evaluation_id); @@ -92,6 +122,12 @@ $$ language 'plpgsql'; CREATE TRIGGER update_teams_updated_at BEFORE UPDATE ON teams FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TRIGGER update_skill_categories_updated_at BEFORE UPDATE ON skill_categories + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_skills_updated_at BEFORE UPDATE ON skills + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/scripts/migrate-skills.ts b/scripts/migrate-skills.ts new file mode 100644 index 0000000..2ba96d7 --- /dev/null +++ b/scripts/migrate-skills.ts @@ -0,0 +1,54 @@ +import { SkillsService } from "../services/skills-service"; +import { loadSkillCategoriesFromFiles } from "../lib/skill-file-loader"; + +async function migrateSkillsToDatabase() { + console.log("🚀 Starting skills migration..."); + + try { + // Load all skill categories from JSON files + const skillCategories = loadSkillCategoriesFromFiles(); + + console.log(`📊 Found ${skillCategories.length} categories`); + + const totalSkills = skillCategories.reduce( + (sum, cat) => sum + cat.skills.length, + 0 + ); + console.log(`🎯 Total skills to migrate: ${totalSkills}`); + + // Bulk insert into database + await SkillsService.bulkInsertSkillsFromJSON(skillCategories); + + console.log("✅ Skills migration completed successfully!"); + + // Verify the migration + const categoriesFromDb = await SkillsService.getSkillCategories(); + console.log( + `✨ Verification: ${categoriesFromDb.length} categories in database` + ); + + const totalSkillsInDb = categoriesFromDb.reduce( + (sum, cat) => sum + cat.skills.length, + 0 + ); + console.log(`✨ Verification: ${totalSkillsInDb} skills in database`); + } catch (error) { + console.error("❌ Migration failed:", error); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + migrateSkillsToDatabase() + .then(() => { + console.log("🎉 Migration script completed"); + process.exit(0); + }) + .catch((error) => { + console.error("💥 Migration script failed:", error); + process.exit(1); + }); +} + +export { migrateSkillsToDatabase }; diff --git a/services/api-client.ts b/services/api-client.ts index bf88ac1..a599607 100644 --- a/services/api-client.ts +++ b/services/api-client.ts @@ -1,4 +1,11 @@ -import { UserEvaluation, UserProfile, SkillLevel, Team } from "../lib/types"; +import { + UserEvaluation, + UserProfile, + SkillLevel, + Team, + SkillCategory, + Skill, +} from "../lib/types"; export class ApiClient { private baseUrl: string; @@ -321,6 +328,131 @@ export class ApiClient { return false; } } + + /** + * Charge toutes les catégories de skills + */ + async loadSkillCategories(): Promise { + try { + const response = await fetch(`${this.baseUrl}/api/skills`); + + if (!response.ok) { + throw new Error("Erreur lors du chargement des catégories de skills"); + } + + return await response.json(); + } catch (error) { + console.error( + "Erreur lors du chargement des catégories de skills:", + error + ); + return []; + } + } + + /** + * Charge les skills d'une catégorie + */ + async loadSkillsByCategory(categoryId: string): Promise { + try { + const response = await fetch(`${this.baseUrl}/api/skills/${categoryId}`); + + if (!response.ok) { + throw new Error("Erreur lors du chargement des skills par catégorie"); + } + + return await response.json(); + } catch (error) { + console.error( + "Erreur lors du chargement des skills par catégorie:", + error + ); + return []; + } + } + + /** + * Migre les skills depuis JSON vers PostgreSQL + */ + async migrateSkills(): Promise<{ + success: boolean; + stats?: any; + error?: string; + }> { + try { + const response = await fetch(`${this.baseUrl}/api/skills/migrate`, { + method: "POST", + }); + + if (!response.ok) { + throw new Error("Erreur lors de la migration des skills"); + } + + return await response.json(); + } catch (error) { + console.error("Erreur lors de la migration des skills:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + /** + * Crée une nouvelle catégorie de skill + */ + async createSkillCategory(category: { + id: string; + name: string; + icon: string; + }): Promise { + try { + const response = await fetch(`${this.baseUrl}/api/skills`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(category), + }); + + return response.ok; + } catch (error) { + console.error( + "Erreur lors de la création de la catégorie de skill:", + error + ); + return false; + } + } + + /** + * Crée une nouvelle skill + */ + async createSkill( + categoryId: string, + skill: { + id: string; + name: string; + description: string; + icon?: string; + links: string[]; + } + ): Promise { + try { + const response = await fetch(`${this.baseUrl}/api/skills/${categoryId}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(skill), + }); + + return response.ok; + } catch (error) { + console.error("Erreur lors de la création de la skill:", error); + return false; + } + } } // Instance singleton diff --git a/services/index.ts b/services/index.ts index cbdb761..0efe391 100644 --- a/services/index.ts +++ b/services/index.ts @@ -11,5 +11,8 @@ export { EvaluationService, evaluationService } from "./evaluation-service"; // Teams services (server-only) export { TeamsService } from "./teams-service"; +// Skills services (server-only) +export { SkillsService } from "./skills-service"; + // API client (can be used client-side) export { ApiClient, apiClient } from "./api-client"; diff --git a/services/skills-service.ts b/services/skills-service.ts new file mode 100644 index 0000000..4a83fe0 --- /dev/null +++ b/services/skills-service.ts @@ -0,0 +1,253 @@ +import { getPool } from "./database"; +import { SkillCategory, Skill } from "@/lib/types"; + +export class SkillsService { + /** + * Get all skill categories with their skills + */ + static async getSkillCategories(): Promise { + const pool = getPool(); + const query = ` + SELECT + sc.id as category_id, + sc.name as category_name, + sc.icon as category_icon, + s.id as skill_id, + s.name as skill_name, + s.description as skill_description, + s.icon as skill_icon, + COALESCE( + json_agg( + sl.url ORDER BY sl.id + ) FILTER (WHERE sl.url IS NOT NULL), + '[]'::json + ) as skill_links + FROM skill_categories sc + LEFT JOIN skills s ON sc.id = s.category_id + LEFT JOIN skill_links sl ON s.id = sl.skill_id + GROUP BY sc.id, sc.name, sc.icon, s.id, s.name, s.description, s.icon + ORDER BY sc.name, s.name + `; + + try { + const result = await pool.query(query); + + // Group by category + const categoriesMap = new Map(); + + for (const row of result.rows) { + const categoryId = row.category_id; + + if (!categoriesMap.has(categoryId)) { + categoriesMap.set(categoryId, { + category: row.category_name, + icon: row.category_icon, + skills: [], + }); + } + + if (row.skill_id) { + const category = categoriesMap.get(categoryId)!; + category.skills.push({ + id: row.skill_id, + name: row.skill_name, + description: row.skill_description, + icon: row.skill_icon, + links: row.skill_links, + }); + } + } + + return Array.from(categoriesMap.values()); + } catch (error) { + console.error("Error fetching skill categories:", error); + throw new Error("Failed to fetch skill categories"); + } + } + + /** + * Get skills by category + */ + static async getSkillsByCategory(categoryId: string): Promise { + const pool = getPool(); + const query = ` + SELECT + s.id, + s.name, + s.description, + s.icon, + COALESCE( + json_agg(sl.url ORDER BY sl.id) FILTER (WHERE sl.url IS NOT NULL), + '[]'::json + ) as links + FROM skills s + LEFT JOIN skill_links sl ON s.id = sl.skill_id + WHERE s.category_id = $1 + GROUP BY s.id, s.name, s.description, s.icon + ORDER BY s.name + `; + + try { + const result = await pool.query(query, [categoryId]); + return result.rows.map((row) => ({ + id: row.id, + name: row.name, + description: row.description, + icon: row.icon, + links: row.links, + })); + } catch (error) { + console.error("Error fetching skills by category:", error); + throw new Error("Failed to fetch skills by category"); + } + } + + /** + * Create a new skill category + */ + static async createSkillCategory(category: { + id: string; + name: string; + icon: string; + }): Promise { + const pool = getPool(); + const query = ` + INSERT INTO skill_categories (id, name, icon) + VALUES ($1, $2, $3) + `; + + try { + await pool.query(query, [category.id, category.name, category.icon]); + } catch (error) { + console.error("Error creating skill category:", error); + throw new Error("Failed to create skill category"); + } + } + + /** + * Create a new skill + */ + static async createSkill(skill: { + id: string; + name: string; + description: string; + icon?: string; + categoryId: string; + links: string[]; + }): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + // Insert skill + const skillQuery = ` + INSERT INTO skills (id, name, description, icon, category_id) + VALUES ($1, $2, $3, $4, $5) + `; + await client.query(skillQuery, [ + skill.id, + skill.name, + skill.description, + skill.icon, + skill.categoryId, + ]); + + // Insert links + if (skill.links.length > 0) { + const linkQuery = ` + INSERT INTO skill_links (skill_id, url) + VALUES ($1, $2) + `; + for (const link of skill.links) { + await client.query(linkQuery, [skill.id, link]); + } + } + + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + console.error("Error creating skill:", error); + throw new Error("Failed to create skill"); + } finally { + client.release(); + } + } + + /** + * Bulk insert skills from JSON data + */ + static async bulkInsertSkillsFromJSON( + skillCategoriesData: SkillCategory[] + ): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + for (const categoryData of skillCategoriesData) { + const categoryId = categoryData.category.toLowerCase(); + + // Insert category + const categoryQuery = ` + INSERT INTO skill_categories (id, name, icon) + VALUES ($1, $2, $3) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + icon = EXCLUDED.icon + `; + await client.query(categoryQuery, [ + categoryId, + categoryData.category, + categoryData.icon, + ]); + + // Insert skills + for (const skill of categoryData.skills) { + const skillQuery = ` + INSERT INTO skills (id, name, description, icon, category_id) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + icon = EXCLUDED.icon, + category_id = EXCLUDED.category_id + `; + await client.query(skillQuery, [ + skill.id, + skill.name, + skill.description, + skill.icon, + categoryId, + ]); + + // Delete existing links + await client.query("DELETE FROM skill_links WHERE skill_id = $1", [ + skill.id, + ]); + + // Insert new links + if (skill.links && skill.links.length > 0) { + const linkQuery = ` + INSERT INTO skill_links (skill_id, url) + VALUES ($1, $2) + `; + for (const link of skill.links) { + await client.query(linkQuery, [skill.id, link]); + } + } + } + } + + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + console.error("Error bulk inserting skills:", error); + throw new Error("Failed to bulk insert skills"); + } finally { + client.release(); + } + } +}