solved merge conflicts

This commit is contained in:
m7338ohd-debug
2026-07-18 16:35:34 +05:30
67 changed files with 1492 additions and 411 deletions
+19 -19
View File
@@ -1,21 +1,21 @@
PORT=5000
NODE_ENV=development
CORS_ORIGIN=http://localhost:5173
JWT_SECRET=supersecretjwtkeythatislongandsecure
JWT_EXPIRES_IN=7d
# PORT=5000
# NODE_ENV=development
# CORS_ORIGIN=http://localhost:5173
# JWT_SECRET=supersecretjwtkeythatislongandsecure
# JWT_EXPIRES_IN=7d
DB_HOST=106.51.105.22
DB_PORT=5432
DB_NAME=pc_dev
DB_USER=pc_user
DB_PASSWORD="#TpW@%a&b$[zm"
DB_DIALECT=postgres
# DB_HOST=106.51.105.22
# DB_PORT=5432
# DB_NAME=pc_dev
# DB_USER=pc_user
# DB_PASSWORD="#TpW@%a&b$[zm"
# DB_DIALECT=postgres
# IMPORTANT: Gmail SMTP requires an App Password, NOT your regular password.
# Go to: https://myaccount.google.com -> Security -> 2-Step Verification -> App Passwords
# Generate a 16-character App Password and paste it below (no spaces).
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USER=pmahboob001@gmail.com
EMAIL_PASSWORD=hlwg yfgi xmkn lqmg
EMAIL_FROM=Maskan PIM <pmahboob001@gmail.com>
# # IMPORTANT: Gmail SMTP requires an App Password, NOT your regular password.
# # Go to: https://myaccount.google.com -> Security -> 2-Step Verification -> App Passwords
# # Generate a 16-character App Password and paste it below (no spaces).
# EMAIL_HOST=smtp.gmail.com
# EMAIL_PORT=587
# EMAIL_USER=pmahboob001@gmail.com
# EMAIL_PASSWORD=hlwg yfgi xmkn lqmg
# EMAIL_FROM=Maskan PIM <pmahboob001@gmail.com>
+11 -11
View File
@@ -1,12 +1,12 @@
PORT=5000
NODE_ENV=development
CORS_ORIGIN=http://localhost:3000
JWT_SECRET=supersecretjwtkeythatislongandsecure
JWT_EXPIRES_IN=7d
# PORT=5000
# NODE_ENV=development
# CORS_ORIGIN=http://localhost:3000
# JWT_SECRET=supersecretjwtkeythatislongandsecure
# JWT_EXPIRES_IN=7d
DB_HOST=127.0.0.1
DB_PORT=5432
DB_USER=postgres
DB_PASS=postgres
DB_NAME=maskan_pim
DB_DIALECT=postgres
# DB_HOST=127.0.0.1
# DB_PORT=5432
# DB_USER=postgres
# DB_PASS=postgres
# DB_NAME=maskan_pim
# DB_DIALECT=postgres
+4 -4
View File
@@ -4,11 +4,11 @@ CORS_ORIGIN=http://localhost:5173
JWT_SECRET=supersecretjwtkeythatislongandsecure
JWT_EXPIRES_IN=7d
DB_HOST=106.51.105.22
DB_HOST=127.0.0.1
DB_PORT=5432
DB_NAME=pc_local
DB_USER=pc_user
DB_PASSWORD="#TpW@%a&b$[zm"
DB_NAME=Pim_Maskan
DB_USER=postgres
DB_PASSWORD=postgres
DB_DIALECT=postgres
# IMPORTANT: Gmail SMTP requires an App Password, NOT your regular password.
+19 -19
View File
@@ -1,21 +1,21 @@
PORT=5000
NODE_ENV=test
CORS_ORIGIN=http://localhost:5173
JWT_SECRET=supersecretjwtkeythatislongandsecure
JWT_EXPIRES_IN=7d
# PORT=5000
# NODE_ENV=test
# CORS_ORIGIN=http://localhost:5173
# JWT_SECRET=supersecretjwtkeythatislongandsecure
# JWT_EXPIRES_IN=7d
DB_HOST=106.51.105.22
DB_PORT=5432
DB_NAME=pc_test
DB_USER=pc_user
DB_PASSWORD="#TpW@%a&b$[zm"
DB_DIALECT=postgres
# DB_HOST=106.51.105.22
# DB_PORT=5432
# DB_NAME=pc_test
# DB_USER=pc_user
# DB_PASSWORD="#TpW@%a&b$[zm"
# DB_DIALECT=postgres
# IMPORTANT: Gmail SMTP requires an App Password, NOT your regular password.
# Go to: https://myaccount.google.com -> Security -> 2-Step Verification -> App Passwords
# Generate a 16-character App Password and paste it below (no spaces).
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USER=pmahboob001@gmail.com
EMAIL_PASSWORD=hlwg yfgi xmkn lqmg
EMAIL_FROM=Maskan PIM <pmahboob001@gmail.com>
# # IMPORTANT: Gmail SMTP requires an App Password, NOT your regular password.
# # Go to: https://myaccount.google.com -> Security -> 2-Step Verification -> App Passwords
# # Generate a 16-character App Password and paste it below (no spaces).
# EMAIL_HOST=smtp.gmail.com
# EMAIL_PORT=587
# EMAIL_USER=pmahboob001@gmail.com
# EMAIL_PASSWORD=hlwg yfgi xmkn lqmg
# EMAIL_FROM=Maskan PIM <pmahboob001@gmail.com>
@@ -4,11 +4,11 @@ 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
import {
createValidation,
updateValidation,
deleteValidation,
getByIdValidation
} from './attributeGroup.validation.js';
const router = Router();
@@ -26,7 +26,7 @@ const router = Router();
router.get(
'/',
authenticate,
authorize(['read:attributes']),
authorize(['products.attributes']),
controller.getAll
);
@@ -47,7 +47,7 @@ router.get(
router.get(
'/:id',
authenticate,
authorize(['read:attributes']),
authorize(['products.attributes']),
getByIdValidation,
validate,
controller.getById
@@ -66,7 +66,7 @@ router.get(
router.post(
'/',
authenticate,
authorize(['write:attributes']),
authorize(['products.attributes']),
createValidation,
validate,
audit('CREATE_ATTRIBUTE_GROUP'),
@@ -90,7 +90,7 @@ router.post(
router.put(
'/:id',
authenticate,
authorize(['write:attributes']),
authorize(['products.attributes']),
updateValidation,
validate,
audit('UPDATE_ATTRIBUTE_GROUP'),
@@ -114,7 +114,7 @@ router.put(
router.delete(
'/:id',
authenticate,
authorize(['write:attributes']),
authorize(['products.attributes']),
deleteValidation,
validate,
audit('DELETE_ATTRIBUTE_GROUP'),
@@ -4,11 +4,11 @@ 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
import {
createValidation,
updateValidation,
deleteValidation,
getByIdValidation
} from './attributeSet.validation.js';
const router = Router();
@@ -26,7 +26,7 @@ const router = Router();
router.get(
'/',
authenticate,
authorize(['read:attributes']),
authorize(['products.attributes']),
controller.getAll
);
@@ -47,7 +47,7 @@ router.get(
router.get(
'/:id',
authenticate,
authorize(['read:attributes']),
authorize(['products.attributes']),
getByIdValidation,
validate,
controller.getById
@@ -56,7 +56,7 @@ router.get(
router.get(
'/:id/structure',
authenticate,
authorize(['read:attributes']),
authorize(['products.attributes']),
getByIdValidation,
validate,
controller.getById
@@ -75,7 +75,7 @@ router.get(
router.post(
'/',
authenticate,
authorize(['write:attributes']),
authorize(['products.attributes']),
createValidation,
validate,
audit('CREATE_ATTRIBUTE_SET'),
@@ -99,7 +99,7 @@ router.post(
router.put(
'/:id',
authenticate,
authorize(['write:attributes']),
authorize(['products.attributes']),
updateValidation,
validate,
audit('UPDATE_ATTRIBUTE_SET'),
@@ -123,7 +123,7 @@ router.put(
router.delete(
'/:id',
authenticate,
authorize(['write:attributes']),
authorize(['products.attributes']),
deleteValidation,
validate,
audit('DELETE_ATTRIBUTE_SET'),
@@ -4,11 +4,11 @@ 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
import {
createValidation,
updateValidation,
deleteValidation,
getByIdValidation
} from './attribute.validation.js';
const router = Router();
@@ -26,7 +26,7 @@ const router = Router();
router.get(
'/',
authenticate,
authorize(['read:attributes']),
authorize(['products.attributes']),
controller.getAll
);
@@ -49,7 +49,7 @@ router.get(
router.get(
'/:id',
authenticate,
authorize(['read:attributes']),
authorize(['products.attributes']),
getByIdValidation,
validate,
controller.getById
@@ -68,7 +68,7 @@ router.get(
router.post(
'/',
authenticate,
authorize(['write:attributes']),
authorize(['products.attributes']),
createValidation,
validate,
audit('CREATE_ATTRIBUTE'),
@@ -92,7 +92,7 @@ router.post(
router.put(
'/:id',
authenticate,
authorize(['write:attributes']),
authorize(['products.attributes']),
updateValidation,
validate,
audit('UPDATE_ATTRIBUTE'),
@@ -116,7 +116,7 @@ router.put(
router.delete(
'/:id',
authenticate,
authorize(['write:attributes']),
authorize(['products.attributes']),
deleteValidation,
validate,
audit('DELETE_ATTRIBUTE'),
@@ -126,7 +126,7 @@ router.delete(
router.post(
'/:id/restore',
authenticate,
authorize(['write:attributes']),
authorize(['products.attributes']),
getByIdValidation,
validate,
audit('RESTORE_ATTRIBUTE'),
@@ -2,6 +2,7 @@ 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 { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import { Op } from 'sequelize';
export class AttributeService {
@@ -122,7 +123,7 @@ export class AttributeService {
]
});
if (!record) {
throw new Error('Attribute not found');
throw new ApiError(404, 'Attribute not found');
}
return record;
}
@@ -306,45 +307,64 @@ export class AttributeService {
try {
const record = await models.Attribute.findByPk(id, { transaction });
if (!record) {
throw new Error('Attribute not found');
throw new ApiError(404, 'Attribute not found');
}
// Usage Check: Groups mapping
const groupCount = await models.AttributeGroupAttribute.count({
where: { attribute_id: id },
// Usage Check: Groups mapping (count only active groups)
const groupCount = await models.AttributeGroup.count({
include: [{
model: models.Attribute,
as: 'attributes',
where: { id: id },
required: true
}],
transaction
});
// Usage Check: Product Families mapping (count only active catalogs)
const familyCount = await models.Catalog.count({
include: [{
model: models.Attribute,
as: 'attributes',
where: { id: id },
required: true
}],
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 },
// Usage Check: Variant axes mapping (count only active catalogs)
const axisCount = await models.Catalog.count({
include: [{
model: models.Attribute,
as: 'variantAxes',
where: { id: id },
required: true
}],
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
let valCount = 0;
if (models.VariantValue) {
const valCount = await models.VariantValue.count({
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.');
}
}
console.log('DIAGNOSTIC attribute delete:', { id, groupCount, familyCount, axisCount, valCount });
if (groupCount > 0) {
throw new Error('This attribute is currently in use and cannot be deleted.');
}
if (familyCount > 0) {
throw new Error('This attribute is currently in use and cannot be deleted.');
}
if (axisCount > 0) {
throw new Error('This attribute is currently in use and cannot be deleted.');
}
if (valCount > 0) {
throw new Error('This attribute is currently in use and cannot be deleted.');
}
const oldValues = record.toJSON();
@@ -2,7 +2,7 @@ import { Model, DataTypes } from 'sequelize';
export class AuditLog extends Model {
static associate(models) {
// Define associations here
// user_id is a plain INTEGER reference - no Sequelize association needed for audit log
}
}
@@ -14,17 +14,37 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
name: {
action: {
type: DataTypes.STRING,
allowNull: false
},
resource: {
type: DataTypes.STRING,
allowNull: false
},
resource_id: {
type: DataTypes.STRING,
allowNull: true
},
user_id: {
type: DataTypes.INTEGER,
allowNull: true
},
old_value: {
type: DataTypes.JSONB,
allowNull: true
},
new_value: {
type: DataTypes.JSONB,
allowNull: true
},
details: {
type: DataTypes.JSONB,
allowNull: true
},
status: {
type: DataTypes.STRING,
defaultValue: 'active'
},
metadata: {
type: DataTypes.JSONB,
allowNull: true
defaultValue: 'success'
}
}, {
sequelize,
@@ -26,7 +26,7 @@ const router = Router();
router.get(
'/',
authenticate,
authorize(['read:auditLogs']),
authorize(['settings.roles']),
controller.getAll
);
@@ -49,7 +49,7 @@ router.get(
router.get(
'/:id',
authenticate,
authorize(['read:auditLogs']),
authorize(['settings.roles']),
getByIdValidation,
validate,
controller.getById
@@ -68,7 +68,7 @@ router.get(
router.post(
'/',
authenticate,
authorize(['write:auditLogs']),
authorize(['settings.roles']),
createValidation,
validate,
audit('CREATE_AUDITLOG'),
@@ -92,7 +92,7 @@ router.post(
router.put(
'/:id',
authenticate,
authorize(['write:auditLogs']),
authorize(['settings.roles']),
updateValidation,
validate,
audit('UPDATE_AUDITLOG'),
@@ -116,7 +116,7 @@ router.put(
router.delete(
'/:id',
authenticate,
authorize(['write:auditLogs']),
authorize(['settings.roles']),
deleteValidation,
validate,
audit('DELETE_AUDITLOG'),
@@ -1,6 +1,7 @@
import repository from './auditLog.repository.js';
import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
export class AuditLogService {
async getAll(query = {}) {
@@ -11,7 +12,7 @@ export class AuditLogService {
async getById(id) {
const record = await repository.findById(id);
if (!record) {
throw new Error('AuditLog not found');
throw new ApiError(404, 'AuditLog not found');
}
return record;
}
@@ -27,7 +28,7 @@ export class AuditLogService {
action: 'CREATE',
resource: 'AuditLog',
resourceId: record.id,
userId: userContext.id || 'system',
userId: userContext.userId || 'system',
details: data
});
@@ -37,7 +38,7 @@ export class AuditLogService {
async update(id, data, userContext = {}) {
const record = await repository.update(id, data);
if (!record) {
throw new Error('AuditLog not found');
throw new ApiError(404, 'AuditLog not found');
}
SocketService.broadcast('auditLog:updated', record);
@@ -46,7 +47,7 @@ export class AuditLogService {
action: 'UPDATE',
resource: 'AuditLog',
resourceId: id,
userId: userContext.id || 'system',
userId: userContext.userId || 'system',
details: data
});
@@ -56,7 +57,7 @@ export class AuditLogService {
async delete(id, userContext = {}) {
const deleted = await repository.delete(id);
if (!deleted) {
throw new Error('AuditLog not found');
throw new ApiError(404, 'AuditLog not found');
}
SocketService.broadcast('auditLog:deleted', { id });
@@ -65,7 +66,7 @@ export class AuditLogService {
action: 'DELETE',
resource: 'AuditLog',
resourceId: id,
userId: userContext.id || 'system'
userId: userContext.userId || 'system'
});
return true;
@@ -22,7 +22,7 @@ const router = Router();
router.get(
'/',
authenticate,
authorize(['settings.roles.view']),
authorize(['settings.roles']),
controller.getAll
);
@@ -66,7 +66,7 @@ router.get(
router.get(
'/permissions',
authenticate,
authorize(['settings.roles.view']),
authorize(['settings.roles']),
controller.getPermissions
);
@@ -93,7 +93,7 @@ router.get(
router.get(
'/:id',
authenticate,
authorize(['settings.roles.view']),
authorize(['settings.roles']),
controller.getById
);
@@ -126,7 +126,7 @@ router.get(
router.post(
'/',
authenticate,
authorize(['settings.roles.create']),
authorize(['settings.roles']),
controller.create
);
@@ -151,7 +151,7 @@ router.post(
router.put(
'/:id',
authenticate,
authorize(['settings.roles.edit']),
authorize(['settings.roles']),
controller.update
);
@@ -176,7 +176,7 @@ router.put(
router.delete(
'/:id',
authenticate,
authorize(['settings.roles.delete']),
authorize(['settings.roles']),
controller.delete
);
@@ -1,5 +1,6 @@
import repository from './role.repository.js';
import { models } from '../../../shared/database/models.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
export class RoleService {
async getAll(query = {}, context = {}) {
@@ -20,7 +21,7 @@ export class RoleService {
async getById(id, context = {}) {
const record = await repository.findById(id, {}, context);
if (!record) {
throw new Error('Role not found');
throw new ApiError(404, 'Role not found');
}
return record;
}
@@ -61,14 +62,18 @@ export class RoleService {
async delete(id, context = {}) {
const deleted = await repository.delete(id, context);
if (!deleted) {
throw new Error('Role not found');
throw new ApiError(404, 'Role not found');
}
return true;
}
// Get all permission nodes currently in database
// Get all permission nodes — returns full rows including capability flags
// (can_view, can_create, can_edit, can_delete, can_alter, can_export, can_import)
// The frontend uses these flags to determine which action pills to render per module.
async getPermissionNodes(context = {}) {
return await models.PermissionNode.findAll();
return await models.PermissionNode.findAll({
order: [['display_order', 'ASC'], ['node_level', 'ASC'], ['id', 'ASC']],
});
}
}
@@ -22,7 +22,7 @@ const router = Router();
router.get(
'/',
authenticate,
authorize(['settings.users.view']),
authorize(['settings.users']),
controller.getAll
);
@@ -49,7 +49,7 @@ router.get(
router.get(
'/:id',
authenticate,
authorize(['settings.users.view']),
authorize(['settings.users']),
controller.getById
);
@@ -82,7 +82,7 @@ router.get(
router.post(
'/invite',
authenticate,
authorize(['settings.users.create']),
authorize(['settings.users']),
controller.invite
);
@@ -107,7 +107,7 @@ router.post(
router.put(
'/:id',
authenticate,
authorize(['settings.users.edit']),
authorize(['settings.users']),
controller.update
);
@@ -132,7 +132,7 @@ router.put(
router.delete(
'/:id',
authenticate,
authorize(['settings.users.delete']),
authorize(['settings.users']),
controller.delete
);
@@ -2,6 +2,7 @@ import repository from './user.repository.js';
import { sendInvitationEmail } from '../../../shared/utils/email.js';
import bcrypt from 'bcrypt';
import { models } from '../../../shared/database/models.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
export class UserService {
async getAll(query = {}, context = {}) {
@@ -11,7 +12,7 @@ export class UserService {
async getById(id, context = {}) {
const record = await repository.findById(id, {}, context);
if (!record) {
throw new Error('User not found');
throw new ApiError(404, 'User not found');
}
return record;
}
@@ -22,7 +23,7 @@ export class UserService {
// Check if user already exists
const existingUser = await repository.findByEmail(email);
if (existingUser) {
throw new Error('User with this email already exists');
throw new ApiError(409, 'User with this email already exists');
}
// Generate temporary password
@@ -83,7 +84,7 @@ export class UserService {
async delete(id, context = {}) {
const deleted = await repository.delete(id, context);
if (!deleted) {
throw new Error('User not found');
throw new ApiError(404, 'User not found');
}
return true;
}
+12 -12
View File
@@ -4,11 +4,11 @@ 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
import {
createValidation,
updateValidation,
deleteValidation,
getByIdValidation
} from './brand.validation.js';
const router = Router();
@@ -26,7 +26,7 @@ const router = Router();
router.get(
'/',
authenticate,
authorize(['read:brands']),
authorize(['masters.brands']),
controller.getAll
);
@@ -49,7 +49,7 @@ router.get(
router.get(
'/:id',
authenticate,
authorize(['read:brands']),
authorize(['masters.brands']),
getByIdValidation,
validate,
controller.getById
@@ -68,7 +68,7 @@ router.get(
router.post(
'/',
authenticate,
authorize(['write:brands']),
authorize(['masters.brands']),
createValidation,
validate,
audit('CREATE_BRAND'),
@@ -92,7 +92,7 @@ router.post(
router.put(
'/:id',
authenticate,
authorize(['write:brands']),
authorize(['masters.brands']),
updateValidation,
validate,
audit('UPDATE_BRAND'),
@@ -116,7 +116,7 @@ router.put(
router.delete(
'/:id',
authenticate,
authorize(['write:brands']),
authorize(['masters.brands']),
deleteValidation,
validate,
audit('DELETE_BRAND'),
@@ -126,7 +126,7 @@ router.delete(
router.post(
'/:id/archive',
authenticate,
authorize(['write:brands']),
authorize(['masters.brands']),
getByIdValidation,
validate,
audit('ARCHIVE_BRAND'),
@@ -136,7 +136,7 @@ router.post(
router.post(
'/:id/restore',
authenticate,
authorize(['write:brands']),
authorize(['masters.brands']),
getByIdValidation,
validate,
audit('RESTORE_BRAND'),
+57 -7
View File
@@ -2,6 +2,8 @@ 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';
import NotificationService from '../../notifications/notifications/notification.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
export class BrandService {
async getAll(query = {}, context = {}) {
@@ -15,7 +17,7 @@ export class BrandService {
async getById(id, context = {}) {
const record = await repository.findById(id, {}, context);
if (!record) {
throw new Error('Brand not found');
throw new ApiError(404, 'Brand not found');
}
return record;
}
@@ -47,17 +49,29 @@ export class BrandService {
action: 'CREATE',
resource: 'Brand',
resourceId: record.id,
userId: context.userId || 'system',
userId: userContext.userId || 'system',
details: data
});
// Notify tenant users
if (userContext.tenantId) {
NotificationService.notifyTenant(userContext.tenantId, userContext.userId, {
variant: 'brand',
action: 'created',
title: 'New brand added',
description: `Brand "${record.name || 'A brand'}" was registered in the master data.`,
entity: record.name || 'Brand',
entity_id: record.id.toString()
}).catch(err => console.error('Notification failed:', err));
}
return record;
}
async update(id, data, context = {}) {
const record = await repository.findById(id, {}, context);
if (!record) {
throw new Error('Brand not found');
throw new ApiError(404, 'Brand not found');
}
if (data.code && data.code !== record.code) {
@@ -75,17 +89,29 @@ export class BrandService {
action: 'UPDATE',
resource: 'Brand',
resourceId: id,
userId: context.userId || 'system',
userId: userContext.userId || 'system',
details: data
});
// Notify tenant users
if (userContext.tenantId) {
NotificationService.notifyTenant(userContext.tenantId, userContext.userId, {
variant: 'brand',
action: 'updated',
title: 'Brand updated',
description: `"${record.name || 'A brand'}" brand details were refreshed.`,
entity: record.name || 'Brand',
entity_id: id.toString()
}).catch(err => console.error('Notification failed:', err));
}
return updatedRecord;
}
async delete(id, context = {}) {
const record = await repository.findById(id, {}, context);
if (!record) {
throw new Error('Brand not found');
throw new ApiError(404, 'Brand not found');
}
// Check product linkage
@@ -103,9 +129,21 @@ export class BrandService {
action: 'DELETE',
resource: 'Brand',
resourceId: id,
userId: context.userId || 'system'
userId: userContext.userId || userContext.id || 'system'
});
// Notify tenant users
if (userContext.tenantId) {
NotificationService.notifyTenant(userContext.tenantId, userContext.userId, {
variant: 'brand',
action: 'deleted',
title: 'Brand deleted',
description: 'A brand was permanently removed from the master data.',
entity: 'Brand',
entity_id: id.toString()
}).catch(err => console.error('Notification failed:', err));
}
return true;
}
@@ -124,9 +162,21 @@ export class BrandService {
action: 'ARCHIVE',
resource: 'Brand',
resourceId: id,
userId: context.userId || 'system'
userId: userContext.userId || userContext.id || 'system'
});
// Notify tenant users
if (userContext.tenantId) {
NotificationService.notifyTenant(userContext.tenantId, userContext.userId, {
variant: 'brand',
action: 'deleted',
title: 'Brand deleted',
description: 'A brand was permanently removed from the master data.',
entity: 'Brand',
entity_id: id.toString()
}).catch(err => console.error('Notification failed:', err));
}
return true;
}
+12 -12
View File
@@ -4,11 +4,11 @@ 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
import {
createValidation,
updateValidation,
deleteValidation,
getByIdValidation
} from './unit.validation.js';
const router = Router();
@@ -26,7 +26,7 @@ const router = Router();
router.get(
'/',
authenticate,
authorize(['read:brands']),
authorize(['masters.units']),
controller.getAll
);
@@ -47,7 +47,7 @@ router.get(
router.get(
'/:id',
authenticate,
authorize(['read:brands']),
authorize(['masters.units']),
getByIdValidation,
validate,
controller.getById
@@ -66,7 +66,7 @@ router.get(
router.post(
'/',
authenticate,
authorize(['write:brands']),
authorize(['masters.units']),
createValidation,
validate,
audit('CREATE_UNIT'),
@@ -90,7 +90,7 @@ router.post(
router.put(
'/:id',
authenticate,
authorize(['write:brands']),
authorize(['masters.units']),
updateValidation,
validate,
audit('UPDATE_UNIT'),
@@ -114,7 +114,7 @@ router.put(
router.delete(
'/:id',
authenticate,
authorize(['write:brands']),
authorize(['masters.units']),
deleteValidation,
validate,
audit('DELETE_UNIT'),
@@ -124,7 +124,7 @@ router.delete(
router.post(
'/:id/archive',
authenticate,
authorize(['write:brands']),
authorize(['masters.units']),
getByIdValidation,
validate,
audit('ARCHIVE_UNIT'),
@@ -134,7 +134,7 @@ router.post(
router.post(
'/:id/restore',
authenticate,
authorize(['write:brands']),
authorize(['masters.units']),
getByIdValidation,
validate,
audit('RESTORE_UNIT'),
@@ -4,11 +4,11 @@ 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
import {
createValidation,
updateValidation,
deleteValidation,
getByIdValidation
} from './catalog.validation.js';
const router = Router();
@@ -26,7 +26,7 @@ const router = Router();
router.get(
'/',
authenticate,
authorize(['read:catalogs']),
authorize(['products.families']),
controller.getAll
);
@@ -49,7 +49,7 @@ router.get(
router.get(
'/:id',
authenticate,
authorize(['read:catalogs']),
authorize(['products.families']),
getByIdValidation,
validate,
controller.getById
@@ -58,7 +58,7 @@ router.get(
router.get(
'/:id/blueprint',
authenticate,
authorize(['read:catalogs']),
authorize(['products.families']),
getByIdValidation,
validate,
controller.getBlueprint
@@ -67,7 +67,7 @@ router.get(
router.get(
'/:id/summary',
authenticate,
authorize(['read:catalogs']),
authorize(['products.families']),
getByIdValidation,
validate,
controller.getSummary
@@ -86,7 +86,7 @@ router.get(
router.post(
'/',
authenticate,
authorize(['write:catalogs']),
authorize(['products.families']),
createValidation,
validate,
audit('CREATE_CATALOG'),
@@ -110,7 +110,7 @@ router.post(
router.put(
'/:id',
authenticate,
authorize(['write:catalogs']),
authorize(['products.families']),
updateValidation,
validate,
audit('UPDATE_CATALOG'),
@@ -134,7 +134,7 @@ router.put(
router.delete(
'/:id',
authenticate,
authorize(['write:catalogs']),
authorize(['products.families']),
deleteValidation,
validate,
audit('DELETE_CATALOG'),
@@ -158,7 +158,7 @@ router.delete(
router.post(
'/:id/restore',
authenticate,
authorize(['write:catalogs']),
authorize(['products.families']),
getByIdValidation,
validate,
audit('RESTORE_CATALOG'),
@@ -3,6 +3,7 @@ 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 { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
export class CatalogService {
async attachCounts(record, transaction) {
@@ -120,7 +121,7 @@ export class CatalogService {
async getById(id, context = {}) {
const record = await repository.findById(id, {}, context);
if (!record) {
throw new Error('Product Family not found');
throw new ApiError(404, 'Product Family not found');
}
await this.attachCounts(record);
return record;
@@ -361,7 +362,7 @@ export class CatalogService {
action: 'CREATE',
resource: 'Catalog',
resourceId: record.id,
userId: context.userId || 'system',
userId: userContext.userId || 'system',
details: data
});
@@ -675,7 +676,7 @@ export class CatalogService {
action: 'DELETE',
resource: 'Catalog',
resourceId: id,
userId: context.userId || 'system'
userId: userContext.userId || 'system'
});
return true;
@@ -4,11 +4,11 @@ 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
import {
createValidation,
updateValidation,
deleteValidation,
getByIdValidation
} from './categorie.validation.js';
const router = Router();
@@ -26,7 +26,7 @@ const router = Router();
router.get(
'/',
authenticate,
authorize(['read:categories']),
authorize(['products.categories']),
controller.getAll
);
@@ -49,7 +49,7 @@ router.get(
router.get(
'/:id',
authenticate,
authorize(['read:categories']),
authorize(['products.categories']),
getByIdValidation,
validate,
controller.getById
@@ -68,7 +68,7 @@ router.get(
router.post(
'/',
authenticate,
authorize(['write:categories']),
authorize(['products.categories']),
createValidation,
validate,
audit('CREATE_CATEGORIE'),
@@ -92,7 +92,7 @@ router.post(
router.put(
'/:id',
authenticate,
authorize(['write:categories']),
authorize(['products.categories']),
updateValidation,
validate,
audit('UPDATE_CATEGORIE'),
@@ -116,7 +116,7 @@ router.put(
router.delete(
'/:id',
authenticate,
authorize(['write:categories']),
authorize(['products.categories']),
deleteValidation,
validate,
audit('DELETE_CATEGORIE'),
@@ -126,7 +126,7 @@ router.delete(
router.post(
'/:id/archive',
authenticate,
authorize(['write:categories']),
authorize(['products.categories']),
getByIdValidation,
validate,
audit('ARCHIVE_CATEGORIE'),
@@ -136,7 +136,7 @@ router.post(
router.post(
'/:id/restore',
authenticate,
authorize(['write:categories']),
authorize(['products.categories']),
getByIdValidation,
validate,
audit('RESTORE_CATEGORIE'),
@@ -2,6 +2,7 @@ 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 NotificationService from '../../notifications/notifications/notification.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import { Op } from 'sequelize';
@@ -17,7 +18,7 @@ export class CategorieService {
async getById(id, context = {}) {
const record = await repository.findById(id, {}, context);
if (!record) {
throw new ApiError(404, 'Category not found');
throw new ApiApiError(404, 404, 'Category not found');
}
return record;
}
@@ -197,6 +198,18 @@ export class CategorieService {
details: data
});
// Notify tenant users
if (userContext.tenantId) {
NotificationService.notifyTenant(userContext.tenantId, userContext.userId, {
variant: 'category',
action: 'updated',
title: 'Category updated',
description: `Category "${record.name || 'A category'}" tree details were updated.`,
entity: record.name || 'Category',
entity_id: id.toString()
}).catch(err => console.error('Notification failed:', err));
}
return fullRecord;
} catch (error) {
await transaction.rollback();
@@ -249,9 +262,21 @@ export class CategorieService {
action: 'DELETE',
resource: 'Categorie',
resourceId: id,
userId: context.userId || 'system'
userId: userContext.userId || 'system'
});
// Notify tenant users
if (userContext.tenantId) {
NotificationService.notifyTenant(userContext.tenantId, userContext.userId, {
variant: 'category',
action: 'deleted',
title: 'Category deleted',
description: 'A category was permanently removed from taxonomy.',
entity: 'Category',
entity_id: id.toString()
}).catch(err => console.error('Notification failed:', err));
}
return true;
} catch (error) {
await transaction.rollback();
@@ -278,9 +303,21 @@ export class CategorieService {
action: 'ARCHIVE',
resource: 'Categorie',
resourceId: id,
userId: context.userId || 'system'
userId: userContext.userId || 'system'
});
// Notify tenant users
if (userContext.tenantId) {
NotificationService.notifyTenant(userContext.tenantId, userContext.userId, {
variant: 'category',
action: 'deleted',
title: 'Category deleted',
description: 'A category was permanently removed from taxonomy.',
entity: 'Category',
entity_id: id.toString()
}).catch(err => console.error('Notification failed:', err));
}
return true;
} catch (error) {
await transaction.rollback();
@@ -305,7 +342,7 @@ export class CategorieService {
action: 'RESTORE',
resource: 'Categorie',
resourceId: id,
userId: context.userId || 'system'
userId: userContext.userId || 'system'
});
return restored;
@@ -16,14 +16,14 @@ const router = Router();
router.get(
'/',
authenticate,
authorize(['read:channels']),
authorize(['settings.integrations']),
controller.getAll
);
router.get(
'/:id',
authenticate,
authorize(['read:channels']),
authorize(['settings.integrations']),
getByIdValidation,
validate,
controller.getById
@@ -32,7 +32,7 @@ router.get(
router.post(
'/',
authenticate,
authorize(['write:channels']),
authorize(['settings.integrations']),
createValidation,
validate,
audit('CREATE_CHANNEL_TYPE'),
@@ -42,7 +42,7 @@ router.post(
router.put(
'/:id',
authenticate,
authorize(['write:channels']),
authorize(['settings.integrations']),
updateValidation,
validate,
audit('UPDATE_CHANNEL_TYPE'),
@@ -52,7 +52,7 @@ router.put(
router.delete(
'/:id',
authenticate,
authorize(['write:channels']),
authorize(['settings.integrations']),
deleteValidation,
validate,
audit('DELETE_CHANNEL_TYPE'),
@@ -62,7 +62,7 @@ router.delete(
router.post(
'/:id/archive',
authenticate,
authorize(['write:channels']),
authorize(['settings.integrations']),
getByIdValidation,
validate,
audit('ARCHIVE_CHANNEL_TYPE'),
@@ -72,7 +72,7 @@ router.post(
router.post(
'/:id/restore',
authenticate,
authorize(['write:channels']),
authorize(['settings.integrations']),
getByIdValidation,
validate,
audit('RESTORE_CHANNEL_TYPE'),
@@ -26,7 +26,7 @@ const router = Router();
router.get(
'/',
authenticate,
authorize(['read:channels']),
authorize(['settings.integrations']),
controller.getAll
);
@@ -49,7 +49,7 @@ router.get(
router.get(
'/:id',
authenticate,
authorize(['read:channels']),
authorize(['settings.integrations']),
getByIdValidation,
validate,
controller.getById
@@ -68,7 +68,7 @@ router.get(
router.post(
'/',
authenticate,
authorize(['write:channels']),
authorize(['settings.integrations']),
createValidation,
validate,
audit('CREATE_CHANNEL'),
@@ -92,7 +92,7 @@ router.post(
router.put(
'/:id',
authenticate,
authorize(['write:channels']),
authorize(['settings.integrations']),
updateValidation,
validate,
audit('UPDATE_CHANNEL'),
@@ -116,7 +116,7 @@ router.put(
router.delete(
'/:id',
authenticate,
authorize(['write:channels']),
authorize(['settings.integrations']),
deleteValidation,
validate,
audit('DELETE_CHANNEL'),
@@ -126,7 +126,7 @@ router.delete(
router.post(
'/:id/archive',
authenticate,
authorize(['write:channels']),
authorize(['settings.integrations']),
getByIdValidation,
validate,
audit('ARCHIVE_CHANNEL'),
@@ -136,7 +136,7 @@ router.post(
router.post(
'/:id/restore',
authenticate,
authorize(['write:channels']),
authorize(['settings.integrations']),
getByIdValidation,
validate,
audit('RESTORE_CHANNEL'),
@@ -1,6 +1,8 @@
import repository from './channel.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';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
export class ChannelService {
async getAll(query = {}) {
@@ -11,7 +13,7 @@ export class ChannelService {
async getById(id) {
const record = await repository.findById(id);
if (!record) {
throw new Error('Channel not found');
throw new ApiError(404, 'Channel not found');
}
return record;
}
@@ -27,7 +29,7 @@ export class ChannelService {
action: 'CREATE',
resource: 'Channel',
resourceId: record.id,
userId: userContext.id || 'system',
userId: userContext.userId || 'system',
details: data
});
@@ -37,7 +39,7 @@ export class ChannelService {
async update(id, data, userContext = {}) {
const record = await repository.update(id, data);
if (!record) {
throw new Error('Channel not found');
throw new ApiError(404, 'Channel not found');
}
SocketService.broadcast('channel:updated', record);
@@ -46,7 +48,7 @@ export class ChannelService {
action: 'UPDATE',
resource: 'Channel',
resourceId: id,
userId: userContext.id || 'system',
userId: userContext.userId || 'system',
details: data
});
@@ -56,7 +58,7 @@ export class ChannelService {
async delete(id, userContext = {}) {
const record = await models.Channel.findByPk(id);
if (!record) {
throw new Error('Channel not found');
throw new ApiError(404, 'Channel not found');
}
// Check usage in families
@@ -84,7 +86,7 @@ export class ChannelService {
action: 'DELETE',
resource: 'Channel',
resourceId: id,
userId: userContext.id || 'system'
userId: userContext.userId || 'system'
});
return true;
@@ -26,7 +26,7 @@ const router = Router();
router.get(
'/',
authenticate,
authorize(['read:metrics']),
authorize(['reports.sales']),
controller.getAll
);
@@ -49,7 +49,7 @@ router.get(
router.get(
'/:id',
authenticate,
authorize(['read:metrics']),
authorize(['reports.sales']),
getByIdValidation,
validate,
controller.getById
@@ -68,7 +68,7 @@ router.get(
router.post(
'/',
authenticate,
authorize(['write:metrics']),
authorize(['reports.sales']),
createValidation,
validate,
audit('CREATE_METRIC'),
@@ -92,7 +92,7 @@ router.post(
router.put(
'/:id',
authenticate,
authorize(['write:metrics']),
authorize(['reports.sales']),
updateValidation,
validate,
audit('UPDATE_METRIC'),
@@ -116,7 +116,7 @@ router.put(
router.delete(
'/:id',
authenticate,
authorize(['write:metrics']),
authorize(['reports.sales']),
deleteValidation,
validate,
audit('DELETE_METRIC'),
@@ -27,7 +27,7 @@ export class MetricService {
action: 'CREATE',
resource: 'Metric',
resourceId: record.id,
userId: userContext.id || 'system',
userId: userContext.userId || 'system',
details: data
});
@@ -46,7 +46,7 @@ export class MetricService {
action: 'UPDATE',
resource: 'Metric',
resourceId: id,
userId: userContext.id || 'system',
userId: userContext.userId || 'system',
details: data
});
@@ -65,7 +65,7 @@ export class MetricService {
action: 'DELETE',
resource: 'Metric',
resourceId: id,
userId: userContext.id || 'system'
userId: userContext.userId || 'system'
});
return true;
@@ -26,7 +26,7 @@ const router = Router();
router.get(
'/',
authenticate,
authorize(['read:exports']),
authorize(['settings.integrations']),
controller.getAll
);
@@ -49,7 +49,7 @@ router.get(
router.get(
'/:id',
authenticate,
authorize(['read:exports']),
authorize(['settings.integrations']),
getByIdValidation,
validate,
controller.getById
@@ -68,7 +68,7 @@ router.get(
router.post(
'/',
authenticate,
authorize(['write:exports']),
authorize(['settings.integrations']),
createValidation,
validate,
audit('CREATE_EXPORT'),
@@ -92,7 +92,7 @@ router.post(
router.put(
'/:id',
authenticate,
authorize(['write:exports']),
authorize(['settings.integrations']),
updateValidation,
validate,
audit('UPDATE_EXPORT'),
@@ -116,7 +116,7 @@ router.put(
router.delete(
'/:id',
authenticate,
authorize(['write:exports']),
authorize(['settings.integrations']),
deleteValidation,
validate,
audit('DELETE_EXPORT'),
@@ -1,6 +1,7 @@
import repository from './export.repository.js';
import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
export class ExportService {
async getAll(query = {}) {
@@ -11,7 +12,7 @@ export class ExportService {
async getById(id) {
const record = await repository.findById(id);
if (!record) {
throw new Error('Export not found');
throw new ApiError(404, 'Export not found');
}
return record;
}
@@ -27,7 +28,7 @@ export class ExportService {
action: 'CREATE',
resource: 'Export',
resourceId: record.id,
userId: userContext.id || 'system',
userId: userContext.userId || 'system',
details: data
});
@@ -37,7 +38,7 @@ export class ExportService {
async update(id, data, userContext = {}) {
const record = await repository.update(id, data);
if (!record) {
throw new Error('Export not found');
throw new ApiError(404, 'Export not found');
}
SocketService.broadcast('export:updated', record);
@@ -46,7 +47,7 @@ export class ExportService {
action: 'UPDATE',
resource: 'Export',
resourceId: id,
userId: userContext.id || 'system',
userId: userContext.userId || 'system',
details: data
});
@@ -56,7 +57,7 @@ export class ExportService {
async delete(id, userContext = {}) {
const deleted = await repository.delete(id);
if (!deleted) {
throw new Error('Export not found');
throw new ApiError(404, 'Export not found');
}
SocketService.broadcast('export:deleted', { id });
@@ -65,7 +66,7 @@ export class ExportService {
action: 'DELETE',
resource: 'Export',
resourceId: id,
userId: userContext.id || 'system'
userId: userContext.userId || 'system'
});
return true;
@@ -26,7 +26,7 @@ const router = Router();
router.get(
'/',
authenticate,
authorize(['read:imports']),
authorize(['settings.integrations']),
controller.getAll
);
@@ -49,7 +49,7 @@ router.get(
router.get(
'/:id',
authenticate,
authorize(['read:imports']),
authorize(['settings.integrations']),
getByIdValidation,
validate,
controller.getById
@@ -68,7 +68,7 @@ router.get(
router.post(
'/',
authenticate,
authorize(['write:imports']),
authorize(['settings.integrations']),
createValidation,
validate,
audit('CREATE_IMPORT'),
@@ -92,7 +92,7 @@ router.post(
router.put(
'/:id',
authenticate,
authorize(['write:imports']),
authorize(['settings.integrations']),
updateValidation,
validate,
audit('UPDATE_IMPORT'),
@@ -116,7 +116,7 @@ router.put(
router.delete(
'/:id',
authenticate,
authorize(['write:imports']),
authorize(['settings.integrations']),
deleteValidation,
validate,
audit('DELETE_IMPORT'),
@@ -1,6 +1,7 @@
import repository from './import.repository.js';
import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
export class ImportService {
async getAll(query = {}) {
@@ -11,7 +12,7 @@ export class ImportService {
async getById(id) {
const record = await repository.findById(id);
if (!record) {
throw new Error('Import not found');
throw new ApiError(404, 'Import not found');
}
return record;
}
@@ -27,7 +28,7 @@ export class ImportService {
action: 'CREATE',
resource: 'Import',
resourceId: record.id,
userId: userContext.id || 'system',
userId: userContext.userId || 'system',
details: data
});
@@ -37,7 +38,7 @@ export class ImportService {
async update(id, data, userContext = {}) {
const record = await repository.update(id, data);
if (!record) {
throw new Error('Import not found');
throw new ApiError(404, 'Import not found');
}
SocketService.broadcast('import:updated', record);
@@ -46,7 +47,7 @@ export class ImportService {
action: 'UPDATE',
resource: 'Import',
resourceId: id,
userId: userContext.id || 'system',
userId: userContext.userId || 'system',
details: data
});
@@ -56,7 +57,7 @@ export class ImportService {
async delete(id, userContext = {}) {
const deleted = await repository.delete(id);
if (!deleted) {
throw new Error('Import not found');
throw new ApiError(404, 'Import not found');
}
SocketService.broadcast('import:deleted', { id });
@@ -65,7 +66,7 @@ export class ImportService {
action: 'DELETE',
resource: 'Import',
resourceId: id,
userId: userContext.id || 'system'
userId: userContext.userId || 'system'
});
return true;
+2
View File
@@ -12,6 +12,7 @@ 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 notificationsRouter from './notifications/index.js';
import workflowsRouter from './workflows/workflow.routes.js';
import variantsRouter from './variants/index.js';
@@ -30,6 +31,7 @@ export default function registerRoutes(app) {
app.use('/api/v1', exportsRouter);
app.use('/api/v1', settingsRouter);
app.use('/api/v1', auditLogsRouter);
app.use('/api/v1', notificationsRouter);
app.use('/api/v1/workflows', workflowsRouter);
app.use('/api/v1', variantsRouter);
}
@@ -30,7 +30,7 @@ const router = Router();
router.get(
'/',
authenticate,
authorize(['read:media']),
authorize(['products.items']),
controller.getAll
);
@@ -57,7 +57,7 @@ router.get(
router.get(
'/:id',
authenticate,
authorize(['read:media']),
authorize(['products.items']),
getByIdValidation,
validate,
controller.getById
@@ -90,7 +90,7 @@ router.get(
router.post(
'/',
authenticate,
authorize(['write:media']),
authorize(['products.items']),
createValidation,
validate,
audit('CREATE_ASSET_FAMILY'),
@@ -129,7 +129,7 @@ router.post(
router.put(
'/:id',
authenticate,
authorize(['write:media']),
authorize(['products.items']),
updateValidation,
validate,
audit('UPDATE_ASSET_FAMILY'),
@@ -159,7 +159,7 @@ router.put(
router.delete(
'/:id',
authenticate,
authorize(['write:media']),
authorize(['products.items']),
deleteValidation,
validate,
audit('DELETE_ASSET_FAMILY'),
@@ -2,6 +2,7 @@ import repository from './assetFamily.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';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
export class AssetFamilyService {
encodeDescription(text = '', assetTypeIds = []) {
@@ -91,7 +92,7 @@ export class AssetFamilyService {
async getById(id) {
const record = await repository.findById(id);
if (!record) {
throw new Error('AssetFamily not found');
throw new ApiError(404, 'AssetFamily not found');
}
return await this.enrichFamilyWithTypes(record);
}
@@ -117,7 +118,7 @@ export class AssetFamilyService {
action: 'CREATE',
resource: 'AssetFamily',
resourceId: record.id,
userId: userContext?.id || userContext?.name || 'system',
userId: userContext?.user_id || userContext?.name || 'system',
details: data
});
@@ -129,7 +130,7 @@ export class AssetFamilyService {
const existingRecord = await repository.findById(id);
if (!existingRecord) {
throw new Error('AssetFamily not found');
throw new ApiError(404, 'AssetFamily not found');
}
let currentText = description;
@@ -153,7 +154,7 @@ export class AssetFamilyService {
action: 'UPDATE',
resource: 'AssetFamily',
resourceId: id,
userId: userContext?.id || userContext?.name || 'system',
userId: userContext?.user_id || userContext?.name || 'system',
details: data
});
@@ -163,7 +164,7 @@ export class AssetFamilyService {
async delete(id, userContext = {}) {
const deleted = await repository.delete(id);
if (!deleted) {
throw new Error('AssetFamily not found');
throw new ApiError(404, 'AssetFamily not found');
}
SocketService.broadcast('assetFamily:deleted', { id });
@@ -172,7 +173,7 @@ export class AssetFamilyService {
action: 'DELETE',
resource: 'AssetFamily',
resourceId: id,
userId: userContext?.id || userContext?.name || 'system'
userId: userContext?.user_id || userContext?.name || 'system'
});
return true;
@@ -30,7 +30,7 @@ const router = Router();
router.get(
'/',
authenticate,
authorize(['read:media']),
authorize(['products.items']),
controller.getAll
);
@@ -57,7 +57,7 @@ router.get(
router.get(
'/:id',
authenticate,
authorize(['read:media']),
authorize(['products.items']),
getByIdValidation,
validate,
controller.getById
@@ -90,7 +90,7 @@ router.get(
router.post(
'/',
authenticate,
authorize(['write:media']),
authorize(['products.items']),
createValidation,
validate,
audit('CREATE_ASSET_TYPE'),
@@ -129,7 +129,7 @@ router.post(
router.put(
'/:id',
authenticate,
authorize(['write:media']),
authorize(['products.items']),
updateValidation,
validate,
audit('UPDATE_ASSET_TYPE'),
@@ -159,7 +159,7 @@ router.put(
router.delete(
'/:id',
authenticate,
authorize(['write:media']),
authorize(['products.items']),
deleteValidation,
validate,
audit('DELETE_ASSET_TYPE'),
@@ -2,6 +2,7 @@ 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';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
export class AssetTypeService {
async getAll(query = {}) {
@@ -15,9 +16,7 @@ export class AssetTypeService {
async getById(id) {
const record = await repository.findById(id);
if (!record) {
const err = new Error('Asset Type not found');
err.statusCode = 404;
throw err;
throw new ApiError(404, 'AssetType not found');
}
return record;
}
@@ -63,7 +62,7 @@ export class AssetTypeService {
action: 'CREATE',
resource: 'AssetType',
resourceId: record.id,
userId: userContext.id || 'system',
userId: userContext.userId || 'system',
details: payload
});
@@ -81,7 +80,7 @@ export class AssetTypeService {
if (data.code && data.code !== record.code) {
const existing = await models.AssetType.findOne({ where: { code: data.code } });
if (existing) {
const err = new Error(`Asset Type with code "${data.code}" already exists`);
const err = new ApiError(404, `Asset Type with code "${data.code}" already exists`);
err.statusCode = 400;
throw err;
}
@@ -100,7 +99,7 @@ export class AssetTypeService {
action: 'UPDATE',
resource: 'AssetType',
resourceId: id,
userId: userContext.id || 'system',
userId: userContext.userId || 'system',
details: payload
});
@@ -125,9 +124,7 @@ export class AssetTypeService {
const deleted = await repository.delete(id);
if (!deleted) {
const err = new Error('Failed to delete Asset Type');
err.statusCode = 500;
throw err;
throw new ApiError(404, 'AssetType not found');
}
SocketService.broadcast('assetType:deleted', { id });
@@ -136,7 +133,7 @@ export class AssetTypeService {
action: 'DELETE',
resource: 'AssetType',
resourceId: id,
userId: userContext.id || 'system'
userId: userContext.userId || 'system'
});
return true;
+13 -13
View File
@@ -18,7 +18,7 @@ const router = Router();
router.get(
'/analytics',
authenticate,
authorize(['read:media']),
authorize(['products.items']),
controller.getAnalytics
);
@@ -26,7 +26,7 @@ router.get(
router.get(
'/folders',
authenticate,
authorize(['read:media']),
authorize(['products.items']),
controller.getFolders
);
@@ -34,7 +34,7 @@ router.get(
router.get(
'/tags',
authenticate,
authorize(['read:media']),
authorize(['products.items']),
controller.getTags
);
@@ -42,7 +42,7 @@ router.get(
router.post(
'/upload',
authenticate,
authorize(['write:media']),
authorize(['products.items']),
uploadSingle('file'),
controller.upload
);
@@ -51,7 +51,7 @@ router.post(
router.get(
'/',
authenticate,
authorize(['read:media']),
authorize(['products.items']),
controller.getAll
);
@@ -59,7 +59,7 @@ router.get(
router.get(
'/:id/relations',
authenticate,
authorize(['read:media']),
authorize(['products.items']),
getByIdValidation,
validate,
controller.getRelations
@@ -69,7 +69,7 @@ router.get(
router.get(
'/:id',
authenticate,
authorize(['read:media']),
authorize(['products.items']),
getByIdValidation,
validate,
controller.getById
@@ -79,7 +79,7 @@ router.get(
router.post(
'/',
authenticate,
authorize(['write:media']),
authorize(['products.items']),
createValidation,
validate,
audit('CREATE_ASSET'),
@@ -90,7 +90,7 @@ router.post(
router.put(
'/:id',
authenticate,
authorize(['write:media']),
authorize(['products.items']),
updateValidation,
validate,
audit('UPDATE_ASSET'),
@@ -101,7 +101,7 @@ router.put(
router.delete(
'/:id',
authenticate,
authorize(['write:media']),
authorize(['products.items']),
deleteValidation,
validate,
audit('DELETE_ASSET'),
@@ -112,7 +112,7 @@ router.delete(
router.post(
'/:id/replace',
authenticate,
authorize(['write:media']),
authorize(['products.items']),
uploadSingle('file'),
controller.replaceFile
);
@@ -121,7 +121,7 @@ router.post(
router.post(
'/:id/archive',
authenticate,
authorize(['write:media']),
authorize(['products.items']),
controller.archive
);
@@ -129,7 +129,7 @@ router.post(
router.post(
'/:id/restore',
authenticate,
authorize(['write:media']),
authorize(['products.items']),
controller.restore
);
+7 -6
View File
@@ -2,6 +2,7 @@ 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 { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import { extractMetadata } from '../../../shared/utils/metadataExtractor.js';
import { Op } from 'sequelize';
import path from 'path';
@@ -55,7 +56,7 @@ export class AssetService {
]
}, context);
if (!record) {
throw new Error('Asset not found');
throw new ApiError(404, 'Asset not found');
}
return record;
}
@@ -148,7 +149,7 @@ export class AssetService {
action: 'CREATE',
resource: 'Asset',
resourceId: record.id,
userId: context.userId || 'system',
userId: userContext.userId || 'system',
details: data
});
@@ -164,7 +165,7 @@ export class AssetService {
try {
const record = await models.Asset.findByPk(id, { transaction });
if (!record) {
throw new Error('Asset not found');
throw new ApiError(404, 'Asset not found');
}
if (data.code && data.code !== record.code) {
@@ -211,7 +212,7 @@ export class AssetService {
action: 'UPDATE',
resource: 'Asset',
resourceId: id,
userId: context.userId || 'system',
userId: userContext.userId || 'system',
details: data
});
@@ -326,7 +327,7 @@ export class AssetService {
async delete(id, context = {}) {
const record = await models.Asset.findByPk(id);
if (!record) {
throw new Error('Asset not found');
throw new ApiError(404, 'Asset not found');
}
// Clean up junction mapping tables so deletion proceeds cleanly
@@ -346,7 +347,7 @@ export class AssetService {
action: 'DELETE',
resource: 'Asset',
resourceId: id,
userId: context.userId || 'system'
userId: userContext.userId || 'system'
});
return true;
+5 -5
View File
@@ -26,7 +26,7 @@ const router = Router();
router.get(
'/',
authenticate,
authorize(['read:medias']),
authorize(['products.items']),
controller.getAll
);
@@ -49,7 +49,7 @@ router.get(
router.get(
'/:id',
authenticate,
authorize(['read:medias']),
authorize(['products.items']),
getByIdValidation,
validate,
controller.getById
@@ -68,7 +68,7 @@ router.get(
router.post(
'/',
authenticate,
authorize(['write:medias']),
authorize(['products.items']),
createValidation,
validate,
audit('CREATE_MEDIA'),
@@ -92,7 +92,7 @@ router.post(
router.put(
'/:id',
authenticate,
authorize(['write:medias']),
authorize(['products.items']),
updateValidation,
validate,
audit('UPDATE_MEDIA'),
@@ -116,7 +116,7 @@ router.put(
router.delete(
'/:id',
authenticate,
authorize(['write:medias']),
authorize(['products.items']),
deleteValidation,
validate,
audit('DELETE_MEDIA'),
+3 -3
View File
@@ -27,7 +27,7 @@ export class MediaService {
action: 'CREATE',
resource: 'Media',
resourceId: record.id,
userId: userContext.id || 'system',
userId: userContext.userId || 'system',
details: data
});
@@ -46,7 +46,7 @@ export class MediaService {
action: 'UPDATE',
resource: 'Media',
resourceId: id,
userId: userContext.id || 'system',
userId: userContext.userId || 'system',
details: data
});
@@ -65,7 +65,7 @@ export class MediaService {
action: 'DELETE',
resource: 'Media',
resourceId: id,
userId: userContext.id || 'system'
userId: userContext.userId || 'system'
});
return true;
+8
View File
@@ -0,0 +1,8 @@
import { Router } from 'express';
import notificationsRouter from './notifications/notification.routes.js';
const router = Router();
router.use('/notifications', notificationsRouter);
export default router;
@@ -0,0 +1,57 @@
import service from './notification.service.js';
export class NotificationController {
async getAll(req, res, next) {
try {
const result = await service.getAll(req.query, req.user);
return res.status(200).json({
success: true,
message: 'Notifications retrieved successfully.',
data: result.data,
pagination: result.pagination,
meta: result.meta
});
} catch (error) {
next(error);
}
}
async getById(req, res, next) {
try {
const record = await service.getById(req.params.id, req.user);
return res.status(200).json({
success: true,
message: 'Notification retrieved successfully.',
data: record
});
} catch (error) {
next(error);
}
}
async markRead(req, res, next) {
try {
await service.markRead(req.params.id, req.user);
return res.status(200).json({
success: true,
message: 'Notification marked as read.'
});
} catch (error) {
next(error);
}
}
async markAllRead(req, res, next) {
try {
await service.markAllRead(req.user);
return res.status(200).json({
success: true,
message: 'All notifications marked as read.'
});
} catch (error) {
next(error);
}
}
}
export default new NotificationController();
@@ -0,0 +1,5 @@
import { EventEmitter } from 'events';
class NotificationEventEmitter extends EventEmitter {}
export const notificationEvents = new NotificationEventEmitter();
@@ -0,0 +1,85 @@
import { Model, DataTypes } from 'sequelize';
export class Notification extends Model {
static associate(models) {
Notification.belongsTo(models.User, {
foreignKey: 'recipient_user_id',
as: 'recipient',
onDelete: 'CASCADE'
});
Notification.belongsTo(models.User, {
foreignKey: 'actor_user_id',
as: 'actor',
onDelete: 'SET NULL'
});
Notification.belongsTo(models.Tenant, {
foreignKey: 'tenant_id',
as: 'tenant',
onDelete: 'CASCADE'
});
}
}
export default (sequelize) => {
Notification.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
recipient_user_id: {
type: DataTypes.INTEGER,
allowNull: false
},
actor_user_id: {
type: DataTypes.INTEGER,
allowNull: true
},
variant: {
type: DataTypes.STRING(50),
allowNull: false
},
action: {
type: DataTypes.STRING(50),
allowNull: false
},
title: {
type: DataTypes.STRING(255),
allowNull: false
},
description: {
type: DataTypes.TEXT,
allowNull: false
},
entity: {
type: DataTypes.STRING(255),
allowNull: false
},
entity_id: {
type: DataTypes.STRING(100),
allowNull: false
},
is_read: {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false
},
metadata: {
type: DataTypes.JSONB,
allowNull: true
}
}, {
sequelize,
modelName: 'Notification',
tableName: 'notifications',
timestamps: true,
underscored: true
});
return Notification;
};
@@ -0,0 +1,43 @@
import { models } from '../../../shared/database/models.js';
export class NotificationRepository {
async findAndCountAll(options = {}) {
return await models.Notification.findAndCountAll(options);
}
async findById(id, options = {}) {
return await models.Notification.findByPk(id, options);
}
async create(data, options = {}) {
return await models.Notification.create(data, options);
}
async markRead(id, recipientUserId, options = {}) {
const record = await models.Notification.findOne({
where: { id, recipient_user_id: recipientUserId },
...options
});
if (!record) return null;
return await record.update({ is_read: true }, options);
}
async markAllRead(recipientUserId, options = {}) {
return await models.Notification.update(
{ is_read: true },
{
where: { recipient_user_id: recipientUserId, is_read: false },
...options
}
);
}
async countUnread(recipientUserId, options = {}) {
return await models.Notification.count({
where: { recipient_user_id: recipientUserId, is_read: false },
...options
});
}
}
export default new NotificationRepository();
@@ -0,0 +1,52 @@
import { Router } from 'express';
import controller from './notification.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 {
getNotificationsValidation,
getByIdValidation,
markReadValidation
} from './notification.validation.js';
const router = Router();
// GET /notifications — list for authenticated user
router.get(
'/',
authenticate,
authorize(['notifications']),
getNotificationsValidation,
validate,
controller.getAll
);
// PATCH /notifications/read-all — static route MUST precede /:id
router.patch(
'/read-all',
authenticate,
authorize(['notifications.update']),
controller.markAllRead
);
// GET /notifications/:id — single notification
router.get(
'/:id',
authenticate,
authorize(['notifications']),
getByIdValidation,
validate,
controller.getById
);
// PATCH /notifications/:id/read — mark single as read
router.patch(
'/:id/read',
authenticate,
authorize(['notifications.update']),
markReadValidation,
validate,
controller.markRead
);
export default router;
@@ -0,0 +1,256 @@
import { Op } from 'sequelize';
import repository from './notification.repository.js';
import { models } from '../../../shared/database/models.js';
import sequelize from '../../../shared/database/connection.js';
import { notificationEvents } from './notification.events.js';
/**
* Maps a Notification Sequelize instance to a DTO.
*
* Per architecture spec:
* - actor / actorAvatar are resolved via the actor association (actor_user_id JOIN)
* - link is NOT generated here — the frontend derives navigation URLs from entity + entity_id
*/
const SORT_COLUMN_MAP = {
createdAt: 'created_at',
isRead: 'is_read',
variant: 'variant',
action: 'action'
};
const mapToDTO = (notification) => ({
id: notification.id,
variant: notification.variant,
action: notification.action,
title: notification.title,
description: notification.description,
entity: notification.entity,
entityId: notification.entity_id,
actor: notification.actor ? notification.actor.user_name : 'System',
actorAvatar: notification.actor ? notification.actor.profile_picture : null,
isRead: notification.is_read,
createdAt: notification.created_at
});
const actorInclude = () => ({
model: models.User,
as: 'actor',
attributes: ['id', 'user_name', 'profile_picture']
});
export class NotificationService {
/**
* List notifications for the authenticated user with filtering, pagination, and sorting.
*/
async getAll(query = {}, userContext = {}) {
const {
page = 1,
limit = 20,
search,
variant,
action,
isRead,
dateFrom,
dateTo,
sortBy = 'createdAt',
sortOrder = 'desc'
} = query;
const offset = (parseInt(page, 10) - 1) * parseInt(limit, 10);
const where = {
recipient_user_id: userContext.user_id
};
if (userContext.user_type === 'tenant') {
where.tenant_id = userContext.tenant_id;
}
if (search && search.trim()) {
const searchPattern = `%${search.trim()}%`;
where[Op.or] = [
{ title: { [Op.iLike]: searchPattern } },
{ description: { [Op.iLike]: searchPattern } }
];
}
if (variant && variant !== 'all') {
where.variant = variant;
}
if (action) {
where.action = action;
}
if (isRead === 'true' || isRead === true) {
where.is_read = true;
} else if (isRead === 'false' || isRead === false) {
where.is_read = false;
}
if (dateFrom || dateTo) {
where.created_at = {};
if (dateFrom) where.created_at[Op.gte] = new Date(dateFrom);
if (dateTo) where.created_at[Op.lte] = new Date(dateTo);
}
const dbSortField = SORT_COLUMN_MAP[sortBy] ?? 'created_at';
const { rows, count } = await repository.findAndCountAll({
where,
limit: parseInt(limit, 10),
offset,
order: [[dbSortField, sortOrder.toUpperCase()]],
include: [actorInclude()]
});
const totalPages = Math.max(1, Math.ceil(count / parseInt(limit, 10)));
const unreadCount = await repository.countUnread(userContext.user_id);
return {
data: rows.map(mapToDTO),
pagination: {
page: parseInt(page, 10),
limit: parseInt(limit, 10),
total: count,
totalPages,
hasNextPage: parseInt(page, 10) < totalPages,
hasPreviousPage: parseInt(page, 10) > 1
},
meta: { unreadCount }
};
}
/**
* Retrieve a single notification by id, scoped to the authenticated user.
*/
async getById(id, userContext = {}) {
const where = { id, recipient_user_id: userContext.user_id };
if (userContext.user_type === 'tenant') {
where.tenant_id = userContext.tenant_id;
}
const notification = await models.Notification.findOne({
where,
include: [actorInclude()]
});
if (!notification) {
const err = new Error('Notification not found');
err.statusCode = 404;
throw err;
}
return mapToDTO(notification);
}
/**
* Mark a single notification as read, then broadcast the updated unread count.
*/
async markRead(id, userContext = {}) {
const updated = await repository.markRead(id, userContext.user_id);
if (!updated) {
const err = new Error('Notification not found');
err.statusCode = 404;
throw err;
}
const count = await repository.countUnread(userContext.user_id);
notificationEvents.emit('notification:unread-count', {
recipientUserId: userContext.user_id,
count
});
return true;
}
/**
* Mark all unread notifications as read for the authenticated user.
* Uses a transaction; the socket broadcast fires only after commit.
*/
async markAllRead(userContext = {}) {
const recipientUserId = userContext.user_id;
await sequelize.transaction(async (t) => {
await repository.markAllRead(recipientUserId, { transaction: t });
const count = await repository.countUnread(recipientUserId, { transaction: t });
t.afterCommit(() => {
notificationEvents.emit('notification:unread-count', {
recipientUserId,
count
});
});
});
return true;
}
/**
* Core notification creation method.
*
* Called ONLY by NotificationService itself (via notifyTenant or directly).
* Business services must never call repository.create() directly.
*
* Flow:
* repository.create() → DB persist → fetch with actor join → emit socket events
*/
async createNotification(data) {
const record = await repository.create(data);
const notification = await models.Notification.findOne({
where: { id: record.id },
include: [actorInclude()]
});
const mapped = mapToDTO(notification);
// recipient_user_id comes from the raw data, not the DTO (DTO omits it by design)
const recipientUserId = data.recipient_user_id;
const count = await repository.countUnread(recipientUserId);
notificationEvents.emit('notification:created', {
recipientUserId,
notification: mapped
});
notificationEvents.emit('notification:unread-count', {
recipientUserId,
count
});
return mapped;
}
/**
* Broadcast a notification to all users in a tenant, excluding the actor.
*
* Called by business services after a successful committed transaction.
* This method is the ONLY authorised entry point for business-module notification generation.
*
* Recipient resolution:
* - All active users belonging to the tenant
* - Actor (actorUserId) is excluded — they performed the action
*/
async notifyTenant(tenantId, actorUserId, notificationData) {
const whereClause = { tenant_id: tenantId };
const users = await models.User.findAll({ where: whereClause });
const creations = users.map((user) =>
this.createNotification({
tenant_id: tenantId,
recipient_user_id: user.id,
actor_user_id: actorUserId || null,
...notificationData
}).catch((err) => {
console.error(`[NotificationService] Failed to notify user ${user.id}:`, err.message);
})
);
await Promise.all(creations);
}
}
export default new NotificationService();
@@ -0,0 +1,67 @@
import { query, param } from 'express-validator';
export const getNotificationsValidation = [
query('page')
.optional()
.isInt({ min: 1 })
.withMessage('Page must be a positive integer')
.toInt(),
query('limit')
.optional()
.isInt({ min: 1, max: 100 })
.withMessage('Limit must be an integer between 1 and 100')
.toInt(),
query('search')
.optional()
.isString()
.trim()
.isLength({ max: 200 })
.withMessage('Search query too long'),
query('variant')
.optional()
.isIn(['all', 'product', 'category', 'brand', 'family', 'attribute', 'store', 'role', 'workflow', 'system'])
.withMessage('Invalid variant type'),
query('action')
.optional()
.isIn(['created', 'updated', 'deleted', 'published', 'approved', 'rejected'])
.withMessage('Invalid action type'),
query('isRead')
.optional()
.isIn(['all', 'true', 'false'])
.withMessage('isRead must be one of: all, true, false'),
query('dateFrom')
.optional()
.isISO8601()
.withMessage('dateFrom must be a valid ISO8601 date'),
query('dateTo')
.optional()
.isISO8601()
.withMessage('dateTo must be a valid ISO8601 date')
.custom((value, { req }) => {
if (req.query.dateFrom && new Date(value) < new Date(req.query.dateFrom)) {
throw new Error('dateTo must not be before dateFrom');
}
return true;
}),
query('sortBy')
.optional()
.isIn(['createdAt', 'variant', 'action', 'isRead'])
.withMessage('Invalid sort field'),
query('sortOrder')
.optional()
.toLowerCase()
.isIn(['asc', 'desc'])
.withMessage('sortOrder must be asc or desc')
];
export const getByIdValidation = [
param('id')
.isUUID(4)
.withMessage('Valid UUID v4 is required')
];
export const markReadValidation = [
param('id')
.isUUID(4)
.withMessage('Valid UUID v4 is required')
];
+5 -5
View File
@@ -26,7 +26,7 @@ const router = Router();
router.get(
'/',
authenticate,
authorize(['read:orgs']),
authorize(['TENANTS_MANAGEMENT']),
controller.getAll
);
@@ -49,7 +49,7 @@ router.get(
router.get(
'/:id',
authenticate,
authorize(['read:orgs']),
authorize(['TENANTS_MANAGEMENT']),
getByIdValidation,
validate,
controller.getById
@@ -68,7 +68,7 @@ router.get(
router.post(
'/',
authenticate,
authorize(['write:orgs']),
authorize(['TENANTS_MANAGEMENT']),
createValidation,
validate,
audit('CREATE_ORG'),
@@ -92,7 +92,7 @@ router.post(
router.put(
'/:id',
authenticate,
authorize(['write:orgs']),
authorize(['TENANTS_MANAGEMENT']),
updateValidation,
validate,
audit('UPDATE_ORG'),
@@ -116,7 +116,7 @@ router.put(
router.delete(
'/:id',
authenticate,
authorize(['write:orgs']),
authorize(['TENANTS_MANAGEMENT']),
deleteValidation,
validate,
audit('DELETE_ORG'),
+7 -6
View File
@@ -1,6 +1,7 @@
import repository from './org.repository.js';
import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
export class OrgService {
async getAll(query = {}) {
@@ -11,7 +12,7 @@ export class OrgService {
async getById(id) {
const record = await repository.findById(id);
if (!record) {
throw new Error('Org not found');
throw new ApiError(404, 'Org not found');
}
return record;
}
@@ -30,7 +31,7 @@ export class OrgService {
action: 'CREATE',
resource: 'Org',
resourceId: record.id,
userId: userContext.id || 'system',
userId: userContext.userId || 'system',
details: data
});
@@ -40,7 +41,7 @@ export class OrgService {
async update(id, data, userContext = {}) {
const record = await repository.update(id, data);
if (!record) {
throw new Error('Org not found');
throw new ApiError(404, 'Org not found');
}
SocketService.broadcast('org:updated', record);
@@ -49,7 +50,7 @@ export class OrgService {
action: 'UPDATE',
resource: 'Org',
resourceId: id,
userId: userContext.id || 'system',
userId: userContext.userId || 'system',
details: data
});
@@ -59,7 +60,7 @@ export class OrgService {
async delete(id, userContext = {}) {
const deleted = await repository.delete(id);
if (!deleted) {
throw new Error('Org not found');
throw new ApiError(404, 'Org not found');
}
SocketService.broadcast('org:deleted', { id });
@@ -68,7 +69,7 @@ export class OrgService {
action: 'DELETE',
resource: 'Org',
resourceId: id,
userId: userContext.id || 'system'
userId: userContext.userId || 'system'
});
return true;
@@ -22,7 +22,7 @@ const router = Router();
router.get(
'/',
authenticate,
authorize(['settings.tenants.view']),
authorize(['TENANTS_MANAGEMENT']),
controller.getAll
);
@@ -49,7 +49,7 @@ router.get(
router.get(
'/:id',
authenticate,
authorize(['settings.tenants.view']),
authorize(['TENANTS_MANAGEMENT']),
controller.getById
);
@@ -82,7 +82,7 @@ router.get(
router.post(
'/',
authenticate,
authorize(['settings.tenants.create']),
authorize(['TENANTS_MANAGEMENT']),
controller.create
);
@@ -107,7 +107,7 @@ router.post(
router.put(
'/:id',
authenticate,
authorize(['settings.tenants.edit']),
authorize(['TENANTS_MANAGEMENT']),
controller.update
);
@@ -132,7 +132,7 @@ router.put(
router.delete(
'/:id',
authenticate,
authorize(['settings.tenants.delete']),
authorize(['TENANTS_MANAGEMENT']),
controller.delete
);
@@ -4,11 +4,11 @@ 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
import {
createValidation,
updateValidation,
deleteValidation,
getByIdValidation
} from './product.validation.js';
const router = Router();
@@ -26,7 +26,7 @@ const router = Router();
router.get(
'/',
authenticate,
authorize(['read:products']),
authorize(['products.items']),
controller.getAll
);
@@ -49,7 +49,7 @@ router.get(
router.get(
'/:id',
authenticate,
authorize(['read:products']),
authorize(['products.items']),
getByIdValidation,
validate,
controller.getById
@@ -68,7 +68,7 @@ router.get(
router.post(
'/',
authenticate,
authorize(['write:products']),
authorize(['products.items']),
createValidation,
validate,
audit('CREATE_PRODUCT'),
@@ -92,7 +92,7 @@ router.post(
router.put(
'/:id',
authenticate,
authorize(['write:products']),
authorize(['products.items']),
updateValidation,
validate,
audit('UPDATE_PRODUCT'),
@@ -116,7 +116,7 @@ router.put(
router.delete(
'/:id',
authenticate,
authorize(['write:products']),
authorize(['products.items']),
deleteValidation,
validate,
audit('DELETE_PRODUCT'),
@@ -126,35 +126,35 @@ router.delete(
router.get(
'/:id/assets',
authenticate,
authorize(['read:products']),
authorize(['products.items']),
controller.getAssets
);
router.post(
'/:id/assets',
authenticate,
authorize(['write:products']),
authorize(['products.items']),
controller.assignAsset
);
router.put(
'/:id/assets/:assetId',
authenticate,
authorize(['write:products']),
authorize(['products.items']),
controller.updateAssetMapping
);
router.delete(
'/:id/assets/:assetId',
authenticate,
authorize(['write:products']),
authorize(['products.items']),
controller.unassignAsset
);
router.post(
'/:id/archive',
authenticate,
authorize(['write:products']),
authorize(['products.items']),
getByIdValidation,
validate,
audit('ARCHIVE_PRODUCT'),
@@ -164,7 +164,7 @@ router.post(
router.post(
'/:id/restore',
authenticate,
authorize(['write:products']),
authorize(['products.items']),
getByIdValidation,
validate,
audit('RESTORE_PRODUCT'),
@@ -3,6 +3,8 @@ 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 NotificationService from '../../notifications/notifications/notification.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import CompletenessService from './completeness.service.js';
async function validateAttributeValue(attr, val, productId = null, transaction = null) {
@@ -229,7 +231,7 @@ export class ProductService {
async getById(id, context = {}) {
const record = await repository.findById(id, {}, context);
if (!record) {
throw new Error('Product not found');
throw new ApiError(404, 'Product not found');
}
const json = record.toJSON();
@@ -404,10 +406,22 @@ export class ProductService {
action: 'CREATE',
resource: 'Product',
resourceId: product.id,
userId: context.userId || 'system',
userId: userContext.userId || 'system',
details: data
});
// Notify tenant users
if (userContext.tenantId) {
NotificationService.notifyTenant(userContext.tenantId, userContext.userId, {
variant: 'product',
action: 'created',
title: 'New product created',
description: `"${product.name || 'A product'}" was added to the catalog.`,
entity: product.name || 'Product',
entity_id: product.id.toString()
}).catch(err => console.error('Notification failed:', err));
}
return fullRecord;
} catch (error) {
await transaction.rollback();
@@ -420,7 +434,7 @@ export class ProductService {
try {
const record = await repository.findById(id, { transaction }, context);
if (!record) {
throw new Error('Product not found');
throw new ApiError(404, 'Product not found');
}
// Validate Family
@@ -554,10 +568,22 @@ export class ProductService {
action: 'UPDATE',
resource: 'Product',
resourceId: id,
userId: context.userId || 'system',
userId: userContext.userId || 'system',
details: data
});
// Notify tenant users
if (userContext.tenantId) {
NotificationService.notifyTenant(userContext.tenantId, userContext.userId, {
variant: 'product',
action: 'updated',
title: 'Product updated',
description: `"${record.name || 'A product'}" was updated.`,
entity: record.name || 'Product',
entity_id: id.toString()
}).catch(err => console.error('Notification failed:', err));
}
return fullRecord;
} catch (error) {
await transaction.rollback();
@@ -568,7 +594,7 @@ export class ProductService {
async delete(id, context = {}) {
const record = await repository.findById(id, {}, context);
if (!record) {
throw new Error('Product not found');
throw new ApiError(404, 'Product not found');
}
await repository.delete(id, {}, context);
@@ -579,9 +605,21 @@ export class ProductService {
action: 'DELETE',
resource: 'Product',
resourceId: id,
userId: context.userId || 'system'
userId: userContext.userId || userContext.id || 'system'
});
// Notify tenant users
if (userContext.tenantId) {
NotificationService.notifyTenant(userContext.tenantId, userContext.userId, {
variant: 'product',
action: 'deleted',
title: 'Product deleted',
description: 'A product was permanently removed from the catalog.',
entity: 'Product',
entity_id: id.toString()
}).catch(err => console.error('Notification failed:', err));
}
return true;
}
@@ -599,9 +637,21 @@ export class ProductService {
action: 'ARCHIVE',
resource: 'Product',
resourceId: id,
userId: context.userId || 'system'
userId: userContext.userId || userContext.id || 'system'
});
// Notify tenant users
if (userContext.tenantId) {
NotificationService.notifyTenant(userContext.tenantId, userContext.userId, {
variant: 'product',
action: 'deleted',
title: 'Product deleted',
description: 'A product was permanently removed from the catalog.',
entity: 'Product',
entity_id: id.toString()
}).catch(err => console.error('Notification failed:', err));
}
return true;
}
@@ -4,11 +4,11 @@ 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
import {
createValidation,
updateValidation,
deleteValidation,
getByIdValidation
} from './setting.validation.js';
const router = Router();
@@ -26,7 +26,7 @@ const router = Router();
router.get(
'/',
authenticate,
authorize(['read:settings']),
authorize(['settings.users']),
controller.getAll
);
@@ -49,7 +49,7 @@ router.get(
router.get(
'/:id',
authenticate,
authorize(['read:settings']),
authorize(['settings.users']),
getByIdValidation,
validate,
controller.getById
@@ -68,7 +68,7 @@ router.get(
router.post(
'/',
authenticate,
authorize(['write:settings']),
authorize(['settings.users']),
createValidation,
validate,
audit('CREATE_SETTING'),
@@ -92,7 +92,7 @@ router.post(
router.put(
'/:id',
authenticate,
authorize(['write:settings']),
authorize(['settings.users']),
updateValidation,
validate,
audit('UPDATE_SETTING'),
@@ -116,7 +116,7 @@ router.put(
router.delete(
'/:id',
authenticate,
authorize(['write:settings']),
authorize(['settings.users']),
deleteValidation,
validate,
audit('DELETE_SETTING'),
@@ -1,6 +1,7 @@
import repository from './setting.repository.js';
import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
export class SettingService {
async getAll(query = {}) {
@@ -11,7 +12,7 @@ export class SettingService {
async getById(id) {
const record = await repository.findById(id);
if (!record) {
throw new Error('Setting not found');
throw new ApiError(404, 'Setting not found');
}
return record;
}
@@ -27,7 +28,7 @@ export class SettingService {
action: 'CREATE',
resource: 'Setting',
resourceId: record.id,
userId: userContext.id || 'system',
userId: userContext.userId || 'system',
details: data
});
@@ -37,7 +38,7 @@ export class SettingService {
async update(id, data, userContext = {}) {
const record = await repository.update(id, data);
if (!record) {
throw new Error('Setting not found');
throw new ApiError(404, 'Setting not found');
}
SocketService.broadcast('setting:updated', record);
@@ -46,7 +47,7 @@ export class SettingService {
action: 'UPDATE',
resource: 'Setting',
resourceId: id,
userId: userContext.id || 'system',
userId: userContext.userId || 'system',
details: data
});
@@ -56,7 +57,7 @@ export class SettingService {
async delete(id, userContext = {}) {
const deleted = await repository.delete(id);
if (!deleted) {
throw new Error('Setting not found');
throw new ApiError(404, 'Setting not found');
}
SocketService.broadcast('setting:deleted', { id });
@@ -65,7 +66,7 @@ export class SettingService {
action: 'DELETE',
resource: 'Setting',
resourceId: id,
userId: userContext.id || 'system'
userId: userContext.userId || 'system'
});
return true;
@@ -4,11 +4,11 @@ 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
import {
createValidation,
updateValidation,
deleteValidation,
getByIdValidation
} from './variant.validation.js';
const router = Router();
@@ -26,7 +26,7 @@ const router = Router();
router.get(
'/',
authenticate,
authorize(['read:products']),
authorize(['products.variants']),
controller.getAll
);
@@ -65,7 +65,7 @@ router.post(
router.get(
'/:id',
authenticate,
authorize(['read:products']),
authorize(['products.variants']),
getByIdValidation,
validate,
controller.getById
@@ -84,7 +84,7 @@ router.get(
router.post(
'/',
authenticate,
authorize(['write:products']),
authorize(['products.variants']),
createValidation,
validate,
audit('CREATE_VARIANT'),
@@ -108,7 +108,7 @@ router.post(
router.put(
'/:id',
authenticate,
authorize(['write:products']),
authorize(['products.variants']),
updateValidation,
validate,
audit('UPDATE_VARIANT'),
@@ -132,7 +132,7 @@ router.put(
router.delete(
'/:id',
authenticate,
authorize(['write:products']),
authorize(['products.variants']),
deleteValidation,
validate,
audit('DELETE_VARIANT'),
@@ -156,7 +156,7 @@ router.delete(
router.post(
'/:id/archive',
authenticate,
authorize(['write:products']),
authorize(['products.variants']),
deleteValidation,
validate,
audit('ARCHIVE_VARIANT'),
@@ -180,7 +180,7 @@ router.post(
router.post(
'/:id/restore',
authenticate,
authorize(['write:products']),
authorize(['products.variants']),
getByIdValidation,
validate,
audit('RESTORE_VARIANT'),
@@ -190,28 +190,28 @@ router.post(
router.get(
'/:id/assets',
authenticate,
authorize(['read:products']),
authorize(['products.variants']),
controller.getAssets
);
router.post(
'/:id/assets',
authenticate,
authorize(['write:products']),
authorize(['products.variants']),
controller.assignAsset
);
router.put(
'/:id/assets/:assetId',
authenticate,
authorize(['write:products']),
authorize(['products.variants']),
controller.updateAssetMapping
);
router.delete(
'/:id/assets/:assetId',
authenticate,
authorize(['write:products']),
authorize(['products.variants']),
controller.unassignAsset
);
@@ -399,7 +399,11 @@ export class VariantService {
throw new Error('Variant not found');
}
await record.destroy({ transaction });
// Hard delete variant values mapping first
await models.VariantValue.destroy({ where: { variant_id: id }, transaction });
// Hard delete variant
await record.destroy({ force: true, transaction });
await transaction.commit();
// Emit socket event
@@ -0,0 +1,105 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('notifications', {
id: {
allowNull: false,
primaryKey: true,
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4
},
tenant_id: {
type: Sequelize.INTEGER,
allowNull: true,
references: {
model: 'tenants',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
},
recipient_user_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
},
actor_user_id: {
type: Sequelize.INTEGER,
allowNull: true,
references: {
model: 'users',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL'
},
variant: {
type: Sequelize.STRING(50),
allowNull: false
},
action: {
type: Sequelize.STRING(50),
allowNull: false
},
title: {
type: Sequelize.STRING(255),
allowNull: false
},
description: {
type: Sequelize.TEXT,
allowNull: false
},
entity: {
type: Sequelize.STRING(255),
allowNull: false
},
entity_id: {
type: Sequelize.STRING(100),
allowNull: false
},
is_read: {
type: Sequelize.BOOLEAN,
defaultValue: false,
allowNull: false
},
metadata: {
type: Sequelize.JSONB,
allowNull: true
},
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
}
});
// Indexes
await queryInterface.addIndex('notifications', ['recipient_user_id', 'is_read'], {
name: 'idx_notifications_recipient_is_read'
});
await queryInterface.addIndex('notifications', ['recipient_user_id', 'created_at'], {
name: 'idx_notifications_recipient_created_at'
});
await queryInterface.addIndex('notifications', ['tenant_id', 'recipient_user_id'], {
name: 'idx_notifications_tenant_recipient'
});
await queryInterface.addIndex('notifications', ['tenant_id', 'recipient_user_id', 'created_at'], {
name: 'idx_notifications_tenant_recipient_created_at'
});
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('notifications');
}
};
@@ -0,0 +1,63 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('auditlogs', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
allowNull: false
},
action: {
type: Sequelize.STRING,
allowNull: false
},
resource: {
type: Sequelize.STRING,
allowNull: false
},
resource_id: {
type: Sequelize.STRING,
allowNull: true
},
user_id: {
type: Sequelize.INTEGER,
allowNull: true
},
old_value: {
type: Sequelize.JSONB,
allowNull: true
},
new_value: {
type: Sequelize.JSONB,
allowNull: true
},
details: {
type: Sequelize.JSONB,
allowNull: true
},
status: {
type: Sequelize.STRING,
defaultValue: 'success'
},
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('auditlogs', ['user_id']);
await queryInterface.addIndex('auditlogs', ['resource', 'resource_id']);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('auditlogs');
}
};
@@ -0,0 +1,66 @@
'use strict';
/**
* Migration: seed-application-permission-nodes
*
* Idempotent: uses ON CONFLICT (node_code) DO NOTHING so it is safe to run
* even when the data has already been inserted.
*/
module.exports = {
async up(queryInterface) {
const now = new Date().toISOString();
// ── Parent groups (node_level: 1) ─────────────────────────────────────────
await queryInterface.sequelize.query(`
INSERT INTO permission_nodes (node_code, node_name, node_type, parent_id, node_level, display_order,
can_view, can_create, can_edit, can_delete, can_alter, can_export, can_import, created_at, updated_at)
VALUES
('products', 'Products', 'group', NULL, 1, 10, true, false, false, false, false, false, false, '${now}', '${now}'),
('masters', 'Masters', 'group', NULL, 1, 20, true, false, false, false, false, false, false, '${now}', '${now}'),
('settings', 'Settings', 'group', NULL, 1, 30, true, false, false, false, false, false, false, '${now}', '${now}'),
('reports', 'Reports', 'group', NULL, 1, 40, true, false, false, false, false, false, false, '${now}', '${now}')
ON CONFLICT (node_code) DO NOTHING;
`);
// Fetch parent IDs
const parents = await queryInterface.sequelize.query(
`SELECT id, node_code FROM permission_nodes WHERE node_code IN ('products','masters','settings','reports')`,
{ type: queryInterface.sequelize.QueryTypes.SELECT }
);
const pid = {};
parents.forEach(r => { pid[r.node_code] = r.id; });
// ── Child nodes (node_level: 2) ───────────────────────────────────────────
await queryInterface.sequelize.query(`
INSERT INTO permission_nodes (node_code, node_name, node_type, parent_id, node_level, display_order,
can_view, can_create, can_edit, can_delete, can_alter, can_export, can_import, created_at, updated_at)
VALUES
('products.items', 'Products', 'feature', '${pid['products']}', 2, 11, true, true, true, true, true, true, true, '${now}', '${now}'),
('products.variants', 'Variant Management', 'feature', '${pid['products']}', 2, 12, true, true, true, true, true, true, true, '${now}', '${now}'),
('products.families', 'Families', 'feature', '${pid['products']}', 2, 13, true, true, true, true, false, false, false, '${now}', '${now}'),
('products.categories', 'Categories', 'feature', '${pid['products']}', 2, 14, true, true, true, true, false, false, false, '${now}', '${now}'),
('products.attributes', 'Attributes', 'feature', '${pid['products']}', 2, 15, true, true, true, true, false, false, false, '${now}', '${now}'),
('masters.brands', 'Brands', 'feature', '${pid['masters']}', 2, 21, true, true, true, true, false, false, false, '${now}', '${now}'),
('masters.units', 'Units', 'feature', '${pid['masters']}', 2, 22, true, true, true, true, false, false, false, '${now}', '${now}'),
('settings.users', 'Users', 'feature', '${pid['settings']}', 2, 31, true, true, true, true, false, false, false, '${now}', '${now}'),
('settings.roles', 'Roles', 'feature', '${pid['settings']}', 2, 32, true, true, true, true, false, false, false, '${now}', '${now}'),
('settings.integrations', 'Integrations', 'feature', '${pid['settings']}', 2, 33, true, true, true, true, false, false, false, '${now}', '${now}'),
('reports.sales', 'Sales Reports', 'feature', '${pid['reports']}', 2, 41, true, false, false, false, false, true, false, '${now}', '${now}'),
('reports.inventory', 'Inventory Reports', 'feature', '${pid['reports']}', 2, 42, true, false, false, false, false, true, false, '${now}', '${now}')
ON CONFLICT (node_code) DO NOTHING;
`);
},
async down(queryInterface) {
await queryInterface.bulkDelete('permission_nodes', {
node_code: [
'products', 'masters', 'settings', 'reports',
'products.items', 'products.variants', 'products.families',
'products.categories', 'products.attributes',
'masters.brands', 'masters.units',
'settings.users', 'settings.roles', 'settings.integrations',
'reports.sales', 'reports.inventory',
],
}, {});
},
};
@@ -0,0 +1,31 @@
'use strict';
module.exports = {
async up(queryInterface) {
const now = new Date().toISOString();
// Find the settings parent group id
const parents = await queryInterface.sequelize.query(
`SELECT id FROM permission_nodes WHERE node_code = 'settings'`,
{ type: queryInterface.sequelize.QueryTypes.SELECT }
);
const parentId = parents.length > 0 ? parents[0].id : null;
await queryInterface.sequelize.query(`
INSERT INTO permission_nodes (node_code, node_name, node_type, parent_id, node_level, display_order,
can_view, can_create, can_edit, can_delete, can_alter, can_export, can_import, created_at, updated_at)
VALUES (
'notifications', 'Notifications', 'feature', ${parentId ? `'${parentId}'` : 'NULL'}, 2, 34,
true, false, true, false, false, false, false, '${now}', '${now}'
)
ON CONFLICT (node_code) DO NOTHING;
`);
},
async down(queryInterface) {
await queryInterface.bulkDelete('permission_nodes', {
node_code: 'notifications'
}, {});
}
};
+2
View File
@@ -155,6 +155,7 @@ import variantValueModelInit from '../../features/variants/variants/variantValue
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 notificationModelInit from '../../features/notifications/notifications/notification.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';
@@ -223,6 +224,7 @@ export const initializeDatabaseModels = () => {
registerModel('AssetType', assetTypeModelInit);
registerModel('AssetFamily', assetFamilyModelInit);
registerModel('Asset', assetModelInit);
registerModel('Notification', notificationModelInit);
registerModel('AssetFolder', assetFolderModelInit);
registerModel('Tag', tagModelInit);
registerModel('AssetTag', assetTagModelInit);
+2 -1
View File
@@ -17,7 +17,8 @@ export const audit = (action) => {
action,
resource: req.baseUrl.split('/').pop(),
resourceId: req.params.id || (parsedBody.data ? parsedBody.data.id : null),
userId: req.user ? req.user.id : 'anonymous',
userId: req.user ? req.user.user_id : 'anonymous',
new_value: req.body && Object.keys(req.body).length ? req.body : null,
details: {
method: req.method,
query: req.query,
@@ -5,6 +5,7 @@ export const validate = (req, res, next) => {
if (!errors.isEmpty()) {
return res.status(422).json({
success: false,
message: `Validation Error: ${errors.array()[0].msg}`,
errors: errors.array().map(err => ({ field: err.path, message: err.msg }))
});
}
+27 -6
View File
@@ -1,10 +1,31 @@
import AuditLogRepository from '../../features/auditLogs/auditLogs/auditLog.repository.js';
export class AuditService {
static async log(auditData) {
// In absolute production code, this inserts a record into AuditLog table
console.log('[AUDIT LOG]:', JSON.stringify({
timestamp: new Date(),
...auditData
}, null, 2));
return true;
// Only allow the middleware to write to the DB (which passes method/ip) to prevent duplicate entries
if (!auditData.details || !auditData.details.method) {
return true; // silently skip service-level calls
}
try {
await AuditLogRepository.create({
action: auditData.action,
resource: auditData.resource,
resource_id: auditData.resourceId?.toString() || null,
user_id: (auditData.userId && auditData.userId !== 'anonymous' && auditData.userId !== 'system')
? parseInt(auditData.userId, 10) || null
: null,
old_value: auditData.old_value || null,
new_value: auditData.new_value || null,
details: auditData.details || null,
status: auditData.status || 'success'
});
return true;
} catch (err) {
console.error('[AuditService] Failed to create audit log:', err.message);
return false;
}
}
}
+11 -18
View File
@@ -1,18 +1,11 @@
import { SocketService } from './socket.service.js';
export class NotificationService {
static async send(userId, notification) {
console.log(`Sending notification to user ${userId}:`, notification);
// Broadcast websocket notification
SocketService.to(`user:${userId}`, 'notification:received', notification);
return true;
}
static async broadcast(notification) {
console.log('Broadcasting notification:', notification);
SocketService.broadcast('notification:broadcast', notification);
return true;
}
}
/**
* Shared notification service entry point.
*
* All notification generation is centralised in the feature-level NotificationService.
* This module re-exports it so business services that import from the shared path
* receive the same singleton instance.
*
* Business services must call notifyTenant() after a committed transaction.
* They must never access NotificationRepository directly.
*/
export { default } from '../../features/notifications/notifications/notification.service.js';
+29
View File
@@ -1,4 +1,5 @@
import { Server } from 'socket.io';
import { notificationEvents } from '../../features/notifications/notifications/notification.events.js';
class SocketServiceClass {
constructor() {
@@ -16,10 +17,38 @@ class SocketServiceClass {
this.io.on('connection', (socket) => {
console.log('Client connected to Socket.io: ' + socket.id);
const userId = socket.handshake.query.userId;
const tenantId = socket.handshake.query.tenantId;
if (userId) {
socket.join(`user_${userId}`);
console.log(`Socket ${socket.id} joined room: user_${userId}`);
}
if (tenantId) {
socket.join(`tenant_${tenantId}`);
console.log(`Socket ${socket.id} joined room: tenant_${tenantId}`);
}
socket.on('join_user', (uid) => {
if (uid) {
socket.join(`user_${uid}`);
console.log(`Socket ${socket.id} joined room: user_${uid} via event`);
}
});
socket.on('disconnect', () => {
console.log('Client disconnected: ' + socket.id);
});
});
// Register decoupled event listeners for real-time notifications
notificationEvents.on('notification:created', ({ recipientUserId, notification }) => {
this.to(`user_${recipientUserId}`, 'notification:created', notification);
});
notificationEvents.on('notification:unread-count', ({ recipientUserId, count }) => {
this.to(`user_${recipientUserId}`, 'notification:unread-count', { unreadCount: count });
});
}
broadcast(event, data) {