diff --git a/app.js b/app.js index 5da4285..8309b0b 100644 --- a/app.js +++ b/app.js @@ -32,6 +32,7 @@ app.get('/health', (req, res) => { }); // Centralized Feature Router Loader +app.use('/uploads', express.static('uploads')); registerRoutes(app); // Global Error Handler diff --git a/index.js b/index.js index 183c7bf..f5ea84b 100644 --- a/index.js +++ b/index.js @@ -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() { diff --git a/src/features/attributes/attributeGroups/attributeGroup.controller.js b/src/features/attributes/attributeGroups/attributeGroup.controller.js new file mode 100644 index 0000000..8913efc --- /dev/null +++ b/src/features/attributes/attributeGroups/attributeGroup.controller.js @@ -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(); diff --git a/src/features/attributes/attributeGroups/attributeGroup.model.js b/src/features/attributes/attributeGroups/attributeGroup.model.js new file mode 100644 index 0000000..89bd10b --- /dev/null +++ b/src/features/attributes/attributeGroups/attributeGroup.model.js @@ -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; +}; diff --git a/src/features/attributes/attributeGroups/attributeGroup.repository.js b/src/features/attributes/attributeGroups/attributeGroup.repository.js new file mode 100644 index 0000000..c15555e --- /dev/null +++ b/src/features/attributes/attributeGroups/attributeGroup.repository.js @@ -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(); diff --git a/src/features/attributes/attributeGroups/attributeGroup.routes.js b/src/features/attributes/attributeGroups/attributeGroup.routes.js new file mode 100644 index 0000000..52cde1d --- /dev/null +++ b/src/features/attributes/attributeGroups/attributeGroup.routes.js @@ -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; diff --git a/src/features/attributes/attributeGroups/attributeGroup.service.js b/src/features/attributes/attributeGroups/attributeGroup.service.js new file mode 100644 index 0000000..200d6d0 --- /dev/null +++ b/src/features/attributes/attributeGroups/attributeGroup.service.js @@ -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(); diff --git a/src/features/attributes/attributeGroups/attributeGroup.validation.js b/src/features/attributes/attributeGroups/attributeGroup.validation.js new file mode 100644 index 0000000..8cb04a9 --- /dev/null +++ b/src/features/attributes/attributeGroups/attributeGroup.validation.js @@ -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') +]; diff --git a/src/features/attributes/attributeGroups/attributeGroupAttribute.model.js b/src/features/attributes/attributeGroups/attributeGroupAttribute.model.js new file mode 100644 index 0000000..aca87d2 --- /dev/null +++ b/src/features/attributes/attributeGroups/attributeGroupAttribute.model.js @@ -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; +}; diff --git a/src/features/attributes/attributeGroups/attributeGroupHistory.model.js b/src/features/attributes/attributeGroups/attributeGroupHistory.model.js new file mode 100644 index 0000000..b2f94c0 --- /dev/null +++ b/src/features/attributes/attributeGroups/attributeGroupHistory.model.js @@ -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; +}; diff --git a/src/features/attributes/attributeSets/attributeSet.controller.js b/src/features/attributes/attributeSets/attributeSet.controller.js new file mode 100644 index 0000000..f80e158 --- /dev/null +++ b/src/features/attributes/attributeSets/attributeSet.controller.js @@ -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(); diff --git a/src/features/attributes/attributeSets/attributeSet.model.js b/src/features/attributes/attributeSets/attributeSet.model.js new file mode 100644 index 0000000..a5e5c12 --- /dev/null +++ b/src/features/attributes/attributeSets/attributeSet.model.js @@ -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; +}; diff --git a/src/features/attributes/attributeSets/attributeSet.repository.js b/src/features/attributes/attributeSets/attributeSet.repository.js new file mode 100644 index 0000000..eb8dde8 --- /dev/null +++ b/src/features/attributes/attributeSets/attributeSet.repository.js @@ -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(); diff --git a/src/features/attributes/attributeSets/attributeSet.routes.js b/src/features/attributes/attributeSets/attributeSet.routes.js new file mode 100644 index 0000000..f74e649 --- /dev/null +++ b/src/features/attributes/attributeSets/attributeSet.routes.js @@ -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; diff --git a/src/features/attributes/attributeSets/attributeSet.service.js b/src/features/attributes/attributeSets/attributeSet.service.js new file mode 100644 index 0000000..e7832be --- /dev/null +++ b/src/features/attributes/attributeSets/attributeSet.service.js @@ -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(); diff --git a/src/features/attributes/attributeSets/attributeSet.validation.js b/src/features/attributes/attributeSets/attributeSet.validation.js new file mode 100644 index 0000000..ad88fac --- /dev/null +++ b/src/features/attributes/attributeSets/attributeSet.validation.js @@ -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') +]; diff --git a/src/features/attributes/attributeSets/attributeSetGroup.model.js b/src/features/attributes/attributeSets/attributeSetGroup.model.js new file mode 100644 index 0000000..7ecc028 --- /dev/null +++ b/src/features/attributes/attributeSets/attributeSetGroup.model.js @@ -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; +}; diff --git a/src/features/attributes/attributeSets/attributeSetHistory.model.js b/src/features/attributes/attributeSets/attributeSetHistory.model.js new file mode 100644 index 0000000..38fe68b --- /dev/null +++ b/src/features/attributes/attributeSets/attributeSetHistory.model.js @@ -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; +}; diff --git a/src/features/attributes/attributes/attribute.controller.js b/src/features/attributes/attributes/attribute.controller.js index 68152ac..7030a35 100644 --- a/src/features/attributes/attributes/attribute.controller.js +++ b/src/features/attributes/attributes/attribute.controller.js @@ -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); } diff --git a/src/features/attributes/attributes/attribute.model.js b/src/features/attributes/attributes/attribute.model.js index 0c76612..a03a687 100644 --- a/src/features/attributes/attributes/attribute.model.js +++ b/src/features/attributes/attributes/attribute.model.js @@ -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; diff --git a/src/features/attributes/attributes/attribute.routes.js b/src/features/attributes/attributes/attribute.routes.js index 7c276f5..2d215b2 100644 --- a/src/features/attributes/attributes/attribute.routes.js +++ b/src/features/attributes/attributes/attribute.routes.js @@ -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; diff --git a/src/features/attributes/attributes/attribute.service.js b/src/features/attributes/attributes/attribute.service.js index 13da8b2..1c55cc2 100644 --- a/src/features/attributes/attributes/attribute.service.js +++ b/src/features/attributes/attributes/attribute.service.js @@ -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; + } } } diff --git a/src/features/attributes/attributes/attribute.validation.js b/src/features/attributes/attributes/attribute.validation.js index 8a1aaf4..c16d2d5 100644 --- a/src/features/attributes/attributes/attribute.validation.js +++ b/src/features/attributes/attributes/attribute.validation.js @@ -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 = [ diff --git a/src/features/attributes/attributes/attributeHistory.model.js b/src/features/attributes/attributes/attributeHistory.model.js new file mode 100644 index 0000000..a7c7738 --- /dev/null +++ b/src/features/attributes/attributes/attributeHistory.model.js @@ -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; +}; diff --git a/src/features/attributes/attributes/attributeOption.model.js b/src/features/attributes/attributes/attributeOption.model.js new file mode 100644 index 0000000..18ddfd7 --- /dev/null +++ b/src/features/attributes/attributes/attributeOption.model.js @@ -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; +}; diff --git a/src/features/attributes/index.js b/src/features/attributes/index.js index af405c7..91e5341 100644 --- a/src/features/attributes/index.js +++ b/src/features/attributes/index.js @@ -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; diff --git a/src/features/brands/brands/brand.controller.js b/src/features/brands/brands/brand.controller.js index 752bf87..29e4a9a 100644 --- a/src/features/brands/brands/brand.controller.js +++ b/src/features/brands/brands/brand.controller.js @@ -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(); diff --git a/src/features/brands/brands/brand.model.js b/src/features/brands/brands/brand.model.js index c61c9f8..bea8223 100644 --- a/src/features/brands/brands/brand.model.js +++ b/src/features/brands/brands/brand.model.js @@ -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 }); diff --git a/src/features/brands/brands/brand.routes.js b/src/features/brands/brands/brand.routes.js index 1cfa979..3cbec98 100644 --- a/src/features/brands/brands/brand.routes.js +++ b/src/features/brands/brands/brand.routes.js @@ -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; diff --git a/src/features/brands/brands/brand.service.js b/src/features/brands/brands/brand.service.js index f27a1ed..e271d31 100644 --- a/src/features/brands/brands/brand.service.js +++ b/src/features/brands/brands/brand.service.js @@ -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(); diff --git a/src/features/brands/brands/brand.validation.js b/src/features/brands/brands/brand.validation.js index 8a1aaf4..6213661 100644 --- a/src/features/brands/brands/brand.validation.js +++ b/src/features/brands/brands/brand.validation.js @@ -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 = [ diff --git a/src/features/brands/index.js b/src/features/brands/index.js index e7a68c1..51afd21 100644 --- a/src/features/brands/index.js +++ b/src/features/brands/index.js @@ -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; diff --git a/src/features/brands/units/unit.controller.js b/src/features/brands/units/unit.controller.js new file mode 100644 index 0000000..d16ecaf --- /dev/null +++ b/src/features/brands/units/unit.controller.js @@ -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(); diff --git a/src/features/brands/units/unit.model.js b/src/features/brands/units/unit.model.js new file mode 100644 index 0000000..07df66c --- /dev/null +++ b/src/features/brands/units/unit.model.js @@ -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; +}; diff --git a/src/features/brands/units/unit.repository.js b/src/features/brands/units/unit.repository.js new file mode 100644 index 0000000..d78c7de --- /dev/null +++ b/src/features/brands/units/unit.repository.js @@ -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(); diff --git a/src/features/brands/units/unit.routes.js b/src/features/brands/units/unit.routes.js new file mode 100644 index 0000000..cdc3b66 --- /dev/null +++ b/src/features/brands/units/unit.routes.js @@ -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; diff --git a/src/features/brands/units/unit.service.js b/src/features/brands/units/unit.service.js new file mode 100644 index 0000000..452e4e8 --- /dev/null +++ b/src/features/brands/units/unit.service.js @@ -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(); diff --git a/src/features/brands/units/unit.validation.js b/src/features/brands/units/unit.validation.js new file mode 100644 index 0000000..a7e5f45 --- /dev/null +++ b/src/features/brands/units/unit.validation.js @@ -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') +]; diff --git a/src/features/catalogs/catalogs/catalog.controller.js b/src/features/catalogs/catalogs/catalog.controller.js index ecc0ab1..248886d 100644 --- a/src/features/catalogs/catalogs/catalog.controller.js +++ b/src/features/catalogs/catalogs/catalog.controller.js @@ -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); } diff --git a/src/features/catalogs/catalogs/catalog.model.js b/src/features/catalogs/catalogs/catalog.model.js index 4371aea..f667436 100644 --- a/src/features/catalogs/catalogs/catalog.model.js +++ b/src/features/catalogs/catalogs/catalog.model.js @@ -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; diff --git a/src/features/catalogs/catalogs/catalog.repository.js b/src/features/catalogs/catalogs/catalog.repository.js index 1e80a45..10747e3 100644 --- a/src/features/catalogs/catalogs/catalog.repository.js +++ b/src/features/catalogs/catalogs/catalog.repository.js @@ -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; diff --git a/src/features/catalogs/catalogs/catalog.routes.js b/src/features/catalogs/catalogs/catalog.routes.js index 0466f44..c9dbbb7 100644 --- a/src/features/catalogs/catalogs/catalog.routes.js +++ b/src/features/catalogs/catalogs/catalog.routes.js @@ -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; diff --git a/src/features/catalogs/catalogs/catalog.service.js b/src/features/catalogs/catalogs/catalog.service.js index b174491..fc1469e 100644 --- a/src/features/catalogs/catalogs/catalog.service.js +++ b/src/features/catalogs/catalogs/catalog.service.js @@ -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 + } + }; } } diff --git a/src/features/catalogs/catalogs/catalog.validation.js b/src/features/catalogs/catalogs/catalog.validation.js index 8a1aaf4..1cf7da4 100644 --- a/src/features/catalogs/catalogs/catalog.validation.js +++ b/src/features/catalogs/catalogs/catalog.validation.js @@ -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 = [ diff --git a/src/features/catalogs/catalogs/familyAsset.model.js b/src/features/catalogs/catalogs/familyAsset.model.js new file mode 100644 index 0000000..fe48e81 --- /dev/null +++ b/src/features/catalogs/catalogs/familyAsset.model.js @@ -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; +}; diff --git a/src/features/catalogs/catalogs/familyAssetRequirement.model.js b/src/features/catalogs/catalogs/familyAssetRequirement.model.js new file mode 100644 index 0000000..fe0a35f --- /dev/null +++ b/src/features/catalogs/catalogs/familyAssetRequirement.model.js @@ -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; +}; diff --git a/src/features/catalogs/catalogs/familyAttribute.model.js b/src/features/catalogs/catalogs/familyAttribute.model.js new file mode 100644 index 0000000..b4dd028 --- /dev/null +++ b/src/features/catalogs/catalogs/familyAttribute.model.js @@ -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; +}; diff --git a/src/features/catalogs/catalogs/familyChannel.model.js b/src/features/catalogs/catalogs/familyChannel.model.js new file mode 100644 index 0000000..f9511a1 --- /dev/null +++ b/src/features/catalogs/catalogs/familyChannel.model.js @@ -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; +}; diff --git a/src/features/catalogs/catalogs/familyVariantAxis.model.js b/src/features/catalogs/catalogs/familyVariantAxis.model.js new file mode 100644 index 0000000..d3eef15 --- /dev/null +++ b/src/features/catalogs/catalogs/familyVariantAxis.model.js @@ -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; +}; diff --git a/src/features/catalogs/index.js b/src/features/catalogs/index.js index c67da30..1f3786e 100644 --- a/src/features/catalogs/index.js +++ b/src/features/catalogs/index.js @@ -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; diff --git a/src/features/categories/categories/categorie.controller.js b/src/features/categories/categories/categorie.controller.js index 0695bbb..20d1610 100644 --- a/src/features/categories/categories/categorie.controller.js +++ b/src/features/categories/categories/categorie.controller.js @@ -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(); diff --git a/src/features/categories/categories/categorie.model.js b/src/features/categories/categories/categorie.model.js index d5b2d83..bbe3759 100644 --- a/src/features/categories/categories/categorie.model.js +++ b/src/features/categories/categories/categorie.model.js @@ -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; diff --git a/src/features/categories/categories/categorie.repository.js b/src/features/categories/categories/categorie.repository.js index 73996b5..a6c4932 100644 --- a/src/features/categories/categories/categorie.repository.js +++ b/src/features/categories/categories/categorie.repository.js @@ -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; diff --git a/src/features/categories/categories/categorie.routes.js b/src/features/categories/categories/categorie.routes.js index eed25ed..3ceff45 100644 --- a/src/features/categories/categories/categorie.routes.js +++ b/src/features/categories/categories/categorie.routes.js @@ -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; diff --git a/src/features/categories/categories/categorie.service.js b/src/features/categories/categories/categorie.service.js index be27ebf..06fbf60 100644 --- a/src/features/categories/categories/categorie.service.js +++ b/src/features/categories/categories/categorie.service.js @@ -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; + } } } diff --git a/src/features/categories/categories/categorie.validation.js b/src/features/categories/categories/categorie.validation.js index 8a1aaf4..a94878c 100644 --- a/src/features/categories/categories/categorie.validation.js +++ b/src/features/categories/categories/categorie.validation.js @@ -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 = [ diff --git a/src/features/categories/categories/categoryAsset.model.js b/src/features/categories/categories/categoryAsset.model.js new file mode 100644 index 0000000..e135790 --- /dev/null +++ b/src/features/categories/categories/categoryAsset.model.js @@ -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; +}; diff --git a/src/features/channels/channelTypes/channelType.controller.js b/src/features/channels/channelTypes/channelType.controller.js new file mode 100644 index 0000000..07fe86d --- /dev/null +++ b/src/features/channels/channelTypes/channelType.controller.js @@ -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(); diff --git a/src/features/channels/channelTypes/channelType.model.js b/src/features/channels/channelTypes/channelType.model.js new file mode 100644 index 0000000..4ab3d75 --- /dev/null +++ b/src/features/channels/channelTypes/channelType.model.js @@ -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; +}; diff --git a/src/features/channels/channelTypes/channelType.repository.js b/src/features/channels/channelTypes/channelType.repository.js new file mode 100644 index 0000000..81385b3 --- /dev/null +++ b/src/features/channels/channelTypes/channelType.repository.js @@ -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(); diff --git a/src/features/channels/channelTypes/channelType.routes.js b/src/features/channels/channelTypes/channelType.routes.js new file mode 100644 index 0000000..ab71717 --- /dev/null +++ b/src/features/channels/channelTypes/channelType.routes.js @@ -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; diff --git a/src/features/channels/channelTypes/channelType.service.js b/src/features/channels/channelTypes/channelType.service.js new file mode 100644 index 0000000..d70c6e3 --- /dev/null +++ b/src/features/channels/channelTypes/channelType.service.js @@ -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(); diff --git a/src/features/channels/channelTypes/channelType.validation.js b/src/features/channels/channelTypes/channelType.validation.js new file mode 100644 index 0000000..413244f --- /dev/null +++ b/src/features/channels/channelTypes/channelType.validation.js @@ -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') +]; diff --git a/src/features/channels/channels/channel.controller.js b/src/features/channels/channels/channel.controller.js index cba1fa1..d8fceb0 100644 --- a/src/features/channels/channels/channel.controller.js +++ b/src/features/channels/channels/channel.controller.js @@ -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(); diff --git a/src/features/channels/channels/channel.model.js b/src/features/channels/channels/channel.model.js index 641f931..b550c15 100644 --- a/src/features/channels/channels/channel.model.js +++ b/src/features/channels/channels/channel.model.js @@ -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; diff --git a/src/features/channels/channels/channel.routes.js b/src/features/channels/channels/channel.routes.js index 4774486..cad4c9b 100644 --- a/src/features/channels/channels/channel.routes.js +++ b/src/features/channels/channels/channel.routes.js @@ -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; diff --git a/src/features/channels/channels/channel.service.js b/src/features/channels/channels/channel.service.js index 3c4b607..fa5643a 100644 --- a/src/features/channels/channels/channel.service.js +++ b/src/features/channels/channels/channel.service.js @@ -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(); diff --git a/src/features/channels/channels/channelAsset.model.js b/src/features/channels/channels/channelAsset.model.js new file mode 100644 index 0000000..85fe373 --- /dev/null +++ b/src/features/channels/channels/channelAsset.model.js @@ -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; +}; diff --git a/src/features/channels/index.js b/src/features/channels/index.js index fd15b73..1eb4cf9 100644 --- a/src/features/channels/index.js +++ b/src/features/channels/index.js @@ -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; diff --git a/src/features/index.js b/src/features/index.js index 51fce0f..754c7cf 100644 --- a/src/features/index.js +++ b/src/features/index.js @@ -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); } diff --git a/src/features/media/assetTypes/assetType.service.js b/src/features/media/assetTypes/assetType.service.js index 4a70fa2..0e91d03 100644 --- a/src/features/media/assetTypes/assetType.service.js +++ b/src/features/media/assetTypes/assetType.service.js @@ -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 }); diff --git a/src/features/media/assets/asset.controller.js b/src/features/media/assets/asset.controller.js index c188075..57e5776 100644 --- a/src/features/media/assets/asset.controller.js +++ b/src/features/media/assets/asset.controller.js @@ -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); } diff --git a/src/features/media/assets/asset.model.js b/src/features/media/assets/asset.model.js index f75b4b6..87de6f3 100644 --- a/src/features/media/assets/asset.model.js +++ b/src/features/media/assets/asset.model.js @@ -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; diff --git a/src/features/media/assets/asset.routes.js b/src/features/media/assets/asset.routes.js index e11f35a..c138850 100644 --- a/src/features/media/assets/asset.routes.js +++ b/src/features/media/assets/asset.routes.js @@ -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; diff --git a/src/features/media/assets/asset.service.js b/src/features/media/assets/asset.service.js index ebe2cb6..cc9f78a 100644 --- a/src/features/media/assets/asset.service.js +++ b/src/features/media/assets/asset.service.js @@ -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(); diff --git a/src/features/media/assets/assetFolder.model.js b/src/features/media/assets/assetFolder.model.js new file mode 100644 index 0000000..0087a00 --- /dev/null +++ b/src/features/media/assets/assetFolder.model.js @@ -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; +}; diff --git a/src/features/media/assets/assetTag.model.js b/src/features/media/assets/assetTag.model.js new file mode 100644 index 0000000..0b8299e --- /dev/null +++ b/src/features/media/assets/assetTag.model.js @@ -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; +}; diff --git a/src/features/media/assets/assetVersion.model.js b/src/features/media/assets/assetVersion.model.js new file mode 100644 index 0000000..607cef4 --- /dev/null +++ b/src/features/media/assets/assetVersion.model.js @@ -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; +}; diff --git a/src/features/media/assets/tag.model.js b/src/features/media/assets/tag.model.js new file mode 100644 index 0000000..3fc8b8b --- /dev/null +++ b/src/features/media/assets/tag.model.js @@ -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; +}; diff --git a/src/features/products/products/completeness.service.js b/src/features/products/products/completeness.service.js new file mode 100644 index 0000000..0044b73 --- /dev/null +++ b/src/features/products/products/completeness.service.js @@ -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; diff --git a/src/features/products/products/product.controller.js b/src/features/products/products/product.controller.js index 7bb50b3..2e73dd9 100644 --- a/src/features/products/products/product.controller.js +++ b/src/features/products/products/product.controller.js @@ -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(); diff --git a/src/features/products/products/product.model.js b/src/features/products/products/product.model.js index 88f3039..f87adec 100644 --- a/src/features/products/products/product.model.js +++ b/src/features/products/products/product.model.js @@ -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; diff --git a/src/features/products/products/product.repository.js b/src/features/products/products/product.repository.js index 7000098..726346c 100644 --- a/src/features/products/products/product.repository.js +++ b/src/features/products/products/product.repository.js @@ -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 = {}) { diff --git a/src/features/products/products/product.routes.js b/src/features/products/products/product.routes.js index ea10365..b77b86a 100644 --- a/src/features/products/products/product.routes.js +++ b/src/features/products/products/product.routes.js @@ -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; diff --git a/src/features/products/products/product.service.js b/src/features/products/products/product.service.js index bc3bc4f..27922ad 100644 --- a/src/features/products/products/product.service.js +++ b/src/features/products/products/product.service.js @@ -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(); diff --git a/src/features/products/products/product.validation.js b/src/features/products/products/product.validation.js index 8a1aaf4..9c10e10 100644 --- a/src/features/products/products/product.validation.js +++ b/src/features/products/products/product.validation.js @@ -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 = [ diff --git a/src/features/products/products/productAsset.model.js b/src/features/products/products/productAsset.model.js new file mode 100644 index 0000000..4158a0c --- /dev/null +++ b/src/features/products/products/productAsset.model.js @@ -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; +}; diff --git a/src/features/products/products/productAttributeValue.model.js b/src/features/products/products/productAttributeValue.model.js new file mode 100644 index 0000000..8831cc2 --- /dev/null +++ b/src/features/products/products/productAttributeValue.model.js @@ -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; +}; diff --git a/src/features/products/products/productCompleteness.model.js b/src/features/products/products/productCompleteness.model.js new file mode 100644 index 0000000..b7f8445 --- /dev/null +++ b/src/features/products/products/productCompleteness.model.js @@ -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; +}; diff --git a/src/features/variants/index.js b/src/features/variants/index.js new file mode 100644 index 0000000..f477fd3 --- /dev/null +++ b/src/features/variants/index.js @@ -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; diff --git a/src/features/variants/variants/variant.controller.js b/src/features/variants/variants/variant.controller.js new file mode 100644 index 0000000..e0a64bc --- /dev/null +++ b/src/features/variants/variants/variant.controller.js @@ -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(); diff --git a/src/features/variants/variants/variant.model.js b/src/features/variants/variants/variant.model.js new file mode 100644 index 0000000..6dd0e5e --- /dev/null +++ b/src/features/variants/variants/variant.model.js @@ -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; +}; diff --git a/src/features/variants/variants/variant.repository.js b/src/features/variants/variants/variant.repository.js new file mode 100644 index 0000000..0ee2bb6 --- /dev/null +++ b/src/features/variants/variants/variant.repository.js @@ -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(); diff --git a/src/features/variants/variants/variant.routes.js b/src/features/variants/variants/variant.routes.js new file mode 100644 index 0000000..aad5b31 --- /dev/null +++ b/src/features/variants/variants/variant.routes.js @@ -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; diff --git a/src/features/variants/variants/variant.service.js b/src/features/variants/variants/variant.service.js new file mode 100644 index 0000000..070ccc8 --- /dev/null +++ b/src/features/variants/variants/variant.service.js @@ -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(); diff --git a/src/features/variants/variants/variant.validation.js b/src/features/variants/variants/variant.validation.js new file mode 100644 index 0000000..0b55572 --- /dev/null +++ b/src/features/variants/variants/variant.validation.js @@ -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') +]; diff --git a/src/features/variants/variants/variantAsset.model.js b/src/features/variants/variants/variantAsset.model.js new file mode 100644 index 0000000..ef6daab --- /dev/null +++ b/src/features/variants/variants/variantAsset.model.js @@ -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; +}; diff --git a/src/features/variants/variants/variantValue.model.js b/src/features/variants/variants/variantValue.model.js new file mode 100644 index 0000000..f064c74 --- /dev/null +++ b/src/features/variants/variants/variantValue.model.js @@ -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; +}; diff --git a/src/features/workflows/workflow.controller.js b/src/features/workflows/workflow.controller.js new file mode 100644 index 0000000..d5c8572 --- /dev/null +++ b/src/features/workflows/workflow.controller.js @@ -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(); diff --git a/src/features/workflows/workflow.model.js b/src/features/workflows/workflow.model.js new file mode 100644 index 0000000..9174d8a --- /dev/null +++ b/src/features/workflows/workflow.model.js @@ -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; +}; diff --git a/src/features/workflows/workflow.repository.js b/src/features/workflows/workflow.repository.js new file mode 100644 index 0000000..08feb54 --- /dev/null +++ b/src/features/workflows/workflow.repository.js @@ -0,0 +1,30 @@ +import { models } from '../../shared/database/models.js'; + +export class WorkflowRepository { + async findAll(options = {}) { + return await models.WorkflowRegistry.findAll(options); + } + + async findById(id, options = {}) { + return await models.WorkflowRegistry.findByPk(id, options); + } + + async create(data, options = {}) { + return await models.WorkflowRegistry.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 WorkflowRepository(); diff --git a/src/features/workflows/workflow.routes.js b/src/features/workflows/workflow.routes.js new file mode 100644 index 0000000..c19dfdc --- /dev/null +++ b/src/features/workflows/workflow.routes.js @@ -0,0 +1,65 @@ +import { Router } from 'express'; +import controller from './workflow.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 './workflow.validation.js'; + +const router = Router(); + +router.get('/', authenticate, controller.getAll); + +router.get('/:id', authenticate, getByIdValidation, validate, controller.getById); + +router.post( + '/', + authenticate, + createValidation, + validate, + audit('CREATE_WORKFLOW'), + controller.create +); + +router.put( + '/:id', + authenticate, + updateValidation, + validate, + audit('UPDATE_WORKFLOW'), + controller.update +); + +router.delete( + '/:id', + authenticate, + deleteValidation, + validate, + audit('DELETE_WORKFLOW'), + controller.delete +); + +router.post( + '/:id/archive', + authenticate, + getByIdValidation, + validate, + audit('ARCHIVE_WORKFLOW'), + controller.archive +); + +router.post( + '/:id/restore', + authenticate, + getByIdValidation, + validate, + audit('RESTORE_WORKFLOW'), + controller.restore +); + +export default router; diff --git a/src/features/workflows/workflow.service.js b/src/features/workflows/workflow.service.js new file mode 100644 index 0000000..bdd83ae --- /dev/null +++ b/src/features/workflows/workflow.service.js @@ -0,0 +1,170 @@ +import repository from './workflow.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 WorkflowService { + 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('Workflow 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 = `wfk_${Date.now()}`; + } + } + data.code = data.code.toLowerCase().trim(); + + // Check duplicate code + const existing = await models.WorkflowRegistry.findOne({ where: { code: data.code } }); + if (existing) { + throw new Error(`Workflow with code "${data.code}" already exists`); + } + + const record = await repository.create({ + code: data.code, + name: data.name, + description: data.description, + category: data.category || 'Standard Approval', + status: data.status || 'active', + stages: data.stages || [] + }); + + SocketService.broadcast('workflow:created', record); + + await AuditService.log({ + action: 'CREATE', + resource: 'WorkflowRegistry', + 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('Workflow not found'); + } + + if (data.code) { + data.code = data.code.toLowerCase().trim(); + if (data.code !== record.code) { + const existing = await models.WorkflowRegistry.findOne({ where: { code: data.code } }); + if (existing) { + throw new Error(`Workflow with code "${data.code}" already exists`); + } + } + } + + const updatedRecord = await repository.update(id, { + code: data.code || record.code, + name: data.name || record.name, + description: data.hasOwnProperty('description') ? data.description : record.description, + category: data.category || record.category, + status: data.status || record.status, + stages: data.stages || record.stages + }); + + SocketService.broadcast('workflow:updated', updatedRecord); + + await AuditService.log({ + action: 'UPDATE', + resource: 'WorkflowRegistry', + 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('Workflow not found'); + } + + // Check usage in product families (Catalog) + const familiesCount = await models.Catalog.count({ where: { workflow_code: record.code } }); + if (familiesCount > 0) { + throw new Error('Cannot delete Workflow because it is used by one or more Product Families'); + } + + // Hard delete + await record.destroy({ force: true }); + + SocketService.broadcast('workflow:deleted', { id }); + + await AuditService.log({ + action: 'DELETE', + resource: 'WorkflowRegistry', + resourceId: id, + userId: userContext.id || 'system' + }); + + return true; + } + + async archive(id, userContext = {}) { + const record = await repository.findById(id); + if (!record) { + throw new Error('Workflow not found'); + } + + // Soft delete / Archive + await record.destroy(); + + SocketService.broadcast('workflow:archived', { id }); + + await AuditService.log({ + action: 'ARCHIVE', + resource: 'WorkflowRegistry', + 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('Workflow not found'); + } + + await record.restore(); + + const restored = await repository.findById(id); + SocketService.broadcast('workflow:restored', restored); + + await AuditService.log({ + action: 'RESTORE', + resource: 'WorkflowRegistry', + resourceId: id, + userId: userContext.id || 'system' + }); + + return restored; + } +} + +export default new WorkflowService(); diff --git a/src/features/workflows/workflow.validation.js b/src/features/workflows/workflow.validation.js new file mode 100644 index 0000000..413244f --- /dev/null +++ b/src/features/workflows/workflow.validation.js @@ -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') +]; diff --git a/src/migrations/20260709000001-create-brands-and-units.cjs b/src/migrations/20260709000001-create-brands-and-units.cjs new file mode 100644 index 0000000..e36ad21 --- /dev/null +++ b/src/migrations/20260709000001-create-brands-and-units.cjs @@ -0,0 +1,118 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + // Create brands table + await queryInterface.createTable('brands', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + code: { + type: Sequelize.STRING(50), + allowNull: false, + unique: true + }, + name: { + type: Sequelize.STRING(100), + allowNull: false + }, + description: { + type: Sequelize.TEXT, + allowNull: true + }, + website: { + type: Sequelize.STRING(255), + allowNull: true + }, + country: { + type: Sequelize.STRING(100), + allowNull: true + }, + status: { + type: Sequelize.STRING(20), + allowNull: false, + defaultValue: 'active' + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + }, + deleted_at: { + type: Sequelize.DATE, + allowNull: true + } + }); + + // Create units table + await queryInterface.createTable('units', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + code: { + type: Sequelize.STRING(50), + allowNull: false, + unique: true + }, + name: { + type: Sequelize.STRING(100), + allowNull: false + }, + symbol: { + type: Sequelize.STRING(20), + allowNull: false + }, + unit_type: { + type: Sequelize.STRING(50), + allowNull: false + }, + conversion_factor: { + type: Sequelize.DECIMAL(15, 6), + allowNull: true + }, + base_unit: { + type: Sequelize.STRING(50), + allowNull: true + }, + description: { + type: Sequelize.TEXT, + allowNull: true + }, + status: { + type: Sequelize.STRING(20), + allowNull: false, + defaultValue: 'active' + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + }, + deleted_at: { + type: Sequelize.DATE, + allowNull: true + } + }); + + // Add unique indexes + await queryInterface.addIndex('brands', ['code'], { unique: true, name: 'idx_brands_code' }); + await queryInterface.addIndex('units', ['code'], { unique: true, name: 'idx_units_code' }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable('units'); + await queryInterface.dropTable('brands'); + } +}; diff --git a/src/migrations/20260709000002-create-categories-and-families.cjs b/src/migrations/20260709000002-create-categories-and-families.cjs new file mode 100644 index 0000000..c92eae8 --- /dev/null +++ b/src/migrations/20260709000002-create-categories-and-families.cjs @@ -0,0 +1,133 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + // Create categories table + await queryInterface.createTable('categories', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + parent_id: { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'categories', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'RESTRICT' + }, + code: { + type: Sequelize.STRING(50), + allowNull: false, + unique: true + }, + name: { + type: Sequelize.STRING(100), + allowNull: false + }, + description: { + type: Sequelize.TEXT, + allowNull: true + }, + status: { + type: Sequelize.STRING(20), + allowNull: false, + defaultValue: 'active' + }, + path: { + type: Sequelize.STRING(500), + allowNull: true + }, + level: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0 + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + }, + deleted_at: { + type: Sequelize.DATE, + allowNull: true + } + }); + + // Create catalogs table (acting as Product Families) + await queryInterface.createTable('catalogs', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + code: { + type: Sequelize.STRING(50), + allowNull: false, + unique: true + }, + name: { + type: Sequelize.STRING(100), + allowNull: false + }, + description: { + type: Sequelize.TEXT, + allowNull: true + }, + status: { + type: Sequelize.STRING(20), + allowNull: false, + defaultValue: 'draft' + }, + category_id: { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'categories', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL' + }, + workflow_code: { + type: Sequelize.STRING(50), + allowNull: false, + defaultValue: 'standard' + }, + completeness_rules: { + type: Sequelize.JSONB, + allowNull: true + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + }, + deleted_at: { + type: Sequelize.DATE, + allowNull: true + } + }); + + // Add indexes for optimization + await queryInterface.addIndex('categories', ['code'], { unique: true, name: 'idx_categories_code' }); + await queryInterface.addIndex('categories', ['parent_id'], { name: 'idx_categories_parent_id' }); + await queryInterface.addIndex('catalogs', ['code'], { unique: true, name: 'idx_catalogs_code' }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable('catalogs'); + await queryInterface.dropTable('categories'); + } +}; diff --git a/src/migrations/20260709000003-create-attributes-and-groups.cjs b/src/migrations/20260709000003-create-attributes-and-groups.cjs new file mode 100644 index 0000000..24dd7cb --- /dev/null +++ b/src/migrations/20260709000003-create-attributes-and-groups.cjs @@ -0,0 +1,388 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + // Create attribute_groups table + await queryInterface.createTable('attribute_groups', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + code: { + type: Sequelize.STRING(50), + allowNull: false, + unique: true + }, + name: { + type: Sequelize.STRING(100), + allowNull: false + }, + status: { + type: Sequelize.STRING(20), + allowNull: false, + defaultValue: 'active' + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + }, + deleted_at: { + type: Sequelize.DATE, + allowNull: true + } + }); + + // Create attributes table + await queryInterface.createTable('attributes', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + code: { + type: Sequelize.STRING(50), + allowNull: false, + unique: true + }, + name: { + type: Sequelize.STRING(100), + allowNull: false + }, + type: { + type: Sequelize.STRING(20), + allowNull: false + }, + is_required: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }, + is_unique: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }, + is_localizable: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }, + is_variant_eligible: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }, + is_searchable: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }, + is_filterable: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }, + is_channel_specific: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }, + min_length: { + type: Sequelize.INTEGER, + allowNull: true + }, + max_length: { + type: Sequelize.INTEGER, + allowNull: true + }, + regex_pattern: { + type: Sequelize.STRING(255), + allowNull: true + }, + default_value: { + type: Sequelize.STRING(255), + allowNull: true + }, + options: { + type: Sequelize.JSONB, + allowNull: true + }, + status: { + type: Sequelize.STRING(20), + allowNull: false, + defaultValue: 'draft' + }, + display_order: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0 + }, + created_by: { + type: Sequelize.UUID, + allowNull: true + }, + updated_by: { + type: Sequelize.UUID, + allowNull: true + }, + deleted_by: { + type: Sequelize.UUID, + allowNull: true + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + }, + deleted_at: { + type: Sequelize.DATE, + allowNull: true + } + }); + + // Create attribute_group_attributes bridge with composite primary keys + await queryInterface.createTable('attribute_group_attributes', { + group_id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + references: { + model: 'attribute_groups', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + attribute_id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + references: { + model: 'attributes', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + display_order: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0 + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + } + }); + + // Create attribute_sets table + await queryInterface.createTable('attribute_sets', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + code: { + type: Sequelize.STRING(50), + allowNull: false, + unique: true + }, + name: { + type: Sequelize.STRING(100), + allowNull: false + }, + description: { + type: Sequelize.TEXT, + allowNull: true + }, + status: { + type: Sequelize.STRING(20), + allowNull: false, + defaultValue: 'active' + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + }, + deleted_at: { + type: Sequelize.DATE, + allowNull: true + } + }); + + // Create attribute_set_groups bridge with composite primary keys + await queryInterface.createTable('attribute_set_groups', { + attribute_set_id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + references: { + model: 'attribute_sets', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + attribute_group_id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + references: { + model: 'attribute_groups', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'RESTRICT' + }, + display_order: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0 + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + } + }); + + // Create family_attributes bridge with composite primary keys + await queryInterface.createTable('family_attributes', { + family_id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + references: { + model: 'catalogs', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + attribute_id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + references: { + model: 'attributes', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'RESTRICT' + }, + display_order: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0 + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + } + }); + + // Create family_variant_axes bridge with composite primary keys + await queryInterface.createTable('family_variant_axes', { + family_id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + references: { + model: 'catalogs', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + attribute_id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + references: { + model: 'attributes', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'RESTRICT' + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + } + }); + + // Create family_channels bridge with composite primary keys + await queryInterface.createTable('family_channels', { + family_id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + references: { + model: 'catalogs', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + channel_code: { + type: Sequelize.STRING(50), + allowNull: false, + primaryKey: true + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + } + }); + + // Indexes + await queryInterface.addIndex('attribute_groups', ['code'], { unique: true, name: 'idx_attribute_groups_code' }); + await queryInterface.addIndex('attributes', ['code'], { unique: true, name: 'idx_attributes_code' }); + await queryInterface.addIndex('attribute_sets', ['code'], { unique: true, name: 'idx_attribute_sets_code' }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable('family_channels'); + await queryInterface.dropTable('family_variant_axes'); + await queryInterface.dropTable('family_attributes'); + await queryInterface.dropTable('attribute_set_groups'); + await queryInterface.dropTable('attribute_sets'); + await queryInterface.dropTable('attribute_group_attributes'); + await queryInterface.dropTable('attributes'); + await queryInterface.dropTable('attribute_groups'); + } +}; diff --git a/src/migrations/20260709000004-create-variants-and-assets.cjs b/src/migrations/20260709000004-create-variants-and-assets.cjs new file mode 100644 index 0000000..a458f07 --- /dev/null +++ b/src/migrations/20260709000004-create-variants-and-assets.cjs @@ -0,0 +1,327 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + // Check if products table exists, if not create it + const tableExists = await queryInterface.describeTable('products').catch(() => null); + if (!tableExists) { + await queryInterface.createTable('products', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + family_id: { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'catalogs', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'RESTRICT' + }, + category_id: { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'categories', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'RESTRICT' + }, + brand_id: { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'brands', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'RESTRICT' + }, + unit_id: { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'units', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'RESTRICT' + }, + code: { + type: Sequelize.STRING(50), + allowNull: false, + unique: true + }, + name: { + type: Sequelize.STRING(255), + allowNull: false + }, + status: { + type: Sequelize.STRING(20), + allowNull: false, + defaultValue: 'draft' + }, + metadata: { + type: Sequelize.JSONB, + allowNull: true + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + }, + deleted_at: { + type: Sequelize.DATE, + allowNull: true + } + }); + await queryInterface.addIndex('products', ['code'], { unique: true, name: 'idx_products_code' }); + } + + // Create product_variants table + await queryInterface.createTable('product_variants', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + product_id: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'products', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + sku: { + type: Sequelize.STRING(100), + allowNull: false, + unique: true + }, + name: { + type: Sequelize.STRING(150), + allowNull: false + }, + price: { + type: Sequelize.DECIMAL(12, 2), + allowNull: false, + defaultValue: 0.00 + }, + stock: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0 + }, + status: { + type: Sequelize.STRING(20), + allowNull: false, + defaultValue: 'draft' + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + }, + deleted_at: { + type: Sequelize.DATE, + allowNull: true + } + }); + + // Create variant_values table with composite primary keys + await queryInterface.createTable('variant_values', { + variant_id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + references: { + model: 'product_variants', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + axis_id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + references: { + model: 'attributes', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'RESTRICT' + }, + value_text: { + type: Sequelize.TEXT, + allowNull: false + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + } + }); + + // Create asset_types table + await queryInterface.createTable('asset_types', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + code: { + type: Sequelize.STRING(50), + allowNull: false, + unique: true + }, + name: { + type: Sequelize.STRING(100), + allowNull: false + }, + description: { + type: Sequelize.TEXT, + allowNull: true + }, + status: { + type: Sequelize.STRING(20), + allowNull: false, + defaultValue: 'active' + }, + is_required: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }, + category: { + type: Sequelize.STRING(20), + allowNull: false, + defaultValue: 'other' + }, + validation: { + type: Sequelize.JSONB, + allowNull: true + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + }, + deleted_at: { + type: Sequelize.DATE, + allowNull: true + } + }); + + // Create assets table + await queryInterface.createTable('assets', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + name: { + type: Sequelize.STRING(255), + allowNull: false + }, + file_url: { + type: Sequelize.STRING(500), + allowNull: true + }, + file_size: { + type: Sequelize.INTEGER, + allowNull: true + }, + mime_type: { + type: Sequelize.STRING(100), + allowNull: true + }, + status: { + type: Sequelize.STRING(20), + allowNull: false, + defaultValue: 'active' + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + }, + deleted_at: { + type: Sequelize.DATE, + allowNull: true + } + }); + + // Create family_asset_requirements table with composite primary keys + await queryInterface.createTable('family_asset_requirements', { + family_id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + references: { + model: 'catalogs', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + asset_type_id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + references: { + model: 'asset_types', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'RESTRICT' + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + } + }); + + // Indexes + await queryInterface.addIndex('product_variants', ['sku'], { unique: true, name: 'idx_variants_sku' }); + await queryInterface.addIndex('product_variants', ['product_id'], { name: 'idx_variants_product_id' }); + await queryInterface.addIndex('asset_types', ['code'], { unique: true, name: 'idx_asset_types_code' }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable('family_asset_requirements'); + await queryInterface.dropTable('assets'); + await queryInterface.dropTable('asset_types'); + await queryInterface.dropTable('variant_values'); + await queryInterface.dropTable('product_variants'); + } +}; diff --git a/src/migrations/20260712180000-setup-dam-tables.cjs b/src/migrations/20260712180000-setup-dam-tables.cjs new file mode 100644 index 0000000..4a173d6 --- /dev/null +++ b/src/migrations/20260712180000-setup-dam-tables.cjs @@ -0,0 +1,522 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + // 1. Create asset_folders table + await queryInterface.createTable('asset_folders', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + name: { + type: Sequelize.STRING(100), + allowNull: false + }, + parent_id: { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'asset_folders', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + path: { + type: Sequelize.STRING(500), + allowNull: true + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + }, + deleted_at: { + type: Sequelize.DATE, + allowNull: true + } + }); + + // 2. Create tags table + await queryInterface.createTable('tags', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + name: { + type: Sequelize.STRING(100), + allowNull: false, + unique: true + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + } + }); + + // 3. Alter assets table + // Add columns to existing assets table + await queryInterface.addColumn('assets', 'code', { + type: Sequelize.STRING(100), + allowNull: true, // Allow null initially to avoid breaking existing rows if any + }); + + await queryInterface.addColumn('assets', 'description', { + type: Sequelize.TEXT, + allowNull: true + }); + + await queryInterface.addColumn('assets', 'asset_type_id', { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'asset_types', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL' + }); + + await queryInterface.addColumn('assets', 'file_name', { + type: Sequelize.STRING(255), + allowNull: true + }); + + await queryInterface.addColumn('assets', 'extension', { + type: Sequelize.STRING(20), + allowNull: true + }); + + await queryInterface.addColumn('assets', 'checksum', { + type: Sequelize.STRING(64), + allowNull: true + }); + + await queryInterface.addColumn('assets', 'width', { + type: Sequelize.INTEGER, + allowNull: true + }); + + await queryInterface.addColumn('assets', 'height', { + type: Sequelize.INTEGER, + allowNull: true + }); + + await queryInterface.addColumn('assets', 'duration', { + type: Sequelize.DECIMAL(10, 2), + allowNull: true + }); + + await queryInterface.addColumn('assets', 'page_count', { + type: Sequelize.INTEGER, + allowNull: true + }); + + await queryInterface.addColumn('assets', 'folder_id', { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'asset_folders', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL' + }); + + await queryInterface.addColumn('assets', 'version', { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 1 + }); + + await queryInterface.addColumn('assets', 'created_by', { + type: Sequelize.STRING(100), + allowNull: true + }); + + await queryInterface.addColumn('assets', 'updated_by', { + type: Sequelize.STRING(100), + allowNull: true + }); + + await queryInterface.addColumn('assets', 'deleted_by', { + type: Sequelize.STRING(100), + allowNull: true + }); + + // Add unique constraint index on code if needed + await queryInterface.addIndex('assets', ['code'], { + unique: true, + name: 'idx_assets_code' + }); + + // 4. Create asset_tags table + await queryInterface.createTable('asset_tags', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + asset_id: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'assets', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + tag_id: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'tags', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + } + }); + + // 5. Create product_assets table + await queryInterface.createTable('product_assets', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + product_id: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'products', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + asset_id: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'assets', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + role: { + type: Sequelize.STRING(100), + allowNull: false + }, + display_order: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0 + }, + is_primary: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + } + }); + + // 6. Create variant_assets table + await queryInterface.createTable('variant_assets', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + variant_id: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'product_variants', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + asset_id: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'assets', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + role: { + type: Sequelize.STRING(100), + allowNull: false + }, + display_order: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0 + }, + is_primary: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + } + }); + + // 7. Create family_assets table + await queryInterface.createTable('family_assets', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + family_id: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'catalogs', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + asset_id: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'assets', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + role: { + type: Sequelize.STRING(100), + allowNull: true + }, + display_order: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0 + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + } + }); + + // 8. Create category_assets table + await queryInterface.createTable('category_assets', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + category_id: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'categories', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + asset_id: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'assets', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + role: { + type: Sequelize.STRING(100), + allowNull: true + }, + display_order: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0 + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + } + }); + + // 9. Create channel_assets table + await queryInterface.createTable('channel_assets', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + channel_code: { + type: Sequelize.STRING(50), + allowNull: false, + references: { + model: 'channels', + key: 'code' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + asset_id: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'assets', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + role: { + type: Sequelize.STRING(100), + allowNull: true + }, + display_order: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0 + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + } + }); + + // 10. Create asset_versions table + await queryInterface.createTable('asset_versions', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + asset_id: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'assets', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + version: { + type: Sequelize.INTEGER, + allowNull: false + }, + file_url: { + type: Sequelize.STRING(500), + allowNull: false + }, + uploaded_by: { + type: Sequelize.STRING(100), + allowNull: true + }, + uploaded_at: { + type: Sequelize.DATE, + allowNull: false + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + } + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable('asset_versions'); + await queryInterface.dropTable('channel_assets'); + await queryInterface.dropTable('category_assets'); + await queryInterface.dropTable('family_assets'); + await queryInterface.dropTable('variant_assets'); + await queryInterface.dropTable('product_assets'); + await queryInterface.dropTable('asset_tags'); + + // Remove added columns from assets table + await queryInterface.removeIndex('assets', 'idx_assets_code'); + await queryInterface.removeColumn('assets', 'deleted_by'); + await queryInterface.removeColumn('assets', 'updated_by'); + await queryInterface.removeColumn('assets', 'created_by'); + await queryInterface.removeColumn('assets', 'version'); + await queryInterface.removeColumn('assets', 'folder_id'); + await queryInterface.removeColumn('assets', 'page_count'); + await queryInterface.removeColumn('assets', 'duration'); + await queryInterface.removeColumn('assets', 'height'); + await queryInterface.removeColumn('assets', 'width'); + await queryInterface.removeColumn('assets', 'checksum'); + await queryInterface.removeColumn('assets', 'extension'); + await queryInterface.removeColumn('assets', 'file_name'); + await queryInterface.removeColumn('assets', 'asset_type_id'); + await queryInterface.removeColumn('assets', 'description'); + await queryInterface.removeColumn('assets', 'code'); + + await queryInterface.dropTable('tags'); + await queryInterface.dropTable('asset_folders'); + } +}; diff --git a/src/migrations/20260712190000-setup-missing-tables.cjs b/src/migrations/20260712190000-setup-missing-tables.cjs new file mode 100644 index 0000000..d8d986a --- /dev/null +++ b/src/migrations/20260712190000-setup-missing-tables.cjs @@ -0,0 +1,339 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + // 1. Create channel_types table + const channelTypesExists = await queryInterface.describeTable('channel_types').catch(() => null); + if (!channelTypesExists) { + await queryInterface.createTable('channel_types', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + code: { + type: Sequelize.STRING(50), + allowNull: false, + unique: true + }, + name: { + type: Sequelize.STRING(100), + allowNull: false + }, + description: { + type: Sequelize.TEXT, + allowNull: true + }, + status: { + type: Sequelize.STRING(20), + allowNull: false, + defaultValue: 'active' + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + }, + deleted_at: { + type: Sequelize.DATE, + allowNull: true + } + }); + await queryInterface.addIndex('channel_types', ['code'], { unique: true, name: 'idx_channel_types_code' }); + } + + // 2. Create channels table + const channelsExists = await queryInterface.describeTable('channels').catch(() => null); + if (!channelsExists) { + await queryInterface.createTable('channels', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + type_id: { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'channel_types', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL' + }, + code: { + type: Sequelize.STRING(50), + allowNull: false, + unique: true + }, + name: { + type: Sequelize.STRING(100), + allowNull: false + }, + description: { + type: Sequelize.TEXT, + allowNull: true + }, + status: { + type: Sequelize.STRING(20), + allowNull: false, + defaultValue: 'active' + }, + metadata: { + type: Sequelize.JSONB, + allowNull: true + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + }, + deleted_at: { + type: Sequelize.DATE, + allowNull: true + } + }); + await queryInterface.addIndex('channels', ['code'], { unique: true, name: 'idx_channels_code' }); + } + + // 3. Create workflow_registries table + const workflowExists = await queryInterface.describeTable('workflow_registries').catch(() => null); + if (!workflowExists) { + await queryInterface.createTable('workflow_registries', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + code: { + type: Sequelize.STRING(50), + allowNull: false, + unique: true + }, + name: { + type: Sequelize.STRING(100), + allowNull: false + }, + description: { + type: Sequelize.TEXT, + allowNull: true + }, + category: { + type: Sequelize.STRING(50), + allowNull: false, + defaultValue: 'Standard Approval' + }, + status: { + type: Sequelize.STRING(20), + allowNull: false, + defaultValue: 'active' + }, + stages: { + type: Sequelize.JSONB, + allowNull: true + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + }, + deleted_at: { + type: Sequelize.DATE, + allowNull: true + } + }); + await queryInterface.addIndex('workflow_registries', ['code'], { unique: true, name: 'idx_workflows_code' }); + } + + // 4. Create product_attribute_values table + const prodAttrValuesExists = await queryInterface.describeTable('product_attribute_values').catch(() => null); + if (!prodAttrValuesExists) { + await queryInterface.createTable('product_attribute_values', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + product_id: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'products', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + attribute_id: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'attributes', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + value: { + type: Sequelize.TEXT, + allowNull: true + }, + locale: { + type: Sequelize.STRING(10), + allowNull: true + }, + channel: { + type: Sequelize.STRING(50), + allowNull: true + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + } + }); + await queryInterface.addIndex('product_attribute_values', ['product_id'], { name: 'idx_prod_attr_val_product' }); + await queryInterface.addIndex('product_attribute_values', ['product_id', 'attribute_id', 'locale', 'channel'], { + unique: true, + name: 'idx_prod_attr_val_unique_key' + }); + } + + // 5. Create product_completeness table + const prodCompletenessExists = await queryInterface.describeTable('product_completeness').catch(() => null); + if (!prodCompletenessExists) { + await queryInterface.createTable('product_completeness', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + product_id: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'products', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + channel: { + type: Sequelize.STRING(50), + allowNull: true + }, + locale: { + type: Sequelize.STRING(10), + allowNull: true + }, + percentage: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0 + }, + missing_attributes: { + type: Sequelize.JSONB, + allowNull: true + }, + missing_assets: { + type: Sequelize.JSONB, + allowNull: true + }, + missing_channels: { + type: Sequelize.JSONB, + allowNull: true + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + } + }); + await queryInterface.addIndex('product_completeness', ['product_id', 'channel', 'locale'], { + unique: true, + name: 'idx_prod_completeness_unique_key' + }); + } + + // 6. Alter asset_types table to add deleted_at + const assetTypesColumns = await queryInterface.describeTable('asset_types').catch(() => ({})); + if (!assetTypesColumns.deleted_at) { + await queryInterface.addColumn('asset_types', 'deleted_at', { + type: Sequelize.DATE, + allowNull: true + }); + } + + // 7. Add missing columns to products table if they don't exist + const productsColumns = await queryInterface.describeTable('products').catch(() => ({})); + if (!productsColumns.code) { + await queryInterface.addColumn('products', 'code', { + type: Sequelize.STRING(50), + allowNull: true + }); + await queryInterface.addIndex('products', ['code'], { unique: true, name: 'idx_products_code' }).catch(() => null); + } + if (!productsColumns.category_id) { + await queryInterface.addColumn('products', 'category_id', { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'categories', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'RESTRICT' + }); + } + if (!productsColumns.deleted_at) { + await queryInterface.addColumn('products', 'deleted_at', { + type: Sequelize.DATE, + allowNull: true + }); + } + }, + + down: async (queryInterface, Sequelize) => { + const productsColumns = await queryInterface.describeTable('products').catch(() => ({})); + if (productsColumns.deleted_at) { + await queryInterface.removeColumn('products', 'deleted_at'); + } + if (productsColumns.category_id) { + await queryInterface.removeColumn('products', 'category_id'); + } + if (productsColumns.code) { + await queryInterface.removeColumn('products', 'code'); + } + + const assetTypesColumns = await queryInterface.describeTable('asset_types').catch(() => ({})); + if (assetTypesColumns.deleted_at) { + await queryInterface.removeColumn('asset_types', 'deleted_at'); + } + + await queryInterface.dropTable('product_completeness').catch(() => null); + await queryInterface.dropTable('product_attribute_values').catch(() => null); + await queryInterface.dropTable('workflow_registries').catch(() => null); + await queryInterface.dropTable('channels').catch(() => null); + await queryInterface.dropTable('channel_types').catch(() => null); + } +}; diff --git a/src/seeders/20260712200000-electronics-attributes.cjs b/src/seeders/20260712200000-electronics-attributes.cjs new file mode 100644 index 0000000..be0891d --- /dev/null +++ b/src/seeders/20260712200000-electronics-attributes.cjs @@ -0,0 +1,151 @@ +'use strict'; + +const crypto = require('crypto'); + +module.exports = { + async up(queryInterface, Sequelize) { + // 1. Insert Groups + const groups = [ + { id: 'a1000000-0000-0000-0000-000000000000', code: 'general', name: 'General Information' }, + { id: 'a2000000-0000-0000-0000-000000000000', code: 'technical', name: 'Technical Specifications' }, + { id: 'a3000000-0000-0000-0000-000000000000', code: 'memory', name: 'Memory & Storage' }, + { id: 'a4000000-0000-0000-0000-000000000000', code: 'display', name: 'Display & Screen' }, + { id: 'a5000000-0000-0000-0000-000000000000', code: 'battery', name: 'Battery & Power' }, + { id: 'a6000000-0000-0000-0000-000000000000', code: 'connectivity', name: 'Connectivity' }, + { id: 'a7000000-0000-0000-0000-000000000000', code: 'physical', name: 'Physical Dimensions' }, + { id: 'a8000000-0000-0000-0000-000000000000', code: 'marketing', name: 'Marketing & SEO' }, + { id: 'a9000000-0000-0000-0000-000000000000', code: 'compliance', name: 'Compliance & Warranty' } + ]; + + for (const g of groups) { + await queryInterface.sequelize.query(` + INSERT INTO attribute_groups (id, code, name, status, created_at, updated_at) + VALUES ('${g.id}', '${g.code}', '${g.name}', 'active', NOW(), NOW()) + ON CONFLICT (code) DO UPDATE SET name = '${g.name}', updated_at = NOW(); + `); + } + + // Helper to generate UUID based on code + const getAttrId = (code) => { + const hash = crypto.createHash('sha1').update(code).digest('hex'); + return `${hash.substring(0,8)}-${hash.substring(8,12)}-4${hash.substring(13,16)}-8${hash.substring(17,20)}-${hash.substring(20,32)}`; + }; + + // 2. Define the 41 Attributes + const attributes = [ + // GENERAL + { code: 'brand', name: 'Brand', type: 'select', is_required: true, is_filterable: true, is_searchable: true, options: ['Apple', 'Dell', 'HP', 'Lenovo', 'ASUS', 'MSI', 'Acer', 'Razer'], group: 'general' }, + { code: 'manufacturer', name: 'Manufacturer', type: 'text', is_searchable: true, group: 'general' }, + { code: 'model', name: 'Model', type: 'text', is_required: true, is_searchable: true, group: 'general' }, + { code: 'product_name', name: 'Product Name', type: 'text', is_required: true, is_searchable: true, is_localizable: true, group: 'general' }, + { code: 'short_description', name: 'Short Description', type: 'longText', is_required: true, is_searchable: true, is_localizable: true, is_channel_specific: true, group: 'general' }, + { code: 'long_description', name: 'Long Description', type: 'longText', is_searchable: true, is_localizable: true, is_channel_specific: true, group: 'general' }, + { code: 'launch_date', name: 'Launch Date', type: 'date', is_filterable: true, group: 'general' }, + + // TECHNICAL + { code: 'processor', name: 'Processor', type: 'select', is_required: true, is_filterable: true, is_searchable: true, options: ['Intel Core i9', 'Intel Core i7', 'Intel Core i5', 'AMD Ryzen 9', 'AMD Ryzen 7', 'AMD Ryzen 5', 'Apple M3 Max', 'Apple M3 Pro', 'Apple M3'], group: 'technical' }, + { code: 'graphics', name: 'Graphics', type: 'select', is_required: true, is_filterable: true, is_searchable: true, options: ['NVIDIA RTX 4090', 'NVIDIA RTX 4080', 'NVIDIA RTX 4070', 'NVIDIA RTX 4060', 'AMD Radeon RX 7800M', 'Intel Iris Xe', 'Apple M3 GPU'], group: 'technical' }, + { code: 'chipset', name: 'Chipset', type: 'text', group: 'technical' }, + { code: 'operating_system', name: 'Operating System', type: 'select', is_required: true, is_filterable: true, is_searchable: true, options: ['Windows 11 Pro', 'Windows 11 Home', 'macOS Sonoma', 'Linux Ubuntu', 'ChromeOS', 'No OS'], group: 'technical' }, + { code: 'bios_version', name: 'BIOS Version', type: 'text', group: 'technical' }, + + // MEMORY + { code: 'ram', name: 'RAM Capacity', type: 'select', is_required: true, is_filterable: true, options: ['8GB', '16GB', '32GB', '64GB', '128GB'], group: 'memory' }, + { code: 'storage', name: 'Storage Capacity', type: 'select', is_required: true, is_filterable: true, options: ['256GB', '512GB', '1TB', '2TB', '4TB'], group: 'memory' }, + { code: 'storage_type', name: 'Storage Type', type: 'select', is_filterable: true, options: ['NVMe PCIe Gen 4 SSD', 'NVMe PCIe Gen 5 SSD', 'SATA SSD', 'eMMC'], group: 'memory' }, + + // DISPLAY + { code: 'screen_size', name: 'Screen Size', type: 'decimal', is_required: true, is_filterable: true, group: 'display' }, + { code: 'resolution', name: 'Resolution', type: 'select', is_required: true, is_filterable: true, options: ['1920x1080 (FHD)', '2560x1600 (QHD)', '3840x2160 (4K UHD)', '3456x2234 (Liquid Retina)'], group: 'display' }, + { code: 'brightness', name: 'Screen Brightness', type: 'number', group: 'display' }, + { code: 'refresh_rate', name: 'Refresh Rate', type: 'select', is_filterable: true, options: ['60Hz', '90Hz', '120Hz', '144Hz', '165Hz', '240Hz'], group: 'display' }, + { code: 'touchscreen', name: 'Touchscreen', type: 'boolean', is_required: true, is_filterable: true, group: 'display' }, + + // BATTERY + { code: 'battery_capacity', name: 'Battery Capacity', type: 'number', group: 'battery' }, + { code: 'battery_life', name: 'Battery Life', type: 'decimal', group: 'battery' }, + { code: 'charging_type', name: 'Charging Type', type: 'select', options: ['USB-C Power Delivery', 'Proprietary AC Adapter', 'MagSafe 3'], group: 'battery' }, + + // CONNECTIVITY + { code: 'wifi', name: 'Wi-Fi', type: 'select', is_filterable: true, options: ['Wi-Fi 7 (802.11be)', 'Wi-Fi 6E (802.11ax)', 'Wi-Fi 6 (802.11ax)'], group: 'connectivity' }, + { code: 'bluetooth', name: 'Bluetooth', type: 'select', options: ['Bluetooth 5.4', 'Bluetooth 5.3', 'Bluetooth 5.2'], group: 'connectivity' }, + { code: 'hdmi_ports', name: 'HDMI Ports Count', type: 'number', group: 'connectivity' }, + { code: 'usb_ports', name: 'USB Ports Count', type: 'number', group: 'connectivity' }, + { code: 'ethernet', name: 'Ethernet RJ-45 Port', type: 'boolean', is_filterable: true, group: 'connectivity' }, + + // PHYSICAL + { code: 'weight', name: 'Weight', type: 'decimal', is_filterable: true, group: 'physical' }, + { code: 'height', name: 'Height', type: 'decimal', group: 'physical' }, + { code: 'width', name: 'Width', type: 'decimal', group: 'physical' }, + { code: 'depth', name: 'Depth', type: 'decimal', group: 'physical' }, + + // VARIANTS + { code: 'color', name: 'Color', type: 'color', is_required: true, is_variant_eligible: true, is_filterable: true, group: 'physical' }, + { code: 'ram_variant', name: 'RAM Variant Axis', type: 'select', is_required: true, is_variant_eligible: true, is_filterable: true, options: ['8GB', '16GB', '32GB', '64GB'], group: 'memory' }, + { code: 'storage_variant', name: 'Storage Variant Axis', type: 'select', is_required: true, is_variant_eligible: true, is_filterable: true, options: ['256GB', '512GB', '1TB', '2TB'], group: 'memory' }, + + // COMPLIANCE + { code: 'warranty', name: 'Warranty Period', type: 'select', is_required: true, is_filterable: true, is_localizable: true, options: ['1 Year Limited', '2 Years Limited', '3 Years Limited', '1 Year Onsite'], group: 'compliance' }, + { code: 'country_of_origin', name: 'Country of Origin', type: 'select', is_required: true, is_filterable: true, options: ['China', 'Taiwan', 'Vietnam', 'USA', 'Germany', 'Japan'], group: 'compliance' }, + { code: 'certification', name: 'Certifications', type: 'multiSelect', is_filterable: true, options: ['CE', 'FCC', 'RoHS', 'Energy Star', 'UL Listed', 'EPEAT Gold'], group: 'compliance' }, + + // MARKETING + { code: 'seo_title', name: 'SEO Meta Title', type: 'text', is_localizable: true, is_channel_specific: true, group: 'marketing' }, + { code: 'seo_description', name: 'SEO Meta Description', type: 'longText', is_localizable: true, is_channel_specific: true, group: 'marketing' }, + { code: 'product_url', name: 'Product URL', type: 'url', is_channel_specific: true, group: 'marketing' } + ]; + + for (const attr of attributes) { + let id = getAttrId(attr.code); + + const existing = await queryInterface.sequelize.query( + `SELECT id FROM attributes WHERE code = :code`, + { replacements: { code: attr.code }, type: Sequelize.QueryTypes.SELECT } + ); + if (existing && existing.length > 0) { + id = existing[0].id; + } + + const optionsStr = attr.options ? JSON.stringify(attr.options) : 'NULL'; + + // Upsert Attribute + await queryInterface.sequelize.query(` + INSERT INTO attributes ( + id, code, name, type, is_required, is_unique, is_localizable, + is_variant_eligible, is_searchable, is_filterable, is_channel_specific, + min_length, max_length, status, display_order, options, created_at, updated_at + ) VALUES ( + '${id}', '${attr.code}', '${attr.name.replace(/'/g, "''")}', '${attr.type}', + ${attr.is_required || false}, ${attr.is_unique || false}, ${attr.is_localizable || false}, + ${attr.is_variant_eligible || false}, ${attr.is_searchable || false}, ${attr.is_filterable || false}, + ${attr.is_channel_specific || false}, 0, 255, 'active', ${attributes.indexOf(attr)}, + ${attr.options ? `'${optionsStr}'` : 'NULL'}, NOW(), NOW() + ) + ON CONFLICT (code) DO UPDATE SET + name = '${attr.name.replace(/'/g, "''")}', + type = '${attr.type}', + is_required = ${attr.is_required || false}, + is_variant_eligible = ${attr.is_variant_eligible || false}, + is_filterable = ${attr.is_filterable || false}, + options = ${attr.options ? `'${optionsStr}'` : 'NULL'}, + updated_at = NOW(); + `); + + // Map to Group + const matchedGroup = groups.find(g => g.code === attr.group); + if (matchedGroup) { + await queryInterface.sequelize.query(` + INSERT INTO attribute_group_attributes (group_id, attribute_id, display_order, created_at, updated_at) + VALUES ('${matchedGroup.id}', '${id}', ${attributes.indexOf(attr)}, NOW(), NOW()) + ON CONFLICT (group_id, attribute_id) DO NOTHING; + `); + } + } + }, + + async down(queryInterface, Sequelize) { + await queryInterface.bulkDelete('attribute_group_attributes', null, {}); + await queryInterface.bulkDelete('attributes', null, {}); + await queryInterface.bulkDelete('attribute_groups', null, {}); + } +}; diff --git a/src/shared/config/database.config.cjs b/src/shared/config/database.config.cjs index afc7ccb..c8f7622 100644 --- a/src/shared/config/database.config.cjs +++ b/src/shared/config/database.config.cjs @@ -19,7 +19,7 @@ module.exports = { host: process.env.DB_HOST || "127.0.0.1", port: process.env.DB_PORT || 5432, dialect: process.env.DB_DIALECT || "postgres", - logging: console.log, + logging: false, }, development: { @@ -29,7 +29,7 @@ module.exports = { host: process.env.DB_HOST || "127.0.0.1", port: process.env.DB_PORT || 5432, dialect: process.env.DB_DIALECT || "postgres", - logging: console.log, + logging: false, }, test: { diff --git a/src/shared/config/env.js b/src/shared/config/env.js new file mode 100644 index 0000000..db8fe62 --- /dev/null +++ b/src/shared/config/env.js @@ -0,0 +1,5 @@ +import dotenv from 'dotenv'; + +const env = process.env.NODE_ENV || 'local'; +dotenv.config({ path: `.env.${env}` }); +console.log(`Loaded environment configuration for env: ${env}`); diff --git a/src/shared/config/multer.config.js b/src/shared/config/multer.config.js index 476ce15..5f98525 100644 --- a/src/shared/config/multer.config.js +++ b/src/shared/config/multer.config.js @@ -1,9 +1,15 @@ import multer from 'multer'; import path from 'path'; +import fs from 'fs'; + const storage = multer.diskStorage({ destination: (req, file, cb) => { - cb(null, 'uploads/'); + const dir = 'uploads/'; + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + cb(null, dir); }, filename: (req, file, cb) => { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); diff --git a/src/shared/database/connection.js b/src/shared/database/connection.js index 4e498ef..8b25540 100644 --- a/src/shared/database/connection.js +++ b/src/shared/database/connection.js @@ -22,7 +22,7 @@ export const connectDatabase = async () => { try { // Initialize models initializeDatabaseModels(); - + await sequelize.authenticate(); console.log('Database connection has been established successfully.'); @@ -51,6 +51,52 @@ export const connectDatabase = async () => { } console.log('Permission nodes seeded successfully.'); } + + // Seed Channels if empty + const { Channel } = sequelize.models; + if (Channel) { + const channelCount = await Channel.count(); + if (channelCount === 0) { + const defaultChannels = [ + { name: 'Amazon', code: 'amazon', description: 'Amazon Marketplace', status: 'active' }, + { name: 'Shopify', code: 'shopify', description: 'E-commerce storefront', status: 'active' }, + { name: 'POS', code: 'pos', description: 'Point of Sale systems', status: 'active' }, + { name: 'B2B Portal', code: 'b2b', description: 'Business customers', status: 'active' }, + { name: 'Mobile App', code: 'mobile', description: 'Mobile application', status: 'active' } + ]; + await Channel.bulkCreate(defaultChannels); + console.log('Default channels seeded successfully.'); + } + } + + // Seed AssetTypes if empty or missing + const { AssetType } = sequelize.models; + if (AssetType) { + const defaultAssetTypes = [ + { name: 'Primary Image', code: 'primary_image', description: 'Main product photo', is_required: true, category: 'image', status: 'active', validation: { allowed_extensions: ['jpg', 'jpeg', 'png', 'webp'], max_file_size: 5 * 1024 * 1024 } }, + { name: 'Gallery Image', code: 'gallery_image', description: 'Additional product images', is_required: false, category: 'image', status: 'active', validation: { allowed_extensions: ['jpg', 'jpeg', 'png', 'webp'], max_file_size: 5 * 1024 * 1024 } }, + { name: 'Thumbnail', code: 'thumbnail', description: 'Small preview thumbnail', is_required: false, category: 'image', status: 'active', validation: { allowed_extensions: ['jpg', 'jpeg', 'png', 'webp'], max_file_size: 1 * 1024 * 1024 } }, + { name: 'Hero Image', code: 'hero_image', description: 'Large banners or feature images', is_required: false, category: 'image', status: 'active', validation: { allowed_extensions: ['jpg', 'jpeg', 'png', 'webp'], max_file_size: 10 * 1024 * 1024 } }, + { name: 'Swatch Image', code: 'swatch_image', description: 'Color/texture swatch indicators', is_required: false, category: 'image', status: 'active', validation: { allowed_extensions: ['jpg', 'jpeg', 'png', 'webp'], max_file_size: 1 * 1024 * 1024 } }, + { name: 'Product Video', code: 'product_video', description: 'Demo video', is_required: false, category: 'video', status: 'active', validation: { allowed_extensions: ['mp4', 'webm'], max_file_size: 50 * 1024 * 1024 } }, + { name: 'Marketing Video', code: 'marketing_video', description: 'High definition advertisement video', is_required: false, category: 'video', status: 'active', validation: { allowed_extensions: ['mp4', 'webm'], max_file_size: 100 * 1024 * 1024 } }, + { name: 'Manual', code: 'manual', description: 'Product user manual', is_required: false, category: 'document', status: 'active', validation: { allowed_extensions: ['pdf'], max_file_size: 20 * 1024 * 1024 } }, + { name: 'Certificate', code: 'certificate', description: 'Compliance and safety certificates', is_required: false, category: 'document', status: 'active', validation: { allowed_extensions: ['pdf', 'jpg', 'png'], max_file_size: 10 * 1024 * 1024 } }, + { name: 'Warranty', code: 'warranty', description: 'Warranty documentations', is_required: false, category: 'document', status: 'active', validation: { allowed_extensions: ['pdf', 'jpg', 'png'], max_file_size: 10 * 1024 * 1024 } }, + { name: 'Packaging', code: 'packaging', description: 'Box artwork/label specification sheets', is_required: false, category: 'document', status: 'active', validation: { allowed_extensions: ['pdf', 'jpg', 'png', 'ai', 'eps'], max_file_size: 30 * 1024 * 1024 } }, + { name: 'Technical Sheet', code: 'technical_sheet', description: 'Technical spec sheet', is_required: false, category: 'document', status: 'active', validation: { allowed_extensions: ['pdf', 'xlsx'], max_file_size: 10 * 1024 * 1024 } }, + { name: 'Safety Sheet', code: 'safety_sheet', description: 'Material safety data sheet (MSDS)', is_required: false, category: 'document', status: 'active', validation: { allowed_extensions: ['pdf'], max_file_size: 10 * 1024 * 1024 } }, + { name: '360 Image', code: 'image_360', description: 'Interactive 3D or 360 viewer asset', is_required: false, category: 'image', status: 'active', validation: { allowed_extensions: ['jpg', 'jpeg', 'png', 'webp'], max_file_size: 10 * 1024 * 1024 } } + ]; + + for (const type of defaultAssetTypes) { + await AssetType.findOrCreate({ + where: { code: type.code }, + defaults: type + }); + } + console.log('Default asset types seeded successfully.'); + } } } catch (error) { console.error('Unable to connect to the database:', error); diff --git a/src/shared/database/models.js b/src/shared/database/models.js index a75f5d2..65ee4ae 100644 --- a/src/shared/database/models.js +++ b/src/shared/database/models.js @@ -8,6 +8,7 @@ export const registerModel = (name, modelInit) => { }; export const associateModels = () => { + // Call individual model associate methods Object.keys(models).forEach((modelName) => { if (models[modelName].associate) { models[modelName].associate(models); @@ -54,8 +55,65 @@ export const associateModels = () => { as: 'users' }); } + + // Explicit DAM Associations + if (models.Product && models.Asset && models.ProductAsset) { + models.Product.belongsToMany(models.Asset, { + through: models.ProductAsset, + foreignKey: 'product_id', + otherKey: 'asset_id', + as: 'assets' + }); + models.Product.hasMany(models.ProductAsset, { foreignKey: 'product_id', as: 'productAssets' }); + } + + if (models.Variant && models.Asset && models.VariantAsset) { + models.Variant.belongsToMany(models.Asset, { + through: models.VariantAsset, + foreignKey: 'variant_id', + otherKey: 'asset_id', + as: 'assets' + }); + models.Variant.hasMany(models.VariantAsset, { foreignKey: 'variant_id', as: 'variantAssets' }); + } + + if (models.Catalog && models.Asset && models.FamilyAsset) { + models.Catalog.belongsToMany(models.Asset, { + through: models.FamilyAsset, + foreignKey: 'family_id', + otherKey: 'asset_id', + as: 'assets' + }); + models.Catalog.hasMany(models.FamilyAsset, { foreignKey: 'family_id', as: 'familyAssets' }); + } + + if (models.Categorie && models.Asset && models.CategoryAsset) { + models.Categorie.belongsToMany(models.Asset, { + through: models.CategoryAsset, + foreignKey: 'category_id', + otherKey: 'asset_id', + as: 'assets' + }); + models.Categorie.hasMany(models.CategoryAsset, { foreignKey: 'category_id', as: 'categoryAssets' }); + } + + if (models.Channel && models.Asset && models.ChannelAsset) { + models.Channel.belongsToMany(models.Asset, { + through: models.ChannelAsset, + foreignKey: 'channel_code', + otherKey: 'asset_id', + sourceKey: 'code', + as: 'assets' + }); + models.Channel.hasMany(models.ChannelAsset, { + foreignKey: 'channel_code', + sourceKey: 'code', + as: 'channelAssets' + }); + } }; +// Import model initializations import tenantModelInit from '../../features/organization/org/tenant.model.js'; import userModelInit from '../../features/authentication/users/user.model.js'; import roleModelInit from '../../features/authentication/access/role.model.js'; @@ -68,13 +126,53 @@ import productModelInit from '../../features/products/products/product.model.js' import catalogModelInit from '../../features/catalogs/catalogs/catalog.model.js'; import categorieModelInit from '../../features/categories/categories/categorie.model.js'; import brandModelInit from '../../features/brands/brands/brand.model.js'; -import attributeModelInit from '../../features/attributes/attributes/attribute.model.js'; -import mediaModelInit from '../../features/media/media/media.model.js'; +import unitModelInit from '../../features/brands/units/unit.model.js'; import settingModelInit from '../../features/settings/settings/setting.model.js'; import auditLogModelInit from '../../features/auditLogs/auditLogs/auditLog.model.js'; + +// Attribute Management Models +import attributeModelInit from '../../features/attributes/attributes/attribute.model.js'; +import attributeOptionModelInit from '../../features/attributes/attributes/attributeOption.model.js'; +import attributeHistoryModelInit from '../../features/attributes/attributes/attributeHistory.model.js'; +import attributeGroupModelInit from '../../features/attributes/attributeGroups/attributeGroup.model.js'; +import attributeGroupHistoryModelInit from '../../features/attributes/attributeGroups/attributeGroupHistory.model.js'; +import attributeGroupAttributeModelInit from '../../features/attributes/attributeGroups/attributeGroupAttribute.model.js'; +import attributeSetModelInit from '../../features/attributes/attributeSets/attributeSet.model.js'; +import attributeSetHistoryModelInit from '../../features/attributes/attributeSets/attributeSetHistory.model.js'; +import attributeSetGroupModelInit from '../../features/attributes/attributeSets/attributeSetGroup.model.js'; + +// Product Family Bridge Models +import familyAttributeModelInit from '../../features/catalogs/catalogs/familyAttribute.model.js'; +import familyVariantAxisModelInit from '../../features/catalogs/catalogs/familyVariantAxis.model.js'; +import familyAssetRequirementModelInit from '../../features/catalogs/catalogs/familyAssetRequirement.model.js'; +import familyChannelModelInit from '../../features/catalogs/catalogs/familyChannel.model.js'; + +// Variant Management Models +import variantModelInit from '../../features/variants/variants/variant.model.js'; +import variantValueModelInit from '../../features/variants/variants/variantValue.model.js'; + +// Asset Management Models import assetTypeModelInit from '../../features/media/assetTypes/assetType.model.js'; import assetFamilyModelInit from '../../features/media/assetFamilies/assetFamily.model.js'; import assetModelInit from '../../features/media/assets/asset.model.js'; +import assetFolderModelInit from '../../features/media/assets/assetFolder.model.js'; +import tagModelInit from '../../features/media/assets/tag.model.js'; +import assetTagModelInit from '../../features/media/assets/assetTag.model.js'; +import assetVersionModelInit from '../../features/media/assets/assetVersion.model.js'; + +// Product/Variant Bridge DAM Models +import productAssetModelInit from '../../features/products/products/productAsset.model.js'; +import variantAssetModelInit from '../../features/variants/variants/variantAsset.model.js'; +import familyAssetModelInit from '../../features/catalogs/catalogs/familyAsset.model.js'; +import categoryAssetModelInit from '../../features/categories/categories/categoryAsset.model.js'; +import channelAssetModelInit from '../../features/channels/channels/channelAsset.model.js'; + +// Channel Management Model +import channelModelInit from '../../features/channels/channels/channel.model.js'; +import channelTypeModelInit from '../../features/channels/channelTypes/channelType.model.js'; +import workflowModelInit from '../../features/workflows/workflow.model.js'; +import productAttributeValueModelInit from '../../features/products/products/productAttributeValue.model.js'; +import productCompletenessModelInit from '../../features/products/products/productCompleteness.model.js'; export const initializeDatabaseModels = () => { registerModel('Tenant', tenantModelInit); @@ -89,13 +187,54 @@ export const initializeDatabaseModels = () => { registerModel('Catalog', catalogModelInit); registerModel('Categorie', categorieModelInit); registerModel('Brand', brandModelInit); - registerModel('Attribute', attributeModelInit); - registerModel('Media', mediaModelInit); + registerModel('Unit', unitModelInit); registerModel('Setting', settingModelInit); registerModel('AuditLog', auditLogModelInit); + + // Attribute Management + registerModel('Attribute', attributeModelInit); + registerModel('AttributeOption', attributeOptionModelInit); + registerModel('AttributeHistory', attributeHistoryModelInit); + registerModel('AttributeGroup', attributeGroupModelInit); + registerModel('AttributeGroupHistory', attributeGroupHistoryModelInit); + registerModel('AttributeGroupAttribute', attributeGroupAttributeModelInit); + registerModel('AttributeSet', attributeSetModelInit); + registerModel('AttributeSetHistory', attributeSetHistoryModelInit); + registerModel('AttributeSetGroup', attributeSetGroupModelInit); + + // Product Family Bridges + registerModel('FamilyAttribute', familyAttributeModelInit); + registerModel('FamilyVariantAxis', familyVariantAxisModelInit); + registerModel('FamilyAssetRequirement', familyAssetRequirementModelInit); + registerModel('FamilyChannel', familyChannelModelInit); + + // Channels + registerModel('Channel', channelModelInit); + registerModel('ChannelType', channelTypeModelInit); + registerModel('WorkflowRegistry', workflowModelInit); + registerModel('ProductAttributeValue', productAttributeValueModelInit); + registerModel('ProductCompleteness', productCompletenessModelInit); + + // Variants + registerModel('Variant', variantModelInit); + registerModel('VariantValue', variantValueModelInit); + + // Assets registerModel('AssetType', assetTypeModelInit); registerModel('AssetFamily', assetFamilyModelInit); registerModel('Asset', assetModelInit); + registerModel('AssetFolder', assetFolderModelInit); + registerModel('Tag', tagModelInit); + registerModel('AssetTag', assetTagModelInit); + registerModel('AssetVersion', assetVersionModelInit); + + // Bridges + registerModel('ProductAsset', productAssetModelInit); + registerModel('VariantAsset', variantAssetModelInit); + registerModel('FamilyAsset', familyAssetModelInit); + registerModel('CategoryAsset', categoryAssetModelInit); + registerModel('ChannelAsset', channelAssetModelInit); + associateModels(); }; diff --git a/src/shared/utils/metadataExtractor.js b/src/shared/utils/metadataExtractor.js new file mode 100644 index 0000000..106e95d --- /dev/null +++ b/src/shared/utils/metadataExtractor.js @@ -0,0 +1,126 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; + +export const getFileChecksum = (filePath) => { + const fileBuffer = fs.readFileSync(filePath); + const hash = crypto.createHash('md5'); + hash.update(fileBuffer); + return hash.digest('hex'); +}; + +export const getImageSize = (filePath) => { + try { + const buffer = fs.readFileSync(filePath); + + // 1. PNG + if (buffer.length >= 24 && buffer.readUInt32BE(0) === 0x89504E47) { + if (buffer.toString('ascii', 12, 16) === 'IHDR') { + return { + width: buffer.readUInt32BE(16), + height: buffer.readUInt32BE(20) + }; + } + } + + // 2. GIF + if (buffer.length >= 10) { + const sig = buffer.toString('ascii', 0, 6); + if (sig === 'GIF87a' || sig === 'GIF89a') { + return { + width: buffer.readUInt16LE(6), + height: buffer.readUInt16LE(8) + }; + } + } + + // 3. JPEG + if (buffer.length >= 4 && buffer[0] === 0xFF && buffer[1] === 0xD8) { + let offset = 2; + while (offset < buffer.length) { + if (buffer[offset] !== 0xFF) { + break; // Invalid JPEG marker + } + while (buffer[offset] === 0xFF) { + offset++; + } + const marker = buffer[offset]; + offset++; + + if (marker === 0xD9) break; // EOI + + if (offset + 2 > buffer.length) break; + const length = buffer.readUInt16BE(offset); + + // SOF0 through SOF15, excluding DHT (0xC4), SOF8 (0xC8), SOF12 (0xCC) + const isSOF = (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC); + if (isSOF) { + if (offset + 7 <= buffer.length) { + const height = buffer.readUInt16BE(offset + 3); + const width = buffer.readUInt16BE(offset + 5); + return { width, height }; + } + } + offset += length; + } + } + + // 4. WEBP + if (buffer.length >= 30 && buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP') { + const chunkType = buffer.toString('ascii', 12, 16); + if (chunkType === 'VP8 ') { + // Lossy WebP: check signature 9d 01 2a at offset 23 + if (buffer[23] === 0x9d && buffer[24] === 0x01 && buffer[25] === 0x2a) { + const width = buffer.readUInt16LE(26) & 0x3FFF; + const height = buffer.readUInt16LE(28) & 0x3FFF; + return { width, height }; + } + } else if (chunkType === 'VP8L') { + // Lossless WebP: signature 0x2f at offset 20 + if (buffer[20] === 0x2f) { + const b0 = buffer[21]; + const b1 = buffer[22]; + const b2 = buffer[23]; + const b3 = buffer[24]; + const width = 1 + (((b1 & 0x3F) << 8) | b0); + const height = 1 + (((b3 & 0xF) << 10) | (b2 << 2) | ((b1 & 0xC0) >> 6)); + return { width, height }; + } + } else if (chunkType === 'VP8X') { + // Extended WebP + const width = 1 + (buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)); + const height = 1 + (buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)); + return { width, height }; + } + } + } catch (error) { + console.error('Failed to extract image dimensions:', error); + } + + return { width: null, height: null }; +}; + +export const extractMetadata = (filePath) => { + const stats = fs.statSync(filePath); + const ext = path.extname(filePath).toLowerCase().replace('.', ''); + const checksum = getFileChecksum(filePath); + + let width = null; + let height = null; + const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp']; + if (imageExtensions.includes(ext)) { + const size = getImageSize(filePath); + width = size.width; + height = size.height; + } + + return { + file_size: stats.size, + extension: ext, + checksum, + width, + height, + duration: null, + page_count: ext === 'pdf' ? 1 : null // Mock or basic page count for PDF + }; +}; diff --git a/uploads/file-1783922723193-381594002.png b/uploads/file-1783922723193-381594002.png new file mode 100644 index 0000000..e89e012 Binary files /dev/null and b/uploads/file-1783922723193-381594002.png differ diff --git a/uploads/file-1783922747703-841221640.png b/uploads/file-1783922747703-841221640.png new file mode 100644 index 0000000..e89e012 Binary files /dev/null and b/uploads/file-1783922747703-841221640.png differ diff --git a/uploads/file-1783925930058-310887255.png b/uploads/file-1783925930058-310887255.png new file mode 100644 index 0000000..651a304 Binary files /dev/null and b/uploads/file-1783925930058-310887255.png differ