diff --git a/.env.development b/.env.development index 7e1bcb5..2a8ac13 100644 --- a/.env.development +++ b/.env.development @@ -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 +# # 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 diff --git a/.env.example b/.env.example index a914dad..a5c59c9 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.env.local b/.env.local index ad9d794..127a7a5 100644 --- a/.env.local +++ b/.env.local @@ -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. diff --git a/.env.test b/.env.test index fe155b0..89707af 100644 --- a/.env.test +++ b/.env.test @@ -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 +# # 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 diff --git a/src/features/attributes/attributes/attribute.service.js b/src/features/attributes/attributes/attribute.service.js index 13da8b2..257b8da 100644 --- a/src/features/attributes/attributes/attribute.service.js +++ b/src/features/attributes/attributes/attribute.service.js @@ -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; diff --git a/src/features/auditLogs/auditLogs/auditLog.model.js b/src/features/auditLogs/auditLogs/auditLog.model.js index af06d66..577579e 100644 --- a/src/features/auditLogs/auditLogs/auditLog.model.js +++ b/src/features/auditLogs/auditLogs/auditLog.model.js @@ -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, diff --git a/src/features/auditLogs/auditLogs/auditLog.service.js b/src/features/auditLogs/auditLogs/auditLog.service.js index 932a2f3..b61d7c2 100644 --- a/src/features/auditLogs/auditLogs/auditLog.service.js +++ b/src/features/auditLogs/auditLogs/auditLog.service.js @@ -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; diff --git a/src/features/authentication/access/role.service.js b/src/features/authentication/access/role.service.js index 46869fe..0add99e 100644 --- a/src/features/authentication/access/role.service.js +++ b/src/features/authentication/access/role.service.js @@ -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']], + }); } } diff --git a/src/features/authentication/users/user.service.js b/src/features/authentication/users/user.service.js index 8fcbdac..b4347f3 100644 --- a/src/features/authentication/users/user.service.js +++ b/src/features/authentication/users/user.service.js @@ -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; } diff --git a/src/features/brands/brands/brand.service.js b/src/features/brands/brands/brand.service.js index f27a1ed..e4af045 100644 --- a/src/features/brands/brands/brand.service.js +++ b/src/features/brands/brands/brand.service.js @@ -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; } } diff --git a/src/features/catalogs/catalogs/catalog.service.js b/src/features/catalogs/catalogs/catalog.service.js index b174491..ddb5efd 100644 --- a/src/features/catalogs/catalogs/catalog.service.js +++ b/src/features/catalogs/catalogs/catalog.service.js @@ -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; diff --git a/src/features/categories/categories/categorie.service.js b/src/features/categories/categories/categorie.service.js index be27ebf..221d21c 100644 --- a/src/features/categories/categories/categorie.service.js +++ b/src/features/categories/categories/categorie.service.js @@ -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; } } diff --git a/src/features/channels/channels/channel.service.js b/src/features/channels/channels/channel.service.js index 3c4b607..6834c3e 100644 --- a/src/features/channels/channels/channel.service.js +++ b/src/features/channels/channels/channel.service.js @@ -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; diff --git a/src/features/dashboard/metrics/metric.service.js b/src/features/dashboard/metrics/metric.service.js index 81e758b..d90be53 100644 --- a/src/features/dashboard/metrics/metric.service.js +++ b/src/features/dashboard/metrics/metric.service.js @@ -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; diff --git a/src/features/exports/exports/export.service.js b/src/features/exports/exports/export.service.js index 66c4188..023ea1e 100644 --- a/src/features/exports/exports/export.service.js +++ b/src/features/exports/exports/export.service.js @@ -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; diff --git a/src/features/imports/imports/import.service.js b/src/features/imports/imports/import.service.js index 7e331e0..d1dfcf0 100644 --- a/src/features/imports/imports/import.service.js +++ b/src/features/imports/imports/import.service.js @@ -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; diff --git a/src/features/index.js b/src/features/index.js index 51fce0f..7931cf6 100644 --- a/src/features/index.js +++ b/src/features/index.js @@ -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); } diff --git a/src/features/media/assetFamilies/assetFamily.service.js b/src/features/media/assetFamilies/assetFamily.service.js index c14dffb..152dbac 100644 --- a/src/features/media/assetFamilies/assetFamily.service.js +++ b/src/features/media/assetFamilies/assetFamily.service.js @@ -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; diff --git a/src/features/media/assetTypes/assetType.service.js b/src/features/media/assetTypes/assetType.service.js index 4a70fa2..e3c5b15 100644 --- a/src/features/media/assetTypes/assetType.service.js +++ b/src/features/media/assetTypes/assetType.service.js @@ -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; diff --git a/src/features/media/assets/asset.service.js b/src/features/media/assets/asset.service.js index ebe2cb6..4480e9c 100644 --- a/src/features/media/assets/asset.service.js +++ b/src/features/media/assets/asset.service.js @@ -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; diff --git a/src/features/media/media/media.service.js b/src/features/media/media/media.service.js index f62115a..4050a2e 100644 --- a/src/features/media/media/media.service.js +++ b/src/features/media/media/media.service.js @@ -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; diff --git a/src/features/notifications/index.js b/src/features/notifications/index.js new file mode 100644 index 0000000..dbfc181 --- /dev/null +++ b/src/features/notifications/index.js @@ -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; diff --git a/src/features/notifications/notifications/notification.controller.js b/src/features/notifications/notifications/notification.controller.js new file mode 100644 index 0000000..082d51c --- /dev/null +++ b/src/features/notifications/notifications/notification.controller.js @@ -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(); diff --git a/src/features/notifications/notifications/notification.events.js b/src/features/notifications/notifications/notification.events.js new file mode 100644 index 0000000..99fa1d7 --- /dev/null +++ b/src/features/notifications/notifications/notification.events.js @@ -0,0 +1,5 @@ +import { EventEmitter } from 'events'; + +class NotificationEventEmitter extends EventEmitter {} + +export const notificationEvents = new NotificationEventEmitter(); diff --git a/src/features/notifications/notifications/notification.model.js b/src/features/notifications/notifications/notification.model.js new file mode 100644 index 0000000..528e7e9 --- /dev/null +++ b/src/features/notifications/notifications/notification.model.js @@ -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; +}; diff --git a/src/features/notifications/notifications/notification.repository.js b/src/features/notifications/notifications/notification.repository.js new file mode 100644 index 0000000..3987a72 --- /dev/null +++ b/src/features/notifications/notifications/notification.repository.js @@ -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(); diff --git a/src/features/notifications/notifications/notification.routes.js b/src/features/notifications/notifications/notification.routes.js new file mode 100644 index 0000000..c4f8105 --- /dev/null +++ b/src/features/notifications/notifications/notification.routes.js @@ -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; diff --git a/src/features/notifications/notifications/notification.service.js b/src/features/notifications/notifications/notification.service.js new file mode 100644 index 0000000..e15c76e --- /dev/null +++ b/src/features/notifications/notifications/notification.service.js @@ -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(); diff --git a/src/features/notifications/notifications/notification.validation.js b/src/features/notifications/notifications/notification.validation.js new file mode 100644 index 0000000..efd5459 --- /dev/null +++ b/src/features/notifications/notifications/notification.validation.js @@ -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') +]; diff --git a/src/features/organization/org/org.service.js b/src/features/organization/org/org.service.js index 8e8df74..a69009e 100644 --- a/src/features/organization/org/org.service.js +++ b/src/features/organization/org/org.service.js @@ -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; diff --git a/src/features/organization/org/tenant.service.js b/src/features/organization/org/tenant.service.js index 2ea59e4..915ea08 100644 --- a/src/features/organization/org/tenant.service.js +++ b/src/features/organization/org/tenant.service.js @@ -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; } diff --git a/src/features/products/products/product.service.js b/src/features/products/products/product.service.js index bc3bc4f..dd7a6b3 100644 --- a/src/features/products/products/product.service.js +++ b/src/features/products/products/product.service.js @@ -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; } } diff --git a/src/features/settings/settings/setting.service.js b/src/features/settings/settings/setting.service.js index b27226a..8a2bd5d 100644 --- a/src/features/settings/settings/setting.service.js +++ b/src/features/settings/settings/setting.service.js @@ -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; diff --git a/src/migrations/20260711162248-create-notifications-table.cjs b/src/migrations/20260711162248-create-notifications-table.cjs new file mode 100644 index 0000000..200e8b5 --- /dev/null +++ b/src/migrations/20260711162248-create-notifications-table.cjs @@ -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'); + } +}; diff --git a/src/migrations/20260714103600-create-audit-logs.cjs b/src/migrations/20260714103600-create-audit-logs.cjs new file mode 100644 index 0000000..6eef26e --- /dev/null +++ b/src/migrations/20260714103600-create-audit-logs.cjs @@ -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'); + } +}; diff --git a/src/seeders/20260629131008-seed-application-permission-nodes.cjs b/src/seeders/20260629131008-seed-application-permission-nodes.cjs new file mode 100644 index 0000000..27f0bb6 --- /dev/null +++ b/src/seeders/20260629131008-seed-application-permission-nodes.cjs @@ -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', + ], + }, {}); + }, +}; diff --git a/src/seeders/20260711162249-seed-notification-permissions.cjs b/src/seeders/20260711162249-seed-notification-permissions.cjs new file mode 100644 index 0000000..b10374b --- /dev/null +++ b/src/seeders/20260711162249-seed-notification-permissions.cjs @@ -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' + }, {}); + } +}; diff --git a/src/shared/database/models.js b/src/shared/database/models.js index a75f5d2..153be61 100644 --- a/src/shared/database/models.js +++ b/src/shared/database/models.js @@ -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(); }; diff --git a/src/shared/middleware/audit.middleware.js b/src/shared/middleware/audit.middleware.js index a56e74e..f3446e9 100644 --- a/src/shared/middleware/audit.middleware.js +++ b/src/shared/middleware/audit.middleware.js @@ -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, diff --git a/src/shared/services/audit.service.js b/src/shared/services/audit.service.js index 675284e..b35e07e 100644 --- a/src/shared/services/audit.service.js +++ b/src/shared/services/audit.service.js @@ -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; + } } } + + diff --git a/src/shared/services/notification.service.js b/src/shared/services/notification.service.js index 17c0bf5..338528f 100644 --- a/src/shared/services/notification.service.js +++ b/src/shared/services/notification.service.js @@ -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'; diff --git a/src/shared/services/socket.service.js b/src/shared/services/socket.service.js index 8ecbd3d..8a260e0 100644 --- a/src/shared/services/socket.service.js +++ b/src/shared/services/socket.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) {