convex-cms 0.0.1 → 0.0.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.
- package/LICENSE +190 -0
- package/README.md +99 -0
- package/admin-dist/nitro.json +15 -0
- package/admin-dist/public/assets/CmsEmptyState-CRswfTzk.js +5 -0
- package/admin-dist/public/assets/CmsPageHeader-CirpXndm.js +1 -0
- package/admin-dist/public/assets/CmsStatusBadge-CbEUpQu-.js +1 -0
- package/admin-dist/public/assets/CmsToolbar-BI2nZOXp.js +1 -0
- package/admin-dist/public/assets/ContentEntryEditor-CBeCyK_m.js +4 -0
- package/admin-dist/public/assets/ErrorState-BIVaWmom.js +1 -0
- package/admin-dist/public/assets/TaxonomyFilter-ChaY6Y_x.js +1 -0
- package/admin-dist/public/assets/_contentTypeId-DQ8k_Rvw.js +1 -0
- package/admin-dist/public/assets/_entryId-CKU_glsK.js +1 -0
- package/admin-dist/public/assets/alert-BXjTqrwQ.js +1 -0
- package/admin-dist/public/assets/badge-hvUOzpVZ.js +1 -0
- package/admin-dist/public/assets/circle-check-big-CF_pR17r.js +1 -0
- package/admin-dist/public/assets/command-DU82cJlt.js +1 -0
- package/admin-dist/public/assets/content-_LXl3pp7.js +1 -0
- package/admin-dist/public/assets/content-types-KjxaXGxY.js +2 -0
- package/admin-dist/public/assets/globals-CS6BZ0zp.css +1 -0
- package/admin-dist/public/assets/index-DNGIZHL-.js +1 -0
- package/admin-dist/public/assets/label-KNtpL71g.js +1 -0
- package/admin-dist/public/assets/link-2-Bw2aI4V4.js +1 -0
- package/admin-dist/public/assets/list-sYepHjt_.js +1 -0
- package/admin-dist/public/assets/main-CKj5yfEi.js +97 -0
- package/admin-dist/public/assets/media-Bkrkffm7.js +1 -0
- package/admin-dist/public/assets/new._contentTypeId-C3LstjNs.js +1 -0
- package/admin-dist/public/assets/plus-DUn8v_Xf.js +1 -0
- package/admin-dist/public/assets/rotate-ccw-DJEoHcRI.js +1 -0
- package/admin-dist/public/assets/scroll-area-DfIlT0in.js +1 -0
- package/admin-dist/public/assets/search-MuAUDJKR.js +1 -0
- package/admin-dist/public/assets/select-BD29IXCI.js +1 -0
- package/admin-dist/public/assets/settings-DmMyn_6A.js +1 -0
- package/admin-dist/public/assets/switch-h3Rrnl5i.js +1 -0
- package/admin-dist/public/assets/tabs-imc8h-Dp.js +1 -0
- package/admin-dist/public/assets/taxonomies-dAsrT65H.js +1 -0
- package/admin-dist/public/assets/textarea-BTy7nwzR.js +1 -0
- package/admin-dist/public/assets/trash-SAWKZZHv.js +1 -0
- package/admin-dist/public/assets/triangle-alert-E52Vfeuh.js +1 -0
- package/admin-dist/public/assets/useBreadcrumbLabel-BECBMCzM.js +1 -0
- package/admin-dist/public/assets/usePermissions-Basjs9BT.js +1 -0
- package/admin-dist/public/favicon.ico +0 -0
- package/admin-dist/server/_chunks/_libs/@date-fns/tz.mjs +217 -0
- package/admin-dist/server/_chunks/_libs/@floating-ui/core.mjs +719 -0
- package/admin-dist/server/_chunks/_libs/@floating-ui/dom.mjs +622 -0
- package/admin-dist/server/_chunks/_libs/@floating-ui/react-dom.mjs +292 -0
- package/admin-dist/server/_chunks/_libs/@floating-ui/utils.mjs +320 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/number.mjs +6 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/primitive.mjs +11 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-arrow.mjs +23 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-avatar.mjs +119 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-checkbox.mjs +270 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-collection.mjs +69 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-compose-refs.mjs +39 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-context.mjs +137 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-dialog.mjs +325 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-direction.mjs +9 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-dismissable-layer.mjs +210 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-dropdown-menu.mjs +253 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-focus-guards.mjs +29 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-focus-scope.mjs +206 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-id.mjs +14 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-label.mjs +23 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-menu.mjs +812 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-popover.mjs +300 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-popper.mjs +286 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-portal.mjs +16 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-presence.mjs +128 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-primitive.mjs +141 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-roving-focus.mjs +224 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-scroll-area.mjs +721 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-select.mjs +1163 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-separator.mjs +28 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-slot.mjs +601 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-switch.mjs +152 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-tabs.mjs +189 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-callback-ref.mjs +11 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-controllable-state.mjs +69 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-effect-event.mjs +1 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-escape-keydown.mjs +17 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-is-hydrated.mjs +15 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-layout-effect.mjs +6 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-previous.mjs +14 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-size.mjs +39 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-visually-hidden.mjs +33 -0
- package/admin-dist/server/_chunks/_libs/@tanstack/history.mjs +409 -0
- package/admin-dist/server/_chunks/_libs/@tanstack/react-router.mjs +1711 -0
- package/admin-dist/server/_chunks/_libs/@tanstack/react-store.mjs +56 -0
- package/admin-dist/server/_chunks/_libs/@tanstack/router-core.mjs +4829 -0
- package/admin-dist/server/_chunks/_libs/@tanstack/store.mjs +134 -0
- package/admin-dist/server/_chunks/_libs/react-dom.mjs +10781 -0
- package/admin-dist/server/_chunks/_libs/react.mjs +513 -0
- package/admin-dist/server/_libs/aria-hidden.mjs +122 -0
- package/admin-dist/server/_libs/class-variance-authority.mjs +44 -0
- package/admin-dist/server/_libs/clsx.mjs +16 -0
- package/admin-dist/server/_libs/cmdk.mjs +315 -0
- package/admin-dist/server/_libs/convex.mjs +4841 -0
- package/admin-dist/server/_libs/cookie-es.mjs +58 -0
- package/admin-dist/server/_libs/croner.mjs +1 -0
- package/admin-dist/server/_libs/crossws.mjs +1 -0
- package/admin-dist/server/_libs/date-fns.mjs +1716 -0
- package/admin-dist/server/_libs/detect-node-es.mjs +1 -0
- package/admin-dist/server/_libs/get-nonce.mjs +9 -0
- package/admin-dist/server/_libs/h3-v2.mjs +277 -0
- package/admin-dist/server/_libs/h3.mjs +401 -0
- package/admin-dist/server/_libs/hookable.mjs +1 -0
- package/admin-dist/server/_libs/isbot.mjs +20 -0
- package/admin-dist/server/_libs/lucide-react.mjs +850 -0
- package/admin-dist/server/_libs/ohash.mjs +1 -0
- package/admin-dist/server/_libs/react-day-picker.mjs +2201 -0
- package/admin-dist/server/_libs/react-remove-scroll-bar.mjs +82 -0
- package/admin-dist/server/_libs/react-remove-scroll.mjs +328 -0
- package/admin-dist/server/_libs/react-style-singleton.mjs +69 -0
- package/admin-dist/server/_libs/rou3.mjs +8 -0
- package/admin-dist/server/_libs/seroval-plugins.mjs +58 -0
- package/admin-dist/server/_libs/seroval.mjs +1765 -0
- package/admin-dist/server/_libs/srvx.mjs +719 -0
- package/admin-dist/server/_libs/tailwind-merge.mjs +3010 -0
- package/admin-dist/server/_libs/tiny-invariant.mjs +12 -0
- package/admin-dist/server/_libs/tiny-warning.mjs +5 -0
- package/admin-dist/server/_libs/tslib.mjs +39 -0
- package/admin-dist/server/_libs/ufo.mjs +54 -0
- package/admin-dist/server/_libs/unctx.mjs +1 -0
- package/admin-dist/server/_libs/unstorage.mjs +1 -0
- package/admin-dist/server/_libs/use-callback-ref.mjs +66 -0
- package/admin-dist/server/_libs/use-sidecar.mjs +106 -0
- package/admin-dist/server/_libs/use-sync-external-store.mjs +139 -0
- package/admin-dist/server/_libs/zod.mjs +4223 -0
- package/admin-dist/server/_ssr/CmsEmptyState-DU7-7-mV.mjs +290 -0
- package/admin-dist/server/_ssr/CmsPageHeader-CseW0AHm.mjs +24 -0
- package/admin-dist/server/_ssr/CmsStatusBadge-B_pi4KCp.mjs +127 -0
- package/admin-dist/server/_ssr/CmsToolbar-X75ex6ek.mjs +49 -0
- package/admin-dist/server/_ssr/ContentEntryEditor-CepusRsA.mjs +3720 -0
- package/admin-dist/server/_ssr/ErrorState-cI-bKLez.mjs +89 -0
- package/admin-dist/server/_ssr/TaxonomyFilter-Bwrq0-cz.mjs +188 -0
- package/admin-dist/server/_ssr/_contentTypeId-BqYKEcLr.mjs +379 -0
- package/admin-dist/server/_ssr/_entryId-CRfnqeDf.mjs +161 -0
- package/admin-dist/server/_ssr/_tanstack-start-manifest_v-BwDlABVk.mjs +4 -0
- package/admin-dist/server/_ssr/alert-CVt45UUP.mjs +92 -0
- package/admin-dist/server/_ssr/badge-6BsP37vG.mjs +125 -0
- package/admin-dist/server/_ssr/command-fy8epIKf.mjs +128 -0
- package/admin-dist/server/_ssr/config.server-D7JHDcDv.mjs +117 -0
- package/admin-dist/server/_ssr/content-B5RhL7uW.mjs +532 -0
- package/admin-dist/server/_ssr/content-types-BIOqCQYN.mjs +1166 -0
- package/admin-dist/server/_ssr/index-DHSHDPt1.mjs +193 -0
- package/admin-dist/server/_ssr/index.mjs +1275 -0
- package/admin-dist/server/_ssr/label-C8Dko1j7.mjs +22 -0
- package/admin-dist/server/_ssr/media-CSx3XttC.mjs +1832 -0
- package/admin-dist/server/_ssr/new._contentTypeId-DzanEZQM.mjs +144 -0
- package/admin-dist/server/_ssr/router-DDWcF-kt.mjs +1556 -0
- package/admin-dist/server/_ssr/scroll-area-bjPYwhXN.mjs +59 -0
- package/admin-dist/server/_ssr/select-BUhDDf4T.mjs +142 -0
- package/admin-dist/server/_ssr/settings-DAsxnw2q.mjs +348 -0
- package/admin-dist/server/_ssr/start-HYkvq4Ni.mjs +4 -0
- package/admin-dist/server/_ssr/switch-BgyRtQ1Z.mjs +31 -0
- package/admin-dist/server/_ssr/tabs-DzMdRB1A.mjs +628 -0
- package/admin-dist/server/_ssr/taxonomies-C8j8g5Q5.mjs +915 -0
- package/admin-dist/server/_ssr/textarea-9jNeYJSc.mjs +18 -0
- package/admin-dist/server/_ssr/trash-DYMxwhZB.mjs +291 -0
- package/admin-dist/server/_ssr/useBreadcrumbLabel-FNSAr2Ha.mjs +16 -0
- package/admin-dist/server/_ssr/usePermissions-BJGGahrJ.mjs +68 -0
- package/admin-dist/server/favicon.ico +0 -0
- package/admin-dist/server/index.mjs +627 -0
- package/dist/cli/index.js +0 -0
- package/dist/client/admin-config.d.ts +0 -1
- package/dist/client/admin-config.d.ts.map +1 -1
- package/dist/client/admin-config.js +0 -1
- package/dist/client/admin-config.js.map +1 -1
- package/dist/client/adminApi.d.ts.map +1 -1
- package/dist/client/agentTools.d.ts +1237 -135
- package/dist/client/agentTools.d.ts.map +1 -1
- package/dist/client/agentTools.js +33 -9
- package/dist/client/agentTools.js.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +9 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/mediaAssets.d.ts +35 -0
- package/dist/component/mediaAssets.d.ts.map +1 -1
- package/dist/component/mediaAssets.js +81 -0
- package/dist/component/mediaAssets.js.map +1 -1
- package/dist/test.d.ts.map +1 -1
- package/dist/test.js +2 -1
- package/dist/test.js.map +1 -1
- package/package.json +24 -9
- package/dist/component/auditLog.d.ts +0 -410
- package/dist/component/auditLog.d.ts.map +0 -1
- package/dist/component/auditLog.js +0 -607
- package/dist/component/auditLog.js.map +0 -1
- package/dist/component/types.d.ts +0 -4
- package/dist/component/types.d.ts.map +0 -1
- package/dist/component/types.js +0 -2
- package/dist/component/types.js.map +0 -1
- package/src/cli/commands/admin.ts +0 -104
- package/src/cli/index.ts +0 -21
- package/src/cli/utils/detectConvexUrl.ts +0 -54
- package/src/cli/utils/openBrowser.ts +0 -16
- package/src/client/admin-config.ts +0 -138
- package/src/client/adminApi.ts +0 -942
- package/src/client/agentTools.ts +0 -1311
- package/src/client/argTypes.ts +0 -316
- package/src/client/field-types.ts +0 -187
- package/src/client/index.ts +0 -1301
- package/src/client/queryBuilder.ts +0 -1100
- package/src/client/schema/codegen.ts +0 -500
- package/src/client/schema/defineContentType.ts +0 -501
- package/src/client/schema/index.ts +0 -169
- package/src/client/schema/schemaDrift.ts +0 -574
- package/src/client/schema/typedClient.ts +0 -688
- package/src/client/schema/types.ts +0 -666
- package/src/client/types.ts +0 -723
- package/src/client/workflows.ts +0 -141
- package/src/client/wrapper.ts +0 -4304
- package/src/component/_generated/api.ts +0 -140
- package/src/component/_generated/component.ts +0 -5029
- package/src/component/_generated/dataModel.ts +0 -60
- package/src/component/_generated/server.ts +0 -156
- package/src/component/authorization.ts +0 -647
- package/src/component/authorizationHooks.ts +0 -668
- package/src/component/bulkOperations.ts +0 -687
- package/src/component/contentEntries.ts +0 -1976
- package/src/component/contentEntryMutations.ts +0 -1223
- package/src/component/contentEntryValidation.ts +0 -707
- package/src/component/contentLock.ts +0 -550
- package/src/component/contentTypeMigration.ts +0 -1064
- package/src/component/contentTypeMutations.ts +0 -969
- package/src/component/contentTypes.ts +0 -346
- package/src/component/convex.config.ts +0 -44
- package/src/component/documentTypes.ts +0 -240
- package/src/component/eventEmitter.ts +0 -485
- package/src/component/exportImport.ts +0 -1169
- package/src/component/index.ts +0 -491
- package/src/component/lib/deepReferenceResolver.ts +0 -999
- package/src/component/lib/errors.ts +0 -816
- package/src/component/lib/index.ts +0 -145
- package/src/component/lib/mediaReferenceResolver.ts +0 -495
- package/src/component/lib/metadataExtractor.ts +0 -792
- package/src/component/lib/mutationAuth.ts +0 -199
- package/src/component/lib/queries.ts +0 -79
- package/src/component/lib/ragContentChunker.ts +0 -1371
- package/src/component/lib/referenceResolver.ts +0 -430
- package/src/component/lib/slugGenerator.ts +0 -262
- package/src/component/lib/slugUniqueness.ts +0 -333
- package/src/component/lib/softDelete.ts +0 -44
- package/src/component/localeFallbackChain.ts +0 -673
- package/src/component/localeFields.ts +0 -896
- package/src/component/mediaAssetMutations.ts +0 -725
- package/src/component/mediaAssets.ts +0 -932
- package/src/component/mediaFolderMutations.ts +0 -1046
- package/src/component/mediaUploadMutations.ts +0 -224
- package/src/component/mediaVariantMutations.ts +0 -900
- package/src/component/mediaVariants.ts +0 -793
- package/src/component/ragContentIndexer.ts +0 -1067
- package/src/component/rateLimitHooks.ts +0 -572
- package/src/component/roles.ts +0 -1360
- package/src/component/scheduledPublish.ts +0 -358
- package/src/component/schema.ts +0 -617
- package/src/component/taxonomies.ts +0 -949
- package/src/component/taxonomyMutations.ts +0 -1210
- package/src/component/trash.ts +0 -724
- package/src/component/userContext.ts +0 -898
- package/src/component/validation.ts +0 -1388
- package/src/component/validators.ts +0 -949
- package/src/component/versionMutations.ts +0 -392
- package/src/component/webhookTrigger.ts +0 -1922
- package/src/react/index.ts +0 -898
- package/src/test.ts +0 -1580
|
@@ -1,1922 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Webhook Trigger Module
|
|
3
|
-
*
|
|
4
|
-
* Scheduled function to process content events and trigger configured webhooks.
|
|
5
|
-
* Supports retry logic with exponential backoff and delivery confirmation.
|
|
6
|
-
*
|
|
7
|
-
* Architecture:
|
|
8
|
-
* 1. Events are captured via the event emitter system (cmsEvents table)
|
|
9
|
-
* 2. A background processor polls for unprocessed events
|
|
10
|
-
* 3. For each event, matching webhook configurations are found
|
|
11
|
-
* 4. Delivery records are created and HTTP requests are dispatched
|
|
12
|
-
* 5. Success/failure is tracked with automatic retry for failures
|
|
13
|
-
*
|
|
14
|
-
* Security Features:
|
|
15
|
-
* - HMAC-SHA256 signature generation for payload verification
|
|
16
|
-
* - Configurable timeout to prevent hanging requests
|
|
17
|
-
* - Secret keys never exposed in API responses
|
|
18
|
-
*
|
|
19
|
-
* Retry Behavior:
|
|
20
|
-
* - Exponential backoff: 1min, 5min, 15min, 1hr, 4hr (configurable)
|
|
21
|
-
* - Automatic retry scheduling via Convex scheduler
|
|
22
|
-
* - Maximum retry limit per webhook configuration
|
|
23
|
-
*
|
|
24
|
-
* @module
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
import { v } from "convex/values";
|
|
28
|
-
import { isDeleted } from "./lib/softDelete.js";
|
|
29
|
-
import {
|
|
30
|
-
mutation,
|
|
31
|
-
query,
|
|
32
|
-
internalMutation,
|
|
33
|
-
internalQuery,
|
|
34
|
-
// action,
|
|
35
|
-
internalAction,
|
|
36
|
-
} from "./_generated/server.js";
|
|
37
|
-
import {
|
|
38
|
-
internal,
|
|
39
|
-
// api
|
|
40
|
-
} from "./_generated/api.js";
|
|
41
|
-
|
|
42
|
-
// =============================================================================
|
|
43
|
-
// Types
|
|
44
|
-
// =============================================================================
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Webhook delivery status.
|
|
48
|
-
*/
|
|
49
|
-
export type WebhookDeliveryStatus =
|
|
50
|
-
| "pending"
|
|
51
|
-
| "processing"
|
|
52
|
-
| "delivered"
|
|
53
|
-
| "failed"
|
|
54
|
-
| "retrying";
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Configuration for the webhook processor.
|
|
58
|
-
*/
|
|
59
|
-
export interface WebhookProcessorConfig {
|
|
60
|
-
/**
|
|
61
|
-
* Maximum number of events to process in a single batch.
|
|
62
|
-
* @default 50
|
|
63
|
-
*/
|
|
64
|
-
batchSize?: number;
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Interval in milliseconds for the background polling job.
|
|
68
|
-
* @default 60000 (1 minute)
|
|
69
|
-
*/
|
|
70
|
-
pollingIntervalMs?: number;
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Default timeout for webhook requests in milliseconds.
|
|
74
|
-
* @default 30000 (30 seconds)
|
|
75
|
-
*/
|
|
76
|
-
defaultTimeoutMs?: number;
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Default maximum retry attempts.
|
|
80
|
-
* @default 5
|
|
81
|
-
*/
|
|
82
|
-
defaultMaxRetries?: number;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Result of processing webhook events.
|
|
87
|
-
*/
|
|
88
|
-
export interface ProcessWebhooksResult {
|
|
89
|
-
/** Number of events processed */
|
|
90
|
-
eventsProcessed: number;
|
|
91
|
-
/** Number of deliveries queued */
|
|
92
|
-
deliveriesQueued: number;
|
|
93
|
-
/** Number of deliveries sent successfully */
|
|
94
|
-
deliveriesSucceeded: number;
|
|
95
|
-
/** Number of deliveries that failed (will retry or exhausted) */
|
|
96
|
-
deliveriesFailed: number;
|
|
97
|
-
/** Whether there are more events to process */
|
|
98
|
-
hasMore: boolean;
|
|
99
|
-
/** Errors encountered */
|
|
100
|
-
errors: Array<{ webhookId: string; eventId: string; error: string }>;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Webhook payload structure sent to endpoints.
|
|
105
|
-
*/
|
|
106
|
-
export interface WebhookPayload {
|
|
107
|
-
/** Unique delivery ID for idempotency */
|
|
108
|
-
deliveryId: string;
|
|
109
|
-
/** Event type (e.g., "contentEntry.published") */
|
|
110
|
-
eventType: string;
|
|
111
|
-
/** Resource type that triggered the event */
|
|
112
|
-
resourceType: string;
|
|
113
|
-
/** ID of the affected resource */
|
|
114
|
-
resourceId: string;
|
|
115
|
-
/** Action performed on the resource */
|
|
116
|
-
action: string;
|
|
117
|
-
/** Event-specific payload data */
|
|
118
|
-
data: unknown;
|
|
119
|
-
/** ISO timestamp when the event occurred */
|
|
120
|
-
timestamp: string;
|
|
121
|
-
/** User who triggered the event (if known) */
|
|
122
|
-
userId?: string;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Statistics about webhook deliveries.
|
|
127
|
-
*/
|
|
128
|
-
export interface WebhookStats {
|
|
129
|
-
/** Total webhooks configured */
|
|
130
|
-
totalWebhooks: number;
|
|
131
|
-
/** Active webhooks */
|
|
132
|
-
activeWebhooks: number;
|
|
133
|
-
/** Pending deliveries */
|
|
134
|
-
pendingDeliveries: number;
|
|
135
|
-
/** Deliveries awaiting retry */
|
|
136
|
-
retryingDeliveries: number;
|
|
137
|
-
/** Deliveries in the last 24 hours */
|
|
138
|
-
deliveriesLast24h: number;
|
|
139
|
-
/** Success rate in the last 24 hours */
|
|
140
|
-
successRateLast24h: number;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// =============================================================================
|
|
144
|
-
// Constants
|
|
145
|
-
// =============================================================================
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Default configuration values.
|
|
149
|
-
*/
|
|
150
|
-
export const DEFAULT_WEBHOOK_CONFIG = {
|
|
151
|
-
batchSize: 50,
|
|
152
|
-
pollingIntervalMs: 60000, // 1 minute
|
|
153
|
-
defaultTimeoutMs: 30000, // 30 seconds
|
|
154
|
-
defaultMaxRetries: 5,
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Exponential backoff delays in milliseconds.
|
|
159
|
-
* Attempt 1: 1 minute
|
|
160
|
-
* Attempt 2: 5 minutes
|
|
161
|
-
* Attempt 3: 15 minutes
|
|
162
|
-
* Attempt 4: 1 hour
|
|
163
|
-
* Attempt 5: 4 hours
|
|
164
|
-
*/
|
|
165
|
-
export const RETRY_DELAYS_MS = [
|
|
166
|
-
1 * 60 * 1000, // 1 minute
|
|
167
|
-
5 * 60 * 1000, // 5 minutes
|
|
168
|
-
15 * 60 * 1000, // 15 minutes
|
|
169
|
-
60 * 60 * 1000, // 1 hour
|
|
170
|
-
4 * 60 * 60 * 1000, // 4 hours
|
|
171
|
-
];
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Maximum response body length to store (truncated for storage).
|
|
175
|
-
*/
|
|
176
|
-
const MAX_RESPONSE_BODY_LENGTH = 1000;
|
|
177
|
-
|
|
178
|
-
// =============================================================================
|
|
179
|
-
// Validators
|
|
180
|
-
// =============================================================================
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Validator for webhook delivery status.
|
|
184
|
-
*/
|
|
185
|
-
export const webhookDeliveryStatusValidator = v.union(
|
|
186
|
-
v.literal("pending"),
|
|
187
|
-
v.literal("processing"),
|
|
188
|
-
v.literal("delivered"),
|
|
189
|
-
v.literal("failed"),
|
|
190
|
-
v.literal("retrying"),
|
|
191
|
-
);
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Validator for creating a webhook configuration.
|
|
195
|
-
*/
|
|
196
|
-
export const createWebhookArgs = v.object({
|
|
197
|
-
/** Human-readable name for the webhook */
|
|
198
|
-
name: v.string(),
|
|
199
|
-
/** Optional description */
|
|
200
|
-
description: v.optional(v.string()),
|
|
201
|
-
/** Target URL (must be HTTPS in production) */
|
|
202
|
-
url: v.string(),
|
|
203
|
-
/** Secret for HMAC signature (optional but recommended) */
|
|
204
|
-
secret: v.optional(v.string()),
|
|
205
|
-
/** Event types to subscribe to (e.g., ["contentEntry.published"]) */
|
|
206
|
-
eventTypes: v.array(v.string()),
|
|
207
|
-
/** Filter by resource types (optional) */
|
|
208
|
-
resourceTypes: v.optional(
|
|
209
|
-
v.array(
|
|
210
|
-
v.union(
|
|
211
|
-
v.literal("contentEntry"),
|
|
212
|
-
v.literal("contentType"),
|
|
213
|
-
v.literal("mediaAsset"),
|
|
214
|
-
v.literal("mediaFolder"),
|
|
215
|
-
),
|
|
216
|
-
),
|
|
217
|
-
),
|
|
218
|
-
/** Filter by content types (optional, for contentEntry events) */
|
|
219
|
-
contentTypes: v.optional(v.array(v.string())),
|
|
220
|
-
/** Additional HTTP headers */
|
|
221
|
-
headers: v.optional(v.any()),
|
|
222
|
-
/** Whether the webhook is enabled (default: true) */
|
|
223
|
-
enabled: v.optional(v.boolean()),
|
|
224
|
-
/** Maximum retry attempts (default: 5) */
|
|
225
|
-
maxRetries: v.optional(v.number()),
|
|
226
|
-
/** Request timeout in ms (default: 30000) */
|
|
227
|
-
timeoutMs: v.optional(v.number()),
|
|
228
|
-
/** User creating the webhook */
|
|
229
|
-
createdBy: v.optional(v.string()),
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Validator for updating a webhook configuration.
|
|
234
|
-
*/
|
|
235
|
-
export const updateWebhookArgs = v.object({
|
|
236
|
-
/** Webhook ID to update */
|
|
237
|
-
id: v.id("webhookConfigs"),
|
|
238
|
-
/** New name */
|
|
239
|
-
name: v.optional(v.string()),
|
|
240
|
-
/** New description */
|
|
241
|
-
description: v.optional(v.string()),
|
|
242
|
-
/** New URL */
|
|
243
|
-
url: v.optional(v.string()),
|
|
244
|
-
/** New secret (set to empty string to remove) */
|
|
245
|
-
secret: v.optional(v.string()),
|
|
246
|
-
/** New event types filter */
|
|
247
|
-
eventTypes: v.optional(v.array(v.string())),
|
|
248
|
-
/** New resource types filter */
|
|
249
|
-
resourceTypes: v.optional(
|
|
250
|
-
v.array(
|
|
251
|
-
v.union(
|
|
252
|
-
v.literal("contentEntry"),
|
|
253
|
-
v.literal("contentType"),
|
|
254
|
-
v.literal("mediaAsset"),
|
|
255
|
-
v.literal("mediaFolder"),
|
|
256
|
-
),
|
|
257
|
-
),
|
|
258
|
-
),
|
|
259
|
-
/** New content types filter */
|
|
260
|
-
contentTypes: v.optional(v.array(v.string())),
|
|
261
|
-
/** New headers */
|
|
262
|
-
headers: v.optional(v.any()),
|
|
263
|
-
/** Enable/disable the webhook */
|
|
264
|
-
enabled: v.optional(v.boolean()),
|
|
265
|
-
/** New max retries */
|
|
266
|
-
maxRetries: v.optional(v.number()),
|
|
267
|
-
/** New timeout */
|
|
268
|
-
timeoutMs: v.optional(v.number()),
|
|
269
|
-
/** User performing the update */
|
|
270
|
-
updatedBy: v.optional(v.string()),
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* Validator for deleting a webhook configuration.
|
|
275
|
-
*/
|
|
276
|
-
export const deleteWebhookArgs = v.object({
|
|
277
|
-
/** Webhook ID to delete */
|
|
278
|
-
id: v.id("webhookConfigs"),
|
|
279
|
-
/** Hard delete (true) or soft delete (false, default) */
|
|
280
|
-
hardDelete: v.optional(v.boolean()),
|
|
281
|
-
/** User performing the deletion */
|
|
282
|
-
deletedBy: v.optional(v.string()),
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Validator for webhook configuration document (return type).
|
|
287
|
-
*/
|
|
288
|
-
export const webhookConfigDoc = v.object({
|
|
289
|
-
_id: v.id("webhookConfigs"),
|
|
290
|
-
_creationTime: v.number(),
|
|
291
|
-
name: v.string(),
|
|
292
|
-
description: v.optional(v.string()),
|
|
293
|
-
url: v.string(),
|
|
294
|
-
// Note: secret is NOT included in return type for security
|
|
295
|
-
eventTypes: v.array(v.string()),
|
|
296
|
-
resourceTypes: v.optional(
|
|
297
|
-
v.array(
|
|
298
|
-
v.union(
|
|
299
|
-
v.literal("contentEntry"),
|
|
300
|
-
v.literal("contentType"),
|
|
301
|
-
v.literal("mediaAsset"),
|
|
302
|
-
v.literal("mediaFolder"),
|
|
303
|
-
),
|
|
304
|
-
),
|
|
305
|
-
),
|
|
306
|
-
contentTypes: v.optional(v.array(v.string())),
|
|
307
|
-
headers: v.optional(v.any()),
|
|
308
|
-
enabled: v.boolean(),
|
|
309
|
-
maxRetries: v.optional(v.number()),
|
|
310
|
-
timeoutMs: v.optional(v.number()),
|
|
311
|
-
deletedAt: v.optional(v.number()),
|
|
312
|
-
createdBy: v.optional(v.string()),
|
|
313
|
-
updatedBy: v.optional(v.string()),
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* Validator for webhook delivery document.
|
|
318
|
-
*/
|
|
319
|
-
export const webhookDeliveryDoc = v.object({
|
|
320
|
-
_id: v.id("webhookDeliveries"),
|
|
321
|
-
_creationTime: v.number(),
|
|
322
|
-
webhookId: v.id("webhookConfigs"),
|
|
323
|
-
eventId: v.id("cmsEvents"),
|
|
324
|
-
eventType: v.string(),
|
|
325
|
-
status: webhookDeliveryStatusValidator,
|
|
326
|
-
attemptCount: v.number(),
|
|
327
|
-
maxAttempts: v.number(),
|
|
328
|
-
lastAttemptAt: v.optional(v.number()),
|
|
329
|
-
nextRetryAt: v.optional(v.number()),
|
|
330
|
-
lastStatusCode: v.optional(v.number()),
|
|
331
|
-
lastError: v.optional(v.string()),
|
|
332
|
-
lastResponseBody: v.optional(v.string()),
|
|
333
|
-
lastDurationMs: v.optional(v.number()),
|
|
334
|
-
payload: v.any(),
|
|
335
|
-
deliveredAt: v.optional(v.number()),
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
// =============================================================================
|
|
339
|
-
// Webhook Configuration CRUD
|
|
340
|
-
// =============================================================================
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* Create a new webhook configuration.
|
|
344
|
-
*
|
|
345
|
-
* @example
|
|
346
|
-
* ```typescript
|
|
347
|
-
* const webhookId = await ctx.runMutation(api.webhookTrigger.createWebhook, {
|
|
348
|
-
* name: "CDN Invalidation",
|
|
349
|
-
* url: "https://api.example.com/webhooks/cms",
|
|
350
|
-
* secret: "my-secret-key",
|
|
351
|
-
* eventTypes: ["contentEntry.published", "contentEntry.deleted"],
|
|
352
|
-
* });
|
|
353
|
-
* ```
|
|
354
|
-
*/
|
|
355
|
-
export const createWebhook = mutation({
|
|
356
|
-
args: createWebhookArgs.fields,
|
|
357
|
-
returns: v.id("webhookConfigs"),
|
|
358
|
-
handler: async (ctx, args) => {
|
|
359
|
-
const {
|
|
360
|
-
name,
|
|
361
|
-
description,
|
|
362
|
-
url,
|
|
363
|
-
secret,
|
|
364
|
-
eventTypes,
|
|
365
|
-
resourceTypes,
|
|
366
|
-
contentTypes,
|
|
367
|
-
headers,
|
|
368
|
-
enabled = true,
|
|
369
|
-
maxRetries = DEFAULT_WEBHOOK_CONFIG.defaultMaxRetries,
|
|
370
|
-
timeoutMs = DEFAULT_WEBHOOK_CONFIG.defaultTimeoutMs,
|
|
371
|
-
createdBy,
|
|
372
|
-
} = args;
|
|
373
|
-
|
|
374
|
-
// Validate URL format
|
|
375
|
-
try {
|
|
376
|
-
const parsedUrl = new URL(url);
|
|
377
|
-
// Warn about non-HTTPS URLs (but allow for development)
|
|
378
|
-
if (parsedUrl.protocol !== "https:") {
|
|
379
|
-
console.warn(
|
|
380
|
-
`Webhook URL is not HTTPS: ${url}. ` +
|
|
381
|
-
"HTTPS is required for production security.",
|
|
382
|
-
);
|
|
383
|
-
}
|
|
384
|
-
} catch {
|
|
385
|
-
throw new Error(`Invalid webhook URL: ${url}`);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// Validate event types format
|
|
389
|
-
for (const eventType of eventTypes) {
|
|
390
|
-
if (!eventType.includes(".")) {
|
|
391
|
-
throw new Error(
|
|
392
|
-
`Invalid event type format: "${eventType}". ` +
|
|
393
|
-
'Expected format: "resourceType.action" (e.g., "contentEntry.published")',
|
|
394
|
-
);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
const webhookId = await ctx.db.insert("webhookConfigs", {
|
|
399
|
-
name,
|
|
400
|
-
description,
|
|
401
|
-
url,
|
|
402
|
-
secret,
|
|
403
|
-
eventTypes,
|
|
404
|
-
resourceTypes,
|
|
405
|
-
contentTypes,
|
|
406
|
-
headers,
|
|
407
|
-
enabled,
|
|
408
|
-
maxRetries,
|
|
409
|
-
timeoutMs,
|
|
410
|
-
createdBy,
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
return webhookId;
|
|
414
|
-
},
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
/**
|
|
418
|
-
* Update an existing webhook configuration.
|
|
419
|
-
*/
|
|
420
|
-
export const updateWebhook = mutation({
|
|
421
|
-
args: updateWebhookArgs.fields,
|
|
422
|
-
returns: webhookConfigDoc,
|
|
423
|
-
handler: async (ctx, args) => {
|
|
424
|
-
const { id, ...updates } = args;
|
|
425
|
-
|
|
426
|
-
const existing = await ctx.db.get(id);
|
|
427
|
-
if (!existing) {
|
|
428
|
-
throw new Error(`Webhook not found: ${id}`);
|
|
429
|
-
}
|
|
430
|
-
if (isDeleted(existing)) {
|
|
431
|
-
throw new Error(`Webhook has been deleted: ${id}`);
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// Validate URL if being updated
|
|
435
|
-
if (updates.url) {
|
|
436
|
-
try {
|
|
437
|
-
new URL(updates.url);
|
|
438
|
-
} catch {
|
|
439
|
-
throw new Error(`Invalid webhook URL: ${updates.url}`);
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
// Validate event types if being updated
|
|
444
|
-
if (updates.eventTypes) {
|
|
445
|
-
for (const eventType of updates.eventTypes) {
|
|
446
|
-
if (!eventType.includes(".")) {
|
|
447
|
-
throw new Error(
|
|
448
|
-
`Invalid event type format: "${eventType}". ` +
|
|
449
|
-
'Expected format: "resourceType.action"',
|
|
450
|
-
);
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// Build update object, excluding undefined values
|
|
456
|
-
const updateData: Record<string, unknown> = {};
|
|
457
|
-
for (const [key, value] of Object.entries(updates)) {
|
|
458
|
-
if (value !== undefined) {
|
|
459
|
-
updateData[key] = value;
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
await ctx.db.patch(id, updateData);
|
|
464
|
-
|
|
465
|
-
const updated = await ctx.db.get(id);
|
|
466
|
-
if (!updated) {
|
|
467
|
-
throw new Error("Failed to retrieve updated webhook");
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// Return without secret
|
|
471
|
-
const { secret: _secret, ...safeWebhook } = updated;
|
|
472
|
-
return safeWebhook as typeof updated;
|
|
473
|
-
},
|
|
474
|
-
});
|
|
475
|
-
|
|
476
|
-
/**
|
|
477
|
-
* Delete a webhook configuration.
|
|
478
|
-
*/
|
|
479
|
-
export const deleteWebhook = mutation({
|
|
480
|
-
args: deleteWebhookArgs.fields,
|
|
481
|
-
returns: v.object({
|
|
482
|
-
success: v.boolean(),
|
|
483
|
-
message: v.string(),
|
|
484
|
-
}),
|
|
485
|
-
handler: async (ctx, args) => {
|
|
486
|
-
const { id, hardDelete = false, deletedBy } = args;
|
|
487
|
-
|
|
488
|
-
const webhook = await ctx.db.get(id);
|
|
489
|
-
if (!webhook) {
|
|
490
|
-
throw new Error(`Webhook not found: ${id}`);
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
if (hardDelete) {
|
|
494
|
-
// Delete all delivery records for this webhook
|
|
495
|
-
const deliveries = await ctx.db
|
|
496
|
-
.query("webhookDeliveries")
|
|
497
|
-
.withIndex("by_webhook", (q) => q.eq("webhookId", id))
|
|
498
|
-
.collect();
|
|
499
|
-
|
|
500
|
-
for (const delivery of deliveries) {
|
|
501
|
-
await ctx.db.delete(delivery._id);
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
// Delete the webhook
|
|
505
|
-
await ctx.db.delete(id);
|
|
506
|
-
|
|
507
|
-
return {
|
|
508
|
-
success: true,
|
|
509
|
-
message: `Webhook "${webhook.name}" permanently deleted with ${deliveries.length} delivery records`,
|
|
510
|
-
};
|
|
511
|
-
} else {
|
|
512
|
-
// Soft delete
|
|
513
|
-
await ctx.db.patch(id, {
|
|
514
|
-
deletedAt: Date.now(),
|
|
515
|
-
enabled: false,
|
|
516
|
-
updatedBy: deletedBy,
|
|
517
|
-
});
|
|
518
|
-
|
|
519
|
-
return {
|
|
520
|
-
success: true,
|
|
521
|
-
message: `Webhook "${webhook.name}" soft-deleted`,
|
|
522
|
-
};
|
|
523
|
-
}
|
|
524
|
-
},
|
|
525
|
-
});
|
|
526
|
-
|
|
527
|
-
/**
|
|
528
|
-
* Restore a soft-deleted webhook.
|
|
529
|
-
*/
|
|
530
|
-
export const restoreWebhook = mutation({
|
|
531
|
-
args: {
|
|
532
|
-
id: v.id("webhookConfigs"),
|
|
533
|
-
restoredBy: v.optional(v.string()),
|
|
534
|
-
},
|
|
535
|
-
returns: webhookConfigDoc,
|
|
536
|
-
handler: async (ctx, args) => {
|
|
537
|
-
const { id, restoredBy } = args;
|
|
538
|
-
|
|
539
|
-
const webhook = await ctx.db.get(id);
|
|
540
|
-
if (!webhook) {
|
|
541
|
-
throw new Error(`Webhook not found: ${id}`);
|
|
542
|
-
}
|
|
543
|
-
if (!isDeleted(webhook)) {
|
|
544
|
-
throw new Error(`Webhook is not deleted: ${id}`);
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
await ctx.db.patch(id, {
|
|
548
|
-
deletedAt: undefined,
|
|
549
|
-
updatedBy: restoredBy,
|
|
550
|
-
});
|
|
551
|
-
|
|
552
|
-
const restored = await ctx.db.get(id);
|
|
553
|
-
if (!restored) {
|
|
554
|
-
throw new Error("Failed to retrieve restored webhook");
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
const { secret: _secret, ...safeWebhook } = restored;
|
|
558
|
-
return safeWebhook as typeof restored;
|
|
559
|
-
},
|
|
560
|
-
});
|
|
561
|
-
|
|
562
|
-
/**
|
|
563
|
-
* Get a single webhook configuration by ID.
|
|
564
|
-
* Note: Secret is not returned for security.
|
|
565
|
-
*/
|
|
566
|
-
export const getWebhook = query({
|
|
567
|
-
args: {
|
|
568
|
-
id: v.id("webhookConfigs"),
|
|
569
|
-
includeDeleted: v.optional(v.boolean()),
|
|
570
|
-
},
|
|
571
|
-
returns: v.union(webhookConfigDoc, v.null()),
|
|
572
|
-
handler: async (ctx, args) => {
|
|
573
|
-
const { id, includeDeleted = false } = args;
|
|
574
|
-
|
|
575
|
-
const webhook = await ctx.db.get(id);
|
|
576
|
-
if (!webhook) return null;
|
|
577
|
-
|
|
578
|
-
if (!includeDeleted && isDeleted(webhook)) {
|
|
579
|
-
return null;
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
const { secret: _secret, ...safeWebhook } = webhook;
|
|
583
|
-
return safeWebhook as typeof webhook;
|
|
584
|
-
},
|
|
585
|
-
});
|
|
586
|
-
|
|
587
|
-
/**
|
|
588
|
-
* List all webhook configurations with optional filtering.
|
|
589
|
-
*/
|
|
590
|
-
export const listWebhooks = query({
|
|
591
|
-
args: {
|
|
592
|
-
enabled: v.optional(v.boolean()),
|
|
593
|
-
includeDeleted: v.optional(v.boolean()),
|
|
594
|
-
limit: v.optional(v.number()),
|
|
595
|
-
},
|
|
596
|
-
returns: v.array(webhookConfigDoc),
|
|
597
|
-
handler: async (ctx, args) => {
|
|
598
|
-
const { enabled, includeDeleted = false, limit = 50 } = args;
|
|
599
|
-
|
|
600
|
-
let webhooks;
|
|
601
|
-
|
|
602
|
-
if (enabled !== undefined) {
|
|
603
|
-
webhooks = await ctx.db
|
|
604
|
-
.query("webhookConfigs")
|
|
605
|
-
.withIndex("by_enabled", (q) => q.eq("enabled", enabled))
|
|
606
|
-
.take(limit * 2);
|
|
607
|
-
} else {
|
|
608
|
-
webhooks = await ctx.db
|
|
609
|
-
.query("webhookConfigs")
|
|
610
|
-
.order("desc")
|
|
611
|
-
.take(limit * 2);
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
// Filter deleted
|
|
615
|
-
if (!includeDeleted) {
|
|
616
|
-
webhooks = webhooks.filter((w) => !isDeleted(w));
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
// Remove secrets before returning
|
|
620
|
-
return webhooks.slice(0, limit).map((webhook) => {
|
|
621
|
-
const { secret: _secret, ...safeWebhook } = webhook;
|
|
622
|
-
return safeWebhook as typeof webhook;
|
|
623
|
-
});
|
|
624
|
-
},
|
|
625
|
-
});
|
|
626
|
-
|
|
627
|
-
// =============================================================================
|
|
628
|
-
// Event Processing & Delivery
|
|
629
|
-
// =============================================================================
|
|
630
|
-
|
|
631
|
-
/**
|
|
632
|
-
* Internal query to get webhooks matching an event.
|
|
633
|
-
*/
|
|
634
|
-
export const getMatchingWebhooks = internalQuery({
|
|
635
|
-
args: {
|
|
636
|
-
eventType: v.string(),
|
|
637
|
-
resourceType: v.union(
|
|
638
|
-
v.literal("contentEntry"),
|
|
639
|
-
v.literal("contentType"),
|
|
640
|
-
v.literal("mediaAsset"),
|
|
641
|
-
v.literal("mediaFolder"),
|
|
642
|
-
),
|
|
643
|
-
contentTypeName: v.optional(v.string()),
|
|
644
|
-
},
|
|
645
|
-
handler: async (ctx, args) => {
|
|
646
|
-
const { eventType, resourceType, contentTypeName } = args;
|
|
647
|
-
|
|
648
|
-
// Get all enabled webhooks
|
|
649
|
-
const webhooks = await ctx.db
|
|
650
|
-
.query("webhookConfigs")
|
|
651
|
-
.withIndex("by_enabled", (q) => q.eq("enabled", true))
|
|
652
|
-
.filter((q) => q.eq(q.field("deletedAt"), undefined))
|
|
653
|
-
.collect();
|
|
654
|
-
|
|
655
|
-
// Filter to matching webhooks
|
|
656
|
-
return webhooks.filter((webhook) => {
|
|
657
|
-
// Check event type filter
|
|
658
|
-
if (
|
|
659
|
-
webhook.eventTypes.length > 0 &&
|
|
660
|
-
!webhook.eventTypes.includes(eventType)
|
|
661
|
-
) {
|
|
662
|
-
return false;
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
// Check resource type filter
|
|
666
|
-
if (
|
|
667
|
-
webhook.resourceTypes &&
|
|
668
|
-
webhook.resourceTypes.length > 0 &&
|
|
669
|
-
!webhook.resourceTypes.includes(resourceType)
|
|
670
|
-
) {
|
|
671
|
-
return false;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
// Check content type filter (only for contentEntry events)
|
|
675
|
-
if (
|
|
676
|
-
resourceType === "contentEntry" &&
|
|
677
|
-
webhook.contentTypes &&
|
|
678
|
-
webhook.contentTypes.length > 0 &&
|
|
679
|
-
contentTypeName &&
|
|
680
|
-
!webhook.contentTypes.includes(contentTypeName)
|
|
681
|
-
) {
|
|
682
|
-
return false;
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
return true;
|
|
686
|
-
});
|
|
687
|
-
},
|
|
688
|
-
});
|
|
689
|
-
|
|
690
|
-
/**
|
|
691
|
-
* Internal query to get unprocessed events for webhook delivery.
|
|
692
|
-
*/
|
|
693
|
-
export const getUnprocessedWebhookEvents = internalQuery({
|
|
694
|
-
args: {
|
|
695
|
-
limit: v.optional(v.number()),
|
|
696
|
-
},
|
|
697
|
-
handler: async (ctx, args) => {
|
|
698
|
-
const { limit = 50 } = args;
|
|
699
|
-
|
|
700
|
-
// Get unprocessed events
|
|
701
|
-
const events = await ctx.db
|
|
702
|
-
.query("cmsEvents")
|
|
703
|
-
.withIndex("by_processed", (q) => q.eq("processed", false))
|
|
704
|
-
.order("asc")
|
|
705
|
-
.take(limit);
|
|
706
|
-
|
|
707
|
-
return events;
|
|
708
|
-
},
|
|
709
|
-
});
|
|
710
|
-
|
|
711
|
-
/**
|
|
712
|
-
* Internal query to get pending or retrying deliveries.
|
|
713
|
-
*/
|
|
714
|
-
export const getPendingDeliveries = internalQuery({
|
|
715
|
-
args: {
|
|
716
|
-
limit: v.optional(v.number()),
|
|
717
|
-
},
|
|
718
|
-
handler: async (ctx, args) => {
|
|
719
|
-
const { limit = 50 } = args;
|
|
720
|
-
const now = Date.now();
|
|
721
|
-
|
|
722
|
-
// Get pending deliveries
|
|
723
|
-
const pending = await ctx.db
|
|
724
|
-
.query("webhookDeliveries")
|
|
725
|
-
.withIndex("by_status", (q) => q.eq("status", "pending"))
|
|
726
|
-
.take(limit);
|
|
727
|
-
|
|
728
|
-
// Get retrying deliveries whose retry time has passed
|
|
729
|
-
const retrying = await ctx.db
|
|
730
|
-
.query("webhookDeliveries")
|
|
731
|
-
.withIndex("by_status", (q) => q.eq("status", "retrying"))
|
|
732
|
-
.filter((q) =>
|
|
733
|
-
q.or(
|
|
734
|
-
q.eq(q.field("nextRetryAt"), undefined),
|
|
735
|
-
q.lte(q.field("nextRetryAt"), now),
|
|
736
|
-
),
|
|
737
|
-
)
|
|
738
|
-
.take(limit);
|
|
739
|
-
|
|
740
|
-
return [...pending, ...retrying].slice(0, limit);
|
|
741
|
-
},
|
|
742
|
-
});
|
|
743
|
-
|
|
744
|
-
/**
|
|
745
|
-
* Internal mutation to create a delivery record for an event-webhook pair.
|
|
746
|
-
*/
|
|
747
|
-
export const createDelivery = internalMutation({
|
|
748
|
-
args: {
|
|
749
|
-
webhookId: v.id("webhookConfigs"),
|
|
750
|
-
eventId: v.id("cmsEvents"),
|
|
751
|
-
eventType: v.string(),
|
|
752
|
-
maxAttempts: v.number(),
|
|
753
|
-
payload: v.any(),
|
|
754
|
-
},
|
|
755
|
-
returns: v.id("webhookDeliveries"),
|
|
756
|
-
handler: async (ctx, args) => {
|
|
757
|
-
const { webhookId, eventId, eventType, maxAttempts, payload } = args;
|
|
758
|
-
|
|
759
|
-
// Check if delivery already exists for this webhook-event pair
|
|
760
|
-
const existing = await ctx.db
|
|
761
|
-
.query("webhookDeliveries")
|
|
762
|
-
.withIndex("by_event", (q) => q.eq("eventId", eventId))
|
|
763
|
-
.filter((q) => q.eq(q.field("webhookId"), webhookId))
|
|
764
|
-
.first();
|
|
765
|
-
|
|
766
|
-
if (existing) {
|
|
767
|
-
// Already exists, return existing ID
|
|
768
|
-
return existing._id;
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
const deliveryId = await ctx.db.insert("webhookDeliveries", {
|
|
772
|
-
webhookId,
|
|
773
|
-
eventId,
|
|
774
|
-
eventType,
|
|
775
|
-
status: "pending",
|
|
776
|
-
attemptCount: 0,
|
|
777
|
-
maxAttempts,
|
|
778
|
-
payload,
|
|
779
|
-
});
|
|
780
|
-
|
|
781
|
-
return deliveryId;
|
|
782
|
-
},
|
|
783
|
-
});
|
|
784
|
-
|
|
785
|
-
/**
|
|
786
|
-
* Internal mutation to update delivery status after an attempt.
|
|
787
|
-
*/
|
|
788
|
-
export const updateDeliveryStatus = internalMutation({
|
|
789
|
-
args: {
|
|
790
|
-
deliveryId: v.id("webhookDeliveries"),
|
|
791
|
-
status: webhookDeliveryStatusValidator,
|
|
792
|
-
statusCode: v.optional(v.number()),
|
|
793
|
-
error: v.optional(v.string()),
|
|
794
|
-
responseBody: v.optional(v.string()),
|
|
795
|
-
durationMs: v.optional(v.number()),
|
|
796
|
-
nextRetryAt: v.optional(v.number()),
|
|
797
|
-
},
|
|
798
|
-
handler: async (ctx, args) => {
|
|
799
|
-
const {
|
|
800
|
-
deliveryId,
|
|
801
|
-
status,
|
|
802
|
-
statusCode,
|
|
803
|
-
error,
|
|
804
|
-
responseBody,
|
|
805
|
-
durationMs,
|
|
806
|
-
nextRetryAt,
|
|
807
|
-
} = args;
|
|
808
|
-
|
|
809
|
-
const delivery = await ctx.db.get(deliveryId);
|
|
810
|
-
if (!delivery) {
|
|
811
|
-
throw new Error(`Delivery not found: ${deliveryId}`);
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
const now = Date.now();
|
|
815
|
-
const updates: Record<string, unknown> = {
|
|
816
|
-
status,
|
|
817
|
-
lastAttemptAt: now,
|
|
818
|
-
attemptCount: delivery.attemptCount + 1,
|
|
819
|
-
};
|
|
820
|
-
|
|
821
|
-
if (statusCode !== undefined) {
|
|
822
|
-
updates.lastStatusCode = statusCode;
|
|
823
|
-
}
|
|
824
|
-
if (error !== undefined) {
|
|
825
|
-
updates.lastError = error;
|
|
826
|
-
}
|
|
827
|
-
if (responseBody !== undefined) {
|
|
828
|
-
updates.lastResponseBody = responseBody.slice(
|
|
829
|
-
0,
|
|
830
|
-
MAX_RESPONSE_BODY_LENGTH,
|
|
831
|
-
);
|
|
832
|
-
}
|
|
833
|
-
if (durationMs !== undefined) {
|
|
834
|
-
updates.lastDurationMs = durationMs;
|
|
835
|
-
}
|
|
836
|
-
if (nextRetryAt !== undefined) {
|
|
837
|
-
updates.nextRetryAt = nextRetryAt;
|
|
838
|
-
}
|
|
839
|
-
if (status === "delivered") {
|
|
840
|
-
updates.deliveredAt = now;
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
await ctx.db.patch(deliveryId, updates);
|
|
844
|
-
},
|
|
845
|
-
});
|
|
846
|
-
|
|
847
|
-
/**
|
|
848
|
-
* Internal mutation to mark delivery as processing.
|
|
849
|
-
*/
|
|
850
|
-
export const markDeliveryProcessing = internalMutation({
|
|
851
|
-
args: {
|
|
852
|
-
deliveryId: v.id("webhookDeliveries"),
|
|
853
|
-
},
|
|
854
|
-
handler: async (ctx, args) => {
|
|
855
|
-
await ctx.db.patch(args.deliveryId, {
|
|
856
|
-
status: "processing",
|
|
857
|
-
});
|
|
858
|
-
},
|
|
859
|
-
});
|
|
860
|
-
|
|
861
|
-
/**
|
|
862
|
-
* Generate HMAC-SHA256 signature for webhook payload.
|
|
863
|
-
*
|
|
864
|
-
* @param payload - The JSON payload to sign
|
|
865
|
-
* @param secret - The secret key for signing
|
|
866
|
-
* @returns Hex-encoded signature
|
|
867
|
-
*/
|
|
868
|
-
async function generateSignature(
|
|
869
|
-
payload: string,
|
|
870
|
-
secret: string,
|
|
871
|
-
): Promise<string> {
|
|
872
|
-
const encoder = new TextEncoder();
|
|
873
|
-
const keyData = encoder.encode(secret);
|
|
874
|
-
const data = encoder.encode(payload);
|
|
875
|
-
|
|
876
|
-
const cryptoKey = await crypto.subtle.importKey(
|
|
877
|
-
"raw",
|
|
878
|
-
keyData,
|
|
879
|
-
{ name: "HMAC", hash: "SHA-256" },
|
|
880
|
-
false,
|
|
881
|
-
["sign"],
|
|
882
|
-
);
|
|
883
|
-
|
|
884
|
-
const signature = await crypto.subtle.sign("HMAC", cryptoKey, data);
|
|
885
|
-
const hashArray = Array.from(new Uint8Array(signature));
|
|
886
|
-
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
/**
|
|
890
|
-
* Internal action to send a webhook delivery.
|
|
891
|
-
* Actions are needed for HTTP requests to external services.
|
|
892
|
-
*/
|
|
893
|
-
export const sendWebhookDelivery = internalAction({
|
|
894
|
-
args: {
|
|
895
|
-
deliveryId: v.id("webhookDeliveries"),
|
|
896
|
-
},
|
|
897
|
-
handler: async (ctx, args) => {
|
|
898
|
-
const { deliveryId } = args;
|
|
899
|
-
|
|
900
|
-
// Get delivery and webhook info
|
|
901
|
-
const delivery = await ctx.runQuery(
|
|
902
|
-
internal.webhookTrigger.getDeliveryWithWebhook,
|
|
903
|
-
{ deliveryId },
|
|
904
|
-
);
|
|
905
|
-
|
|
906
|
-
if (!delivery) {
|
|
907
|
-
console.error(`Delivery not found: ${deliveryId}`);
|
|
908
|
-
return;
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
const { webhook, ...deliveryData } = delivery;
|
|
912
|
-
|
|
913
|
-
// Mark as processing
|
|
914
|
-
await ctx.runMutation(internal.webhookTrigger.markDeliveryProcessing, {
|
|
915
|
-
deliveryId,
|
|
916
|
-
});
|
|
917
|
-
|
|
918
|
-
const payload = JSON.stringify(deliveryData.payload);
|
|
919
|
-
const startTime = Date.now();
|
|
920
|
-
|
|
921
|
-
try {
|
|
922
|
-
// Build headers
|
|
923
|
-
const headers: Record<string, string> = {
|
|
924
|
-
"Content-Type": "application/json",
|
|
925
|
-
"X-Webhook-Delivery-Id": deliveryId,
|
|
926
|
-
"X-Webhook-Event-Type": deliveryData.eventType,
|
|
927
|
-
...(webhook.headers || {}),
|
|
928
|
-
};
|
|
929
|
-
|
|
930
|
-
// Add signature if secret is configured
|
|
931
|
-
if (webhook.secret) {
|
|
932
|
-
const signature = await generateSignature(payload, webhook.secret);
|
|
933
|
-
headers["X-Webhook-Signature"] = `sha256=${signature}`;
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
// Send request with timeout
|
|
937
|
-
const controller = new AbortController();
|
|
938
|
-
const timeoutMs =
|
|
939
|
-
webhook.timeoutMs || DEFAULT_WEBHOOK_CONFIG.defaultTimeoutMs;
|
|
940
|
-
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
941
|
-
|
|
942
|
-
const response = await fetch(webhook.url, {
|
|
943
|
-
method: "POST",
|
|
944
|
-
headers,
|
|
945
|
-
body: payload,
|
|
946
|
-
signal: controller.signal,
|
|
947
|
-
});
|
|
948
|
-
|
|
949
|
-
clearTimeout(timeoutId);
|
|
950
|
-
|
|
951
|
-
const durationMs = Date.now() - startTime;
|
|
952
|
-
const responseBody = await response.text().catch(() => "");
|
|
953
|
-
|
|
954
|
-
if (response.ok) {
|
|
955
|
-
// Success (2xx status)
|
|
956
|
-
await ctx.runMutation(internal.webhookTrigger.updateDeliveryStatus, {
|
|
957
|
-
deliveryId,
|
|
958
|
-
status: "delivered",
|
|
959
|
-
statusCode: response.status,
|
|
960
|
-
responseBody,
|
|
961
|
-
durationMs,
|
|
962
|
-
});
|
|
963
|
-
console.log(
|
|
964
|
-
`Webhook delivered successfully: ${deliveryId} to ${webhook.url} (${response.status})`,
|
|
965
|
-
);
|
|
966
|
-
} else {
|
|
967
|
-
// HTTP error
|
|
968
|
-
await handleDeliveryFailure(
|
|
969
|
-
ctx,
|
|
970
|
-
deliveryId,
|
|
971
|
-
deliveryData.attemptCount + 1,
|
|
972
|
-
deliveryData.maxAttempts,
|
|
973
|
-
`HTTP ${response.status}: ${response.statusText}`,
|
|
974
|
-
response.status,
|
|
975
|
-
responseBody,
|
|
976
|
-
durationMs,
|
|
977
|
-
);
|
|
978
|
-
}
|
|
979
|
-
} catch (error) {
|
|
980
|
-
const durationMs = Date.now() - startTime;
|
|
981
|
-
const errorMessage =
|
|
982
|
-
error instanceof Error ? error.message : "Unknown error";
|
|
983
|
-
|
|
984
|
-
// Handle timeout specifically
|
|
985
|
-
if (errorMessage.includes("aborted")) {
|
|
986
|
-
await handleDeliveryFailure(
|
|
987
|
-
ctx,
|
|
988
|
-
deliveryId,
|
|
989
|
-
deliveryData.attemptCount + 1,
|
|
990
|
-
deliveryData.maxAttempts,
|
|
991
|
-
`Request timeout after ${
|
|
992
|
-
webhook.timeoutMs || DEFAULT_WEBHOOK_CONFIG.defaultTimeoutMs
|
|
993
|
-
}ms`,
|
|
994
|
-
undefined,
|
|
995
|
-
undefined,
|
|
996
|
-
durationMs,
|
|
997
|
-
);
|
|
998
|
-
} else {
|
|
999
|
-
await handleDeliveryFailure(
|
|
1000
|
-
ctx,
|
|
1001
|
-
deliveryId,
|
|
1002
|
-
deliveryData.attemptCount + 1,
|
|
1003
|
-
deliveryData.maxAttempts,
|
|
1004
|
-
errorMessage,
|
|
1005
|
-
undefined,
|
|
1006
|
-
undefined,
|
|
1007
|
-
durationMs,
|
|
1008
|
-
);
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
},
|
|
1012
|
-
});
|
|
1013
|
-
|
|
1014
|
-
/**
|
|
1015
|
-
* Handle delivery failure with retry logic.
|
|
1016
|
-
*/
|
|
1017
|
-
async function handleDeliveryFailure(
|
|
1018
|
-
ctx: { runMutation: (fn: any, args: any) => Promise<any>; scheduler: any },
|
|
1019
|
-
deliveryId: string,
|
|
1020
|
-
attemptCount: number,
|
|
1021
|
-
maxAttempts: number,
|
|
1022
|
-
error: string,
|
|
1023
|
-
statusCode?: number,
|
|
1024
|
-
responseBody?: string,
|
|
1025
|
-
durationMs?: number,
|
|
1026
|
-
) {
|
|
1027
|
-
if (attemptCount < maxAttempts) {
|
|
1028
|
-
// Calculate next retry time with exponential backoff
|
|
1029
|
-
const delayIndex = Math.min(attemptCount - 1, RETRY_DELAYS_MS.length - 1);
|
|
1030
|
-
const delay = RETRY_DELAYS_MS[delayIndex];
|
|
1031
|
-
const nextRetryAt = Date.now() + delay;
|
|
1032
|
-
|
|
1033
|
-
await ctx.runMutation(internal.webhookTrigger.updateDeliveryStatus, {
|
|
1034
|
-
deliveryId: deliveryId,
|
|
1035
|
-
status: "retrying",
|
|
1036
|
-
statusCode,
|
|
1037
|
-
error,
|
|
1038
|
-
responseBody,
|
|
1039
|
-
durationMs,
|
|
1040
|
-
nextRetryAt,
|
|
1041
|
-
});
|
|
1042
|
-
|
|
1043
|
-
console.log(
|
|
1044
|
-
`Webhook delivery ${deliveryId} failed (attempt ${attemptCount}/${maxAttempts}). ` +
|
|
1045
|
-
`Retrying in ${delay / 1000}s. Error: ${error}`,
|
|
1046
|
-
);
|
|
1047
|
-
|
|
1048
|
-
// Schedule retry
|
|
1049
|
-
await ctx.scheduler.runAt(
|
|
1050
|
-
nextRetryAt,
|
|
1051
|
-
internal.webhookTrigger.sendWebhookDelivery,
|
|
1052
|
-
{ deliveryId: deliveryId },
|
|
1053
|
-
);
|
|
1054
|
-
} else {
|
|
1055
|
-
// Max retries exhausted
|
|
1056
|
-
await ctx.runMutation(internal.webhookTrigger.updateDeliveryStatus, {
|
|
1057
|
-
deliveryId: deliveryId,
|
|
1058
|
-
status: "failed",
|
|
1059
|
-
statusCode,
|
|
1060
|
-
error,
|
|
1061
|
-
responseBody,
|
|
1062
|
-
durationMs,
|
|
1063
|
-
});
|
|
1064
|
-
|
|
1065
|
-
console.error(
|
|
1066
|
-
`Webhook delivery ${deliveryId} failed permanently after ${maxAttempts} attempts. Error: ${error}`,
|
|
1067
|
-
);
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
/**
|
|
1072
|
-
* Internal query to get delivery with webhook info.
|
|
1073
|
-
*/
|
|
1074
|
-
export const getDeliveryWithWebhook = internalQuery({
|
|
1075
|
-
args: {
|
|
1076
|
-
deliveryId: v.id("webhookDeliveries"),
|
|
1077
|
-
},
|
|
1078
|
-
handler: async (ctx, args) => {
|
|
1079
|
-
const delivery = await ctx.db.get(args.deliveryId);
|
|
1080
|
-
if (!delivery) return null;
|
|
1081
|
-
|
|
1082
|
-
const webhook = await ctx.db.get(delivery.webhookId);
|
|
1083
|
-
if (!webhook || isDeleted(webhook) || !webhook.enabled) {
|
|
1084
|
-
return null;
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
return {
|
|
1088
|
-
...delivery,
|
|
1089
|
-
webhook,
|
|
1090
|
-
};
|
|
1091
|
-
},
|
|
1092
|
-
});
|
|
1093
|
-
|
|
1094
|
-
// =============================================================================
|
|
1095
|
-
// Background Job Scheduling
|
|
1096
|
-
// =============================================================================
|
|
1097
|
-
|
|
1098
|
-
/**
|
|
1099
|
-
* Process unprocessed events and create webhook deliveries.
|
|
1100
|
-
*
|
|
1101
|
-
* This mutation:
|
|
1102
|
-
* 1. Gets unprocessed events from cmsEvents
|
|
1103
|
-
* 2. For each event, finds matching webhook configurations
|
|
1104
|
-
* 3. Creates delivery records for each event-webhook pair
|
|
1105
|
-
*
|
|
1106
|
-
* Call this periodically to queue new deliveries.
|
|
1107
|
-
*/
|
|
1108
|
-
export const processEventsForDelivery = internalMutation({
|
|
1109
|
-
args: {
|
|
1110
|
-
batchSize: v.optional(v.number()),
|
|
1111
|
-
},
|
|
1112
|
-
returns: v.object({
|
|
1113
|
-
eventsProcessed: v.number(),
|
|
1114
|
-
deliveriesCreated: v.number(),
|
|
1115
|
-
hasMore: v.boolean(),
|
|
1116
|
-
}),
|
|
1117
|
-
handler: async (ctx, args) => {
|
|
1118
|
-
const { batchSize = DEFAULT_WEBHOOK_CONFIG.batchSize } = args;
|
|
1119
|
-
|
|
1120
|
-
// Get unprocessed events
|
|
1121
|
-
const events = await ctx.db
|
|
1122
|
-
.query("cmsEvents")
|
|
1123
|
-
.withIndex("by_processed", (q) => q.eq("processed", false))
|
|
1124
|
-
.order("asc")
|
|
1125
|
-
.take(batchSize + 1);
|
|
1126
|
-
|
|
1127
|
-
const hasMore = events.length > batchSize;
|
|
1128
|
-
const eventsToProcess = events.slice(0, batchSize);
|
|
1129
|
-
|
|
1130
|
-
let deliveriesCreated = 0;
|
|
1131
|
-
|
|
1132
|
-
for (const event of eventsToProcess) {
|
|
1133
|
-
// Get content type name from payload if available
|
|
1134
|
-
const payload = event.payload as { contentTypeName?: string } | undefined;
|
|
1135
|
-
const contentTypeName = payload?.contentTypeName;
|
|
1136
|
-
|
|
1137
|
-
// Get matching webhooks
|
|
1138
|
-
const webhooks = await ctx.db
|
|
1139
|
-
.query("webhookConfigs")
|
|
1140
|
-
.withIndex("by_enabled", (q) => q.eq("enabled", true))
|
|
1141
|
-
.filter((q) => q.eq(q.field("deletedAt"), undefined))
|
|
1142
|
-
.collect();
|
|
1143
|
-
|
|
1144
|
-
// Filter to matching webhooks
|
|
1145
|
-
const matchingWebhooks = webhooks.filter((webhook) => {
|
|
1146
|
-
// Check event type filter
|
|
1147
|
-
if (
|
|
1148
|
-
webhook.eventTypes.length > 0 &&
|
|
1149
|
-
!webhook.eventTypes.includes(event.eventType)
|
|
1150
|
-
) {
|
|
1151
|
-
return false;
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
// Check resource type filter
|
|
1155
|
-
if (
|
|
1156
|
-
webhook.resourceTypes &&
|
|
1157
|
-
webhook.resourceTypes.length > 0 &&
|
|
1158
|
-
!webhook.resourceTypes.includes(event.resourceType)
|
|
1159
|
-
) {
|
|
1160
|
-
return false;
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
// Check content type filter
|
|
1164
|
-
if (
|
|
1165
|
-
event.resourceType === "contentEntry" &&
|
|
1166
|
-
webhook.contentTypes &&
|
|
1167
|
-
webhook.contentTypes.length > 0 &&
|
|
1168
|
-
contentTypeName &&
|
|
1169
|
-
!webhook.contentTypes.includes(contentTypeName)
|
|
1170
|
-
) {
|
|
1171
|
-
return false;
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
return true;
|
|
1175
|
-
});
|
|
1176
|
-
|
|
1177
|
-
// Create delivery for each matching webhook
|
|
1178
|
-
for (const webhook of matchingWebhooks) {
|
|
1179
|
-
// Check if delivery already exists
|
|
1180
|
-
const existing = await ctx.db
|
|
1181
|
-
.query("webhookDeliveries")
|
|
1182
|
-
.withIndex("by_event", (q) => q.eq("eventId", event._id))
|
|
1183
|
-
.filter((q) => q.eq(q.field("webhookId"), webhook._id))
|
|
1184
|
-
.first();
|
|
1185
|
-
|
|
1186
|
-
if (!existing) {
|
|
1187
|
-
// Build webhook payload
|
|
1188
|
-
const webhookPayload: WebhookPayload = {
|
|
1189
|
-
deliveryId: "", // Will be updated after creation
|
|
1190
|
-
eventType: event.eventType,
|
|
1191
|
-
resourceType: event.resourceType,
|
|
1192
|
-
resourceId: event.resourceId,
|
|
1193
|
-
action: event.action,
|
|
1194
|
-
data: event.payload,
|
|
1195
|
-
timestamp: new Date(event._creationTime).toISOString(),
|
|
1196
|
-
userId: event.userId,
|
|
1197
|
-
};
|
|
1198
|
-
|
|
1199
|
-
const deliveryId = await ctx.db.insert("webhookDeliveries", {
|
|
1200
|
-
webhookId: webhook._id,
|
|
1201
|
-
eventId: event._id,
|
|
1202
|
-
eventType: event.eventType,
|
|
1203
|
-
status: "pending",
|
|
1204
|
-
attemptCount: 0,
|
|
1205
|
-
maxAttempts:
|
|
1206
|
-
webhook.maxRetries ?? DEFAULT_WEBHOOK_CONFIG.defaultMaxRetries,
|
|
1207
|
-
payload: { ...webhookPayload, deliveryId: "pending" },
|
|
1208
|
-
});
|
|
1209
|
-
|
|
1210
|
-
// Update payload with actual delivery ID
|
|
1211
|
-
const updatedPayload = { ...webhookPayload, deliveryId };
|
|
1212
|
-
await ctx.db.patch(deliveryId, { payload: updatedPayload });
|
|
1213
|
-
|
|
1214
|
-
deliveriesCreated++;
|
|
1215
|
-
}
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
// Mark event as processed for webhooks
|
|
1219
|
-
// Note: We don't use the generic markEventsProcessed because
|
|
1220
|
-
// other processors (like RAG indexer) may also need the event
|
|
1221
|
-
// Instead, we track webhook processing via delivery records
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
|
-
return {
|
|
1225
|
-
eventsProcessed: eventsToProcess.length,
|
|
1226
|
-
deliveriesCreated,
|
|
1227
|
-
hasMore,
|
|
1228
|
-
};
|
|
1229
|
-
},
|
|
1230
|
-
});
|
|
1231
|
-
|
|
1232
|
-
/**
|
|
1233
|
-
* Trigger webhook deliveries for pending/retrying items.
|
|
1234
|
-
*
|
|
1235
|
-
* This internal mutation schedules action calls for each pending delivery.
|
|
1236
|
-
*/
|
|
1237
|
-
export const triggerPendingDeliveries = internalMutation({
|
|
1238
|
-
args: {
|
|
1239
|
-
batchSize: v.optional(v.number()),
|
|
1240
|
-
},
|
|
1241
|
-
returns: v.object({
|
|
1242
|
-
deliveriesTriggered: v.number(),
|
|
1243
|
-
hasMore: v.boolean(),
|
|
1244
|
-
}),
|
|
1245
|
-
handler: async (ctx, args) => {
|
|
1246
|
-
const { batchSize = DEFAULT_WEBHOOK_CONFIG.batchSize } = args;
|
|
1247
|
-
const now = Date.now();
|
|
1248
|
-
|
|
1249
|
-
// Get pending deliveries
|
|
1250
|
-
const pending = await ctx.db
|
|
1251
|
-
.query("webhookDeliveries")
|
|
1252
|
-
.withIndex("by_status", (q) => q.eq("status", "pending"))
|
|
1253
|
-
.take(batchSize);
|
|
1254
|
-
|
|
1255
|
-
// Get retrying deliveries whose retry time has passed
|
|
1256
|
-
const retrying = await ctx.db
|
|
1257
|
-
.query("webhookDeliveries")
|
|
1258
|
-
.withIndex("by_status", (q) => q.eq("status", "retrying"))
|
|
1259
|
-
.filter((q) =>
|
|
1260
|
-
q.or(
|
|
1261
|
-
q.eq(q.field("nextRetryAt"), undefined),
|
|
1262
|
-
q.lte(q.field("nextRetryAt"), now),
|
|
1263
|
-
),
|
|
1264
|
-
)
|
|
1265
|
-
.take(batchSize);
|
|
1266
|
-
|
|
1267
|
-
const allDeliveries = [...pending, ...retrying].slice(0, batchSize);
|
|
1268
|
-
const hasMore = pending.length >= batchSize || retrying.length >= batchSize;
|
|
1269
|
-
|
|
1270
|
-
// Schedule delivery action for each
|
|
1271
|
-
for (const delivery of allDeliveries) {
|
|
1272
|
-
await ctx.scheduler.runAfter(
|
|
1273
|
-
0,
|
|
1274
|
-
internal.webhookTrigger.sendWebhookDelivery,
|
|
1275
|
-
{ deliveryId: delivery._id },
|
|
1276
|
-
);
|
|
1277
|
-
}
|
|
1278
|
-
|
|
1279
|
-
return {
|
|
1280
|
-
deliveriesTriggered: allDeliveries.length,
|
|
1281
|
-
hasMore,
|
|
1282
|
-
};
|
|
1283
|
-
},
|
|
1284
|
-
});
|
|
1285
|
-
|
|
1286
|
-
/**
|
|
1287
|
-
* Main scheduled function that processes events and triggers webhooks.
|
|
1288
|
-
*
|
|
1289
|
-
* This should be called periodically (e.g., every minute) to:
|
|
1290
|
-
* 1. Create deliveries for new events
|
|
1291
|
-
* 2. Trigger pending/retrying deliveries
|
|
1292
|
-
*/
|
|
1293
|
-
export const processWebhooks = internalMutation({
|
|
1294
|
-
args: {
|
|
1295
|
-
config: v.optional(
|
|
1296
|
-
v.object({
|
|
1297
|
-
batchSize: v.optional(v.number()),
|
|
1298
|
-
}),
|
|
1299
|
-
),
|
|
1300
|
-
},
|
|
1301
|
-
returns: v.object({
|
|
1302
|
-
eventsProcessed: v.number(),
|
|
1303
|
-
deliveriesCreated: v.number(),
|
|
1304
|
-
deliveriesTriggered: v.number(),
|
|
1305
|
-
hasMore: v.boolean(),
|
|
1306
|
-
}),
|
|
1307
|
-
handler: async (ctx, args) => {
|
|
1308
|
-
const config = { ...DEFAULT_WEBHOOK_CONFIG, ...(args.config || {}) };
|
|
1309
|
-
const batchSize = config.batchSize ?? DEFAULT_WEBHOOK_CONFIG.batchSize;
|
|
1310
|
-
|
|
1311
|
-
// Step 1: Process events and create deliveries
|
|
1312
|
-
// Inline the logic to avoid calling handler directly
|
|
1313
|
-
const events = await ctx.db
|
|
1314
|
-
.query("cmsEvents")
|
|
1315
|
-
.withIndex("by_processed", (q) => q.eq("processed", false))
|
|
1316
|
-
.order("asc")
|
|
1317
|
-
.take(batchSize + 1);
|
|
1318
|
-
|
|
1319
|
-
const hasMoreEvents = events.length > batchSize;
|
|
1320
|
-
const eventsToProcess = events.slice(0, batchSize);
|
|
1321
|
-
|
|
1322
|
-
let deliveriesCreated = 0;
|
|
1323
|
-
|
|
1324
|
-
for (const event of eventsToProcess) {
|
|
1325
|
-
const payload = event.payload as { contentTypeName?: string } | undefined;
|
|
1326
|
-
const contentTypeName = payload?.contentTypeName;
|
|
1327
|
-
|
|
1328
|
-
const webhooks = await ctx.db
|
|
1329
|
-
.query("webhookConfigs")
|
|
1330
|
-
.withIndex("by_enabled", (q) => q.eq("enabled", true))
|
|
1331
|
-
.filter((q) => q.eq(q.field("deletedAt"), undefined))
|
|
1332
|
-
.collect();
|
|
1333
|
-
|
|
1334
|
-
const matchingWebhooks = webhooks.filter((webhook) => {
|
|
1335
|
-
if (
|
|
1336
|
-
webhook.eventTypes.length > 0 &&
|
|
1337
|
-
!webhook.eventTypes.includes(event.eventType)
|
|
1338
|
-
) {
|
|
1339
|
-
return false;
|
|
1340
|
-
}
|
|
1341
|
-
if (
|
|
1342
|
-
webhook.resourceTypes &&
|
|
1343
|
-
webhook.resourceTypes.length > 0 &&
|
|
1344
|
-
!webhook.resourceTypes.includes(event.resourceType)
|
|
1345
|
-
) {
|
|
1346
|
-
return false;
|
|
1347
|
-
}
|
|
1348
|
-
if (
|
|
1349
|
-
event.resourceType === "contentEntry" &&
|
|
1350
|
-
webhook.contentTypes &&
|
|
1351
|
-
webhook.contentTypes.length > 0 &&
|
|
1352
|
-
contentTypeName &&
|
|
1353
|
-
!webhook.contentTypes.includes(contentTypeName)
|
|
1354
|
-
) {
|
|
1355
|
-
return false;
|
|
1356
|
-
}
|
|
1357
|
-
return true;
|
|
1358
|
-
});
|
|
1359
|
-
|
|
1360
|
-
for (const webhook of matchingWebhooks) {
|
|
1361
|
-
const existing = await ctx.db
|
|
1362
|
-
.query("webhookDeliveries")
|
|
1363
|
-
.withIndex("by_event", (q) => q.eq("eventId", event._id))
|
|
1364
|
-
.filter((q) => q.eq(q.field("webhookId"), webhook._id))
|
|
1365
|
-
.first();
|
|
1366
|
-
|
|
1367
|
-
if (!existing) {
|
|
1368
|
-
const webhookPayload: WebhookPayload = {
|
|
1369
|
-
deliveryId: "pending",
|
|
1370
|
-
eventType: event.eventType,
|
|
1371
|
-
resourceType: event.resourceType,
|
|
1372
|
-
resourceId: event.resourceId,
|
|
1373
|
-
action: event.action,
|
|
1374
|
-
data: event.payload,
|
|
1375
|
-
timestamp: new Date(event._creationTime).toISOString(),
|
|
1376
|
-
userId: event.userId,
|
|
1377
|
-
};
|
|
1378
|
-
|
|
1379
|
-
const deliveryId = await ctx.db.insert("webhookDeliveries", {
|
|
1380
|
-
webhookId: webhook._id,
|
|
1381
|
-
eventId: event._id,
|
|
1382
|
-
eventType: event.eventType,
|
|
1383
|
-
status: "pending",
|
|
1384
|
-
attemptCount: 0,
|
|
1385
|
-
maxAttempts:
|
|
1386
|
-
webhook.maxRetries ?? DEFAULT_WEBHOOK_CONFIG.defaultMaxRetries,
|
|
1387
|
-
payload: webhookPayload,
|
|
1388
|
-
});
|
|
1389
|
-
|
|
1390
|
-
const updatedPayload = { ...webhookPayload, deliveryId };
|
|
1391
|
-
await ctx.db.patch(deliveryId, { payload: updatedPayload });
|
|
1392
|
-
deliveriesCreated++;
|
|
1393
|
-
}
|
|
1394
|
-
}
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
|
-
// Step 2: Trigger pending deliveries
|
|
1398
|
-
const now = Date.now();
|
|
1399
|
-
const pending = await ctx.db
|
|
1400
|
-
.query("webhookDeliveries")
|
|
1401
|
-
.withIndex("by_status", (q) => q.eq("status", "pending"))
|
|
1402
|
-
.take(batchSize);
|
|
1403
|
-
|
|
1404
|
-
const retrying = await ctx.db
|
|
1405
|
-
.query("webhookDeliveries")
|
|
1406
|
-
.withIndex("by_status", (q) => q.eq("status", "retrying"))
|
|
1407
|
-
.filter((q) =>
|
|
1408
|
-
q.or(
|
|
1409
|
-
q.eq(q.field("nextRetryAt"), undefined),
|
|
1410
|
-
q.lte(q.field("nextRetryAt"), now),
|
|
1411
|
-
),
|
|
1412
|
-
)
|
|
1413
|
-
.take(batchSize);
|
|
1414
|
-
|
|
1415
|
-
const allDeliveries = [...pending, ...retrying].slice(0, batchSize);
|
|
1416
|
-
const hasMoreDeliveries =
|
|
1417
|
-
pending.length >= batchSize || retrying.length >= batchSize;
|
|
1418
|
-
|
|
1419
|
-
for (const delivery of allDeliveries) {
|
|
1420
|
-
await ctx.scheduler.runAfter(
|
|
1421
|
-
0,
|
|
1422
|
-
internal.webhookTrigger.sendWebhookDelivery,
|
|
1423
|
-
{ deliveryId: delivery._id },
|
|
1424
|
-
);
|
|
1425
|
-
}
|
|
1426
|
-
|
|
1427
|
-
return {
|
|
1428
|
-
eventsProcessed: eventsToProcess.length,
|
|
1429
|
-
deliveriesCreated,
|
|
1430
|
-
deliveriesTriggered: allDeliveries.length,
|
|
1431
|
-
hasMore: hasMoreEvents || hasMoreDeliveries,
|
|
1432
|
-
};
|
|
1433
|
-
},
|
|
1434
|
-
});
|
|
1435
|
-
|
|
1436
|
-
/**
|
|
1437
|
-
* Schedule the next webhook processing run.
|
|
1438
|
-
*
|
|
1439
|
-
* Call this to set up recurring background processing.
|
|
1440
|
-
*
|
|
1441
|
-
* @param delayMs - Delay before next run in milliseconds
|
|
1442
|
-
*/
|
|
1443
|
-
export const scheduleNextWebhookRun = mutation({
|
|
1444
|
-
args: {
|
|
1445
|
-
delayMs: v.optional(v.number()),
|
|
1446
|
-
},
|
|
1447
|
-
returns: v.object({
|
|
1448
|
-
scheduledAt: v.number(),
|
|
1449
|
-
}),
|
|
1450
|
-
handler: async (ctx, args) => {
|
|
1451
|
-
const delayMs = args.delayMs ?? DEFAULT_WEBHOOK_CONFIG.pollingIntervalMs;
|
|
1452
|
-
const runAt = Date.now() + delayMs;
|
|
1453
|
-
|
|
1454
|
-
await ctx.scheduler.runAt(
|
|
1455
|
-
runAt,
|
|
1456
|
-
internal.webhookTrigger.triggerWebhookCheck,
|
|
1457
|
-
{},
|
|
1458
|
-
);
|
|
1459
|
-
|
|
1460
|
-
return { scheduledAt: runAt };
|
|
1461
|
-
},
|
|
1462
|
-
});
|
|
1463
|
-
|
|
1464
|
-
/**
|
|
1465
|
-
* Internal mutation triggered by scheduler to process webhooks.
|
|
1466
|
-
* This inlines the processWebhooks logic to avoid calling .handler() directly.
|
|
1467
|
-
*/
|
|
1468
|
-
export const triggerWebhookCheck = internalMutation({
|
|
1469
|
-
args: {},
|
|
1470
|
-
handler: async (ctx) => {
|
|
1471
|
-
const batchSize = DEFAULT_WEBHOOK_CONFIG.batchSize;
|
|
1472
|
-
|
|
1473
|
-
// Step 1: Process events and create deliveries
|
|
1474
|
-
const events = await ctx.db
|
|
1475
|
-
.query("cmsEvents")
|
|
1476
|
-
.withIndex("by_processed", (q) => q.eq("processed", false))
|
|
1477
|
-
.order("asc")
|
|
1478
|
-
.take(batchSize + 1);
|
|
1479
|
-
|
|
1480
|
-
const hasMoreEvents = events.length > batchSize;
|
|
1481
|
-
const eventsToProcess = events.slice(0, batchSize);
|
|
1482
|
-
|
|
1483
|
-
let deliveriesCreated = 0;
|
|
1484
|
-
|
|
1485
|
-
for (const event of eventsToProcess) {
|
|
1486
|
-
const payload = event.payload as { contentTypeName?: string } | undefined;
|
|
1487
|
-
const contentTypeName = payload?.contentTypeName;
|
|
1488
|
-
|
|
1489
|
-
const webhooks = await ctx.db
|
|
1490
|
-
.query("webhookConfigs")
|
|
1491
|
-
.withIndex("by_enabled", (q) => q.eq("enabled", true))
|
|
1492
|
-
.filter((q) => q.eq(q.field("deletedAt"), undefined))
|
|
1493
|
-
.collect();
|
|
1494
|
-
|
|
1495
|
-
const matchingWebhooks = webhooks.filter((webhook) => {
|
|
1496
|
-
if (
|
|
1497
|
-
webhook.eventTypes.length > 0 &&
|
|
1498
|
-
!webhook.eventTypes.includes(event.eventType)
|
|
1499
|
-
) {
|
|
1500
|
-
return false;
|
|
1501
|
-
}
|
|
1502
|
-
if (
|
|
1503
|
-
webhook.resourceTypes &&
|
|
1504
|
-
webhook.resourceTypes.length > 0 &&
|
|
1505
|
-
!webhook.resourceTypes.includes(event.resourceType)
|
|
1506
|
-
) {
|
|
1507
|
-
return false;
|
|
1508
|
-
}
|
|
1509
|
-
if (
|
|
1510
|
-
event.resourceType === "contentEntry" &&
|
|
1511
|
-
webhook.contentTypes &&
|
|
1512
|
-
webhook.contentTypes.length > 0 &&
|
|
1513
|
-
contentTypeName &&
|
|
1514
|
-
!webhook.contentTypes.includes(contentTypeName)
|
|
1515
|
-
) {
|
|
1516
|
-
return false;
|
|
1517
|
-
}
|
|
1518
|
-
return true;
|
|
1519
|
-
});
|
|
1520
|
-
|
|
1521
|
-
for (const webhook of matchingWebhooks) {
|
|
1522
|
-
const existing = await ctx.db
|
|
1523
|
-
.query("webhookDeliveries")
|
|
1524
|
-
.withIndex("by_event", (q) => q.eq("eventId", event._id))
|
|
1525
|
-
.filter((q) => q.eq(q.field("webhookId"), webhook._id))
|
|
1526
|
-
.first();
|
|
1527
|
-
|
|
1528
|
-
if (!existing) {
|
|
1529
|
-
const webhookPayload: WebhookPayload = {
|
|
1530
|
-
deliveryId: "pending",
|
|
1531
|
-
eventType: event.eventType,
|
|
1532
|
-
resourceType: event.resourceType,
|
|
1533
|
-
resourceId: event.resourceId,
|
|
1534
|
-
action: event.action,
|
|
1535
|
-
data: event.payload,
|
|
1536
|
-
timestamp: new Date(event._creationTime).toISOString(),
|
|
1537
|
-
userId: event.userId,
|
|
1538
|
-
};
|
|
1539
|
-
|
|
1540
|
-
const deliveryId = await ctx.db.insert("webhookDeliveries", {
|
|
1541
|
-
webhookId: webhook._id,
|
|
1542
|
-
eventId: event._id,
|
|
1543
|
-
eventType: event.eventType,
|
|
1544
|
-
status: "pending",
|
|
1545
|
-
attemptCount: 0,
|
|
1546
|
-
maxAttempts:
|
|
1547
|
-
webhook.maxRetries ?? DEFAULT_WEBHOOK_CONFIG.defaultMaxRetries,
|
|
1548
|
-
payload: webhookPayload,
|
|
1549
|
-
});
|
|
1550
|
-
|
|
1551
|
-
const updatedPayload = { ...webhookPayload, deliveryId };
|
|
1552
|
-
await ctx.db.patch(deliveryId, { payload: updatedPayload });
|
|
1553
|
-
deliveriesCreated++;
|
|
1554
|
-
}
|
|
1555
|
-
}
|
|
1556
|
-
}
|
|
1557
|
-
|
|
1558
|
-
// Step 2: Trigger pending deliveries
|
|
1559
|
-
const now = Date.now();
|
|
1560
|
-
const pending = await ctx.db
|
|
1561
|
-
.query("webhookDeliveries")
|
|
1562
|
-
.withIndex("by_status", (q) => q.eq("status", "pending"))
|
|
1563
|
-
.take(batchSize);
|
|
1564
|
-
|
|
1565
|
-
const retrying = await ctx.db
|
|
1566
|
-
.query("webhookDeliveries")
|
|
1567
|
-
.withIndex("by_status", (q) => q.eq("status", "retrying"))
|
|
1568
|
-
.filter((q) =>
|
|
1569
|
-
q.or(
|
|
1570
|
-
q.eq(q.field("nextRetryAt"), undefined),
|
|
1571
|
-
q.lte(q.field("nextRetryAt"), now),
|
|
1572
|
-
),
|
|
1573
|
-
)
|
|
1574
|
-
.take(batchSize);
|
|
1575
|
-
|
|
1576
|
-
const allDeliveries = [...pending, ...retrying].slice(0, batchSize);
|
|
1577
|
-
const hasMoreDeliveries =
|
|
1578
|
-
pending.length >= batchSize || retrying.length >= batchSize;
|
|
1579
|
-
|
|
1580
|
-
for (const delivery of allDeliveries) {
|
|
1581
|
-
await ctx.scheduler.runAfter(
|
|
1582
|
-
0,
|
|
1583
|
-
internal.webhookTrigger.sendWebhookDelivery,
|
|
1584
|
-
{ deliveryId: delivery._id },
|
|
1585
|
-
);
|
|
1586
|
-
}
|
|
1587
|
-
|
|
1588
|
-
const result = {
|
|
1589
|
-
eventsProcessed: eventsToProcess.length,
|
|
1590
|
-
deliveriesCreated,
|
|
1591
|
-
deliveriesTriggered: allDeliveries.length,
|
|
1592
|
-
hasMore: hasMoreEvents || hasMoreDeliveries,
|
|
1593
|
-
};
|
|
1594
|
-
|
|
1595
|
-
console.log(
|
|
1596
|
-
`Webhook processor: processed ${result.eventsProcessed} events, ` +
|
|
1597
|
-
`created ${result.deliveriesCreated} deliveries, ` +
|
|
1598
|
-
`triggered ${result.deliveriesTriggered} deliveries`,
|
|
1599
|
-
);
|
|
1600
|
-
|
|
1601
|
-
// If there's more work, reschedule sooner
|
|
1602
|
-
if (result.hasMore) {
|
|
1603
|
-
await ctx.scheduler.runAfter(
|
|
1604
|
-
5000, // 5 seconds
|
|
1605
|
-
internal.webhookTrigger.triggerWebhookCheck,
|
|
1606
|
-
{},
|
|
1607
|
-
);
|
|
1608
|
-
}
|
|
1609
|
-
|
|
1610
|
-
return result;
|
|
1611
|
-
},
|
|
1612
|
-
});
|
|
1613
|
-
|
|
1614
|
-
// =============================================================================
|
|
1615
|
-
// Query Functions
|
|
1616
|
-
// =============================================================================
|
|
1617
|
-
|
|
1618
|
-
/**
|
|
1619
|
-
* Get delivery statistics for a webhook.
|
|
1620
|
-
*/
|
|
1621
|
-
export const getWebhookDeliveryStats = query({
|
|
1622
|
-
args: {
|
|
1623
|
-
webhookId: v.id("webhookConfigs"),
|
|
1624
|
-
since: v.optional(v.number()),
|
|
1625
|
-
},
|
|
1626
|
-
returns: v.object({
|
|
1627
|
-
total: v.number(),
|
|
1628
|
-
pending: v.number(),
|
|
1629
|
-
processing: v.number(),
|
|
1630
|
-
delivered: v.number(),
|
|
1631
|
-
failed: v.number(),
|
|
1632
|
-
retrying: v.number(),
|
|
1633
|
-
}),
|
|
1634
|
-
handler: async (ctx, args) => {
|
|
1635
|
-
const { webhookId, since } = args;
|
|
1636
|
-
|
|
1637
|
-
const deliveries = await ctx.db
|
|
1638
|
-
.query("webhookDeliveries")
|
|
1639
|
-
.withIndex("by_webhook", (q) => q.eq("webhookId", webhookId))
|
|
1640
|
-
.filter((q) =>
|
|
1641
|
-
since ? q.gte(q.field("_creationTime"), since) : q.eq(true, true),
|
|
1642
|
-
)
|
|
1643
|
-
.collect();
|
|
1644
|
-
|
|
1645
|
-
const stats = {
|
|
1646
|
-
total: deliveries.length,
|
|
1647
|
-
pending: 0,
|
|
1648
|
-
processing: 0,
|
|
1649
|
-
delivered: 0,
|
|
1650
|
-
failed: 0,
|
|
1651
|
-
retrying: 0,
|
|
1652
|
-
};
|
|
1653
|
-
|
|
1654
|
-
for (const d of deliveries) {
|
|
1655
|
-
stats[d.status]++;
|
|
1656
|
-
}
|
|
1657
|
-
|
|
1658
|
-
return stats;
|
|
1659
|
-
},
|
|
1660
|
-
});
|
|
1661
|
-
|
|
1662
|
-
/**
|
|
1663
|
-
* List recent deliveries for a webhook.
|
|
1664
|
-
*/
|
|
1665
|
-
export const listWebhookDeliveries = query({
|
|
1666
|
-
args: {
|
|
1667
|
-
webhookId: v.id("webhookConfigs"),
|
|
1668
|
-
status: v.optional(webhookDeliveryStatusValidator),
|
|
1669
|
-
limit: v.optional(v.number()),
|
|
1670
|
-
},
|
|
1671
|
-
returns: v.array(webhookDeliveryDoc),
|
|
1672
|
-
handler: async (ctx, args) => {
|
|
1673
|
-
const { webhookId, status, limit = 50 } = args;
|
|
1674
|
-
|
|
1675
|
-
let query = ctx.db
|
|
1676
|
-
.query("webhookDeliveries")
|
|
1677
|
-
.withIndex("by_webhook", (q) => q.eq("webhookId", webhookId));
|
|
1678
|
-
|
|
1679
|
-
if (status) {
|
|
1680
|
-
query = query.filter((q) => q.eq(q.field("status"), status));
|
|
1681
|
-
}
|
|
1682
|
-
|
|
1683
|
-
return await query.order("desc").take(limit);
|
|
1684
|
-
},
|
|
1685
|
-
});
|
|
1686
|
-
|
|
1687
|
-
/**
|
|
1688
|
-
* Get overall webhook statistics.
|
|
1689
|
-
*/
|
|
1690
|
-
export const getWebhookStats = query({
|
|
1691
|
-
args: {},
|
|
1692
|
-
returns: v.object({
|
|
1693
|
-
totalWebhooks: v.number(),
|
|
1694
|
-
activeWebhooks: v.number(),
|
|
1695
|
-
pendingDeliveries: v.number(),
|
|
1696
|
-
retryingDeliveries: v.number(),
|
|
1697
|
-
deliveriesLast24h: v.number(),
|
|
1698
|
-
successRateLast24h: v.number(),
|
|
1699
|
-
}),
|
|
1700
|
-
handler: async (ctx) => {
|
|
1701
|
-
// Count webhooks
|
|
1702
|
-
const allWebhooks = await ctx.db
|
|
1703
|
-
.query("webhookConfigs")
|
|
1704
|
-
.filter((q) => q.eq(q.field("deletedAt"), undefined))
|
|
1705
|
-
.collect();
|
|
1706
|
-
|
|
1707
|
-
const activeWebhooks = allWebhooks.filter((w) => w.enabled).length;
|
|
1708
|
-
|
|
1709
|
-
// Count pending and retrying deliveries
|
|
1710
|
-
const pending = await ctx.db
|
|
1711
|
-
.query("webhookDeliveries")
|
|
1712
|
-
.withIndex("by_status", (q) => q.eq("status", "pending"))
|
|
1713
|
-
.collect();
|
|
1714
|
-
|
|
1715
|
-
const retrying = await ctx.db
|
|
1716
|
-
.query("webhookDeliveries")
|
|
1717
|
-
.withIndex("by_status", (q) => q.eq("status", "retrying"))
|
|
1718
|
-
.collect();
|
|
1719
|
-
|
|
1720
|
-
// Get deliveries in last 24 hours
|
|
1721
|
-
const last24h = Date.now() - 24 * 60 * 60 * 1000;
|
|
1722
|
-
const recentDeliveries = await ctx.db
|
|
1723
|
-
.query("webhookDeliveries")
|
|
1724
|
-
.filter((q) => q.gte(q.field("_creationTime"), last24h))
|
|
1725
|
-
.collect();
|
|
1726
|
-
|
|
1727
|
-
const successfulRecent = recentDeliveries.filter(
|
|
1728
|
-
(d) => d.status === "delivered",
|
|
1729
|
-
).length;
|
|
1730
|
-
const completedRecent = recentDeliveries.filter(
|
|
1731
|
-
(d) => d.status === "delivered" || d.status === "failed",
|
|
1732
|
-
).length;
|
|
1733
|
-
|
|
1734
|
-
return {
|
|
1735
|
-
totalWebhooks: allWebhooks.length,
|
|
1736
|
-
activeWebhooks,
|
|
1737
|
-
pendingDeliveries: pending.length,
|
|
1738
|
-
retryingDeliveries: retrying.length,
|
|
1739
|
-
deliveriesLast24h: recentDeliveries.length,
|
|
1740
|
-
successRateLast24h:
|
|
1741
|
-
completedRecent > 0
|
|
1742
|
-
? Math.round((successfulRecent / completedRecent) * 100)
|
|
1743
|
-
: 100,
|
|
1744
|
-
};
|
|
1745
|
-
},
|
|
1746
|
-
});
|
|
1747
|
-
|
|
1748
|
-
/**
|
|
1749
|
-
* Get delivery details by ID.
|
|
1750
|
-
*/
|
|
1751
|
-
export const getDelivery = query({
|
|
1752
|
-
args: {
|
|
1753
|
-
deliveryId: v.id("webhookDeliveries"),
|
|
1754
|
-
},
|
|
1755
|
-
returns: v.union(webhookDeliveryDoc, v.null()),
|
|
1756
|
-
handler: async (ctx, args) => {
|
|
1757
|
-
return await ctx.db.get(args.deliveryId);
|
|
1758
|
-
},
|
|
1759
|
-
});
|
|
1760
|
-
|
|
1761
|
-
/**
|
|
1762
|
-
* Manually retry a failed delivery.
|
|
1763
|
-
*/
|
|
1764
|
-
export const retryDelivery = mutation({
|
|
1765
|
-
args: {
|
|
1766
|
-
deliveryId: v.id("webhookDeliveries"),
|
|
1767
|
-
},
|
|
1768
|
-
returns: v.object({
|
|
1769
|
-
success: v.boolean(),
|
|
1770
|
-
message: v.string(),
|
|
1771
|
-
}),
|
|
1772
|
-
handler: async (ctx, args) => {
|
|
1773
|
-
const { deliveryId } = args;
|
|
1774
|
-
|
|
1775
|
-
const delivery = await ctx.db.get(deliveryId);
|
|
1776
|
-
if (!delivery) {
|
|
1777
|
-
throw new Error(`Delivery not found: ${deliveryId}`);
|
|
1778
|
-
}
|
|
1779
|
-
|
|
1780
|
-
if (delivery.status === "delivered") {
|
|
1781
|
-
return {
|
|
1782
|
-
success: false,
|
|
1783
|
-
message: "Delivery already succeeded",
|
|
1784
|
-
};
|
|
1785
|
-
}
|
|
1786
|
-
|
|
1787
|
-
// Reset for retry
|
|
1788
|
-
await ctx.db.patch(deliveryId, {
|
|
1789
|
-
status: "pending",
|
|
1790
|
-
attemptCount: 0,
|
|
1791
|
-
lastError: undefined,
|
|
1792
|
-
nextRetryAt: undefined,
|
|
1793
|
-
});
|
|
1794
|
-
|
|
1795
|
-
// Schedule immediate delivery
|
|
1796
|
-
await ctx.scheduler.runAfter(
|
|
1797
|
-
0,
|
|
1798
|
-
internal.webhookTrigger.sendWebhookDelivery,
|
|
1799
|
-
{ deliveryId },
|
|
1800
|
-
);
|
|
1801
|
-
|
|
1802
|
-
return {
|
|
1803
|
-
success: true,
|
|
1804
|
-
message: "Delivery scheduled for retry",
|
|
1805
|
-
};
|
|
1806
|
-
},
|
|
1807
|
-
});
|
|
1808
|
-
|
|
1809
|
-
/**
|
|
1810
|
-
* Clean up old delivery records.
|
|
1811
|
-
*/
|
|
1812
|
-
export const cleanupOldDeliveries = mutation({
|
|
1813
|
-
args: {
|
|
1814
|
-
retentionDays: v.optional(v.number()),
|
|
1815
|
-
},
|
|
1816
|
-
returns: v.object({
|
|
1817
|
-
deletedCount: v.number(),
|
|
1818
|
-
}),
|
|
1819
|
-
handler: async (ctx, args) => {
|
|
1820
|
-
const { retentionDays = 30 } = args;
|
|
1821
|
-
const cutoffTime = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
|
1822
|
-
let deletedCount = 0;
|
|
1823
|
-
|
|
1824
|
-
// Get old successful deliveries
|
|
1825
|
-
const oldDeliveries = await ctx.db
|
|
1826
|
-
.query("webhookDeliveries")
|
|
1827
|
-
.withIndex("by_status", (q) => q.eq("status", "delivered"))
|
|
1828
|
-
.filter((q) => q.lt(q.field("_creationTime"), cutoffTime))
|
|
1829
|
-
.take(1000);
|
|
1830
|
-
|
|
1831
|
-
for (const delivery of oldDeliveries) {
|
|
1832
|
-
await ctx.db.delete(delivery._id);
|
|
1833
|
-
deletedCount++;
|
|
1834
|
-
}
|
|
1835
|
-
|
|
1836
|
-
return { deletedCount };
|
|
1837
|
-
},
|
|
1838
|
-
});
|
|
1839
|
-
|
|
1840
|
-
// =============================================================================
|
|
1841
|
-
// Test Helpers
|
|
1842
|
-
// =============================================================================
|
|
1843
|
-
|
|
1844
|
-
/**
|
|
1845
|
-
* Test a webhook by sending a test event.
|
|
1846
|
-
* Useful for verifying webhook configuration before enabling.
|
|
1847
|
-
*/
|
|
1848
|
-
export const testWebhook = mutation({
|
|
1849
|
-
args: {
|
|
1850
|
-
webhookId: v.id("webhookConfigs"),
|
|
1851
|
-
},
|
|
1852
|
-
returns: v.object({
|
|
1853
|
-
success: v.boolean(),
|
|
1854
|
-
message: v.string(),
|
|
1855
|
-
deliveryId: v.optional(v.id("webhookDeliveries")),
|
|
1856
|
-
}),
|
|
1857
|
-
handler: async (ctx, args) => {
|
|
1858
|
-
const { webhookId } = args;
|
|
1859
|
-
|
|
1860
|
-
const webhook = await ctx.db.get(webhookId);
|
|
1861
|
-
if (!webhook) {
|
|
1862
|
-
throw new Error(`Webhook not found: ${webhookId}`);
|
|
1863
|
-
}
|
|
1864
|
-
|
|
1865
|
-
// Create a test event
|
|
1866
|
-
const testEventId = await ctx.db.insert("cmsEvents", {
|
|
1867
|
-
eventType: "test.webhook",
|
|
1868
|
-
resourceType: "contentEntry",
|
|
1869
|
-
resourceId: "test-resource",
|
|
1870
|
-
action: "created",
|
|
1871
|
-
payload: {
|
|
1872
|
-
test: true,
|
|
1873
|
-
message: "This is a test webhook delivery",
|
|
1874
|
-
timestamp: new Date().toISOString(),
|
|
1875
|
-
},
|
|
1876
|
-
userId: undefined,
|
|
1877
|
-
processed: true, // Mark as processed so it doesn't trigger other handlers
|
|
1878
|
-
});
|
|
1879
|
-
|
|
1880
|
-
// Create test delivery
|
|
1881
|
-
const testPayload: WebhookPayload = {
|
|
1882
|
-
deliveryId: "test",
|
|
1883
|
-
eventType: "test.webhook",
|
|
1884
|
-
resourceType: "contentEntry",
|
|
1885
|
-
resourceId: "test-resource",
|
|
1886
|
-
action: "created",
|
|
1887
|
-
data: {
|
|
1888
|
-
test: true,
|
|
1889
|
-
message: "This is a test webhook delivery",
|
|
1890
|
-
},
|
|
1891
|
-
timestamp: new Date().toISOString(),
|
|
1892
|
-
};
|
|
1893
|
-
|
|
1894
|
-
const deliveryId = await ctx.db.insert("webhookDeliveries", {
|
|
1895
|
-
webhookId,
|
|
1896
|
-
eventId: testEventId,
|
|
1897
|
-
eventType: "test.webhook",
|
|
1898
|
-
status: "pending",
|
|
1899
|
-
attemptCount: 0,
|
|
1900
|
-
maxAttempts: 1,
|
|
1901
|
-
payload: testPayload,
|
|
1902
|
-
});
|
|
1903
|
-
|
|
1904
|
-
// Update with actual delivery ID
|
|
1905
|
-
await ctx.db.patch(deliveryId, {
|
|
1906
|
-
payload: { ...testPayload, deliveryId },
|
|
1907
|
-
});
|
|
1908
|
-
|
|
1909
|
-
// Schedule immediate delivery
|
|
1910
|
-
await ctx.scheduler.runAfter(
|
|
1911
|
-
0,
|
|
1912
|
-
internal.webhookTrigger.sendWebhookDelivery,
|
|
1913
|
-
{ deliveryId },
|
|
1914
|
-
);
|
|
1915
|
-
|
|
1916
|
-
return {
|
|
1917
|
-
success: true,
|
|
1918
|
-
message: `Test webhook scheduled. Check delivery ${deliveryId} for results.`,
|
|
1919
|
-
deliveryId,
|
|
1920
|
-
};
|
|
1921
|
-
},
|
|
1922
|
-
});
|