webspresso 0.0.98 → 0.0.100

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 (88) hide show
  1. package/.agents/ADMIN.md +11 -1
  2. package/.agents/AGENTS.md +2 -2
  3. package/.agents/AUTH.md +415 -0
  4. package/.agents/CLI.md +47 -0
  5. package/.agents/ERRORS.md +59 -0
  6. package/.agents/ORM.md +178 -4
  7. package/.agents/PLUGINS.md +113 -8
  8. package/.agents/QUEUE.md +92 -0
  9. package/.agents/ROUTING.md +160 -7
  10. package/.agents/STREAMING.md +35 -0
  11. package/AGENTS.md +2 -0
  12. package/README.md +145 -93
  13. package/bin/commands/add-docker.js +467 -0
  14. package/bin/commands/deploy-check.js +339 -0
  15. package/bin/webspresso.js +4 -0
  16. package/core/auth/index.js +123 -1
  17. package/core/auth/magic-link.js +170 -0
  18. package/core/auth/manager.js +477 -3
  19. package/core/auth/middleware.js +118 -5
  20. package/core/auth/oauth.js +517 -0
  21. package/core/auth/qrcode.js +499 -0
  22. package/core/auth/session-manager.js +255 -0
  23. package/core/auth/sessions.js +351 -0
  24. package/core/auth/tokens.js +1 -0
  25. package/core/auth/totp.js +507 -0
  26. package/core/auth/user-agent.js +161 -0
  27. package/core/orm/eager-loader.js +144 -10
  28. package/core/orm/index.js +10 -1
  29. package/core/orm/model.js +55 -1
  30. package/core/orm/query-builder.js +90 -4
  31. package/core/orm/repository.js +327 -16
  32. package/core/orm/scopes.js +3 -2
  33. package/core/orm/search.js +213 -0
  34. package/core/orm/types.js +6 -1
  35. package/core/queue/cron.js +544 -0
  36. package/core/queue/index.js +8 -1
  37. package/core/queue/manager.js +100 -1
  38. package/core/queue/scheduler.js +415 -0
  39. package/core/sse/index.js +19 -0
  40. package/core/sse/stream.js +430 -0
  41. package/core/ssr/stream.js +62 -2
  42. package/index.d.ts +794 -6
  43. package/index.js +48 -0
  44. package/package.json +7 -5
  45. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
  46. package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
  47. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  48. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  49. package/plugins/admin-panel/field-renderers/index.js +2 -0
  50. package/plugins/admin-panel/modules/admin-users.js +1 -1
  51. package/plugins/api-key/crypto.js +101 -0
  52. package/plugins/api-key/index.js +288 -0
  53. package/plugins/api-key/middleware.js +461 -0
  54. package/plugins/api-key/migration-template.js +48 -0
  55. package/plugins/api-key/model.js +50 -0
  56. package/plugins/api-key/scopes.js +125 -0
  57. package/plugins/audit-log/admin-component.js +487 -70
  58. package/plugins/audit-log/api-handlers.js +114 -0
  59. package/plugins/audit-log/index.js +24 -0
  60. package/plugins/content/client/inline-edit.js +2 -46
  61. package/plugins/cors.js +13 -2
  62. package/plugins/email/auth-bridge.js +66 -0
  63. package/plugins/email/bundled-templates.js +15 -0
  64. package/plugins/email/templates/auth-magic-link.mjml +13 -0
  65. package/plugins/file-manager/admin-component.js +69 -53
  66. package/plugins/file-manager/index.js +25 -8
  67. package/plugins/index.js +8 -0
  68. package/plugins/oauth/index.js +338 -0
  69. package/plugins/queue/index.js +4 -0
  70. package/plugins/webhooks/admin-component.js +608 -0
  71. package/plugins/webhooks/api-handlers.js +237 -0
  72. package/plugins/webhooks/crypto.js +144 -0
  73. package/plugins/webhooks/dispatcher.js +708 -0
  74. package/plugins/webhooks/index.js +263 -0
  75. package/plugins/webhooks/matcher.js +113 -0
  76. package/plugins/webhooks/migration-template.js +75 -0
  77. package/plugins/webhooks/models.js +98 -0
  78. package/plugins/webhooks/sender.js +176 -0
  79. package/src/api/api-loader.js +1 -0
  80. package/src/bundle/index.js +2 -1
  81. package/src/discovery/file-route-parser.js +44 -1
  82. package/src/discovery/scan-routes.js +2 -2
  83. package/src/file-router.js +310 -104
  84. package/src/helpers.js +16 -1
  85. package/src/njk-frontmatter.js +3 -0
  86. package/src/routing/cascading.js +490 -0
  87. package/src/routing/middleware-resolver.js +92 -0
  88. package/src/server.js +6 -0
@@ -0,0 +1,708 @@
1
+ /**
2
+ * Webhook Dispatcher Engine
3
+ * Manages webhook endpoints, event matching, background delivery, retries, and history
4
+ * @module plugins/webhooks/dispatcher
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const EventEmitter = require('events');
10
+ const crypto = require('crypto');
11
+ const { generateSecret } = require('./crypto');
12
+ const { matchesAnyEvent, normalizeEvents } = require('./matcher');
13
+ const { sendWebhook } = require('./sender');
14
+
15
+ /**
16
+ * Generate a standard unique delivery identifier
17
+ * @returns {string}
18
+ */
19
+ function generateDeliveryId() {
20
+ if (typeof crypto.randomUUID === 'function') {
21
+ return `dlv_${crypto.randomUUID().replace(/-/g, '')}`;
22
+ }
23
+ return `dlv_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`;
24
+ }
25
+
26
+ class WebhookDispatcher extends EventEmitter {
27
+ /**
28
+ * @param {Object} [options]
29
+ * @param {any} [options.db] - Webspresso database or Knex instance
30
+ * @param {any} [options.knex] - Knex instance
31
+ * @param {string} [options.endpointsTable='webhook_endpoints']
32
+ * @param {string} [options.deliveriesTable='webhook_deliveries']
33
+ * @param {number} [options.maxAttempts=3] - Maximum retry attempts
34
+ * @param {number} [options.backoffBaseMs=1000] - Base delay for exponential backoff (ms)
35
+ * @param {number} [options.timeoutMs=10000] - Request timeout (ms)
36
+ * @param {boolean} [options.wrapPayload=true] - Wrap data in standard event envelope
37
+ * @param {any} [options.queue] - Webspresso QueueManager instance
38
+ * @param {Array<Object>} [options.endpoints] - Static in-memory endpoints
39
+ * @param {any} [options.logger] - Logger instance
40
+ */
41
+ constructor(options = {}) {
42
+ super();
43
+ this.options = options;
44
+ this.db = options.db || null;
45
+ this.knex = options.knex || (this.db && this.db.knex) || (typeof this.db === 'function' ? this.db : null);
46
+ this.endpointsTable = options.endpointsTable || 'webhook_endpoints';
47
+ this.deliveriesTable = options.deliveriesTable || 'webhook_deliveries';
48
+ this.maxAttempts = typeof options.maxAttempts === 'number' ? options.maxAttempts : 3;
49
+ this.backoffBaseMs = typeof options.backoffBaseMs === 'number' ? options.backoffBaseMs : 1000;
50
+ this.timeoutMs = typeof options.timeoutMs === 'number' ? options.timeoutMs : 10000;
51
+ this.wrapPayload = options.wrapPayload !== undefined ? options.wrapPayload : true;
52
+ this.queue = options.queue || null;
53
+ this.logger = options.logger || console;
54
+ this._staticEndpoints = Array.isArray(options.endpoints) ? [...options.endpoints] : null;
55
+ this._activeTimers = new Set();
56
+ }
57
+
58
+ /**
59
+ * Resolve Knex query builder
60
+ * @returns {any}
61
+ */
62
+ resolveKnex() {
63
+ return this.knex || (this.db && this.db.knex) || (typeof this.db === 'function' ? this.db : null);
64
+ }
65
+
66
+ /**
67
+ * Set or update queue manager
68
+ * @param {any} queue
69
+ */
70
+ setQueue(queue) {
71
+ this.queue = queue;
72
+ }
73
+
74
+ /**
75
+ * Cleanup any pending in-memory retry timers on shutdown
76
+ */
77
+ dispose() {
78
+ for (const timer of this._activeTimers) {
79
+ clearTimeout(timer);
80
+ }
81
+ this._activeTimers.clear();
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Endpoints CRUD
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /**
89
+ * Retrieve active endpoints that subscribe to a given event
90
+ *
91
+ * @param {string} eventName
92
+ * @returns {Promise<Array<Object>>}
93
+ */
94
+ async getEndpointsForEvent(eventName) {
95
+ if (this._staticEndpoints) {
96
+ return this._staticEndpoints.filter((ep) => {
97
+ const isActive = ep.is_active !== false && ep.active !== false;
98
+ return isActive && matchesAnyEvent(ep.events, eventName);
99
+ });
100
+ }
101
+
102
+ const kx = this.resolveKnex();
103
+ if (!kx) {
104
+ return [];
105
+ }
106
+
107
+ const rows = await kx(this.endpointsTable).where({ is_active: true });
108
+ return rows.filter((ep) => matchesAnyEvent(ep.events, eventName));
109
+ }
110
+
111
+ /**
112
+ * List all webhook endpoints
113
+ *
114
+ * @param {Object} [filters]
115
+ * @param {boolean} [filters.active]
116
+ * @param {string} [filters.search]
117
+ * @param {number} [filters.limit]
118
+ * @param {number} [filters.offset]
119
+ * @returns {Promise<Array<Object>>}
120
+ */
121
+ async listEndpoints(filters = {}) {
122
+ if (this._staticEndpoints) {
123
+ let list = [...this._staticEndpoints];
124
+ if (filters.active !== undefined) {
125
+ list = list.filter((ep) => (ep.is_active !== false) === !!filters.active);
126
+ }
127
+ if (filters.search) {
128
+ const q = filters.search.toLowerCase();
129
+ list = list.filter((ep) => (ep.name && ep.name.toLowerCase().includes(q)) || (ep.url && ep.url.toLowerCase().includes(q)));
130
+ }
131
+ return list;
132
+ }
133
+
134
+ const kx = this.resolveKnex();
135
+ if (!kx) return [];
136
+
137
+ let query = kx(this.endpointsTable).select('*');
138
+ if (filters.active !== undefined) {
139
+ query = query.where({ is_active: !!filters.active });
140
+ }
141
+ if (filters.search) {
142
+ query = query.where((q) => {
143
+ q.where('name', 'like', `%${filters.search}%`)
144
+ .orWhere('url', 'like', `%${filters.search}%`);
145
+ });
146
+ }
147
+ query = query.orderBy('created_at', 'desc');
148
+ if (filters.limit) query = query.limit(filters.limit);
149
+ if (filters.offset) query = query.offset(filters.offset);
150
+
151
+ return query;
152
+ }
153
+
154
+ /**
155
+ * Get an endpoint by ID
156
+ *
157
+ * @param {number|string} id
158
+ * @returns {Promise<Object|null>}
159
+ */
160
+ async getEndpoint(id) {
161
+ if (this._staticEndpoints) {
162
+ return this._staticEndpoints.find((ep) => String(ep.id) === String(id)) || null;
163
+ }
164
+
165
+ const kx = this.resolveKnex();
166
+ if (!kx) return null;
167
+ const ep = await kx(this.endpointsTable).where({ id }).first();
168
+ return ep || null;
169
+ }
170
+
171
+ /**
172
+ * Create a new webhook endpoint
173
+ *
174
+ * @param {Object} data
175
+ * @returns {Promise<Object>}
176
+ */
177
+ async createEndpoint(data) {
178
+ if (!data.name || typeof data.name !== 'string') {
179
+ throw new Error('Endpoint name is required.');
180
+ }
181
+ if (!data.url || typeof data.url !== 'string') {
182
+ throw new Error('Endpoint URL is required.');
183
+ }
184
+
185
+ const secret = data.secret || generateSecret();
186
+ const eventsList = normalizeEvents(data.events);
187
+
188
+ const record = {
189
+ name: data.name.trim(),
190
+ url: data.url.trim(),
191
+ secret,
192
+ events: JSON.stringify(eventsList),
193
+ is_active: data.is_active !== undefined ? !!data.is_active : (data.active !== undefined ? !!data.active : true),
194
+ description: data.description || null,
195
+ headers: data.headers ? (typeof data.headers === 'string' ? data.headers : JSON.stringify(data.headers)) : null,
196
+ metadata: data.metadata ? (typeof data.metadata === 'string' ? data.metadata : JSON.stringify(data.metadata)) : null,
197
+ created_at: new Date(),
198
+ updated_at: new Date(),
199
+ };
200
+
201
+ if (this._staticEndpoints) {
202
+ record.id = this._staticEndpoints.length + 1;
203
+ this._staticEndpoints.push(record);
204
+ return record;
205
+ }
206
+
207
+ const kx = this.resolveKnex();
208
+ if (!kx) {
209
+ record.id = 1;
210
+ return record;
211
+ }
212
+
213
+ const insertRes = await kx(this.endpointsTable).insert(record);
214
+ let insertedId = 1;
215
+ if (Array.isArray(insertRes) && insertRes.length > 0) {
216
+ insertedId = typeof insertRes[0] === 'object' && insertRes[0] !== null ? insertRes[0].id : insertRes[0];
217
+ } else if (typeof insertRes === 'number') {
218
+ insertedId = insertRes;
219
+ }
220
+ record.id = insertedId;
221
+ return record;
222
+ }
223
+
224
+ /**
225
+ * Update an existing webhook endpoint
226
+ *
227
+ * @param {number|string} id
228
+ * @param {Object} data
229
+ * @returns {Promise<Object|null>}
230
+ */
231
+ async updateEndpoint(id, data) {
232
+ const updatePayload = {
233
+ updated_at: new Date(),
234
+ };
235
+
236
+ if (data.name !== undefined) updatePayload.name = String(data.name).trim();
237
+ if (data.url !== undefined) updatePayload.url = String(data.url).trim();
238
+ if (data.secret !== undefined) updatePayload.secret = String(data.secret);
239
+ if (data.events !== undefined) updatePayload.events = JSON.stringify(normalizeEvents(data.events));
240
+ if (data.is_active !== undefined) updatePayload.is_active = !!data.is_active;
241
+ if (data.active !== undefined) updatePayload.is_active = !!data.active;
242
+ if (data.description !== undefined) updatePayload.description = data.description;
243
+ if (data.headers !== undefined) {
244
+ updatePayload.headers = data.headers ? (typeof data.headers === 'string' ? data.headers : JSON.stringify(data.headers)) : null;
245
+ }
246
+ if (data.metadata !== undefined) {
247
+ updatePayload.metadata = data.metadata ? (typeof data.metadata === 'string' ? data.metadata : JSON.stringify(data.metadata)) : null;
248
+ }
249
+
250
+ if (this._staticEndpoints) {
251
+ const idx = this._staticEndpoints.findIndex((ep) => String(ep.id) === String(id));
252
+ if (idx === -1) return null;
253
+ this._staticEndpoints[idx] = { ...this._staticEndpoints[idx], ...updatePayload };
254
+ return this._staticEndpoints[idx];
255
+ }
256
+
257
+ const kx = this.resolveKnex();
258
+ if (!kx) return null;
259
+
260
+ const rowsUpdated = await kx(this.endpointsTable).where({ id }).update(updatePayload);
261
+ if (rowsUpdated === 0) return null;
262
+
263
+ return this.getEndpoint(id);
264
+ }
265
+
266
+ /**
267
+ * Delete a webhook endpoint
268
+ *
269
+ * @param {number|string} id
270
+ * @returns {Promise<boolean>}
271
+ */
272
+ async deleteEndpoint(id) {
273
+ if (this._staticEndpoints) {
274
+ const initialLen = this._staticEndpoints.length;
275
+ this._staticEndpoints = this._staticEndpoints.filter((ep) => String(ep.id) !== String(id));
276
+ return this._staticEndpoints.length < initialLen;
277
+ }
278
+
279
+ const kx = this.resolveKnex();
280
+ if (!kx) return false;
281
+
282
+ // Delete associated deliveries first
283
+ await kx(this.deliveriesTable).where({ endpoint_id: id }).delete();
284
+ const rowsDeleted = await kx(this.endpointsTable).where({ id }).delete();
285
+ return rowsDeleted > 0;
286
+ }
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // Webhook Dispatch & Execution
290
+ // ---------------------------------------------------------------------------
291
+
292
+ /**
293
+ * Dispatch an event to all subscribed webhook endpoints
294
+ *
295
+ * @param {string} event - Event name (e.g. 'order.created')
296
+ * @param {any} payload - Event data payload
297
+ * @param {Object} [options]
298
+ * @param {boolean} [options.wait=false] - Whether to await all HTTP deliveries before returning
299
+ * @param {number} [options.endpointId] - Limit dispatch to a specific endpoint
300
+ * @returns {Promise<{ dispatched: number, deliveries: Array<Object> }>}
301
+ */
302
+ async dispatch(event, payload, options = {}) {
303
+ if (!event || typeof event !== 'string') {
304
+ throw new Error('Event name must be a non-empty string.');
305
+ }
306
+
307
+ let endpoints = await this.getEndpointsForEvent(event);
308
+ if (options.endpointId) {
309
+ endpoints = endpoints.filter((ep) => String(ep.id) === String(options.endpointId));
310
+ }
311
+
312
+ if (endpoints.length === 0) {
313
+ return { dispatched: 0, deliveries: [] };
314
+ }
315
+
316
+ const kx = this.resolveKnex();
317
+ const deliveries = [];
318
+ const executionPromises = [];
319
+
320
+ for (const endpoint of endpoints) {
321
+ const deliveryId = generateDeliveryId();
322
+ const payloadStr = typeof payload === 'string' ? payload : JSON.stringify(payload);
323
+
324
+ const deliveryRecord = {
325
+ endpoint_id: endpoint.id,
326
+ event,
327
+ payload: payloadStr,
328
+ status_code: null,
329
+ success: false,
330
+ duration_ms: null,
331
+ error: null,
332
+ response_body: null,
333
+ attempt: 1,
334
+ max_attempts: this.maxAttempts,
335
+ next_retry_at: null,
336
+ created_at: new Date(),
337
+ updated_at: new Date(),
338
+ };
339
+
340
+ if (kx) {
341
+ try {
342
+ const insertRes = await kx(this.deliveriesTable).insert(deliveryRecord);
343
+ let dbId = deliveryId;
344
+ if (Array.isArray(insertRes) && insertRes.length > 0) {
345
+ dbId = typeof insertRes[0] === 'object' && insertRes[0] !== null ? insertRes[0].id : insertRes[0];
346
+ } else if (typeof insertRes === 'number') {
347
+ dbId = insertRes;
348
+ }
349
+ deliveryRecord.id = dbId;
350
+ } catch (dbErr) {
351
+ deliveryRecord.id = deliveryId;
352
+ }
353
+ } else {
354
+ deliveryRecord.id = deliveryId;
355
+ }
356
+
357
+ deliveries.push(deliveryRecord);
358
+
359
+ // Determine dispatch mechanism: Queue vs In-process
360
+ if (this.queue && typeof this.queue.dispatch === 'function') {
361
+ const queuePromise = this.queue.dispatch('webhooks.deliver', {
362
+ deliveryId: deliveryRecord.id,
363
+ endpointId: endpoint.id,
364
+ event,
365
+ payload,
366
+ attempt: 1,
367
+ }, {
368
+ attempts: this.maxAttempts,
369
+ backoff: { type: 'exponential', delay: this.backoffBaseMs },
370
+ timeout: this.timeoutMs,
371
+ });
372
+ executionPromises.push(queuePromise);
373
+ } else {
374
+ const execPromise = this.executeDelivery({
375
+ endpoint,
376
+ deliveryId: deliveryRecord.id,
377
+ event,
378
+ payload,
379
+ attempt: 1,
380
+ });
381
+ executionPromises.push(execPromise);
382
+ }
383
+ }
384
+
385
+ this.emit('dispatched', { event, count: endpoints.length, endpoints: endpoints.map((e) => e.id) });
386
+
387
+ if (options.wait) {
388
+ const results = await Promise.allSettled(executionPromises);
389
+ return {
390
+ dispatched: endpoints.length,
391
+ deliveries,
392
+ results: results.map((r) => (r.status === 'fulfilled' ? r.value : { error: r.reason?.message })),
393
+ };
394
+ }
395
+
396
+ return { dispatched: endpoints.length, deliveries };
397
+ }
398
+
399
+ /**
400
+ * Execute single HTTP delivery to an endpoint
401
+ *
402
+ * @param {Object} params
403
+ * @param {Object} params.endpoint
404
+ * @param {number|string} params.deliveryId
405
+ * @param {string} params.event
406
+ * @param {any} params.payload
407
+ * @param {number} [params.attempt=1]
408
+ * @param {boolean} [params.autoRetry=true]
409
+ * @returns {Promise<Object>}
410
+ */
411
+ async executeDelivery(params) {
412
+ const {
413
+ endpoint,
414
+ deliveryId,
415
+ event,
416
+ payload,
417
+ attempt = 1,
418
+ autoRetry = true,
419
+ } = params;
420
+
421
+ const res = await sendWebhook({
422
+ url: endpoint.url,
423
+ event,
424
+ payload,
425
+ secret: endpoint.secret,
426
+ deliveryId: String(deliveryId),
427
+ headers: endpoint.headers,
428
+ timeoutMs: this.timeoutMs,
429
+ wrapPayload: this.wrapPayload,
430
+ });
431
+
432
+ const kx = this.resolveKnex();
433
+ const updateData = {
434
+ status_code: res.statusCode || null,
435
+ success: !!res.success,
436
+ duration_ms: res.durationMs,
437
+ error: res.error || null,
438
+ response_body: res.responseBody || null,
439
+ attempt,
440
+ updated_at: new Date(),
441
+ };
442
+
443
+ let willRetry = false;
444
+ let nextRetryAt = null;
445
+
446
+ if (!res.success && attempt < this.maxAttempts) {
447
+ willRetry = true;
448
+ const delayMs = this.backoffBaseMs * Math.pow(2, attempt - 1);
449
+ nextRetryAt = new Date(Date.now() + delayMs);
450
+ updateData.next_retry_at = nextRetryAt;
451
+
452
+ // In-process retry timer if no queue is managing it
453
+ if (autoRetry && !this.queue) {
454
+ const timer = setTimeout(() => {
455
+ this._activeTimers.delete(timer);
456
+ this.executeDelivery({
457
+ endpoint,
458
+ deliveryId,
459
+ event,
460
+ payload,
461
+ attempt: attempt + 1,
462
+ autoRetry: true,
463
+ }).catch((err) => {
464
+ this.emit('error', err);
465
+ });
466
+ }, delayMs);
467
+ this._activeTimers.add(timer);
468
+ }
469
+ } else {
470
+ updateData.next_retry_at = null;
471
+ }
472
+
473
+ if (kx && deliveryId) {
474
+ try {
475
+ await kx(this.deliveriesTable).where({ id: deliveryId }).update(updateData);
476
+ } catch (dbErr) {
477
+ // Log or handle gracefully
478
+ }
479
+ }
480
+
481
+ const outcome = {
482
+ deliveryId,
483
+ endpointId: endpoint.id,
484
+ event,
485
+ attempt,
486
+ success: res.success,
487
+ statusCode: res.statusCode,
488
+ durationMs: res.durationMs,
489
+ error: res.error,
490
+ willRetry,
491
+ nextRetryAt,
492
+ };
493
+
494
+ if (res.success) {
495
+ this.emit('delivered', outcome);
496
+ } else {
497
+ this.emit('failed', outcome);
498
+ }
499
+
500
+ return outcome;
501
+ }
502
+
503
+ /**
504
+ * Manually retry a specific delivery
505
+ *
506
+ * @param {number|string} deliveryId
507
+ * @returns {Promise<Object>}
508
+ */
509
+ async retryDelivery(deliveryId) {
510
+ const kx = this.resolveKnex();
511
+ if (!kx) {
512
+ throw new Error('Database connection required to retry delivery.');
513
+ }
514
+
515
+ const delivery = await kx(this.deliveriesTable).where({ id: deliveryId }).first();
516
+ if (!delivery) {
517
+ throw new Error(`Delivery #${deliveryId} not found.`);
518
+ }
519
+
520
+ const endpoint = await this.getEndpoint(delivery.endpoint_id);
521
+ if (!endpoint) {
522
+ throw new Error(`Target endpoint #${delivery.endpoint_id} no longer exists.`);
523
+ }
524
+
525
+ let parsedPayload = delivery.payload;
526
+ try {
527
+ parsedPayload = JSON.parse(delivery.payload);
528
+ } catch (e) {
529
+ // Keep string if not json
530
+ }
531
+
532
+ const nextAttempt = (delivery.attempt || 1) + 1;
533
+ return this.executeDelivery({
534
+ endpoint,
535
+ deliveryId,
536
+ event: delivery.event,
537
+ payload: parsedPayload,
538
+ attempt: nextAttempt,
539
+ autoRetry: false,
540
+ });
541
+ }
542
+
543
+ /**
544
+ * Send a test ping webhook to an endpoint
545
+ *
546
+ * @param {number|string} endpointId
547
+ * @returns {Promise<Object>}
548
+ */
549
+ async pingEndpoint(endpointId) {
550
+ const endpoint = await this.getEndpoint(endpointId);
551
+ if (!endpoint) {
552
+ throw new Error(`Endpoint #${endpointId} not found.`);
553
+ }
554
+
555
+ const pingPayload = {
556
+ ping: true,
557
+ message: 'Webspresso Webhook ping test',
558
+ timestamp: new Date().toISOString(),
559
+ endpoint: {
560
+ id: endpoint.id,
561
+ name: endpoint.name,
562
+ },
563
+ };
564
+
565
+ const deliveryId = generateDeliveryId();
566
+ const payloadStr = JSON.stringify(pingPayload);
567
+ const kx = this.resolveKnex();
568
+
569
+ const deliveryRecord = {
570
+ endpoint_id: endpoint.id,
571
+ event: 'ping',
572
+ payload: payloadStr,
573
+ status_code: null,
574
+ success: false,
575
+ duration_ms: null,
576
+ error: null,
577
+ response_body: null,
578
+ attempt: 1,
579
+ max_attempts: 1,
580
+ next_retry_at: null,
581
+ created_at: new Date(),
582
+ updated_at: new Date(),
583
+ };
584
+
585
+ if (kx) {
586
+ try {
587
+ const insertRes = await kx(this.deliveriesTable).insert(deliveryRecord);
588
+ let dbId = deliveryId;
589
+ if (Array.isArray(insertRes) && insertRes.length > 0) {
590
+ dbId = typeof insertRes[0] === 'object' && insertRes[0] !== null ? insertRes[0].id : insertRes[0];
591
+ } else if (typeof insertRes === 'number') {
592
+ dbId = insertRes;
593
+ }
594
+ deliveryRecord.id = dbId;
595
+ } catch (err) {
596
+ deliveryRecord.id = deliveryId;
597
+ }
598
+ } else {
599
+ deliveryRecord.id = deliveryId;
600
+ }
601
+
602
+ return this.executeDelivery({
603
+ endpoint,
604
+ deliveryId: deliveryRecord.id,
605
+ event: 'ping',
606
+ payload: pingPayload,
607
+ attempt: 1,
608
+ autoRetry: false,
609
+ });
610
+ }
611
+
612
+ // ---------------------------------------------------------------------------
613
+ // Delivery Logs & Stats
614
+ // ---------------------------------------------------------------------------
615
+
616
+ /**
617
+ * List delivery logs with pagination & filtering
618
+ *
619
+ * @param {Object} [filters]
620
+ * @param {number|string} [filters.endpointId]
621
+ * @param {string} [filters.event]
622
+ * @param {boolean} [filters.success]
623
+ * @param {number} [filters.page=1]
624
+ * @param {number} [filters.limit=25]
625
+ * @returns {Promise<{ items: Array<Object>, total: number, page: number, limit: number, pages: number }>}
626
+ */
627
+ async listDeliveries(filters = {}) {
628
+ const kx = this.resolveKnex();
629
+ if (!kx) {
630
+ return { items: [], total: 0, page: 1, limit: 25, pages: 0 };
631
+ }
632
+
633
+ const page = Math.max(1, parseInt(filters.page, 10) || 1);
634
+ const limit = Math.max(1, Math.min(100, parseInt(filters.limit, 10) || 25));
635
+ const offset = (page - 1) * limit;
636
+
637
+ let baseQuery = kx(this.deliveriesTable);
638
+ if (filters.endpointId) {
639
+ baseQuery = baseQuery.where({ endpoint_id: filters.endpointId });
640
+ }
641
+ if (filters.event) {
642
+ baseQuery = baseQuery.where('event', 'like', `%${filters.event}%`);
643
+ }
644
+ if (filters.success !== undefined && filters.success !== null && filters.success !== '') {
645
+ baseQuery = baseQuery.where({ success: filters.success === true || filters.success === 'true' });
646
+ }
647
+
648
+ const countRes = await baseQuery.clone().count({ count: '*' });
649
+ let total = 0;
650
+ if (Array.isArray(countRes) && countRes.length > 0) {
651
+ total = parseInt(countRes[0].count || countRes[0]['count(*)'] || 0, 10);
652
+ }
653
+
654
+ const items = await baseQuery
655
+ .clone()
656
+ .select('*')
657
+ .orderBy('created_at', 'desc')
658
+ .limit(limit)
659
+ .offset(offset);
660
+
661
+ return {
662
+ items,
663
+ total,
664
+ page,
665
+ limit,
666
+ pages: Math.ceil(total / limit),
667
+ };
668
+ }
669
+
670
+ /**
671
+ * Get single delivery record by ID
672
+ *
673
+ * @param {number|string} id
674
+ * @returns {Promise<Object|null>}
675
+ */
676
+ async getDelivery(id) {
677
+ const kx = this.resolveKnex();
678
+ if (!kx) return null;
679
+ const row = await kx(this.deliveriesTable).where({ id }).first();
680
+ return row || null;
681
+ }
682
+
683
+ /**
684
+ * Purge old delivery logs
685
+ *
686
+ * @param {Object} [options]
687
+ * @param {number} [options.days=30]
688
+ * @returns {Promise<number>} Number of deleted rows
689
+ */
690
+ async purgeDeliveries(options = {}) {
691
+ const kx = this.resolveKnex();
692
+ if (!kx) return 0;
693
+
694
+ const days = typeof options.days === 'number' ? options.days : 30;
695
+ const cutoffDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
696
+
697
+ const deleted = await kx(this.deliveriesTable)
698
+ .where('created_at', '<', cutoffDate)
699
+ .delete();
700
+
701
+ return deleted;
702
+ }
703
+ }
704
+
705
+ module.exports = {
706
+ WebhookDispatcher,
707
+ generateDeliveryId,
708
+ };