@onlineapps/service-common 2.0.1 → 3.0.1

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.
@@ -2,10 +2,20 @@
2
2
 
3
3
  const nodemailer = require('nodemailer');
4
4
  const runtimeCfg = require('../config');
5
+ const { assertLogger } = require('@onlineapps/logger-contract');
5
6
 
6
7
  let transporter = null;
7
8
  let cachedConfigSignature = null;
8
9
 
10
+ /**
11
+ * The lowest and highest SMTP reply code that means "try again later"
12
+ * (RFC 5321 §4.2.1: a 4yz reply is a transient negative completion, a 5yz reply
13
+ * is permanent). These are protocol constants, not configuration — the relay
14
+ * does not get to redefine what `421` means.
15
+ */
16
+ const TRANSIENT_REPLY_MIN = 400;
17
+ const TRANSIENT_REPLY_MAX = 499;
18
+
9
19
  function loadConfig() {
10
20
  return {
11
21
  host: runtimeCfg.get('infraReportSmtpHost'),
@@ -14,10 +24,36 @@ function loadConfig() {
14
24
  user: runtimeCfg.get('infraReportSmtpUser'),
15
25
  pass: runtimeCfg.get('infraReportSmtpPass'),
16
26
  from: runtimeCfg.get('infraReportFrom'),
17
- to: runtimeCfg.get('infraReportTo')
27
+ to: runtimeCfg.get('infraReportTo'),
28
+ maxConnections: runtimeCfg.get('infraReportSmtpMaxConnections'),
29
+ maxMessages: runtimeCfg.get('infraReportSmtpMaxMessages'),
30
+ rateDeltaMs: runtimeCfg.get('infraReportSmtpRateDeltaMs'),
31
+ rateLimit: runtimeCfg.get('infraReportSmtpRateLimit'),
32
+ maxAttempts: runtimeCfg.get('infraReportSmtpMaxAttempts'),
33
+ retryDelayMs: runtimeCfg.get('infraReportSmtpRetryDelayMs')
18
34
  };
19
35
  }
20
36
 
37
+ /**
38
+ * Is this refusal the relay saying "later", or "never"?
39
+ *
40
+ * Only a real reply code answers that. An error without one — a socket that
41
+ * died, a TLS handshake that failed — is NOT guessed to be temporary: a retry
42
+ * then spends the relay's auth-rate budget on something the relay never said.
43
+ *
44
+ * @param {Error} error - The error nodemailer rejected with
45
+ * @returns {boolean} true when the relay replied 4yz
46
+ */
47
+ function isTemporaryRefusal(error) {
48
+ return Number.isInteger(error.responseCode)
49
+ && error.responseCode >= TRANSIENT_REPLY_MIN
50
+ && error.responseCode <= TRANSIENT_REPLY_MAX;
51
+ }
52
+
53
+ function wait(delayMs) {
54
+ return new Promise((resolve) => setTimeout(resolve, delayMs));
55
+ }
56
+
21
57
  function createTransportIfNeeded(config) {
22
58
  const signature = JSON.stringify(config);
23
59
  if (transporter && cachedConfigSignature === signature) {
@@ -32,6 +68,34 @@ function createTransportIfNeeded(config) {
32
68
  host: config.host,
33
69
  port: config.port,
34
70
  secure: config.secure,
71
+ // The relay runs `smtpd_tls_security_level = may`: it OFFERS STARTTLS and
72
+ // does not insist. Without this flag nodemailer does not insist either, so a
73
+ // relay that stops advertising STARTTLS — a downgrade, a misconfiguration,
74
+ // someone in the middle — gets `AUTH PLAIN <base64>` on a clear socket and
75
+ // the account is gone. With it, that send is refused instead.
76
+ //
77
+ // Set unconditionally. On implicit TLS (`secure: true`, port 465) the
78
+ // channel is already encrypted and the flag changes nothing, and there is
79
+ // deliberately no "only in production" branch: behaviour that depends on
80
+ // NODE_ENV is what `architecture-principles.md` §8 forbids, and the dev
81
+ // relay carries a real password too.
82
+ requireTLS: true,
83
+ // One connection shared by the whole burst, instead of one TCP+TLS+AUTH per
84
+ // mail. Measured 2026-09-11 (INFRA-monitoring): seven `service_down`
85
+ // episodes in one second opened seven connections, the relay answered
86
+ // `421 4.7.0 … too many connections` 18×, and 12 alerts were never
87
+ // delivered. The relay's own limits are 20 connections / 10 AUTH per 60 s
88
+ // (confirmation `alert-smtp-relay` 003 § Conditions), so the burst, not the
89
+ // volume, is what breaks: pooling turns N authentications into one.
90
+ //
91
+ // Every value is a declared config key with an owner default derived from
92
+ // those limits — see ../defaults.js. No literal here, and no
93
+ // environment-dependent branch (`architecture-principles.md` §8).
94
+ pool: true,
95
+ maxConnections: config.maxConnections,
96
+ maxMessages: config.maxMessages,
97
+ rateDelta: config.rateDeltaMs,
98
+ rateLimit: config.rateLimit,
35
99
  auth: {
36
100
  user: config.user,
37
101
  pass: config.pass
@@ -41,45 +105,116 @@ function createTransportIfNeeded(config) {
41
105
  return transporter;
42
106
  }
43
107
 
44
- async function sendMonitoringFailFallbackEmail(subject, text, html) {
108
+ /**
109
+ * Send the monitoring fallback email.
110
+ *
111
+ * Every outcome is swallowed into the boolean return, so the injected logger is
112
+ * the only record of what this channel — the last one left when monitoring is
113
+ * down — actually did (confirmation 001, 002, 003).
114
+ *
115
+ * @param {string} subject - Mail subject
116
+ * @param {string} text - Plain-text body
117
+ * @param {string} html - HTML body
118
+ * @param {Object} logger - Logger with info/warn/error/debug (required)
119
+ * @returns {Promise<boolean>} true when the mail was handed to the SMTP server
120
+ */
121
+ async function sendMonitoringFailFallbackEmail(subject, text, html, logger) {
122
+ const log = assertLogger(
123
+ 'sendMonitoringFailFallbackEmail',
124
+ logger,
125
+ 'the outcome of the last reporting channel is not swallowed with its boolean'
126
+ );
127
+
45
128
  const config = loadConfig();
129
+
130
+ // Fail-fast at method entry (`architecture-principles.md` §4): a bound below
131
+ // one is not "no retries", it is a loop that never sends. It cannot happen
132
+ // with the owner default, only with an explicitly configured value, so it is a
133
+ // deployment defect and is raised as one rather than swallowed into the
134
+ // boolean — the caller (`api_monitoring` alerting) records it as the reason.
135
+ if (!Number.isInteger(config.maxAttempts) || config.maxAttempts < 1) {
136
+ throw new Error(
137
+ '[MonitoringFallbackEmail] Invalid retry bound - ' +
138
+ `INFRA_REPORT_SMTP_MAX_ATTEMPTS must be an integer >= 1, got "${config.maxAttempts}". ` +
139
+ 'Fix: set INFRA_REPORT_SMTP_MAX_ATTEMPTS in env-active/*.env, or unset it to use the owner default.'
140
+ );
141
+ }
142
+
46
143
  const mailer = createTransportIfNeeded(config);
47
144
 
48
145
  if (!mailer) {
49
- console.warn('[MonitoringFallbackEmail] SMTP configuration missing, skipping email send');
146
+ // Presence only — the SMTP password must never reach a log line.
147
+ log.warn('[MonitoringFallbackEmail] SMTP configuration missing, skipping send', {
148
+ hasHost: Boolean(config.host),
149
+ hasPort: Boolean(config.port),
150
+ hasUser: Boolean(config.user),
151
+ hasPass: Boolean(config.pass),
152
+ hasFrom: Boolean(config.from),
153
+ hasTo: Boolean(config.to)
154
+ });
50
155
  return false;
51
156
  }
52
157
 
53
158
  const recipients = config.to.split(',').map(addr => addr.trim()).filter(Boolean);
54
159
  if (recipients.length === 0) {
55
- console.warn('[MonitoringFallbackEmail] No recipients configured in INFRA_REPORT_TO');
160
+ log.warn('[MonitoringFallbackEmail] No recipients configured', { envKey: 'INFRA_REPORT_TO' });
56
161
  return false;
57
162
  }
58
163
 
59
- try {
60
- const result = await mailer.sendMail({
61
- from: config.from,
62
- to: recipients,
63
- subject,
64
- text,
65
- html
66
- });
67
- console.log('[MonitoringFallbackEmail] Email sent successfully', {
68
- messageId: result.messageId,
69
- response: result.response,
70
- accepted: result.accepted,
71
- rejected: result.rejected
72
- });
73
- return true;
74
- } catch (error) {
75
- console.error('[MonitoringFallbackEmail] Failed to send email:', {
76
- message: error.message,
77
- code: error.code,
78
- command: error.command,
79
- response: error.response,
80
- responseCode: error.responseCode
81
- });
82
- return false;
164
+ // A temporary refusal is re-tried HERE, by the transport, and the caller is
165
+ // told once `delivery-mail-channel` 001 point 5: "retry is layered, not
166
+ // doubled … a refused send is the mail service's provider retry". For the
167
+ // alert channel this module IS the mail service, so a second retry loop in
168
+ // the monitoring consumer would be the doubling that entry forbids; what the
169
+ // consumer records (`notification_sent`) is this function's single verdict.
170
+ for (let attempt = 1; ; attempt += 1) {
171
+ try {
172
+ const result = await mailer.sendMail({
173
+ from: config.from,
174
+ to: recipients,
175
+ subject,
176
+ text,
177
+ html
178
+ });
179
+ log.info('[MonitoringFallbackEmail] Email sent', {
180
+ messageId: result.messageId,
181
+ response: result.response,
182
+ accepted: result.accepted,
183
+ rejected: result.rejected,
184
+ attempts: attempt
185
+ });
186
+ return true;
187
+ } catch (error) {
188
+ const retryable = isTemporaryRefusal(error) && attempt < config.maxAttempts;
189
+
190
+ if (!retryable) {
191
+ log.error('[MonitoringFallbackEmail] Email send failed', {
192
+ reason: error.message,
193
+ code: error.code,
194
+ command: error.command,
195
+ response: error.response,
196
+ responseCode: error.responseCode,
197
+ attempts: attempt
198
+ });
199
+ return false;
200
+ }
201
+
202
+ // Doubling backoff from the configured delay, which is itself the relay's
203
+ // tightest documented spacing (60 000 / 10 AUTH per minute = 6 s).
204
+ // Doubling only widens it, so the retries can never be the thing that
205
+ // trips the limit they exist to survive.
206
+ const delayMs = config.retryDelayMs * 2 ** (attempt - 1);
207
+ log.warn('[MonitoringFallbackEmail] Temporary SMTP refusal, retrying', {
208
+ reason: error.message,
209
+ code: error.code,
210
+ response: error.response,
211
+ responseCode: error.responseCode,
212
+ attempt,
213
+ maxAttempts: config.maxAttempts,
214
+ retryInMs: delayMs
215
+ });
216
+ await wait(delayMs);
217
+ }
83
218
  }
84
219
  }
85
220
 
@@ -12,37 +12,165 @@
12
12
  const runtimeCfg = require('./config');
13
13
  const { createRuntimeConfig } = require('@onlineapps/runtime-config');
14
14
 
15
+ /** A bare env file name: `shared.env`, `monitoring.env`. No directory, no other suffix. */
16
+ const ENV_FILE_PATTERN = /^[A-Za-z0-9._-]+\.env$/;
17
+
18
+ /**
19
+ * Where an operator is told to set the key.
20
+ *
21
+ * With an owning file the sentence names it; without one it is the sentence
22
+ * `architecture-principles.md` §5 prescribes verbatim. The generic form is not a
23
+ * fallback for a missing input — it is the contract's own wording for a key
24
+ * whose file the call site did not name.
25
+ *
26
+ * The file is an INPUT and never a derivation. `api/config/shared-env.json` owns
27
+ * the platform's shared key set, but only build-time tooling reads it
28
+ * (`oa-sync-template shared-env`); it is absent from a service's runtime, and a
29
+ * library hunting for it through `__dirname`/cwd would break principle 1. So the
30
+ * call site names the file, and the declaration remains the authority that
31
+ * review checks the call site against.
32
+ *
33
+ * @param {string} [file] - bare env file name, already validated
34
+ * @returns {string} the location half of a `Fix:` sentence
35
+ */
36
+ function whereToSet(file) {
37
+ return file ? `config/env-active/${file}` : 'env-active/*.env (or pass explicit config)';
38
+ }
39
+
40
+ /**
41
+ * Validate `options.file` at method entry — a typo must fail on the happy path
42
+ * too, not only the day the variable goes missing (principle 4, fail-fast).
43
+ *
44
+ * @param {object} options - caller options
45
+ * @returns {string|undefined} the validated file name
46
+ */
47
+ function owningFileOf(options) {
48
+ const file = options && options.file;
49
+ if (file === undefined || file === null) return undefined;
50
+ if (typeof file !== 'string' || !ENV_FILE_PATTERN.test(file)) {
51
+ throw new Error(
52
+ '[ServiceConfig] Invalid options.file - expected a bare env file name such as '
53
+ + `"shared.env", got: "${file}". Fix: pass the file name alone; the helper writes `
54
+ + 'the config/env-active/ prefix.'
55
+ );
56
+ }
57
+ return file;
58
+ }
59
+
15
60
  /**
16
61
  * Resolve arbitrary environment variables through runtime-config (no direct process.env access).
17
62
  * Note: Prefer module schema keys (runtimeCfg.get) over these generic helpers.
63
+ *
64
+ * The `Fix:` half of the message is composed HERE, for every caller. Until d.399
65
+ * it was not: the helper appended only `description`, so the sentence
66
+ * `architecture-principles.md` §5 requires appeared exactly where a caller had
67
+ * typed it into that description by hand — six call sites in
68
+ * `infra/api_delivery_endpoint/src/config.js`, and nowhere else. A rule that
69
+ * holds only where somebody remembered it is enforced by review, not by a
70
+ * mechanism (`automation-gates.md` §5).
71
+ *
72
+ * @param {string} name - Environment variable name
73
+ * @param {string} [description] - Human-readable description for error messages
74
+ * @param {{file?: string}} [options] - `file`: the env file that owns this key,
75
+ * as a bare name (`shared.env`); the message writes the `config/env-active/`
76
+ * prefix itself
77
+ * @returns {string} Value resolved by `@onlineapps/runtime-config`
78
+ * @throws {Error} If the variable is missing, or `options.file` is malformed
18
79
  */
19
- function requireEnv(name, description) {
80
+ function requireEnv(name, description, options) {
81
+ const file = owningFileOf(options);
82
+ // Klíč schématu je jméno proměnné, ne interní `value`. Resolver jiné jméno
83
+ // klíče nezná, a co mu helper předá, to napíše do hlášky: do d.466b psal
84
+ // `Invalid number value for "value"`, takže z hlášky nešlo přečíst, který klíč
85
+ // je špatně (INFRA, kaskáda 2026-09-15). Zdejší kolej žádné jiné jméno nemá —
86
+ // helper dostává proměnnou, ne konfigurační klíč — a vymyslet mu jméno by
87
+ // znamenalo psát do hlášky fakt, který nikdo nedeklaroval.
20
88
  const cfg = createRuntimeConfig({
21
89
  defaults: {},
22
- schema: { value: { env: name, required: true } },
90
+ schema: { [name]: { env: name, required: true } },
23
91
  });
24
92
  try {
25
- return cfg.get('value');
93
+ return cfg.get(name);
26
94
  } catch (err) {
27
- const hint = description ? ` ${description}` : '';
28
- throw new Error(`[ServiceConfig] Missing environment variable - ${name} is required.${hint}`.trim());
95
+ // Jeden šablonový literál, ne skládání polem: statická sonda
96
+ // `tests/unit/error-message-contract.test.js` čte zprávu ze zdroje, a
97
+ // hlášku, kterou si nepřečte, hlásí jako bezkontextovou — mechanismus, který
98
+ // nevidí, není mechanismus (`automation-gates.md` §5).
99
+ const hint = description ? `${description} ` : '';
100
+ throw new Error(
101
+ `[ServiceConfig] Missing environment variable - ${name} is required. ${hint}`
102
+ + `Fix: set ${name} in ${whereToSet(file)}.`
103
+ );
29
104
  }
30
105
  }
31
106
 
32
107
  /**
33
- * Require a numeric environment variable (fail-fast, no default).
108
+ * Read a required environment variable through the resolver with a declared type.
109
+ *
110
+ * The missing-variable contract stays with `requireEnv` — all four required
111
+ * helpers in this file report an absent variable with the same sentence, so the
112
+ * operator reads one shape regardless of which type the key has. Coercion has
113
+ * exactly one owner, `@onlineapps/runtime-config`, and so does the message for a
114
+ * malformed value: a second parser here would be a second definition of what a
115
+ * number is (`.claude/rules/change-discipline.md` § One rail per concern).
116
+ *
34
117
  * @param {string} name - Environment variable name
35
118
  * @param {string} description - Human-readable description for error messages
36
- * @returns {number} Parsed number
37
- * @throws {Error} If variable is missing or not a valid number
119
+ * @param {'number'|'float'} type - Resolver type that owns the coercion
120
+ * @returns {number} Value coerced by the resolver
121
+ * @private
38
122
  */
39
- function requireNumberEnv(name, description) {
40
- const raw = requireEnv(name, description);
41
- const num = Number(raw);
42
- if (isNaN(num)) {
43
- throw new Error(`[ServiceConfig] ${name} must be a number, got: "${raw}"`);
44
- }
45
- return num;
123
+ function requireTypedEnv(name, description, type, options) {
124
+ requireEnv(name, description, options);
125
+ // `file` dojde až do schématu: hlášku o vadné hodnotě skládá resolver a bez
126
+ // deklarace nemá odkud vzít místo, kam má operátor sáhnout. Do d.466b se
127
+ // `options.file` použil jen na větu o CHYBĚJÍCÍ proměnné a pro vadnou hodnotu
128
+ // se zahodil, ačkoli obě věty mluví o témž klíči v témž souboru.
129
+ const file = owningFileOf(options);
130
+ const cfg = createRuntimeConfig({
131
+ defaults: {},
132
+ schema: { [name]: { env: name, required: true, type, ...(file ? { file } : {}) } },
133
+ });
134
+ return cfg.get(name);
135
+ }
136
+
137
+ /**
138
+ * Require a WHOLE-number environment variable (fail-fast, no default).
139
+ *
140
+ * Every platform key reading through this helper is a port, a count, a budget
141
+ * or a millisecond/second/minute interval — a fraction there is a configuration
142
+ * error, not something to round. A key that is genuinely fractional (a ratio, a
143
+ * factor) has its own rail: {@link requireFloatEnv}.
144
+ *
145
+ * The strictness itself lives in the resolver and follows its pin: up to
146
+ * `@onlineapps/runtime-config` 1.0.3 a fractional value is still truncated by
147
+ * `parseInt`, from the d.253 release it is refused.
148
+ *
149
+ * @param {string} name - Environment variable name
150
+ * @param {string} description - Human-readable description for error messages
151
+ * @returns {number} Whole number as resolved by `@onlineapps/runtime-config`
152
+ * @throws {Error} If the variable is missing, or its value is not a whole number
153
+ */
154
+ function requireNumberEnv(name, description, options) {
155
+ return requireTypedEnv(name, description, 'number', options);
156
+ }
157
+
158
+ /**
159
+ * Require a FRACTIONAL environment variable (fail-fast, no default).
160
+ *
161
+ * For keys whose value is a ratio or a factor rather than a count — e.g. a
162
+ * completion ratio in `0.0-1.0`. Using {@link requireNumberEnv} for such a key
163
+ * loses the fraction (and, once the whole-number resolver is pinned, stops the
164
+ * boot), so the choice between the two rails is a property of the key and
165
+ * belongs at the call site.
166
+ *
167
+ * @param {string} name - Environment variable name
168
+ * @param {string} description - Human-readable description for error messages
169
+ * @returns {number} Fractional number as resolved by `@onlineapps/runtime-config`
170
+ * @throws {Error} If the variable is missing, or its value is not a number
171
+ */
172
+ function requireFloatEnv(name, description, options) {
173
+ return requireTypedEnv(name, description, 'float', options);
46
174
  }
47
175
 
48
176
  /**
@@ -53,10 +181,13 @@ function requireNumberEnv(name, description) {
53
181
  * @returns {boolean} Parsed boolean
54
182
  * @throws {Error} If variable is missing or not "true"/"false"
55
183
  */
56
- function requireBoolEnv(name, description) {
57
- const raw = requireEnv(name, description);
184
+ function requireBoolEnv(name, description, options) {
185
+ const raw = requireEnv(name, description, options);
58
186
  if (raw !== 'true' && raw !== 'false') {
59
- throw new Error(`[ServiceConfig] ${name} must be "true" or "false", got: "${raw}"`);
187
+ throw new Error(
188
+ `[ServiceConfig] ${name} must be "true" or "false" - got: "${raw}". `
189
+ + `Fix: set ${name} to the literal true or false in ${whereToSet(owningFileOf(options))}.`
190
+ );
60
191
  }
61
192
  return raw === 'true';
62
193
  }
@@ -71,9 +202,9 @@ function requireBoolEnv(name, description) {
71
202
  function optionalEnv(name, defaultValue) {
72
203
  const cfg = createRuntimeConfig({
73
204
  defaults: {},
74
- schema: { value: { env: name, default: defaultValue } },
205
+ schema: { [name]: { env: name, default: defaultValue } },
75
206
  });
76
- return cfg.get('value');
207
+ return cfg.get(name);
77
208
  }
78
209
 
79
210
  /**
@@ -85,9 +216,9 @@ function optionalEnv(name, defaultValue) {
85
216
  function optionalNumberEnv(name, defaultValue) {
86
217
  const cfg = createRuntimeConfig({
87
218
  defaults: {},
88
- schema: { value: { env: name, default: defaultValue, type: 'number' } },
219
+ schema: { [name]: { env: name, default: defaultValue, type: 'number' } },
89
220
  });
90
- return cfg.get('value');
221
+ return cfg.get(name);
91
222
  }
92
223
 
93
224
  /**
@@ -104,17 +235,6 @@ function getCriticalConfig() {
104
235
  };
105
236
  }
106
237
 
107
- /**
108
- * Get critical infrastructure configuration with fallbacks
109
- *
110
- * Strict mode: This function is kept for compatibility but DOES NOT provide fallbacks.
111
- * Topology is FAIL-FAST (no defaults).
112
- * @returns {Object} Critical infrastructure config with fallbacks
113
- */
114
- function getCriticalConfigWithFallbacks() {
115
- return getCriticalConfig();
116
- }
117
-
118
238
  /**
119
239
  * Get infrastructure health configuration with defaults
120
240
  * @returns {Object} Infrastructure health config
@@ -126,7 +246,6 @@ function getInfrastructureHealthConfig() {
126
246
  waitMaxTime: runtimeCfg.get('infrastructureHealthWaitMaxTimeMs'),
127
247
  waitCheckInterval: runtimeCfg.get('infrastructureHealthWaitCheckIntervalMs'),
128
248
  healthCheckTimeout: runtimeCfg.get('infrastructureHealthTimeoutMs'),
129
- redisKeyTTL: runtimeCfg.get('infrastructureHealthRedisTtlSeconds'),
130
249
  cleanupInterval: runtimeCfg.get('infrastructureHealthCleanupIntervalMs'),
131
250
  queueWaitMaxTime: runtimeCfg.get('infrastructureHealthQueueWaitMaxTimeMs'),
132
251
  queueWaitCheckInterval: runtimeCfg.get('infrastructureHealthQueueWaitCheckIntervalMs'),
@@ -136,11 +255,11 @@ function getInfrastructureHealthConfig() {
136
255
  module.exports = {
137
256
  requireEnv,
138
257
  requireNumberEnv,
258
+ requireFloatEnv,
139
259
  requireBoolEnv,
140
260
  optionalEnv,
141
261
  optionalNumberEnv,
142
262
  getCriticalConfig,
143
- getCriticalConfigWithFallbacks,
144
263
  getInfrastructureHealthConfig
145
264
  };
146
265
 
@@ -1,118 +0,0 @@
1
- 'use strict';
2
-
3
- // See: docs/biz/70-contracts/error-handling.md
4
- const ERROR_TYPES = {
5
- TRANSIENT: 'TRANSIENT',
6
- BUSINESS: 'BUSINESS',
7
- FATAL: 'FATAL',
8
- VALIDATION: 'VALIDATION',
9
- TIMEOUT: 'TIMEOUT',
10
- RATE_LIMIT: 'RATE_LIMIT',
11
- UNKNOWN: 'UNKNOWN'
12
- };
13
-
14
- class BusinessError extends Error {
15
- constructor(message, { statusCode = 500, errorCode = 'INTERNAL_ERROR', type = 'BUSINESS', details = [], operation = null, service = null } = {}) {
16
- super(message);
17
- this.name = this.constructor.name;
18
- this.statusCode = statusCode;
19
- this.errorCode = errorCode;
20
- this.type = type;
21
- this.details = Array.isArray(details) ? details : [details];
22
- this.operation = operation || null;
23
- this.service = service || process.env.SERVICE_NAME || null;
24
- }
25
-
26
- toJSON() {
27
- return {
28
- code: this.errorCode,
29
- message: this.message,
30
- statusCode: this.statusCode,
31
- details: this.details.length > 0 ? this.details : undefined,
32
- service: this.service || undefined,
33
- operation: this.operation || undefined
34
- };
35
- }
36
- }
37
-
38
- class NotFoundError extends BusinessError {
39
- constructor(message, options = {}) {
40
- super(message, {
41
- statusCode: 404,
42
- errorCode: options.errorCode || 'RESOURCE_NOT_FOUND',
43
- type: ERROR_TYPES.BUSINESS,
44
- ...options
45
- });
46
- }
47
- }
48
-
49
- class ValidationError extends BusinessError {
50
- constructor(message, options = {}) {
51
- super(message, {
52
- statusCode: 400,
53
- errorCode: options.errorCode || 'VALIDATION_FAILED',
54
- type: ERROR_TYPES.VALIDATION,
55
- ...options
56
- });
57
- }
58
- }
59
-
60
- class ConflictError extends BusinessError {
61
- constructor(message, options = {}) {
62
- super(message, {
63
- statusCode: 409,
64
- errorCode: options.errorCode || 'DUPLICATE_RESOURCE',
65
- type: ERROR_TYPES.BUSINESS,
66
- ...options
67
- });
68
- }
69
- }
70
-
71
- class BusinessRuleError extends BusinessError {
72
- constructor(message, options = {}) {
73
- super(message, {
74
- statusCode: 422,
75
- errorCode: options.errorCode || 'BUSINESS_RULE_VIOLATED',
76
- type: ERROR_TYPES.VALIDATION,
77
- ...options
78
- });
79
- }
80
- }
81
-
82
- class AuthorizationError extends BusinessError {
83
- constructor(message, options = {}) {
84
- super(message, {
85
- statusCode: 403,
86
- errorCode: options.errorCode || 'FORBIDDEN',
87
- type: ERROR_TYPES.BUSINESS,
88
- ...options
89
- });
90
- }
91
- }
92
-
93
- class ServiceUnavailableError extends BusinessError {
94
- constructor(message, options = {}) {
95
- super(message, {
96
- statusCode: 503,
97
- errorCode: options.errorCode || 'SERVICE_UNAVAILABLE',
98
- type: ERROR_TYPES.TRANSIENT,
99
- ...options
100
- });
101
- }
102
- }
103
-
104
- function isBusinessError(error) {
105
- return error instanceof BusinessError;
106
- }
107
-
108
- module.exports = {
109
- BusinessError,
110
- NotFoundError,
111
- ValidationError,
112
- ConflictError,
113
- BusinessRuleError,
114
- AuthorizationError,
115
- ServiceUnavailableError,
116
- isBusinessError,
117
- ERROR_TYPES
118
- };