servcraft 0.1.0 → 0.1.3

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.
Files changed (217) hide show
  1. package/.claude/settings.local.json +30 -0
  2. package/.github/CODEOWNERS +18 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +46 -0
  4. package/.github/dependabot.yml +59 -0
  5. package/.github/workflows/ci.yml +188 -0
  6. package/.github/workflows/release.yml +195 -0
  7. package/AUDIT.md +602 -0
  8. package/LICENSE +21 -0
  9. package/README.md +1102 -1
  10. package/dist/cli/index.cjs +2026 -2168
  11. package/dist/cli/index.cjs.map +1 -1
  12. package/dist/cli/index.js +2026 -2168
  13. package/dist/cli/index.js.map +1 -1
  14. package/dist/index.cjs +595 -616
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +114 -52
  17. package/dist/index.d.ts +114 -52
  18. package/dist/index.js +595 -616
  19. package/dist/index.js.map +1 -1
  20. package/docs/CLI-001_MULTI_DB_PLAN.md +546 -0
  21. package/docs/DATABASE_MULTI_ORM.md +399 -0
  22. package/docs/PHASE1_BREAKDOWN.md +346 -0
  23. package/docs/PROGRESS.md +550 -0
  24. package/docs/modules/ANALYTICS.md +226 -0
  25. package/docs/modules/API-VERSIONING.md +252 -0
  26. package/docs/modules/AUDIT.md +192 -0
  27. package/docs/modules/AUTH.md +431 -0
  28. package/docs/modules/CACHE.md +346 -0
  29. package/docs/modules/EMAIL.md +254 -0
  30. package/docs/modules/FEATURE-FLAG.md +291 -0
  31. package/docs/modules/I18N.md +294 -0
  32. package/docs/modules/MEDIA-PROCESSING.md +281 -0
  33. package/docs/modules/MFA.md +266 -0
  34. package/docs/modules/NOTIFICATION.md +311 -0
  35. package/docs/modules/OAUTH.md +237 -0
  36. package/docs/modules/PAYMENT.md +804 -0
  37. package/docs/modules/QUEUE.md +540 -0
  38. package/docs/modules/RATE-LIMIT.md +339 -0
  39. package/docs/modules/SEARCH.md +288 -0
  40. package/docs/modules/SECURITY.md +327 -0
  41. package/docs/modules/SESSION.md +382 -0
  42. package/docs/modules/SWAGGER.md +305 -0
  43. package/docs/modules/UPLOAD.md +296 -0
  44. package/docs/modules/USER.md +505 -0
  45. package/docs/modules/VALIDATION.md +294 -0
  46. package/docs/modules/WEBHOOK.md +270 -0
  47. package/docs/modules/WEBSOCKET.md +691 -0
  48. package/package.json +53 -38
  49. package/prisma/schema.prisma +395 -1
  50. package/src/cli/commands/add-module.ts +520 -87
  51. package/src/cli/commands/db.ts +3 -4
  52. package/src/cli/commands/docs.ts +256 -6
  53. package/src/cli/commands/generate.ts +12 -19
  54. package/src/cli/commands/init.ts +384 -214
  55. package/src/cli/index.ts +0 -4
  56. package/src/cli/templates/repository.ts +6 -1
  57. package/src/cli/templates/routes.ts +6 -21
  58. package/src/cli/utils/docs-generator.ts +6 -7
  59. package/src/cli/utils/env-manager.ts +717 -0
  60. package/src/cli/utils/field-parser.ts +16 -7
  61. package/src/cli/utils/interactive-prompt.ts +223 -0
  62. package/src/cli/utils/template-manager.ts +346 -0
  63. package/src/config/database.config.ts +183 -0
  64. package/src/config/env.ts +0 -10
  65. package/src/config/index.ts +0 -14
  66. package/src/core/server.ts +1 -1
  67. package/src/database/adapters/mongoose.adapter.ts +132 -0
  68. package/src/database/adapters/prisma.adapter.ts +118 -0
  69. package/src/database/connection.ts +190 -0
  70. package/src/database/interfaces/database.interface.ts +85 -0
  71. package/src/database/interfaces/index.ts +7 -0
  72. package/src/database/interfaces/repository.interface.ts +129 -0
  73. package/src/database/models/mongoose/index.ts +7 -0
  74. package/src/database/models/mongoose/payment.schema.ts +347 -0
  75. package/src/database/models/mongoose/user.schema.ts +154 -0
  76. package/src/database/prisma.ts +1 -4
  77. package/src/database/redis.ts +101 -0
  78. package/src/database/repositories/mongoose/index.ts +7 -0
  79. package/src/database/repositories/mongoose/payment.repository.ts +380 -0
  80. package/src/database/repositories/mongoose/user.repository.ts +255 -0
  81. package/src/database/seed.ts +6 -1
  82. package/src/index.ts +9 -20
  83. package/src/middleware/security.ts +2 -6
  84. package/src/modules/analytics/analytics.routes.ts +80 -0
  85. package/src/modules/analytics/analytics.service.ts +364 -0
  86. package/src/modules/analytics/index.ts +18 -0
  87. package/src/modules/analytics/types.ts +180 -0
  88. package/src/modules/api-versioning/index.ts +15 -0
  89. package/src/modules/api-versioning/types.ts +86 -0
  90. package/src/modules/api-versioning/versioning.middleware.ts +120 -0
  91. package/src/modules/api-versioning/versioning.routes.ts +54 -0
  92. package/src/modules/api-versioning/versioning.service.ts +189 -0
  93. package/src/modules/audit/audit.repository.ts +206 -0
  94. package/src/modules/audit/audit.service.ts +27 -59
  95. package/src/modules/auth/auth.controller.ts +2 -2
  96. package/src/modules/auth/auth.middleware.ts +3 -9
  97. package/src/modules/auth/auth.routes.ts +10 -107
  98. package/src/modules/auth/auth.service.ts +126 -23
  99. package/src/modules/auth/index.ts +3 -4
  100. package/src/modules/cache/cache.service.ts +367 -0
  101. package/src/modules/cache/index.ts +10 -0
  102. package/src/modules/cache/types.ts +44 -0
  103. package/src/modules/email/email.service.ts +3 -10
  104. package/src/modules/email/templates.ts +2 -8
  105. package/src/modules/feature-flag/feature-flag.repository.ts +303 -0
  106. package/src/modules/feature-flag/feature-flag.routes.ts +247 -0
  107. package/src/modules/feature-flag/feature-flag.service.ts +566 -0
  108. package/src/modules/feature-flag/index.ts +20 -0
  109. package/src/modules/feature-flag/types.ts +192 -0
  110. package/src/modules/i18n/i18n.middleware.ts +186 -0
  111. package/src/modules/i18n/i18n.routes.ts +191 -0
  112. package/src/modules/i18n/i18n.service.ts +456 -0
  113. package/src/modules/i18n/index.ts +18 -0
  114. package/src/modules/i18n/types.ts +118 -0
  115. package/src/modules/media-processing/index.ts +17 -0
  116. package/src/modules/media-processing/media-processing.routes.ts +111 -0
  117. package/src/modules/media-processing/media-processing.service.ts +245 -0
  118. package/src/modules/media-processing/types.ts +156 -0
  119. package/src/modules/mfa/index.ts +20 -0
  120. package/src/modules/mfa/mfa.repository.ts +206 -0
  121. package/src/modules/mfa/mfa.routes.ts +595 -0
  122. package/src/modules/mfa/mfa.service.ts +572 -0
  123. package/src/modules/mfa/totp.ts +150 -0
  124. package/src/modules/mfa/types.ts +57 -0
  125. package/src/modules/notification/index.ts +20 -0
  126. package/src/modules/notification/notification.repository.ts +356 -0
  127. package/src/modules/notification/notification.service.ts +483 -0
  128. package/src/modules/notification/types.ts +119 -0
  129. package/src/modules/oauth/index.ts +20 -0
  130. package/src/modules/oauth/oauth.repository.ts +219 -0
  131. package/src/modules/oauth/oauth.routes.ts +446 -0
  132. package/src/modules/oauth/oauth.service.ts +293 -0
  133. package/src/modules/oauth/providers/apple.provider.ts +250 -0
  134. package/src/modules/oauth/providers/facebook.provider.ts +181 -0
  135. package/src/modules/oauth/providers/github.provider.ts +248 -0
  136. package/src/modules/oauth/providers/google.provider.ts +189 -0
  137. package/src/modules/oauth/providers/twitter.provider.ts +214 -0
  138. package/src/modules/oauth/types.ts +94 -0
  139. package/src/modules/payment/index.ts +19 -0
  140. package/src/modules/payment/payment.repository.ts +733 -0
  141. package/src/modules/payment/payment.routes.ts +390 -0
  142. package/src/modules/payment/payment.service.ts +354 -0
  143. package/src/modules/payment/providers/mobile-money.provider.ts +274 -0
  144. package/src/modules/payment/providers/paypal.provider.ts +190 -0
  145. package/src/modules/payment/providers/stripe.provider.ts +215 -0
  146. package/src/modules/payment/types.ts +140 -0
  147. package/src/modules/queue/cron.ts +438 -0
  148. package/src/modules/queue/index.ts +87 -0
  149. package/src/modules/queue/queue.routes.ts +600 -0
  150. package/src/modules/queue/queue.service.ts +842 -0
  151. package/src/modules/queue/types.ts +222 -0
  152. package/src/modules/queue/workers.ts +366 -0
  153. package/src/modules/rate-limit/index.ts +59 -0
  154. package/src/modules/rate-limit/rate-limit.middleware.ts +134 -0
  155. package/src/modules/rate-limit/rate-limit.routes.ts +269 -0
  156. package/src/modules/rate-limit/rate-limit.service.ts +348 -0
  157. package/src/modules/rate-limit/stores/memory.store.ts +165 -0
  158. package/src/modules/rate-limit/stores/redis.store.ts +322 -0
  159. package/src/modules/rate-limit/types.ts +153 -0
  160. package/src/modules/search/adapters/elasticsearch.adapter.ts +326 -0
  161. package/src/modules/search/adapters/meilisearch.adapter.ts +261 -0
  162. package/src/modules/search/adapters/memory.adapter.ts +278 -0
  163. package/src/modules/search/index.ts +21 -0
  164. package/src/modules/search/search.service.ts +234 -0
  165. package/src/modules/search/types.ts +214 -0
  166. package/src/modules/security/index.ts +40 -0
  167. package/src/modules/security/sanitize.ts +223 -0
  168. package/src/modules/security/security-audit.service.ts +388 -0
  169. package/src/modules/security/security.middleware.ts +398 -0
  170. package/src/modules/session/index.ts +3 -0
  171. package/src/modules/session/session.repository.ts +159 -0
  172. package/src/modules/session/session.service.ts +340 -0
  173. package/src/modules/session/types.ts +38 -0
  174. package/src/modules/swagger/index.ts +7 -1
  175. package/src/modules/swagger/schema-builder.ts +16 -4
  176. package/src/modules/swagger/swagger.service.ts +9 -10
  177. package/src/modules/swagger/types.ts +0 -2
  178. package/src/modules/upload/index.ts +14 -0
  179. package/src/modules/upload/types.ts +83 -0
  180. package/src/modules/upload/upload.repository.ts +199 -0
  181. package/src/modules/upload/upload.routes.ts +311 -0
  182. package/src/modules/upload/upload.service.ts +448 -0
  183. package/src/modules/user/index.ts +3 -3
  184. package/src/modules/user/user.controller.ts +15 -9
  185. package/src/modules/user/user.repository.ts +237 -113
  186. package/src/modules/user/user.routes.ts +39 -164
  187. package/src/modules/user/user.service.ts +4 -3
  188. package/src/modules/validation/validator.ts +12 -17
  189. package/src/modules/webhook/index.ts +91 -0
  190. package/src/modules/webhook/retry.ts +196 -0
  191. package/src/modules/webhook/signature.ts +135 -0
  192. package/src/modules/webhook/types.ts +181 -0
  193. package/src/modules/webhook/webhook.repository.ts +358 -0
  194. package/src/modules/webhook/webhook.routes.ts +442 -0
  195. package/src/modules/webhook/webhook.service.ts +457 -0
  196. package/src/modules/websocket/features.ts +504 -0
  197. package/src/modules/websocket/index.ts +106 -0
  198. package/src/modules/websocket/middlewares.ts +298 -0
  199. package/src/modules/websocket/types.ts +181 -0
  200. package/src/modules/websocket/websocket.service.ts +692 -0
  201. package/src/utils/errors.ts +7 -0
  202. package/src/utils/pagination.ts +4 -1
  203. package/tests/helpers/db-check.ts +79 -0
  204. package/tests/integration/auth-redis.test.ts +94 -0
  205. package/tests/integration/cache-redis.test.ts +387 -0
  206. package/tests/integration/mongoose-repositories.test.ts +410 -0
  207. package/tests/integration/payment-prisma.test.ts +637 -0
  208. package/tests/integration/queue-bullmq.test.ts +417 -0
  209. package/tests/integration/user-prisma.test.ts +441 -0
  210. package/tests/integration/websocket-socketio.test.ts +552 -0
  211. package/tests/setup.ts +11 -9
  212. package/vitest.config.ts +3 -8
  213. package/npm-cache/_cacache/content-v2/sha512/1c/d0/03440d500a0487621aad1d6402978340698976602046db8e24fa03c01ee6c022c69b0582f969042d9442ee876ac35c038e960dd427d1e622fa24b8eb7dba +0 -0
  214. package/npm-cache/_cacache/content-v2/sha512/42/55/28b493ca491833e5aab0e9c3108d29ab3f36c248ca88f45d4630674fce9130959e56ae308797ac2b6328fa7f09a610b9550ed09cb971d039876d293fc69d +0 -0
  215. package/npm-cache/_cacache/content-v2/sha512/e0/12/f360dc9315ee5f17844a0c8c233ee6bf7c30837c4a02ea0d56c61c7f7ab21c0e958e50ed2c57c59f983c762b93056778c9009b2398ffc26def0183999b13 +0 -0
  216. package/npm-cache/_cacache/content-v2/sha512/ed/b0/fae1161902898f4c913c67d7f6cdf6be0665aec3b389b9c4f4f0a101ca1da59badf1b59c4e0030f5223023b8d63cfe501c46a32c20c895d4fb3f11ca2232 +0 -0
  217. package/npm-cache/_cacache/index-v5/58/94/c2cba79e0f16b4c10e95a87e32255741149e8222cc314a476aab67c39cc0 +0 -5
@@ -0,0 +1,457 @@
1
+ /**
2
+ * Webhook Service
3
+ * Manages webhook endpoints, events, and deliveries
4
+ *
5
+ * Persistence:
6
+ * - Endpoints: Prisma/PostgreSQL (persistent)
7
+ * - Deliveries: Prisma/PostgreSQL (persistent)
8
+ * - Processing queue: In-memory Set (runtime state only)
9
+ */
10
+ import { randomUUID } from 'crypto';
11
+ import { logger } from '../../core/logger.js';
12
+ import { NotFoundError, BadRequestError } from '../../utils/errors.js';
13
+ import { prisma } from '../../database/prisma.js';
14
+ import { WebhookRepository } from './webhook.repository.js';
15
+ import type {
16
+ WebhookEndpoint,
17
+ WebhookEvent,
18
+ WebhookDelivery,
19
+ WebhookConfig,
20
+ WebhookEventType,
21
+ WebhookStats,
22
+ WebhookFilter,
23
+ CreateWebhookEndpointData,
24
+ UpdateWebhookEndpointData,
25
+ WebhookRetryStrategy,
26
+ } from './types.js';
27
+ import { generateSignature, formatSignatureHeader } from './signature.js';
28
+ import { createDefaultRetryStrategy, calculateNextRetryTime } from './retry.js';
29
+
30
+ const defaultConfig: Required<WebhookConfig> = {
31
+ maxRetries: 5,
32
+ initialRetryDelay: 1000,
33
+ maxRetryDelay: 60000,
34
+ backoffMultiplier: 2,
35
+ timeout: 10000,
36
+ enableSignature: true,
37
+ signatureHeader: 'X-Webhook-Signature',
38
+ timestampHeader: 'X-Webhook-Timestamp',
39
+ };
40
+
41
+ export class WebhookService {
42
+ private config: Required<WebhookConfig>;
43
+ private repository: WebhookRepository;
44
+ private retryStrategy: WebhookRetryStrategy;
45
+ private processingQueue: Set<string> = new Set();
46
+ private retryInterval: NodeJS.Timeout | null = null;
47
+
48
+ constructor(config?: WebhookConfig) {
49
+ this.config = { ...defaultConfig, ...config };
50
+ this.repository = new WebhookRepository(prisma);
51
+ this.retryStrategy = createDefaultRetryStrategy();
52
+
53
+ // Start background retry processor
54
+ this.startRetryProcessor();
55
+ }
56
+
57
+ // ==========================================
58
+ // ENDPOINT MANAGEMENT
59
+ // ==========================================
60
+
61
+ async createEndpoint(data: CreateWebhookEndpointData): Promise<WebhookEndpoint> {
62
+ // Validate URL
63
+ try {
64
+ new URL(data.url);
65
+ } catch {
66
+ throw new BadRequestError('Invalid webhook URL');
67
+ }
68
+
69
+ const endpoint = await this.repository.createEndpoint(data);
70
+ logger.info({ endpointId: endpoint.id, url: endpoint.url }, 'Webhook endpoint created');
71
+
72
+ return endpoint;
73
+ }
74
+
75
+ async getEndpoint(id: string): Promise<WebhookEndpoint> {
76
+ const endpoint = await this.repository.getEndpointById(id);
77
+ if (!endpoint) {
78
+ throw new NotFoundError('Webhook endpoint not found');
79
+ }
80
+ return endpoint;
81
+ }
82
+
83
+ async listEndpoints(): Promise<WebhookEndpoint[]> {
84
+ return this.repository.listEndpoints();
85
+ }
86
+
87
+ async updateEndpoint(id: string, data: UpdateWebhookEndpointData): Promise<WebhookEndpoint> {
88
+ if (data.url) {
89
+ try {
90
+ new URL(data.url);
91
+ } catch {
92
+ throw new BadRequestError('Invalid webhook URL');
93
+ }
94
+ }
95
+
96
+ const endpoint = await this.repository.updateEndpoint(id, data);
97
+ if (!endpoint) {
98
+ throw new NotFoundError('Webhook endpoint not found');
99
+ }
100
+
101
+ logger.info({ endpointId: id }, 'Webhook endpoint updated');
102
+ return endpoint;
103
+ }
104
+
105
+ async deleteEndpoint(id: string): Promise<void> {
106
+ const deleted = await this.repository.deleteEndpoint(id);
107
+ if (!deleted) {
108
+ throw new NotFoundError('Webhook endpoint not found');
109
+ }
110
+ logger.info({ endpointId: id }, 'Webhook endpoint deleted');
111
+ }
112
+
113
+ async rotateSecret(id: string): Promise<WebhookEndpoint> {
114
+ const endpoint = await this.repository.rotateSecret(id);
115
+ if (!endpoint) {
116
+ throw new NotFoundError('Webhook endpoint not found');
117
+ }
118
+
119
+ logger.info({ endpointId: id }, 'Webhook secret rotated');
120
+ return endpoint;
121
+ }
122
+
123
+ // ==========================================
124
+ // EVENT PUBLISHING
125
+ // ==========================================
126
+
127
+ async publishEvent(
128
+ type: WebhookEventType,
129
+ payload: Record<string, unknown>,
130
+ targetEndpoints?: string[]
131
+ ): Promise<WebhookEvent> {
132
+ const event: WebhookEvent = {
133
+ id: randomUUID(),
134
+ type,
135
+ payload,
136
+ occurredAt: new Date(),
137
+ endpoints: targetEndpoints,
138
+ };
139
+
140
+ logger.info({ eventId: event.id, type }, 'Webhook event published');
141
+
142
+ // Dispatch to endpoints asynchronously
143
+ setImmediate(() => {
144
+ this.dispatchEvent(event).catch((error) => {
145
+ logger.error({ error, eventId: event.id }, 'Failed to dispatch event');
146
+ });
147
+ });
148
+
149
+ return event;
150
+ }
151
+
152
+ private async dispatchEvent(event: WebhookEvent): Promise<void> {
153
+ // Get matching endpoints from database
154
+ let matchingEndpoints: WebhookEndpoint[];
155
+
156
+ if (event.endpoints && event.endpoints.length > 0) {
157
+ // Filter to specified endpoints
158
+ const allEndpoints = await this.repository.getEndpointsByEvent(event.type);
159
+ matchingEndpoints = allEndpoints.filter((e) => event.endpoints!.includes(e.id));
160
+ } else {
161
+ matchingEndpoints = await this.repository.getEndpointsByEvent(event.type);
162
+ }
163
+
164
+ logger.debug(
165
+ { eventId: event.id, endpointCount: matchingEndpoints.length },
166
+ 'Dispatching event to endpoints'
167
+ );
168
+
169
+ // Create deliveries
170
+ const deliveryPromises = matchingEndpoints.map((endpoint) =>
171
+ this.createDelivery(endpoint, event)
172
+ );
173
+
174
+ await Promise.allSettled(deliveryPromises);
175
+ }
176
+
177
+ private async createDelivery(
178
+ endpoint: WebhookEndpoint,
179
+ event: WebhookEvent
180
+ ): Promise<WebhookDelivery> {
181
+ const delivery = await this.repository.createDelivery({
182
+ endpointId: endpoint.id,
183
+ eventType: event.type,
184
+ payload: event.payload,
185
+ maxAttempts: this.config.maxRetries,
186
+ });
187
+
188
+ // Attempt delivery immediately
189
+ await this.attemptDelivery(delivery.id);
190
+
191
+ return delivery;
192
+ }
193
+
194
+ private async attemptDelivery(deliveryId: string): Promise<void> {
195
+ // Prevent concurrent processing
196
+ if (this.processingQueue.has(deliveryId)) {
197
+ return;
198
+ }
199
+
200
+ this.processingQueue.add(deliveryId);
201
+
202
+ try {
203
+ const delivery = await this.repository.getDeliveryById(deliveryId);
204
+ if (!delivery) {
205
+ throw new Error(`Delivery ${deliveryId} not found`);
206
+ }
207
+
208
+ const endpoint = await this.repository.getEndpointById(delivery.endpointId);
209
+ if (!endpoint) {
210
+ throw new Error(`Endpoint ${delivery.endpointId} not found`);
211
+ }
212
+
213
+ // Increment attempts
214
+ await this.repository.incrementAttempts(deliveryId);
215
+ const currentAttempts = delivery.attempts + 1;
216
+
217
+ await this.repository.updateDelivery(deliveryId, {
218
+ status: currentAttempts > 1 ? 'retrying' : 'pending',
219
+ });
220
+
221
+ const startTime = Date.now();
222
+
223
+ try {
224
+ // Prepare request
225
+ const headers: Record<string, string> = {
226
+ 'Content-Type': 'application/json',
227
+ 'User-Agent': 'Servcraft-Webhooks/1.0',
228
+ ...endpoint.headers,
229
+ };
230
+
231
+ // Add signature
232
+ if (this.config.enableSignature) {
233
+ const signature = generateSignature(delivery.payload, endpoint.secret);
234
+ headers[this.config.signatureHeader] = formatSignatureHeader(signature);
235
+ headers[this.config.timestampHeader] = signature.timestamp.toString();
236
+ }
237
+
238
+ // Send request
239
+ const controller = new AbortController();
240
+ const timeout = setTimeout(() => controller.abort(), this.config.timeout);
241
+
242
+ const response = await fetch(endpoint.url, {
243
+ method: 'POST',
244
+ headers,
245
+ body: JSON.stringify({
246
+ id: delivery.id,
247
+ type: delivery.eventType,
248
+ created: delivery.createdAt.toISOString(),
249
+ data: delivery.payload,
250
+ }),
251
+ signal: controller.signal,
252
+ });
253
+
254
+ clearTimeout(timeout);
255
+
256
+ const duration = Date.now() - startTime;
257
+ const responseBody = await response.text().catch(() => '');
258
+
259
+ // Check success
260
+ if (response.ok) {
261
+ await this.repository.updateDelivery(deliveryId, {
262
+ status: 'success',
263
+ deliveredAt: new Date(),
264
+ responseStatus: response.status,
265
+ responseBody: responseBody.substring(0, 1000),
266
+ });
267
+
268
+ logger.info(
269
+ { deliveryId, endpointId: endpoint.id, duration },
270
+ 'Webhook delivered successfully'
271
+ );
272
+ } else {
273
+ throw new Error(`HTTP ${response.status}: ${responseBody}`);
274
+ }
275
+ } catch (error) {
276
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
277
+
278
+ // Check if should retry
279
+ if (this.retryStrategy.shouldRetry(currentAttempts, error as Error)) {
280
+ const nextRetryAt = calculateNextRetryTime(currentAttempts, this.retryStrategy);
281
+
282
+ if (nextRetryAt) {
283
+ await this.repository.updateDelivery(deliveryId, {
284
+ status: 'retrying',
285
+ nextRetryAt,
286
+ error: errorMessage,
287
+ });
288
+
289
+ logger.warn(
290
+ {
291
+ deliveryId,
292
+ endpointId: endpoint.id,
293
+ attempt: currentAttempts,
294
+ nextRetry: nextRetryAt,
295
+ error: errorMessage,
296
+ },
297
+ 'Webhook delivery failed, will retry'
298
+ );
299
+ } else {
300
+ await this.repository.updateDelivery(deliveryId, {
301
+ status: 'failed',
302
+ error: errorMessage,
303
+ });
304
+
305
+ logger.error(
306
+ {
307
+ deliveryId,
308
+ endpointId: endpoint.id,
309
+ attempts: currentAttempts,
310
+ error: errorMessage,
311
+ },
312
+ 'Webhook delivery failed after all retries'
313
+ );
314
+ }
315
+ } else {
316
+ await this.repository.updateDelivery(deliveryId, {
317
+ status: 'failed',
318
+ error: errorMessage,
319
+ });
320
+
321
+ logger.error(
322
+ {
323
+ deliveryId,
324
+ endpointId: endpoint.id,
325
+ attempts: currentAttempts,
326
+ error: errorMessage,
327
+ },
328
+ 'Webhook delivery failed, not retrying'
329
+ );
330
+ }
331
+ }
332
+ } finally {
333
+ this.processingQueue.delete(deliveryId);
334
+ }
335
+ }
336
+
337
+ // ==========================================
338
+ // RETRY PROCESSING
339
+ // ==========================================
340
+
341
+ private startRetryProcessor(): void {
342
+ // Process retries every 5 seconds
343
+ this.retryInterval = setInterval(() => {
344
+ this.processRetries().catch((error) => {
345
+ logger.error({ error }, 'Error processing retries');
346
+ });
347
+ }, 5000);
348
+ }
349
+
350
+ private async processRetries(): Promise<void> {
351
+ const retriableDeliveries = await this.repository.getRetriableDeliveries();
352
+
353
+ if (retriableDeliveries.length > 0) {
354
+ logger.debug({ count: retriableDeliveries.length }, 'Processing retry batch');
355
+ }
356
+
357
+ await Promise.allSettled(
358
+ retriableDeliveries
359
+ .filter((d) => !this.processingQueue.has(d.id))
360
+ .map((d) => this.attemptDelivery(d.id))
361
+ );
362
+ }
363
+
364
+ // ==========================================
365
+ // QUERY & STATS
366
+ // ==========================================
367
+
368
+ async getDelivery(id: string): Promise<WebhookDelivery> {
369
+ const delivery = await this.repository.getDeliveryById(id);
370
+ if (!delivery) {
371
+ throw new NotFoundError('Webhook delivery not found');
372
+ }
373
+ return delivery;
374
+ }
375
+
376
+ async listDeliveries(filter?: WebhookFilter): Promise<WebhookDelivery[]> {
377
+ return this.repository.listDeliveries(filter);
378
+ }
379
+
380
+ async getDeliveryAttempts(
381
+ _deliveryId: string
382
+ ): Promise<Array<{ attempt: number; timestamp: Date; statusCode?: number; error?: string }>> {
383
+ // Delivery attempts are now tracked via the attempts counter and status updates
384
+ // Historical attempt data would require a separate model to persist
385
+ // For now, return empty array - attempt history can be derived from logs
386
+ return [];
387
+ }
388
+
389
+ async retryDelivery(deliveryId: string): Promise<void> {
390
+ const delivery = await this.repository.getDeliveryById(deliveryId);
391
+ if (!delivery) {
392
+ throw new NotFoundError('Webhook delivery not found');
393
+ }
394
+
395
+ if (delivery.status === 'success') {
396
+ throw new BadRequestError('Cannot retry successful delivery');
397
+ }
398
+
399
+ // Reset for retry
400
+ await this.repository.updateDelivery(deliveryId, {
401
+ status: 'pending',
402
+ nextRetryAt: new Date(),
403
+ });
404
+
405
+ logger.info({ deliveryId }, 'Manual retry triggered');
406
+
407
+ await this.attemptDelivery(deliveryId);
408
+ }
409
+
410
+ async getStats(endpointId?: string): Promise<WebhookStats> {
411
+ const stats = await this.repository.getStats(endpointId);
412
+
413
+ const successRate =
414
+ stats.totalDeliveries > 0 ? (stats.successCount / stats.totalDeliveries) * 100 : 0;
415
+
416
+ return {
417
+ totalEvents: stats.totalDeliveries,
418
+ successfulDeliveries: stats.successCount,
419
+ failedDeliveries: stats.failedCount,
420
+ pendingDeliveries: stats.pendingCount,
421
+ averageDeliveryTime: 0, // Would need to track this separately
422
+ successRate,
423
+ };
424
+ }
425
+
426
+ // ==========================================
427
+ // CLEANUP
428
+ // ==========================================
429
+
430
+ async cleanup(olderThanDays = 30): Promise<number> {
431
+ return this.repository.cleanupOldDeliveries(olderThanDays);
432
+ }
433
+
434
+ /**
435
+ * Stop the retry processor
436
+ */
437
+ stop(): void {
438
+ if (this.retryInterval) {
439
+ clearInterval(this.retryInterval);
440
+ this.retryInterval = null;
441
+ }
442
+ }
443
+ }
444
+
445
+ let webhookService: WebhookService | null = null;
446
+
447
+ export function getWebhookService(): WebhookService {
448
+ if (!webhookService) {
449
+ webhookService = new WebhookService();
450
+ }
451
+ return webhookService;
452
+ }
453
+
454
+ export function createWebhookService(config?: WebhookConfig): WebhookService {
455
+ webhookService = new WebhookService(config);
456
+ return webhookService;
457
+ }