creation of notification flow
This commit is contained in:
+19
-19
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
];
|
||||
@@ -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'
|
||||
}, {});
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user