From 644b828dea5af7a93cf82f9c53fe8c379bc18bf5 Mon Sep 17 00:00:00 2001 From: m7338ohd-debug Date: Mon, 13 Jul 2026 17:49:01 +0530 Subject: [PATCH] Implemented backend for attribute managemnet --- app.js | 1 + index.js | 5 +- .../attributeGroup.controller.js | 50 ++ .../attributeGroups/attributeGroup.model.js | 54 ++ .../attributeGroup.repository.js | 58 ++ .../attributeGroups/attributeGroup.routes.js | 124 +++ .../attributeGroups/attributeGroup.service.js | 190 +++++ .../attributeGroup.validation.js | 54 ++ .../attributeGroupAttribute.model.js | 33 + .../attributeGroupHistory.model.js | 39 + .../attributeSets/attributeSet.controller.js | 50 ++ .../attributeSets/attributeSet.model.js | 51 ++ .../attributeSets/attributeSet.repository.js | 72 ++ .../attributeSets/attributeSet.routes.js | 133 +++ .../attributeSets/attributeSet.service.js | 184 +++++ .../attributeSets/attributeSet.validation.js | 54 ++ .../attributeSets/attributeSetGroup.model.js | 33 + .../attributeSetHistory.model.js | 39 + .../attributes/attribute.controller.js | 116 ++- .../attributes/attributes/attribute.model.js | 147 +++- .../attributes/attributes/attribute.routes.js | 10 + .../attributes/attribute.service.js | 454 ++++++++++- .../attributes/attribute.validation.js | 42 +- .../attributes/attributeHistory.model.js | 39 + .../attributes/attributeOption.model.js | 50 ++ src/features/attributes/index.js | 4 + .../brands/brands/brand.controller.js | 18 + src/features/brands/brands/brand.model.js | 29 +- src/features/brands/brands/brand.routes.js | 20 + src/features/brands/brands/brand.service.js | 94 ++- .../brands/brands/brand.validation.js | 41 +- src/features/brands/index.js | 2 + src/features/brands/units/unit.controller.js | 68 ++ src/features/brands/units/unit.model.js | 64 ++ src/features/brands/units/unit.repository.js | 30 + src/features/brands/units/unit.routes.js | 144 ++++ src/features/brands/units/unit.service.js | 143 ++++ src/features/brands/units/unit.validation.js | 92 +++ .../catalogs/catalogs/catalog.controller.js | 149 +++- .../catalogs/catalogs/catalog.model.js | 76 +- .../catalogs/catalogs/catalog.repository.js | 86 +- .../catalogs/catalogs/catalog.routes.js | 42 + .../catalogs/catalogs/catalog.service.js | 762 +++++++++++++++++- .../catalogs/catalogs/catalog.validation.js | 69 +- .../catalogs/catalogs/familyAsset.model.js | 43 + .../catalogs/familyAssetRequirement.model.js | 28 + .../catalogs/familyAttribute.model.js | 33 + .../catalogs/catalogs/familyChannel.model.js | 28 + .../catalogs/familyVariantAxis.model.js | 28 + src/features/catalogs/index.js | 2 +- .../categories/categorie.controller.js | 35 +- .../categories/categories/categorie.model.js | 46 +- .../categories/categorie.repository.js | 48 +- .../categories/categories/categorie.routes.js | 20 + .../categories/categorie.service.js | 331 +++++++- .../categories/categorie.validation.js | 29 +- .../categories/categoryAsset.model.js | 43 + .../channelTypes/channelType.controller.js | 68 ++ .../channelTypes/channelType.model.js | 48 ++ .../channelTypes/channelType.repository.js | 30 + .../channelTypes/channelType.routes.js | 82 ++ .../channelTypes/channelType.service.js | 156 ++++ .../channelTypes/channelType.validation.js | 45 ++ .../channels/channels/channel.controller.js | 18 + .../channels/channels/channel.model.js | 24 +- .../channels/channels/channel.routes.js | 20 + .../channels/channels/channel.service.js | 65 +- .../channels/channels/channelAsset.model.js | 43 + src/features/channels/index.js | 2 + src/features/index.js | 4 + .../media/assetTypes/assetType.service.js | 45 +- src/features/media/assets/asset.controller.js | 106 ++- src/features/media/assets/asset.model.js | 127 ++- src/features/media/assets/asset.routes.js | 181 ++--- src/features/media/assets/asset.service.js | 475 ++++++++++- .../media/assets/assetFolder.model.js | 50 ++ src/features/media/assets/assetTag.model.js | 35 + .../media/assets/assetVersion.model.js | 46 ++ src/features/media/assets/tag.model.js | 36 + .../products/products/completeness.service.js | 155 ++++ .../products/products/product.controller.js | 54 ++ .../products/products/product.model.js | 62 +- .../products/products/product.repository.js | 122 ++- .../products/products/product.routes.js | 48 ++ .../products/products/product.service.js | 568 ++++++++++++- .../products/products/product.validation.js | 44 +- .../products/products/productAsset.model.js | 47 ++ .../products/productAttributeValue.model.js | 53 ++ .../products/productCompleteness.model.js | 58 ++ src/features/variants/index.js | 8 + .../variants/variants/variant.controller.js | 180 +++++ .../variants/variants/variant.model.js | 96 +++ .../variants/variants/variant.repository.js | 86 ++ .../variants/variants/variant.routes.js | 200 +++++ .../variants/variants/variant.service.js | 578 +++++++++++++ .../variants/variants/variant.validation.js | 71 ++ .../variants/variants/variantAsset.model.js | 47 ++ .../variants/variants/variantValue.model.js | 43 + src/features/workflows/workflow.controller.js | 103 +++ src/features/workflows/workflow.model.js | 54 ++ src/features/workflows/workflow.repository.js | 30 + src/features/workflows/workflow.routes.js | 65 ++ src/features/workflows/workflow.service.js | 170 ++++ src/features/workflows/workflow.validation.js | 45 ++ ...20260709000001-create-brands-and-units.cjs | 118 +++ ...9000002-create-categories-and-families.cjs | 133 +++ ...709000003-create-attributes-and-groups.cjs | 388 +++++++++ ...60709000004-create-variants-and-assets.cjs | 327 ++++++++ .../20260712180000-setup-dam-tables.cjs | 522 ++++++++++++ .../20260712190000-setup-missing-tables.cjs | 339 ++++++++ .../20260712200000-electronics-attributes.cjs | 151 ++++ src/shared/config/database.config.cjs | 4 +- src/shared/config/env.js | 5 + src/shared/config/multer.config.js | 8 +- src/shared/database/connection.js | 48 +- src/shared/database/models.js | 147 +++- src/shared/utils/metadataExtractor.js | 126 +++ uploads/file-1783922723193-381594002.png | Bin 0 -> 1875990 bytes uploads/file-1783922747703-841221640.png | Bin 0 -> 1875990 bytes uploads/file-1783925930058-310887255.png | Bin 0 -> 2162114 bytes 120 files changed, 11322 insertions(+), 416 deletions(-) create mode 100644 src/features/attributes/attributeGroups/attributeGroup.controller.js create mode 100644 src/features/attributes/attributeGroups/attributeGroup.model.js create mode 100644 src/features/attributes/attributeGroups/attributeGroup.repository.js create mode 100644 src/features/attributes/attributeGroups/attributeGroup.routes.js create mode 100644 src/features/attributes/attributeGroups/attributeGroup.service.js create mode 100644 src/features/attributes/attributeGroups/attributeGroup.validation.js create mode 100644 src/features/attributes/attributeGroups/attributeGroupAttribute.model.js create mode 100644 src/features/attributes/attributeGroups/attributeGroupHistory.model.js create mode 100644 src/features/attributes/attributeSets/attributeSet.controller.js create mode 100644 src/features/attributes/attributeSets/attributeSet.model.js create mode 100644 src/features/attributes/attributeSets/attributeSet.repository.js create mode 100644 src/features/attributes/attributeSets/attributeSet.routes.js create mode 100644 src/features/attributes/attributeSets/attributeSet.service.js create mode 100644 src/features/attributes/attributeSets/attributeSet.validation.js create mode 100644 src/features/attributes/attributeSets/attributeSetGroup.model.js create mode 100644 src/features/attributes/attributeSets/attributeSetHistory.model.js create mode 100644 src/features/attributes/attributes/attributeHistory.model.js create mode 100644 src/features/attributes/attributes/attributeOption.model.js create mode 100644 src/features/brands/units/unit.controller.js create mode 100644 src/features/brands/units/unit.model.js create mode 100644 src/features/brands/units/unit.repository.js create mode 100644 src/features/brands/units/unit.routes.js create mode 100644 src/features/brands/units/unit.service.js create mode 100644 src/features/brands/units/unit.validation.js create mode 100644 src/features/catalogs/catalogs/familyAsset.model.js create mode 100644 src/features/catalogs/catalogs/familyAssetRequirement.model.js create mode 100644 src/features/catalogs/catalogs/familyAttribute.model.js create mode 100644 src/features/catalogs/catalogs/familyChannel.model.js create mode 100644 src/features/catalogs/catalogs/familyVariantAxis.model.js create mode 100644 src/features/categories/categories/categoryAsset.model.js create mode 100644 src/features/channels/channelTypes/channelType.controller.js create mode 100644 src/features/channels/channelTypes/channelType.model.js create mode 100644 src/features/channels/channelTypes/channelType.repository.js create mode 100644 src/features/channels/channelTypes/channelType.routes.js create mode 100644 src/features/channels/channelTypes/channelType.service.js create mode 100644 src/features/channels/channelTypes/channelType.validation.js create mode 100644 src/features/channels/channels/channelAsset.model.js create mode 100644 src/features/media/assets/assetFolder.model.js create mode 100644 src/features/media/assets/assetTag.model.js create mode 100644 src/features/media/assets/assetVersion.model.js create mode 100644 src/features/media/assets/tag.model.js create mode 100644 src/features/products/products/completeness.service.js create mode 100644 src/features/products/products/productAsset.model.js create mode 100644 src/features/products/products/productAttributeValue.model.js create mode 100644 src/features/products/products/productCompleteness.model.js create mode 100644 src/features/variants/index.js create mode 100644 src/features/variants/variants/variant.controller.js create mode 100644 src/features/variants/variants/variant.model.js create mode 100644 src/features/variants/variants/variant.repository.js create mode 100644 src/features/variants/variants/variant.routes.js create mode 100644 src/features/variants/variants/variant.service.js create mode 100644 src/features/variants/variants/variant.validation.js create mode 100644 src/features/variants/variants/variantAsset.model.js create mode 100644 src/features/variants/variants/variantValue.model.js create mode 100644 src/features/workflows/workflow.controller.js create mode 100644 src/features/workflows/workflow.model.js create mode 100644 src/features/workflows/workflow.repository.js create mode 100644 src/features/workflows/workflow.routes.js create mode 100644 src/features/workflows/workflow.service.js create mode 100644 src/features/workflows/workflow.validation.js create mode 100644 src/migrations/20260709000001-create-brands-and-units.cjs create mode 100644 src/migrations/20260709000002-create-categories-and-families.cjs create mode 100644 src/migrations/20260709000003-create-attributes-and-groups.cjs create mode 100644 src/migrations/20260709000004-create-variants-and-assets.cjs create mode 100644 src/migrations/20260712180000-setup-dam-tables.cjs create mode 100644 src/migrations/20260712190000-setup-missing-tables.cjs create mode 100644 src/seeders/20260712200000-electronics-attributes.cjs create mode 100644 src/shared/config/env.js create mode 100644 src/shared/utils/metadataExtractor.js create mode 100644 uploads/file-1783922723193-381594002.png create mode 100644 uploads/file-1783922747703-841221640.png create mode 100644 uploads/file-1783925930058-310887255.png 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 0000000000000000000000000000000000000000..e89e0127827c050c27bb88611d91d91cda007a40 GIT binary patch literal 1875990 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 C)0(0o{*bp6 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�N z;3hEip$CYjP$;Ej-9mVn^CMw(S#Uk@wF(=tRMM9!XoZ}$>`E-th*pX?43@zCY8}f3 ziZ=z>l?kVbMP$zDegq|<2=4?AGoU#j7MF!`c9|bHlgTB==hdKxQ_0FgQiWpTD3&%T zEY+)WsY#YFt?8IqEOeV1nDuP9@=>249Iq6Rv;~@wQ3);Dw9;(x+s>F7LMVdJ2wbPE zx92op5pa1h$j)CGB}#?Unj{$Vux9Fo=}MWfdaPtc(i%}5 z&J@Qk(Q$-*zz3X|txulVJc(P8w?CvdLdudWr=wpSpj*86Xc`0hn;7wChE;EBf za4BjA6~B}P;)=-y_&)%VhoZh)C|^xEzIGc&QwET&g<=9mEGJ&TR2SZZwav0hn2m8% zwGx|%W@}jrhQp+oT~ay+-k3t&%jp%+B?dgy)!ScY=j6=iy91fqe!j&;B(`|z2eD|4 zpOMYjuL;oz)yWbO;%d33_p+Y*fB1tlHcWyThEpWj(N5PNjH5)?icBV4HlS>7Za3)E&rXbyucucAss(b^la6p33-Fyxrl z`_`V~2%gl+M(oq8Ffn~GL zV6lWN>y(A&#}q?@+l^8E~86S`*hb;6un zoE&RzZf=z*R}2iIA?AhR$JUfqWS8!&=58VAAu-luunzJWrL@`+;2k806Px(L(X0C@ z(qiqc2nruIR~~SqGQ?$XG1gpT#+^nomXwka4slnU72@1pu&%~EA_kQ5FxEua@UWBl zTq`k3mQ+&*6 zZTXVL&DIJ?RON;11-D@2i@9S=UD<++*|@(VI0(3z)MPx*XqBYYL|TvsLy$tE>Z8Bf zln@1zdGbX#iO^-0Fjw+j)8m2pf=k50kA*zYoFF|AiewnYmt+hOs1S5S+OW%6##$Xi zaD}WBE(?Dj&74NTf}X;ZL$y6-Q-K@6MCC)!{=n|#W0H8c) zyM^43Ox((u2}(l>p2<6WGKvd956wW?b`22NDgiwvGxVtd#G1Us!>Uo7>JW6QVgfty zNt9(|rc1)~+^{_@qqs$VZ4$F{v7%eyp9_aV#uQry!;HV!S&v;Uj(!nux8v%gSDtgl z`a#4o9wZ>6HR1;sssb%hVsxjpR~B)c-GvHsBAv)V<-(as1l*hrSh4>b{^hv*a82OZ zM6(goM3J9PGz#G{^vC)5i_h1ZzhdqAG#WWki+-cBsr}L=8K`T&7vgC$bZ;qZ%Mr(%Q-O zx&gj%qA<892+6BT5uR|1#o|}Q(sB3l7R1LR`fBp*unkGg(v+?BO_x9IGlzVJ_?W2$ zWUU19K@NiVC@&$(MyaW{Z=fvrDJ_aR&0q~4nT2|oSxJ+{I_=g9?KU;W&R_s>!t6{! zlAcVFn-XWkQEHk@82_LEu_);U_E4hl`L1isAb^dPFmk{KpcGM0$B9{;mp`iZ+UoBu zJr$o{D_6%hJUkq+)R)~$CmSC~j6Pa(Rrkup%i?o*pbFLHp?u+xY4Ng0lgl45Z*2efj;nf^;+Jnp9>1ZZb6|7Z+sOQLR!zjWR0RXA<%aOOV76B0KMns#{=|$l% zzC@#|jk-j_v1k(UqL>mfhiLN<{K&-tZq%oVPixfUs3(c^DYIZ&suKSPry&H=rF~8W z;4Q>na;DI7GD4{`RCFZ8XvJqAIO&9xp_f$h8zeJQIq-PHYvx;AucVe3$+}=0BXxmq z(L(nch2}#j=%lHIJ}&2!MCnjk;Tc6Mprc}O?ys5x(%C~`5zr%UjcPT^`fuX6Lmg=s zY2T+tJliiLGHfeucU+^RBC$c@hMt1*j5!b$<{qIV;6JM`Y2iM%>L*1_0MR(uPsJRZ zS~tJO;2Vl;--1J?VdIHfM%?Y|4;UBeUgTGNbDWUq>2pjH&D;#$gO?7I0fp95go_`o zfYudOcz>j1f&$;{_yIZqz93ZH_;YkGW> zw!MB%rA1aPRBP$TIlvJU)}C&w;p&h_8f|u*Yft%Le@n&W*ibXcz6n@gAVnP*b~X62vM?v}98aRFlk@h`+(1ql8ZR z?IOH`W7EgHPt1R$`*+L!{5$)6+lp-zp%ttf4mVwSy>WP`=>9CDcs!T>xo8dQga@GS z@F8plnSxI(uG(NTxz<~4Q?uDbZT8jTp`LtuK;aOX&dz{59zkHfQ%Vm+#)wH9>?UEk zU{|+qWV*ohDY9HLq--=wO~g=cT318Qz^S;=i%I=nK#QWZ53hiX4@t(D#i4d=8%ciW zjCK=SHprK1IVt@i-Qc`TMgQQ@JAsrXFOwdU*LlN+1|!oaX)2|%*#pWaMk@e@F8IEN zj*rwB4)dO*nej$+PpUwbb&SBneW3f_|Zm>ID*dA85)5y2FJzkG0bhHeKq=0OHU9!9AFh(+p3glE@; z>JX(gQ6?M`UXV`%nMq)2gue#J?$EYl*@QRVdLF1NE*{u+m;t0DJP3FVrL-UNhnt{4 zpgH%nUk7$Xm&r^hslr9AjS&_Dql7YBZfWAXu_SW-emm7R5$o_@uD<&8XRbYc?Ek76 zgtCV#10BMcK@1$LFA-73WCxnFeFAFaUd*8Sv{;96seGh{Q92q3J?CaPS_x*#I)u-3 zeKkk0%4sg9x-_|9Y$|-61_mEWi3rR|LD4mo5b7f{m2`p@F=(2B-px^~8kZ4l?OoGP z%`({}^;uRP`h)n%0m9GT@D|C%{A zBGVModMk`Ax@&R95Nk@bWW-gd$Dg6V!w;gE;4+2i z;~%$P3DjV7mz;o+k|)GZz=(?|=PnB_a2sYbkG|4rR># z=O-0tQB6EPh12wU0H1P5x^U^;T{Y?k!I8fS()y1}-&;4c3Ul*VLUbp?dy;O!pT@BL?1&fDt>lAx2>-W8} z;t%uEZW&Ywg>u$U5-Iz`y$Y_9j7`m1&dWN!7y9X~lM`ODl-zDF0aYBH+ASpnnGk;qn9I*{~0kmOsT^S0>QEv32-rXg3n^GNhS~4$%y( zYsNwKTALhE0|*M64dZt^*;SO*ej_Pm5zEPId}C&So#iyP0AshG{l2dYD@l)#eCPrQ zN=PYEGW`ZZK4Nb0GF2!ji$W!cA19GoONzWm-R7Of3~dYQHdyI=X59-Ji4Qc)X~cfk zrO+0XNgDGMMMz*%%o}`NNM~e5v0^=-jMX$E0iZi*MB=U^cR(`&sv$arDevB_#&Ki2 zzIEXbncBL1$IW z7a^2A>LFy&r0rf{j$wJAH4yw6$4XX^xcfN#OA(MH=dpQBL}T+?37}tCdYo*ffIQ$` ztT|qkR`t=6%HJb^u54eIPdJJiGk$aS4HOyKt_-axAu~r?3W1~4VdI@tY>$UQ{u~aw+3i+z zEu~4&D+@w?vrU*j53NWzWD$!Ino0~+QPFet(-<8CO`(9pfYktbO=Z*=8I3P?_@+*} z?2+!pJ9RBun;4sa@u8j@dq$hy`c>0s;D9b;(3wrdt*rsoC=yQmI`RYmAc2`6+cq`K z=S*${6cKMD35}V7ZW}LA+Nb-_o+#8PeVz&!wZv^1!z`j@;_NeG z=VwZGC$y5m*XnG0c!6bFX#LE^8=shVRr3J=h+6P3zm761-FJw>5_gFR(UBFF>>Ti7 z$zzU&l-}gF6)mBDbX6=V@B*`mhZde9x7iXKnQE_`*zsy>Z!#A1*fuXf3!+ft%S4VQ zGGOos@igl#NHSAWrpp=#I*ORU3y4<4iq-`Uwtb^)GuNUGBq-LieT$|^=N4s%OB(5k z+YwJm$s~6}FYG_9do80VxDO#qWn40><` zaAU~82iOqZ$9qVi4_K8CGY*k0$jJ&A?BF{@fHUBr$>$793@f3*AsrVWC}N5kLFLxy zSl(w>6TA>iog$Ik6%{79&RkODs0R2@q=Ya?qK1!-!On>YU+hKv5Il6&q8<2=|1TWg zSTkz4jpX&>U^Zh?-^B626Ny>|FdPX9VGo`-(mZA{eg(NMX)$s-q-+2J9zt7~6|2=W z@ES@- zMds*<8r8IqEgx`VP&o6R2@Ez5^Y@YUvB-+DdMW=>PJ2>;ng{g~9Ukq8-~FR${7HWM zLWQ1O-R>77zGmScAn|WOh$!nzp&xzyV$f~_$gGNfe$MtypM5|$wosXHTM|_vB-~t^ zI-3P4QJfTGM;4Jt5Ex=YP!)Ztm9$+HV8|4R9{`_kW_XmD#XFWaVYt9uQ!@x^E>1;e zu`#5R2){QSFhaPYec(!D%C)!RsIxGm6$oub990%(X?EhdIB!^JNLyt;q#>7)K&@M8O zcSDO4cUq4FC8iZhk-d9HNvHBTnu8o{r~||p$~}paZ7Sv%DG28wi37yII_gsY+5=ZH+?%MIA)hVw+lpijnzkRa$|fXuLAMLFe(-^`hF z&56Y;I|wq&C0%1Zqn{>j?Ai3jYf8t1y@}E8B#pqksu4 zU56+h#EM(hO30Lm-SO2RGLXjdg^G~Lf}+ zMXz?x1wH*sA4~Q;nFu>Vmzx!5?W;}E37Z#8-tu_;+{|lQH0DVq$I8XV;?i^Ob^P_R z*A@)dtsWiVRGn5vJdrx#p4@%DL+UDQ*c{pUS64I#JYFXX+pf zd=-#DC32z3<>KGZSgUcMXrNCtg(||0Yt47YIi@5<7a|purdzoHx{zdZ`w+`bU7{W@ zwbs?Y{>+f^ucB6rUM^i}W@GR-(RFyN!4zh!8x~PDf!SO6iP3{@rCprB^x3D6{{l%U zPh=-tba3{BHJ1RWQXkJWMkY8LHI&N(A1zyR2@94;4~+<79TbsB-Pv~?rG&>J_L>^n z64mInUK&?p?6eH)^Z3^83JeVeB(`9Ng)arwhNf&h;3SStu-})F^020z&=R0WOgibc zXQ90a&)%vG!~C;y)f}M_-c-VG4Yf-$1J?wlhszxdv~CeL%uWKXoLNUG5)G>i^audL z^wU6Ob>(cdsrcOO%Ds|8KV8_|uyju^dvJX?6q2U~IG6Y=WpkZoUl_XEHs1{ivv=kw z+l)xpHW*d8Rk?3L2I|}5ar1#|*?KF}eu@%_`wxKjUAeP+;j@cxc%mMO;;Qd|wmK|4_weMNKshot*gT{ipE=y|^gJ`(pQuE!I^99h=F|}y&j4)&GD6ETE^Lv zCOe9l+fh*?mDz<@VIG|zKrkT=W$;DOgTS6v6Gg5o?U2rETl&W1*D6nif)_p3ylnSN zR~_0s=P$o)U%hXGZTF6vKW~;-{c7H5>e9-Noj<;MW1Z%X@|cY^7yG;q=bm-^n{7UC8>T zeCmykOj%K!=t|3e{o#NcL3-0o03iTPP zXHg0o#f00eOIk`H)bZi#MN4nBo^-p@2TpS|qm7=4!t@$5_Xr%1FdL1uqP@cNZV<*) zYbZ!ZERGHyGa+cjo2LX!+>mhqW@ijbW5%wIHcO=Yz+GgLEMB!>kqG&y+zsgiYAJ7R z2JZ?t7c0O<35~5i*~?%VGi4g!FE=Hb%4TfBrQ@>1Jt_Op?5!w~PqL~JLOKQU*MY#w z;_awIYS#z-tlQAb;E#NHDdD@J;y?hwl_XHbK}(+h?oR^dk*$N1KrxkqtP>4UhBw@R zY9_a8F?ekS;Z%y*jUN|o+e#Ehk?hEDxdyEB8gU;m^3e4NAi!0_2hz<+-o*^pC=^)a zGMW%;2YEWf3`U~X7p@v!M+8)+IoaLh7ZEtESt&%3Yg^2*p7Tz^rfvMK*xC9JNn*|^ z*qJuqa!DS%Mo1=o47OzIp)C~C3#X0O$%lyYG6dvG7?7}sQbR&H#BgB4Jln#}yALm1 zIUNizv-Z&^p8Ah>{#7$;#Cyq2WFD0Q%$S)HJSrGvm@OQ(K@t))$%ICrrHK#Sb`502 zZeg6%jB2TvVb4!B4%T3r4HLsHXF$6UN$d4K21_D1O$13JxAyj?0Nf)Q zudJoSV(-;Ch|{?~KPdn?db^)_2zgY2MA4For}!aR;-p-63RKRFJp;i(;`6-+Up{$^ zO>yIm8UF(8Rfg^quE7+xhf7^pdrxQr&QjU{)*-*%H$< z*bon<(U4>0J@={2FC1Qc zuU48e^Uy2Zn-+%NUATHrQn~ff`<;^CFa)y5$IFB;6d;eCMx2kB7n&V|7!4e{4Yr1% zhyWTSAcK0w4!AbvKzS`%{kj=oU|^DPi!FwcV1FM%w9uflg>@p~@q`CIUh$jlyGOG( zG=KHrqMM(baZ}^g1Iz2{9J}t>HhJHFF3^7ap9j42XFc+_QJXukvUvHUZ8y9!$=>NOR0q4x2Dn( zzm&!yVa8_#k(x7w!YXsv-oLb}efp&NqlU5aMVGfEw%sdLp&wqlZ%WdYh`A6lBoa8e z;!(~SL^m}-Aqz$_5a3uvzs6f?PD||ev`Zu;ORi5+bQ_^(6Soi@xc{`P-5D5er3NE! zGa79w1*05#^B}$T zIpoT6xhXUV4S?kh9O)D~-Y^3W1>DRp87VwC1hq{iCX%d#k)N|q$XoDLyXaXcTk+>NtIjv2hJlnif4)7)-hE}kL$zp5@W4hDih!Hs) zejx!eViPJ!I-Ve);MtoLl|h@~3?{nTpv~H4Bc-87h(=kN$(&=NQe$CW42&OLf;aFm z9uKK55q`q{T(ShDXLKSNLGDi(#(|7duQZ@?3Gc@MM8%`zS5Qk;@@m z+%lgN+D!vDJSG)M%NF{s#OBQqU6Qxc)`SQj35o~>MJ4A_*i-3d< zB@!w8(S{6`XL9I(sJV|v%0X2|DFMb4lZ=EZQWlHi5D=6hj7TUuNpAb_n45UvbWjl) zDiuD7)14;e+VphPq;90=fMjd^*1Sm3CkCwwZW7^8!0ag~ZqQO}V;p6)(7{%47&h?v z+t6{v(IbIiKuqJPV!B)hI^vE450Jow50QE?6l)4!#`g8ZE~M-%^BYcQgE>0dfK@6& z97@qYtglmzX7JAx2E~`bB?$-U6Cgfr_1Wj7eQYQ=zP!h0*1*G2wg4GU$}b`DB5gyU zqIhaptgHqWU`-T}u4(^r3)SifYoLMlSH;|3F{wfmEn}8(9Bh$D`|@#Uu4@LYK>5Y) z1?W+mMuKia;Gjke{KOK!XOVQU0UqE?NtJ_`vXoYpf-1gr()A1}I6=)F!^@VNw9t$Z z+Uz%1fc1xW*rY&RMgs>m(6Z;|84P92Vbu-wr)7NRLSuPSOxocKxZOILA51-$ZQigT zW39QSHLqPd8PcC~d`$XS!#Fr+&h$sVSh>qxF?4Io(lwiY+EmVTtXiFV@+wCtAO(k; zaIC+iO7WcJ4WS#y=%d+8Ze9q@+};OjJ`fB<(tLQTR*KM%o+(Y`%4AwuLQcMqPEbcg zLs=r|0y1aY0}lYNpVGT(>jQ_k*8gnQA6LJ0IPvRW78h6iwsX;^iSxI0UoiiNOLpC} zh3@-M=n3p1LH=@0CHf|)SgZ2{Ca)v78 zC+>p1n=c&clJDrOMG>rc5)gO1s+PJ!ZC(YpD7EpPETgEOGm_Q|qCX*}DpY!IMdd(P zAIK-z5-GmrL=2~~?6Ni2&@ZUsk4)^-yBM*+e0H<1+Od!~=ec;P#5MR#84wb79-@9q zY83gG4{2gP2_QBWFc81W;0}1U+ys|EC?rL4+^g#H<5tDRh{AGAre-rICCBD!xcz;t0aF`(xZ-^Eh zJ9;Poh-j8k)HPNa#gXPowcD>L6E!Zw83+2Sc{s4wB-6PH)?@-5#OjM^TP}x8$PLJ2 za*2B7ZX#;(iC7DVW9QzbLC{MkIwRX5vQYuQmi0cFm9spoPfy9pNj)lLJwG`>F8$~- z1&lLh9;^%P%shJ}I{YM^Rs`A*fq|O?3j(B=+sh)EVES!o>ZTGVNRNwyW1LMG+e)L< z)3NiD)9vg2wCjpH-n{O+58wIb<%fRoS@?>JZa;qJk0zXWf8~ll9NzZIVTWtQ4a-j) zUH`*>-GANqQ`dd-pSR!bI{&{-TQ_!2d#kDMPtltvZ#BC!$!J5|&5%b`u{Of5K<+r% z30?~KXs|&HPP`A;$OIBCh6tx4E;O}t2C2r(+y>96UY|?2$@tAM&{&0EUE?aXsy-y6 z%!MdhSszNpQjN2gnX9v}RIK9+WLh(&HJ*u+ZcJpUkp?P|Z~~yLeR@BqGi()ZP8KDC zicprgOJ?X=AJPNoP%{0_Cf)~4RZ6hj|%Qa2GLFwq70N^)7S$Y73PW)L$-lSd>Bnn)z(F@t?ntGqQt z@#)+Er$&qspG1RbABf}LJ)&_5g_*e_0mLN+SD=%UiZ``W8VzAq7_Xx`=`0w+y=!J3 zU1tUbZ-04D54N7BgO}mS67fX*Yb`*;%@KeSv}u;D%h5F>ikcUZJZFU>QIF41lDgR; z205`A=|TL#Z)omG5Or8Zx-9mI{%7VnideJpu;3(@V1KYHkVu|W)E0+1td#G|O-7jS z_Sx(Pi*SgGuuk&JC|hcA8p4sPcxYSk17+*YS93Ljpyf5`vU5G!q&)c?E{g&=xxWm| z<}D7)AM2GZ_fM_Ay2Ubb>tZQhw`guyClZbwCK~e@ViKawh^VM!oot|*&Vvn@H-{95 zt+t@lKiwVouy{9%jHRU{=CI$4nlSo6wF@vgf<%=NBH>+bh*75jpDWZX<-xksCja

zG=L!IU!{AAbR-~Q)0IkowtJEr&D_VgK#&YQh-eoxPemY{0ik>eFLC3@#2tSb`f!l4@T+&R-8 zd96;7h5}%^@iBIMj=c9*(P7q`OJ4eDCp~9{^eO?ZYNzJQj&2!EY& z^clzxPBXZ<<{BsCDzYNbd0?izm1&zaITBcj_n2;hY&B4&Jq9p>y~7eCP*382U?(#r zd^%H;3*!faR>t0+bZaF>)pS=$WRJuUZlfE3jf`e1KyFHe9k5uMvo8ar5K~WZlPyA; zu4DmJuOgQl=4JNT1|usmLm>7+cdR7k;l1}6u&nd*f!HHo3bFe^y9iMSPth1xAR{7u zaRQA3aWNW%JK5X@E`RJzD+hTBFbgt$2!#pBWK!F(Z}F8!{{HoYCq6p(;JSZ${vZFq zOkd7F+-imu#|HW@MUh?hz z*L^+u;&*#4d1384e@vdSa#DwR+4#Clwl409-Tteg$1eH&i|yY&{o;wyFTQ(a%N-wH zb4SlLKYxAw5C74)b?i^~J^1X=^2iORyV~BrVAtVKR{UyGo)l8tU>RhoF*m@l3{mId za}ErF2Kc0iTY2EFE`J2vsx2=_CbCY1nn1-O7T^=%-hw2eU%>*%I~Zamip|(Wa4~xp z;+VA85R+9cnwgFZfP1~pyG0P=inNdjl#@P|Y(*JckRxK{3Br6mYQgZ~& z_|Zp)s7aIuS?9I!L~xMM@F@6sVfwKKR>1>H2ZNDXFbp;$DiQGk@ME#*cpY zhFrkit{FH-6B1YLCibWSQwb`GcggOyQ;8ZM`FKOYGHcSpxp_1qB`E@)*2opb%cJ|7 z3ICLFEaTY~5_Pwj(|IQXz&x9zRhi7TDTwW;0G8N%(c2`}XX1}(KG>GL>=vbl0p4>C z+f=}59=-3qck6dAdVb*7^B>y&r%Pu4=auE#E(yIpv2A?o?zf6hAOH02o4)7x{*vAQ z@3{{*?|WU3_5J<1-RC_u=ZV<94_Cf%$&%u z_^TyA8($5*5j_5y3>4OcppIO>CCfn9DsA~0$>rq6ntw8xqQBw077m!V&Re8BLITmXO0tk zQgB)Ni?A^9Tw@c^B{JwrWSH4ho(9qLXihNX&t%DM$PAdIF=;sOqhJBP)(|7-jB?>M z?VbFPKrV7qjb4g?sUSBR1YKq9v5?PI=Dgv21!C3`B0#>(N)Ec4Pl8oIz;j&`y2xEi zqyFNMKo`E7J{br|;VdQ2Q@nK<`Ov(Z-+KD_e`X*1_M2l{&pLeD59W_y3O0=*470T5 zNwluU){p%)Knp(EROxfA-x!CcJq3_V13I`t48u z`1TibGl{_C_pF+qtozAMUC|>y{AAgl7ao7(v$Mbb`j_9ncksL3cK{o1z3(S?ji;|Y zvh=!d>#uw7{8R>b3GzeK8o?pr479cqy|Aj% zEmb9O?xM*zFyo1wJRdT$s!`w#*%Y^ehw$*ah_E6?iQX4IN#b)_l0?RxKU4%a;d&Sq z=s{1?28)YZ&k^h&z-)}|t2S2{kQ|~Y8nw01Geq{U>BCYaz9uq#GF&IJvS9)Y*errT zu0zdZB`I`mb1k{p{lF>&K*1_tdSAlb4?V7vu{A`Yr_le5(3$4bt%Jd)<1e_P6!Wl%sn%_x$qO1F?K8;b=WP!4 zT50Qf!uNvYPc5mQ6b&Xh9}B42y?sRDftv}cW;*tK0}+kh2%|c-ak|S%b?-T`NX5?w zGy}hp;`k<+OqV?T_)LI#1#SM_IZXgwS|%e+vODs10s_uy9TRdnZQ2~OPi2A_tQm|* zwY=)BAv?)S3(eW2us(%4D!P*`tUwV)7UUB#0@nNOPz_v@DpBF9=mi{zq>)-vpWfLi zD)Km2#DBC>P-2M;(?TF&UIdd!W;!R)l8W{U)K4qy%ej2x%ga7=UzoT&_S`q0 zw|uhtAIsi)`NB{D;@4cF*?X17KISH;u&(cB2e`OPbM z$Xlf*6j_h!1K?*!%~Gu0252w@e1j%#TvTX-lB0F@R(ws8DM8^TBSr*VRSB6u3rTqp zA)M8`jA{W!BZ+3!iy%8EVV*fySMVc=_Lb3{9u$AX5lJ+1Wt60wqDV7_IV{nvgVU2Z zCcN<^iUE<*R~B@@d`t&<@;Z)R#qwILo{Dha6QwtcQcDWq`QtW+Ic@7_;xdTdz0i>q zBAE@#;mVLagiVVOCetAkK<~*mGJ8|j-pYbm=^FyGV2TxDR~HyAvx6sr!J9r8nc%dg z{23RpH5h4gK9|H}BhCReZfn}o^AWcYN|1cPKD9v21_w7AgXUs9BNm{mHQ&j-0(ZuY zoK~bG=t&ExGVP}VRRqmZEM;KyhG|y^yf-Eeoc*st+gGDM|NceijxTzxcJn;WK^BgT zL|{0k%arUE=gMUl>>tN$kA0=zf9u_wr#$=R&!770zAyfF&)MI;{l#1F{PCjSHhy^X zd;k3DHbSVz zsVTdHZqsWWDr4-}h6zp7X+w1ZTR-yM=!ykNvQaTgEX=UT-w%;&NJa5;7LW4!&^WVt0XgZwUM4LvXb3&Yv+Lm z@>#K6@y_-pwTkS|G@UYj8kR}87l98Qh^$fQhEa@R!*?RhQ5 z7?|p+kH(p6xqvgtbe|ujT;iX*{eUS(ujUlOK}!xn5?Dr@4WFM=E9J<$HMv`yob)~? zvQ?lbBK9PU7yFhb>k0lIxGPHH;!>|>cE73(eKSWv^jWg#nmlyMm8+G;p62S5C7qbr*?NAIPm4qonseN8~=LT{A#iK z0I^R!OO7h8>>PL9Yxh51zj9hVr2sW$Q&V-hSrkZ;u-u&M(UXa$`bnD?oIbaLH@4Gm z^0G#w!5}Stp}Aw>)cBR}YW|vGLxtnd7ZjWBU-a4rB@mfdDR*Cfx2;nCu>|v^Ov&UP z@tRuHl1?pv+3gGHkOiG_EwgtfsYb#Q|M~K9g1xYq71;E|x9!UKbZi=K={HH4U^kP{ zy4pkKX<85z!`r$wIVXPqC1AP1<#1K5Lf@4PL_{5|OMC>^Sfidm6<4*hi;0 zkb(9DcnCDUkxJI8r(Daf*+e^sXH!$2T1pny8EOyy}d9!JV*#k ztvg-{Vjm;Z3$&!aGvildgmK7`2CaZVx?kEC5FzKZlxd)#JUh5a;S#E$Db)`AHZvnU zVbp_Ok5b&H;~FY-vT~tj_2sjD-XR?38gmi<7X+g^FzD1=)aEdj$QgE7p0%kN>aWd& zC$WZCNw%h@VadFRkd%pVE>3A2L`)kEIY2jPH`O2i@2bLW-@fq2Z#KU4-mE{Q+P-uD z>6Ul?bkTuVX8(I>;ExRCK0~x&Bxrzp>PJ~j)s}+!InX71XG zB*;veAZYLgk(iOduA%b3&M5i- z^<=xFdF%529NG57%j5kJB0L6TgwIJSnkKzI2-w<6L?t9IwK~3tj}bgC*}y9^ObWUr zxfK&_lGQ3|3mj*Z*CdGg!V&?hUr>f>z_PJj`SZAXvumZa%mk1jZ-lNPI~d%$`4EOk z5NGS9rf!+EP7)y*P&+0Rs7K-tqPcZ7RkdlZ-*6MW#u1rZumMTMpk%^|W)Y=Yk}=}; zMaaRx?XebTkDstF7t(gWDGGE&?NKdwH&;fg(~rgkuLp*@@$p1JJ28z4~T+2 z#V|xrAe7P+cG;FPx``^Jb)|0Z`I4mCA`s+x#ce?^LCKBefeHu&>gtqyolyP}atf5# zpqv6W1P~Gjb)qms$!Da1Y6%4Nd+T0T-0qS?T45OzQl2`SJVpNtco z-1j$DTo@}Py`h}R%U&`FOwX82fR}tRaJs3MhuuTvgMob!X*%?Jk=HuNYP<#85O|i? zlp_1#PC#{j7$omtH6UP$!|d4*nf>$hkIuUNyTASM%YT0T%$n!+UHr=K`DeVd=*%c(bnpzlB=5vgj z60S`mWbhEpp$1O&_pQs_mYoXqp0>FjG}98tm;~#2p0;T2j(<+7ox20F!?~c`fBz|^ zj(N@oOYNo7u*ISBX+VQ5%LgyI-Q zfnHDc7)Ka9(W`Tucdl(l-N@}}R)&fK*7vg6j1=3~-e_o1kt8w0hG18CJ9KS)315Yq z<#J6sHgzU;xk3f{COq^Z|B=y5sd>YRFw%Gz>MmVol0H}$g~m-MR$qd69F@-?=WGR? z%xiF-xS}iVG=hU<#xKdM%2Nw=TDoFs>4Tf!{%Y@CYyY+B^S|Hsv%}B6 z(eU3#s{Yix@5!?sd-KWD4B4l70TI7I8yMmkkS@2|Mxj;{`Fzv-i0SG=`kZ{e)&m%N=lE1p{M*S{@(&e7F#)xNh{*57vB z?ABfD*T1={@*{6{3|< zr=D7$ym7{YH*Y(!>z!k#J@nlkjIW0tZR z%zT5!pu~GAVa3=!A|gY^=kdnTS2kSqN8mio*=i)!ZywYkQi)i#s?>L(M`()TY9C=- ztkCV4(~#YA86X!XPa$-X4C@Wr+C9mELpvJv&@&i_pjT=5%mMgJ5(fLcCr^`|$FN6WKL3K+)OP+-8-|gkN-m(abAtDF>=!006 zi1fo-2k#dnaDYk&V3{@cBeLd-Quu0n;5=#;D1{QU5S3xG=vwVrjxLL;m?IT?(5bL# z39$*Eg_(4IUDgUxVy}_4AOw`de;503st|Khbu7RIkIND_n=yic3>7tWBC2+&%vmi9 zhct13YLv*o6U_L-WO$7)A^Ssk-^e?#Bml^TsRT~Gf`!oFiy+U%!3xBQ>(5~8mw@NM zD$o!sgj$%i1|`(JVdvC)7i`|ZhZ4vaw2XSl;i%MnmG|$oJrbM~GEB?(C5ZWnb9@=C zPRm(0t98BiIBdY&F|baf_JjJ>gdr zEJxVOaOw8k`0sFFM5-7(LW!VGJu1$CZ zbd_ewQ^A;-g8WU~5oVq_@_x+OMn{cPOv!0&`COoz!66mVq%OrUNVvj7gJd7X@Z`q% z)%CAvRw4JqZ(cwl zi4FqpkNlIaOrsz-$pJ_Z>rkv;lzQ-RHfEvy8$gp?gbSl`s8d1D0aZ@Z zfoS@nBycxw5O5*UIK}6K^P(i7a$4KGx<@Gv9BpD4MV@Gwv+aeCuROimb=%pWUi<9R zvm3YGdfzQuZasd(-+pqUu>Q9XeRKg3b8n|ov}3I! z-0kwuR(AaBycfSZ@15@+eCOEabAAwAck`2rXN)XcenZFAJ6~CT%i-JXPe!%ZZnu-DO_G2S|c+0|Ra;}@>d=N#h-zkGWHn8q#Vq8+aPML?&lWin7lqs}YP05~AE@=d$Y=j8NWg22< zA=%)hl)+T8Ji(oD)Phb}TFgL3#P`G!9H~SrLvwdHEDcM9$7QV04^f(> z*tEDLcuEH)`p(S>ODR}3r1u*`pt&JIiQ}z_kd0--zXQ}RjLjvH3y2T|Ay6a6ia}MQ z+<}Y}z~Z$v;h~e3ybM^H=~#lP5{sKrjiqROZ#EK-bu1C6DbL=Dj0v7VBJ2|@oD<6y z!7%vaf$$|Vcc7=Gp5+rsCY;cu$88KM4AM=jdyxJ|X#ZiIqn4Uf8XpsnPGZRj-zi8) zBvpUx7o#EY|7F>AHySjC3%j065`#ulq$r*yF--NL%!7l0&41{?9-%(*@;A=dq(lnj zoEw{Hi!s#_w4JG@c98V{U?hwp++}3|L(9yIhDtm_r4>1FSudTS{D06L(I&wnK)Y$1 zLX1izqXh1;!8P8!VQPKAjhV97Y)M=kn6Cuf^%-qiU2krZ-9*$8pOMA4FqFSkZx75h zPa_qyq^46aXle>oTn(qQ&CrH0UVCrOU~$t0#Uvf8U`)nA6iU|&=k(;Jo)xaq5uzr> zdU|ec`NPd?7MJ|)*Z`z-b?cDMxWI*#q+= zoi1yCH7j^kLg}xnApn>AnoSayM`ZiUZyp?q5buP?up<5j*?t(T;>6=yTe-kpX$HqN zJ*yU1&S)W|rV&Xo5A-$Yc4QRe7;*WfU|S`ET>L0Vh+ON&Q@(Bn1x_^c5Nxm{_+cge zbs3HE-CR=1sLH%Nj!!QFu1VE6jWt?IBrzzajuRt-CZX2-3@dg$5iy0kfXEG$gLv`{ z@hsS|m4OHO6%wij$?etm8bNkVa=u!0&VnLJ{MaFt@ir$_HE<}}1Xnmx^pPywFw#wk zb{RhwV{U;|F1-C5`=m3s1tPc}kgFIS8wH@XDwhno7Hn(oWQzMHBwh{ zjbug)9j)|$3w#j&0oc`8rw;X=`!Ok!Ld-NB7eSOif-J;8AJECum8X|R>#ph zkB%}Oi|%3as7AEeiAu z4YqxRv@|s}Kr)oPf6wSqIlC6b8$gH$8GxOqT}p!#K96Xg5ZF^YmcxTun-1<1>=5>L zs-O@?3+ocU5M(ogf|6nU_jPe(A8SNFF-adtO*VXVJ*vO}&0t&#-Y zGww5_+tb)0C6Yf451!0>bUMDmY`?GhUnj)p66zr%je!3a8xWG;v zE~7I{=s4&rgq@9V*jl5L^y837=^zxr!cIPKt{ftk*0vCxVo&xof&aLG!Bm(>vpYuc zIGNAf-6nVs6>7{kXVR%Jxn6wpKj)u#H+KB(JO8xudzWAK(U$ioA7}}!UVXfC9SWG4$5gU!l+HUvFJdpQ>{&`b=sA~?|JR-kNa`&gPRMupCH3s)9R7wc2e{$Lez*+6jFRfIS|yAe1qDVJc=DAl3#R{V2Z}nU>83G1JE0x zrGl_xxMBlW`HQ}_s7E4lZVWS_e0KuZK{AyQf&1q2k2pEkmx-!;OmCu3+S?f* zTS8~T!lW)fRaCetd$1$aV_T`kP40yN77`FG;NlE`Wd^xf!Xe4SA@M_GncSTLhZK!X zvA$S=uSHoT3z3_2=}7BEwdTNvlsKnNrD>ClKhdWyBgY|QJ)p{n%_GgI85d4l+vvn5&w)B@Fo z%s5@if^{&x8#*SV!PA24w1ryqJaIX}o@TgjK(y{jwaGRnNI}e5Jr!4#9AY{&C^2RU z_i0jW_Q%U+DlOItNWHRS&1QqTwJ_1pm6HL{hr%+zSR+A)z+N3UojWm?9`gh@ebQgR>G0vNoYJ_yrnx zmMN}j%hj5a=j%tE`nP-Sz@I0!z1%cr(ZyBG@zW!A58gT2yzFT0(aV}HjdyDoJ=V3} z(qr23FOLn1m{ri+R_l!|M*F$5!rK zI;-8TKZmsFzrigcZJ$N4RtQ zlv`I-h8o1^DpB}&*(2oP7y~zR5y%tsz_rR8a#!XfNurp~rX4aY%|9A{6!Q&>lH%5f zXQ5KzRIEs%&J04dD+CiIS;MTC51pz)G*BzIk>{pR8j*bNF2@yUfN&8-4TP)SNM^P- zvZ`b%DSI$FGowUQ>IY%f(Md)6o`Aa+VtWvUZ)GUH6ecb~mOKU@GPy*}>qdUIYp4X4 zmqp8Jva~+X%PM09kBwD)(b#a3-zN*9;=;Dj`6*8+C%B&rt&>uXvfA6#gEmn>LFgmF zK9K8nIJ-kx&6a*NimWhyG z-$cBJZ8neE_F>4t!SfETm~`S!<@(#V6znz&K9pzneY4r{7)Cv2)yY`%k^X)TlCJa8>zcDaw>VS`P`V@}zzW?Qq3WR_in6jTD;@KR+~1)E<9c7v+zyX#k!YA#~izt)nP@r zS9<*IRgcfF-?jJLtgDCmuX?k7%$aBR^Kx=7{F0S@W5o3vdF9!I-fnDJx8(MXm-Uac z_WnDwXwaws)E<8K;s7d0wi%T>&vb>0|ui`)+ziz?hsJmJeKfwLgVl= zp+qE6Ba?PS4Aiw#KCNBenY{t4#T49nJqZAQ+TO|sARu>l$=$qMpD4K$#|&DJv$M(q zG&GSnl`d)QqKvg7l7*T#2~|E29uGa^z)K$$t80wNQ~_vs+auIM8wC;D;n?eCMlWuA zw&_8rSe<|l;C3N3x>nJ4!J?x{2)8T`LLwt- z1V|7So{hYF-0Qr{OabPavSL$<|1PE7MC{^VLG8MG7@_IN2vjWW7#GohX)>)>&1G+~SZEc=vcR@pa7 z_YY7$&hje^(EmdS>aOcN^dCeR%86U(XAMpPYZVUBjm8rdNJrK7KN6z|l*|!=onc zmAC!SVY*bR%Gqz8+x}F${hdaP4gR|8CqJ~)*cTKv9hepC^=p1t|J1m(^}Sw9+jA{V zS>nL&Aj)WR^ENkGzx>*q?Qh7B-JPt>x@NS+4md01=f$Rk)mQ>lUQuuusEKp^qiY6E zsFvrZo~;ZiG1{F2^`WXN6=Ojc@6sZo4B1Z;Ijl5vQm1UT=_>Q{g8BvFW$R>W9jq=D zO7gLigPrRV)inR(ZIfVy?gZfmY_9>;X4WZoD{Lr5kF6h-D1Y(PQwTO0tPH( zT;ygPrQy=V@>z(_B0wKPeW$449yD1ulc0&02HCQTt63CvaYxt@jT&*5bw-wK!L85w zhC#Ahu$m|c)U$F454YTpn+3a}RQ7FeX5${@1K@yR(f}}s(vy+A9KVvcmNyj_BQq$g z1kDK=EY^2Em!K_+Mz<2RQcVjgG4dPbR-BUh4f1^ljP zP1C?-BfObCrybU?%pw?6rMyjG4f$w_h=@a$-#Xt7G8zO~DwbG{i5CRp8fd%vK$jE| z0@0(u%|bRiRb!GeM6@#=1JZ>6*x5B5TNoxivt2<-bRe*mXMQ1->S!WRO=uJOr9<~` z`fyW+CFM_t-)&8gS72fwdz>3-(d?x##YCCv-}AXF4DQ{Af>@jx^b-G0Y|9l>g| z(~+3!Th*(pdo5tQ_+;h3lUA9Hv@9$)DAOTd$fWsBE5`Td{EZ|ZwpTyQcz ze$JfL<)uw;Z|{B4TmI<^Q+!^5WZ<2L@t^%2bhCd=@0gw6tiICJzGmgEEeqECc5b2U z$@EVn%$MFh8uR|mrqxXiGn#X1A6%_n?IvMDBt|eeSS=ed+zC(3QYU>Y91h|j3tkD) z+pa1R!&fC#!i~aMh-r@Vk#I|tI3UMe$R~sdpw{Htv6Q>1a_vk}E7bs%V3y&G=R6it zI1y8VFm!UxW7g$)GBs?C3?BtoMXpJqp3|B-lyGO8k_6gD3NN4BAf5s&T7Lq+T$XgSp4UZ04(?rs8ND%8>JP=(AgGZhM^KB!Xz+u~;2gT~j>Swl%O zC$D@KD+-Bz5|t@=5ulzVt{xyHglIonfR&**Sy(xXI_L|8KZ$C3i5yUJ62DJn=GY-` zApz4rA1bW)9Y>`C`Sm}v3Bfz!D6|jit4~bfRRyKC;pn=l0d?7 zdD(eCanjAp=qRw+F<7Rn_dLzMnnFVP}KTYzRP0Kc5g zin|qNgVxWaBY|Lx#GWp*z3iQu=J9j7hRtDGCp1#a_BZKA#kP-Yw|U71{dbc)WpBQe z!nv)Kx*Byc_d-6&QruH$s~(Ot?<^kH#@Rh$pIN8R5H#2cwg#(AI@JJ!R<%xRHkozu z%-(uxO3(V$`LR)RGs<^6Lb^o8m}@Q{8MXh~7uy2maG4HN~DpNbZgjS8BD5D)A`3 zP?hj@N6%$LVh*~zYTA8j(ewK&;-B=3xAu9~yXS4Q&&Gvg_imr+zj%3{BT4J?e)!RZ zoW76ZfGkT9+AF31%sWS7e;oQZ^AtrQGXUi0Q`2LTQWp18ClP$+kk_cE$fL}*#nnQIP0mi*p*OU%8=2rcDpeeH z-jW!4oS8NWf+w$VHjzp_0B~3?`Vc)x0UUYOp);Y$u_8(C4uRq8?pV3F=KxDrH5`W^J=`pIqaRZm#-~UopfR}zLmdJfFW}(84&qFDwrg<& z?(}Gb!y*A|2Dte@_zj{O5xo%YCn|HdIcqmAd_HI6!jOKmvrjzzqjp8l1-lZI_vX52 zbQ1PmaRJgxPHf(|HZ)GEDc|<~X;ymG%qPG9RWRu7vx!5R-?Xe-*>L;Sn;VDU&Koyi z;HQ-X)~r50^~U#^FEWZsN7{CIq>b=DxYxAj?fk=^JsrR4)Pr>I(eMAQ8}y*(yT>=S zef50QD*u-2$|4Sp-c@;QVf_1U@jJJCZ7xeX@Y|B!t_M$jb1XN0dBySYXVaIdmp!Q( zy!7>X^TY+G?mgaeabxPet;0j!Ja4t8?%?UCHR;t=qvX*Ua{?4e$a>WLlLrP67?;Eo zBk0vc(L}jgv{j-mR3?|Dr=EX%iOrwNZqdoZ)X_x2N#s7xiN1oQI0Qh39y8T8s`A{1`4Ur0G*uc29KoU&60t1FQ@mn+(UAT zMf5WMr&+CER%f^;lHrnuphH$1Gax%=UhKY6p$nEI@7y}#$F5Rx41j7l8AG$OEn~ZF zE*CC;IXkoD^V$89Tet6&TX1|-H|?BHf`dOuY@aXt_0o+UQ?|68W4vbFZ0mpxy8pVNGK*Q(baH&+~sulQj?=Iy1=4$aN$fB1);FJ|qiUh-?)%>|D-(8x~Q z5}DdG_xWSfq0EaR&ZRwyXYUxO_jWCo_$s=oNBnkZf1G~L!Oz#FZEurQ_D96gkteg< zGFbhlYVCwB8A0_z$rU;&Cs|<};Tf-{QFe6d<))X{82Jt=(My|?uEKeSnc=EOZy|=r zipm0`M|6)~GPRfi$`GRputubG-w~~{sFT^pOQN#TEnP zFJih_lbP6J7)1r7+g*wE(z74`o+?wAZ0>=)PE;Tv9&&t97QtX40m9ikxyT`B|9;0*J1@iKYRw3)Gmv1PM}(L>fpQtAeK=C|;4Sb$|1IXaPPaJo7hFkXXM6LqtF(&GngtK!IxLJNlW4*-)`v`ej`4u}s< zrf^}X*()qRKz`!TmZw8AH+D$4_M-2uK@aneX9Wja$coFCfG=ZA>4Xt3P-4urN0_?j z40WFHzbAc9-)TJaZ{O8#Uv>O<`|vk!dcM2b@%`nFwec4}KD#_OzT3X2i~4mhbPuAj zj9#DIQ1I!KlkI*iy;0TNBen6)>&A^|io*v!Z}tA~ZfoA}8_@CH_w)DP`0!}cD?RLV;tLvMp=JkJBuwdu@7k54}9dB1L?bBP^TRc2;@Zj}+x8qF>m5$F|O}+HX zk-Y66Pn~?zW6k?*Yo6Ud)7&`x)T8bncX-^El|m#ses@wt(BVGYDU;jfmt3MD!lTH? zbeB=a?!XNkDf~G;HEEL8ke1-dOvC0E?ZmJ|8KHsz16;zcBHB1apbW>SifGK}>?3~4 z#17ffc2;uR_!M#HMpSLw7)hez}+zIgQxfOejs7k9K-rgzAnnHSw z&Qq*Ao=vr~y>TGXSnkOPIVoUL1Z>+|W5jtxu8=5zF2Go%62lExlZfLyjNEq2=YzBc z)ZD>>)D__=XTk0*;-*lYC^4l7xyL9hBu2vAFZA*P4ljBq&&TO1%2)~M(t{#+lYy#4(kB@h~K(!-$YDs*fO;64E{q*|9jMN$i<6gOl#Nj&?iFfkR^nri1Q zfLcuO0+Im4sDd%GJERtxXMVlbl|8uwQa4uKl1imw?zOEyjT%LspWtI{^MR(+IH2Ho zpX?#OUTGbcIdExKn_&uf51cZIk^!+TJNF(jYJgap$ZY3xir*QUA)ijtj6=KZj<<$!{I0o^ao&Wp@H_-EGXHeQQz zUf-^JQ*iyt9bHP&E48{{I`l(We=A8wCd)>#&2U(`A<$(k5Cq@K7I7$oVm7`8hID^^d2g^ zgX$58;rDHV1+}vhbB-BCgWgl`$bef5zj1aA>K$ERC0Q_Dxv8k3Q_iA1GWto)yUX=o zZxx=3@`e&M>NpGA3GWs$EKH+9 zm_o6!$6hHGbDrjH|Jh|UnkXbtqJ5O$Z$Zk`VOtpeUQ&4+?y+Ff#HU~$GGb3Oh}0b8 zaBHVqAufJHg~GJ*Dkcv)b?LIE6-)2W%lUU|@wsZfJSjDW{kNCl)|ib6Ptv0&JlyxZ zcOsMUwPq(rzFmO_D9{)dLij9C=E!PPJpy++3RZ`j7rzBcTm0x^5<7!O)GKq?pfAqw z6SHh%it^VKJI}6vDNQJy3)@e*gfSv%Vt5-XvEd6MDS{kf_%drjpjo1)h;>OM zr$6FIn~sRU8Uc-Vu0J|%YP&5$ZYp`o&OoEYQ_7RUt(Zp7V;=@m8J}rNp3MzM2-=0j z7PJhgka7?^;?%X}23Xu#7mEpYYF201gN*z>yV0J;w#h^Alx8+`dODKYiiqzy%)%J@ z?>$&uHT77ZZUlfJ<>&gcv0I=r5eYx;w$2mEclB_9SL}N}qVo`_JqQJ7n{TN_^s{2@VWcJb+B;ffXDlNz+SU9|FvwAhfq#at2WL`YMy*IfveZtUg1nV^4k1W@oFUh+c4Ey9 z;c^n&E8@zhc6QC@>37A|&IjQPTqHfA^HYI|2cjf3wHyvkKdIdz0!`Ecva15*G~$vJ z{Z~N{pP^2aKt))r66Qi2tgcirY{DS#i{{@z62wPJooHB3UyAGH$*Y5!g?GX)r9~Re zx(s88t29dH$@e6t1pxrz_Mw*o0!7~o;xI`-A>bcI0GxS?f2=vzRcrJ!Fx0eboRz!x zcPmIe_xXrW_e5>CeczH~XSc}u?eoxR#)dj7tMX?Rg;jmvuQOcj+;Umb)lR-^UDIsA ziPpScy|PX7t_{sBc2>BUodg4*zAInPd|?@Uv}o&g&#dPInDdkv+&SO|BFA2it2l? z@ryxEul61}YDx8kd4c64W4c+jBR{@2{9yj>FOTgGtkGo+Z5!|;DsK8Rebn)tFK;IF z>o=~Khhuy136s;zVOeuO8|mscEN9Ei3A=7w>pjCf@{<)UhkxQ5Yfef%E7#Zr38%M| zO_@a|wW6P*ZFN=U`htzBk$Y2s!Y$4}w#R&x?VTWFm*!DvG&lsgF4#gT&p^>{=wBl_ zywLGcDVnGdk@G<{H%kp_XS6^vDMJa|5ZWXjPvY8>*dd*tl@3%yqD1SmyZk@93>p@* z>sSEI>>cnfO9-4H8L&$3QUyuMpfI4n#l0}qgzy=|ZHRz9)1NjX*Uy^pVTd$oGpD%E zrCz1&I`3b6DzEBfAFFa$+1?-aEG;JJUSUr^b@b@K6%Vf_EjU(xwJQ1Eykjq4x4wMd z(~?J(uF=}ugENNqUs16(XID;S+jT96N7vLwM)wL46KPox$weQ#ca<6uww{VaoGgW@ zbYz|YPOX&FO&ZCKvoga+c2|=o(x+W3i}<$v@NZCtdxygmVg}=7_0?^QH0ElZ(8t2O zZz1|ea zGS$s>!-pSyF?90SA>ZWo`fPNE?5R0D{Wsm{JAK8>IdP{uIIjP%Y5L?2Ige{=AD#I9 zP1fGYeKzJ4rcBlyd~sxIYRuseCyv`Ys(eGb-?{@`Lp$ck10VibTbuu+`^zh5^Bar( zCpIpt`|MTq<=&%_f}(gg3Zj@uBF^hnQ3f@pfJ;u0qh)dzS0O!mO8(g*kOgdQLOI$E z6p@U8nN)~#J}=OfN&qZw#3YF<#$2Sx^fZ;07A0(S#BE4gteAlAYjOcR;4AV}J%cSJ zN1}gDpMGfn*55AZUf-&nr$6}YlSjQZRTekMw?-#V3<^ej1l^U+$i1Hlj>L_MpN0dq zU@mF72%bIJ^#rJ`+(5dLB4uR};?uPxGl{#-S&$?#vy(apRS3R@j-D{0L=|rIGikAT zrsS0aX_r_8(Nv>-YS7moMo6~)Gh+!sNioK%7}y^+ySC_aV1t%*U;@{eu)ynDHyfQ zEp$?hUV`ak=uH@q*9^4qe;`j3szek+t(J&*`9lmrB=pLh1^AEHGsyXJYOPfoWrEu+PEifeR%1{{u`6y%7n1! z5rLTf+&n!ig<^+=Zd&;1+u$2!-w{Vb{+E#(+dbCiT$7QNRnV=VWLjmV13{x3jWDZS zd$0n&2d;YfrzQyjp&E2y2L6_y1KL!AWyiHGc=2xmlw;0rV9sh1NV0O$s|#;b_a3$} z`>QjR{xzFUzkJ>&bLrA!XWGZbFDyKBbi-ffD~Cg#%osQ?swU#gXQw-iN;uZ8^N?=e z=nIE_;6JZh;g{25!**QP1r)k{*P^=m&zp@gYx=i8l8=FAmHJ+eeERZ63kuo|gM z!&J<)#e^RpjQ9NYMQRPqffTYb(j-%i&YVc0R8V131IQspBPB$0ToBWm#GP{1x zvBT98ml5lOyiYJ;y^pZ8z%PGnB$FJbh4xStS0}By&@kx!*}nB(h0WYj8U5{ohJRYEdh+V+ z>yGbkZ+d6HxvF{6>YIZH-Z@bBcGryGW7H3hES9C&41!{X#2B{E5;k}(p3*`~%TqD8i9vz9xdO@P7~ z*Gm+xZ1b`)289ZP?%0PD+nZC57nXPoU@DALH5m02k?9g2h;E4z@~0)CdP+bBWnMU4 zDJxNaBuRipxDr)~RVo)1fnFs6NAC;H9^xrqq%u%Q8yD`~wwnyi1Bze?Cl!n%)9!W4 zr;9|rB7Tw>YM1CjgES-0OdE?gZk>DN>7bt{&C*?Z%?-m@n8jO|vu;m2=nTm3I~+t&E7 zxOU$Azjk{c>c6c1{=~t_4>q{8ec4yv@a**Co)=H;4Y+l6=Ex!Q`;T-UePsG5-=A|$ z?I$1nz<&A5{lkxr_bJfai0Xc4_Tid#M^lGQ3Qx?RnzJrO)(Nu>Vil~nMCAx(ji^ye zbhX&>+%2w{I|!0P98)?l(<-sk5miLT5!8oikDLK4J#w2}AFPP7A?8quu?a|@2o)IZ z;!Fuc9+b09LN6QaX{gSRHAK~4`J*{K;b!fhbNk#{KJ!_1R6%%Ip85H-?>H&*{W%vg zB?vYNcM|}Vl7U-Vq@@Vh$xN5t67)cyOg$e)10JPu(K!MsChThGogtlq4f}l&??keY zapPU0Q*%-!H;e(7g5b<5QG!2{Mv;{fh`2D|)t2Q*b&!9F?QtNwMNt}0JXg=f8EZ+H z7AtNeTm~m8@+zjSY-h|I*JXsef_+(=vKFIm5>+7q!DN1@7|9A!!AV^CFMlVKi*_?D zbe0|k5WW#<8j!2;$B3~IF|eJO>8Q>>W7NHzl^Ekh=n@d#wCr%nR@F&q4+2<4OHpia z%`mpoIHB6a`&SAzDJlgs2tkHcv!JQ@Z+>ebPeRXS778y@?tOJ%fKiNqWNj@8O!Jqi zBx+5h`deFy)`WcsdxtSxVJN02r-2ni2DI>3J(gJwA2^Q{ARxzDt&*#0_B2LY9C5m$ zAa`}p*V=H;PZnJ16zE#COI|Fms{F9)vc>b4RLez6yYibOZi2%&L{Tc=XIu2Oo55IS zbdI*0N^K>Mv=)|V@@+%ZcF_&VAj;y3TSSerO_Ya3!mlV+n}*H)@ZjE@yDj%rFF!tI z^r=d>nvwse40xPSJgwp6ZxPxOV^B@qg+zYTWKEpE+m6ioCp&Q-k+OYUDPVv&H7W zQ;twqqfBSP&16~T!7>oDnY~1;rgF!XKpS8;NvkHvnAX5}W9O-s;8M;~8dIWupepdB zGE_>QRG-nnb@x`Tt?t($W3K+v;(*IUwe`ubj(qqT^9Q$&4zrw=LZYR9Dux{X^3tgW*u5@m&NWb3s z%dOO$kAJ+IUWW1rAtGCns&pA#4`%=V61ME2MvWMC52M>mPD=@OIJiZQi2!+`Huo$p zSCFegAqMO zo3SEsYv|HtdX^zFsJE%s79dPT;sBvPlS-JtIS<)E9u>b)h(-%wCZWU?O=ZI@Kf;T} zvCpNCm=LkAiU2^3lj=XJ=~@Xp+hfH>vvr)ugn6ADzlElFP2^-#G7*rbSg*;)ty}td z+Q3IA9-nBe82GT`?&VL56N@u)WLz>ey8WrcomTlLN8Ya8UiEfKAOD-zcD~r3^7`_NzzOp+vI=8v+_Wyb{}o*vxaQd8 z!`b(y6!iF3aW!jbsHfWMuBRNY6hti*E)ZT3dxR(Bohj_GDx#;bSh7=CQursDd5@HG zFT%`V4VXk!2@FRX2?c;$Q9gq*5utmJE#i$v<}Z?AC`ZLAt4to{Osc&Tu}fOgLQ%?~ zA#g(480d)!L~vmrVd5Yb04=L?6Vy?=hj9?Wq5WtQPWmZ+VI1kE969SCU% zGB35T2Eb5S5J}|_B@@UkBGIuyydCTU^WBl@ToG!9ssU<7Zc{tU2;4bK7ZE}Kp{H#? zVIblGv_nVJ4WvJ)ukDheT~u1D@aiqdN*)Nu-Y%I zT*8J&ErEFG5{7zRNSl+dtk4P`-h9Zx(r`4yAOa0Gq;sKgz*4kYt_`$Rp~1W~WA$CS znI4@FP0LH&Y~9>Tt(j$4r3h7{b*h~x*u4BS)=RS-#SS4~7iiREeYf@VkzGILBg2)V z6QeGXNkOqqqa976w)JM&=Iv9;MV`7o^rTy3g6rWo-#6X->Yt2tyMCT_$GPIztI~>o z1FEvebo8F+KhR%);?%5@&5yFi_Rs58Q~SrO^rn|5<9pSt-*w@~gH`!8U6UhI72(YX zi%zb{ZQpvszR6`j_sp_>G~xB!;~lbR*Z9xzl~@1YjtwK`j%=0mYIsFb>hvkepUxjN zwQ%q3q|{N>mPfXY>5Zp*sXq^|ifP*4c<8hDzh$q}sluhUu|}J#yp2U-m2td}r)o$e zW|O%oFp=Vj?**MB0(c4|j$<el16)(d=e#EcG7qId!d%R|M|X{OFO@*o|L^; zG3K_pcIW8tqO#XC_g(X9`0BfJ^hcM~);HDu{oVAJ7hM16zT?s7;~tI}Ju10rz=D&{ z)~)`#?d2!azbf6feErc?1vekh>66{9%|ta5R~OCFEW_#Z;RD|+oza-J>)gV3R~NpV zl3B65`r7;Z74P4;O@Fnt?w{1@&2!egX;|~5;@zLV(;sEk-ut-rnEbLV5do5{NQTOj zX){9#tW-XBc@^^~K?(ZUu9xDy#fIc;kV0Td^aavzY-FWpgj94ux*Azil5!<5tCe!L z)7;8%X(h5KVKjs}(uo5(Uow^ft|H7)w3b{@$c;XXRFxQ=wQSV9T0kE#P9c|v)iU9W z9nktge&U5?-i;i^VwC~fMTUkAMIFsz@}pp*!5Wt(dGNllnwY;iKoX*G@6o zBtACXR@+q%zAPy^qiAZxwPIVs-W*_KfJZB`J6cmw7<1jfZH`O}66Ba`5t0-~&=U$t zlBAr{fZ48*ok#e1cqu=SAq@{2m<&zVbz(u&q}7F!2#EOi!0l&qW<1KvB?0Z&mI8++A1n z+xe{vCd_}iefOjBTbES_`cDWd$Sml-WWH(VvXBwO55|~}cPRKR;`!p2wI|m+YrAH_ zjY(&FzWM8o!+YWCU42&-^j*C!esDs$+lqC=eLn~hh`h$gWe*keYmOKHVxV4@+{JFM z#BI-1r4{~BD+7K$z>3+(0f2*>v=V%&xWj|<-PKOPmON!EV`f8i6gn&QRY!d!BRlps z1X700RmHtuw0(s8kAiADEa$lEq?ikH}~DEk`TD8jnr_shKBU6RuJ(Cxsn>CMd|| zlydYL2!OGdD=jH4$V>2xerA>%5a#hx>*UGJ8EsP&JV}&neK+yHTsTqv%D<7w{Fpz7 zz&fGxe;!ndA<_Cxe?ilU9+e0oFaDbF6eQHDfDWQv`H7U@{HK%9M>G*@SZ7y(X!bc% z@F#*e5_~vDaEc_7L6az>oQxuUiEAcn*F(8V8N&=07)Yo>+!JekjbZzOG+Mic$wZiz z+}<{VUcHui!|n@3Es#|PTm0Gj%Vp?`Jd6@l&|$^6qzpzsD>zIStt_+{6-X|uLw{$r zBzRx9{YI&o>P^2sHsxdH+a>UCl2Uae%vM8wKv!#@AI@*_>6&i#Q8(dt#r*0N;t{HL zl3In_tRrjN7_DMs!MBzaJU->#iC3>@9j;q`Z(G9u4LLfex$WhgZSPJFh+lXneqmhH zYV(|Xi;hpu*_L^+@8yoJM{lpHeD&f|UR6kHP0#kXzj|`#$4%>|$D89OUVM-o`|#?Od41|<4yxOIePptqX8qDNE1LXOVTbuC$-|CD@Qk19H!Eje z%-+3@c@O>(kWn@&PH@+y$-t^>F|G^PsLOo!INKHvW zfpU!|Qfdf|2=O2dS*DK5Fwi=-kXcZ3B?grsz@ULfGKY&>@KD!SeAx;MpzA{v&OU}T zEu2#iOkw5}yb}@7jEVRX$P(4)(t_YqYZ>;^nx|<~w1UQDpphri0=Ng-=LEe8IWJUD zm`O}2gh=)G8yXlr8GSI#61M_9j4;0SWjwyXmGjHvSc`zgV91-87YhNGbFoM32qs^o zIdJyKlYX3jtK(yo-YcS>&F!v4)#6SaQfun ze|!era~XVZZ|$3$+D=WfN7gGFFFft??%bBT*QW;ky>!OgFJ|1$>hLtR?$wsTufo^7 zYnt&Y&->Zs804phPU?Y;1HP;3vg+2lh34m@S6z>PUov6Asm9pa-wP-HIr!zX(=W5v zJZn`^yW*w$rq$20SC;mzi!(jBHt6+d)mJp1Y;Q)mgfquNc)K+Z#0(@dai1a)&4baj z0JtD3ueE(z$A9jPTS|?`vO7<40EJI zNkC^G63i}~V0fmlHMhnHdW&0SIwEzcs3)Rhvlj_r#za66qKMSF26T#ArbZslU4R3_ zn(HPW88$n+!?Jv3rBD(PY@{)Y)k45a0htw~Hfv;+TxP&g7$JyS%OK}cxexh6QkSSm z1~qu)bbzB-zHo^UMH?(mXU(3pusEP_pm z3fv-ulT55sZ{A&TWO|2F#obRm@;+77IQZ$)HBVl^X}=XvZ(Go^W%nqbn!=yDUs*eE zsPlEjTI;6tYq}msJ>Qn*jJ|yCi<7xWt7{(x7G(L}*wU@2ch-avsvUc+*Nk z_f5CvNJZ#?Bkh*#-2dQ@^M!LK&Rh|-`0syS{=DZ!!J$5DzkGaa)!@IU)x92m=7o31 zHwX5-z1#EE|F}wmgRysts|L0=P6`)|p%T6;owY4D!P6-M^=%7u0bzKueK0vua?8Dt zY%q7POpEoB5%9r7EVa2&)iTNZ51h_ygsPtaCWCPRJUaGRSV%*`8yTn2>I9%KDsEr% zsAFW~&yA0(JGL>uSUj`#vp;sE4%?h_bkI%t!lv{j%@ zIZw|g8&p6F0-qrPQP>EID-O|@2%fK68+-H;h3&9!&dQ&XxdDDkL?L0Z4um!?dI=h$ zk+Rc^0RstipyfNHV+%xFJ-v_gQe1uumhKRya}zqyk)lp+3D!fdqj=mRZhpeZ=71C| z(zx|xA*BoWnL%I*PR8As%@aIX#mwPW#yP{w-*HoA$Tp^!Jp|WgF%HXvs&#^ zn^uQVoL)pyJeJxO#d(Q=(LNT z$RbI!1xZ~b={!9NozakUqn4;lHA+Q)w_}PVH@!4^fJQ-9uEJuMV#uZaXn`aN2Pw>e zlmNLhW2j|wtU*J5mZ4MhK>te{Nwe^hpAJ#FCJqyxv> zT~xYx?7022X;;AgA001$9MEq6gJ^<}ukC--z3jnPaRC>P51Rbv`MhggHa@w%YGPJ_ zT^)*<1}&a#tk4w^w$c(UB9c%Qajn}ed_D%LI6h~iA*xT&@WTPc0;v>2xJ?jb5mExS zjZLW)QwPW6`MB0>dcq~>(mbU~i$P7s4jxEnF9tvi64+xSy`Wf8peh!1C)BheuLV;) zn>WLEoT-nySkZm6aV(L#tmhzOu$$r@!jQ!rCKruZP%EHCI)aM=*vN{tBjuBfhnV4e zo5I@ox@imFOzb%HL}q!H>RuRDsW8gyQ`~RY{?(4Qt6p?J^C*1fxk+n!Y=~b`b9CkPzRPd!d7o9cq9OjP zYcUCJg1+`I4bH!K<5s%`1;t+;c~sPpJh|>!Q|%vH2H$DD==783HI;kbT$%CCQL*|} zVc$W|eLH;hclXl|y9|2jRC{{si2d3c(Zi$AB)Y*TCjW*z6D_k)0(mXa=q-VjqWb`E zA*!JsF3@ecR59CgI0S~=J5w_%xA;qe%Al|PqVvSJGZ^;x#Q>26Qqp+ zU7Xt2{IO|axxVeQBh4NKgI*tU9i4sjXwkg&&tBEO&DsMm?ca>iiW;>Zf!U9&9t@o zVWiz7ge4X=iHOe|*bz?w95QEKC}i9GSBM!a;aj^3Ax12H^nsYIT&2u5l>m`xqyR9; zFBN>-(k5fUUX(D%N=+eSRTi@%<9t{hD0YSsa%bXQMY~!tH&mWG z%cT~0vZdIl;|5DgAl;vLB3FUi#>Z^6&DuRmVPa$fW0qV|9$Pjngf5&V+erZWpr|o) zvTcpIr(}i;Z44_$DCtj55nzajEuN|rt<1x-qLXu?)mfn(OjD)o&GlZDVTA`HvF!!N2^d>jJ z#T75DR;!FilMdW?FtWp`cTtBDj@~@|@KVUF*GEo1`K9?*>9r|a%3|Yp?J7Tc`sUdy zSI#c^tgiON!dHJhjBlE=HD_4UiRF&953W6_s+;`$=+uIoogZ%eaP;n56VKmnXw~!M z(FKv8t(qCNegE6=ldI}F4nEyS4+!?-9_vnb?-H+neJC>!6?x{;HyT_$9F-2~Fcsw%9{Lp{*-vP7=q4O3_(YS!8KfEzGuzV2O}BBLk7B z>3qPToPSG^aC{GVZDjqQ6Hb{N5Xu!HMh%q|%f;8vnVv<0+CB-HbSiT+zNc6NycK3d z(2xB~r_M&%X=GxcBNX0#;98e6FOvs7+}}7RzR&D}cdg#v?>YGKgfj#84Q@VoX26=K zcbixJ-X-VaslX}sKYubH{%LVlTzaqJ1MfTvPZ{3u{^ip#Z__H?{Zn+}%VTHa-&C(V zT{qy=gSx@*N{7zTg&1p8HU8JiAL=)E>)X)#!$t%@XOB2N>eZ$1jC&u=sNd0esQksh zjiaYU9qI5*dFt_m&Sff?)>uWROr7mwRtAPTf~o#IiehDyas!h|-xG3KcUz)sLe#l% zo2fs@GNxHz25(31$jl2{JD;c+sbrDqxlqJN!Og*Fkb%lLkQ<;XpySDQO9NFY*Rhl{ z(eatF*f6FRVFoo)lB3e2Yve-QVAKeLjDCaJ!GEVe>zg!$=4jr;7{{>6%K8t$90@4~ zsfmA?@uZnqf=DT#zC=>%Z$)5*IuUshb|w_O7JyV!XWOFfA;ogPMBk_|Ij%%PbA)Gp z>5}C1YnA&obH|nJeS67oN+#h!|8d{sxL+BC280Yr)`G=U1z9XCdNx66N-t)_jWjV=_-IQ4){m)J)d$6_b zhZpzeef+ay>X_kC-{i{jPW{yC_?_q($>)2Y`00o#|If=eF3nBow=t^bR_)ZB8+B9~ zaeqHAY?W{+xnukG!@vblLK)J~joGP!P_8lZ5*>jml&oC#9)x}Km8o@79#-2~$^j+B zd0~BXSg~I1U23t4sU}WFr<7CN6CKe7r`}jMN-JsBxA95>7)7&Otce3u9Vg%@*s^Gd(;gm4jjRus;47TWw z+4i{-UMnhs#f(`AHZPK7EW82X7GG0}FEOF)CL>{_Gabu?iGaelli68EtAJV9Nu30Q z(KJQPTtGi*B)l1eQR5`HD zMc!JCpwalH>{C2?IQPAK_VSyPR{k~r%>AXqSG3*p`X859yKCwO<#`pZC~p0v!~Q=S zKAk#x#fq}3`bSr<{9Ux7mHgr_A)B8Z?>+Ed?b~597WON=;)r>&D53me`QFlo%TM}T zbkzUcShhB~<%IhBm$Tz;#ohURZl4jK7WJvy8SiM>yzZ02#`)I$izmA!JT!V`-CA<5 z&p4Ii){dUHwpTRAZToQcs^^7|dR&~{W^&(F2V)AlPg&)6vAsv)&>fxpZw~2L{b$*` z$64h$P2-D0-Sw3p%q>Um&m=KF$mGKpZq?fGU~3F&-L@9UoGO@<1RE(V#2F~O(e*9a z)AbiSUp`N(5KbI4I-O&eLe*k#ft=fvr1jxCs7iM=!uO-0Val#g6C?3}1fT+)Nx~A7 zMerSqGiI^m<+{L=pe2 z5e-+84xc%F<z)(CUv(5OBXWKNfEl|qin|Fo04v2X0fD?f zZOp%cB_bg&C|FES!?2`zc1tEV6r-80h$gQ#m;fclHQ1bl|G4Nbeh^~4ytUCi3P`3` zs-4u4YS+}A)%}i*sw~_zwdbU^J+@Z;_;~7&zDLUooDGEC1_a}z$t<0!0pqe&D^nd$f`{81rWjP3pZxk53PHJ?Q<*!|%-c3C}(cSN5KBV3sQT&`$@0bo