@onlineapps/service-common 1.0.15 → 1.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/service-common",
3
- "version": "1.0.15",
3
+ "version": "1.0.16",
4
4
  "description": "Common utilities for both infrastructure services and business services",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+
3
+ const ERROR_TYPES = {
4
+ TRANSIENT: 'TRANSIENT',
5
+ BUSINESS: 'BUSINESS',
6
+ FATAL: 'FATAL',
7
+ VALIDATION: 'VALIDATION',
8
+ TIMEOUT: 'TIMEOUT',
9
+ RATE_LIMIT: 'RATE_LIMIT',
10
+ UNKNOWN: 'UNKNOWN'
11
+ };
12
+
13
+ class BusinessError extends Error {
14
+ constructor(message, { statusCode = 500, errorCode = 'INTERNAL_ERROR', type = 'BUSINESS', details = [], operation = null, service = null } = {}) {
15
+ super(message);
16
+ this.name = this.constructor.name;
17
+ this.statusCode = statusCode;
18
+ this.errorCode = errorCode;
19
+ this.type = type;
20
+ this.details = Array.isArray(details) ? details : [details];
21
+ this.operation = operation || null;
22
+ this.service = service || process.env.SERVICE_NAME || null;
23
+ }
24
+
25
+ toJSON() {
26
+ return {
27
+ code: this.errorCode,
28
+ message: this.message,
29
+ statusCode: this.statusCode,
30
+ details: this.details.length > 0 ? this.details : undefined,
31
+ service: this.service || undefined,
32
+ operation: this.operation || undefined
33
+ };
34
+ }
35
+ }
36
+
37
+ class NotFoundError extends BusinessError {
38
+ constructor(message, options = {}) {
39
+ super(message, {
40
+ statusCode: 404,
41
+ errorCode: options.errorCode || 'RESOURCE_NOT_FOUND',
42
+ type: ERROR_TYPES.BUSINESS,
43
+ ...options
44
+ });
45
+ }
46
+ }
47
+
48
+ class ValidationError extends BusinessError {
49
+ constructor(message, options = {}) {
50
+ super(message, {
51
+ statusCode: 400,
52
+ errorCode: options.errorCode || 'VALIDATION_FAILED',
53
+ type: ERROR_TYPES.VALIDATION,
54
+ ...options
55
+ });
56
+ }
57
+ }
58
+
59
+ class ConflictError extends BusinessError {
60
+ constructor(message, options = {}) {
61
+ super(message, {
62
+ statusCode: 409,
63
+ errorCode: options.errorCode || 'DUPLICATE_RESOURCE',
64
+ type: ERROR_TYPES.BUSINESS,
65
+ ...options
66
+ });
67
+ }
68
+ }
69
+
70
+ class BusinessRuleError extends BusinessError {
71
+ constructor(message, options = {}) {
72
+ super(message, {
73
+ statusCode: 422,
74
+ errorCode: options.errorCode || 'BUSINESS_RULE_VIOLATED',
75
+ type: ERROR_TYPES.VALIDATION,
76
+ ...options
77
+ });
78
+ }
79
+ }
80
+
81
+ class AuthorizationError extends BusinessError {
82
+ constructor(message, options = {}) {
83
+ super(message, {
84
+ statusCode: 403,
85
+ errorCode: options.errorCode || 'FORBIDDEN',
86
+ type: ERROR_TYPES.BUSINESS,
87
+ ...options
88
+ });
89
+ }
90
+ }
91
+
92
+ class ServiceUnavailableError extends BusinessError {
93
+ constructor(message, options = {}) {
94
+ super(message, {
95
+ statusCode: 503,
96
+ errorCode: options.errorCode || 'SERVICE_UNAVAILABLE',
97
+ type: ERROR_TYPES.TRANSIENT,
98
+ ...options
99
+ });
100
+ }
101
+ }
102
+
103
+ function isBusinessError(error) {
104
+ return error instanceof BusinessError;
105
+ }
106
+
107
+ module.exports = {
108
+ BusinessError,
109
+ NotFoundError,
110
+ ValidationError,
111
+ ConflictError,
112
+ BusinessRuleError,
113
+ AuthorizationError,
114
+ ServiceUnavailableError,
115
+ isBusinessError,
116
+ ERROR_TYPES
117
+ };
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+
3
+ const { BusinessError, isBusinessError } = require('./BusinessError');
4
+
5
+ function businessErrorHandler(err, req, res, next) {
6
+ if (res.headersSent) {
7
+ return next(err);
8
+ }
9
+
10
+ const logger = req.app?.locals?.logger || console;
11
+
12
+ if (isBusinessError(err)) {
13
+ logger.warn(`[BusinessError] ${err.errorCode}: ${err.message}`, {
14
+ statusCode: err.statusCode,
15
+ errorCode: err.errorCode,
16
+ type: err.type,
17
+ details: err.details,
18
+ service: err.service,
19
+ operation: err.operation,
20
+ method: req.method,
21
+ url: req.originalUrl
22
+ });
23
+
24
+ return res.status(err.statusCode).json({
25
+ error: err.toJSON()
26
+ });
27
+ }
28
+
29
+ if (err.name === 'SequelizeUniqueConstraintError') {
30
+ const fields = err.errors?.map(e => e.path) || [];
31
+ logger.warn(`[BusinessError] DUPLICATE_RESOURCE: ${err.message}`, {
32
+ statusCode: 409,
33
+ fields,
34
+ method: req.method,
35
+ url: req.originalUrl
36
+ });
37
+
38
+ return res.status(409).json({
39
+ error: {
40
+ code: 'DUPLICATE_RESOURCE',
41
+ message: 'Resource already exists',
42
+ statusCode: 409,
43
+ details: fields.length > 0 ? [`Duplicate value for: ${fields.join(', ')}`] : undefined
44
+ }
45
+ });
46
+ }
47
+
48
+ if (err.name === 'SequelizeValidationError') {
49
+ const details = err.errors?.map(e => e.message) || [];
50
+ logger.warn(`[BusinessError] VALIDATION_FAILED: ${err.message}`, {
51
+ statusCode: 400,
52
+ details,
53
+ method: req.method,
54
+ url: req.originalUrl
55
+ });
56
+
57
+ return res.status(400).json({
58
+ error: {
59
+ code: 'VALIDATION_FAILED',
60
+ message: 'Validation failed',
61
+ statusCode: 400,
62
+ details
63
+ }
64
+ });
65
+ }
66
+
67
+ if (err.type === 'entity.parse.failed' || (err.message && err.message.includes('JSON'))) {
68
+ return res.status(400).json({
69
+ error: {
70
+ code: 'INVALID_JSON',
71
+ message: 'Invalid JSON in request body',
72
+ statusCode: 400
73
+ }
74
+ });
75
+ }
76
+
77
+ logger.error(`[UnhandledError] ${err.message}`, {
78
+ error: err.message,
79
+ stack: err.stack,
80
+ method: req.method,
81
+ url: req.originalUrl
82
+ });
83
+
84
+ const isProduction = process.env.NODE_ENV === 'production';
85
+
86
+ return res.status(500).json({
87
+ error: {
88
+ code: 'INTERNAL_ERROR',
89
+ message: isProduction ? 'Internal server error' : err.message,
90
+ statusCode: 500
91
+ }
92
+ });
93
+ }
94
+
95
+ function notFoundHandler(req, res) {
96
+ const logger = req.app?.locals?.logger || console;
97
+ logger.warn('Route not found', {
98
+ method: req.method,
99
+ url: req.originalUrl
100
+ });
101
+
102
+ res.status(404).json({
103
+ error: {
104
+ code: 'ROUTE_NOT_FOUND',
105
+ message: `Route ${req.method} ${req.originalUrl} not found`,
106
+ statusCode: 404
107
+ }
108
+ });
109
+ }
110
+
111
+ module.exports = { businessErrorHandler, notFoundHandler };
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ BusinessError,
5
+ NotFoundError,
6
+ ValidationError,
7
+ ConflictError,
8
+ BusinessRuleError,
9
+ AuthorizationError,
10
+ ServiceUnavailableError,
11
+ isBusinessError,
12
+ ERROR_TYPES
13
+ } = require('./BusinessError');
14
+
15
+ const { businessErrorHandler, notFoundHandler } = require('./errorMiddleware');
16
+
17
+ module.exports = {
18
+ BusinessError,
19
+ NotFoundError,
20
+ ValidationError,
21
+ ConflictError,
22
+ BusinessRuleError,
23
+ AuthorizationError,
24
+ ServiceUnavailableError,
25
+ isBusinessError,
26
+ ERROR_TYPES,
27
+ businessErrorHandler,
28
+ notFoundHandler
29
+ };
package/src/index.js CHANGED
@@ -30,6 +30,19 @@ const {
30
30
  getCriticalConfigWithFallbacks,
31
31
  getInfrastructureHealthConfig
32
32
  } = require('./runtime-config');
33
+ const {
34
+ BusinessError,
35
+ NotFoundError,
36
+ ValidationError,
37
+ ConflictError,
38
+ BusinessRuleError,
39
+ AuthorizationError,
40
+ ServiceUnavailableError,
41
+ isBusinessError,
42
+ ERROR_TYPES,
43
+ businessErrorHandler,
44
+ notFoundHandler
45
+ } = require('./errors');
33
46
 
34
47
  module.exports = {
35
48
  // Infrastructure readiness utilities (used by both infrastructure and business services)
@@ -55,7 +68,20 @@ module.exports = {
55
68
  getInfrastructureHealthConfig,
56
69
 
57
70
  // Reporting utilities
58
- sendMonitoringFailFallbackEmail
71
+ sendMonitoringFailFallbackEmail,
72
+
73
+ // Business error classes and middleware
74
+ BusinessError,
75
+ NotFoundError,
76
+ ValidationError,
77
+ ConflictError,
78
+ BusinessRuleError,
79
+ AuthorizationError,
80
+ ServiceUnavailableError,
81
+ isBusinessError,
82
+ ERROR_TYPES,
83
+ businessErrorHandler,
84
+ notFoundHandler
59
85
  };
60
86
 
61
87
 
@@ -0,0 +1,171 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ BusinessError,
5
+ NotFoundError,
6
+ ValidationError,
7
+ ConflictError,
8
+ BusinessRuleError,
9
+ AuthorizationError,
10
+ ServiceUnavailableError,
11
+ isBusinessError,
12
+ businessErrorHandler,
13
+ notFoundHandler
14
+ } = require('../../src/errors');
15
+
16
+ describe('BusinessError hierarchy', () => {
17
+ test('BusinessError has correct defaults', () => {
18
+ const err = new BusinessError('Something went wrong');
19
+ expect(err).toBeInstanceOf(Error);
20
+ expect(err).toBeInstanceOf(BusinessError);
21
+ expect(err.name).toBe('BusinessError');
22
+ expect(err.message).toBe('Something went wrong');
23
+ expect(err.statusCode).toBe(500);
24
+ expect(err.errorCode).toBe('INTERNAL_ERROR');
25
+ expect(err.type).toBe('BUSINESS');
26
+ expect(err.details).toEqual([]);
27
+ });
28
+
29
+ test('NotFoundError sets 404', () => {
30
+ const err = new NotFoundError('Document not found', {
31
+ details: ['UUID abc123 does not exist'],
32
+ operation: 'getDocument'
33
+ });
34
+ expect(err).toBeInstanceOf(BusinessError);
35
+ expect(err.statusCode).toBe(404);
36
+ expect(err.errorCode).toBe('RESOURCE_NOT_FOUND');
37
+ expect(err.type).toBe('BUSINESS');
38
+ expect(err.details).toEqual(['UUID abc123 does not exist']);
39
+ expect(err.operation).toBe('getDocument');
40
+ });
41
+
42
+ test('ValidationError sets 400', () => {
43
+ const err = new ValidationError('Invalid input', { details: ['field X is required'] });
44
+ expect(err.statusCode).toBe(400);
45
+ expect(err.errorCode).toBe('VALIDATION_FAILED');
46
+ expect(err.type).toBe('VALIDATION');
47
+ });
48
+
49
+ test('ConflictError sets 409', () => {
50
+ const err = new ConflictError('Duplicate');
51
+ expect(err.statusCode).toBe(409);
52
+ expect(err.errorCode).toBe('DUPLICATE_RESOURCE');
53
+ });
54
+
55
+ test('BusinessRuleError sets 422', () => {
56
+ const err = new BusinessRuleError('Cannot invoice draft');
57
+ expect(err.statusCode).toBe(422);
58
+ expect(err.errorCode).toBe('BUSINESS_RULE_VIOLATED');
59
+ expect(err.type).toBe('VALIDATION');
60
+ });
61
+
62
+ test('AuthorizationError sets 403', () => {
63
+ const err = new AuthorizationError('No access');
64
+ expect(err.statusCode).toBe(403);
65
+ expect(err.errorCode).toBe('FORBIDDEN');
66
+ });
67
+
68
+ test('ServiceUnavailableError sets 503', () => {
69
+ const err = new ServiceUnavailableError('DB down');
70
+ expect(err.statusCode).toBe(503);
71
+ expect(err.errorCode).toBe('SERVICE_UNAVAILABLE');
72
+ expect(err.type).toBe('TRANSIENT');
73
+ });
74
+
75
+ test('isBusinessError returns true for BusinessError subtypes', () => {
76
+ expect(isBusinessError(new NotFoundError('x'))).toBe(true);
77
+ expect(isBusinessError(new Error('x'))).toBe(false);
78
+ expect(isBusinessError(null)).toBe(false);
79
+ });
80
+
81
+ test('toJSON returns structured error', () => {
82
+ const err = new NotFoundError('Not found', {
83
+ details: ['detail1'],
84
+ operation: 'findDoc',
85
+ service: 'invoicing'
86
+ });
87
+ const json = err.toJSON();
88
+ expect(json).toEqual({
89
+ code: 'RESOURCE_NOT_FOUND',
90
+ message: 'Not found',
91
+ statusCode: 404,
92
+ details: ['detail1'],
93
+ service: 'invoicing',
94
+ operation: 'findDoc'
95
+ });
96
+ });
97
+
98
+ test('custom errorCode can be overridden', () => {
99
+ const err = new NotFoundError('Tenant missing', { errorCode: 'TENANT_NOT_FOUND' });
100
+ expect(err.errorCode).toBe('TENANT_NOT_FOUND');
101
+ });
102
+ });
103
+
104
+ describe('businessErrorHandler middleware', () => {
105
+ let req, res, next;
106
+
107
+ beforeEach(() => {
108
+ req = { method: 'GET', originalUrl: '/api/test', app: { locals: { logger: { warn: jest.fn(), error: jest.fn() } } } };
109
+ res = { status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis(), headersSent: false };
110
+ next = jest.fn();
111
+ });
112
+
113
+ test('formats BusinessError as structured JSON', () => {
114
+ const err = new NotFoundError('Doc not found', { details: ['UUID 123'] });
115
+ businessErrorHandler(err, req, res, next);
116
+
117
+ expect(res.status).toHaveBeenCalledWith(404);
118
+ expect(res.json).toHaveBeenCalledWith({
119
+ error: {
120
+ code: 'RESOURCE_NOT_FOUND',
121
+ message: 'Doc not found',
122
+ statusCode: 404,
123
+ details: ['UUID 123'],
124
+ service: undefined,
125
+ operation: undefined
126
+ }
127
+ });
128
+ });
129
+
130
+ test('wraps generic Error as INTERNAL_ERROR 500', () => {
131
+ const err = new Error('kaboom');
132
+ businessErrorHandler(err, req, res, next);
133
+
134
+ expect(res.status).toHaveBeenCalledWith(500);
135
+ const body = res.json.mock.calls[0][0];
136
+ expect(body.error.code).toBe('INTERNAL_ERROR');
137
+ expect(body.error.statusCode).toBe(500);
138
+ });
139
+
140
+ test('does not leak stack in production', () => {
141
+ const origEnv = process.env.NODE_ENV;
142
+ process.env.NODE_ENV = 'production';
143
+
144
+ const err = new Error('secret details');
145
+ businessErrorHandler(err, req, res, next);
146
+
147
+ const body = res.json.mock.calls[0][0];
148
+ expect(body.error.message).toBe('Internal server error');
149
+
150
+ process.env.NODE_ENV = origEnv;
151
+ });
152
+
153
+ test('calls next() if headers already sent', () => {
154
+ res.headersSent = true;
155
+ const err = new Error('late');
156
+ businessErrorHandler(err, req, res, next);
157
+ expect(next).toHaveBeenCalledWith(err);
158
+ expect(res.status).not.toHaveBeenCalled();
159
+ });
160
+ });
161
+
162
+ describe('notFoundHandler middleware', () => {
163
+ test('returns ROUTE_NOT_FOUND 404', () => {
164
+ const req = { method: 'GET', originalUrl: '/unknown', app: { locals: { logger: { warn: jest.fn() } } } };
165
+ const res = { status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() };
166
+ notFoundHandler(req, res);
167
+
168
+ expect(res.status).toHaveBeenCalledWith(404);
169
+ expect(res.json.mock.calls[0][0].error.code).toBe('ROUTE_NOT_FOUND');
170
+ });
171
+ });