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
getCategoryTreefor displaying full hierarchies (pre-built) - Use
getRootCategories+getChildrenfor lazy loading - Fetch categories once per form, not per field
- Cache category options in frontend state
Future Enhancements
- Category Usage Tracking: Track which products use each category
- Category Merging: Admin tool to merge duplicate categories
- Category Import/Export: Bulk operations with CSV/JSON
- Category Analytics: Most used categories, empty categories
- Smart Suggestions: AI-powered category suggestions for products
- Category Images: Support for category banner images
- Category SEO: Meta descriptions, OG images for category pages
- 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:
- Run database migration script
- Update all 9 module forms to fetch real categories
- Update category form UI with new fields
- Test circular reference prevention
- Test global vs module-specific categories
- 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