Implemented backend for attribute managemnet

This commit is contained in:
m7338ohd-debug
2026-07-13 17:49:01 +05:30
parent e09ca83560
commit 644b828dea
120 changed files with 11322 additions and 416 deletions
+1
View File
@@ -32,6 +32,7 @@ app.get('/health', (req, res) => {
});
// Centralized Feature Router Loader
app.use('/uploads', express.static('uploads'));
registerRoutes(app);
// Global Error Handler
+1 -4
View File
@@ -1,12 +1,9 @@
import dotenv from 'dotenv';
import './src/shared/config/env.js';
import http from 'http';
import app from './app.js';
import { connectDatabase } from './src/shared/database/connection.js';
import { SocketService } from './src/shared/services/socket.service.js';
// Load environment variables
const env = process.env.NODE_ENV || 'local';
dotenv.config({ path: `.env.${env}` });
const PORT = process.env.PORT || 5000;
async function bootstrap() {
@@ -0,0 +1,50 @@
import service from './attributeGroup.service.js';
export class AttributeGroupController {
async getAll(req, res, next) {
try {
const records = await service.getAll(req.query);
return res.status(200).json({ success: true, data: records });
} catch (error) {
next(error);
}
}
async getById(req, res, next) {
try {
const record = await service.getById(req.params.id);
return res.status(200).json({ success: true, data: record });
} catch (error) {
next(error);
}
}
async create(req, res, next) {
try {
const record = await service.create(req.body, req.user);
return res.status(201).json({ success: true, data: record });
} catch (error) {
next(error);
}
}
async update(req, res, next) {
try {
const record = await service.update(req.params.id, req.body, req.user);
return res.status(200).json({ success: true, data: record });
} catch (error) {
next(error);
}
}
async delete(req, res, next) {
try {
await service.delete(req.params.id, req.user);
return res.status(200).json({ success: true, message: 'Attribute Group deleted successfully' });
} catch (error) {
next(error);
}
}
}
export default new AttributeGroupController();
@@ -0,0 +1,54 @@
import { Model, DataTypes } from 'sequelize';
export class AttributeGroup extends Model {
static associate(models) {
// M:N with Attribute
AttributeGroup.belongsToMany(models.Attribute, {
through: models.AttributeGroupAttribute,
foreignKey: 'group_id',
otherKey: 'attribute_id',
as: 'attributes'
});
// M:N with AttributeSet
AttributeGroup.belongsToMany(models.AttributeSet, {
through: models.AttributeSetGroup,
foreignKey: 'attribute_group_id',
otherKey: 'attribute_set_id',
as: 'sets'
});
}
}
export default (sequelize) => {
AttributeGroup.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true
},
name: {
type: DataTypes.STRING(100),
allowNull: false
},
status: {
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'active'
}
}, {
sequelize,
modelName: 'AttributeGroup',
tableName: 'attribute_groups',
timestamps: true,
underscored: true,
paranoid: true
});
return AttributeGroup;
};
@@ -0,0 +1,58 @@
import { models } from '../../../shared/database/models.js';
export class AttributeGroupRepository {
async findAll(options = {}) {
return await models.AttributeGroup.findAll({
include: [
{
model: models.Attribute,
as: 'attributes',
through: { attributes: ['display_order'] }
}
],
order: [
['name', 'ASC']
],
...options
});
}
async findById(id, options = {}) {
return await models.AttributeGroup.findByPk(id, {
include: [
{
model: models.Attribute,
as: 'attributes',
through: { attributes: ['display_order'] }
}
],
...options
});
}
async findByCode(code, options = {}) {
return await models.AttributeGroup.findOne({
where: { code },
...options
});
}
async create(data, options = {}) {
return await models.AttributeGroup.create(data, options);
}
async update(id, data, options = {}) {
const record = await models.AttributeGroup.findByPk(id, options);
if (!record) return null;
return await record.update(data, options);
}
async delete(id, options = {}) {
const record = await models.AttributeGroup.findByPk(id, options);
if (!record) return false;
await record.destroy(options);
return true;
}
}
export default new AttributeGroupRepository();
@@ -0,0 +1,124 @@
import { Router } from 'express';
import controller from './attributeGroup.controller.js';
import { authenticate } from '../../../shared/middleware/auth.middleware.js';
import { validate } from '../../../shared/middleware/validation.middleware.js';
import { authorize } from '../../../shared/middleware/permission.middleware.js';
import { audit } from '../../../shared/middleware/audit.middleware.js';
import {
createValidation,
updateValidation,
deleteValidation,
getByIdValidation
} from './attributeGroup.validation.js';
const router = Router();
/**
* @swagger
* /api/v1/attribute-groups:
* get:
* summary: Retrieve all attribute groups
* tags: [AttributeGroups]
* responses:
* 200:
* description: Success
*/
router.get(
'/',
authenticate,
authorize(['read:attributes']),
controller.getAll
);
/**
* @swagger
* /api/v1/attribute-groups/{id}:
* get:
* summary: Retrieve a single attribute group
* tags: [AttributeGroups]
* parameters:
* - in: path
* name: id
* required: true
* responses:
* 200:
* description: Success
*/
router.get(
'/:id',
authenticate,
authorize(['read:attributes']),
getByIdValidation,
validate,
controller.getById
);
/**
* @swagger
* /api/v1/attribute-groups:
* post:
* summary: Create an attribute group
* tags: [AttributeGroups]
* responses:
* 201:
* description: Success
*/
router.post(
'/',
authenticate,
authorize(['write:attributes']),
createValidation,
validate,
audit('CREATE_ATTRIBUTE_GROUP'),
controller.create
);
/**
* @swagger
* /api/v1/attribute-groups/{id}:
* put:
* summary: Update an attribute group
* tags: [AttributeGroups]
* parameters:
* - in: path
* name: id
* required: true
* responses:
* 200:
* description: Success
*/
router.put(
'/:id',
authenticate,
authorize(['write:attributes']),
updateValidation,
validate,
audit('UPDATE_ATTRIBUTE_GROUP'),
controller.update
);
/**
* @swagger
* /api/v1/attribute-groups/{id}:
* delete:
* summary: Delete an attribute group
* tags: [AttributeGroups]
* parameters:
* - in: path
* name: id
* required: true
* responses:
* 200:
* description: Success
*/
router.delete(
'/:id',
authenticate,
authorize(['write:attributes']),
deleteValidation,
validate,
audit('DELETE_ATTRIBUTE_GROUP'),
controller.delete
);
export default router;
@@ -0,0 +1,190 @@
import repository from './attributeGroup.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';
export class AttributeGroupService {
async getAll(query = {}) {
const where = {};
if (query.status) {
where.status = query.status;
}
return await repository.findAll({ where });
}
async getById(id) {
const record = await repository.findById(id);
if (!record) {
throw new Error('Attribute Group not found');
}
return record;
}
async create(data, userContext = {}) {
const transaction = await sequelize.transaction();
try {
// Check duplicate code
const existing = await models.AttributeGroup.findOne({ where: { code: data.code }, transaction });
if (existing) {
throw new Error(`Attribute Group with code "${data.code}" already exists`);
}
const record = await models.AttributeGroup.create(data, { transaction });
// Handle attributes linking
if (data.attributes && Array.isArray(data.attributes)) {
for (let i = 0; i < data.attributes.length; i++) {
const attributeId = data.attributes[i];
await models.AttributeGroupAttribute.create({
group_id: record.id,
attribute_id: attributeId,
display_order: i
}, { transaction });
}
}
await transaction.commit();
const fullRecord = await repository.findById(record.id);
SocketService.broadcast('attributeGroup:created', fullRecord);
SocketService.broadcast('attributeGroup.created', fullRecord);
SocketService.broadcast('attribute.group.created', fullRecord);
await AuditService.log({
action: 'CREATE',
resource: 'AttributeGroup',
resourceId: record.id,
userId: userContext.id || 'system',
details: data
});
if (models.AttributeGroupHistory) {
await models.AttributeGroupHistory.create({
group_id: record.id,
action: 'CREATE',
changed_by: userContext.id || null,
changes: { newValues: fullRecord.toJSON() }
});
}
return fullRecord;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async update(id, data, userContext = {}) {
const transaction = await sequelize.transaction();
try {
const record = await models.AttributeGroup.findByPk(id, { transaction });
if (!record) {
throw new Error('Attribute Group not found');
}
if (data.code && data.code !== record.code) {
const existing = await models.AttributeGroup.findOne({ where: { code: data.code }, transaction });
if (existing) {
throw new Error(`Attribute Group with code "${data.code}" already exists`);
}
}
await record.update(data, { transaction });
// Handle attributes updating
if (data.attributes && Array.isArray(data.attributes)) {
// Clear previous associations
await models.AttributeGroupAttribute.destroy({ where: { group_id: id }, transaction });
for (let i = 0; i < data.attributes.length; i++) {
const attributeId = data.attributes[i];
await models.AttributeGroupAttribute.create({
group_id: id,
attribute_id: attributeId,
display_order: i
}, { transaction });
}
}
await transaction.commit();
const fullRecord = await repository.findById(id);
SocketService.broadcast('attributeGroup:updated', fullRecord);
SocketService.broadcast('attributeGroup.updated', fullRecord);
SocketService.broadcast('attribute.group.updated', fullRecord);
await AuditService.log({
action: 'UPDATE',
resource: 'AttributeGroup',
resourceId: id,
userId: userContext.id || 'system',
details: data
});
if (models.AttributeGroupHistory) {
await models.AttributeGroupHistory.create({
group_id: id,
action: 'UPDATE',
changed_by: userContext.id || null,
changes: { newValues: fullRecord.toJSON() }
});
}
return fullRecord;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async delete(id, userContext = {}) {
const transaction = await sequelize.transaction();
try {
const record = await models.AttributeGroup.findByPk(id, { transaction });
if (!record) {
throw new Error('Attribute Group not found');
}
// Check if group is assigned in any Attribute Set
const setAssociationCount = await models.AttributeSetGroup.count({ where: { attribute_group_id: id }, transaction });
if (setAssociationCount > 0) {
throw new Error('Cannot delete Attribute Group as it is associated with one or more Attribute Sets');
}
// Clear child associations
await models.AttributeGroupAttribute.destroy({ where: { group_id: id }, transaction });
await record.destroy({ transaction });
await transaction.commit();
SocketService.broadcast('attributeGroup:deleted', { id });
SocketService.broadcast('attributeGroup.deleted', { id });
SocketService.broadcast('attribute.group.deleted', { id });
await AuditService.log({
action: 'DELETE',
resource: 'AttributeGroup',
resourceId: id,
userId: userContext.id || 'system'
});
if (models.AttributeGroupHistory) {
await models.AttributeGroupHistory.create({
group_id: id,
action: 'DELETE',
changed_by: userContext.id || null,
changes: { deletedId: id }
});
}
return true;
} catch (error) {
await transaction.rollback();
throw error;
}
}
}
export default new AttributeGroupService();
@@ -0,0 +1,54 @@
import { body, param } from 'express-validator';
export const createValidation = [
body('code')
.isString()
.trim()
.notEmpty()
.withMessage('Code is required')
.matches(/^[a-z0-9_]+$/)
.withMessage('Code must be lowercase alphanumeric and underscores only'),
body('name')
.isString()
.trim()
.notEmpty()
.withMessage('Name is required'),
body('attributes')
.optional()
.isArray()
.withMessage('Attributes must be an array of attribute IDs')
];
export const updateValidation = [
param('id')
.isUUID()
.withMessage('Valid UUID is required'),
body('code')
.optional()
.isString()
.trim()
.matches(/^[a-z0-9_]+$/)
.withMessage('Code must be lowercase alphanumeric and underscores only'),
body('name')
.optional()
.isString()
.trim()
.notEmpty()
.withMessage('Name cannot be empty'),
body('attributes')
.optional()
.isArray()
.withMessage('Attributes must be an array of attribute IDs')
];
export const deleteValidation = [
param('id')
.isUUID()
.withMessage('Valid UUID is required')
];
export const getByIdValidation = [
param('id')
.isUUID()
.withMessage('Valid UUID is required')
];
@@ -0,0 +1,33 @@
import { Model, DataTypes } from 'sequelize';
export class AttributeGroupAttribute extends Model {
static associate(models) {}
}
export default (sequelize) => {
AttributeGroupAttribute.init({
group_id: {
type: DataTypes.UUID,
allowNull: false,
primaryKey: true
},
attribute_id: {
type: DataTypes.UUID,
allowNull: false,
primaryKey: true
},
display_order: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
}
}, {
sequelize,
modelName: 'AttributeGroupAttribute',
tableName: 'attribute_group_attributes',
timestamps: true,
underscored: true
});
return AttributeGroupAttribute;
};
@@ -0,0 +1,39 @@
import { Model, DataTypes } from 'sequelize';
export class AttributeGroupHistory extends Model {}
export default (sequelize) => {
AttributeGroupHistory.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
group_id: {
type: DataTypes.UUID,
allowNull: false
},
action: {
type: DataTypes.STRING(50),
allowNull: false
},
changed_by: {
type: DataTypes.UUID,
allowNull: true
},
changes: {
type: DataTypes.JSONB,
allowNull: false
}
}, {
sequelize,
modelName: 'AttributeGroupHistory',
tableName: 'attribute_group_history',
timestamps: true,
updatedAt: false,
underscored: true
});
return AttributeGroupHistory;
};
@@ -0,0 +1,50 @@
import service from './attributeSet.service.js';
export class AttributeSetController {
async getAll(req, res, next) {
try {
const records = await service.getAll(req.query);
return res.status(200).json({ success: true, data: records });
} catch (error) {
next(error);
}
}
async getById(req, res, next) {
try {
const record = await service.getById(req.params.id);
return res.status(200).json({ success: true, data: record });
} catch (error) {
next(error);
}
}
async create(req, res, next) {
try {
const record = await service.create(req.body, req.user);
return res.status(201).json({ success: true, data: record });
} catch (error) {
next(error);
}
}
async update(req, res, next) {
try {
const record = await service.update(req.params.id, req.body, req.user);
return res.status(200).json({ success: true, data: record });
} catch (error) {
next(error);
}
}
async delete(req, res, next) {
try {
await service.delete(req.params.id, req.user);
return res.status(200).json({ success: true, message: 'Attribute Set deleted successfully' });
} catch (error) {
next(error);
}
}
}
export default new AttributeSetController();
@@ -0,0 +1,51 @@
import { Model, DataTypes } from 'sequelize';
export class AttributeSet extends Model {
static associate(models) {
// M:N with AttributeGroup
AttributeSet.belongsToMany(models.AttributeGroup, {
through: models.AttributeSetGroup,
foreignKey: 'attribute_set_id',
otherKey: 'attribute_group_id',
as: 'groups'
});
}
}
export default (sequelize) => {
AttributeSet.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true
},
name: {
type: DataTypes.STRING(100),
allowNull: false
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
status: {
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'active'
}
}, {
sequelize,
modelName: 'AttributeSet',
tableName: 'attribute_sets',
timestamps: true,
underscored: true,
paranoid: true
});
return AttributeSet;
};
@@ -0,0 +1,72 @@
import { models } from '../../../shared/database/models.js';
export class AttributeSetRepository {
async findAll(options = {}) {
return await models.AttributeSet.findAll({
include: [
{
model: models.AttributeGroup,
as: 'groups',
through: { attributes: ['display_order'] },
include: [
{
model: models.Attribute,
as: 'attributes',
through: { attributes: ['display_order'] }
}
]
}
],
order: [
['name', 'ASC']
],
...options
});
}
async findById(id, options = {}) {
return await models.AttributeSet.findByPk(id, {
include: [
{
model: models.AttributeGroup,
as: 'groups',
through: { attributes: ['display_order'] },
include: [
{
model: models.Attribute,
as: 'attributes',
through: { attributes: ['display_order'] }
}
]
}
],
...options
});
}
async findByCode(code, options = {}) {
return await models.AttributeSet.findOne({
where: { code },
...options
});
}
async create(data, options = {}) {
return await models.AttributeSet.create(data, options);
}
async update(id, data, options = {}) {
const record = await models.AttributeSet.findByPk(id, options);
if (!record) return null;
return await record.update(data, options);
}
async delete(id, options = {}) {
const record = await models.AttributeSet.findByPk(id, options);
if (!record) return false;
await record.destroy(options);
return true;
}
}
export default new AttributeSetRepository();
@@ -0,0 +1,133 @@
import { Router } from 'express';
import controller from './attributeSet.controller.js';
import { authenticate } from '../../../shared/middleware/auth.middleware.js';
import { validate } from '../../../shared/middleware/validation.middleware.js';
import { authorize } from '../../../shared/middleware/permission.middleware.js';
import { audit } from '../../../shared/middleware/audit.middleware.js';
import {
createValidation,
updateValidation,
deleteValidation,
getByIdValidation
} from './attributeSet.validation.js';
const router = Router();
/**
* @swagger
* /api/v1/attribute-sets:
* get:
* summary: Retrieve all attribute sets
* tags: [AttributeSets]
* responses:
* 200:
* description: Success
*/
router.get(
'/',
authenticate,
authorize(['read:attributes']),
controller.getAll
);
/**
* @swagger
* /api/v1/attribute-sets/{id}:
* get:
* summary: Retrieve a single attribute set
* tags: [AttributeSets]
* parameters:
* - in: path
* name: id
* required: true
* responses:
* 200:
* description: Success
*/
router.get(
'/:id',
authenticate,
authorize(['read:attributes']),
getByIdValidation,
validate,
controller.getById
);
router.get(
'/:id/structure',
authenticate,
authorize(['read:attributes']),
getByIdValidation,
validate,
controller.getById
);
/**
* @swagger
* /api/v1/attribute-sets:
* post:
* summary: Create an attribute set
* tags: [AttributeSets]
* responses:
* 201:
* description: Success
*/
router.post(
'/',
authenticate,
authorize(['write:attributes']),
createValidation,
validate,
audit('CREATE_ATTRIBUTE_SET'),
controller.create
);
/**
* @swagger
* /api/v1/attribute-sets/{id}:
* put:
* summary: Update an attribute set
* tags: [AttributeSets]
* parameters:
* - in: path
* name: id
* required: true
* responses:
* 200:
* description: Success
*/
router.put(
'/:id',
authenticate,
authorize(['write:attributes']),
updateValidation,
validate,
audit('UPDATE_ATTRIBUTE_SET'),
controller.update
);
/**
* @swagger
* /api/v1/attribute-sets/{id}:
* delete:
* summary: Delete an attribute set
* tags: [AttributeSets]
* parameters:
* - in: path
* name: id
* required: true
* responses:
* 200:
* description: Success
*/
router.delete(
'/:id',
authenticate,
authorize(['write:attributes']),
deleteValidation,
validate,
audit('DELETE_ATTRIBUTE_SET'),
controller.delete
);
export default router;
@@ -0,0 +1,184 @@
import repository from './attributeSet.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';
export class AttributeSetService {
async getAll(query = {}) {
const where = {};
if (query.status) {
where.status = query.status;
}
return await repository.findAll({ where });
}
async getById(id) {
const record = await repository.findById(id);
if (!record) {
throw new Error('Attribute Set not found');
}
return record;
}
async create(data, userContext = {}) {
const transaction = await sequelize.transaction();
try {
// Check duplicate code
const existing = await models.AttributeSet.findOne({ where: { code: data.code }, transaction });
if (existing) {
throw new Error(`Attribute Set with code "${data.code}" already exists`);
}
const record = await models.AttributeSet.create(data, { transaction });
// Handle groups linking
if (data.groups && Array.isArray(data.groups)) {
for (let i = 0; i < data.groups.length; i++) {
const groupId = data.groups[i];
await models.AttributeSetGroup.create({
attribute_set_id: record.id,
attribute_group_id: groupId,
display_order: i
}, { transaction });
}
}
await transaction.commit();
const fullRecord = await repository.findById(record.id);
SocketService.broadcast('attributeSet:created', fullRecord);
SocketService.broadcast('attributeSet.created', fullRecord);
SocketService.broadcast('attribute.set.created', fullRecord);
await AuditService.log({
action: 'CREATE',
resource: 'AttributeSet',
resourceId: record.id,
userId: userContext.id || 'system',
details: data
});
if (models.AttributeSetHistory) {
await models.AttributeSetHistory.create({
set_id: record.id,
action: 'CREATE',
changed_by: userContext.id || null,
changes: { newValues: fullRecord.toJSON() }
});
}
return fullRecord;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async update(id, data, userContext = {}) {
const transaction = await sequelize.transaction();
try {
const record = await models.AttributeSet.findByPk(id, { transaction });
if (!record) {
throw new Error('Attribute Set not found');
}
if (data.code && data.code !== record.code) {
const existing = await models.AttributeSet.findOne({ where: { code: data.code }, transaction });
if (existing) {
throw new Error(`Attribute Set with code "${data.code}" already exists`);
}
}
await record.update(data, { transaction });
// Handle groups updating
if (data.groups && Array.isArray(data.groups)) {
// Clear previous associations
await models.AttributeSetGroup.destroy({ where: { attribute_set_id: id }, transaction });
for (let i = 0; i < data.groups.length; i++) {
const groupId = data.groups[i];
await models.AttributeSetGroup.create({
attribute_set_id: id,
attribute_group_id: groupId,
display_order: i
}, { transaction });
}
}
await transaction.commit();
const fullRecord = await repository.findById(id);
SocketService.broadcast('attributeSet:updated', fullRecord);
SocketService.broadcast('attributeSet.updated', fullRecord);
SocketService.broadcast('attribute.set.updated', fullRecord);
await AuditService.log({
action: 'UPDATE',
resource: 'AttributeSet',
resourceId: id,
userId: userContext.id || 'system',
details: data
});
if (models.AttributeSetHistory) {
await models.AttributeSetHistory.create({
set_id: id,
action: 'UPDATE',
changed_by: userContext.id || null,
changes: { newValues: fullRecord.toJSON() }
});
}
return fullRecord;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async delete(id, userContext = {}) {
const transaction = await sequelize.transaction();
try {
const record = await models.AttributeSet.findByPk(id, { transaction });
if (!record) {
throw new Error('Attribute Set not found');
}
// Clear child associations
await models.AttributeSetGroup.destroy({ where: { attribute_set_id: id }, transaction });
await record.destroy({ transaction });
await transaction.commit();
SocketService.broadcast('attributeSet:deleted', { id });
SocketService.broadcast('attributeSet.deleted', { id });
SocketService.broadcast('attribute.set.deleted', { id });
await AuditService.log({
action: 'DELETE',
resource: 'AttributeSet',
resourceId: id,
userId: userContext.id || 'system'
});
if (models.AttributeSetHistory) {
await models.AttributeSetHistory.create({
set_id: id,
action: 'DELETE',
changed_by: userContext.id || null,
changes: { deletedId: id }
});
}
return true;
} catch (error) {
await transaction.rollback();
throw error;
}
}
}
export default new AttributeSetService();
@@ -0,0 +1,54 @@
import { body, param } from 'express-validator';
export const createValidation = [
body('code')
.isString()
.trim()
.notEmpty()
.withMessage('Code is required')
.matches(/^[a-z0-9_]+$/)
.withMessage('Code must be lowercase alphanumeric and underscores only'),
body('name')
.isString()
.trim()
.notEmpty()
.withMessage('Name is required'),
body('groups')
.optional()
.isArray()
.withMessage('Groups must be an array of group IDs')
];
export const updateValidation = [
param('id')
.isUUID()
.withMessage('Valid UUID is required'),
body('code')
.optional()
.isString()
.trim()
.matches(/^[a-z0-9_]+$/)
.withMessage('Code must be lowercase alphanumeric and underscores only'),
body('name')
.optional()
.isString()
.trim()
.notEmpty()
.withMessage('Name cannot be empty'),
body('groups')
.optional()
.isArray()
.withMessage('Groups must be an array of group IDs')
];
export const deleteValidation = [
param('id')
.isUUID()
.withMessage('Valid UUID is required')
];
export const getByIdValidation = [
param('id')
.isUUID()
.withMessage('Valid UUID is required')
];
@@ -0,0 +1,33 @@
import { Model, DataTypes } from 'sequelize';
export class AttributeSetGroup extends Model {
static associate(models) {}
}
export default (sequelize) => {
AttributeSetGroup.init({
attribute_set_id: {
type: DataTypes.UUID,
allowNull: false,
primaryKey: true
},
attribute_group_id: {
type: DataTypes.UUID,
allowNull: false,
primaryKey: true
},
display_order: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
}
}, {
sequelize,
modelName: 'AttributeSetGroup',
tableName: 'attribute_set_groups',
timestamps: true,
underscored: true
});
return AttributeSetGroup;
};
@@ -0,0 +1,39 @@
import { Model, DataTypes } from 'sequelize';
export class AttributeSetHistory extends Model {}
export default (sequelize) => {
AttributeSetHistory.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
set_id: {
type: DataTypes.UUID,
allowNull: false
},
action: {
type: DataTypes.STRING(50),
allowNull: false
},
changed_by: {
type: DataTypes.UUID,
allowNull: true
},
changes: {
type: DataTypes.JSONB,
allowNull: false
}
}, {
sequelize,
modelName: 'AttributeSetHistory',
tableName: 'attribute_set_history',
timestamps: true,
updatedAt: false,
underscored: true
});
return AttributeSetHistory;
};
@@ -1,10 +1,75 @@
import service from './attribute.service.js';
function serializeAttribute(attr) {
if (!attr) return null;
const json = attr.toJSON ? attr.toJSON() : { ...attr };
// Mapping to camelCase
json.isRequired = json.is_required ?? false;
json.isUnique = json.is_unique ?? false;
json.isLocalizable = json.is_localizable ?? false;
json.isVariantEligible = json.is_variant_eligible ?? false;
json.isSearchable = json.is_searchable ?? false;
json.isFilterable = json.is_filterable ?? false;
json.isChannelSpecific = json.is_channel_specific ?? false;
json.minLength = json.min_length ?? 0;
json.maxLength = json.max_length ?? 255;
json.regexPattern = json.regex_pattern ?? null;
json.defaultValue = json.default_value ?? null;
json.displayOrder = json.display_order ?? 0;
// Extract primary mapped group code for frontend consumption
json.group = json.groups && json.groups[0] ? json.groups[0].code : null;
// Cleanup snake_case database columns from output response
delete json.is_required;
delete json.is_unique;
delete json.is_localizable;
delete json.is_variant_eligible;
delete json.is_searchable;
delete json.is_filterable;
delete json.is_channel_specific;
delete json.min_length;
delete json.max_length;
delete json.regex_pattern;
delete json.default_value;
delete json.display_order;
return json;
}
function deserializeAttribute(data) {
if (!data) return {};
const out = { ...data };
if (out.isRequired !== undefined) { out.is_required = out.isRequired; delete out.isRequired; }
if (out.isUnique !== undefined) { out.is_unique = out.isUnique; delete out.isUnique; }
if (out.isLocalizable !== undefined) { out.is_localizable = out.isLocalizable; delete out.isLocalizable; }
if (out.isVariantEligible !== undefined) { out.is_variant_eligible = out.isVariantEligible; delete out.isVariantEligible; }
if (out.isSearchable !== undefined) { out.is_searchable = out.isSearchable; delete out.isSearchable; }
if (out.isFilterable !== undefined) { out.is_filterable = out.isFilterable; delete out.isFilterable; }
if (out.isChannelSpecific !== undefined) { out.is_channel_specific = out.isChannelSpecific; delete out.isChannelSpecific; }
if (out.minLength !== undefined) { out.min_length = out.minLength; delete out.minLength; }
if (out.maxLength !== undefined) { out.max_length = out.maxLength; delete out.maxLength; }
if (out.regexPattern !== undefined) { out.regex_pattern = out.regexPattern; delete out.regexPattern; }
if (out.defaultValue !== undefined) { out.default_value = out.defaultValue; delete out.defaultValue; }
if (out.displayOrder !== undefined) { out.display_order = out.displayOrder; delete out.displayOrder; }
return out;
}
export class AttributeController {
async getAll(req, res, next) {
try {
const records = await service.getAll(req.query);
return res.status(200).json({ success: true, data: records });
const { records, pagination } = await service.getAll(req.query);
const data = records.map(serializeAttribute);
return res.status(200).json({
success: true,
message: 'Attributes fetched successfully',
data,
pagination,
timestamp: new Date()
});
} catch (error) {
next(error);
}
@@ -13,7 +78,12 @@ export class AttributeController {
async getById(req, res, next) {
try {
const record = await service.getById(req.params.id);
return res.status(200).json({ success: true, data: record });
return res.status(200).json({
success: true,
message: 'Attribute fetched successfully',
data: serializeAttribute(record),
timestamp: new Date()
});
} catch (error) {
next(error);
}
@@ -21,8 +91,14 @@ export class AttributeController {
async create(req, res, next) {
try {
const record = await service.create(req.body, req.user);
return res.status(201).json({ success: true, data: record });
const mappedPayload = deserializeAttribute(req.body);
const record = await service.create(mappedPayload, req.user);
return res.status(201).json({
success: true,
message: 'Attribute created successfully',
data: serializeAttribute(record),
timestamp: new Date()
});
} catch (error) {
next(error);
}
@@ -30,8 +106,14 @@ export class AttributeController {
async update(req, res, next) {
try {
const record = await service.update(req.params.id, req.body, req.user);
return res.status(200).json({ success: true, data: record });
const mappedPayload = deserializeAttribute(req.body);
const record = await service.update(req.params.id, mappedPayload, req.user);
return res.status(200).json({
success: true,
message: 'Attribute updated successfully',
data: serializeAttribute(record),
timestamp: new Date()
});
} catch (error) {
next(error);
}
@@ -40,7 +122,25 @@ export class AttributeController {
async delete(req, res, next) {
try {
await service.delete(req.params.id, req.user);
return res.status(200).json({ success: true, message: 'Attribute deleted successfully' });
return res.status(200).json({
success: true,
message: 'Attribute archived successfully',
timestamp: new Date()
});
} catch (error) {
next(error);
}
}
async restore(req, res, next) {
try {
const record = await service.restore(req.params.id, req.user);
return res.status(200).json({
success: true,
message: 'Attribute restored successfully',
data: serializeAttribute(record),
timestamp: new Date()
});
} catch (error) {
next(error);
}
@@ -2,7 +2,33 @@ import { Model, DataTypes } from 'sequelize';
export class Attribute extends Model {
static associate(models) {
// Define associations here
// M:N with AttributeGroup
Attribute.belongsToMany(models.AttributeGroup, {
through: models.AttributeGroupAttribute,
foreignKey: 'attribute_id',
otherKey: 'group_id',
as: 'groups'
});
// M:N with ProductFamily (Catalog)
Attribute.belongsToMany(models.Catalog, {
through: models.FamilyAttribute,
foreignKey: 'attribute_id',
otherKey: 'family_id',
as: 'families'
});
// M:N with ProductFamily for variant axes
Attribute.belongsToMany(models.Catalog, {
through: models.FamilyVariantAxis,
foreignKey: 'attribute_id',
otherKey: 'family_id',
as: 'variantFamilies'
});
// Options mapping association
Attribute.hasMany(models.AttributeOption, {
foreignKey: 'attribute_id',
as: 'optionsList'
});
}
}
@@ -14,24 +40,131 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true
},
name: {
type: DataTypes.STRING,
type: DataTypes.STRING(100),
allowNull: false
},
type: {
type: DataTypes.STRING(20),
allowNull: false
},
is_required: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
is_unique: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
is_localizable: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
is_variant_eligible: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
is_searchable: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
is_filterable: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
is_channel_specific: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
min_length: {
type: DataTypes.INTEGER,
allowNull: true
},
max_length: {
type: DataTypes.INTEGER,
allowNull: true
},
regex_pattern: {
type: DataTypes.STRING(255),
allowNull: true
},
default_value: {
type: DataTypes.STRING(255),
allowNull: true
},
options: {
type: DataTypes.JSONB,
allowNull: true
},
status: {
type: DataTypes.STRING,
defaultValue: 'active'
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'draft'
},
metadata: {
type: DataTypes.JSONB,
display_order: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
created_by: {
type: DataTypes.UUID,
allowNull: true
},
updated_by: {
type: DataTypes.UUID,
allowNull: true
},
deleted_by: {
type: DataTypes.UUID,
allowNull: true
},
help_text: {
type: DataTypes.TEXT,
allowNull: true
},
placeholder: {
type: DataTypes.STRING(255),
allowNull: true
},
sortable: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
visible_in_grid: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true
},
visible_in_product: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true
},
api_visible: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true
}
}, {
sequelize,
modelName: 'Attribute',
tableName: 'attributes',
timestamps: true,
underscored: true
underscored: true,
paranoid: true // Soft deletes support
});
return Attribute;
@@ -123,4 +123,14 @@ router.delete(
controller.delete
);
router.post(
'/:id/restore',
authenticate,
authorize(['write:attributes']),
getByIdValidation,
validate,
audit('RESTORE_ATTRIBUTE'),
controller.restore
);
export default router;
@@ -1,15 +1,126 @@
import repository from './attribute.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 { Op } from 'sequelize';
export class AttributeService {
async getAll(query = {}) {
// Add business logic filtering, pagination, etc.
return await repository.findAll();
const where = {};
let paranoid = true;
// Status filter
if (query.status) {
const statusValue = query.status.toLowerCase();
if (statusValue === 'archived') {
where.deleted_at = { [Op.ne]: null };
paranoid = false;
} else {
where.status = statusValue;
}
}
// Search by Code, Name, Description, Type, Status
if (query.search) {
const searchLike = { [Op.iLike]: `%${query.search}%` };
where[Op.or] = [
{ code: searchLike },
{ name: searchLike },
{ description: searchLike },
{ type: searchLike },
{ status: searchLike }
];
}
// Exact boolean/string filters
if (query.type) where.type = query.type;
if (query.isRequired !== undefined) where.is_required = query.isRequired === 'true' || query.isRequired === true;
if (query.isUnique !== undefined) where.is_unique = query.isUnique === 'true' || query.isUnique === true;
if (query.isSearchable !== undefined) where.is_searchable = query.isSearchable === 'true' || query.isSearchable === true;
if (query.isFilterable !== undefined) where.is_filterable = query.isFilterable === 'true' || query.isFilterable === true;
if (query.isVariantEligible !== undefined) where.is_variant_eligible = query.isVariantEligible === 'true' || query.isVariantEligible === true;
if (query.isLocalizable !== undefined) where.is_localizable = query.isLocalizable === 'true' || query.isLocalizable === true;
if (query.isChannelSpecific !== undefined) where.is_channel_specific = query.isChannelSpecific === 'true' || query.isChannelSpecific === true;
// Date filters
if (query.createdDate) {
where.created_at = { [Op.gte]: new Date(query.createdDate) };
}
if (query.updatedDate) {
where.updated_at = { [Op.gte]: new Date(query.updatedDate) };
}
// Sorting
let order = [['display_order', 'ASC']];
if (query.sortBy) {
const direction = query.sortDir?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
if (query.sortBy === 'name') order = [['name', direction]];
else if (query.sortBy === 'code') order = [['code', direction]];
else if (query.sortBy === 'createdDate') order = [['created_at', direction]];
else if (query.sortBy === 'updatedDate') order = [['updated_at', direction]];
else if (query.sortBy === 'displayOrder') order = [['display_order', direction]];
}
// Pagination
const page = query.page ? parseInt(query.page, 10) : 1;
const limit = query.limit ? parseInt(query.limit, 10) : null;
const offset = limit ? (page - 1) * limit : null;
const findOptions = {
where,
order,
paranoid,
include: [
{
model: models.AttributeGroup,
as: 'groups',
attributes: ['code', 'name'],
through: { attributes: [] }
},
{
model: models.AttributeOption,
as: 'optionsList',
attributes: ['id', 'code', 'label', 'sort_order', 'status']
}
]
};
if (limit !== null) {
findOptions.limit = limit;
findOptions.offset = offset;
}
const { count, rows } = await models.Attribute.findAndCountAll(findOptions);
const totalPages = limit ? Math.ceil(count / limit) : 1;
return {
records: rows,
pagination: {
total: count,
page,
limit,
totalPages
}
};
}
async getById(id) {
const record = await repository.findById(id);
const record = await models.Attribute.findByPk(id, {
include: [
{
model: models.AttributeGroup,
as: 'groups',
attributes: ['code', 'name'],
through: { attributes: [] }
},
{
model: models.AttributeOption,
as: 'optionsList',
attributes: ['id', 'code', 'label', 'sort_order', 'status']
}
]
});
if (!record) {
throw new Error('Attribute not found');
}
@@ -17,58 +128,315 @@ export class AttributeService {
}
async create(data, userContext = {}) {
const record = await repository.create(data);
// Broadcast event
SocketService.broadcast('attribute:created', record);
// Log audit
await AuditService.log({
action: 'CREATE',
resource: 'Attribute',
resourceId: record.id,
userId: userContext.id || 'system',
details: data
});
const transaction = await sequelize.transaction();
try {
if (!data.code || !data.code.trim()) {
if (data.name) {
data.code = data.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
}
if (!data.code) {
data.code = `attr_${Date.now()}`;
}
}
data.code = data.code.toLowerCase().trim();
return record;
// Check duplicate code
const existing = await models.Attribute.findOne({
where: { code: data.code },
paranoid: false,
transaction
});
if (existing) {
throw new Error(`Attribute with code "${data.code}" already exists`);
}
// Automatically assign display order if not provided
if (data.display_order === undefined || data.display_order === null) {
const maxOrder = await models.Attribute.max('display_order', { transaction }) || 0;
data.display_order = maxOrder + 1;
}
// Set user audit context
data.created_by = userContext.id || null;
const record = await models.Attribute.create(data, { transaction });
// Handle options linking
if (data.options && Array.isArray(data.options)) {
for (let i = 0; i < data.options.length; i++) {
const opt = data.options[i];
let code, label;
if (typeof opt === 'string') {
label = opt;
code = opt.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
} else if (opt && typeof opt === 'object') {
code = opt.code;
label = opt.label;
}
if (code && label) {
await models.AttributeOption.create({
attribute_id: record.id,
code,
label,
sort_order: i,
status: 'active'
}, { transaction });
}
}
}
await transaction.commit();
const fullRecord = await this.getById(record.id);
// Broadcast and Audit
SocketService.broadcast('attribute:created', fullRecord);
SocketService.broadcast('attribute.created', fullRecord);
if (data.options && data.options.length > 0) {
SocketService.broadcast('attribute.option.created', { attributeId: record.id, options: data.options });
}
await AuditService.log({
action: 'CREATE',
resource: 'Attribute',
resourceId: record.id,
userId: userContext.id || 'system',
details: { newValues: fullRecord.toJSON() }
});
// Write to attribute_history
if (models.AttributeHistory) {
await models.AttributeHistory.create({
attribute_id: record.id,
action: 'CREATE',
changed_by: userContext.id || null,
changes: { newValues: fullRecord.toJSON() }
});
}
return fullRecord;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async update(id, data, userContext = {}) {
const record = await repository.update(id, data);
if (!record) {
throw new Error('Attribute not found');
const transaction = await sequelize.transaction();
try {
const record = await models.Attribute.findByPk(id, { transaction });
if (!record) {
throw new Error('Attribute not found');
}
// Enforce immutability of the attribute code
if (data.code && data.code !== record.code) {
throw new Error('Attribute code is immutable after creation');
}
const oldValues = record.toJSON();
data.updated_by = userContext.id || null;
await record.update(data, { transaction });
// Handle options updating
if (data.options && Array.isArray(data.options)) {
await models.AttributeOption.destroy({ where: { attribute_id: id }, transaction });
for (let i = 0; i < data.options.length; i++) {
const opt = data.options[i];
let code, label;
if (typeof opt === 'string') {
label = opt;
code = opt.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
} else if (opt && typeof opt === 'object') {
code = opt.code;
label = opt.label;
}
if (code && label) {
await models.AttributeOption.create({
attribute_id: id,
code,
label,
sort_order: i,
status: 'active'
}, { transaction });
}
}
}
await transaction.commit();
const updatedRecord = await this.getById(id);
SocketService.broadcast('attribute:updated', updatedRecord);
SocketService.broadcast('attribute.updated', updatedRecord);
if (data.options && data.options.length > 0) {
SocketService.broadcast('attribute.option.updated', { attributeId: id, options: data.options });
}
await AuditService.log({
action: 'UPDATE',
resource: 'Attribute',
resourceId: id,
userId: userContext.id || 'system',
details: { oldValues, newValues: updatedRecord.toJSON() }
});
// Write to attribute_history
if (models.AttributeHistory) {
await models.AttributeHistory.create({
attribute_id: id,
action: 'UPDATE',
changed_by: userContext.id || null,
changes: { oldValues, newValues: updatedRecord.toJSON() }
});
}
return updatedRecord;
} catch (error) {
await transaction.rollback();
throw error;
}
SocketService.broadcast('attribute:updated', record);
await AuditService.log({
action: 'UPDATE',
resource: 'Attribute',
resourceId: id,
userId: userContext.id || 'system',
details: data
});
return record;
}
async delete(id, userContext = {}) {
const deleted = await repository.delete(id);
if (!deleted) {
throw new Error('Attribute not found');
const transaction = await sequelize.transaction();
try {
const record = await models.Attribute.findByPk(id, { transaction });
if (!record) {
throw new Error('Attribute not found');
}
// Usage Check: Groups mapping
const groupCount = await models.AttributeGroupAttribute.count({
where: { attribute_id: id },
transaction
});
if (groupCount > 0) {
throw new Error('This attribute is currently in use and cannot be deleted.');
}
// Usage Check: Product Families mapping
const familyCount = await models.FamilyAttribute.count({
where: { attribute_id: id },
transaction
});
if (familyCount > 0) {
throw new Error('This attribute is currently in use and cannot be deleted.');
}
// Usage Check: Variant axes mapping
const axisCount = await models.FamilyVariantAxis.count({
where: { attribute_id: id },
transaction
});
if (axisCount > 0) {
throw new Error('This attribute is currently in use and cannot be deleted.');
}
// Usage Check: Variant Values
if (models.VariantValue) {
const valCount = await models.VariantValue.count({
where: { axis_id: id },
transaction
});
if (valCount > 0) {
throw new Error('This attribute is currently in use and cannot be deleted.');
}
}
const oldValues = record.toJSON();
// Change status to Archived, save updated_by and deleted_by context
await record.update({
status: 'archived',
updated_by: userContext.id || null,
deleted_by: userContext.id || null
}, { transaction });
// Perform soft delete
await record.destroy({ transaction });
await transaction.commit();
SocketService.broadcast('attribute:deleted', { id });
SocketService.broadcast('attribute.deleted', { id });
SocketService.broadcast('attribute.archived', { id });
await AuditService.log({
action: 'ARCHIVE',
resource: 'Attribute',
resourceId: id,
userId: userContext.id || 'system',
details: { oldValues }
});
// Write history log
if (models.AttributeHistory) {
await models.AttributeHistory.create({
attribute_id: id,
action: 'ARCHIVE',
changed_by: userContext.id || null,
changes: { oldValues }
});
}
return true;
} catch (error) {
await transaction.rollback();
throw error;
}
}
SocketService.broadcast('attribute:deleted', { id });
async restore(id, userContext = {}) {
const transaction = await sequelize.transaction();
try {
const record = await models.Attribute.findByPk(id, {
paranoid: false,
transaction
});
if (!record) {
throw new Error('Attribute not found');
}
await AuditService.log({
action: 'DELETE',
resource: 'Attribute',
resourceId: id,
userId: userContext.id || 'system'
});
await record.restore({ transaction });
await record.update({
status: 'active',
deleted_by: null,
updated_by: userContext.id || null
}, { transaction });
return true;
await transaction.commit();
const restoredRecord = await this.getById(id);
SocketService.broadcast('attribute:restored', restoredRecord);
SocketService.broadcast('attribute.restored', restoredRecord);
await AuditService.log({
action: 'RESTORE',
resource: 'Attribute',
resourceId: id,
userId: userContext.id || 'system',
details: { newValues: restoredRecord.toJSON() }
});
// Write history log
if (models.AttributeHistory) {
await models.AttributeHistory.create({
attribute_id: id,
action: 'RESTORE',
changed_by: userContext.id || null,
changes: { newValues: restoredRecord.toJSON() }
});
}
return restoredRecord;
} catch (error) {
await transaction.rollback();
throw error;
}
}
}
@@ -1,22 +1,56 @@
import { body, param } from 'express-validator';
const ALLOWED_TYPES = [
'text', 'textarea', 'rich_text', 'number', 'boolean',
'date', 'datetime', 'price', 'metric', 'percentage',
'image', 'file', 'url', 'email', 'select', 'multiselect',
'reference', 'json'
];
export const createValidation = [
body('name')
.optional()
body('code')
.optional({ checkFalsy: true })
.isString()
.trim()
.withMessage('Name must be a string')
.matches(/^[a-z0-9_]+$/)
.withMessage('Code must contain only lowercase letters, numbers, and underscores (snake_case)'),
body('name')
.isString()
.trim()
.notEmpty()
.withMessage('Name is required'),
body('type')
.isString()
.trim()
.notEmpty()
.withMessage('Type is required')
.isIn(ALLOWED_TYPES)
.withMessage('Type must be a valid enterprise attribute format')
];
export const updateValidation = [
param('id')
.isUUID()
.withMessage('Valid UUID is required'),
body('code')
.custom((value) => {
if (value !== undefined) {
throw new Error('Attribute code is immutable and cannot be updated after creation');
}
return true;
}),
body('name')
.optional()
.isString()
.trim()
.withMessage('Name must be a string')
.notEmpty()
.withMessage('Name cannot be empty'),
body('type')
.optional()
.isString()
.trim()
.isIn(ALLOWED_TYPES)
.withMessage('Type must be a valid enterprise attribute format')
];
export const deleteValidation = [
@@ -0,0 +1,39 @@
import { Model, DataTypes } from 'sequelize';
export class AttributeHistory extends Model {}
export default (sequelize) => {
AttributeHistory.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
attribute_id: {
type: DataTypes.UUID,
allowNull: false
},
action: {
type: DataTypes.STRING(50),
allowNull: false
},
changed_by: {
type: DataTypes.UUID,
allowNull: true
},
changes: {
type: DataTypes.JSONB,
allowNull: false
}
}, {
sequelize,
modelName: 'AttributeHistory',
tableName: 'attribute_history',
timestamps: true,
updatedAt: false,
underscored: true
});
return AttributeHistory;
};
@@ -0,0 +1,50 @@
import { Model, DataTypes } from 'sequelize';
export class AttributeOption extends Model {}
export default (sequelize) => {
AttributeOption.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
attribute_id: {
type: DataTypes.UUID,
allowNull: false
},
code: {
type: DataTypes.STRING(255),
allowNull: false
},
label: {
field: 'value',
type: DataTypes.STRING(255),
allowNull: false
},
sort_order: {
field: 'display_order',
type: DataTypes.INTEGER,
defaultValue: 0,
allowNull: false
},
status: {
type: DataTypes.VIRTUAL,
get() {
return 'active';
},
set(val) {
// no-op
}
}
}, {
sequelize,
modelName: 'AttributeOption',
tableName: 'attribute_options',
timestamps: true,
underscored: true
});
return AttributeOption;
};
+4
View File
@@ -1,8 +1,12 @@
import { Router } from 'express';
import attributesRouter from './attributes/attribute.routes.js';
import attributeGroupsRouter from './attributeGroups/attributeGroup.routes.js';
import attributeSetsRouter from './attributeSets/attributeSet.routes.js';
const router = Router();
router.use('/attributes', attributesRouter);
router.use('/attribute-groups', attributeGroupsRouter);
router.use('/attribute-sets', attributeSetsRouter);
export default router;
@@ -45,6 +45,24 @@ export class BrandController {
next(error);
}
}
async archive(req, res, next) {
try {
await service.archive(req.params.id, req.user);
return res.status(200).json({ success: true, message: 'Brand archived successfully' });
} catch (error) {
next(error);
}
}
async restore(req, res, next) {
try {
const data = await service.restore(req.params.id, req.user);
return res.status(200).json({ success: true, data, message: 'Brand restored successfully' });
} catch (error) {
next(error);
}
}
}
export default new BrandController();
+22 -7
View File
@@ -2,7 +2,7 @@ import { Model, DataTypes } from 'sequelize';
export class Brand extends Model {
static associate(models) {
// Define associations here
Brand.hasMany(models.Product, { foreignKey: 'brand_id', as: 'products' });
}
}
@@ -14,23 +14,38 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true
},
name: {
type: DataTypes.STRING,
type: DataTypes.STRING(100),
allowNull: false
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
website: {
type: DataTypes.STRING(255),
allowNull: true
},
country: {
type: DataTypes.STRING(100),
allowNull: true
},
status: {
type: DataTypes.STRING,
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'active'
},
metadata: {
type: DataTypes.JSONB,
allowNull: true
}
}, {
sequelize,
modelName: 'Brand',
tableName: 'brands',
timestamps: true,
paranoid: true,
underscored: true
});
@@ -123,4 +123,24 @@ router.delete(
controller.delete
);
router.post(
'/:id/archive',
authenticate,
authorize(['write:brands']),
getByIdValidation,
validate,
audit('ARCHIVE_BRAND'),
controller.archive
);
router.post(
'/:id/restore',
authenticate,
authorize(['write:brands']),
getByIdValidation,
validate,
audit('RESTORE_BRAND'),
controller.restore
);
export default router;
+87 -7
View File
@@ -1,11 +1,15 @@
import repository from './brand.repository.js';
import { models } from '../../../shared/database/models.js';
import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
export class BrandService {
async getAll(query = {}) {
// Add business logic filtering, pagination, etc.
return await repository.findAll();
const where = {};
if (query.status) {
where.status = query.status;
}
return await repository.findAll({ where });
}
async getById(id) {
@@ -17,6 +21,22 @@ export class BrandService {
}
async create(data, userContext = {}) {
if (!data.code || !data.code.trim()) {
if (data.name) {
data.code = data.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
}
if (!data.code) {
data.code = `brd_${Date.now()}`;
}
}
data.code = data.code.toLowerCase().trim();
// Check duplicate code
const existing = await models.Brand.findOne({ where: { code: data.code } });
if (existing) {
throw new Error(`Brand with code "${data.code}" already exists`);
}
const record = await repository.create(data);
// Broadcast event
@@ -35,12 +55,21 @@ export class BrandService {
}
async update(id, data, userContext = {}) {
const record = await repository.update(id, data);
const record = await repository.findById(id);
if (!record) {
throw new Error('Brand not found');
}
SocketService.broadcast('brand:updated', record);
if (data.code && data.code !== record.code) {
const existing = await models.Brand.findOne({ where: { code: data.code } });
if (existing) {
throw new Error(`Brand with code "${data.code}" already exists`);
}
}
const updatedRecord = await repository.update(id, data);
SocketService.broadcast('brand:updated', updatedRecord);
await AuditService.log({
action: 'UPDATE',
@@ -50,15 +79,24 @@ export class BrandService {
details: data
});
return record;
return updatedRecord;
}
async delete(id, userContext = {}) {
const deleted = await repository.delete(id);
if (!deleted) {
const record = await models.Brand.findByPk(id);
if (!record) {
throw new Error('Brand not found');
}
// Check product linkage
const productCount = await models.Product.count({ where: { brand_id: id } });
if (productCount > 0) {
throw new Error('Cannot delete Brand because it is used by one or more products');
}
// Hard delete
await record.destroy({ force: true });
SocketService.broadcast('brand:deleted', { id });
await AuditService.log({
@@ -70,6 +108,48 @@ export class BrandService {
return true;
}
async archive(id, userContext = {}) {
const record = await models.Brand.findByPk(id);
if (!record) {
throw new Error('Brand not found');
}
// Soft delete / Archive
await record.destroy();
SocketService.broadcast('brand:archived', { id });
await AuditService.log({
action: 'ARCHIVE',
resource: 'Brand',
resourceId: id,
userId: userContext.id || 'system'
});
return true;
}
async restore(id, userContext = {}) {
const record = await models.Brand.findByPk(id, { paranoid: false });
if (!record) {
throw new Error('Brand not found');
}
await record.restore();
const restored = await repository.findById(id);
SocketService.broadcast('brand:restored', restored);
await AuditService.log({
action: 'RESTORE',
resource: 'Brand',
resourceId: id,
userId: userContext.id || 'system'
});
return restored;
}
}
export default new BrandService();
+39 -2
View File
@@ -1,22 +1,59 @@
import { body, param } from 'express-validator';
export const createValidation = [
body('code')
.optional({ checkFalsy: true })
.isString()
.trim()
.matches(/^[a-z0-9_]+$/)
.withMessage('Code must be lowercase alphanumeric and underscores only'),
body('name')
.isString()
.trim()
.notEmpty()
.withMessage('Name is required'),
body('description')
.optional()
.isString()
.trim(),
body('website')
.optional()
.isString()
.trim(),
body('country')
.optional()
.isString()
.trim()
.withMessage('Name must be a string')
];
export const updateValidation = [
param('id')
.isUUID()
.withMessage('Valid UUID is required'),
body('code')
.optional()
.isString()
.trim()
.matches(/^[a-z0-9_]+$/)
.withMessage('Code must be lowercase alphanumeric and underscores only'),
body('name')
.optional()
.isString()
.trim()
.withMessage('Name must be a string')
.notEmpty()
.withMessage('Name cannot be empty'),
body('description')
.optional()
.isString()
.trim(),
body('website')
.optional()
.isString()
.trim(),
body('country')
.optional()
.isString()
.trim()
];
export const deleteValidation = [
+2
View File
@@ -1,8 +1,10 @@
import { Router } from 'express';
import brandsRouter from './brands/brand.routes.js';
import unitsRouter from './units/unit.routes.js';
const router = Router();
router.use('/brands', brandsRouter);
router.use('/units', unitsRouter);
export default router;
@@ -0,0 +1,68 @@
import service from './unit.service.js';
export class UnitController {
async getAll(req, res, next) {
try {
const records = await service.getAll(req.query);
return res.status(200).json({ success: true, data: records });
} catch (error) {
next(error);
}
}
async getById(req, res, next) {
try {
const record = await service.getById(req.params.id);
return res.status(200).json({ success: true, data: record });
} catch (error) {
next(error);
}
}
async create(req, res, next) {
try {
const record = await service.create(req.body, req.user);
return res.status(201).json({ success: true, data: record });
} catch (error) {
next(error);
}
}
async update(req, res, next) {
try {
const record = await service.update(req.params.id, req.body, req.user);
return res.status(200).json({ success: true, data: record });
} catch (error) {
next(error);
}
}
async delete(req, res, next) {
try {
await service.delete(req.params.id, req.user);
return res.status(200).json({ success: true, message: 'Unit deleted successfully' });
} catch (error) {
next(error);
}
}
async archive(req, res, next) {
try {
await service.archive(req.params.id, req.user);
return res.status(200).json({ success: true, message: 'Unit archived successfully' });
} catch (error) {
next(error);
}
}
async restore(req, res, next) {
try {
const data = await service.restore(req.params.id, req.user);
return res.status(200).json({ success: true, data, message: 'Unit restored successfully' });
} catch (error) {
next(error);
}
}
}
export default new UnitController();
+64
View File
@@ -0,0 +1,64 @@
import { Model, DataTypes } from 'sequelize';
export class Unit extends Model {
static associate(models) {
Unit.hasMany(models.Product, { foreignKey: 'unit_id', as: 'products' });
}
}
export default (sequelize) => {
Unit.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true
},
name: {
type: DataTypes.STRING(100),
allowNull: false
},
symbol: {
type: DataTypes.STRING(20),
allowNull: false
},
unitType: {
type: DataTypes.STRING(50),
allowNull: false,
field: 'unit_type'
},
conversionFactor: {
type: DataTypes.DECIMAL(15, 6),
allowNull: true,
field: 'conversion_factor'
},
baseUnit: {
type: DataTypes.STRING(50),
allowNull: true,
field: 'base_unit'
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
status: {
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'active'
}
}, {
sequelize,
modelName: 'Unit',
tableName: 'units',
timestamps: true,
paranoid: true,
underscored: true
});
return Unit;
};
@@ -0,0 +1,30 @@
import { models } from '../../../shared/database/models.js';
export class UnitRepository {
async findAll(options = {}) {
return await models.Unit.findAll(options);
}
async findById(id, options = {}) {
return await models.Unit.findByPk(id, options);
}
async create(data, options = {}) {
return await models.Unit.create(data, options);
}
async update(id, data, options = {}) {
const record = await this.findById(id, options);
if (!record) return null;
return await record.update(data, options);
}
async delete(id, options = {}) {
const record = await this.findById(id, options);
if (!record) return false;
await record.destroy(options);
return true;
}
}
export default new UnitRepository();
+144
View File
@@ -0,0 +1,144 @@
import { Router } from 'express';
import controller from './unit.controller.js';
import { authenticate } from '../../../shared/middleware/auth.middleware.js';
import { validate } from '../../../shared/middleware/validation.middleware.js';
import { authorize } from '../../../shared/middleware/permission.middleware.js';
import { audit } from '../../../shared/middleware/audit.middleware.js';
import {
createValidation,
updateValidation,
deleteValidation,
getByIdValidation
} from './unit.validation.js';
const router = Router();
/**
* @swagger
* /api/v1/units:
* get:
* summary: Retrieve all units
* tags: [Units]
* responses:
* 200:
* description: Success
*/
router.get(
'/',
authenticate,
authorize(['read:brands']),
controller.getAll
);
/**
* @swagger
* /api/v1/units/{id}:
* get:
* summary: Retrieve a single unit
* tags: [Units]
* parameters:
* - in: path
* name: id
* required: true
* responses:
* 200:
* description: Success
*/
router.get(
'/:id',
authenticate,
authorize(['read:brands']),
getByIdValidation,
validate,
controller.getById
);
/**
* @swagger
* /api/v1/units:
* post:
* summary: Create a unit
* tags: [Units]
* responses:
* 201:
* description: Success
*/
router.post(
'/',
authenticate,
authorize(['write:brands']),
createValidation,
validate,
audit('CREATE_UNIT'),
controller.create
);
/**
* @swagger
* /api/v1/units/{id}:
* put:
* summary: Update a unit
* tags: [Units]
* parameters:
* - in: path
* name: id
* required: true
* responses:
* 200:
* description: Success
*/
router.put(
'/:id',
authenticate,
authorize(['write:brands']),
updateValidation,
validate,
audit('UPDATE_UNIT'),
controller.update
);
/**
* @swagger
* /api/v1/units/{id}:
* delete:
* summary: Delete a unit
* tags: [Units]
* parameters:
* - in: path
* name: id
* required: true
* responses:
* 200:
* description: Success
*/
router.delete(
'/:id',
authenticate,
authorize(['write:brands']),
deleteValidation,
validate,
audit('DELETE_UNIT'),
controller.delete
);
router.post(
'/:id/archive',
authenticate,
authorize(['write:brands']),
getByIdValidation,
validate,
audit('ARCHIVE_UNIT'),
controller.archive
);
router.post(
'/:id/restore',
authenticate,
authorize(['write:brands']),
getByIdValidation,
validate,
audit('RESTORE_UNIT'),
controller.restore
);
export default router;
+143
View File
@@ -0,0 +1,143 @@
import repository from './unit.repository.js';
import { models } from '../../../shared/database/models.js';
import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
export class UnitService {
async getAll(query = {}) {
const where = {};
if (query.status) {
where.status = query.status;
}
return await repository.findAll({ where });
}
async getById(id) {
const record = await repository.findById(id);
if (!record) {
throw new Error('Unit not found');
}
return record;
}
async create(data, userContext = {}) {
// Check duplicate code
const existing = await models.Unit.findOne({ where: { code: data.code } });
if (existing) {
throw new Error(`Unit with code "${data.code}" already exists`);
}
const record = await repository.create(data);
SocketService.broadcast('unit:created', record);
await AuditService.log({
action: 'CREATE',
resource: 'Unit',
resourceId: record.id,
userId: userContext.id || 'system',
details: data
});
return record;
}
async update(id, data, userContext = {}) {
const record = await repository.findById(id);
if (!record) {
throw new Error('Unit not found');
}
if (data.code && data.code !== record.code) {
const existing = await models.Unit.findOne({ where: { code: data.code } });
if (existing) {
throw new Error(`Unit with code "${data.code}" already exists`);
}
}
const updatedRecord = await repository.update(id, data);
SocketService.broadcast('unit:updated', updatedRecord);
await AuditService.log({
action: 'UPDATE',
resource: 'Unit',
resourceId: id,
userId: userContext.id || 'system',
details: data
});
return updatedRecord;
}
async delete(id, userContext = {}) {
const record = await models.Unit.findByPk(id);
if (!record) {
throw new Error('Unit not found');
}
// Check product linkage
const productCount = await models.Product.count({ where: { unit_id: id } });
if (productCount > 0) {
throw new Error('Cannot delete Unit because it is used by one or more products');
}
// Hard delete
await record.destroy({ force: true });
SocketService.broadcast('unit:deleted', { id });
await AuditService.log({
action: 'DELETE',
resource: 'Unit',
resourceId: id,
userId: userContext.id || 'system'
});
return true;
}
async archive(id, userContext = {}) {
const record = await models.Unit.findByPk(id);
if (!record) {
throw new Error('Unit not found');
}
// Soft delete / Archive
await record.destroy();
SocketService.broadcast('unit:archived', { id });
await AuditService.log({
action: 'ARCHIVE',
resource: 'Unit',
resourceId: id,
userId: userContext.id || 'system'
});
return true;
}
async restore(id, userContext = {}) {
const record = await models.Unit.findByPk(id, { paranoid: false });
if (!record) {
throw new Error('Unit not found');
}
await record.restore();
const restored = await repository.findById(id);
SocketService.broadcast('unit:restored', restored);
await AuditService.log({
action: 'RESTORE',
resource: 'Unit',
resourceId: id,
userId: userContext.id || 'system'
});
return restored;
}
}
export default new UnitService();
@@ -0,0 +1,92 @@
import { body, param } from 'express-validator';
export const createValidation = [
body('code')
.isString()
.trim()
.notEmpty()
.withMessage('Code is required')
.matches(/^[a-z0-9_]+$/)
.withMessage('Code must be lowercase alphanumeric and underscores only'),
body('name')
.isString()
.trim()
.notEmpty()
.withMessage('Name is required'),
body('symbol')
.isString()
.trim()
.notEmpty()
.withMessage('Symbol is required'),
body('unitType')
.isString()
.trim()
.notEmpty()
.withMessage('Unit type is required'),
body('conversionFactor')
.optional({ nullable: true })
.isFloat({ min: 0 })
.withMessage('Conversion factor must be a positive number'),
body('baseUnit')
.optional({ nullable: true })
.isString()
.trim(),
body('description')
.optional()
.isString()
.trim()
];
export const updateValidation = [
param('id')
.isUUID()
.withMessage('Valid UUID is required'),
body('code')
.optional()
.isString()
.trim()
.matches(/^[a-z0-9_]+$/)
.withMessage('Code must be lowercase alphanumeric and underscores only'),
body('name')
.optional()
.isString()
.trim()
.notEmpty()
.withMessage('Name cannot be empty'),
body('symbol')
.optional()
.isString()
.trim()
.notEmpty()
.withMessage('Symbol cannot be empty'),
body('unitType')
.optional()
.isString()
.trim()
.notEmpty()
.withMessage('Unit type cannot be empty'),
body('conversionFactor')
.optional({ nullable: true })
.isFloat({ min: 0 })
.withMessage('Conversion factor must be a positive number'),
body('baseUnit')
.optional({ nullable: true })
.isString()
.trim(),
body('description')
.optional()
.isString()
.trim()
];
export const deleteValidation = [
param('id')
.isUUID()
.withMessage('Valid UUID is required')
];
export const getByIdValidation = [
param('id')
.isUUID()
.withMessage('Valid UUID is required')
];
@@ -1,46 +1,175 @@
import service from './catalog.service.js';
export class CatalogController {
async getAll(req, res, next) {
serializeFamily(record, viewMode = 'detail') {
if (!record) return null;
const raw = record.toJSON ? record.toJSON() : record;
return {
id: raw.id,
code: raw.code,
name: raw.name,
description: raw.description,
status: raw.status,
categoryId: raw.category_id || null,
// For FamilyList table compatibility, map category name under 'category' in list view.
// For NewFamily Formik select box compatibility, map category ID under 'category' in edit/detail mode.
category: viewMode === 'list'
? (raw.category ? raw.category.name : '')
: (raw.category_id || ''),
workflowCode: raw.workflow_code || 'standard',
completenessRules: raw.completeness_rules || {},
attributes: Array.isArray(raw.attributes)
? raw.attributes.map(a => a.id)
: [],
variantAxes: Array.isArray(raw.variantAxes)
? raw.variantAxes.map(a => a.id)
: [],
assetRequirements: Array.isArray(raw.assetRequirements)
? raw.assetRequirements.map(a => a.id)
: [],
channels: Array.isArray(raw.channels)
? raw.channels.map(c => c.channel_code)
: [],
productCount: raw.productCount ?? 0,
attributeCount: raw.attributeCount ?? 0,
variantAxisCount: raw.variantAxisCount ?? 0,
assetRequirementCount: raw.assetRequirementCount ?? 0,
channelCount: raw.channelCount ?? 0,
attributeGroups: raw.attributeGroups ?? 0,
canDelete: raw.canDelete ?? true,
lastUpdated: raw.updated_at || raw.updatedAt,
createdBy: raw.created_by || 'system'
};
}
getAll = async (req, res, next) => {
try {
const records = await service.getAll(req.query);
return res.status(200).json({ success: true, data: records });
const data = records.map(r => this.serializeFamily(r, 'list'));
return res.status(200).json({
success: true,
message: 'Product Families retrieved successfully',
data,
pagination: null,
timestamp: new Date().toISOString()
});
} catch (error) {
next(error);
}
}
async getById(req, res, next) {
getById = async (req, res, next) => {
try {
const record = await service.getById(req.params.id);
return res.status(200).json({ success: true, data: record });
return res.status(200).json({
success: true,
message: 'Product Family retrieved successfully',
data: this.serializeFamily(record, 'detail'),
pagination: null,
timestamp: new Date().toISOString()
});
} catch (error) {
next(error);
}
}
async create(req, res, next) {
create = async (req, res, next) => {
try {
const record = await service.create(req.body, req.user);
return res.status(201).json({ success: true, data: record });
return res.status(201).json({
success: true,
message: 'Product Family created successfully',
data: this.serializeFamily(record, 'detail'),
pagination: null,
timestamp: new Date().toISOString()
});
} catch (error) {
next(error);
}
}
async update(req, res, next) {
update = async (req, res, next) => {
try {
const record = await service.update(req.params.id, req.body, req.user);
return res.status(200).json({ success: true, data: record });
return res.status(200).json({
success: true,
message: 'Product Family updated successfully',
data: this.serializeFamily(record, 'detail'),
pagination: null,
timestamp: new Date().toISOString()
});
} catch (error) {
next(error);
}
}
async delete(req, res, next) {
delete = async (req, res, next) => {
try {
await service.delete(req.params.id, req.user);
return res.status(200).json({ success: true, message: 'Catalog deleted successfully' });
return res.status(200).json({
success: true,
message: 'Product Family archived successfully',
data: {
canDelete: true,
productCount: 0
},
pagination: null,
timestamp: new Date().toISOString()
});
} catch (error) {
if (error.statusCode === 400 && error.data) {
return res.status(400).json({
success: false,
message: error.message,
data: error.data,
pagination: null,
timestamp: new Date().toISOString()
});
}
next(error);
}
}
restore = async (req, res, next) => {
try {
const record = await service.restore(req.params.id, req.user);
return res.status(200).json({
success: true,
message: 'Product Family restored successfully',
data: this.serializeFamily(record, 'detail'),
pagination: null,
timestamp: new Date().toISOString()
});
} catch (error) {
next(error);
}
}
getBlueprint = async (req, res, next) => {
try {
const data = await service.getBlueprint(req.params.id);
return res.status(200).json({
success: true,
message: 'Product Family Blueprint retrieved successfully',
data,
pagination: null,
timestamp: new Date().toISOString()
});
} catch (error) {
next(error);
}
}
getSummary = async (req, res, next) => {
try {
const data = await service.getSummary(req.params.id);
return res.status(200).json({
success: true,
message: 'Product Family Summary retrieved successfully',
data,
pagination: null,
timestamp: new Date().toISOString()
});
} catch (error) {
next(error);
}
@@ -2,7 +2,47 @@ import { Model, DataTypes } from 'sequelize';
export class Catalog extends Model {
static associate(models) {
// Define associations here
// Parent category association
Catalog.belongsTo(models.Categorie, {
foreignKey: 'category_id',
as: 'category'
});
// M:N with Attribute for generic attributes
Catalog.belongsToMany(models.Attribute, {
through: models.FamilyAttribute,
foreignKey: 'family_id',
otherKey: 'attribute_id',
as: 'attributes'
});
// M:N with Attribute for variant axes
Catalog.belongsToMany(models.Attribute, {
through: models.FamilyVariantAxis,
foreignKey: 'family_id',
otherKey: 'attribute_id',
as: 'variantAxes'
});
// M:N with AssetType for required assets
Catalog.belongsToMany(models.AssetType, {
through: models.FamilyAssetRequirement,
foreignKey: 'family_id',
otherKey: 'asset_type_id',
as: 'assetRequirements'
});
// HasMany channels list
Catalog.hasMany(models.FamilyChannel, {
foreignKey: 'family_id',
as: 'channels'
});
// AttributeSet association
Catalog.belongsTo(models.AttributeSet, {
foreignKey: 'attribute_set_id',
as: 'attributeSet'
});
}
}
@@ -14,24 +54,48 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true
},
name: {
type: DataTypes.STRING,
type: DataTypes.STRING(100),
allowNull: false
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
status: {
type: DataTypes.STRING,
defaultValue: 'active'
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'draft'
},
metadata: {
category_id: {
type: DataTypes.UUID,
allowNull: true
},
workflow_code: {
type: DataTypes.STRING(50),
allowNull: false,
defaultValue: 'standard'
},
completeness_rules: {
type: DataTypes.JSONB,
allowNull: true
},
attribute_set_id: {
type: DataTypes.UUID,
allowNull: true
}
}, {
sequelize,
modelName: 'Catalog',
tableName: 'catalogs',
timestamps: true,
underscored: true
underscored: true,
paranoid: true // Soft deletes support
});
return Catalog;
@@ -2,11 +2,89 @@ import { models } from '../../../shared/database/models.js';
export class CatalogRepository {
async findAll(options = {}) {
return await models.Catalog.findAll(options);
return await models.Catalog.findAll({
include: [
{
model: models.Categorie,
as: 'category',
attributes: ['id', 'name', 'code']
},
{
model: models.Attribute,
as: 'attributes',
through: { attributes: ['display_order'] }
},
{
model: models.Attribute,
as: 'variantAxes',
through: { attributes: [] }
},
{
model: models.AssetType,
as: 'assetRequirements',
through: { attributes: [] }
},
{
model: models.FamilyChannel,
as: 'channels',
attributes: ['channel_code']
},
{
model: models.AttributeSet,
as: 'attributeSet',
attributes: ['id', 'name', 'code']
}
],
order: [
['name', 'ASC']
],
...options
});
}
async findById(id, options = {}) {
return await models.Catalog.findByPk(id, options);
return await models.Catalog.findByPk(id, {
include: [
{
model: models.Categorie,
as: 'category',
attributes: ['id', 'name', 'code']
},
{
model: models.Attribute,
as: 'attributes',
through: { attributes: ['display_order'] }
},
{
model: models.Attribute,
as: 'variantAxes',
through: { attributes: [] }
},
{
model: models.AssetType,
as: 'assetRequirements',
through: { attributes: [] }
},
{
model: models.FamilyChannel,
as: 'channels',
attributes: ['channel_code']
},
{
model: models.AttributeSet,
as: 'attributeSet',
attributes: ['id', 'name', 'code']
}
],
...options
});
}
async findByCode(code, options = {}) {
return await models.Catalog.findOne({
where: { code },
...options
});
}
async create(data, options = {}) {
@@ -14,13 +92,13 @@ export class CatalogRepository {
}
async update(id, data, options = {}) {
const record = await this.findById(id, options);
const record = await models.Catalog.findByPk(id, options);
if (!record) return null;
return await record.update(data, options);
}
async delete(id, options = {}) {
const record = await this.findById(id, options);
const record = await models.Catalog.findByPk(id, options);
if (!record) return false;
await record.destroy(options);
return true;
@@ -55,6 +55,24 @@ router.get(
controller.getById
);
router.get(
'/:id/blueprint',
authenticate,
authorize(['read:catalogs']),
getByIdValidation,
validate,
controller.getBlueprint
);
router.get(
'/:id/summary',
authenticate,
authorize(['read:catalogs']),
getByIdValidation,
validate,
controller.getSummary
);
/**
* @swagger
* /api/v1/catalogs:
@@ -123,4 +141,28 @@ router.delete(
controller.delete
);
/**
* @swagger
* /api/v1/catalogs/{id}/restore:
* post:
* summary: Restore a deleted catalog
* tags: [Catalogs]
* parameters:
* - in: path
* name: id
* required: true
* responses:
* 200:
* description: Success
*/
router.post(
'/:id/restore',
authenticate,
authorize(['write:catalogs']),
getByIdValidation,
validate,
audit('RESTORE_CATALOG'),
controller.restore
);
export default router;
+718 -44
View File
@@ -1,74 +1,748 @@
import repository from './catalog.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';
export class CatalogService {
async attachCounts(record, transaction) {
if (!record) return null;
const id = record.id;
// Count associated products
const productCount = await models.Product.count({
where: { family_id: id },
transaction
});
// Count associated attributes
const attributeCount = await models.FamilyAttribute.count({
where: { family_id: id },
transaction
});
// Count associated variant axes
const variantAxisCount = await models.FamilyVariantAxis.count({
where: { family_id: id },
transaction
});
// Count associated asset requirements
const assetRequirementCount = await models.FamilyAssetRequirement.count({
where: { family_id: id },
transaction
});
// Count associated channels
const channelCount = await models.FamilyChannel.count({
where: { family_id: id },
transaction
});
// Count unique attribute groups
const attributeRelations = await models.FamilyAttribute.findAll({
where: { family_id: id },
transaction
});
const attributeIds = attributeRelations.map(ar => ar.attribute_id);
let attributeGroupsCount = 0;
if (attributeIds.length > 0) {
const groupRelations = await models.AttributeGroupAttribute.findAll({
where: { attribute_id: attributeIds },
attributes: ['group_id'],
raw: true,
transaction
});
const uniqueGroups = new Set(groupRelations.map(gr => gr.group_id));
attributeGroupsCount = uniqueGroups.size;
}
record.setDataValue('productCount', productCount);
record.setDataValue('attributeCount', attributeCount);
record.setDataValue('variantAxisCount', variantAxisCount);
record.setDataValue('assetRequirementCount', assetRequirementCount);
record.setDataValue('channelCount', channelCount);
record.setDataValue('attributeGroups', attributeGroupsCount);
record.setDataValue('canDelete', productCount === 0);
return record;
}
async getAll(query = {}) {
// Add business logic filtering, pagination, etc.
return await repository.findAll();
const where = {};
if (query.status) {
where.status = query.status;
}
// Find all records
const records = await repository.findAll({ where });
// Attach counts
for (const record of records) {
await this.attachCounts(record);
}
return records;
}
async getById(id) {
const record = await repository.findById(id);
if (!record) {
throw new Error('Catalog not found');
throw new Error('Product Family not found');
}
await this.attachCounts(record);
return record;
}
async create(data, userContext = {}) {
const record = await repository.create(data);
// Broadcast event
SocketService.broadcast('catalog:created', record);
// Log audit
await AuditService.log({
action: 'CREATE',
resource: 'Catalog',
resourceId: record.id,
userId: userContext.id || 'system',
details: data
});
const transaction = await sequelize.transaction();
try {
if (!data.code || !data.code.trim()) {
if (data.name) {
data.code = data.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
}
if (!data.code) {
data.code = `fam_${Date.now()}`;
}
}
data.code = data.code.toLowerCase().trim();
return record;
// 1. Basic Code uniquely check
const existing = await models.Catalog.findOne({ where: { code: data.code }, transaction });
if (existing) {
throw new Error(`Product Family with code "${data.code}" already exists`);
}
// 2. Category Validation
const categoryId = data.categoryId || data.category || null;
if (categoryId) {
const category = await models.Categorie.findOne({
where: { id: categoryId },
transaction
});
if (!category) {
throw new Error('Category not found');
}
if (category.status !== 'active') {
throw new Error('Category is inactive');
}
if (category.deleted_at || category.deletedAt) {
throw new Error('Category has been deleted');
}
}
// Resolve attributes from attribute set if provided
const attributeSetId = data.attributeSetId || data.attribute_set_id || null;
if (attributeSetId) {
const attributeSet = await models.AttributeSet.findByPk(attributeSetId, {
transaction,
include: [
{
model: models.AttributeGroup,
as: 'groups',
include: [
{
model: models.Attribute,
as: 'attributes'
}
]
}
]
});
if (attributeSet && attributeSet.groups) {
const inheritedAttributes = [];
for (const group of attributeSet.groups) {
if (group.attributes) {
for (const attr of group.attributes) {
inheritedAttributes.push(attr.id);
}
}
}
data.attributes = [...new Set(inheritedAttributes)];
}
}
// 3. Attribute Validation & Deduplication
let attributes = [];
if (data.attributes && Array.isArray(data.attributes)) {
const uniqueAttributeIds = [...new Set(data.attributes)];
const existingAttributes = await models.Attribute.findAll({
where: { id: uniqueAttributeIds },
transaction
});
if (existingAttributes.length !== uniqueAttributeIds.length) {
throw new Error('One or more selected attributes do not exist');
}
for (const attr of existingAttributes) {
if (attr.deleted_at || attr.deletedAt) {
throw new Error(`Attribute "${attr.name}" has been deleted`);
}
}
attributes = uniqueAttributeIds; // Preserving display order from payload
}
// 4. Variant Strategy Validation & Deduplication
let variantAxes = [];
if (data.variantAxes && Array.isArray(data.variantAxes)) {
const uniqueAxes = [...new Set(data.variantAxes)];
const attributesSet = new Set(attributes);
const invalidAxes = uniqueAxes.filter(axis => !attributesSet.has(axis));
if (invalidAxes.length > 0) {
throw new Error('Variant axes must be a subset of family attributes');
}
variantAxes = uniqueAxes;
}
// 5. Asset Requirement Validation & Deduplication
let assetRequirements = [];
if (data.assetRequirements && Array.isArray(data.assetRequirements)) {
const uniqueAssetTypes = [...new Set(data.assetRequirements)];
const existingAssetTypes = await models.AssetType.findAll({
where: { id: uniqueAssetTypes },
transaction
});
if (existingAssetTypes.length !== uniqueAssetTypes.length) {
throw new Error('One or more selected asset types do not exist');
}
for (const at of existingAssetTypes) {
if (at.status !== 'active') {
throw new Error(`Asset type "${at.name}" is inactive`);
}
if (at.deleted_at || at.deletedAt) {
throw new Error(`Asset type "${at.name}" has been deleted`);
}
}
assetRequirements = uniqueAssetTypes;
}
// 6. Channel Validation & Deduplication
let channels = [];
if (data.channels && Array.isArray(data.channels)) {
const uniqueChannels = [...new Set(data.channels)];
const existingChannels = await models.Channel.findAll({
where: { code: uniqueChannels },
transaction
});
if (existingChannels.length !== uniqueChannels.length) {
throw new Error('One or more selected channels do not exist');
}
channels = uniqueChannels;
}
// 7. Workflow Code Validation
let workflowCode = data.workflowCode || data.workflow_code || 'standard';
if (workflowCode.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)) {
const wfReg = await models.WorkflowRegistry.findByPk(workflowCode, { transaction });
if (wfReg) {
workflowCode = wfReg.code;
}
}
const existsInDb = await models.WorkflowRegistry.findOne({
where: { code: workflowCode },
transaction
});
const isStandard = ['standard', 'none', 'express', 'compliance'].includes(workflowCode);
if (!isStandard && !existsInDb) {
throw new Error(`Unknown workflow code: "${workflowCode}"`);
}
// 8. Completeness Rules Validation
const completenessRules = data.completenessRules || data.completeness_rules || {};
if (Object.keys(completenessRules).length > 0) {
let totalWeight = 0;
for (const [key, val] of Object.entries(completenessRules)) {
const weight = Number(val);
if (isNaN(weight)) {
throw new Error(`Completeness rule weight for "${key}" must be a number`);
}
if (weight < 0) {
throw new Error(`Completeness rule weight for "${key}" cannot be negative`);
}
totalWeight += weight;
}
if (totalWeight !== 100) {
throw new Error(`Total completeness rules weight must equal 100% (currently ${totalWeight}%)`);
}
}
// 9. Save Catalog
const createData = {
code: data.code,
name: data.name,
description: data.description,
status: data.status || 'draft',
category_id: categoryId,
workflow_code: workflowCode,
completeness_rules: completenessRules,
attribute_set_id: attributeSetId
};
const record = await models.Catalog.create(createData, { transaction });
// Save attribute relationships preserving the payload display order
for (let i = 0; i < attributes.length; i++) {
await models.FamilyAttribute.create({
family_id: record.id,
attribute_id: attributes[i],
display_order: i
}, { transaction });
}
// Save variant axis relationships
for (const attributeId of variantAxes) {
await models.FamilyVariantAxis.create({
family_id: record.id,
attribute_id: attributeId
}, { transaction });
}
// Save asset requirements
for (const assetTypeId of assetRequirements) {
await models.FamilyAssetRequirement.create({
family_id: record.id,
asset_type_id: assetTypeId
}, { transaction });
}
// Save channel relationships
for (const channelCode of channels) {
await models.FamilyChannel.create({
family_id: record.id,
channel_code: channelCode
}, { transaction });
}
await transaction.commit();
const fullRecord = await repository.findById(record.id);
await this.attachCounts(fullRecord);
// Socket Emit family.created
SocketService.broadcast('family.created', fullRecord);
// Audit Logs
await AuditService.log({
action: 'CREATE',
resource: 'Catalog',
resourceId: record.id,
userId: userContext.id || 'system',
details: data
});
return fullRecord;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async update(id, data, userContext = {}) {
const record = await repository.update(id, data);
if (!record) {
throw new Error('Catalog not found');
const transaction = await sequelize.transaction();
try {
const record = await models.Catalog.findByPk(id, { transaction });
if (!record) {
throw new Error('Product Family not found');
}
// 1. Immutable Code Validation
if (data.code && data.code !== record.code) {
throw new Error('Family code is immutable and cannot be updated');
}
// 2. Category Validation
const categoryId = data.hasOwnProperty('categoryId')
? data.categoryId
: (data.hasOwnProperty('category') ? data.category : record.category_id);
if (categoryId) {
const category = await models.Categorie.findOne({
where: { id: categoryId },
transaction
});
if (!category) {
throw new Error('Category not found');
}
if (category.status !== 'active') {
throw new Error('Category is inactive');
}
if (category.deleted_at || category.deletedAt) {
throw new Error('Category has been deleted');
}
}
// Resolve attributes from attribute set if updated
const attributeSetId = data.hasOwnProperty('attributeSetId')
? data.attributeSetId
: (data.hasOwnProperty('attribute_set_id') ? data.attribute_set_id : record.attribute_set_id);
if (data.hasOwnProperty('attributeSetId') || data.hasOwnProperty('attribute_set_id')) {
if (attributeSetId) {
const attributeSet = await models.AttributeSet.findByPk(attributeSetId, {
transaction,
include: [
{
model: models.AttributeGroup,
as: 'groups',
include: [
{
model: models.Attribute,
as: 'attributes'
}
]
}
]
});
if (attributeSet && attributeSet.groups) {
const inheritedAttributes = [];
for (const group of attributeSet.groups) {
if (group.attributes) {
for (const attr of group.attributes) {
inheritedAttributes.push(attr.id);
}
}
}
data.attributes = [...new Set(inheritedAttributes)];
}
} else {
data.attributes = [];
}
}
// 3. Attribute Validation & Deduplication
let attributes = null;
if (data.attributes && Array.isArray(data.attributes)) {
const uniqueAttributeIds = [...new Set(data.attributes)];
const existingAttributes = await models.Attribute.findAll({
where: { id: uniqueAttributeIds },
transaction
});
if (existingAttributes.length !== uniqueAttributeIds.length) {
throw new Error('One or more selected attributes do not exist');
}
for (const attr of existingAttributes) {
if (attr.deleted_at || attr.deletedAt) {
throw new Error(`Attribute "${attr.name}" has been deleted`);
}
}
attributes = uniqueAttributeIds;
}
// 4. Variant Strategy Validation & Deduplication
let variantAxes = null;
if (data.variantAxes && Array.isArray(data.variantAxes)) {
const uniqueAxes = [...new Set(data.variantAxes)];
const targetAttributes = attributes || (await record.getAttributes({ transaction })).map(a => a.id);
const attributesSet = new Set(targetAttributes);
const invalidAxes = uniqueAxes.filter(axis => !attributesSet.has(axis));
if (invalidAxes.length > 0) {
throw new Error('Variant axes must be a subset of family attributes');
}
variantAxes = uniqueAxes;
}
// 5. Asset Requirement Validation & Deduplication
let assetRequirements = null;
if (data.assetRequirements && Array.isArray(data.assetRequirements)) {
const uniqueAssetTypes = [...new Set(data.assetRequirements)];
const existingAssetTypes = await models.AssetType.findAll({
where: { id: uniqueAssetTypes },
transaction
});
if (existingAssetTypes.length !== uniqueAssetTypes.length) {
throw new Error('One or more selected asset types do not exist');
}
for (const at of existingAssetTypes) {
if (at.status !== 'active') {
throw new Error(`Asset type "${at.name}" is inactive`);
}
if (at.deleted_at || at.deletedAt) {
throw new Error(`Asset type "${at.name}" has been deleted`);
}
}
assetRequirements = uniqueAssetTypes;
}
// 6. Channel Validation & Deduplication
let channels = null;
if (data.channels && Array.isArray(data.channels)) {
const uniqueChannels = [...new Set(data.channels)];
const existingChannels = await models.Channel.findAll({
where: { code: uniqueChannels },
transaction
});
if (existingChannels.length !== uniqueChannels.length) {
throw new Error('One or more selected channels do not exist');
}
channels = uniqueChannels;
}
// 7. Workflow Code Validation
let workflowCode = data.workflowCode || data.workflow_code || record.workflow_code;
if (workflowCode && workflowCode.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)) {
const wfReg = await models.WorkflowRegistry.findByPk(workflowCode, { transaction });
if (wfReg) {
workflowCode = wfReg.code;
}
}
if (workflowCode) {
const existsInDb = await models.WorkflowRegistry.findOne({
where: { code: workflowCode },
transaction
});
const isStandard = ['standard', 'none', 'express', 'compliance'].includes(workflowCode);
if (!isStandard && !existsInDb) {
throw new Error(`Unknown workflow code: "${workflowCode}"`);
}
}
// 8. Completeness Rules Validation
const completenessRules = data.completenessRules || data.completeness_rules || record.completeness_rules;
if (completenessRules && Object.keys(completenessRules).length > 0) {
let totalWeight = 0;
for (const [key, val] of Object.entries(completenessRules)) {
const weight = Number(val);
if (isNaN(weight)) {
throw new Error(`Completeness rule weight for "${key}" must be a number`);
}
if (weight < 0) {
throw new Error(`Completeness rule weight for "${key}" cannot be negative`);
}
totalWeight += weight;
}
if (totalWeight !== 100) {
throw new Error(`Total completeness rules weight must equal 100% (currently ${totalWeight}%)`);
}
}
// 9. Update Catalog
const updateData = {
name: data.name || record.name,
description: data.hasOwnProperty('description') ? data.description : record.description,
status: data.status || record.status,
category_id: categoryId,
workflow_code: workflowCode,
completeness_rules: completenessRules,
attribute_set_id: attributeSetId
};
await record.update(updateData, { transaction });
// Save bridge relationships
if (attributes !== null) {
await models.FamilyAttribute.destroy({ where: { family_id: id }, transaction });
for (let i = 0; i < attributes.length; i++) {
await models.FamilyAttribute.create({
family_id: id,
attribute_id: attributes[i],
display_order: i
}, { transaction });
}
await AuditService.log({ action: 'ATTRIBUTES_CHANGED', resource: 'Catalog', resourceId: id, userId: userContext.id || 'system', details: attributes });
}
if (variantAxes !== null) {
await models.FamilyVariantAxis.destroy({ where: { family_id: id }, transaction });
for (const attributeId of variantAxes) {
await models.FamilyVariantAxis.create({
family_id: id,
attribute_id: attributeId
}, { transaction });
}
await AuditService.log({ action: 'VARIANT_AXES_CHANGED', resource: 'Catalog', resourceId: id, userId: userContext.id || 'system', details: variantAxes });
}
if (assetRequirements !== null) {
await models.FamilyAssetRequirement.destroy({ where: { family_id: id }, transaction });
for (const assetTypeId of assetRequirements) {
await models.FamilyAssetRequirement.create({
family_id: id,
asset_type_id: assetTypeId
}, { transaction });
}
await AuditService.log({ action: 'ASSET_REQUIREMENTS_CHANGED', resource: 'Catalog', resourceId: id, userId: userContext.id || 'system', details: assetRequirements });
}
if (channels !== null) {
await models.FamilyChannel.destroy({ where: { family_id: id }, transaction });
for (const channelCode of channels) {
await models.FamilyChannel.create({
family_id: id,
channel_code: channelCode
}, { transaction });
}
await AuditService.log({ action: 'CHANNELS_CHANGED', resource: 'Catalog', resourceId: id, userId: userContext.id || 'system', details: channels });
}
if (data.workflowCode || data.workflow_code) {
await AuditService.log({ action: 'WORKFLOW_CHANGED', resource: 'Catalog', resourceId: id, userId: userContext.id || 'system', details: workflowCode });
}
await transaction.commit();
const fullRecord = await repository.findById(id);
await this.attachCounts(fullRecord);
// Socket Emit family.updated
SocketService.broadcast('family.updated', fullRecord);
// General Update Audit
await AuditService.log({
action: 'UPDATE',
resource: 'Catalog',
resourceId: id,
userId: userContext.id || 'system',
details: data
});
return fullRecord;
} catch (error) {
await transaction.rollback();
throw error;
}
SocketService.broadcast('catalog:updated', record);
await AuditService.log({
action: 'UPDATE',
resource: 'Catalog',
resourceId: id,
userId: userContext.id || 'system',
details: data
});
return record;
}
async delete(id, userContext = {}) {
const deleted = await repository.delete(id);
if (!deleted) {
throw new Error('Catalog not found');
const transaction = await sequelize.transaction();
try {
const record = await models.Catalog.findByPk(id, { transaction });
if (!record) {
throw new Error('Product Family not found');
}
// Check product linkages
const productCount = await models.Product.count({ where: { family_id: id }, transaction });
if (productCount > 0) {
const error = new Error('Cannot delete Product Family as it is used by one or more products');
error.statusCode = 400;
error.data = { canDelete: false, productCount };
throw error;
}
// Soft delete/Archive
await record.destroy({ transaction });
await transaction.commit();
// Socket Emit family.archived
SocketService.broadcast('family.archived', { id });
// Audit log ARCHIVE
await AuditService.log({
action: 'DELETE',
resource: 'Catalog',
resourceId: id,
userId: userContext.id || 'system'
});
return true;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async restore(id, userContext = {}) {
const transaction = await sequelize.transaction();
try {
const record = await models.Catalog.findByPk(id, { paranoid: false, transaction });
if (!record) {
throw new Error('Product Family not found');
}
if (!record.deletedAt && !record.deleted_at) {
throw new Error('Product Family is not archived');
}
await record.restore({ transaction });
await transaction.commit();
const restoredRecord = await repository.findById(id);
await this.attachCounts(restoredRecord);
// Socket Emit family.restored
SocketService.broadcast('family.restored', restoredRecord);
// Audit log RESTORE
await AuditService.log({
action: 'RESTORE',
resource: 'Catalog',
resourceId: id,
userId: userContext.id || 'system'
});
return restoredRecord;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async getBlueprint(id) {
const family = await repository.findById(id);
if (!family) throw new Error('Product Family not found');
let groups = [];
if (family.attribute_set_id) {
const setRecord = await models.AttributeSet.findByPk(family.attribute_set_id, {
include: [
{
model: models.AttributeGroup,
as: 'groups',
include: [
{
model: models.Attribute,
as: 'attributes',
include: [
{
model: models.AttributeOption,
as: 'optionsList',
attributes: ['id', 'code', 'label', 'sort_order', 'status']
}
]
}
]
}
]
});
if (setRecord && setRecord.groups) {
groups = setRecord.groups;
}
}
SocketService.broadcast('catalog:deleted', { id });
return {
familyId: family.id,
code: family.code,
name: family.name,
category: family.category,
attributeSet: family.attributeSet,
groups,
variantAxes: family.variantAxes || [],
channels: (family.channels || []).map(c => c.channel_code),
assetRequirements: family.assetRequirements || [],
workflowCode: family.workflow_code,
completenessRules: family.completeness_rules || {}
};
}
await AuditService.log({
action: 'DELETE',
resource: 'Catalog',
resourceId: id,
userId: userContext.id || 'system'
});
return true;
async getSummary(id) {
const family = await repository.findById(id);
if (!family) throw new Error('Product Family not found');
const recordWithCounts = await this.attachCounts(family);
return {
id: recordWithCounts.id,
code: recordWithCounts.code,
name: recordWithCounts.name,
stats: {
groupsCount: recordWithCounts.getDataValue('attributeGroups') || 0,
attributesCount: recordWithCounts.getDataValue('attributeCount') || 0,
variantAxesCount: recordWithCounts.getDataValue('variantAxisCount') || 0,
channelsCount: recordWithCounts.getDataValue('channelCount') || 0,
assetRequirementsCount: recordWithCounts.getDataValue('assetRequirementCount') || 0,
workflowCode: recordWithCounts.workflow_code || 'standard',
completenessRulesCount: Object.keys(recordWithCounts.completeness_rules || {}).length
}
};
}
}
@@ -1,22 +1,83 @@
import { body, param } from 'express-validator';
export const createValidation = [
body('name')
.optional()
body('code')
.optional({ checkFalsy: true })
.isString()
.trim()
.withMessage('Name must be a string')
.matches(/^[a-z0-9_]+$/)
.withMessage('Code must be lowercase alphanumeric and underscores only'),
body('name')
.isString()
.trim()
.notEmpty()
.withMessage('Name is required'),
body('categoryId')
.optional({ checkFalsy: true })
.isUUID()
.withMessage('Category ID must be a valid UUID'),
body('category')
.optional({ checkFalsy: true })
.isUUID()
.withMessage('Category ID must be a valid UUID'),
body('attributes')
.optional()
.isArray()
.withMessage('Attributes must be an array of attribute IDs'),
body('variantAxes')
.optional()
.isArray()
.withMessage('Variant axes must be an array of attribute IDs'),
body('assetRequirements')
.optional()
.isArray()
.withMessage('Asset requirements must be an array of asset type IDs'),
body('channels')
.optional()
.isArray()
.withMessage('Channels must be an array of channel code strings')
];
export const updateValidation = [
param('id')
.isUUID()
.withMessage('Valid UUID is required'),
body('code')
.optional()
.isString()
.trim()
.matches(/^[a-z0-9_]+$/)
.withMessage('Code must be lowercase alphanumeric and underscores only'),
body('name')
.optional()
.isString()
.trim()
.withMessage('Name must be a string')
.notEmpty()
.withMessage('Name cannot be empty'),
body('categoryId')
.optional({ checkFalsy: true })
.isUUID()
.withMessage('Category ID must be a valid UUID'),
body('category')
.optional({ checkFalsy: true })
.isUUID()
.withMessage('Category ID must be a valid UUID'),
body('attributes')
.optional()
.isArray()
.withMessage('Attributes must be an array of attribute IDs'),
body('variantAxes')
.optional()
.isArray()
.withMessage('Variant axes must be an array of attribute IDs'),
body('assetRequirements')
.optional()
.isArray()
.withMessage('Asset requirements must be an array of asset type IDs'),
body('channels')
.optional()
.isArray()
.withMessage('Channels must be an array of channel code strings')
];
export const deleteValidation = [
@@ -0,0 +1,43 @@
import { Model, DataTypes } from 'sequelize';
export class FamilyAsset extends Model {
static associate(models) {
FamilyAsset.belongsTo(models.Catalog, { foreignKey: 'family_id', as: 'family' });
FamilyAsset.belongsTo(models.Asset, { foreignKey: 'asset_id', as: 'asset' });
}
}
export default (sequelize) => {
FamilyAsset.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
family_id: {
type: DataTypes.UUID,
allowNull: false
},
asset_id: {
type: DataTypes.UUID,
allowNull: false
},
role: {
type: DataTypes.STRING(100),
allowNull: true
},
display_order: {
type: DataTypes.INTEGER,
defaultValue: 0
}
}, {
sequelize,
modelName: 'FamilyAsset',
tableName: 'family_assets',
timestamps: true,
underscored: true
});
return FamilyAsset;
};
@@ -0,0 +1,28 @@
import { Model, DataTypes } from 'sequelize';
export class FamilyAssetRequirement extends Model {
static associate(models) {}
}
export default (sequelize) => {
FamilyAssetRequirement.init({
family_id: {
type: DataTypes.UUID,
allowNull: false,
primaryKey: true
},
asset_type_id: {
type: DataTypes.UUID,
allowNull: false,
primaryKey: true
}
}, {
sequelize,
modelName: 'FamilyAssetRequirement',
tableName: 'family_asset_requirements',
timestamps: true,
underscored: true
});
return FamilyAssetRequirement;
};
@@ -0,0 +1,33 @@
import { Model, DataTypes } from 'sequelize';
export class FamilyAttribute extends Model {
static associate(models) {}
}
export default (sequelize) => {
FamilyAttribute.init({
family_id: {
type: DataTypes.UUID,
allowNull: false,
primaryKey: true
},
attribute_id: {
type: DataTypes.UUID,
allowNull: false,
primaryKey: true
},
display_order: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
}
}, {
sequelize,
modelName: 'FamilyAttribute',
tableName: 'family_attributes',
timestamps: true,
underscored: true
});
return FamilyAttribute;
};
@@ -0,0 +1,28 @@
import { Model, DataTypes } from 'sequelize';
export class FamilyChannel extends Model {
static associate(models) {}
}
export default (sequelize) => {
FamilyChannel.init({
family_id: {
type: DataTypes.UUID,
allowNull: false,
primaryKey: true
},
channel_code: {
type: DataTypes.STRING(50),
allowNull: false,
primaryKey: true
}
}, {
sequelize,
modelName: 'FamilyChannel',
tableName: 'family_channels',
timestamps: true,
underscored: true
});
return FamilyChannel;
};
@@ -0,0 +1,28 @@
import { Model, DataTypes } from 'sequelize';
export class FamilyVariantAxis extends Model {
static associate(models) {}
}
export default (sequelize) => {
FamilyVariantAxis.init({
family_id: {
type: DataTypes.UUID,
allowNull: false,
primaryKey: true
},
attribute_id: {
type: DataTypes.UUID,
allowNull: false,
primaryKey: true
}
}, {
sequelize,
modelName: 'FamilyVariantAxis',
tableName: 'family_variant_axes',
timestamps: true,
underscored: true
});
return FamilyVariantAxis;
};
+1 -1
View File
@@ -3,6 +3,6 @@ import catalogsRouter from './catalogs/catalog.routes.js';
const router = Router();
router.use('/catalogs', catalogsRouter);
router.use('/families', catalogsRouter);
export default router;
@@ -1,10 +1,19 @@
import service from './categorie.service.js';
function serializeCategory(cat) {
if (!cat) return null;
const json = cat.toJSON ? cat.toJSON() : { ...cat };
json.parentId = json.parent_id;
json.parentName = json.parent ? json.parent.name : null;
return json;
}
export class CategorieController {
async getAll(req, res, next) {
try {
const records = await service.getAll(req.query);
return res.status(200).json({ success: true, data: records });
const data = Array.isArray(records) ? records.map(serializeCategory) : [];
return res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
@@ -13,7 +22,7 @@ export class CategorieController {
async getById(req, res, next) {
try {
const record = await service.getById(req.params.id);
return res.status(200).json({ success: true, data: record });
return res.status(200).json({ success: true, data: serializeCategory(record) });
} catch (error) {
next(error);
}
@@ -22,7 +31,7 @@ export class CategorieController {
async create(req, res, next) {
try {
const record = await service.create(req.body, req.user);
return res.status(201).json({ success: true, data: record });
return res.status(201).json({ success: true, data: serializeCategory(record) });
} catch (error) {
next(error);
}
@@ -31,7 +40,7 @@ export class CategorieController {
async update(req, res, next) {
try {
const record = await service.update(req.params.id, req.body, req.user);
return res.status(200).json({ success: true, data: record });
return res.status(200).json({ success: true, data: serializeCategory(record) });
} catch (error) {
next(error);
}
@@ -45,6 +54,24 @@ export class CategorieController {
next(error);
}
}
async archive(req, res, next) {
try {
await service.archive(req.params.id, req.user);
return res.status(200).json({ success: true, message: 'Category archived successfully' });
} catch (error) {
next(error);
}
}
async restore(req, res, next) {
try {
const data = await service.restore(req.params.id, req.user);
return res.status(200).json({ success: true, data: serializeCategory(data), message: 'Category restored successfully' });
} catch (error) {
next(error);
}
}
}
export default new CategorieController();
@@ -2,7 +2,21 @@ import { Model, DataTypes } from 'sequelize';
export class Categorie extends Model {
static associate(models) {
// Define associations here
// Self-referential associations for Category tree hierarchy
Categorie.belongsTo(models.Categorie, {
foreignKey: 'parent_id',
as: 'parent'
});
Categorie.hasMany(models.Categorie, {
foreignKey: 'parent_id',
as: 'children'
});
// M:N with ProductFamily (Catalog)
Categorie.hasMany(models.Catalog, {
foreignKey: 'category_id',
as: 'families'
});
}
}
@@ -14,24 +28,44 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
parent_id: {
type: DataTypes.UUID,
allowNull: true
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true
},
name: {
type: DataTypes.STRING,
type: DataTypes.STRING(100),
allowNull: false
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
status: {
type: DataTypes.STRING,
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'active'
},
metadata: {
type: DataTypes.JSONB,
path: {
type: DataTypes.STRING(500),
allowNull: true
},
level: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
}
}, {
sequelize,
modelName: 'Categorie',
tableName: 'categories',
timestamps: true,
underscored: true
underscored: true,
paranoid: true // Soft deletes support
});
return Categorie;
@@ -2,11 +2,51 @@ import { models } from '../../../shared/database/models.js';
export class CategorieRepository {
async findAll(options = {}) {
return await models.Categorie.findAll(options);
return await models.Categorie.findAll({
include: [
{
model: models.Categorie,
as: 'parent',
attributes: ['name']
}
],
order: [
['path', 'ASC'],
['name', 'ASC']
],
...options
});
}
async findById(id, options = {}) {
return await models.Categorie.findByPk(id, options);
return await models.Categorie.findByPk(id, {
include: [
{
model: models.Categorie,
as: 'parent',
attributes: ['name']
},
{
model: models.Categorie,
as: 'children'
}
],
...options
});
}
async findByCode(code, options = {}) {
return await models.Categorie.findOne({
where: { code },
...options
});
}
async findChildren(parentId, options = {}) {
return await models.Categorie.findAll({
where: { parent_id: parentId },
...options
});
}
async create(data, options = {}) {
@@ -14,13 +54,13 @@ export class CategorieRepository {
}
async update(id, data, options = {}) {
const record = await this.findById(id, options);
const record = await models.Categorie.findByPk(id, options);
if (!record) return null;
return await record.update(data, options);
}
async delete(id, options = {}) {
const record = await this.findById(id, options);
const record = await models.Categorie.findByPk(id, options);
if (!record) return false;
await record.destroy(options);
return true;
@@ -123,4 +123,24 @@ router.delete(
controller.delete
);
router.post(
'/:id/archive',
authenticate,
authorize(['write:categories']),
getByIdValidation,
validate,
audit('ARCHIVE_CATEGORIE'),
controller.archive
);
router.post(
'/:id/restore',
authenticate,
authorize(['write:categories']),
getByIdValidation,
validate,
audit('RESTORE_CATEGORIE'),
controller.restore
);
export default router;
@@ -1,74 +1,319 @@
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 { Op } from 'sequelize';
export class CategorieService {
async getAll(query = {}) {
// Add business logic filtering, pagination, etc.
return await repository.findAll();
const where = {};
if (query.status) {
where.status = query.status;
}
return await repository.findAll({ where });
}
async getById(id) {
const record = await repository.findById(id);
if (!record) {
throw new Error('Categorie not found');
throw new Error('Category not found');
}
return record;
}
async create(data, userContext = {}) {
const record = await repository.create(data);
// Broadcast event
SocketService.broadcast('categorie:created', record);
// Log audit
await AuditService.log({
action: 'CREATE',
resource: 'Categorie',
resourceId: record.id,
userId: userContext.id || 'system',
details: data
});
const transaction = await sequelize.transaction();
try {
if (!data.code || !data.code.trim()) {
if (data.name) {
data.code = data.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
}
if (!data.code) {
data.code = `cat_${Date.now()}`;
}
}
data.code = data.code.toLowerCase().trim();
return record;
// Check duplicate code
const existing = await models.Categorie.findOne({ where: { code: data.code }, transaction });
if (existing) {
throw new Error(`Category with code "${data.code}" already exists`);
}
let level = 0;
let path = `/${data.code}`;
if (data.parentId) {
const parent = await models.Categorie.findByPk(data.parentId, { transaction });
if (!parent) {
throw new Error('Parent category not found');
}
level = parent.level + 1;
path = `${parent.path}/${data.code}`;
}
const createData = {
code: data.code,
name: data.name,
description: data.description,
status: data.status || 'active',
parent_id: data.parentId || null,
level,
path
};
const record = await models.Categorie.create(createData, { transaction });
await transaction.commit();
const fullRecord = await repository.findById(record.id);
SocketService.broadcast('categorie:created', fullRecord);
await AuditService.log({
action: 'CREATE',
resource: 'Categorie',
resourceId: record.id,
userId: userContext.id || 'system',
details: createData
});
return fullRecord;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async update(id, data, userContext = {}) {
const record = await repository.update(id, data);
if (!record) {
throw new Error('Categorie not found');
const transaction = await sequelize.transaction();
try {
const record = await models.Categorie.findByPk(id, { transaction });
if (!record) {
throw new Error('Category not found');
}
if (data.code && data.code !== record.code) {
const existing = await models.Categorie.findOne({ where: { code: data.code }, transaction });
if (existing) {
throw new Error(`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 Error('Circular reference: Category cannot be its own parent');
}
const targetParent = await models.Categorie.findByPk(newParentId, { transaction });
if (!targetParent) {
throw new Error('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 Error('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 models.Categorie.findByPk(newParentId, { transaction });
level = parent.level + 1;
path = `${parent.path}/${categoryCode}`;
}
updateData.level = level;
updateData.path = path;
}
await record.update(updateData, { transaction });
// Cascade update children paths & levels recursively if path changed
if (pathChanged) {
const descendants = await models.Categorie.findAll({
where: {
path: {
[Op.like]: `${oldPath}/%`
}
},
transaction
});
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;
await desc.update({
path: newDescPath,
level: newDescLevel
}, { transaction });
}
}
await transaction.commit();
const fullRecord = await repository.findById(id);
SocketService.broadcast('categorie:updated', fullRecord);
await AuditService.log({
action: 'UPDATE',
resource: 'Categorie',
resourceId: id,
userId: userContext.id || 'system',
details: data
});
return fullRecord;
} catch (error) {
await transaction.rollback();
throw error;
}
SocketService.broadcast('categorie:updated', record);
await AuditService.log({
action: 'UPDATE',
resource: 'Categorie',
resourceId: id,
userId: userContext.id || 'system',
details: data
});
return record;
}
async delete(id, userContext = {}) {
const deleted = await repository.delete(id);
if (!deleted) {
throw new Error('Categorie not found');
const transaction = await sequelize.transaction();
try {
const record = await models.Categorie.findByPk(id, { transaction });
if (!record) {
throw new Error('Category not found');
}
// Check subcategories
const subcategoriesCount = await models.Categorie.count({
where: { parent_id: id },
transaction
});
if (subcategoriesCount > 0) {
throw new Error('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 Error('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 Error('Cannot delete category because it is used by one or more products');
}
await record.destroy({ force: true, transaction });
await transaction.commit();
SocketService.broadcast('categorie:deleted', { id });
await AuditService.log({
action: 'DELETE',
resource: 'Categorie',
resourceId: id,
userId: userContext.id || 'system'
});
return true;
} catch (error) {
await transaction.rollback();
throw error;
}
}
SocketService.broadcast('categorie:deleted', { id });
async archive(id, userContext = {}) {
const transaction = await sequelize.transaction();
try {
const record = await models.Categorie.findByPk(id, { transaction });
if (!record) {
throw new Error('Category not found');
}
await AuditService.log({
action: 'DELETE',
resource: 'Categorie',
resourceId: id,
userId: userContext.id || 'system'
});
// Soft delete/Archive
await record.destroy({ transaction });
return true;
await transaction.commit();
SocketService.broadcast('categorie:archived', { id });
await AuditService.log({
action: 'ARCHIVE',
resource: 'Categorie',
resourceId: id,
userId: userContext.id || 'system'
});
return true;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async restore(id, userContext = {}) {
const transaction = await sequelize.transaction();
try {
const record = await models.Categorie.findByPk(id, { paranoid: false, transaction });
if (!record) {
throw new Error('Category not found');
}
await record.restore({ transaction });
await transaction.commit();
const restored = await repository.findById(id);
SocketService.broadcast('categorie:restored', restored);
await AuditService.log({
action: 'RESTORE',
resource: 'Categorie',
resourceId: id,
userId: userContext.id || 'system'
});
return restored;
} catch (error) {
await transaction.rollback();
throw error;
}
}
}
@@ -1,22 +1,43 @@
import { body, param } from 'express-validator';
export const createValidation = [
body('name')
.optional()
body('code')
.optional({ checkFalsy: true })
.isString()
.trim()
.withMessage('Name must be a string')
.matches(/^[a-z0-9_]+$/)
.withMessage('Code must be lowercase alphanumeric and underscores only'),
body('name')
.isString()
.trim()
.notEmpty()
.withMessage('Name is required'),
body('parentId')
.optional({ checkFalsy: true })
.isUUID()
.withMessage('Parent category ID must be a valid UUID')
];
export const updateValidation = [
param('id')
.isUUID()
.withMessage('Valid UUID is required'),
body('code')
.optional()
.isString()
.trim()
.matches(/^[a-z0-9_]+$/)
.withMessage('Code must be lowercase alphanumeric and underscores only'),
body('name')
.optional()
.isString()
.trim()
.withMessage('Name must be a string')
.notEmpty()
.withMessage('Name cannot be empty'),
body('parentId')
.optional({ checkFalsy: true })
.isUUID()
.withMessage('Parent category ID must be a valid UUID')
];
export const deleteValidation = [
@@ -0,0 +1,43 @@
import { Model, DataTypes } from 'sequelize';
export class CategoryAsset extends Model {
static associate(models) {
CategoryAsset.belongsTo(models.Categorie, { foreignKey: 'category_id', as: 'category' });
CategoryAsset.belongsTo(models.Asset, { foreignKey: 'asset_id', as: 'asset' });
}
}
export default (sequelize) => {
CategoryAsset.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
category_id: {
type: DataTypes.UUID,
allowNull: false
},
asset_id: {
type: DataTypes.UUID,
allowNull: false
},
role: {
type: DataTypes.STRING(100),
allowNull: true
},
display_order: {
type: DataTypes.INTEGER,
defaultValue: 0
}
}, {
sequelize,
modelName: 'CategoryAsset',
tableName: 'category_assets',
timestamps: true,
underscored: true
});
return CategoryAsset;
};
@@ -0,0 +1,68 @@
import service from './channelType.service.js';
export class ChannelTypeController {
async getAll(req, res, next) {
try {
const records = await service.getAll(req.query);
return res.status(200).json({ success: true, data: records });
} catch (error) {
next(error);
}
}
async getById(req, res, next) {
try {
const record = await service.getById(req.params.id);
return res.status(200).json({ success: true, data: record });
} catch (error) {
next(error);
}
}
async create(req, res, next) {
try {
const record = await service.create(req.body, req.user);
return res.status(201).json({ success: true, data: record });
} catch (error) {
next(error);
}
}
async update(req, res, next) {
try {
const record = await service.update(req.params.id, req.body, req.user);
return res.status(200).json({ success: true, data: record });
} catch (error) {
next(error);
}
}
async delete(req, res, next) {
try {
await service.delete(req.params.id, req.user);
return res.status(200).json({ success: true, message: 'Channel Type deleted successfully' });
} catch (error) {
next(error);
}
}
async archive(req, res, next) {
try {
await service.archive(req.params.id, req.user);
return res.status(200).json({ success: true, message: 'Channel Type archived successfully' });
} catch (error) {
next(error);
}
}
async restore(req, res, next) {
try {
const record = await service.restore(req.params.id, req.user);
return res.status(200).json({ success: true, data: record, message: 'Channel Type restored successfully' });
} catch (error) {
next(error);
}
}
}
export default new ChannelTypeController();
@@ -0,0 +1,48 @@
import { Model, DataTypes } from 'sequelize';
export class ChannelType extends Model {
static associate(models) {
ChannelType.hasMany(models.Channel, {
foreignKey: 'type_id',
as: 'channels'
});
}
}
export default (sequelize) => {
ChannelType.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true
},
name: {
type: DataTypes.STRING(100),
allowNull: false
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
status: {
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'active'
}
}, {
sequelize,
modelName: 'ChannelType',
tableName: 'channel_types',
timestamps: true,
underscored: true,
paranoid: true
});
return ChannelType;
};
@@ -0,0 +1,30 @@
import { models } from '../../../shared/database/models.js';
export class ChannelTypeRepository {
async findAll(options = {}) {
return await models.ChannelType.findAll(options);
}
async findById(id, options = {}) {
return await models.ChannelType.findByPk(id, options);
}
async create(data, options = {}) {
return await models.ChannelType.create(data, options);
}
async update(id, data, options = {}) {
const record = await this.findById(id, options);
if (!record) return null;
return await record.update(data, options);
}
async delete(id, options = {}) {
const record = await this.findById(id, options);
if (!record) return false;
await record.destroy(options);
return true;
}
}
export default new ChannelTypeRepository();
@@ -0,0 +1,82 @@
import { Router } from 'express';
import controller from './channelType.controller.js';
import { authenticate } from '../../../shared/middleware/auth.middleware.js';
import { validate } from '../../../shared/middleware/validation.middleware.js';
import { authorize } from '../../../shared/middleware/permission.middleware.js';
import { audit } from '../../../shared/middleware/audit.middleware.js';
import {
createValidation,
updateValidation,
deleteValidation,
getByIdValidation
} from './channelType.validation.js';
const router = Router();
router.get(
'/',
authenticate,
authorize(['read:channels']),
controller.getAll
);
router.get(
'/:id',
authenticate,
authorize(['read:channels']),
getByIdValidation,
validate,
controller.getById
);
router.post(
'/',
authenticate,
authorize(['write:channels']),
createValidation,
validate,
audit('CREATE_CHANNEL_TYPE'),
controller.create
);
router.put(
'/:id',
authenticate,
authorize(['write:channels']),
updateValidation,
validate,
audit('UPDATE_CHANNEL_TYPE'),
controller.update
);
router.delete(
'/:id',
authenticate,
authorize(['write:channels']),
deleteValidation,
validate,
audit('DELETE_CHANNEL_TYPE'),
controller.delete
);
router.post(
'/:id/archive',
authenticate,
authorize(['write:channels']),
getByIdValidation,
validate,
audit('ARCHIVE_CHANNEL_TYPE'),
controller.archive
);
router.post(
'/:id/restore',
authenticate,
authorize(['write:channels']),
getByIdValidation,
validate,
audit('RESTORE_CHANNEL_TYPE'),
controller.restore
);
export default router;
@@ -0,0 +1,156 @@
import repository from './channelType.repository.js';
import { models } from '../../../shared/database/models.js';
import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
export class ChannelTypeService {
async getAll(query = {}) {
const where = {};
if (query.status) {
where.status = query.status;
}
return await repository.findAll({ where });
}
async getById(id) {
const record = await repository.findById(id);
if (!record) {
throw new Error('Channel Type not found');
}
return record;
}
async create(data, userContext = {}) {
if (!data.code || !data.code.trim()) {
if (data.name) {
data.code = data.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
}
if (!data.code) {
data.code = `cht_${Date.now()}`;
}
}
data.code = data.code.toLowerCase().trim();
// Check duplicate code
const existing = await models.ChannelType.findOne({ where: { code: data.code } });
if (existing) {
throw new Error(`Channel Type with code "${data.code}" already exists`);
}
const record = await repository.create(data);
SocketService.broadcast('channelType:created', record);
await AuditService.log({
action: 'CREATE',
resource: 'ChannelType',
resourceId: record.id,
userId: userContext.id || 'system',
details: data
});
return record;
}
async update(id, data, userContext = {}) {
const record = await repository.findById(id);
if (!record) {
throw new Error('Channel Type not found');
}
if (data.code) {
data.code = data.code.toLowerCase().trim();
if (data.code !== record.code) {
const existing = await models.ChannelType.findOne({ where: { code: data.code } });
if (existing) {
throw new Error(`Channel Type with code "${data.code}" already exists`);
}
}
}
const updatedRecord = await repository.update(id, data);
SocketService.broadcast('channelType:updated', updatedRecord);
await AuditService.log({
action: 'UPDATE',
resource: 'ChannelType',
resourceId: id,
userId: userContext.id || 'system',
details: data
});
return updatedRecord;
}
async delete(id, userContext = {}) {
const record = await repository.findById(id);
if (!record) {
throw new Error('Channel Type not found');
}
// Check usage in active channels
const channelsCount = await models.Channel.count({ where: { type_id: id } });
if (channelsCount > 0) {
throw new Error('Cannot delete Channel Type because it is used by one or more active Channels');
}
// Hard delete
await record.destroy({ force: true });
SocketService.broadcast('channelType:deleted', { id });
await AuditService.log({
action: 'DELETE',
resource: 'ChannelType',
resourceId: id,
userId: userContext.id || 'system'
});
return true;
}
async archive(id, userContext = {}) {
const record = await repository.findById(id);
if (!record) {
throw new Error('Channel Type not found');
}
// Soft delete / Archive
await record.destroy();
SocketService.broadcast('channelType:archived', { id });
await AuditService.log({
action: 'ARCHIVE',
resource: 'ChannelType',
resourceId: id,
userId: userContext.id || 'system'
});
return true;
}
async restore(id, userContext = {}) {
const record = await repository.findById(id, { paranoid: false });
if (!record) {
throw new Error('Channel Type not found');
}
await record.restore();
const restored = await repository.findById(id);
SocketService.broadcast('channelType:restored', restored);
await AuditService.log({
action: 'RESTORE',
resource: 'ChannelType',
resourceId: id,
userId: userContext.id || 'system'
});
return restored;
}
}
export default new ChannelTypeService();
@@ -0,0 +1,45 @@
import { body, param } from 'express-validator';
export const createValidation = [
body('name')
.notEmpty()
.withMessage('Name is required')
.isString()
.trim()
.withMessage('Name must be a string'),
body('code')
.optional({ checkFalsy: true })
.isString()
.trim()
.matches(/^[a-z0-9_]+$/)
.withMessage('Code must be lowercase alphanumeric and underscores only')
];
export const updateValidation = [
param('id')
.isUUID()
.withMessage('Valid UUID is required'),
body('name')
.optional()
.isString()
.trim()
.withMessage('Name must be a string'),
body('code')
.optional({ checkFalsy: true })
.isString()
.trim()
.matches(/^[a-z0-9_]+$/)
.withMessage('Code must be lowercase alphanumeric and underscores only')
];
export const deleteValidation = [
param('id')
.isUUID()
.withMessage('Valid UUID is required')
];
export const getByIdValidation = [
param('id')
.isUUID()
.withMessage('Valid UUID is required')
];
@@ -45,6 +45,24 @@ export class ChannelController {
next(error);
}
}
async archive(req, res, next) {
try {
await service.archive(req.params.id, req.user);
return res.status(200).json({ success: true, message: 'Channel archived successfully' });
} catch (error) {
next(error);
}
}
async restore(req, res, next) {
try {
const data = await service.restore(req.params.id, req.user);
return res.status(200).json({ success: true, data, message: 'Channel restored successfully' });
} catch (error) {
next(error);
}
}
}
export default new ChannelController();
@@ -2,7 +2,10 @@ import { Model, DataTypes } from 'sequelize';
export class Channel extends Model {
static associate(models) {
// Define associations here
Channel.belongsTo(models.ChannelType, {
foreignKey: 'type_id',
as: 'type'
});
}
}
@@ -14,12 +17,26 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
type_id: {
type: DataTypes.UUID,
allowNull: true
},
name: {
type: DataTypes.STRING,
allowNull: false
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
status: {
type: DataTypes.STRING,
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'active'
},
metadata: {
@@ -31,7 +48,8 @@ export default (sequelize) => {
modelName: 'Channel',
tableName: 'channels',
timestamps: true,
underscored: true
underscored: true,
paranoid: true
});
return Channel;
@@ -123,4 +123,24 @@ router.delete(
controller.delete
);
router.post(
'/:id/archive',
authenticate,
authorize(['write:channels']),
getByIdValidation,
validate,
audit('ARCHIVE_CHANNEL'),
controller.archive
);
router.post(
'/:id/restore',
authenticate,
authorize(['write:channels']),
getByIdValidation,
validate,
audit('RESTORE_CHANNEL'),
controller.restore
);
export default router;
@@ -54,11 +54,30 @@ export class ChannelService {
}
async delete(id, userContext = {}) {
const deleted = await repository.delete(id);
if (!deleted) {
const record = await models.Channel.findByPk(id);
if (!record) {
throw new Error('Channel not found');
}
// Check usage in families
const familyChannelCount = await models.FamilyChannel.count({
where: { channel_code: record.code }
});
if (familyChannelCount > 0) {
throw new Error('Cannot delete Channel because it is used by one or more Product Families');
}
// Check usage in assets
const assetCount = await models.ChannelAsset.count({
where: { channel_code: record.code }
});
if (assetCount > 0) {
throw new Error('Cannot delete Channel because it has digital assets linked');
}
// Hard delete
await record.destroy({ force: true });
SocketService.broadcast('channel:deleted', { id });
await AuditService.log({
@@ -70,6 +89,48 @@ export class ChannelService {
return true;
}
async archive(id, userContext = {}) {
const record = await models.Channel.findByPk(id);
if (!record) {
throw new Error('Channel not found');
}
// Soft delete / Archive
await record.destroy();
SocketService.broadcast('channel:archived', { id });
await AuditService.log({
action: 'ARCHIVE',
resource: 'Channel',
resourceId: id,
userId: userContext.id || 'system'
});
return true;
}
async restore(id, userContext = {}) {
const record = await models.Channel.findByPk(id, { paranoid: false });
if (!record) {
throw new Error('Channel not found');
}
await record.restore();
const restored = await repository.findById(id);
SocketService.broadcast('channel:restored', restored);
await AuditService.log({
action: 'RESTORE',
resource: 'Channel',
resourceId: id,
userId: userContext.id || 'system'
});
return restored;
}
}
export default new ChannelService();
@@ -0,0 +1,43 @@
import { Model, DataTypes } from 'sequelize';
export class ChannelAsset extends Model {
static associate(models) {
ChannelAsset.belongsTo(models.Channel, { foreignKey: 'channel_code', targetKey: 'code', as: 'channel' });
ChannelAsset.belongsTo(models.Asset, { foreignKey: 'asset_id', as: 'asset' });
}
}
export default (sequelize) => {
ChannelAsset.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
channel_code: {
type: DataTypes.STRING(50),
allowNull: false
},
asset_id: {
type: DataTypes.UUID,
allowNull: false
},
role: {
type: DataTypes.STRING(100),
allowNull: true
},
display_order: {
type: DataTypes.INTEGER,
defaultValue: 0
}
}, {
sequelize,
modelName: 'ChannelAsset',
tableName: 'channel_assets',
timestamps: true,
underscored: true
});
return ChannelAsset;
};
+2
View File
@@ -1,8 +1,10 @@
import { Router } from 'express';
import channelsRouter from './channels/channel.routes.js';
import channelTypesRouter from './channelTypes/channelType.routes.js';
const router = Router();
router.use('/channels', channelsRouter);
router.use('/channel-types', channelTypesRouter);
export default router;
+4
View File
@@ -12,6 +12,8 @@ import importsRouter from './imports/index.js';
import exportsRouter from './exports/index.js';
import settingsRouter from './settings/index.js';
import auditLogsRouter from './auditLogs/index.js';
import workflowsRouter from './workflows/workflow.routes.js';
import variantsRouter from './variants/index.js';
export default function registerRoutes(app) {
app.use('/api/v1', authenticationRouter);
@@ -28,4 +30,6 @@ export default function registerRoutes(app) {
app.use('/api/v1', exportsRouter);
app.use('/api/v1', settingsRouter);
app.use('/api/v1', auditLogsRouter);
app.use('/api/v1/workflows', workflowsRouter);
app.use('/api/v1', variantsRouter);
}
@@ -1,21 +1,32 @@
import repository from './assetType.repository.js';
import { models } from '../../../shared/database/models.js';
import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
export class AssetTypeService {
async getAll(query = {}) {
return await repository.findAll();
const where = {};
if (query.status) {
where.status = query.status;
}
return await repository.findAll({ where });
}
async getById(id) {
const record = await repository.findById(id);
if (!record) {
throw new Error('AssetType not found');
throw new Error('Asset Type not found');
}
return record;
}
async create(data, userContext = {}) {
// Check duplicate code
const existing = await models.AssetType.findOne({ where: { code: data.code } });
if (existing) {
throw new Error(`Asset Type with code "${data.code}" already exists`);
}
const record = await repository.create(data);
SocketService.broadcast('assetType:created', record);
@@ -32,12 +43,21 @@ export class AssetTypeService {
}
async update(id, data, userContext = {}) {
const record = await repository.update(id, data);
const record = await repository.findById(id);
if (!record) {
throw new Error('AssetType not found');
throw new Error('Asset Type not found');
}
SocketService.broadcast('assetType:updated', record);
if (data.code && data.code !== record.code) {
const existing = await models.AssetType.findOne({ where: { code: data.code } });
if (existing) {
throw new Error(`Asset Type with code "${data.code}" already exists`);
}
}
const updatedRecord = await repository.update(id, data);
SocketService.broadcast('assetType:updated', updatedRecord);
await AuditService.log({
action: 'UPDATE',
@@ -47,13 +67,24 @@ export class AssetTypeService {
details: data
});
return record;
return updatedRecord;
}
async delete(id, userContext = {}) {
const record = await repository.findById(id);
if (!record) {
throw new Error('Asset Type not found');
}
// Check if linked to any product family requirements
const requirementCount = await models.FamilyAssetRequirement.count({ where: { asset_type_id: id } });
if (requirementCount > 0) {
throw new Error('Cannot delete Asset Type because it is required by one or more Product Families');
}
const deleted = await repository.delete(id);
if (!deleted) {
throw new Error('AssetType not found');
throw new Error('Failed to delete Asset Type');
}
SocketService.broadcast('assetType:deleted', { id });
+101 -5
View File
@@ -1,7 +1,7 @@
import service from './asset.service.js';
export class AssetController {
async getAll(req, res, next) {
getAll = async (req, res, next) => {
try {
const records = await service.getAll(req.query);
return res.status(200).json({ success: true, data: records });
@@ -10,7 +10,7 @@ export class AssetController {
}
}
async getById(req, res, next) {
getById = async (req, res, next) => {
try {
const record = await service.getById(req.params.id);
return res.status(200).json({ success: true, data: record });
@@ -19,7 +19,7 @@ export class AssetController {
}
}
async create(req, res, next) {
create = async (req, res, next) => {
try {
const record = await service.create(req.body, req.user);
return res.status(201).json({ success: true, data: record });
@@ -28,7 +28,7 @@ export class AssetController {
}
}
async update(req, res, next) {
update = async (req, res, next) => {
try {
const record = await service.update(req.params.id, req.body, req.user);
return res.status(200).json({ success: true, data: record });
@@ -37,10 +37,106 @@ export class AssetController {
}
}
async delete(req, res, next) {
delete = async (req, res, next) => {
try {
const success = await service.delete(req.params.id, req.user);
return res.status(200).json({ success, message: 'Asset deleted successfully' });
} catch (error) {
if (error.statusCode === 400) {
return res.status(400).json({
success: false,
message: error.message,
data: error.relations
});
}
next(error);
}
}
upload = async (req, res, next) => {
try {
if (!req.file) {
return res.status(400).json({ success: false, message: 'No file uploaded' });
}
return res.status(200).json({
success: true,
data: {
name: req.file.originalname,
file_url: `/uploads/${req.file.filename}`,
file_size: req.file.size,
mime_type: req.file.mimetype
}
});
} catch (error) {
next(error);
}
}
replaceFile = async (req, res, next) => {
try {
if (!req.file) {
return res.status(400).json({ success: false, message: 'No file uploaded' });
}
const record = await service.replaceFile(
req.params.id,
{ file_url: `/uploads/${req.file.filename}` },
req.user
);
return res.status(200).json({ success: true, data: record });
} catch (error) {
next(error);
}
}
archive = async (req, res, next) => {
try {
const record = await service.archive(req.params.id, req.user);
return res.status(200).json({ success: true, data: record });
} catch (error) {
next(error);
}
}
restore = async (req, res, next) => {
try {
const record = await service.restore(req.params.id, req.user);
return res.status(200).json({ success: true, data: record });
} catch (error) {
next(error);
}
}
getRelations = async (req, res, next) => {
try {
const data = await service.getRelations(req.params.id);
return res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
}
getAnalytics = async (req, res, next) => {
try {
const data = await service.getAnalytics();
return res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
}
getFolders = async (req, res, next) => {
try {
const data = await service.getFolders();
return res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
}
getTags = async (req, res, next) => {
try {
const data = await service.getTags();
return res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
+123 -4
View File
@@ -1,7 +1,50 @@
import { Model, DataTypes } from 'sequelize';
export class Asset extends Model {
static associate(models) {}
static associate(models) {
Asset.belongsTo(models.AssetType, { foreignKey: 'asset_type_id', as: 'assetType' });
Asset.belongsTo(models.AssetFolder, { foreignKey: 'folder_id', as: 'folder' });
Asset.belongsToMany(models.Tag, {
through: models.AssetTag,
foreignKey: 'asset_id',
otherKey: 'tag_id',
as: 'tags'
});
Asset.hasMany(models.AssetVersion, { foreignKey: 'asset_id', as: 'versions' });
// Derived relationships
Asset.belongsToMany(models.Product, {
through: models.ProductAsset,
foreignKey: 'asset_id',
otherKey: 'product_id',
as: 'products'
});
Asset.belongsToMany(models.Variant, {
through: models.VariantAsset,
foreignKey: 'asset_id',
otherKey: 'variant_id',
as: 'variants'
});
Asset.belongsToMany(models.Catalog, {
through: models.FamilyAsset,
foreignKey: 'asset_id',
otherKey: 'family_id',
as: 'families'
});
Asset.belongsToMany(models.Categorie, {
through: models.CategoryAsset,
foreignKey: 'asset_id',
otherKey: 'category_id',
as: 'categories'
});
Asset.belongsToMany(models.Channel, {
through: models.ChannelAsset,
foreignKey: 'asset_id',
otherKey: 'channel_code',
targetKey: 'code',
as: 'channels'
});
}
}
export default (sequelize) => {
@@ -12,20 +55,96 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
code: {
type: DataTypes.STRING(100),
allowNull: false,
unique: true
},
name: {
type: DataTypes.STRING,
type: DataTypes.STRING(255),
allowNull: false
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
asset_type_id: {
type: DataTypes.UUID,
allowNull: true
},
file_name: {
type: DataTypes.STRING(255),
allowNull: false
},
file_url: {
type: DataTypes.STRING(500),
allowNull: false
},
mime_type: {
type: DataTypes.STRING(100),
allowNull: false
},
extension: {
type: DataTypes.STRING(20),
allowNull: false
},
file_size: {
type: DataTypes.INTEGER,
allowNull: false
},
checksum: {
type: DataTypes.STRING(64),
allowNull: false
},
width: {
type: DataTypes.INTEGER,
allowNull: true
},
height: {
type: DataTypes.INTEGER,
allowNull: true
},
duration: {
type: DataTypes.DECIMAL(10, 2),
allowNull: true
},
page_count: {
type: DataTypes.INTEGER,
allowNull: true
},
folder_id: {
type: DataTypes.UUID,
allowNull: true
},
version: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 1
},
status: {
type: DataTypes.STRING,
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'active'
},
created_by: {
type: DataTypes.STRING(100),
allowNull: true
},
updated_by: {
type: DataTypes.STRING(100),
allowNull: true
},
deleted_by: {
type: DataTypes.STRING(100),
allowNull: true
}
}, {
sequelize,
modelName: 'Asset',
tableName: 'assets',
timestamps: true,
underscored: true
underscored: true,
paranoid: true // Soft deletes support
});
return Asset;
+74 -107
View File
@@ -4,6 +4,7 @@ import { authenticate } from '../../../shared/middleware/auth.middleware.js';
import { validate } from '../../../shared/middleware/validation.middleware.js';
import { authorize } from '../../../shared/middleware/permission.middleware.js';
import { audit } from '../../../shared/middleware/audit.middleware.js';
import { uploadSingle } from '../../../shared/middleware/upload.middleware.js';
import {
createValidation,
updateValidation,
@@ -13,20 +14,40 @@ import {
const router = Router();
/**
* @swagger
* /api/v1/assets:
* get:
* summary: Retrieve all assets
* tags: [Assets]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Success
* 401:
* description: Unauthorized
*/
// 1. Usage Analytics (Must be above /:id to prevent matching as parameter)
router.get(
'/analytics',
authenticate,
authorize(['read:media']),
controller.getAnalytics
);
// Folders List
router.get(
'/folders',
authenticate,
authorize(['read:media']),
controller.getFolders
);
// Tags List
router.get(
'/tags',
authenticate,
authorize(['read:media']),
controller.getTags
);
// 2. Upload asset file
router.post(
'/upload',
authenticate,
authorize(['write:media']),
uploadSingle('file'),
controller.upload
);
// 3. Get all assets
router.get(
'/',
authenticate,
@@ -34,26 +55,17 @@ router.get(
controller.getAll
);
/**
* @swagger
* /api/v1/assets/{id}:
* get:
* summary: Retrieve a single asset
* tags: [Assets]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Success
* 404:
* description: Not Found
*/
// 4. Get asset relations
router.get(
'/:id/relations',
authenticate,
authorize(['read:media']),
getByIdValidation,
validate,
controller.getRelations
);
// 5. Get asset by ID
router.get(
'/:id',
authenticate,
@@ -63,30 +75,7 @@ router.get(
controller.getById
);
/**
* @swagger
* /api/v1/assets:
* post:
* summary: Create a new asset
* tags: [Assets]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [name]
* properties:
* name:
* type: string
* responses:
* 201:
* description: Created
* 400:
* description: Validation Error
*/
// 6. Create new asset metadata entry
router.post(
'/',
authenticate,
@@ -97,35 +86,7 @@ router.post(
controller.create
);
/**
* @swagger
* /api/v1/assets/{id}:
* put:
* summary: Update an asset
* tags: [Assets]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* responses:
* 200:
* description: Success
* 404:
* description: Not Found
*/
// 7. Update asset metadata
router.put(
'/:id',
authenticate,
@@ -136,26 +97,7 @@ router.put(
controller.update
);
/**
* @swagger
* /api/v1/assets/{id}:
* delete:
* summary: Delete an asset
* tags: [Assets]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Deleted
* 404:
* description: Not Found
*/
// 8. Delete / Unlink asset
router.delete(
'/:id',
authenticate,
@@ -166,4 +108,29 @@ router.delete(
controller.delete
);
// 9. Replace asset file (new version)
router.post(
'/:id/replace',
authenticate,
authorize(['write:media']),
uploadSingle('file'),
controller.replaceFile
);
// 10. Archive asset
router.post(
'/:id/archive',
authenticate,
authorize(['write:media']),
controller.archive
);
// 11. Restore asset
router.post(
'/:id/restore',
authenticate,
authorize(['write:media']),
controller.restore
);
export default router;
+455 -20
View File
@@ -1,14 +1,59 @@
import repository from './asset.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 { extractMetadata } from '../../../shared/utils/metadataExtractor.js';
import { Op } from 'sequelize';
import path from 'path';
export class AssetService {
async getAll(query = {}) {
return await repository.findAll();
const where = {};
// Status filter
if (query.status) {
where.status = query.status;
}
// Search filter
if (query.search) {
where[Op.or] = [
{ name: { [Op.iLike]: `%${query.search}%` } },
{ code: { [Op.iLike]: `%${query.search}%` } },
{ file_name: { [Op.iLike]: `%${query.search}%` } }
];
}
// Folder filter
if (query.folder_id) {
where.folder_id = query.folder_id;
}
// Asset type filter
if (query.asset_type_id) {
where.asset_type_id = query.asset_type_id;
}
return await models.Asset.findAll({
where,
include: [
{ model: models.AssetType, as: 'assetType' },
{ model: models.AssetFolder, as: 'folder' },
{ model: models.Tag, as: 'tags', through: { attributes: [] } }
],
order: [['created_at', 'DESC']]
});
}
async getById(id) {
const record = await repository.findById(id);
const record = await models.Asset.findByPk(id, {
include: [
{ model: models.AssetType, as: 'assetType' },
{ model: models.AssetFolder, as: 'folder' },
{ model: models.Tag, as: 'tags', through: { attributes: [] } },
{ model: models.AssetVersion, as: 'versions', order: [['version', 'DESC']] }
]
});
if (!record) {
throw new Error('Asset not found');
}
@@ -16,46 +61,279 @@ export class AssetService {
}
async create(data, userContext = {}) {
const record = await repository.create(data);
SocketService.broadcast('asset:created', record);
const transaction = await sequelize.transaction();
try {
// 1. Generate unique code if not provided
if (!data.code) {
data.code = `AST-${Date.now()}-${Math.round(Math.random() * 1000)}`;
}
// Check unique code
const existingCode = await models.Asset.findOne({ where: { code: data.code }, transaction });
if (existingCode) {
throw new Error(`Asset with code "${data.code}" already exists`);
}
// 2. Set file-based fields if file is being uploaded
let metadata = {};
if (data.file_url) {
// Assume file is saved in uploads/ relative to process path
const relativePath = data.file_url.replace(/^\/uploads\//, 'uploads/');
const absolutePath = path.resolve(relativePath);
metadata = extractMetadata(absolutePath);
data.file_name = path.basename(absolutePath);
data.extension = metadata.extension;
data.file_size = metadata.file_size;
data.checksum = metadata.checksum;
data.width = metadata.width;
data.height = metadata.height;
data.page_count = metadata.page_count;
// Check duplicates by checksum
const duplicate = await models.Asset.findOne({
where: { checksum: data.checksum, status: 'active' },
transaction
});
if (duplicate) {
throw new Error(`Duplicate asset detected. File already exists under code: "${duplicate.code}"`);
}
}
// 3. Folder setting
if (data.folder_path) {
const folder = await this.resolveFolder(data.folder_path, transaction);
data.folder_id = folder.id;
}
data.created_by = userContext.name || userContext.username || 'system';
const record = await models.Asset.create(data, { transaction });
// 4. Handle tags relationally
if (data.tags && Array.isArray(data.tags)) {
for (const tagName of data.tags) {
if (!tagName || !tagName.trim()) continue;
const [tagRecord] = await models.Tag.findOrCreate({
where: { name: tagName.trim() },
transaction
});
await models.AssetTag.create({
asset_id: record.id,
tag_id: tagRecord.id
}, { transaction });
}
}
await transaction.commit();
// Fetch complete record
const fullRecord = await this.getById(record.id);
SocketService.broadcast('asset:created', fullRecord);
await AuditService.log({
action: 'CREATE',
resource: 'Asset',
resourceId: record.id,
userId: userContext.id || 'system',
details: data
});
return fullRecord;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async update(id, data, userContext = {}) {
const transaction = await sequelize.transaction();
try {
const record = await models.Asset.findByPk(id, { transaction });
if (!record) {
throw new Error('Asset not found');
}
if (data.code && data.code !== record.code) {
const existingCode = await models.Asset.findOne({ where: { code: data.code, id: { [Op.ne]: id } }, transaction });
if (existingCode) {
throw new Error(`Asset with code "${data.code}" already exists`);
}
}
// Resolve folder if path changes
if (data.folder_path) {
const folder = await this.resolveFolder(data.folder_path, transaction);
data.folder_id = folder.id;
}
data.updated_by = userContext.name || userContext.username || 'system';
await record.update(data, { transaction });
// Manage tags if provided
if (data.tags && Array.isArray(data.tags)) {
// Delete old associations
await models.AssetTag.destroy({ where: { asset_id: id }, transaction });
// Create new ones
for (const tagName of data.tags) {
if (!tagName || !tagName.trim()) continue;
const [tagRecord] = await models.Tag.findOrCreate({
where: { name: tagName.trim() },
transaction
});
await models.AssetTag.create({
asset_id: id,
tag_id: tagRecord.id
}, { transaction });
}
}
await transaction.commit();
const fullRecord = await this.getById(id);
SocketService.broadcast('asset:updated', fullRecord);
await AuditService.log({
action: 'UPDATE',
resource: 'Asset',
resourceId: id,
userId: userContext.id || 'system',
details: data
});
return fullRecord;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async replaceFile(id, fileData, userContext = {}) {
const transaction = await sequelize.transaction();
try {
const asset = await models.Asset.findByPk(id, { transaction });
if (!asset) {
throw new Error('Asset not found');
}
// Check duplicates of new file checksum
const relativePath = fileData.file_url.replace(/^\/uploads\//, 'uploads/');
const absolutePath = path.resolve(relativePath);
const metadata = extractMetadata(absolutePath);
const newChecksum = metadata.checksum;
const duplicate = await models.Asset.findOne({
where: { checksum: newChecksum, status: 'active', id: { [Op.ne]: id } },
transaction
});
if (duplicate) {
throw new Error(`Duplicate file detected. Asset already exists with code: "${duplicate.code}"`);
}
// 1. Log current version in asset_versions
await models.AssetVersion.create({
asset_id: asset.id,
version: asset.version,
file_url: asset.file_url,
uploaded_by: asset.updated_by || asset.created_by || 'system',
uploaded_at: asset.updated_at || asset.created_at
}, { transaction });
// 2. Update asset with new file details
const updateData = {
file_url: fileData.file_url,
file_name: path.basename(absolutePath),
extension: metadata.extension,
file_size: metadata.file_size,
checksum: newChecksum,
width: metadata.width,
height: metadata.height,
page_count: metadata.page_count,
version: asset.version + 1,
updated_by: userContext.name || userContext.username || 'system'
};
await asset.update(updateData, { transaction });
await transaction.commit();
const fullRecord = await this.getById(id);
SocketService.broadcast('asset:replaced', fullRecord);
await AuditService.log({
action: 'REPLACE_FILE',
resource: 'Asset',
resourceId: id,
userId: userContext.id || 'system',
details: { old_version: asset.version, new_version: fullRecord.version }
});
return fullRecord;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async archive(id, userContext = {}) {
const record = await models.Asset.findByPk(id);
if (!record) throw new Error('Asset not found');
await record.update({ status: 'inactive', updated_by: userContext.name || 'system' });
SocketService.broadcast('asset:updated', record);
await AuditService.log({
action: 'CREATE',
action: 'ARCHIVE_ASSET',
resource: 'Asset',
resourceId: record.id,
userId: userContext.id || 'system',
details: data
resourceId: id,
userId: userContext.id || 'system'
});
return record;
}
async update(id, data, userContext = {}) {
const record = await repository.update(id, data);
if (!record) {
throw new Error('Asset not found');
}
async restore(id, userContext = {}) {
const record = await models.Asset.findByPk(id);
if (!record) throw new Error('Asset not found');
await record.update({ status: 'active', updated_by: userContext.name || 'system' });
SocketService.broadcast('asset:updated', record);
await AuditService.log({
action: 'UPDATE',
action: 'RESTORE_ASSET',
resource: 'Asset',
resourceId: id,
userId: userContext.id || 'system',
details: data
userId: userContext.id || 'system'
});
return record;
}
async delete(id, userContext = {}) {
const deleted = await repository.delete(id);
if (!deleted) {
const relations = await this.getRelations(id);
const hasRelations =
relations.products.length > 0 ||
relations.variants.length > 0 ||
relations.families.length > 0 ||
relations.categories.length > 0 ||
relations.channels.length > 0;
if (hasRelations) {
const depError = new Error('Cannot delete asset because it has active relations');
depError.relations = relations;
depError.statusCode = 400;
throw depError;
}
const record = await models.Asset.findByPk(id);
if (!record) {
throw new Error('Asset not found');
}
await record.update({ deleted_by: userContext.name || 'system' });
await record.destroy();
SocketService.broadcast('asset:deleted', { id });
await AuditService.log({
@@ -67,6 +345,163 @@ export class AssetService {
return true;
}
async getRelations(id) {
const products = await models.ProductAsset.findAll({
where: { asset_id: id },
include: [{ model: models.Product, as: 'product', attributes: ['id', 'name', 'sku'] }]
});
const variants = await models.VariantAsset.findAll({
where: { asset_id: id },
include: [{ model: models.Variant, as: 'variant', attributes: ['id', 'name', 'sku'] }]
});
const families = await models.FamilyAsset.findAll({
where: { asset_id: id },
include: [{ model: models.Catalog, as: 'family', attributes: ['id', 'name', 'code'] }]
});
const categories = await models.CategoryAsset.findAll({
where: { asset_id: id },
include: [{ model: models.Categorie, as: 'category', attributes: ['id', 'name', 'code'] }]
});
const channels = await models.ChannelAsset.findAll({
where: { asset_id: id },
include: [{ model: models.Channel, as: 'channel', attributes: ['name', 'code'] }]
});
return {
products: products.map(p => ({ id: p.product.id, name: p.product.name, code: p.product.sku, role: p.role })),
variants: variants.map(v => ({ id: v.variant.id, name: v.variant.name, code: v.variant.sku, role: v.role })),
families: families.map(f => ({ id: f.family.id, name: f.family.name, code: f.family.code, role: f.role })),
categories: categories.map(c => ({ id: c.category.id, name: c.category.name, code: c.category.code, role: c.role })),
channels: channels.map(ch => ({ id: ch.channel.code, name: ch.channel.name, code: ch.channel.code, role: ch.role }))
};
}
async getAnalytics() {
const total = await models.Asset.count();
// Counts by type
const images = await models.Asset.count({ where: { mime_type: { [Op.iLike]: 'image/%' } } });
const videos = await models.Asset.count({ where: { mime_type: { [Op.iLike]: 'video/%' } } });
const pdfs = await models.Asset.count({ where: { [Op.or]: [{ mime_type: 'application/pdf' }, { extension: 'pdf' }] } });
const documents = await models.Asset.count({
where: {
[Op.and]: [
{ mime_type: { [Op.notILike]: 'image/%' } },
{ mime_type: { [Op.notILike]: 'video/%' } },
{ mime_type: { [Op.notILike]: 'audio/%' } },
{ mime_type: { [Op.ne]: 'application/pdf' } },
{ extension: { [Op.ne]: 'pdf' } }
]
}
});
const storageUsed = await models.Asset.sum('file_size') || 0;
const archived = await models.Asset.count({ where: { status: 'inactive' } });
// Derive unused assets (assets not referenced in any mapping table)
const activeAssets = await models.Asset.findAll({ attributes: ['id'] });
const usedAssetIds = new Set();
const [prodAssets, varAssets, famAssets, catAssets, chAssets] = await Promise.all([
models.ProductAsset.findAll({ attributes: ['asset_id'] }),
models.VariantAsset.findAll({ attributes: ['asset_id'] }),
models.FamilyAsset.findAll({ attributes: ['asset_id'] }),
models.CategoryAsset.findAll({ attributes: ['asset_id'] }),
models.ChannelAsset.findAll({ attributes: ['asset_id'] })
]);
prodAssets.forEach(a => usedAssetIds.add(a.asset_id));
varAssets.forEach(a => usedAssetIds.add(a.asset_id));
famAssets.forEach(a => usedAssetIds.add(a.asset_id));
catAssets.forEach(a => usedAssetIds.add(a.asset_id));
chAssets.forEach(a => usedAssetIds.add(a.asset_id));
const unused = activeAssets.filter(a => !usedAssetIds.has(a.id)).length;
// Largest Assets
const largest = await models.Asset.findAll({
order: [['file_size', 'DESC']],
limit: 5,
attributes: ['id', 'name', 'file_size', 'file_url', 'mime_type']
});
// Recently Uploaded / Modified
const recentlyUploaded = await models.Asset.findAll({
order: [['created_at', 'DESC']],
limit: 5,
attributes: ['id', 'name', 'created_at', 'file_url']
});
const recentlyModified = await models.Asset.findAll({
order: [['updated_at', 'DESC']],
limit: 5,
attributes: ['id', 'name', 'updated_at', 'file_url']
});
return {
stats: {
total,
images,
videos,
pdfs,
documents,
storageUsed,
unused,
archived
},
largest,
recentlyUploaded,
recentlyModified
};
}
// Resolve directory folder relationally
async resolveFolder(folderPath, transaction) {
const parts = folderPath.split('/').filter(Boolean);
let parentId = null;
let folder = null;
// Root level fallback
if (parts.length === 0) {
[folder] = await models.AssetFolder.findOrCreate({
where: { name: 'Root', parent_id: null },
defaults: { path: '/' },
transaction
});
return folder;
}
let currentPath = '';
for (const name of parts) {
currentPath += '/' + name;
[folder] = await models.AssetFolder.findOrCreate({
where: { name, parent_id: parentId },
defaults: { path: currentPath },
transaction
});
parentId = folder.id;
}
return folder;
}
async getFolders() {
return await models.AssetFolder.findAll({
order: [['name', 'ASC']]
});
}
async getTags() {
return await models.Tag.findAll({
order: [['name', 'ASC']]
});
}
}
export default new AssetService();
@@ -0,0 +1,50 @@
import { Model, DataTypes } from 'sequelize';
export class AssetFolder extends Model {
static associate(models) {
AssetFolder.belongsTo(models.AssetFolder, {
foreignKey: 'parent_id',
as: 'parent'
});
AssetFolder.hasMany(models.AssetFolder, {
foreignKey: 'parent_id',
as: 'children'
});
AssetFolder.hasMany(models.Asset, {
foreignKey: 'folder_id',
as: 'assets'
});
}
}
export default (sequelize) => {
AssetFolder.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
name: {
type: DataTypes.STRING(100),
allowNull: false
},
parent_id: {
type: DataTypes.UUID,
allowNull: true
},
path: {
type: DataTypes.STRING(500),
allowNull: true
}
}, {
sequelize,
modelName: 'AssetFolder',
tableName: 'asset_folders',
timestamps: true,
underscored: true,
paranoid: true
});
return AssetFolder;
};
@@ -0,0 +1,35 @@
import { Model, DataTypes } from 'sequelize';
export class AssetTag extends Model {
static associate(models) {
AssetTag.belongsTo(models.Asset, { foreignKey: 'asset_id', as: 'asset' });
AssetTag.belongsTo(models.Tag, { foreignKey: 'tag_id', as: 'tag' });
}
}
export default (sequelize) => {
AssetTag.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
asset_id: {
type: DataTypes.UUID,
allowNull: false
},
tag_id: {
type: DataTypes.UUID,
allowNull: false
}
}, {
sequelize,
modelName: 'AssetTag',
tableName: 'asset_tags',
timestamps: true,
underscored: true
});
return AssetTag;
};
@@ -0,0 +1,46 @@
import { Model, DataTypes } from 'sequelize';
export class AssetVersion extends Model {
static associate(models) {
AssetVersion.belongsTo(models.Asset, { foreignKey: 'asset_id', as: 'asset' });
}
}
export default (sequelize) => {
AssetVersion.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
asset_id: {
type: DataTypes.UUID,
allowNull: false
},
version: {
type: DataTypes.INTEGER,
allowNull: false
},
file_url: {
type: DataTypes.STRING(500),
allowNull: false
},
uploaded_by: {
type: DataTypes.STRING(100),
allowNull: true
},
uploaded_at: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW
}
}, {
sequelize,
modelName: 'AssetVersion',
tableName: 'asset_versions',
timestamps: true,
underscored: true
});
return AssetVersion;
};
+36
View File
@@ -0,0 +1,36 @@
import { Model, DataTypes } from 'sequelize';
export class Tag extends Model {
static associate(models) {
Tag.belongsToMany(models.Asset, {
through: models.AssetTag,
foreignKey: 'tag_id',
otherKey: 'asset_id',
as: 'assets'
});
}
}
export default (sequelize) => {
Tag.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
name: {
type: DataTypes.STRING(100),
allowNull: false,
unique: true
}
}, {
sequelize,
modelName: 'Tag',
tableName: 'tags',
timestamps: true,
underscored: true
});
return Tag;
};
@@ -0,0 +1,155 @@
import { models } from '../../../shared/database/models.js';
import { SocketService } from '../../../shared/services/socket.service.js';
export class CompletenessService {
static async calculate(productId, transaction = null) {
const product = await models.Product.findByPk(productId, {
include: [
{
model: models.Catalog,
as: 'family',
include: [
{
model: models.Attribute,
as: 'attributes'
},
{
model: models.AttributeSet,
as: 'attributeSet',
include: [
{
model: models.AttributeGroup,
as: 'groups',
include: [
{
model: models.Attribute,
as: 'attributes'
}
]
}
]
},
{
model: models.AssetType,
as: 'assetRequirements'
},
{
model: models.FamilyChannel,
as: 'channels'
}
]
},
{
model: models.ProductAttributeValue,
as: 'attributeValues',
include: [{ model: models.Attribute, as: 'attribute' }]
},
{
model: models.ProductAsset,
as: 'productAssets',
include: [
{
model: models.Asset,
as: 'asset',
include: [{ model: models.AssetType, as: 'assetType' }]
}
]
}
],
transaction
});
if (!product || !product.family) return;
const family = product.family;
// Resolve all attributes (both direct and from attribute set)
const attributesMap = new Map();
if (family.attributes) {
for (const attr of family.attributes) {
attributesMap.set(attr.id, attr);
}
}
if (family.attributeSet && family.attributeSet.groups) {
for (const g of family.attributeSet.groups) {
if (g.attributes) {
for (const attr of g.attributes) {
attributesMap.set(attr.id, attr);
}
}
}
}
const allAttributes = Array.from(attributesMap.values());
const requiredAttributes = allAttributes.filter(attr => attr.is_required);
const requiredAssetTypes = family.assetRequirements || [];
const requiredChannels = family.channels || [];
const totalCount = requiredAttributes.length + requiredAssetTypes.length + requiredChannels.length;
let fulfilledCount = 0;
const missingAttributes = [];
const missingAssets = [];
const missingChannels = [];
// 1. Validate required attributes
const filledAttrIds = (product.attributeValues || []).map(av => av.attribute_id);
for (const attr of requiredAttributes) {
if (filledAttrIds.includes(attr.id)) {
fulfilledCount++;
} else {
missingAttributes.push({ code: attr.code, name: attr.name });
}
}
// 2. Validate required assets
const assignedAssetTypeIds = (product.productAssets || []).map(pa => pa.asset?.assetType?.id).filter(Boolean);
for (const assetType of requiredAssetTypes) {
if (assignedAssetTypeIds.includes(assetType.id)) {
fulfilledCount++;
} else {
missingAssets.push({ code: assetType.code, name: assetType.name });
}
}
// 3. Validate required channels
const enabledChannels = product.metadata?.channels || [];
for (const ch of requiredChannels) {
if (enabledChannels.includes(ch.channel_code)) {
fulfilledCount++;
} else {
missingChannels.push({ code: ch.channel_code });
}
}
const percentage = totalCount > 0 ? Math.round((fulfilledCount / totalCount) * 100) : 100;
// Upsert generic default completeness
await models.ProductCompleteness.upsert({
product_id: productId,
channel: 'default',
locale: 'en',
percentage,
missing_attributes: missingAttributes,
missing_assets: missingAssets,
missing_channels: missingChannels
}, { transaction });
// Store per channel completeness
for (const ch of requiredChannels) {
const isChannelEnabled = enabledChannels.includes(ch.channel_code);
const chPercentage = isChannelEnabled ? percentage : 0;
await models.ProductCompleteness.upsert({
product_id: productId,
channel: ch.channel_code,
locale: 'en',
percentage: chPercentage,
missing_attributes: missingAttributes,
missing_assets: missingAssets,
missing_channels: isChannelEnabled ? [] : [{ code: ch.channel_code }]
}, { transaction });
}
// Broadcast completeness update
SocketService.broadcast('product.completeness.recalculated', { productId, percentage });
}
}
export default CompletenessService;
@@ -45,6 +45,60 @@ export class ProductController {
next(error);
}
}
async archive(req, res, next) {
try {
await service.archive(req.params.id, req.user);
return res.status(200).json({ success: true, message: 'Product archived successfully' });
} catch (error) {
next(error);
}
}
async restore(req, res, next) {
try {
const record = await service.restore(req.params.id, req.user);
return res.status(200).json({ success: true, data: record, message: 'Product restored successfully' });
} catch (error) {
next(error);
}
}
getAssets = async (req, res, next) => {
try {
const data = await service.getAssets(req.params.id);
return res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
}
assignAsset = async (req, res, next) => {
try {
const data = await service.assignAsset(req.params.id, req.body.asset_id, req.body, req.user);
return res.status(201).json({ success: true, data });
} catch (error) {
next(error);
}
}
updateAssetMapping = async (req, res, next) => {
try {
const data = await service.updateAssetMapping(req.params.id, req.params.assetId, req.body, req.user);
return res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
}
unassignAsset = async (req, res, next) => {
try {
await service.unassignAsset(req.params.id, req.params.assetId, req.user);
return res.status(200).json({ success: true, message: 'Asset unassigned from product successfully' });
} catch (error) {
next(error);
}
}
}
export default new ProductController();
@@ -2,7 +2,34 @@ import { Model, DataTypes } from 'sequelize';
export class Product extends Model {
static associate(models) {
// Define associations here
Product.belongsTo(models.Catalog, {
foreignKey: 'family_id',
as: 'family'
});
Product.belongsTo(models.Categorie, {
foreignKey: 'category_id',
as: 'category'
});
Product.belongsTo(models.Brand, {
foreignKey: 'brand_id',
as: 'brand'
});
Product.belongsTo(models.Unit, {
foreignKey: 'unit_id',
as: 'unit'
});
Product.hasMany(models.Variant, {
foreignKey: 'product_id',
as: 'variants'
});
Product.hasMany(models.ProductAttributeValue, {
foreignKey: 'product_id',
as: 'attributeValues'
});
Product.hasMany(models.ProductCompleteness, {
foreignKey: 'product_id',
as: 'completenessEntries'
});
}
}
@@ -14,13 +41,35 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true
},
name: {
type: DataTypes.STRING,
allowNull: true
type: DataTypes.STRING(255),
allowNull: false
},
status: {
type: DataTypes.STRING,
defaultValue: 'active'
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'draft'
},
family_id: {
type: DataTypes.UUID,
allowNull: true
},
category_id: {
type: DataTypes.UUID,
allowNull: true
},
brand_id: {
type: DataTypes.UUID,
allowNull: true
},
unit_id: {
type: DataTypes.UUID,
allowNull: true
},
metadata: {
type: DataTypes.JSONB,
@@ -31,7 +80,8 @@ export default (sequelize) => {
modelName: 'Product',
tableName: 'products',
timestamps: true,
underscored: true
underscored: true,
paranoid: true
});
return Product;
@@ -2,11 +2,129 @@ import { models } from '../../../shared/database/models.js';
export class ProductRepository {
async findAll(options = {}) {
return await models.Product.findAll(options);
return await models.Product.findAll({
include: [
{
model: models.Catalog,
as: 'family',
include: [
{
model: models.Attribute,
as: 'variantAxes'
},
{
model: models.Categorie,
as: 'category',
attributes: ['id', 'name']
}
]
},
{
model: models.Categorie,
as: 'category',
attributes: ['id', 'name', 'code']
},
{
model: models.Brand,
as: 'brand',
attributes: ['id', 'name', 'code']
},
{
model: models.Unit,
as: 'unit',
attributes: ['id', 'name', 'symbol']
},
{
model: models.ProductCompleteness,
as: 'completenessEntries'
}
],
...options
});
}
async findById(id, options = {}) {
return await models.Product.findByPk(id, options);
return await models.Product.findByPk(id, {
include: [
{
model: models.Catalog,
as: 'family',
include: [
{
model: models.Attribute,
as: 'variantAxes'
},
{
model: models.Categorie,
as: 'category',
attributes: ['id', 'name']
}
]
},
{
model: models.Categorie,
as: 'category',
attributes: ['id', 'name', 'code']
},
{
model: models.Brand,
as: 'brand',
attributes: ['id', 'name', 'code']
},
{
model: models.Unit,
as: 'unit',
attributes: ['id', 'name', 'symbol']
},
{
model: models.Variant,
as: 'variants',
include: [
{
model: models.VariantValue,
as: 'values',
include: [
{
model: models.Attribute,
as: 'axis'
}
]
}
]
},
{
model: models.ProductAttributeValue,
as: 'attributeValues',
include: [
{
model: models.Attribute,
as: 'attribute'
}
]
},
{
model: models.ProductCompleteness,
as: 'completenessEntries'
},
{
model: models.ProductAsset,
as: 'productAssets',
include: [
{
model: models.Asset,
as: 'asset',
include: [
{
model: models.AssetType,
as: 'assetType'
}
]
}
]
}
],
...options
});
}
async create(data, options = {}) {
@@ -123,4 +123,52 @@ router.delete(
controller.delete
);
router.get(
'/:id/assets',
authenticate,
authorize(['read:products']),
controller.getAssets
);
router.post(
'/:id/assets',
authenticate,
authorize(['write:products']),
controller.assignAsset
);
router.put(
'/:id/assets/:assetId',
authenticate,
authorize(['write:products']),
controller.updateAssetMapping
);
router.delete(
'/:id/assets/:assetId',
authenticate,
authorize(['write:products']),
controller.unassignAsset
);
router.post(
'/:id/archive',
authenticate,
authorize(['write:products']),
getByIdValidation,
validate,
audit('ARCHIVE_PRODUCT'),
controller.archive
);
router.post(
'/:id/restore',
authenticate,
authorize(['write:products']),
getByIdValidation,
validate,
audit('RESTORE_PRODUCT'),
controller.restore
);
export default router;
+531 -37
View File
@@ -1,11 +1,229 @@
import { Op } from 'sequelize';
import repository from './product.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 CompletenessService from './completeness.service.js';
async function validateAttributeValue(attr, val, productId = null, transaction = null) {
if (attr.is_required && (val === undefined || val === null || val === '')) {
throw new Error(`Attribute "${attr.name}" (${attr.code}) is required.`);
}
if (val === undefined || val === null || val === '') {
return;
}
const strVal = String(val);
if (attr.is_unique) {
const whereClause = {
attribute_id: attr.id,
value: strVal
};
if (productId) {
whereClause.product_id = { [Op.ne]: productId };
}
const existingVal = await models.ProductAttributeValue.findOne({
where: whereClause,
transaction
});
if (existingVal) {
throw new Error(`Attribute "${attr.name}" value must be unique. "${strVal}" is already in use.`);
}
}
switch (attr.type) {
case 'text':
case 'textarea':
if (attr.min_length !== null && strVal.length < attr.min_length) {
throw new Error(`Attribute "${attr.name}" must be at least ${attr.min_length} characters.`);
}
if (attr.max_length !== null && strVal.length > attr.max_length) {
throw new Error(`Attribute "${attr.name}" cannot exceed ${attr.max_length} characters.`);
}
if (attr.regex_pattern) {
const regex = new RegExp(attr.regex_pattern);
if (!regex.test(strVal)) {
throw new Error(`Attribute "${attr.name}" value "${strVal}" does not match validation pattern.`);
}
}
break;
case 'number':
const num = Number(val);
if (isNaN(num)) {
throw new Error(`Attribute "${attr.name}" must be a number.`);
}
if (attr.min_length !== null && num < attr.min_length) {
throw new Error(`Attribute "${attr.name}" must be at least ${attr.min_length}.`);
}
if (attr.max_length !== null && num > attr.max_length) {
throw new Error(`Attribute "${attr.name}" cannot exceed ${attr.max_length}.`);
}
break;
case 'boolean':
if (strVal !== 'true' && strVal !== 'false' && strVal !== '1' && strVal !== '0') {
throw new Error(`Attribute "${attr.name}" must be a boolean.`);
}
break;
case 'date':
if (isNaN(Date.parse(strVal))) {
throw new Error(`Attribute "${attr.name}" must be a valid date.`);
}
break;
}
}
export class ProductService {
async calculateCompleteness(productId, transaction = null) {
return await CompletenessService.calculate(productId, transaction);
}
async getAssets(productId) {
const product = await repository.findById(productId);
if (!product) throw new Error('Product not found');
return await models.ProductAsset.findAll({
where: { product_id: productId },
include: [{ model: models.Asset, as: 'asset', include: [{ model: models.AssetType, as: 'assetType' }] }],
order: [['display_order', 'ASC']]
});
}
async assignAsset(productId, assetId, data, userContext = {}) {
const transaction = await sequelize.transaction();
try {
const product = await repository.findById(productId, { transaction });
if (!product) throw new Error('Product not found');
const asset = await models.Asset.findByPk(assetId, { transaction });
if (!asset) throw new Error('Asset not found');
if (data.is_primary) {
await models.ProductAsset.update(
{ is_primary: false },
{ where: { product_id: productId }, transaction }
);
}
const [mapping, created] = await models.ProductAsset.findOrCreate({
where: { product_id: productId, asset_id: assetId },
defaults: {
role: data.role || 'gallery_image',
display_order: data.display_order || 0,
is_primary: !!data.is_primary
},
transaction
});
if (!created) {
await mapping.update({
role: data.role || mapping.role,
display_order: data.display_order !== undefined ? data.display_order : mapping.display_order,
is_primary: data.is_primary !== undefined ? !!data.is_primary : mapping.is_primary
}, { transaction });
}
await this.calculateCompleteness(productId, transaction);
await transaction.commit();
SocketService.broadcast('product.updated', { id: productId });
return mapping;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async updateAssetMapping(productId, assetId, data, userContext = {}) {
const transaction = await sequelize.transaction();
try {
const mapping = await models.ProductAsset.findOne({
where: { product_id: productId, asset_id: assetId },
transaction
});
if (!mapping) throw new Error('Asset mapping not found');
if (data.is_primary) {
await models.ProductAsset.update(
{ is_primary: false },
{ where: { product_id: productId }, transaction }
);
}
await mapping.update({
role: data.role || mapping.role,
display_order: data.display_order !== undefined ? data.display_order : mapping.display_order,
is_primary: data.is_primary !== undefined ? !!data.is_primary : mapping.is_primary
}, { transaction });
await this.calculateCompleteness(productId, transaction);
await transaction.commit();
SocketService.broadcast('product.updated', { id: productId });
return mapping;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async unassignAsset(productId, assetId, userContext = {}) {
const transaction = await sequelize.transaction();
try {
const mapping = await models.ProductAsset.findOne({
where: { product_id: productId, asset_id: assetId },
transaction
});
if (!mapping) throw new Error('Asset mapping not found');
await mapping.destroy({ transaction });
await this.calculateCompleteness(productId, transaction);
await transaction.commit();
SocketService.broadcast('product.updated', { id: productId });
return true;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async getAll(query = {}) {
// Add business logic filtering, pagination, etc.
return await repository.findAll();
const where = {};
if (query.status) {
where.status = query.status;
}
if (query.familyId) {
where.family_id = query.familyId;
}
if (query.categoryId) {
where.category_id = query.categoryId;
}
if (query.brandId) {
where.brand_id = query.brandId;
}
if (query.search) {
where[Op.or] = [
{ sku: { [Op.iLike]: `%${query.search}%` } },
{ name: { [Op.iLike]: `%${query.search}%` } },
{ code: { [Op.iLike]: `%${query.search}%` } }
];
}
const records = await repository.findAll({ where });
// Map completeness percentage to rows
const data = records.map(p => {
const json = p.toJSON ? p.toJSON() : { ...p };
const defaultCompleteness = (json.completenessEntries || []).find(c => c.channel === 'default');
json.completeness = defaultCompleteness ? defaultCompleteness.percentage : 0;
return json;
});
return data;
}
async getById(id) {
@@ -13,53 +231,288 @@ export class ProductService {
if (!record) {
throw new Error('Product not found');
}
return record;
const json = record.toJSON();
const defaultCompleteness = (json.completenessEntries || []).find(c => c.channel === 'default');
json.completeness = defaultCompleteness ? defaultCompleteness.percentage : 0;
return json;
}
async create(data, userContext = {}) {
const record = await repository.create(data);
// Broadcast event
SocketService.broadcast('product:created', record);
// Log audit
await AuditService.log({
action: 'CREATE',
resource: 'Product',
resourceId: record.id,
userId: userContext.id || 'system',
details: data
});
const transaction = await sequelize.transaction();
try {
// 1. SKU uniqueness validation
const existing = await models.Product.findOne({
where: { code: data.code || data.sku },
paranoid: false,
transaction
});
if (existing) {
throw new Error(`Product SKU / Code "${data.code || data.sku}" already exists`);
}
return record;
// 2. Validate Family exists
const family = await models.Catalog.findByPk(data.family_id || data.familyId, {
transaction,
include: [
{
model: models.FamilyChannel,
as: 'channels'
},
{
model: models.AttributeSet,
as: 'attributeSet',
include: [
{
model: models.AttributeGroup,
as: 'groups',
include: [
{
model: models.Attribute,
as: 'attributes'
}
]
}
]
}
]
});
if (!family) {
throw new Error('Product Family (Catalog) is required and must exist');
}
// 3. Category Inheritance validation
let categoryId = data.category_id || data.categoryId || data.category;
if (!categoryId) {
categoryId = family.category_id;
} else if (family.category_id && categoryId !== family.category_id) {
throw new Error(`Category mismatch: Product category must match Family category (expected: "${family.category_id}")`);
}
// 4. Autogenerate code
if (!data.code || !data.code.trim()) {
if (data.name) {
data.code = data.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
}
if (!data.code) {
data.code = `prd_${Date.now()}`;
}
}
data.code = data.code.toLowerCase().trim();
// Setup default inherited channels and workflows
const metadata = data.metadata || {};
if (!metadata.channels) {
metadata.channels = (family.channels || []).map(c => c.channel_code);
}
if (!metadata.workflow_code) {
metadata.workflow_code = family.workflow_code || 'standard';
}
// 5. Create Core Product
const product = await models.Product.create({
code: data.code,
name: data.name,
status: data.status || 'draft',
family_id: family.id,
category_id: categoryId,
brand_id: data.brand_id || data.brandId || data.brand,
unit_id: data.unit_id || data.unitId || data.unit,
metadata: metadata
}, { transaction });
// 6. Save dynamic attributes from inherited Attribute Set blueprint
const familyAttributes = [];
if (family.attributeSet && family.attributeSet.groups) {
for (const g of family.attributeSet.groups) {
if (g.attributes) {
for (const a of g.attributes) {
familyAttributes.push(a);
}
}
}
}
const bodyAttributes = data.attributes || data.attributeValues || data;
for (const attr of familyAttributes) {
let val = bodyAttributes[attr.code];
if (val === undefined && bodyAttributes.metadata) {
val = bodyAttributes.metadata[attr.code];
}
await validateAttributeValue(attr, val, product.id, transaction);
if (val !== undefined && val !== null) {
await models.ProductAttributeValue.create({
product_id: product.id,
attribute_id: attr.id,
value: String(val),
locale: 'en',
channel: 'default'
}, { transaction });
}
}
// 7. Calculate completeness
await this.calculateCompleteness(product.id, transaction);
await transaction.commit();
const fullRecord = await this.getById(product.id);
SocketService.broadcast('product.created', fullRecord);
await AuditService.log({
action: 'CREATE',
resource: 'Product',
resourceId: product.id,
userId: userContext.id || 'system',
details: data
});
return fullRecord;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async update(id, data, userContext = {}) {
const record = await repository.update(id, data);
const transaction = await sequelize.transaction();
try {
const record = await models.Product.findByPk(id, { transaction });
if (!record) {
throw new Error('Product not found');
}
// Validate Family
const familyId = data.family_id || data.familyId || record.family_id;
const family = await models.Catalog.findByPk(familyId, {
transaction,
include: [
{
model: models.FamilyChannel,
as: 'channels'
},
{
model: models.AttributeSet,
as: 'attributeSet',
include: [
{
model: models.AttributeGroup,
as: 'groups',
include: [
{
model: models.Attribute,
as: 'attributes'
}
]
}
]
}
]
});
if (!family) {
throw new Error('Product Family (Catalog) must exist');
}
// Validate Category inheritance
let categoryId = data.category_id || data.categoryId;
if (!categoryId) {
categoryId = family.category_id;
} else if (family.category_id && categoryId !== family.category_id) {
throw new Error(`Category mismatch: Product category must match Family category (expected: "${family.category_id}")`);
}
// Ensure inherited channels are locked and cannot be removed
const metadata = data.metadata || record.metadata || {};
const familyChannels = (family.channels || []).map(c => c.channel_code);
let updatedChannels = metadata.channels || [];
// Combine existing with family channels so family channels cannot be deleted
updatedChannels = [...new Set([...updatedChannels, ...familyChannels])];
metadata.channels = updatedChannels;
if (!metadata.workflow_code) {
metadata.workflow_code = family.workflow_code || record.metadata?.workflow_code || 'standard';
}
// Update Core Product
await record.update({
name: data.name || record.name,
status: data.status || record.status,
brand_id: data.brand_id || data.brandId || data.brand || record.brand_id,
unit_id: data.unit_id || data.unitId || data.unit || record.unit_id,
category_id: categoryId,
metadata: metadata
}, { transaction });
// Save dynamic attributes from inherited Attribute Set blueprint
const familyAttributes = [];
if (family.attributeSet && family.attributeSet.groups) {
for (const g of family.attributeSet.groups) {
if (g.attributes) {
for (const a of g.attributes) {
familyAttributes.push(a);
}
}
}
}
const bodyAttributes = data.attributes || data.attributeValues || data;
for (const attr of familyAttributes) {
let val = bodyAttributes[attr.code];
if (val === undefined && bodyAttributes.metadata) {
val = bodyAttributes.metadata[attr.code];
}
await validateAttributeValue(attr, val, id, transaction);
if (val !== undefined && val !== null) {
const [attrVal, created] = await models.ProductAttributeValue.findOrCreate({
where: { product_id: id, attribute_id: attr.id, locale: 'en', channel: 'default' },
defaults: { value: String(val) },
transaction
});
if (!created) {
await attrVal.update({ value: String(val) }, { transaction });
}
}
}
// Recalculate completeness
await this.calculateCompleteness(id, transaction);
await transaction.commit();
const fullRecord = await this.getById(id);
SocketService.broadcast('product.updated', fullRecord);
await AuditService.log({
action: 'UPDATE',
resource: 'Product',
resourceId: id,
userId: userContext.id || 'system',
details: data
});
return fullRecord;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async delete(id, userContext = {}) {
const record = await models.Product.findByPk(id);
if (!record) {
throw new Error('Product not found');
}
SocketService.broadcast('product:updated', record);
await record.destroy({ force: true });
await AuditService.log({
action: 'UPDATE',
resource: 'Product',
resourceId: id,
userId: userContext.id || 'system',
details: data
});
return record;
}
async delete(id, userContext = {}) {
const deleted = await repository.delete(id);
if (!deleted) {
throw new Error('Product not found');
}
SocketService.broadcast('product:deleted', { id });
SocketService.broadcast('product.deleted', { id });
await AuditService.log({
action: 'DELETE',
@@ -70,6 +523,47 @@ export class ProductService {
return true;
}
async archive(id, userContext = {}) {
const record = await models.Product.findByPk(id);
if (!record) {
throw new Error('Product not found');
}
await record.destroy();
SocketService.broadcast('product.archived', { id });
await AuditService.log({
action: 'ARCHIVE',
resource: 'Product',
resourceId: id,
userId: userContext.id || 'system'
});
return true;
}
async restore(id, userContext = {}) {
const record = await models.Product.findByPk(id, { paranoid: false });
if (!record) {
throw new Error('Product not found');
}
await record.restore();
const restored = await this.getById(id);
SocketService.broadcast('product.restored', restored);
await AuditService.log({
action: 'RESTORE',
resource: 'Product',
resourceId: id,
userId: userContext.id || 'system'
});
return restored;
}
}
export default new ProductService();
@@ -2,10 +2,32 @@ import { body, param } from 'express-validator';
export const createValidation = [
body('name')
.optional()
.notEmpty()
.withMessage('Name is required')
.isString()
.trim()
.withMessage('Name must be a string'),
body('family_id')
.notEmpty()
.withMessage('Product Family (family_id) is required')
.isUUID()
.withMessage('Product Family (family_id) must be a valid UUID'),
body('category_id')
.optional({ checkFalsy: true })
.isUUID()
.withMessage('Category must be a valid UUID'),
body('brand_id')
.optional({ checkFalsy: true })
.isUUID()
.withMessage('Brand must be a valid UUID'),
body('unit_id')
.optional({ checkFalsy: true })
.isUUID()
.withMessage('Unit must be a valid UUID'),
body('code')
.optional({ checkFalsy: true })
.isString()
.trim()
.withMessage('Name must be a string')
];
export const updateValidation = [
@@ -16,7 +38,23 @@ export const updateValidation = [
.optional()
.isString()
.trim()
.withMessage('Name must be a string')
.withMessage('Name must be a string'),
body('family_id')
.optional()
.isUUID()
.withMessage('Product Family (family_id) must be a valid UUID'),
body('category_id')
.optional({ checkFalsy: true })
.isUUID()
.withMessage('Category must be a valid UUID'),
body('brand_id')
.optional({ checkFalsy: true })
.isUUID()
.withMessage('Brand must be a valid UUID'),
body('unit_id')
.optional({ checkFalsy: true })
.isUUID()
.withMessage('Unit must be a valid UUID')
];
export const deleteValidation = [
@@ -0,0 +1,47 @@
import { Model, DataTypes } from 'sequelize';
export class ProductAsset extends Model {
static associate(models) {
ProductAsset.belongsTo(models.Product, { foreignKey: 'product_id', as: 'product' });
ProductAsset.belongsTo(models.Asset, { foreignKey: 'asset_id', as: 'asset' });
}
}
export default (sequelize) => {
ProductAsset.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
product_id: {
type: DataTypes.UUID,
allowNull: false
},
asset_id: {
type: DataTypes.UUID,
allowNull: false
},
role: {
type: DataTypes.STRING(100),
allowNull: false
},
display_order: {
type: DataTypes.INTEGER,
defaultValue: 0
},
is_primary: {
type: DataTypes.BOOLEAN,
defaultValue: false
}
}, {
sequelize,
modelName: 'ProductAsset',
tableName: 'product_assets',
timestamps: true,
underscored: true
});
return ProductAsset;
};
@@ -0,0 +1,53 @@
import { Model, DataTypes } from 'sequelize';
export class ProductAttributeValue extends Model {
static associate(models) {
ProductAttributeValue.belongsTo(models.Product, {
foreignKey: 'product_id',
as: 'product'
});
ProductAttributeValue.belongsTo(models.Attribute, {
foreignKey: 'attribute_id',
as: 'attribute'
});
}
}
export default (sequelize) => {
ProductAttributeValue.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
product_id: {
type: DataTypes.UUID,
allowNull: false
},
attribute_id: {
type: DataTypes.UUID,
allowNull: false
},
value: {
type: DataTypes.TEXT,
allowNull: true
},
locale: {
type: DataTypes.STRING(10),
allowNull: true
},
channel: {
type: DataTypes.STRING(50),
allowNull: true
}
}, {
sequelize,
modelName: 'ProductAttributeValue',
tableName: 'product_attribute_values',
timestamps: true,
underscored: true
});
return ProductAttributeValue;
};
@@ -0,0 +1,58 @@
import { Model, DataTypes } from 'sequelize';
export class ProductCompleteness extends Model {
static associate(models) {
ProductCompleteness.belongsTo(models.Product, {
foreignKey: 'product_id',
as: 'product'
});
}
}
export default (sequelize) => {
ProductCompleteness.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
product_id: {
type: DataTypes.UUID,
allowNull: false
},
channel: {
type: DataTypes.STRING(50),
allowNull: true
},
locale: {
type: DataTypes.STRING(10),
allowNull: true
},
percentage: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
missing_attributes: {
type: DataTypes.JSONB,
allowNull: true
},
missing_assets: {
type: DataTypes.JSONB,
allowNull: true
},
missing_channels: {
type: DataTypes.JSONB,
allowNull: true
}
}, {
sequelize,
modelName: 'ProductCompleteness',
tableName: 'product_completeness',
timestamps: true,
underscored: true
});
return ProductCompleteness;
};
+8
View File
@@ -0,0 +1,8 @@
import { Router } from 'express';
import variantsRouter from './variants/variant.routes.js';
const router = Router();
router.use('/variants', variantsRouter);
export default router;
@@ -0,0 +1,180 @@
import service from './variant.service.js';
export class VariantController {
serializeVariant(record) {
if (!record) return null;
const raw = record.toJSON ? record.toJSON() : record;
const attributes = {};
if (Array.isArray(raw.values)) {
for (const val of raw.values) {
if (val.axis) {
attributes[val.axis.code] = val.value_text;
}
}
}
return {
id: raw.id,
sku: raw.sku,
parentProductId: raw.product_id || (raw.product ? raw.product.id : null),
parentProductName: raw.product ? raw.product.name : '',
name: raw.name,
attributes,
status: raw.status || 'draft',
stock: raw.stock || 0,
price: raw.price ? parseFloat(raw.price) : 0.00,
costPrice: raw.cost_price ? parseFloat(raw.cost_price) : 0.00,
currency: raw.currency || 'USD',
availableStock: raw.available_stock || 0,
reservedStock: raw.reserved_stock || 0,
safetyStock: raw.safety_stock || 0,
lastUpdated: raw.updated_at || raw.updatedAt,
createdBy: raw.created_by || 'system'
};
}
getAll = async (req, res, next) => {
try {
const records = await service.getAll(req.query);
const data = records.map(r => this.serializeVariant(r));
return res.status(200).json({
success: true,
message: 'Variants retrieved successfully',
data,
pagination: null,
timestamp: new Date().toISOString()
});
} catch (error) {
next(error);
}
}
getById = async (req, res, next) => {
try {
const record = await service.getById(req.params.id);
return res.status(200).json({
success: true,
message: 'Variant retrieved successfully',
data: this.serializeVariant(record),
pagination: null,
timestamp: new Date().toISOString()
});
} catch (error) {
next(error);
}
}
create = async (req, res, next) => {
try {
const record = await service.create(req.body, req.user);
return res.status(201).json({
success: true,
message: 'Variant created successfully',
data: this.serializeVariant(record),
pagination: null,
timestamp: new Date().toISOString()
});
} catch (error) {
next(error);
}
}
update = async (req, res, next) => {
try {
const record = await service.update(req.params.id, req.body, req.user);
return res.status(200).json({
success: true,
message: 'Variant updated successfully',
data: this.serializeVariant(record),
pagination: null,
timestamp: new Date().toISOString()
});
} catch (error) {
next(error);
}
}
delete = async (req, res, next) => {
try {
await service.delete(req.params.id, req.user);
return res.status(200).json({
success: true,
message: 'Variant deleted successfully',
data: null,
pagination: null,
timestamp: new Date().toISOString()
});
} catch (error) {
next(error);
}
}
archive = async (req, res, next) => {
try {
await service.archive(req.params.id, req.user);
return res.status(200).json({
success: true,
message: 'Variant archived successfully',
data: null,
pagination: null,
timestamp: new Date().toISOString()
});
} catch (error) {
next(error);
}
}
restore = async (req, res, next) => {
try {
const record = await service.restore(req.params.id, req.user);
return res.status(200).json({
success: true,
message: 'Variant restored successfully',
data: this.serializeVariant(record),
pagination: null,
timestamp: new Date().toISOString()
});
} catch (error) {
next(error);
}
}
getAssets = async (req, res, next) => {
try {
const data = await service.getAssets(req.params.id);
return res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
}
assignAsset = async (req, res, next) => {
try {
const data = await service.assignAsset(req.params.id, req.body.asset_id, req.body, req.user);
return res.status(201).json({ success: true, data });
} catch (error) {
next(error);
}
}
updateAssetMapping = async (req, res, next) => {
try {
const data = await service.updateAssetMapping(req.params.id, req.params.assetId, req.body, req.user);
return res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
}
unassignAsset = async (req, res, next) => {
try {
await service.unassignAsset(req.params.id, req.params.assetId, req.user);
return res.status(200).json({ success: true, message: 'Asset unassigned from variant successfully' });
} catch (error) {
next(error);
}
}
}
export default new VariantController();
@@ -0,0 +1,96 @@
import { Model, DataTypes } from 'sequelize';
export class Variant extends Model {
static associate(models) {
// Belongs to product
Variant.belongsTo(models.Product, {
foreignKey: 'product_id',
as: 'product'
});
// M:N with Attribute for variant values
Variant.belongsToMany(models.Attribute, {
through: models.VariantValue,
foreignKey: 'variant_id',
otherKey: 'axis_id',
as: 'axes'
});
// HasMany values list directly
Variant.hasMany(models.VariantValue, {
foreignKey: 'variant_id',
as: 'values'
});
}
}
export default (sequelize) => {
Variant.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
product_id: {
type: DataTypes.UUID,
allowNull: false
},
sku: {
type: DataTypes.STRING(100),
allowNull: false,
unique: true
},
name: {
type: DataTypes.STRING(150),
allowNull: false
},
price: {
type: DataTypes.DECIMAL(12, 2),
allowNull: false,
defaultValue: 0.00
},
cost_price: {
type: DataTypes.DECIMAL(12, 2),
allowNull: true,
defaultValue: 0.00
},
currency: {
type: DataTypes.STRING(10),
allowNull: true,
defaultValue: 'USD'
},
stock: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
available_stock: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 0
},
reserved_stock: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 0
},
safety_stock: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 0
},
status: {
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'draft'
}
}, {
sequelize,
modelName: 'Variant',
tableName: 'product_variants',
timestamps: true,
underscored: true,
paranoid: true // Soft deletes support
});
return Variant;
};
@@ -0,0 +1,86 @@
import { models } from '../../../shared/database/models.js';
export class VariantRepository {
async findAll(options = {}) {
return await models.Variant.findAll({
include: [
{
model: models.VariantValue,
as: 'values',
include: [
{
model: models.Attribute,
as: 'axis',
attributes: ['code', 'name', 'type']
}
]
},
{
model: models.Product,
as: 'product',
attributes: ['id', 'name', 'code']
}
],
order: [
['sku', 'ASC']
],
...options
});
}
async findById(id, options = {}) {
return await models.Variant.findByPk(id, {
include: [
{
model: models.VariantValue,
as: 'values',
include: [
{
model: models.Attribute,
as: 'axis',
attributes: ['code', 'name', 'type']
}
]
},
{
model: models.Product,
as: 'product',
attributes: ['id', 'name', 'code']
}
],
...options
});
}
async findBySku(sku, options = {}) {
return await models.Variant.findOne({
where: { sku },
include: [
{
model: models.VariantValue,
as: 'values'
}
],
...options
});
}
async create(data, options = {}) {
return await models.Variant.create(data, options);
}
async update(id, data, options = {}) {
const record = await models.Variant.findByPk(id, options);
if (!record) return null;
return await record.update(data, options);
}
async delete(id, options = {}) {
const record = await models.Variant.findByPk(id, options);
if (!record) return false;
await record.destroy(options);
return true;
}
}
export default new VariantRepository();
@@ -0,0 +1,200 @@
import { Router } from 'express';
import controller from './variant.controller.js';
import { authenticate } from '../../../shared/middleware/auth.middleware.js';
import { validate } from '../../../shared/middleware/validation.middleware.js';
import { authorize } from '../../../shared/middleware/permission.middleware.js';
import { audit } from '../../../shared/middleware/audit.middleware.js';
import {
createValidation,
updateValidation,
deleteValidation,
getByIdValidation
} from './variant.validation.js';
const router = Router();
/**
* @swagger
* /api/v1/variants:
* get:
* summary: Retrieve all variants
* tags: [Variants]
* responses:
* 200:
* description: Success
*/
router.get(
'/',
authenticate,
authorize(['read:products']),
controller.getAll
);
/**
* @swagger
* /api/v1/variants/{id}:
* get:
* summary: Retrieve a single variant
* tags: [Variants]
* parameters:
* - in: path
* name: id
* required: true
* responses:
* 200:
* description: Success
*/
router.get(
'/:id',
authenticate,
authorize(['read:products']),
getByIdValidation,
validate,
controller.getById
);
/**
* @swagger
* /api/v1/variants:
* post:
* summary: Create a variant
* tags: [Variants]
* responses:
* 201:
* description: Success
*/
router.post(
'/',
authenticate,
authorize(['write:products']),
createValidation,
validate,
audit('CREATE_VARIANT'),
controller.create
);
/**
* @swagger
* /api/v1/variants/{id}:
* put:
* summary: Update a variant
* tags: [Variants]
* parameters:
* - in: path
* name: id
* required: true
* responses:
* 200:
* description: Success
*/
router.put(
'/:id',
authenticate,
authorize(['write:products']),
updateValidation,
validate,
audit('UPDATE_VARIANT'),
controller.update
);
/**
* @swagger
* /api/v1/variants/{id}:
* delete:
* summary: Delete a variant
* tags: [Variants]
* parameters:
* - in: path
* name: id
* required: true
* responses:
* 200:
* description: Success
*/
router.delete(
'/:id',
authenticate,
authorize(['write:products']),
deleteValidation,
validate,
audit('DELETE_VARIANT'),
controller.delete
);
/**
* @swagger
* /api/v1/variants/{id}/archive:
* post:
* summary: Archive a variant
* tags: [Variants]
* parameters:
* - in: path
* name: id
* required: true
* responses:
* 200:
* description: Success
*/
router.post(
'/:id/archive',
authenticate,
authorize(['write:products']),
deleteValidation,
validate,
audit('ARCHIVE_VARIANT'),
controller.archive
);
/**
* @swagger
* /api/v1/variants/{id}/restore:
* post:
* summary: Restore a deleted variant
* tags: [Variants]
* parameters:
* - in: path
* name: id
* required: true
* responses:
* 200:
* description: Success
*/
router.post(
'/:id/restore',
authenticate,
authorize(['write:products']),
getByIdValidation,
validate,
audit('RESTORE_VARIANT'),
controller.restore
);
router.get(
'/:id/assets',
authenticate,
authorize(['read:products']),
controller.getAssets
);
router.post(
'/:id/assets',
authenticate,
authorize(['write:products']),
controller.assignAsset
);
router.put(
'/:id/assets/:assetId',
authenticate,
authorize(['write:products']),
controller.updateAssetMapping
);
router.delete(
'/:id/assets/:assetId',
authenticate,
authorize(['write:products']),
controller.unassignAsset
);
export default router;
@@ -0,0 +1,578 @@
import { Op } from 'sequelize';
import repository from './variant.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';
export class VariantService {
async getAll(query = {}) {
const where = {};
if (query.status) {
where.status = query.status;
}
// Support filters (Parent Product, Family, Category, Workflow)
const productInclude = {
model: models.Product,
as: 'product',
attributes: ['id', 'name', 'code'],
include: []
};
if (query.parentProductId) {
where.product_id = query.parentProductId;
}
const catalogInclude = {
model: models.Catalog,
as: 'family',
attributes: ['id', 'name', 'code', 'workflow_code', 'category_id'],
include: []
};
if (query.familyId) {
catalogInclude.where = { id: query.familyId };
productInclude.include.push(catalogInclude);
} else if (query.categoryId || query.workflowCode) {
const catalogWhere = {};
if (query.categoryId) catalogWhere.category_id = query.categoryId;
if (query.workflowCode) catalogWhere.workflow_code = query.workflowCode;
catalogInclude.where = catalogWhere;
productInclude.include.push(catalogInclude);
} else {
productInclude.include.push(catalogInclude);
}
// Support search query (SKU, name)
if (query.search) {
where[Op.or] = [
{ sku: { [Op.iLike]: `%${query.search}%` } },
{ name: { [Op.iLike]: `%${query.search}%` } }
];
}
const records = await repository.findAll({
where,
include: [
{
model: models.VariantValue,
as: 'values',
include: [
{
model: models.Attribute,
as: 'axis',
attributes: ['code', 'name', 'type']
}
]
},
productInclude
]
});
return records;
}
async getById(id) {
const record = await repository.findById(id);
if (!record) {
throw new Error('Variant not found');
}
return record;
}
async create(data, userContext = {}) {
const transaction = await sequelize.transaction();
try {
// 1. Validate SKU unique
const existing = await models.Variant.findOne({
where: { sku: data.sku },
paranoid: false,
transaction
});
if (existing) {
throw new Error(`Variant SKU "${data.sku}" already exists`);
}
// 2. Validate Parent Product exists and is active
const parentProduct = await models.Product.findByPk(data.parentProductId, {
include: [
{
model: models.Catalog,
as: 'family',
include: [
{
model: models.Attribute,
as: 'variantAxes'
}
]
}
],
transaction
});
if (!parentProduct) {
throw new Error('Parent product not found');
}
if (parentProduct.status !== 'active') {
throw new Error(`Parent product is not active (current status: "${parentProduct.status}")`);
}
const family = parentProduct.family;
if (!family) {
throw new Error('Parent product does not have an assigned Product Family');
}
// 3. Validate Variant Axes & Required values
const axesCodes = family.variantAxes ? family.variantAxes.map(axis => axis.code) : [];
const providedAttributes = data.attributes || data.attributeValues || {};
const providedKeys = Object.keys(providedAttributes);
// Check required axes are provided
for (const axis of family.variantAxes || []) {
const val = providedAttributes[axis.code];
if (val === undefined || val === null || String(val).trim() === '') {
throw new Error(`Variant must define a value for the variant axis "${axis.name || axis.code}"`);
}
}
// Check that variant axis belongs to the family
for (const code of providedKeys) {
if (!axesCodes.includes(code)) {
throw new Error(`Attribute "${code}" is not configured as a variant axis in the "${family.name}" family`);
}
}
// 4. Validate Price and Stock
const price = parseFloat(data.price) ?? 0;
const stock = parseInt(data.stock) ?? 0;
if (price < 0) throw new Error('Price must be a positive number');
if (stock < 0) throw new Error('Stock must be a non-negative integer');
// 5. Reject duplicate variant combination
const existingVariants = await models.Variant.findAll({
where: { product_id: data.parentProductId },
include: [
{
model: models.VariantValue,
as: 'values',
include: [{ model: models.Attribute, as: 'axis' }]
}
],
transaction
});
for (const variant of existingVariants) {
let isMatch = true;
const variantMap = {};
for (const val of variant.values || []) {
if (val.axis) {
variantMap[val.axis.code] = val.value_text;
}
}
// Compare key-value pairs
for (const code of axesCodes) {
const v1 = String(providedAttributes[code] || '').trim();
const v2 = String(variantMap[code] || '').trim();
if (v1 !== v2) {
isMatch = false;
break;
}
}
if (isMatch && axesCodes.length > 0) {
throw new Error(`A variant with the same axes combination (${axesCodes.map(c => `${c}: ${providedAttributes[c]}`).join(', ')}) already exists for this product`);
}
}
// 6. Create Variant
const record = await models.Variant.create({
product_id: data.parentProductId,
sku: data.sku,
name: data.name || data.variantName,
price: price,
cost_price: parseFloat(data.cost_price || data.costPrice) || 0.00,
currency: data.currency || 'USD',
stock: stock,
available_stock: parseInt(data.available_stock || data.availableStock) || stock,
reserved_stock: parseInt(data.reserved_stock || data.reservedStock) || 0,
safety_stock: parseInt(data.safety_stock || data.safetyStock) || 0,
status: data.status || 'draft'
}, { transaction });
// 7. Insert Variant Values
for (const [code, val] of Object.entries(providedAttributes)) {
const attribute = family.variantAxes.find(axis => axis.code === code);
if (attribute) {
await models.VariantValue.create({
variant_id: record.id,
axis_id: attribute.id,
value_text: String(val)
}, { transaction });
}
}
await transaction.commit();
const fullRecord = await repository.findById(record.id);
// Emit socket event
SocketService.broadcast('variant.created', fullRecord);
// Audit Log
await AuditService.log({
action: 'CREATE',
resource: 'Variant',
resourceId: record.id,
userId: userContext.id || 'system',
details: data
});
return fullRecord;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async update(id, data, userContext = {}) {
const transaction = await sequelize.transaction();
try {
const record = await models.Variant.findByPk(id, { transaction });
if (!record) {
throw new Error('Variant not found');
}
// 1. SKU unique check if updated
if (data.sku && data.sku !== record.sku) {
const existing = await models.Variant.findOne({
where: { sku: data.sku, id: { [Op.ne]: id } },
paranoid: false,
transaction
});
if (existing) {
throw new Error(`Variant SKU "${data.sku}" already exists`);
}
}
// 2. Load Parent Product details for variant axes validation
const parentProduct = await models.Product.findByPk(record.product_id, {
include: [
{
model: models.Catalog,
as: 'family',
include: [
{
model: models.Attribute,
as: 'variantAxes'
}
]
}
],
transaction
});
const family = parentProduct?.family;
const providedAttributes = data.attributes || data.attributeValues;
if (family && providedAttributes) {
const axesCodes = family.variantAxes ? family.variantAxes.map(axis => axis.code) : [];
const providedKeys = Object.keys(providedAttributes);
// Check required axes are provided
for (const axis of family.variantAxes || []) {
const val = providedAttributes[axis.code];
if (val === undefined || val === null || String(val).trim() === '') {
throw new Error(`Variant must define a value for the variant axis "${axis.name || axis.code}"`);
}
}
// Check validation codes match family axes
for (const code of providedKeys) {
if (!axesCodes.includes(code)) {
throw new Error(`Attribute "${code}" is not configured as a variant axis in the "${family.name}" family`);
}
}
// 3. Reject duplicate variant combination
const existingVariants = await models.Variant.findAll({
where: { product_id: record.product_id, id: { [Op.ne]: id } },
include: [
{
model: models.VariantValue,
as: 'values',
include: [{ model: models.Attribute, as: 'axis' }]
}
],
transaction
});
for (const variant of existingVariants) {
let isMatch = true;
const variantMap = {};
for (const val of variant.values || []) {
if (val.axis) {
variantMap[val.axis.code] = val.value_text;
}
}
for (const code of axesCodes) {
const v1 = String(providedAttributes[code] || '').trim();
const v2 = String(variantMap[code] || '').trim();
if (v1 !== v2) {
isMatch = false;
break;
}
}
if (isMatch && axesCodes.length > 0) {
throw new Error(`A variant with the same axes combination already exists for this product`);
}
}
}
// 4. Validate pricing & stock
const price = data.hasOwnProperty('price') ? parseFloat(data.price) : record.price;
const stock = data.hasOwnProperty('stock') ? parseInt(data.stock) : record.stock;
if (price < 0) throw new Error('Price must be a positive number');
if (stock < 0) throw new Error('Stock must be a non-negative integer');
// 5. Update Record
const updatePayload = {
sku: data.sku || record.sku,
name: data.name || data.variantName || record.name,
price: price,
cost_price: data.hasOwnProperty('cost_price') ? parseFloat(data.cost_price) : (data.hasOwnProperty('costPrice') ? parseFloat(data.costPrice) : record.cost_price),
currency: data.currency || record.currency,
stock: stock,
available_stock: data.hasOwnProperty('available_stock') ? parseInt(data.available_stock) : (data.hasOwnProperty('availableStock') ? parseInt(data.availableStock) : record.available_stock),
reserved_stock: data.hasOwnProperty('reserved_stock') ? parseInt(data.reserved_stock) : (data.hasOwnProperty('reservedStock') ? parseInt(data.reservedStock) : record.reserved_stock),
safety_stock: data.hasOwnProperty('safety_stock') ? parseInt(data.safety_stock) : (data.hasOwnProperty('safetyStock') ? parseInt(data.safetyStock) : record.safety_stock),
status: data.status || record.status
};
await record.update(updatePayload, { transaction });
// 6. Update Variant Values if provided
if (providedAttributes && family) {
await models.VariantValue.destroy({ where: { variant_id: id }, transaction });
for (const [code, val] of Object.entries(providedAttributes)) {
const attribute = family.variantAxes.find(axis => axis.code === code);
if (attribute) {
await models.VariantValue.create({
variant_id: id,
axis_id: attribute.id,
value_text: String(val)
}, { transaction });
}
}
}
await transaction.commit();
const fullRecord = await repository.findById(id);
// Emit socket event
SocketService.broadcast('variant.updated', fullRecord);
// Audit Log
await AuditService.log({
action: 'UPDATE',
resource: 'Variant',
resourceId: id,
userId: userContext.id || 'system',
details: data
});
return fullRecord;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async delete(id, userContext = {}) {
const transaction = await sequelize.transaction();
try {
const record = await models.Variant.findByPk(id, { transaction });
if (!record) {
throw new Error('Variant not found');
}
await record.destroy({ transaction });
await transaction.commit();
// Emit socket event
SocketService.broadcast('variant.deleted', { id });
// Audit Log
await AuditService.log({
action: 'DELETE',
resource: 'Variant',
resourceId: id,
userId: userContext.id || 'system'
});
return true;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async archive(id, userContext = {}) {
const transaction = await sequelize.transaction();
try {
const record = await models.Variant.findByPk(id, { transaction });
if (!record) {
throw new Error('Variant not found');
}
await record.destroy({ transaction });
await transaction.commit();
SocketService.broadcast('variant.archived', { id });
await AuditService.log({
action: 'ARCHIVE',
resource: 'Variant',
resourceId: id,
userId: userContext.id || 'system'
});
return true;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async restore(id, userContext = {}) {
const transaction = await sequelize.transaction();
try {
const record = await models.Variant.findByPk(id, { paranoid: false, transaction });
if (!record) {
throw new Error('Variant not found');
}
await record.restore({ transaction });
await transaction.commit();
const restored = await repository.findById(id);
SocketService.broadcast('variant.restored', restored);
await AuditService.log({
action: 'RESTORE',
resource: 'Variant',
resourceId: id,
userId: userContext.id || 'system'
});
return restored;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async getAssets(variantId) {
const variant = await repository.findById(variantId);
if (!variant) throw new Error('Variant not found');
return await models.VariantAsset.findAll({
where: { variant_id: variantId },
include: [{ model: models.Asset, as: 'asset', include: [{ model: models.AssetType, as: 'assetType' }] }],
order: [['display_order', 'ASC']]
});
}
async assignAsset(variantId, assetId, data, userContext = {}) {
const transaction = await sequelize.transaction();
try {
const variant = await repository.findById(variantId, { transaction });
if (!variant) throw new Error('Variant not found');
const asset = await models.Asset.findByPk(assetId, { transaction });
if (!asset) throw new Error('Asset not found');
if (data.is_primary) {
await models.VariantAsset.update(
{ is_primary: false },
{ where: { variant_id: variantId }, transaction }
);
}
const [mapping, created] = await models.VariantAsset.findOrCreate({
where: { variant_id: variantId, asset_id: assetId },
defaults: {
role: data.role || 'gallery_image',
display_order: data.display_order || 0,
is_primary: !!data.is_primary
},
transaction
});
if (!created) {
await mapping.update({
role: data.role || mapping.role,
display_order: data.display_order !== undefined ? data.display_order : mapping.display_order,
is_primary: data.is_primary !== undefined ? !!data.is_primary : mapping.is_primary
}, { transaction });
}
await transaction.commit();
SocketService.broadcast('variant.updated', { id: variantId });
return mapping;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async updateAssetMapping(variantId, assetId, data, userContext = {}) {
const transaction = await sequelize.transaction();
try {
const mapping = await models.VariantAsset.findOne({
where: { variant_id: variantId, asset_id: assetId },
transaction
});
if (!mapping) throw new Error('Asset mapping not found');
if (data.is_primary) {
await models.VariantAsset.update(
{ is_primary: false },
{ where: { variant_id: variantId }, transaction }
);
}
await mapping.update({
role: data.role || mapping.role,
display_order: data.display_order !== undefined ? data.display_order : mapping.display_order,
is_primary: data.is_primary !== undefined ? !!data.is_primary : mapping.is_primary
}, { transaction });
await transaction.commit();
SocketService.broadcast('variant.updated', { id: variantId });
return mapping;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async unassignAsset(variantId, assetId, userContext = {}) {
const mapping = await models.VariantAsset.findOne({
where: { variant_id: variantId, asset_id: assetId }
});
if (!mapping) throw new Error('Asset mapping not found');
await mapping.destroy();
SocketService.broadcast('variant.updated', { id: variantId });
return true;
}
}
export default new VariantService();
@@ -0,0 +1,71 @@
import { body, param } from 'express-validator';
export const createValidation = [
body('sku')
.isString()
.trim()
.notEmpty()
.withMessage('SKU is required'),
body('name')
.isString()
.trim()
.notEmpty()
.withMessage('Name is required'),
body('parentProductId')
.isUUID()
.withMessage('Parent product ID must be a valid UUID'),
body('price')
.optional()
.isFloat({ min: 0 })
.withMessage('Price must be a positive number'),
body('stock')
.optional()
.isInt({ min: 0 })
.withMessage('Stock must be a non-negative integer'),
body('attributes')
.optional()
.isObject()
.withMessage('Attributes must be an object of key-value axes values')
];
export const updateValidation = [
param('id')
.isUUID()
.withMessage('Valid UUID is required'),
body('sku')
.optional()
.isString()
.trim()
.notEmpty()
.withMessage('SKU cannot be empty'),
body('name')
.optional()
.isString()
.trim()
.notEmpty()
.withMessage('Name cannot be empty'),
body('price')
.optional()
.isFloat({ min: 0 })
.withMessage('Price must be a positive number'),
body('stock')
.optional()
.isInt({ min: 0 })
.withMessage('Stock must be a non-negative integer'),
body('attributes')
.optional()
.isObject()
.withMessage('Attributes must be an object of key-value axes values')
];
export const deleteValidation = [
param('id')
.isUUID()
.withMessage('Valid UUID is required')
];
export const getByIdValidation = [
param('id')
.isUUID()
.withMessage('Valid UUID is required')
];
@@ -0,0 +1,47 @@
import { Model, DataTypes } from 'sequelize';
export class VariantAsset extends Model {
static associate(models) {
VariantAsset.belongsTo(models.Variant, { foreignKey: 'variant_id', as: 'variant' });
VariantAsset.belongsTo(models.Asset, { foreignKey: 'asset_id', as: 'asset' });
}
}
export default (sequelize) => {
VariantAsset.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
variant_id: {
type: DataTypes.UUID,
allowNull: false
},
asset_id: {
type: DataTypes.UUID,
allowNull: false
},
role: {
type: DataTypes.STRING(100),
allowNull: false
},
display_order: {
type: DataTypes.INTEGER,
defaultValue: 0
},
is_primary: {
type: DataTypes.BOOLEAN,
defaultValue: false
}
}, {
sequelize,
modelName: 'VariantAsset',
tableName: 'variant_assets',
timestamps: true,
underscored: true
});
return VariantAsset;
};
@@ -0,0 +1,43 @@
import { Model, DataTypes } from 'sequelize';
export class VariantValue extends Model {
static associate(models) {
// Belongs to variant
VariantValue.belongsTo(models.Variant, {
foreignKey: 'variant_id',
as: 'variant'
});
// Belongs to axis attribute
VariantValue.belongsTo(models.Attribute, {
foreignKey: 'axis_id',
as: 'axis'
});
}
}
export default (sequelize) => {
VariantValue.init({
variant_id: {
type: DataTypes.UUID,
allowNull: false,
primaryKey: true
},
axis_id: {
type: DataTypes.UUID,
allowNull: false,
primaryKey: true
},
value_text: {
type: DataTypes.TEXT,
allowNull: false
}
}, {
sequelize,
modelName: 'VariantValue',
tableName: 'variant_values',
timestamps: true,
underscored: true
});
return VariantValue;
};
@@ -0,0 +1,103 @@
import service from './workflow.service.js';
export class WorkflowController {
getAll = async (req, res, next) => {
try {
const records = await service.getAll(req.query);
return res.status(200).json({
success: true,
message: 'Workflows retrieved successfully',
data: records,
pagination: null,
timestamp: new Date().toISOString()
});
} catch (error) {
next(error);
}
}
getById = async (req, res, next) => {
try {
const record = await service.getById(req.params.id);
return res.status(200).json({
success: true,
message: 'Workflow retrieved successfully',
data: record,
pagination: null,
timestamp: new Date().toISOString()
});
} catch (error) {
next(error);
}
}
create = async (req, res, next) => {
try {
const record = await service.create(req.body, req.user);
return res.status(201).json({
success: true,
message: 'Workflow created successfully',
data: record,
timestamp: new Date().toISOString()
});
} catch (error) {
next(error);
}
}
update = async (req, res, next) => {
try {
const record = await service.update(req.params.id, req.body, req.user);
return res.status(200).json({
success: true,
message: 'Workflow updated successfully',
data: record,
timestamp: new Date().toISOString()
});
} catch (error) {
next(error);
}
}
delete = async (req, res, next) => {
try {
await service.delete(req.params.id, req.user);
return res.status(200).json({
success: true,
message: 'Workflow deleted successfully',
timestamp: new Date().toISOString()
});
} catch (error) {
next(error);
}
}
archive = async (req, res, next) => {
try {
await service.archive(req.params.id, req.user);
return res.status(200).json({
success: true,
message: 'Workflow archived successfully',
timestamp: new Date().toISOString()
});
} catch (error) {
next(error);
}
}
restore = async (req, res, next) => {
try {
const record = await service.restore(req.params.id, req.user);
return res.status(200).json({
success: true,
data: record,
message: 'Workflow restored successfully',
timestamp: new Date().toISOString()
});
} catch (error) {
next(error);
}
}
}
export default new WorkflowController();
+54
View File
@@ -0,0 +1,54 @@
import { Model, DataTypes } from 'sequelize';
export class WorkflowRegistry extends Model {
static associate(models) {
// Registry is decoupled, referenceable by workflow_code
}
}
export default (sequelize) => {
WorkflowRegistry.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true
},
name: {
type: DataTypes.STRING(100),
allowNull: false
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
category: {
type: DataTypes.STRING(50),
allowNull: false,
defaultValue: 'Standard Approval'
},
status: {
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'active'
},
stages: {
type: DataTypes.JSONB,
allowNull: true
}
}, {
sequelize,
modelName: 'WorkflowRegistry',
tableName: 'workflow_registries',
timestamps: true,
underscored: true,
paranoid: true
});
return WorkflowRegistry;
};

Some files were not shown because too many files have changed in this diff Show More