creation of notification flow

This commit is contained in:
MohamedHasan07
2026-07-16 11:27:21 +05:30
parent e09ca83560
commit 6dc5021176
42 changed files with 1221 additions and 183 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>
@@ -1,6 +1,7 @@
import repository from './attribute.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 AttributeService {
async getAll(query = {}) {
@@ -11,7 +12,7 @@ export class AttributeService {
async getById(id) {
const record = await repository.findById(id);
if (!record) {
throw new Error('Attribute not found');
throw new ApiError(404, 'Attribute not found');
}
return record;
}
@@ -27,7 +28,7 @@ export class AttributeService {
action: 'CREATE',
resource: 'Attribute',
resourceId: record.id,
userId: userContext.id || 'system',
userId: userContext.user_id || 'system',
details: data
});
@@ -37,7 +38,7 @@ export class AttributeService {
async update(id, data, userContext = {}) {
const record = await repository.update(id, data);
if (!record) {
throw new Error('Attribute not found');
throw new ApiError(404, 'Attribute not found');
}
SocketService.broadcast('attribute:updated', record);
@@ -46,7 +47,7 @@ export class AttributeService {
action: 'UPDATE',
resource: 'Attribute',
resourceId: id,
userId: userContext.id || 'system',
userId: userContext.user_id || 'system',
details: data
});
@@ -56,7 +57,7 @@ export class AttributeService {
async delete(id, userContext = {}) {
const deleted = await repository.delete(id);
if (!deleted) {
throw new Error('Attribute not found');
throw new ApiError(404, 'Attribute not found');
}
SocketService.broadcast('attribute:deleted', { id });
@@ -65,7 +66,7 @@ export class AttributeService {
action: 'DELETE',
resource: 'Attribute',
resourceId: id,
userId: userContext.id || 'system'
userId: userContext.user_id || 'system'
});
return true;
@@ -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,
@@ -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.user_id || '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.user_id || '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.user_id || 'system'
});
return true;
@@ -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 = {}) {
@@ -20,7 +21,7 @@ export class RoleService {
async getById(id) {
const record = await repository.findById(id);
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) {
const deleted = await repository.delete(id);
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() {
return await models.PermissionNode.findAll();
return await models.PermissionNode.findAll({
order: [['display_order', 'ASC'], ['node_level', 'ASC'], ['id', 'ASC']],
});
}
}
@@ -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 = {}) {
@@ -11,7 +12,7 @@ export class UserService {
async getById(id) {
const record = await repository.findById(id);
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) {
const deleted = await repository.delete(id);
if (!deleted) {
throw new Error('User not found');
throw new ApiError(404, 'User not found');
}
return true;
}
+44 -6
View File
@@ -1,6 +1,8 @@
import repository from './brand.repository.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 = {}) {
@@ -11,7 +13,7 @@ export class BrandService {
async getById(id) {
const record = await repository.findById(id);
if (!record) {
throw new Error('Brand not found');
throw new ApiError(404, 'Brand not found');
}
return record;
}
@@ -27,17 +29,29 @@ export class BrandService {
action: 'CREATE',
resource: 'Brand',
resourceId: record.id,
userId: userContext.id || 'system',
userId: userContext.user_id || 'system',
details: data
});
// Notify tenant users
if (userContext.tenant_id) {
NotificationService.notifyTenant(userContext.tenant_id, userContext.user_id, {
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, userContext = {}) {
const record = await repository.update(id, data);
if (!record) {
throw new Error('Brand not found');
throw new ApiError(404, 'Brand not found');
}
SocketService.broadcast('brand:updated', record);
@@ -46,17 +60,29 @@ export class BrandService {
action: 'UPDATE',
resource: 'Brand',
resourceId: id,
userId: userContext.id || 'system',
userId: userContext.user_id || 'system',
details: data
});
// Notify tenant users
if (userContext.tenant_id) {
NotificationService.notifyTenant(userContext.tenant_id, userContext.user_id, {
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 record;
}
async delete(id, userContext = {}) {
const deleted = await repository.delete(id);
if (!deleted) {
throw new Error('Brand not found');
throw new ApiError(404, 'Brand not found');
}
SocketService.broadcast('brand:deleted', { id });
@@ -65,9 +91,21 @@ export class BrandService {
action: 'DELETE',
resource: 'Brand',
resourceId: id,
userId: userContext.id || 'system'
userId: userContext.user_id || 'system'
});
// Notify tenant users
if (userContext.tenant_id) {
NotificationService.notifyTenant(userContext.tenant_id, userContext.user_id, {
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;
}
}
@@ -1,6 +1,7 @@
import repository from './catalog.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 CatalogService {
async getAll(query = {}) {
@@ -11,7 +12,7 @@ export class CatalogService {
async getById(id) {
const record = await repository.findById(id);
if (!record) {
throw new Error('Catalog not found');
throw new ApiError(404, 'Catalog not found');
}
return record;
}
@@ -27,7 +28,7 @@ export class CatalogService {
action: 'CREATE',
resource: 'Catalog',
resourceId: record.id,
userId: userContext.id || 'system',
userId: userContext.user_id || 'system',
details: data
});
@@ -37,7 +38,7 @@ export class CatalogService {
async update(id, data, userContext = {}) {
const record = await repository.update(id, data);
if (!record) {
throw new Error('Catalog not found');
throw new ApiError(404, 'Catalog not found');
}
SocketService.broadcast('catalog:updated', record);
@@ -46,7 +47,7 @@ export class CatalogService {
action: 'UPDATE',
resource: 'Catalog',
resourceId: id,
userId: userContext.id || 'system',
userId: userContext.user_id || 'system',
details: data
});
@@ -56,7 +57,7 @@ export class CatalogService {
async delete(id, userContext = {}) {
const deleted = await repository.delete(id);
if (!deleted) {
throw new Error('Catalog not found');
throw new ApiError(404, 'Catalog not found');
}
SocketService.broadcast('catalog:deleted', { id });
@@ -65,7 +66,7 @@ export class CatalogService {
action: 'DELETE',
resource: 'Catalog',
resourceId: id,
userId: userContext.id || 'system'
userId: userContext.user_id || 'system'
});
return true;
@@ -1,6 +1,8 @@
import repository from './categorie.repository.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 CategorieService {
async getAll(query = {}) {
@@ -11,7 +13,7 @@ export class CategorieService {
async getById(id) {
const record = await repository.findById(id);
if (!record) {
throw new Error('Categorie not found');
throw new ApiError(404, 'Categorie not found');
}
return record;
}
@@ -27,17 +29,29 @@ export class CategorieService {
action: 'CREATE',
resource: 'Categorie',
resourceId: record.id,
userId: userContext.id || 'system',
userId: userContext.user_id || 'system',
details: data
});
// Notify tenant users
if (userContext.tenant_id) {
NotificationService.notifyTenant(userContext.tenant_id, userContext.user_id, {
variant: 'category',
action: 'created',
title: 'New category created',
description: `Category "${record.name || 'A category'}" was added.`,
entity: record.name || 'Category',
entity_id: record.id.toString()
}).catch(err => console.error('Notification failed:', err));
}
return record;
}
async update(id, data, userContext = {}) {
const record = await repository.update(id, data);
if (!record) {
throw new Error('Categorie not found');
throw new ApiError(404, 'Categorie not found');
}
SocketService.broadcast('categorie:updated', record);
@@ -46,17 +60,29 @@ export class CategorieService {
action: 'UPDATE',
resource: 'Categorie',
resourceId: id,
userId: userContext.id || 'system',
userId: userContext.user_id || 'system',
details: data
});
// Notify tenant users
if (userContext.tenant_id) {
NotificationService.notifyTenant(userContext.tenant_id, userContext.user_id, {
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 record;
}
async delete(id, userContext = {}) {
const deleted = await repository.delete(id);
if (!deleted) {
throw new Error('Categorie not found');
throw new ApiError(404, 'Categorie not found');
}
SocketService.broadcast('categorie:deleted', { id });
@@ -65,9 +91,21 @@ export class CategorieService {
action: 'DELETE',
resource: 'Categorie',
resourceId: id,
userId: userContext.id || 'system'
userId: userContext.user_id || 'system'
});
// Notify tenant users
if (userContext.tenant_id) {
NotificationService.notifyTenant(userContext.tenant_id, userContext.user_id, {
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;
}
}
@@ -1,6 +1,7 @@
import repository from './channel.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 ChannelService {
async getAll(query = {}) {
@@ -11,7 +12,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 +28,7 @@ export class ChannelService {
action: 'CREATE',
resource: 'Channel',
resourceId: record.id,
userId: userContext.id || 'system',
userId: userContext.user_id || 'system',
details: data
});
@@ -37,7 +38,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 +47,7 @@ export class ChannelService {
action: 'UPDATE',
resource: 'Channel',
resourceId: id,
userId: userContext.id || 'system',
userId: userContext.user_id || 'system',
details: data
});
@@ -56,7 +57,7 @@ export class ChannelService {
async delete(id, userContext = {}) {
const deleted = await repository.delete(id);
if (!deleted) {
throw new Error('Channel not found');
throw new ApiError(404, 'Channel not found');
}
SocketService.broadcast('channel:deleted', { id });
@@ -65,7 +66,7 @@ export class ChannelService {
action: 'DELETE',
resource: 'Channel',
resourceId: id,
userId: userContext.id || 'system'
userId: userContext.user_id || 'system'
});
return true;
@@ -27,7 +27,7 @@ export class MetricService {
action: 'CREATE',
resource: 'Metric',
resourceId: record.id,
userId: userContext.id || 'system',
userId: userContext.user_id || 'system',
details: data
});
@@ -46,7 +46,7 @@ export class MetricService {
action: 'UPDATE',
resource: 'Metric',
resourceId: id,
userId: userContext.id || 'system',
userId: userContext.user_id || 'system',
details: data
});
@@ -65,7 +65,7 @@ export class MetricService {
action: 'DELETE',
resource: 'Metric',
resourceId: id,
userId: userContext.id || 'system'
userId: userContext.user_id || 'system'
});
return true;
@@ -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.user_id || '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.user_id || '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.user_id || 'system'
});
return true;
@@ -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.user_id || '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.user_id || '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.user_id || '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';
export default function registerRoutes(app) {
app.use('/api/v1', authenticationRouter);
@@ -28,4 +29,5 @@ 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);
}
@@ -1,6 +1,7 @@
import repository from './assetFamily.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 AssetFamilyService {
async getAll(query = {}) {
@@ -10,7 +11,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 record;
}
@@ -24,7 +25,7 @@ export class AssetFamilyService {
action: 'CREATE',
resource: 'AssetFamily',
resourceId: record.id,
userId: userContext.id || 'system',
userId: userContext.user_id || 'system',
details: data
});
@@ -34,7 +35,7 @@ export class AssetFamilyService {
async update(id, data, userContext = {}) {
const record = await repository.update(id, data);
if (!record) {
throw new Error('AssetFamily not found');
throw new ApiError(404, 'AssetFamily not found');
}
SocketService.broadcast('assetFamily:updated', record);
@@ -43,7 +44,7 @@ export class AssetFamilyService {
action: 'UPDATE',
resource: 'AssetFamily',
resourceId: id,
userId: userContext.id || 'system',
userId: userContext.user_id || 'system',
details: data
});
@@ -53,7 +54,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 });
@@ -62,7 +63,7 @@ export class AssetFamilyService {
action: 'DELETE',
resource: 'AssetFamily',
resourceId: id,
userId: userContext.id || 'system'
userId: userContext.user_id || 'system'
});
return true;
@@ -1,6 +1,7 @@
import repository from './assetType.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 AssetTypeService {
async getAll(query = {}) {
@@ -10,7 +11,7 @@ export class AssetTypeService {
async getById(id) {
const record = await repository.findById(id);
if (!record) {
throw new Error('AssetType not found');
throw new ApiError(404, 'AssetType not found');
}
return record;
}
@@ -24,7 +25,7 @@ export class AssetTypeService {
action: 'CREATE',
resource: 'AssetType',
resourceId: record.id,
userId: userContext.id || 'system',
userId: userContext.user_id || 'system',
details: data
});
@@ -34,7 +35,7 @@ export class AssetTypeService {
async update(id, data, userContext = {}) {
const record = await repository.update(id, data);
if (!record) {
throw new Error('AssetType not found');
throw new ApiError(404, 'AssetType not found');
}
SocketService.broadcast('assetType:updated', record);
@@ -43,7 +44,7 @@ export class AssetTypeService {
action: 'UPDATE',
resource: 'AssetType',
resourceId: id,
userId: userContext.id || 'system',
userId: userContext.user_id || 'system',
details: data
});
@@ -53,7 +54,7 @@ export class AssetTypeService {
async delete(id, userContext = {}) {
const deleted = await repository.delete(id);
if (!deleted) {
throw new Error('AssetType not found');
throw new ApiError(404, 'AssetType not found');
}
SocketService.broadcast('assetType:deleted', { id });
@@ -62,7 +63,7 @@ export class AssetTypeService {
action: 'DELETE',
resource: 'AssetType',
resourceId: id,
userId: userContext.id || 'system'
userId: userContext.user_id || 'system'
});
return true;
+7 -6
View File
@@ -1,6 +1,7 @@
import repository from './asset.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 AssetService {
async getAll(query = {}) {
@@ -10,7 +11,7 @@ export class AssetService {
async getById(id) {
const record = await repository.findById(id);
if (!record) {
throw new Error('Asset not found');
throw new ApiError(404, 'Asset not found');
}
return record;
}
@@ -24,7 +25,7 @@ export class AssetService {
action: 'CREATE',
resource: 'Asset',
resourceId: record.id,
userId: userContext.id || 'system',
userId: userContext.user_id || 'system',
details: data
});
@@ -34,7 +35,7 @@ export class AssetService {
async update(id, data, userContext = {}) {
const record = await repository.update(id, data);
if (!record) {
throw new Error('Asset not found');
throw new ApiError(404, 'Asset not found');
}
SocketService.broadcast('asset:updated', record);
@@ -43,7 +44,7 @@ export class AssetService {
action: 'UPDATE',
resource: 'Asset',
resourceId: id,
userId: userContext.id || 'system',
userId: userContext.user_id || 'system',
details: data
});
@@ -53,7 +54,7 @@ export class AssetService {
async delete(id, userContext = {}) {
const deleted = await repository.delete(id);
if (!deleted) {
throw new Error('Asset not found');
throw new ApiError(404, 'Asset not found');
}
SocketService.broadcast('asset:deleted', { id });
@@ -62,7 +63,7 @@ export class AssetService {
action: 'DELETE',
resource: 'Asset',
resourceId: id,
userId: userContext.id || 'system'
userId: userContext.user_id || 'system'
});
return true;
+3 -3
View File
@@ -27,7 +27,7 @@ export class MediaService {
action: 'CREATE',
resource: 'Media',
resourceId: record.id,
userId: userContext.id || 'system',
userId: userContext.user_id || 'system',
details: data
});
@@ -46,7 +46,7 @@ export class MediaService {
action: 'UPDATE',
resource: 'Media',
resourceId: id,
userId: userContext.id || 'system',
userId: userContext.user_id || 'system',
details: data
});
@@ -65,7 +65,7 @@ export class MediaService {
action: 'DELETE',
resource: 'Media',
resourceId: id,
userId: userContext.id || 'system'
userId: userContext.user_id || '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.view']),
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.view']),
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')
];
+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.user_id || '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.user_id || '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.user_id || 'system'
});
return true;
@@ -1,4 +1,5 @@
import { models } from '../../../shared/database/models.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
export class TenantService {
async getAll() {
@@ -8,7 +9,7 @@ export class TenantService {
async getById(id) {
const record = await models.Tenant.findByPk(id);
if (!record) {
throw new Error('Tenant not found');
throw new ApiError(404, 'Tenant not found');
}
return record;
}
@@ -1,6 +1,8 @@
import repository from './product.repository.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 ProductService {
async getAll(query = {}) {
@@ -11,7 +13,7 @@ export class ProductService {
async getById(id) {
const record = await repository.findById(id);
if (!record) {
throw new Error('Product not found');
throw new ApiError(404, 'Product not found');
}
return record;
}
@@ -27,17 +29,29 @@ export class ProductService {
action: 'CREATE',
resource: 'Product',
resourceId: record.id,
userId: userContext.id || 'system',
userId: userContext.user_id || 'system',
details: data
});
// Notify tenant users
if (userContext.tenant_id) {
NotificationService.notifyTenant(userContext.tenant_id, userContext.user_id, {
variant: 'product',
action: 'created',
title: 'New product created',
description: `"${record.name || 'A product'}" was added to the catalog.`,
entity: record.name || 'Product',
entity_id: record.id.toString()
}).catch(err => console.error('Notification failed:', err));
}
return record;
}
async update(id, data, userContext = {}) {
const record = await repository.update(id, data);
if (!record) {
throw new Error('Product not found');
throw new ApiError(404, 'Product not found');
}
SocketService.broadcast('product:updated', record);
@@ -46,17 +60,29 @@ export class ProductService {
action: 'UPDATE',
resource: 'Product',
resourceId: id,
userId: userContext.id || 'system',
userId: userContext.user_id || 'system',
details: data
});
// Notify tenant users
if (userContext.tenant_id) {
NotificationService.notifyTenant(userContext.tenant_id, userContext.user_id, {
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 record;
}
async delete(id, userContext = {}) {
const deleted = await repository.delete(id);
if (!deleted) {
throw new Error('Product not found');
throw new ApiError(404, 'Product not found');
}
SocketService.broadcast('product:deleted', { id });
@@ -65,9 +91,21 @@ export class ProductService {
action: 'DELETE',
resource: 'Product',
resourceId: id,
userId: userContext.id || 'system'
userId: userContext.user_id || 'system'
});
// Notify tenant users
if (userContext.tenant_id) {
NotificationService.notifyTenant(userContext.tenant_id, userContext.user_id, {
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;
}
}
@@ -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.user_id || '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.user_id || '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.user_id || 'system'
});
return true;
@@ -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
@@ -75,6 +75,7 @@ import auditLogModelInit from '../../features/auditLogs/auditLogs/auditLog.model
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';
export const initializeDatabaseModels = () => {
registerModel('Tenant', tenantModelInit);
@@ -96,6 +97,7 @@ export const initializeDatabaseModels = () => {
registerModel('AssetType', assetTypeModelInit);
registerModel('AssetFamily', assetFamilyModelInit);
registerModel('Asset', assetModelInit);
registerModel('Notification', notificationModelInit);
associateModels();
};
+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,
+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) {