Compare commits
3
Commits
hasan_backend
..
ali
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79a3b47be5 | ||
|
|
6f18030f88 | ||
|
|
dace58ad6c |
@@ -13,7 +13,9 @@ import { buildContext } from './src/shared/middleware/context.middleware.js';
|
||||
const app = express();
|
||||
|
||||
// Middlewares
|
||||
app.use(helmet());
|
||||
app.use(helmet({
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
}));
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN || '*',
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'],
|
||||
@@ -38,7 +40,12 @@ app.get('/health', (req, res) => {
|
||||
});
|
||||
|
||||
// Centralized Feature Router Loader
|
||||
app.use('/uploads', express.static('uploads'));
|
||||
app.use('/uploads', express.static('uploads', {
|
||||
setHeaders: (res) => {
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
}
|
||||
}));
|
||||
registerRoutes(app);
|
||||
|
||||
// Global Error Handler
|
||||
|
||||
@@ -92,7 +92,7 @@ export class CatalogRepository {
|
||||
{
|
||||
model: models.Attribute,
|
||||
as: 'variantAxes',
|
||||
through: { attributes: [] },
|
||||
through: { attributes: ['id', 'required', 'display_order', 'active'] },
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
|
||||
@@ -101,6 +101,22 @@ export class CatalogService {
|
||||
throw new ApiError(404, 'Product Family not found');
|
||||
}
|
||||
await this.attachCounts(record);
|
||||
|
||||
if (record.variantAxes && Array.isArray(record.variantAxes)) {
|
||||
for (const axisAttr of record.variantAxes) {
|
||||
const throughId = axisAttr.FamilyVariantAxis ? axisAttr.FamilyVariantAxis.id : null;
|
||||
if (throughId) {
|
||||
const suggestedVals = await models.FamilyVariantAxisValue.findAll({
|
||||
where: { axis_id: throughId },
|
||||
order: [['sort_order', 'ASC']]
|
||||
});
|
||||
axisAttr.setDataValue('suggestedValues', suggestedVals.map(v => v.value));
|
||||
} else {
|
||||
axisAttr.setDataValue('suggestedValues', []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
@@ -189,13 +205,15 @@ export class CatalogService {
|
||||
// 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));
|
||||
const invalidAxes = data.variantAxes.filter(axis => {
|
||||
const attrId = typeof axis === 'string' ? axis : (axis.attributeId || axis.attribute_id || axis.id);
|
||||
return !attrId || !attributesSet.has(attrId);
|
||||
});
|
||||
if (invalidAxes.length > 0) {
|
||||
throw new Error('Variant axes must be a subset of family attributes');
|
||||
}
|
||||
variantAxes = uniqueAxes;
|
||||
variantAxes = data.variantAxes;
|
||||
}
|
||||
|
||||
// 5. Asset Family Validation & Deduplication
|
||||
@@ -271,13 +289,23 @@ export class CatalogService {
|
||||
throw new Error(`Total completeness rules weight must equal 100% (currently ${totalWeight}%)`);
|
||||
}
|
||||
|
||||
// Category Resolution
|
||||
const rawCategoryId = data.category || data.category_id || data.categoryId || null;
|
||||
let categoryId = null;
|
||||
if (rawCategoryId) {
|
||||
const catObj = await models.Categorie.findByPk(rawCategoryId, { transaction });
|
||||
if (catObj) {
|
||||
categoryId = catObj.id;
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Save Catalog
|
||||
const createData = {
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
status: data.status || 'draft',
|
||||
category_id: null,
|
||||
category_id: categoryId,
|
||||
workflow_code: workflowCode,
|
||||
completeness_rules: completenessRules,
|
||||
attribute_set_id: attributeSetId
|
||||
@@ -295,11 +323,32 @@ export class CatalogService {
|
||||
}
|
||||
|
||||
// Save variant axis relationships
|
||||
for (const attributeId of variantAxes) {
|
||||
await models.FamilyVariantAxis.create({
|
||||
for (let i = 0; i < variantAxes.length; i++) {
|
||||
const item = variantAxes[i];
|
||||
const attrId = typeof item === 'string' ? item : (item.attributeId || item.attribute_id || item.id);
|
||||
const required = typeof item === 'object' && item.required !== undefined ? !!item.required : true;
|
||||
const active = typeof item === 'object' && item.active !== undefined ? !!item.active : true;
|
||||
const displayOrder = typeof item === 'object' && item.display_order !== undefined ? item.display_order : i;
|
||||
|
||||
const axisRecord = await models.FamilyVariantAxis.create({
|
||||
family_id: record.id,
|
||||
attribute_id: attributeId
|
||||
attribute_id: attrId,
|
||||
required,
|
||||
display_order: displayOrder,
|
||||
active
|
||||
}, { transaction });
|
||||
|
||||
const suggestedValues = typeof item === 'object' ? (item.suggestedValues || item.suggested_values || []) : [];
|
||||
for (let j = 0; j < suggestedValues.length; j++) {
|
||||
const valItem = suggestedValues[j];
|
||||
const valText = typeof valItem === 'string' ? valItem : valItem.value;
|
||||
if (!valText) continue;
|
||||
await models.FamilyVariantAxisValue.create({
|
||||
axis_id: axisRecord.id,
|
||||
value: valText,
|
||||
sort_order: typeof valItem === 'object' && valItem.sort_order !== undefined ? valItem.sort_order : j
|
||||
}, { transaction });
|
||||
}
|
||||
}
|
||||
|
||||
// Save asset requirements
|
||||
@@ -420,14 +469,16 @@ export class CatalogService {
|
||||
// 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));
|
||||
const invalidAxes = data.variantAxes.filter(axis => {
|
||||
const attrId = typeof axis === 'string' ? axis : (axis.attributeId || axis.attribute_id || axis.id);
|
||||
return !attrId || !attributesSet.has(attrId);
|
||||
});
|
||||
if (invalidAxes.length > 0) {
|
||||
throw new Error('Variant axes must be a subset of family attributes');
|
||||
}
|
||||
variantAxes = uniqueAxes;
|
||||
variantAxes = data.variantAxes;
|
||||
}
|
||||
|
||||
// 5. Asset Family Validation & Deduplication
|
||||
@@ -509,6 +560,22 @@ export class CatalogService {
|
||||
throw new Error(`Total completeness rules weight must equal 100% (currently ${totalWeight}%)`);
|
||||
}
|
||||
|
||||
// Category Resolution for Update
|
||||
const rawCategoryId = data.hasOwnProperty('category') ? data.category : (data.hasOwnProperty('category_id') ? data.category_id : (data.hasOwnProperty('categoryId') ? data.categoryId : undefined));
|
||||
let categoryId = undefined;
|
||||
if (rawCategoryId !== undefined) {
|
||||
if (rawCategoryId) {
|
||||
const catObj = await models.Categorie.findByPk(rawCategoryId, { transaction });
|
||||
if (catObj) {
|
||||
categoryId = catObj.id;
|
||||
} else {
|
||||
categoryId = null;
|
||||
}
|
||||
} else {
|
||||
categoryId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Update Catalog
|
||||
const updateData = {
|
||||
name: data.name || record.name,
|
||||
@@ -518,6 +585,9 @@ export class CatalogService {
|
||||
completeness_rules: completenessRules,
|
||||
attribute_set_id: attributeSetId
|
||||
};
|
||||
if (categoryId !== undefined) {
|
||||
updateData.category_id = categoryId;
|
||||
}
|
||||
|
||||
await record.update(updateData, { transaction });
|
||||
|
||||
@@ -535,12 +605,40 @@ export class CatalogService {
|
||||
}
|
||||
|
||||
if (variantAxes !== null) {
|
||||
// Find existing axis records to clean up suggested values
|
||||
const existingAxes = await models.FamilyVariantAxis.findAll({ where: { family_id: id }, transaction });
|
||||
const existingAxisIds = existingAxes.map(a => a.id);
|
||||
if (existingAxisIds.length > 0) {
|
||||
await models.FamilyVariantAxisValue.destroy({ where: { axis_id: existingAxisIds }, transaction });
|
||||
}
|
||||
await models.FamilyVariantAxis.destroy({ where: { family_id: id }, transaction });
|
||||
for (const attributeId of variantAxes) {
|
||||
await models.FamilyVariantAxis.create({
|
||||
|
||||
for (let i = 0; i < variantAxes.length; i++) {
|
||||
const item = variantAxes[i];
|
||||
const attrId = typeof item === 'string' ? item : (item.attributeId || item.attribute_id || item.id);
|
||||
const required = typeof item === 'object' && item.required !== undefined ? !!item.required : true;
|
||||
const active = typeof item === 'object' && item.active !== undefined ? !!item.active : true;
|
||||
const displayOrder = typeof item === 'object' && item.display_order !== undefined ? item.display_order : i;
|
||||
|
||||
const axisRecord = await models.FamilyVariantAxis.create({
|
||||
family_id: id,
|
||||
attribute_id: attributeId
|
||||
attribute_id: attrId,
|
||||
required,
|
||||
display_order: displayOrder,
|
||||
active
|
||||
}, { transaction });
|
||||
|
||||
const suggestedValues = typeof item === 'object' ? (item.suggestedValues || item.suggested_values || []) : [];
|
||||
for (let j = 0; j < suggestedValues.length; j++) {
|
||||
const valItem = suggestedValues[j];
|
||||
const valText = typeof valItem === 'string' ? valItem : valItem.value;
|
||||
if (!valText) continue;
|
||||
await models.FamilyVariantAxisValue.create({
|
||||
axis_id: axisRecord.id,
|
||||
value: valText,
|
||||
sort_order: typeof valItem === 'object' && valItem.sort_order !== undefined ? valItem.sort_order : j
|
||||
}, { transaction });
|
||||
}
|
||||
}
|
||||
await AuditService.log({ action: 'VARIANT_AXES_CHANGED', resource: 'Catalog', resourceId: id, userId: context.userId || 'system', details: variantAxes });
|
||||
}
|
||||
|
||||
@@ -39,7 +39,16 @@ export const createValidation = [
|
||||
.isArray()
|
||||
.withMessage('Allowed units must be an array')
|
||||
.custom((value) => value.every(val => typeof val === 'string'))
|
||||
.withMessage('Allowed units must be an array of unit IDs')
|
||||
.withMessage('Allowed units must be an array of unit IDs'),
|
||||
body('category')
|
||||
.optional({ nullable: true })
|
||||
.isString(),
|
||||
body('category_id')
|
||||
.optional({ nullable: true })
|
||||
.isString(),
|
||||
body('categoryId')
|
||||
.optional({ nullable: true })
|
||||
.isString()
|
||||
];
|
||||
|
||||
export const updateValidation = [
|
||||
@@ -85,7 +94,16 @@ export const updateValidation = [
|
||||
.isArray()
|
||||
.withMessage('Allowed units must be an array')
|
||||
.custom((value) => value.every(val => typeof val === 'string'))
|
||||
.withMessage('Allowed units must be an array of unit IDs')
|
||||
.withMessage('Allowed units must be an array of unit IDs'),
|
||||
body('category')
|
||||
.optional({ nullable: true })
|
||||
.isString(),
|
||||
body('category_id')
|
||||
.optional({ nullable: true })
|
||||
.isString(),
|
||||
body('categoryId')
|
||||
.optional({ nullable: true })
|
||||
.isString()
|
||||
];
|
||||
|
||||
export const deleteValidation = [
|
||||
|
||||
@@ -1,20 +1,52 @@
|
||||
import { Model, DataTypes } from 'sequelize';
|
||||
|
||||
export class FamilyVariantAxis extends Model {
|
||||
static associate(models) {}
|
||||
static associate(models) {
|
||||
FamilyVariantAxis.belongsTo(models.Catalog, {
|
||||
foreignKey: 'family_id',
|
||||
as: 'family'
|
||||
});
|
||||
FamilyVariantAxis.belongsTo(models.Attribute, {
|
||||
foreignKey: 'attribute_id',
|
||||
as: 'attribute'
|
||||
});
|
||||
FamilyVariantAxis.hasMany(models.FamilyVariantAxisValue, {
|
||||
foreignKey: 'axis_id',
|
||||
as: 'suggestedValues'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default (sequelize) => {
|
||||
FamilyVariantAxis.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
family_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
primaryKey: true
|
||||
allowNull: false
|
||||
},
|
||||
attribute_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
required: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
primaryKey: true
|
||||
defaultValue: true
|
||||
},
|
||||
display_order: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
active: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Model, DataTypes } from 'sequelize';
|
||||
|
||||
export class FamilyVariantAxisValue extends Model {
|
||||
static associate(models) {
|
||||
FamilyVariantAxisValue.belongsTo(models.FamilyVariantAxis, {
|
||||
foreignKey: 'axis_id',
|
||||
as: 'axis'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default (sequelize) => {
|
||||
FamilyVariantAxisValue.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
axis_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
value: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
sort_order: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'FamilyVariantAxisValue',
|
||||
tableName: 'family_variant_axis_values',
|
||||
timestamps: true,
|
||||
underscored: true
|
||||
});
|
||||
|
||||
return FamilyVariantAxisValue;
|
||||
};
|
||||
@@ -99,6 +99,39 @@ export class ProductController {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
getVariantValues = async (req, res, next) => {
|
||||
try {
|
||||
const data = await service.getVariantValues(req.params.id, req.context);
|
||||
return res.status(200).json({ success: true, data });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
saveVariantValues = async (req, res, next) => {
|
||||
try {
|
||||
const data = await service.saveVariantValues(req.params.id, req.body.values, req.context);
|
||||
return res.status(200).json({ success: true, data });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
generateVariants = async (req, res, next) => {
|
||||
try {
|
||||
const result = await service.generateVariants(req.params.id, req.body, req.context);
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
variants: result.variants,
|
||||
generatedCount: result.generatedCount,
|
||||
warnings: result.warnings
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ProductController();
|
||||
|
||||
|
||||
@@ -171,4 +171,27 @@ router.post(
|
||||
controller.restore
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:id/variant-values',
|
||||
authenticate,
|
||||
authorize(['products.items']),
|
||||
controller.getVariantValues
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/variant-values',
|
||||
authenticate,
|
||||
authorize(['products.items']),
|
||||
controller.saveVariantValues
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/generate-variants',
|
||||
authenticate,
|
||||
authorize(['products.items']),
|
||||
audit('GENERATE_VARIANTS'),
|
||||
controller.generateVariants
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@@ -850,6 +850,67 @@ export class ProductService {
|
||||
|
||||
return restored;
|
||||
}
|
||||
|
||||
async getVariantValues(productId, context = {}) {
|
||||
const values = await models.ProductVariantValue.findAll({
|
||||
where: { product_id: productId },
|
||||
include: [
|
||||
{
|
||||
model: models.Attribute,
|
||||
as: 'axis',
|
||||
attributes: ['id', 'code', 'name', 'type']
|
||||
}
|
||||
],
|
||||
order: [['sort_order', 'ASC'], ['created_at', 'ASC']]
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
async saveVariantValues(productId, values = [], context = {}) {
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
await models.ProductVariantValue.destroy({
|
||||
where: { product_id: productId },
|
||||
transaction
|
||||
});
|
||||
|
||||
const records = [];
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const item = values[i];
|
||||
if (!item.axis_id || !item.value) continue;
|
||||
const created = await models.ProductVariantValue.create({
|
||||
product_id: productId,
|
||||
axis_id: item.axis_id,
|
||||
value: item.value,
|
||||
sort_order: item.sort_order !== undefined ? item.sort_order : i
|
||||
}, { transaction });
|
||||
records.push(created);
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
return records;
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async generateVariants(productId, data, context = {}) {
|
||||
// Import variantService dynamically to avoid circular dependencies
|
||||
const variantService = (await import('../../variants/variants/variant.service.js')).default;
|
||||
const result = await variantService.generateBatch({
|
||||
productId,
|
||||
axes: data.axes,
|
||||
skuTemplate: data.skuTemplate
|
||||
}, context);
|
||||
|
||||
return {
|
||||
variants: result.variants,
|
||||
generatedCount: result.created ? result.created.length : 0,
|
||||
warnings: result.skipped ? result.skipped.map(s => `Skipped combination ${JSON.stringify(s.combination)}: ${s.reason}`) : []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default new ProductService();
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Model, DataTypes } from 'sequelize';
|
||||
|
||||
export class ProductVariantValue extends Model {
|
||||
static associate(models) {
|
||||
ProductVariantValue.belongsTo(models.Product, {
|
||||
foreignKey: 'product_id',
|
||||
as: 'product'
|
||||
});
|
||||
ProductVariantValue.belongsTo(models.Attribute, {
|
||||
foreignKey: 'axis_id',
|
||||
as: 'axis'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default (sequelize) => {
|
||||
ProductVariantValue.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
product_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
axis_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
value: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
sort_order: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'ProductVariantValue',
|
||||
tableName: 'product_variant_values',
|
||||
timestamps: true,
|
||||
underscored: true
|
||||
});
|
||||
|
||||
return ProductVariantValue;
|
||||
};
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Router } from 'express';
|
||||
import settingsRouter from './settings/setting.routes.js';
|
||||
import themeRouter from './theme/routes/theme.routes.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use('/settings/theme', themeRouter);
|
||||
router.use('/settings', settingsRouter);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -3,6 +3,7 @@ import controller from './setting.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,
|
||||
@@ -58,20 +59,11 @@ router.get(
|
||||
* @swagger
|
||||
* /api/v1/settings:
|
||||
* post:
|
||||
* summary: Create a new setting
|
||||
* summary: Create a setting
|
||||
* tags: [Settings]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Created
|
||||
* description: Success
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
@@ -79,6 +71,7 @@ router.post(
|
||||
authorize(['settings.users']),
|
||||
createValidation,
|
||||
validate,
|
||||
audit('CREATE_SETTING'),
|
||||
controller.create
|
||||
);
|
||||
|
||||
@@ -86,23 +79,12 @@ router.post(
|
||||
* @swagger
|
||||
* /api/v1/settings/{id}:
|
||||
* put:
|
||||
* summary: Update an existing setting
|
||||
* summary: Update a setting
|
||||
* tags: [Settings]
|
||||
* 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
|
||||
@@ -113,6 +95,7 @@ router.put(
|
||||
authorize(['settings.users']),
|
||||
updateValidation,
|
||||
validate,
|
||||
audit('UPDATE_SETTING'),
|
||||
controller.update
|
||||
);
|
||||
|
||||
@@ -126,8 +109,6 @@ router.put(
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
@@ -138,6 +119,7 @@ router.delete(
|
||||
authorize(['settings.users']),
|
||||
deleteValidation,
|
||||
validate,
|
||||
audit('DELETE_SETTING'),
|
||||
controller.delete
|
||||
);
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export const DEFAULT_THEME = 'royal-purple';
|
||||
|
||||
export const ALLOWED_THEME_CODES = [
|
||||
'royal-purple',
|
||||
'forest-green',
|
||||
'ocean-blue',
|
||||
'sunset-orange',
|
||||
'dark'
|
||||
];
|
||||
|
||||
export const SYSTEM_THEMES = ['royal-purple', 'dark'];
|
||||
@@ -1,31 +0,0 @@
|
||||
import service from '../services/theme.service.js';
|
||||
|
||||
export class ThemeController {
|
||||
getCurrentTheme = async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.user_id || req.user.id;
|
||||
const data = await service.getCurrentTheme(userId);
|
||||
return res.status(200).json({ success: true, data });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
updateCurrentTheme = async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.user_id || req.user.id;
|
||||
const { themeCode } = req.body;
|
||||
const requestContext = {
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
userAgent: req.headers['user-agent']
|
||||
};
|
||||
await service.updateCurrentTheme(userId, themeCode, requestContext);
|
||||
return res.status(200).json({ success: true, message: 'Theme updated successfully.' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ThemeController();
|
||||
@@ -1,5 +0,0 @@
|
||||
export { default as themeRoutes } from './routes/theme.routes.js';
|
||||
export { default as themeController } from './controllers/theme.controller.js';
|
||||
export { default as themeService } from './services/theme.service.js';
|
||||
export { default as themeRepository } from './repositories/theme.repository.js';
|
||||
export { default as userThemeModel } from './models/userTheme.model.js';
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Model, DataTypes } from 'sequelize';
|
||||
|
||||
export class UserTheme extends Model {
|
||||
static associate(models) {
|
||||
UserTheme.belongsTo(models.User, {
|
||||
foreignKey: 'user_id',
|
||||
as: 'user',
|
||||
onDelete: 'CASCADE'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default (sequelize) => {
|
||||
UserTheme.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
unique: true
|
||||
},
|
||||
theme_code: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'UserTheme',
|
||||
tableName: 'user_themes',
|
||||
timestamps: true,
|
||||
underscored: true
|
||||
});
|
||||
|
||||
return UserTheme;
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
import { models } from '../../../../shared/database/models.js';
|
||||
|
||||
export class ThemeRepository {
|
||||
async findByUserId(userId, options = {}) {
|
||||
return await models.UserTheme.findOne({
|
||||
where: { user_id: userId },
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
async create(data, options = {}) {
|
||||
return await models.UserTheme.create(data, options);
|
||||
}
|
||||
|
||||
async update(userId, themeCode, options = {}) {
|
||||
const record = await this.findByUserId(userId, options);
|
||||
if (!record) return null;
|
||||
return await record.update({ theme_code: themeCode }, options);
|
||||
}
|
||||
|
||||
async save(userThemeInstance, options = {}) {
|
||||
return await userThemeInstance.save(options);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ThemeRepository();
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import controller from '../controllers/theme.controller.js';
|
||||
import { authenticate } from '../../../../shared/middleware/auth.middleware.js';
|
||||
import { validate } from '../../../../shared/middleware/validation.middleware.js';
|
||||
import { themeValidation } from '../validators/theme.validation.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/settings/theme:
|
||||
* get:
|
||||
* summary: Retrieve user theme preference
|
||||
* tags: [Theme]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
*/
|
||||
router.get(
|
||||
'/',
|
||||
authenticate,
|
||||
controller.getCurrentTheme
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/settings/theme:
|
||||
* put:
|
||||
* summary: Update user theme preference
|
||||
* tags: [Theme]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
*/
|
||||
router.put(
|
||||
'/',
|
||||
authenticate,
|
||||
themeValidation,
|
||||
validate,
|
||||
controller.updateCurrentTheme
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -1,72 +0,0 @@
|
||||
import repository from '../repositories/theme.repository.js';
|
||||
import { ALLOWED_THEME_CODES, DEFAULT_THEME } from '../constants/theme.constants.js';
|
||||
import { sequelize } from '../../../../shared/database/models.js';
|
||||
import { AuditService } from '../../../../shared/services/audit.service.js';
|
||||
import { ApiError } from '../../../../utils/helpers/ApiError.utils.js';
|
||||
|
||||
export class ThemeService {
|
||||
async getCurrentTheme(userId) {
|
||||
if (!userId) {
|
||||
return { themeCode: DEFAULT_THEME };
|
||||
}
|
||||
const preference = await repository.findByUserId(userId);
|
||||
return {
|
||||
themeCode: preference ? preference.theme_code : DEFAULT_THEME
|
||||
};
|
||||
}
|
||||
|
||||
validateTheme(themeCode) {
|
||||
if (!ALLOWED_THEME_CODES.includes(themeCode)) {
|
||||
throw new ApiError(400, `Invalid theme code: "${themeCode}"`);
|
||||
}
|
||||
}
|
||||
|
||||
async updateCurrentTheme(userId, themeCode, requestContext = {}) {
|
||||
if (!userId) {
|
||||
throw new ApiError(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
this.validateTheme(themeCode);
|
||||
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
const existing = await repository.findByUserId(userId, { transaction });
|
||||
const oldTheme = existing ? existing.theme_code : DEFAULT_THEME;
|
||||
|
||||
let result;
|
||||
if (existing) {
|
||||
result = await existing.update({ theme_code: themeCode }, { transaction });
|
||||
} else {
|
||||
result = await repository.create({
|
||||
user_id: userId,
|
||||
theme_code: themeCode
|
||||
}, { transaction });
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
// Log the theme change via the existing Audit System.
|
||||
// We pass a dummy 'method' in details to satisfy AuditService validation checks.
|
||||
await AuditService.log({
|
||||
action: 'UPDATE_THEME_PREFERENCE',
|
||||
resource: 'settings',
|
||||
resourceId: result.id,
|
||||
userId: userId,
|
||||
old_value: { theme_code: oldTheme },
|
||||
new_value: { theme_code: themeCode },
|
||||
details: {
|
||||
method: requestContext.method || 'PUT',
|
||||
ip: requestContext.ip || '127.0.0.1',
|
||||
userAgent: requestContext.userAgent || 'system'
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ThemeService();
|
||||
@@ -1,12 +0,0 @@
|
||||
import { body } from 'express-validator';
|
||||
import { ALLOWED_THEME_CODES } from '../constants/theme.constants.js';
|
||||
|
||||
export const themeValidation = [
|
||||
body('themeCode')
|
||||
.notEmpty()
|
||||
.withMessage('themeCode is required')
|
||||
.isString()
|
||||
.withMessage('themeCode must be a string')
|
||||
.isIn(ALLOWED_THEME_CODES)
|
||||
.withMessage(`themeCode must be one of: ${ALLOWED_THEME_CODES.join(', ')}`)
|
||||
];
|
||||
@@ -0,0 +1,169 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
// 1. Extend or recreate family_variant_axes to ensure id, required, display_order, active exist
|
||||
const familyAxesExists = await queryInterface.describeTable('family_variant_axes').catch(() => null);
|
||||
if (familyAxesExists) {
|
||||
if (!familyAxesExists.id) {
|
||||
await queryInterface.addColumn('family_variant_axes', 'id', {
|
||||
type: Sequelize.UUID,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
allowNull: true
|
||||
});
|
||||
}
|
||||
if (!familyAxesExists.required) {
|
||||
await queryInterface.addColumn('family_variant_axes', 'required', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true
|
||||
});
|
||||
}
|
||||
if (!familyAxesExists.display_order) {
|
||||
await queryInterface.addColumn('family_variant_axes', 'display_order', {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
});
|
||||
}
|
||||
if (!familyAxesExists.active) {
|
||||
await queryInterface.addColumn('family_variant_axes', 'active', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await queryInterface.createTable('family_variant_axes', {
|
||||
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'
|
||||
},
|
||||
attribute_id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: { model: 'attributes', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'RESTRICT'
|
||||
},
|
||||
required: {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true
|
||||
},
|
||||
display_order: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
active: {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Create family_variant_axis_values table
|
||||
const familyAxisValuesExists = await queryInterface.describeTable('family_variant_axis_values').catch(() => null);
|
||||
if (!familyAxisValuesExists) {
|
||||
await queryInterface.createTable('family_variant_axis_values', {
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
axis_id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
value: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
sort_order: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false
|
||||
}
|
||||
});
|
||||
await queryInterface.addIndex('family_variant_axis_values', ['axis_id'], { name: 'idx_fam_var_axis_val_axis' }).catch(() => null);
|
||||
}
|
||||
|
||||
// 3. Create product_variant_values table
|
||||
const productVariantValuesExists = await queryInterface.describeTable('product_variant_values').catch(() => null);
|
||||
if (!productVariantValuesExists) {
|
||||
await queryInterface.createTable('product_variant_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'
|
||||
},
|
||||
axis_id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: { model: 'attributes', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'RESTRICT'
|
||||
},
|
||||
value: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
sort_order: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false
|
||||
}
|
||||
});
|
||||
await queryInterface.addIndex('product_variant_values', ['product_id'], { name: 'idx_prod_var_val_prod' }).catch(() => null);
|
||||
}
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.dropTable('product_variant_values').catch(() => null);
|
||||
await queryInterface.dropTable('family_variant_axis_values').catch(() => null);
|
||||
}
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.createTable('user_themes', {
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
user_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
theme_code: {
|
||||
type: Sequelize.STRING(50),
|
||||
allowNull: false
|
||||
},
|
||||
created_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
},
|
||||
updated_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
}
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('user_themes', ['user_id']);
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.dropTable('user_themes');
|
||||
}
|
||||
};
|
||||
@@ -18,11 +18,17 @@ const storage = multer.diskStorage({
|
||||
});
|
||||
|
||||
export const fileFilter = (req, file, cb) => {
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf', 'text/csv', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'];
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
const allowedTypes = [
|
||||
'image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif', 'image/svg+xml', 'image/avif', 'image/bmp',
|
||||
'video/mp4', 'video/webm', 'video/quicktime',
|
||||
'application/pdf', 'text/csv', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-excel', 'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
];
|
||||
if (allowedTypes.includes(file.mimetype) || file.mimetype.startsWith('image/') || file.mimetype.startsWith('video/')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type'), false);
|
||||
cb(new Error(`Invalid file type: ${file.mimetype}`), false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -16,10 +16,6 @@ export const associateModels = () => {
|
||||
});
|
||||
|
||||
// Explicit RBAC Associations
|
||||
if (models.User && models.UserTheme) {
|
||||
models.User.hasOne(models.UserTheme, { foreignKey: 'user_id', as: 'theme' });
|
||||
}
|
||||
|
||||
if (models.Tenant && models.User) {
|
||||
models.User.belongsTo(models.Tenant, { foreignKey: 'tenant_id', as: 'tenant' });
|
||||
models.Tenant.hasMany(models.User, { foreignKey: 'tenant_id', as: 'users' });
|
||||
@@ -133,7 +129,6 @@ import brandModelInit from '../../features/brands/brands/brand.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';
|
||||
import userThemeModelInit from '../../features/settings/theme/models/userTheme.model.js';
|
||||
|
||||
// Attribute Management Models
|
||||
import attributeModelInit from '../../features/attributes/attributes/attribute.model.js';
|
||||
@@ -149,6 +144,7 @@ import attributeSetGroupModelInit from '../../features/attributes/attributeSets/
|
||||
// Product Family Bridge Models
|
||||
import familyAttributeModelInit from '../../features/catalogs/catalogs/familyAttribute.model.js';
|
||||
import familyVariantAxisModelInit from '../../features/catalogs/catalogs/familyVariantAxis.model.js';
|
||||
import familyVariantAxisValueModelInit from '../../features/catalogs/catalogs/familyVariantAxisValue.model.js';
|
||||
import familyAssetRequirementModelInit from '../../features/catalogs/catalogs/familyAssetRequirement.model.js';
|
||||
import familyChannelModelInit from '../../features/catalogs/catalogs/familyChannel.model.js';
|
||||
|
||||
@@ -178,6 +174,7 @@ 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 productVariantValueModelInit from '../../features/products/products/productVariantValue.model.js';
|
||||
import productCompletenessModelInit from '../../features/products/products/productCompleteness.model.js';
|
||||
|
||||
export const initializeDatabaseModels = () => {
|
||||
@@ -196,7 +193,6 @@ export const initializeDatabaseModels = () => {
|
||||
registerModel('Unit', unitModelInit);
|
||||
registerModel('Setting', settingModelInit);
|
||||
registerModel('AuditLog', auditLogModelInit);
|
||||
registerModel('UserTheme', userThemeModelInit);
|
||||
|
||||
// Attribute Management
|
||||
registerModel('Attribute', attributeModelInit);
|
||||
@@ -212,6 +208,7 @@ export const initializeDatabaseModels = () => {
|
||||
// Product Family Bridges
|
||||
registerModel('FamilyAttribute', familyAttributeModelInit);
|
||||
registerModel('FamilyVariantAxis', familyVariantAxisModelInit);
|
||||
registerModel('FamilyVariantAxisValue', familyVariantAxisValueModelInit);
|
||||
registerModel('FamilyAssetRequirement', familyAssetRequirementModelInit);
|
||||
registerModel('FamilyChannel', familyChannelModelInit);
|
||||
|
||||
@@ -220,6 +217,7 @@ export const initializeDatabaseModels = () => {
|
||||
registerModel('ChannelType', channelTypeModelInit);
|
||||
registerModel('WorkflowRegistry', workflowModelInit);
|
||||
registerModel('ProductAttributeValue', productAttributeValueModelInit);
|
||||
registerModel('ProductVariantValue', productVariantValueModelInit);
|
||||
registerModel('ProductCompleteness', productCompletenessModelInit);
|
||||
|
||||
// Variants
|
||||
@@ -247,3 +245,4 @@ export const initializeDatabaseModels = () => {
|
||||
};
|
||||
|
||||
export { models, sequelize };
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Reference in New Issue
Block a user