Aller au contenu principal

Category System Refactoring - Unified Hierarchical Model

Overview

This document details the migration from a per-module category system to a unified hierarchical category system with support for:

  • Multiple parents (DAG structure)
  • Infinite hierarchy with depth tracking
  • Category levels (category, subcategory, tag)
  • Global categories shared across modules
  • Rich metadata (icon, color, order, path)

Architecture Changes

Before (Old System)

{
_id: ObjectId,
module_id: ObjectId, // Required - tied to specific module
modules: ObjectId[], // Parent category IDs
title: string,
slug: string,
description: string,
thumbnail: string,
files: string[],
publicationDate: Date,
protection: Protection,
active: boolean
}

Problems:

  • Categories isolated per module (products, news, events all have separate trees)
  • No category reusability across modules
  • No depth tracking or level distinction
  • No ordering capability
  • Products have hardcoded category values instead of fetching from API

After (New System)

{
_id: ObjectId,
module_id?: ObjectId, // Optional - null for global categories

// Hierarchy (DAG with multiple parents)
parents: ObjectId[], // Array of parent category IDs
level: "category" | "subcategory" | "tag",
depth: number, // 0=root, 1=first level, etc.
path: string, // e.g., "/electronics/computers"

// Content
title: string,
slug: string,
description: string,

// Visual metadata
icon?: string, // Material icon name
color?: string, // Hex color (#FF5722)

// Media
thumbnail: string,
files: string[],

// Publication
publicationDate: Date,
protection: Protection,
active: boolean
}

Benefits:

  • Global categories shared across all modules
  • Module-specific categories when needed
  • Unlimited hierarchy depth with tracking
  • Category type system (category → subcategory → tag)
  • Visual metadata for better UX
  • Proper ordering
  • Full path for breadcrumbs

Database Migration

Step 1: Backup Current Data

# Backup current categories collection
mongodump --db=baldr --collection=categories --out=/backup/category-migration-$(date +%Y%m%d)

Step 2: Create Migration Script

Create /scripts/migrations/migrate-categories.ts:

import mongoose from 'mongoose';
import Category from '../models/category.model';
import { ICategory } from '../interfaces/category.interface';

async function migrateCategories() {
try {
await mongoose.connect(process.env.MONGODB_URI!);

console.log('Starting category migration...');

const categories = await Category.find({});
console.log(`Found ${categories.length} categories to migrate`);

for (const category of categories) {
const updates: any = {
// Rename modules → parents
parents: (category as any).modules || [],

// Determine level based on depth
level: determineLevel(category),

// Calculate depth (0 for root, 1+ for children)
depth: calculateDepth(category, categories),

// Generate path
path: generatePath(category, categories),

// Set default order
order: 0,

// Set isGlobal flag
isGlobal: false, // Default to module-specific

// module_id is already optional, no change needed
};

await Category.updateOne(
{ _id: category._id },
{ $set: updates, $unset: { modules: "" } }
);

console.log(`Migrated category: ${category.title}`);
}

console.log('Migration completed successfully!');

} catch (error) {
console.error('Migration failed:', error);
throw error;
} finally {
await mongoose.disconnect();
}
}

function determineLevel(category: any): 'category' | 'subcategory' | 'tag' {
const depth = category.modules?.length || 0;
if (depth === 0) return 'category';
if (depth === 1) return 'subcategory';
return 'tag';
}

function calculateDepth(category: any, allCategories: any[]): number {
const parents = category.modules || [];
if (parents.length === 0) return 0;

// Find max depth among all parents
let maxDepth = 0;
for (const parentId of parents) {
const parent = allCategories.find(c => c._id.toString() === parentId.toString());
if (parent) {
const parentDepth = calculateDepth(parent, allCategories);
maxDepth = Math.max(maxDepth, parentDepth + 1);
}
}

return maxDepth;
}

function generatePath(category: any, allCategories: any[]): string {
const parents = category.modules || [];
if (parents.length === 0) return `/${category.slug}`;

// Use first parent for path (handle multiple parents by picking one)
const firstParent = allCategories.find(c => c._id.toString() === parents[0].toString());
if (!firstParent) return `/${category.slug}`;

const parentPath = generatePath(firstParent, allCategories);
return `${parentPath}/${category.slug}`;
}

// Run migration
migrateCategories()
.then(() => process.exit(0))
.catch(() => process.exit(1));

Step 3: Run Migration

cd BaldrTs
npm run ts-node scripts/migrations/migrate-categories.ts

Step 4: Verify Migration

# Check that old 'modules' field is removed
mongo baldr --eval "db.categories.findOne({modules: {\$exists: true}})"

# Check that new fields exist
mongo baldr --eval "db.categories.findOne({parents: {\$exists: true}, level: {\$exists: true}})"

# Count categories by level
mongo baldr --eval "db.categories.aggregate([{\$group: {_id: '\$level', count: {\$sum: 1}}}])"

Product Model Updates

Step 5: Update Product Models

Currently, 9 models have separate categories and subcategories fields:

  • product, news, event, gallery, vehicle, wine, partner, job, room

Option A: Simple Migration (Recommended) Keep both fields but deprecate subcategories:

// In product.model.ts (and other 8 models)
{
categories: {
type: [Schema.Types.ObjectId],
ref: "Category",
required: false,
default: [],
},
// Keep for backward compatibility but deprecate
subcategories: {
type: [Schema.Types.ObjectId],
ref: "Category",
required: false,
default: [],
// Mark as deprecated in comments
},
}

Option B: Full Migration Merge subcategories into categories:

// Migration script
for (const product of products) {
const allCategories = [...product.categories, ...product.subcategories];
await Product.updateOne(
{ _id: product._id },
{
$set: { categories: allCategories },
$unset: { subcategories: "" }
}
);
}

API Endpoints

New Hierarchy Endpoints

All endpoints added and working:

// Get root categories (no parents)
GET /category/roots?moduleId=xxx
Response: ICategory[]

// Get children of a category
GET /category/children/:id
Response: ICategory[]

// Get full path for a category
GET /category/path/:id
Response: ICategory[] // [root, parent, ..., current]

// Get hierarchical tree
GET /category/tree?moduleId=xxx&maxDepth=3
Response: ICategory[] // with nested children property

// Get categories by module (includes global)
GET /category/module/:id
Response: ICategory[]

Updated Endpoints

Existing endpoints work with new schema:

POST /category         // Get all (with filters)
POST /category/search // Search categories
GET /category/:id // Get by ID
GET /category/slug/:slug
POST /category/new // Create
PUT /category/edit/:id
DELETE /category/delete
PUT /category/toggle

Frontend Updates

Step 6: Update Category Forms

Update categoryFormInformations.organism.tsx:

// OLD:
<MultiSelect
value={formData.modules}
onChange={(e) => handleFieldChange("modules", e.value)}
options={availableCategories.map(cat => ({
label: cat.title,
value: cat._id,
}))}
placeholder="Sélectionner les catégories parentes"
/>

// NEW:
<MultiSelect
value={formData.parents}
onChange={(e) => handleFieldChange("parents", e.value)}
options={availableCategories.map(cat => ({
label: `${" ".repeat(cat.depth)}${cat.title} (${cat.level})`,
value: cat._id,
}))}
placeholder="Sélectionner les catégories parentes"
/>

// Add new fields:
<Dropdown
value={formData.level}
onChange={(e) => handleFieldChange("level", e.value)}
options={[
{ label: "Catégorie", value: "category" },
{ label: "Sous-catégorie", value: "subcategory" },
{ label: "Tag", value: "tag" },
]}
/>

<InputNumber
value={formData.order}
onChange={(e) => handleFieldChange("order", e.value)}
placeholder="Ordre d'affichage"
/>

<InputSwitch
checked={formData.isGlobal}
onChange={(e) => handleFieldChange("isGlobal", e.value)}
/>

<InputText
value={formData.icon}
onChange={(e) => handleFieldChange("icon", e.target.value)}
placeholder="Nom de l'icône Material"
/>

<ColorPicker
value={formData.color}
onChange={(e) => handleFieldChange("color", e.value)}
/>

Step 7: Update Product Forms

Update productFormInformation.organism.tsx to fetch real categories:

// REMOVE hardcoded categories:
/*
const categoryOptions = [
{ label: "Catégorie 1", value: "1" },
{ label: "Catégorie 2", value: "2" },
];
*/

// ADD real category fetching:
const { data: categories = [] } = useQuery({
queryKey: ["categories", moduleId],
queryFn: () => Category.getCategoriesByModule(moduleId),
staleTime: 300000,
});

const categoryOptions = categories
.filter(cat => cat.level === "category" || cat.level === "subcategory")
.map(cat => ({
label: `${" ".repeat(cat.depth)}${cat.title}`,
value: cat._id,
}));

const tagOptions = categories
.filter(cat => cat.level === "tag")
.map(cat => ({
label: cat.title,
value: cat._id,
}));

// Use single categories field (not separate subcategories):
<MultiSelect
value={formData.categories}
onChange={(e) => handleFieldChange("categories", e.value)}
options={categoryOptions}
placeholder="Sélectionner les catégories"
/>

Repeat for all 9 modules: news, event, gallery, vehicle, wine, partner, job, room.

Step 8: Update Category List

Update categoryList.page.tsx:

// Add new columns in TableManager:
{
field: "level",
header: "Type",
body: (rowData) => {
const badges = {
category: { label: "Catégorie", severity: "info" },
subcategory: { label: "Sous-cat.", severity: "success" },
tag: { label: "Tag", severity: "warning" },
};
const badge = badges[rowData.level];
return <Tag value={badge.label} severity={badge.severity} />;
},
},
{
field: "depth",
header: "Niveau",
body: (rowData) => rowData.depth,
},
{
field: "order",
header: "Ordre",
body: (rowData) => rowData.order,
},
{
field: "isGlobal",
header: "Global",
body: (rowData) => (
<i className={`pi ${rowData.isGlobal ? "pi-check" : "pi-times"}`} />
),
},
{
field: "parents",
header: "Parents",
body: (rowData) => {
if (!rowData.parents?.length) return <span className="text-muted">Racine</span>;
// Fetch parent names using getCategoryPath
return <span>{rowData.parents.length} parent(s)</span>;
},
},

Testing Checklist

Backend Tests

  • Categories can have multiple parents
  • Circular reference detection works with BFS
  • Root categories fetch correctly (empty parents array)
  • Children endpoint returns correct children
  • Path endpoint returns full hierarchy
  • Tree endpoint builds nested structure
  • Global categories appear for all modules
  • Module-specific categories filter correctly
  • Depth calculation is accurate
  • Path generation handles multiple parents

Frontend Tests

  • Category form shows new fields (level, order, isGlobal, icon, color)
  • Multiple parent selection works
  • Circular reference warning appears
  • Product forms fetch real categories (not hardcoded)
  • Category list displays new columns
  • Category filtering by level works
  • Category search includes new fields
  • Breadcrumbs use path field
  • Icons and colors display correctly

Integration Tests

  • Create category with multiple parents
  • Create subcategory under category
  • Create tag under subcategory
  • Create global category (visible in all modules)
  • Assign global category to product
  • Assign module-specific category to product
  • Delete category with children (handle gracefully)
  • Update category parents (no circular refs)
  • Filter products by category
  • Export categories with hierarchy

Rollback Plan

If issues occur:

# Restore backup
mongorestore --db=baldr --collection=categories /backup/category-migration-YYYYMMDD/baldr/categories.bson

# Revert code changes
git checkout HEAD~1 -- BaldrTs/src/interfaces/category.interface.ts
git checkout HEAD~1 -- BaldrTs/src/models/category.model.ts
git checkout HEAD~1 -- BaldrTs/src/controllers/category.controller.ts
git checkout HEAD~1 -- BaldrTs/src/routes/category.route.ts
git checkout HEAD~1 -- Baldr-Bo/app/interfaces/category.interface.ts
git checkout HEAD~1 -- Baldr-Bo/app/schemas/category.schema.ts
git checkout HEAD~1 -- Baldr-Bo/app/api/category.api.ts

# Restart services
npm run dev

Performance Considerations

Indexes

Already added in model:

CategorySchema.index({ module_id: 1, level: 1 });
CategorySchema.index({ parents: 1 });
CategorySchema.index({ slug: 1 });
CategorySchema.index({ isGlobal: 1 });
CategorySchema.index({ path: 1 });

Caching Strategy

// Frontend: Cache categories per module for 5 minutes
const { data: categories } = useQuery({
queryKey: ["categories", moduleId],
queryFn: () => Category.getCategoriesByModule(moduleId),
staleTime: 300000, // 5 minutes
});

// Backend: Consider Redis caching for category trees
// (if performance becomes an issue)

Optimization Tips

  • Use getCategoryTree for displaying full hierarchies (pre-built)
  • Use getRootCategories + getChildren for lazy loading
  • Fetch categories once per form, not per field
  • Cache category options in frontend state

Future Enhancements

  1. Category Usage Tracking: Track which products use each category
  2. Category Merging: Admin tool to merge duplicate categories
  3. Category Import/Export: Bulk operations with CSV/JSON
  4. Category Analytics: Most used categories, empty categories
  5. Smart Suggestions: AI-powered category suggestions for products
  6. Category Images: Support for category banner images
  7. Category SEO: Meta descriptions, OG images for category pages
  8. Category Permissions: Fine-grained access control per category

Summary

Implemented:

  • New category schema with parents, level, depth, path, order, isGlobal
  • Backend API with hierarchy endpoints (roots, children, path, tree)
  • Frontend interfaces and schemas updated
  • BFS circular reference detection
  • Multiple parent support (DAG structure)
  • Infinite hierarchy with proper tracking
  • Global categories shared across modules

To Complete:

  1. Run database migration script
  2. Update all 9 module forms to fetch real categories
  3. Update category form UI with new fields
  4. Test circular reference prevention
  5. Test global vs module-specific categories
  6. Update documentation with examples

📝 Migration Estimate:

  • Database migration: 30 minutes
  • Frontend updates: 2-3 hours (9 modules + category forms)
  • Testing: 1-2 hours
  • Total: ~4-5 hours