fix(categories): set tenant_id and reject duplicate code collisions with HTTP 409 in CategorieService.create

This commit is contained in:
Inamul-hasan-tec
2026-08-12 17:07:26 +05:30
parent acd58f1624
commit 943e706f8d
@@ -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;
}
}