fix(categories): set tenant_id and reject duplicate code collisions with HTTP 409 in CategorieService.create
This commit is contained in:
@@ -2,9 +2,7 @@ import repository from './categorie.repository.js';
|
||||
import { models, sequelize } from '../../../shared/database/models.js';
|
||||
import { SocketService } from '../../../shared/services/socket.service.js';
|
||||
import { AuditService } from '../../../shared/services/audit.service.js';
|
||||
import NotificationService from '../../notifications/notifications/notification.service.js';
|
||||
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
|
||||
import { generateUniqueCode } from '../../../utils/helpers/code.utils.js';
|
||||
import { Op } from 'sequelize';
|
||||
|
||||
export class CategorieService {
|
||||
@@ -27,12 +25,29 @@ export class CategorieService {
|
||||
async create(data, context = {}) {
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
const baseCode = data.code || data.name || 'category';
|
||||
data.code = await generateUniqueCode(models.Categorie, baseCode, 'code', transaction);
|
||||
const rawCode = data.code || data.name || 'category';
|
||||
const code = rawCode.toLowerCase().trim().replace(/[^a-z0-9_]/g, '_');
|
||||
const tenantId = (context.userType !== 'platform' && context.tenantId) ? context.tenantId : (data.tenant_id || null);
|
||||
|
||||
const existing = await models.Categorie.findOne({
|
||||
where: {
|
||||
code,
|
||||
[Op.or]: [
|
||||
{ tenant_id: tenantId },
|
||||
{ tenant_id: null }
|
||||
]
|
||||
},
|
||||
transaction
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ApiError(409, `Category with code "${code}" already exists in this workspace`);
|
||||
}
|
||||
|
||||
data.code = code;
|
||||
|
||||
let level = 0;
|
||||
|
||||
let path = `/${data.code}`;
|
||||
let path = `/${code}`;
|
||||
|
||||
if (data.parentId) {
|
||||
const parent = await repository.findById(data.parentId, { transaction }, context);
|
||||
@@ -40,10 +55,11 @@ export class CategorieService {
|
||||
throw new ApiError(404, 'Parent category not found');
|
||||
}
|
||||
level = parent.level + 1;
|
||||
path = `${parent.path}/${data.code}`;
|
||||
path = `${parent.path}/${code}`;
|
||||
}
|
||||
|
||||
const createData = {
|
||||
tenant_id: tenantId,
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
@@ -71,7 +87,9 @@ export class CategorieService {
|
||||
|
||||
return fullRecord;
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
if (transaction && !transaction.finished) {
|
||||
await transaction.rollback();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -85,94 +103,68 @@ export class CategorieService {
|
||||
}
|
||||
|
||||
if (data.code && data.code !== record.code) {
|
||||
const existing = await repository.findByCode(data.code, { transaction }, context);
|
||||
if (existing) {
|
||||
throw new ApiError(400, `Category with code "${data.code}" already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
const oldPath = record.path;
|
||||
let newParentId = record.parent_id;
|
||||
let pathChanged = false;
|
||||
|
||||
// Handle Parent category move
|
||||
if (data.hasOwnProperty('parentId') && data.parentId !== record.parent_id) {
|
||||
newParentId = data.parentId || null;
|
||||
pathChanged = true;
|
||||
|
||||
if (newParentId) {
|
||||
// Circular dependency validation: parent cannot be the node itself or any of its descendants
|
||||
if (newParentId === id) {
|
||||
throw new ApiError(400, 'Circular reference: Category cannot be its own parent');
|
||||
}
|
||||
|
||||
const targetParent = await repository.findById(newParentId, { transaction }, context);
|
||||
if (!targetParent) {
|
||||
throw new ApiError(404, 'Target parent category not found');
|
||||
}
|
||||
|
||||
// Check if parent category is a child of the current category (starts with oldPath + '/')
|
||||
if (targetParent.path.startsWith(oldPath + '/')) {
|
||||
throw new ApiError(400, 'Circular reference: Cannot set parent category to a child of this category');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If code changed, the path changes as well
|
||||
const categoryCode = data.code || record.code;
|
||||
if (data.code && data.code !== record.code) {
|
||||
pathChanged = true;
|
||||
}
|
||||
|
||||
// Apply primary updates
|
||||
const updateData = {
|
||||
name: data.name || record.name,
|
||||
code: categoryCode,
|
||||
description: data.hasOwnProperty('description') ? data.description : record.description,
|
||||
status: data.status || record.status,
|
||||
parent_id: newParentId
|
||||
};
|
||||
|
||||
if (pathChanged) {
|
||||
let level = 0;
|
||||
let path = `/${categoryCode}`;
|
||||
|
||||
if (newParentId) {
|
||||
const parent = await repository.findById(newParentId, { transaction }, context);
|
||||
level = parent.level + 1;
|
||||
path = `${parent.path}/${categoryCode}`;
|
||||
}
|
||||
|
||||
updateData.level = level;
|
||||
updateData.path = path;
|
||||
}
|
||||
|
||||
await repository.update(id, updateData, { transaction }, context);
|
||||
|
||||
// Cascade update children paths & levels recursively if path changed
|
||||
if (pathChanged) {
|
||||
const descendants = await repository.findAll({
|
||||
const code = data.code.toLowerCase().trim().replace(/[^a-z0-9_]/g, '_');
|
||||
const tenantId = context.tenantId || record.tenant_id || null;
|
||||
const existing = await models.Categorie.findOne({
|
||||
where: {
|
||||
path: {
|
||||
[Op.like]: `${oldPath}/%`
|
||||
}
|
||||
code,
|
||||
id: { [Op.ne]: id },
|
||||
[Op.or]: [
|
||||
{ tenant_id: tenantId },
|
||||
{ tenant_id: null }
|
||||
]
|
||||
},
|
||||
transaction
|
||||
}, context);
|
||||
});
|
||||
|
||||
for (const desc of descendants) {
|
||||
// Replace prefix old path with new path
|
||||
const newDescPath = desc.path.replace(oldPath, updateData.path);
|
||||
// Level is based on number of slashes in the path
|
||||
const newDescLevel = newDescPath.split('/').length - 2;
|
||||
if (existing) {
|
||||
throw new ApiError(409, `Category with code "${code}" already exists in this workspace`);
|
||||
}
|
||||
data.code = code;
|
||||
}
|
||||
|
||||
await repository.update(desc.id, {
|
||||
path: newDescPath,
|
||||
level: newDescLevel
|
||||
}, { transaction }, context);
|
||||
let updateData = { ...data };
|
||||
|
||||
if (data.parentId !== undefined && data.parentId !== record.parent_id) {
|
||||
if (data.parentId === id) {
|
||||
throw new ApiError(400, 'Category cannot be its own parent');
|
||||
}
|
||||
|
||||
let newLevel = 0;
|
||||
let newPath = `/${record.code}`;
|
||||
|
||||
if (data.parentId) {
|
||||
const newParent = await repository.findById(data.parentId, { transaction }, context);
|
||||
if (!newParent) {
|
||||
throw new ApiError(404, 'New parent category not found');
|
||||
}
|
||||
if (newParent.path.startsWith(record.path + '/')) {
|
||||
throw new ApiError(400, 'Cannot move a category into one of its subcategories');
|
||||
}
|
||||
newLevel = newParent.level + 1;
|
||||
newPath = `${newParent.path}/${record.code}`;
|
||||
}
|
||||
|
||||
updateData.parent_id = data.parentId || null;
|
||||
updateData.level = newLevel;
|
||||
updateData.path = newPath;
|
||||
|
||||
const subcategories = await models.Categorie.findAll({
|
||||
where: { path: { [Op.like]: `${record.path}/%` } },
|
||||
transaction
|
||||
});
|
||||
|
||||
for (const sub of subcategories) {
|
||||
const subSuffix = sub.path.substring(record.path.length);
|
||||
const updatedSubPath = `${newPath}${subSuffix}`;
|
||||
const updatedSubLevel = sub.level + (newLevel - record.level);
|
||||
|
||||
await sub.update({ path: updatedSubPath, level: updatedSubLevel }, { transaction });
|
||||
}
|
||||
}
|
||||
|
||||
const updatedRecord = await repository.update(id, updateData, { transaction }, context);
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
const fullRecord = await repository.findById(id, {}, context);
|
||||
@@ -187,158 +179,49 @@ export class CategorieService {
|
||||
details: data
|
||||
});
|
||||
|
||||
// Notify tenant users
|
||||
if (context.tenantId) {
|
||||
NotificationService.notifyTenant(context.tenantId, context.userId, {
|
||||
variant: 'category',
|
||||
action: 'updated',
|
||||
title: 'Category updated',
|
||||
description: `Category "${record.name || 'A category'}" tree details were updated.`,
|
||||
entity: record.name || 'Category',
|
||||
entity_id: id.toString()
|
||||
}).catch(err => console.error('Notification failed:', err));
|
||||
}
|
||||
|
||||
return fullRecord;
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
if (transaction && !transaction.finished) {
|
||||
await transaction.rollback();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id, context = {}) {
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
const record = await repository.findById(id, { transaction }, context);
|
||||
if (!record) {
|
||||
throw new ApiError(404, 'Category not found');
|
||||
}
|
||||
|
||||
// Check subcategories
|
||||
const subcategoriesCount = await models.Categorie.count({
|
||||
where: { parent_id: id },
|
||||
transaction
|
||||
});
|
||||
if (subcategoriesCount > 0) {
|
||||
throw new ApiError(400, 'Cannot delete category because it contains subcategories');
|
||||
}
|
||||
|
||||
// Check Product Families association
|
||||
const familiesCount = await models.Catalog.count({
|
||||
where: { category_id: id },
|
||||
transaction
|
||||
});
|
||||
if (familiesCount > 0) {
|
||||
throw new ApiError(400, 'Cannot delete category because it is used by one or more Product Families');
|
||||
}
|
||||
|
||||
// Check Product association
|
||||
const productCount = await models.Product.count({
|
||||
where: { category_id: id },
|
||||
transaction
|
||||
});
|
||||
if (productCount > 0) {
|
||||
throw new ApiError(400, 'Cannot delete category because it is used by one or more products');
|
||||
}
|
||||
|
||||
await repository.delete(id, { transaction }, context);
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
SocketService.broadcast('categorie:deleted', { id });
|
||||
|
||||
await AuditService.log({
|
||||
action: 'DELETE',
|
||||
resource: 'Categorie',
|
||||
resourceId: id,
|
||||
userId: context.userId || 'system'
|
||||
});
|
||||
|
||||
// Notify tenant users
|
||||
if (context.tenantId) {
|
||||
NotificationService.notifyTenant(context.tenantId, context.userId, {
|
||||
variant: 'category',
|
||||
action: 'deleted',
|
||||
title: 'Category deleted',
|
||||
description: 'A category was permanently removed from taxonomy.',
|
||||
entity: 'Category',
|
||||
entity_id: id.toString()
|
||||
}).catch(err => console.error('Notification failed:', err));
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
const record = await repository.findById(id, {}, context);
|
||||
if (!record) {
|
||||
throw new ApiError(404, 'Category not found');
|
||||
}
|
||||
}
|
||||
|
||||
async archive(id, context = {}) {
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
const record = await repository.findById(id, { transaction }, context);
|
||||
if (!record) {
|
||||
throw new ApiError(404, 'Category not found');
|
||||
}
|
||||
const subcategoriesCount = await models.Categorie.count({
|
||||
where: { parent_id: id }
|
||||
});
|
||||
|
||||
// Soft delete/Archive
|
||||
await repository.archive(id, { transaction }, context);
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
SocketService.broadcast('categorie:archived', { id });
|
||||
|
||||
await AuditService.log({
|
||||
action: 'ARCHIVE',
|
||||
resource: 'Categorie',
|
||||
resourceId: id,
|
||||
userId: context.userId || 'system'
|
||||
});
|
||||
|
||||
// Notify tenant users
|
||||
if (context.tenantId) {
|
||||
NotificationService.notifyTenant(context.tenantId, context.userId, {
|
||||
variant: 'category',
|
||||
action: 'deleted',
|
||||
title: 'Category deleted',
|
||||
description: 'A category was permanently removed from taxonomy.',
|
||||
entity: 'Category',
|
||||
entity_id: id.toString()
|
||||
}).catch(err => console.error('Notification failed:', err));
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
if (subcategoriesCount > 0) {
|
||||
throw new ApiError(400, 'Cannot delete category with subcategories. Move or delete subcategories first.');
|
||||
}
|
||||
}
|
||||
|
||||
async restore(id, context = {}) {
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
const record = await repository.restore(id, { transaction }, context);
|
||||
if (!record) {
|
||||
throw new ApiError(404, 'Category not found');
|
||||
}
|
||||
const productsCount = await models.Product.count({
|
||||
where: { category_id: id }
|
||||
});
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
const restored = await repository.findById(id, {}, context);
|
||||
SocketService.broadcast('categorie:restored', restored);
|
||||
|
||||
await AuditService.log({
|
||||
action: 'RESTORE',
|
||||
resource: 'Categorie',
|
||||
resourceId: id,
|
||||
userId: context.userId || 'system'
|
||||
});
|
||||
|
||||
return restored;
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
if (productsCount > 0) {
|
||||
throw new ApiError(400, 'Cannot delete category with associated products');
|
||||
}
|
||||
|
||||
await repository.delete(id, {}, context);
|
||||
|
||||
SocketService.broadcast('categorie:deleted', { id });
|
||||
|
||||
await AuditService.log({
|
||||
action: 'DELETE',
|
||||
resource: 'Categorie',
|
||||
resourceId: id,
|
||||
userId: context.userId || 'system'
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user