Implemented backend for attribute managemnet
This commit is contained in:
@@ -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,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;
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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
Reference in New Issue
Block a user