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.
Files changed (267) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +99 -0
  3. package/admin-dist/nitro.json +15 -0
  4. package/admin-dist/public/assets/CmsEmptyState-CRswfTzk.js +5 -0
  5. package/admin-dist/public/assets/CmsPageHeader-CirpXndm.js +1 -0
  6. package/admin-dist/public/assets/CmsStatusBadge-CbEUpQu-.js +1 -0
  7. package/admin-dist/public/assets/CmsToolbar-BI2nZOXp.js +1 -0
  8. package/admin-dist/public/assets/ContentEntryEditor-CBeCyK_m.js +4 -0
  9. package/admin-dist/public/assets/ErrorState-BIVaWmom.js +1 -0
  10. package/admin-dist/public/assets/TaxonomyFilter-ChaY6Y_x.js +1 -0
  11. package/admin-dist/public/assets/_contentTypeId-DQ8k_Rvw.js +1 -0
  12. package/admin-dist/public/assets/_entryId-CKU_glsK.js +1 -0
  13. package/admin-dist/public/assets/alert-BXjTqrwQ.js +1 -0
  14. package/admin-dist/public/assets/badge-hvUOzpVZ.js +1 -0
  15. package/admin-dist/public/assets/circle-check-big-CF_pR17r.js +1 -0
  16. package/admin-dist/public/assets/command-DU82cJlt.js +1 -0
  17. package/admin-dist/public/assets/content-_LXl3pp7.js +1 -0
  18. package/admin-dist/public/assets/content-types-KjxaXGxY.js +2 -0
  19. package/admin-dist/public/assets/globals-CS6BZ0zp.css +1 -0
  20. package/admin-dist/public/assets/index-DNGIZHL-.js +1 -0
  21. package/admin-dist/public/assets/label-KNtpL71g.js +1 -0
  22. package/admin-dist/public/assets/link-2-Bw2aI4V4.js +1 -0
  23. package/admin-dist/public/assets/list-sYepHjt_.js +1 -0
  24. package/admin-dist/public/assets/main-CKj5yfEi.js +97 -0
  25. package/admin-dist/public/assets/media-Bkrkffm7.js +1 -0
  26. package/admin-dist/public/assets/new._contentTypeId-C3LstjNs.js +1 -0
  27. package/admin-dist/public/assets/plus-DUn8v_Xf.js +1 -0
  28. package/admin-dist/public/assets/rotate-ccw-DJEoHcRI.js +1 -0
  29. package/admin-dist/public/assets/scroll-area-DfIlT0in.js +1 -0
  30. package/admin-dist/public/assets/search-MuAUDJKR.js +1 -0
  31. package/admin-dist/public/assets/select-BD29IXCI.js +1 -0
  32. package/admin-dist/public/assets/settings-DmMyn_6A.js +1 -0
  33. package/admin-dist/public/assets/switch-h3Rrnl5i.js +1 -0
  34. package/admin-dist/public/assets/tabs-imc8h-Dp.js +1 -0
  35. package/admin-dist/public/assets/taxonomies-dAsrT65H.js +1 -0
  36. package/admin-dist/public/assets/textarea-BTy7nwzR.js +1 -0
  37. package/admin-dist/public/assets/trash-SAWKZZHv.js +1 -0
  38. package/admin-dist/public/assets/triangle-alert-E52Vfeuh.js +1 -0
  39. package/admin-dist/public/assets/useBreadcrumbLabel-BECBMCzM.js +1 -0
  40. package/admin-dist/public/assets/usePermissions-Basjs9BT.js +1 -0
  41. package/admin-dist/public/favicon.ico +0 -0
  42. package/admin-dist/server/_chunks/_libs/@date-fns/tz.mjs +217 -0
  43. package/admin-dist/server/_chunks/_libs/@floating-ui/core.mjs +719 -0
  44. package/admin-dist/server/_chunks/_libs/@floating-ui/dom.mjs +622 -0
  45. package/admin-dist/server/_chunks/_libs/@floating-ui/react-dom.mjs +292 -0
  46. package/admin-dist/server/_chunks/_libs/@floating-ui/utils.mjs +320 -0
  47. package/admin-dist/server/_chunks/_libs/@radix-ui/number.mjs +6 -0
  48. package/admin-dist/server/_chunks/_libs/@radix-ui/primitive.mjs +11 -0
  49. package/admin-dist/server/_chunks/_libs/@radix-ui/react-arrow.mjs +23 -0
  50. package/admin-dist/server/_chunks/_libs/@radix-ui/react-avatar.mjs +119 -0
  51. package/admin-dist/server/_chunks/_libs/@radix-ui/react-checkbox.mjs +270 -0
  52. package/admin-dist/server/_chunks/_libs/@radix-ui/react-collection.mjs +69 -0
  53. package/admin-dist/server/_chunks/_libs/@radix-ui/react-compose-refs.mjs +39 -0
  54. package/admin-dist/server/_chunks/_libs/@radix-ui/react-context.mjs +137 -0
  55. package/admin-dist/server/_chunks/_libs/@radix-ui/react-dialog.mjs +325 -0
  56. package/admin-dist/server/_chunks/_libs/@radix-ui/react-direction.mjs +9 -0
  57. package/admin-dist/server/_chunks/_libs/@radix-ui/react-dismissable-layer.mjs +210 -0
  58. package/admin-dist/server/_chunks/_libs/@radix-ui/react-dropdown-menu.mjs +253 -0
  59. package/admin-dist/server/_chunks/_libs/@radix-ui/react-focus-guards.mjs +29 -0
  60. package/admin-dist/server/_chunks/_libs/@radix-ui/react-focus-scope.mjs +206 -0
  61. package/admin-dist/server/_chunks/_libs/@radix-ui/react-id.mjs +14 -0
  62. package/admin-dist/server/_chunks/_libs/@radix-ui/react-label.mjs +23 -0
  63. package/admin-dist/server/_chunks/_libs/@radix-ui/react-menu.mjs +812 -0
  64. package/admin-dist/server/_chunks/_libs/@radix-ui/react-popover.mjs +300 -0
  65. package/admin-dist/server/_chunks/_libs/@radix-ui/react-popper.mjs +286 -0
  66. package/admin-dist/server/_chunks/_libs/@radix-ui/react-portal.mjs +16 -0
  67. package/admin-dist/server/_chunks/_libs/@radix-ui/react-presence.mjs +128 -0
  68. package/admin-dist/server/_chunks/_libs/@radix-ui/react-primitive.mjs +141 -0
  69. package/admin-dist/server/_chunks/_libs/@radix-ui/react-roving-focus.mjs +224 -0
  70. package/admin-dist/server/_chunks/_libs/@radix-ui/react-scroll-area.mjs +721 -0
  71. package/admin-dist/server/_chunks/_libs/@radix-ui/react-select.mjs +1163 -0
  72. package/admin-dist/server/_chunks/_libs/@radix-ui/react-separator.mjs +28 -0
  73. package/admin-dist/server/_chunks/_libs/@radix-ui/react-slot.mjs +601 -0
  74. package/admin-dist/server/_chunks/_libs/@radix-ui/react-switch.mjs +152 -0
  75. package/admin-dist/server/_chunks/_libs/@radix-ui/react-tabs.mjs +189 -0
  76. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-callback-ref.mjs +11 -0
  77. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-controllable-state.mjs +69 -0
  78. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-effect-event.mjs +1 -0
  79. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-escape-keydown.mjs +17 -0
  80. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-is-hydrated.mjs +15 -0
  81. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-layout-effect.mjs +6 -0
  82. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-previous.mjs +14 -0
  83. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-size.mjs +39 -0
  84. package/admin-dist/server/_chunks/_libs/@radix-ui/react-visually-hidden.mjs +33 -0
  85. package/admin-dist/server/_chunks/_libs/@tanstack/history.mjs +409 -0
  86. package/admin-dist/server/_chunks/_libs/@tanstack/react-router.mjs +1711 -0
  87. package/admin-dist/server/_chunks/_libs/@tanstack/react-store.mjs +56 -0
  88. package/admin-dist/server/_chunks/_libs/@tanstack/router-core.mjs +4829 -0
  89. package/admin-dist/server/_chunks/_libs/@tanstack/store.mjs +134 -0
  90. package/admin-dist/server/_chunks/_libs/react-dom.mjs +10781 -0
  91. package/admin-dist/server/_chunks/_libs/react.mjs +513 -0
  92. package/admin-dist/server/_libs/aria-hidden.mjs +122 -0
  93. package/admin-dist/server/_libs/class-variance-authority.mjs +44 -0
  94. package/admin-dist/server/_libs/clsx.mjs +16 -0
  95. package/admin-dist/server/_libs/cmdk.mjs +315 -0
  96. package/admin-dist/server/_libs/convex.mjs +4841 -0
  97. package/admin-dist/server/_libs/cookie-es.mjs +58 -0
  98. package/admin-dist/server/_libs/croner.mjs +1 -0
  99. package/admin-dist/server/_libs/crossws.mjs +1 -0
  100. package/admin-dist/server/_libs/date-fns.mjs +1716 -0
  101. package/admin-dist/server/_libs/detect-node-es.mjs +1 -0
  102. package/admin-dist/server/_libs/get-nonce.mjs +9 -0
  103. package/admin-dist/server/_libs/h3-v2.mjs +277 -0
  104. package/admin-dist/server/_libs/h3.mjs +401 -0
  105. package/admin-dist/server/_libs/hookable.mjs +1 -0
  106. package/admin-dist/server/_libs/isbot.mjs +20 -0
  107. package/admin-dist/server/_libs/lucide-react.mjs +850 -0
  108. package/admin-dist/server/_libs/ohash.mjs +1 -0
  109. package/admin-dist/server/_libs/react-day-picker.mjs +2201 -0
  110. package/admin-dist/server/_libs/react-remove-scroll-bar.mjs +82 -0
  111. package/admin-dist/server/_libs/react-remove-scroll.mjs +328 -0
  112. package/admin-dist/server/_libs/react-style-singleton.mjs +69 -0
  113. package/admin-dist/server/_libs/rou3.mjs +8 -0
  114. package/admin-dist/server/_libs/seroval-plugins.mjs +58 -0
  115. package/admin-dist/server/_libs/seroval.mjs +1765 -0
  116. package/admin-dist/server/_libs/srvx.mjs +719 -0
  117. package/admin-dist/server/_libs/tailwind-merge.mjs +3010 -0
  118. package/admin-dist/server/_libs/tiny-invariant.mjs +12 -0
  119. package/admin-dist/server/_libs/tiny-warning.mjs +5 -0
  120. package/admin-dist/server/_libs/tslib.mjs +39 -0
  121. package/admin-dist/server/_libs/ufo.mjs +54 -0
  122. package/admin-dist/server/_libs/unctx.mjs +1 -0
  123. package/admin-dist/server/_libs/unstorage.mjs +1 -0
  124. package/admin-dist/server/_libs/use-callback-ref.mjs +66 -0
  125. package/admin-dist/server/_libs/use-sidecar.mjs +106 -0
  126. package/admin-dist/server/_libs/use-sync-external-store.mjs +139 -0
  127. package/admin-dist/server/_libs/zod.mjs +4223 -0
  128. package/admin-dist/server/_ssr/CmsEmptyState-DU7-7-mV.mjs +290 -0
  129. package/admin-dist/server/_ssr/CmsPageHeader-CseW0AHm.mjs +24 -0
  130. package/admin-dist/server/_ssr/CmsStatusBadge-B_pi4KCp.mjs +127 -0
  131. package/admin-dist/server/_ssr/CmsToolbar-X75ex6ek.mjs +49 -0
  132. package/admin-dist/server/_ssr/ContentEntryEditor-CepusRsA.mjs +3720 -0
  133. package/admin-dist/server/_ssr/ErrorState-cI-bKLez.mjs +89 -0
  134. package/admin-dist/server/_ssr/TaxonomyFilter-Bwrq0-cz.mjs +188 -0
  135. package/admin-dist/server/_ssr/_contentTypeId-BqYKEcLr.mjs +379 -0
  136. package/admin-dist/server/_ssr/_entryId-CRfnqeDf.mjs +161 -0
  137. package/admin-dist/server/_ssr/_tanstack-start-manifest_v-BwDlABVk.mjs +4 -0
  138. package/admin-dist/server/_ssr/alert-CVt45UUP.mjs +92 -0
  139. package/admin-dist/server/_ssr/badge-6BsP37vG.mjs +125 -0
  140. package/admin-dist/server/_ssr/command-fy8epIKf.mjs +128 -0
  141. package/admin-dist/server/_ssr/config.server-D7JHDcDv.mjs +117 -0
  142. package/admin-dist/server/_ssr/content-B5RhL7uW.mjs +532 -0
  143. package/admin-dist/server/_ssr/content-types-BIOqCQYN.mjs +1166 -0
  144. package/admin-dist/server/_ssr/index-DHSHDPt1.mjs +193 -0
  145. package/admin-dist/server/_ssr/index.mjs +1275 -0
  146. package/admin-dist/server/_ssr/label-C8Dko1j7.mjs +22 -0
  147. package/admin-dist/server/_ssr/media-CSx3XttC.mjs +1832 -0
  148. package/admin-dist/server/_ssr/new._contentTypeId-DzanEZQM.mjs +144 -0
  149. package/admin-dist/server/_ssr/router-DDWcF-kt.mjs +1556 -0
  150. package/admin-dist/server/_ssr/scroll-area-bjPYwhXN.mjs +59 -0
  151. package/admin-dist/server/_ssr/select-BUhDDf4T.mjs +142 -0
  152. package/admin-dist/server/_ssr/settings-DAsxnw2q.mjs +348 -0
  153. package/admin-dist/server/_ssr/start-HYkvq4Ni.mjs +4 -0
  154. package/admin-dist/server/_ssr/switch-BgyRtQ1Z.mjs +31 -0
  155. package/admin-dist/server/_ssr/tabs-DzMdRB1A.mjs +628 -0
  156. package/admin-dist/server/_ssr/taxonomies-C8j8g5Q5.mjs +915 -0
  157. package/admin-dist/server/_ssr/textarea-9jNeYJSc.mjs +18 -0
  158. package/admin-dist/server/_ssr/trash-DYMxwhZB.mjs +291 -0
  159. package/admin-dist/server/_ssr/useBreadcrumbLabel-FNSAr2Ha.mjs +16 -0
  160. package/admin-dist/server/_ssr/usePermissions-BJGGahrJ.mjs +68 -0
  161. package/admin-dist/server/favicon.ico +0 -0
  162. package/admin-dist/server/index.mjs +627 -0
  163. package/dist/cli/index.js +0 -0
  164. package/dist/client/admin-config.d.ts +0 -1
  165. package/dist/client/admin-config.d.ts.map +1 -1
  166. package/dist/client/admin-config.js +0 -1
  167. package/dist/client/admin-config.js.map +1 -1
  168. package/dist/client/adminApi.d.ts.map +1 -1
  169. package/dist/client/agentTools.d.ts +1237 -135
  170. package/dist/client/agentTools.d.ts.map +1 -1
  171. package/dist/client/agentTools.js +33 -9
  172. package/dist/client/agentTools.js.map +1 -1
  173. package/dist/client/index.d.ts +1 -1
  174. package/dist/client/index.d.ts.map +1 -1
  175. package/dist/client/index.js.map +1 -1
  176. package/dist/component/_generated/component.d.ts +9 -0
  177. package/dist/component/_generated/component.d.ts.map +1 -1
  178. package/dist/component/mediaAssets.d.ts +35 -0
  179. package/dist/component/mediaAssets.d.ts.map +1 -1
  180. package/dist/component/mediaAssets.js +81 -0
  181. package/dist/component/mediaAssets.js.map +1 -1
  182. package/dist/test.d.ts.map +1 -1
  183. package/dist/test.js +2 -1
  184. package/dist/test.js.map +1 -1
  185. package/package.json +24 -9
  186. package/dist/component/auditLog.d.ts +0 -410
  187. package/dist/component/auditLog.d.ts.map +0 -1
  188. package/dist/component/auditLog.js +0 -607
  189. package/dist/component/auditLog.js.map +0 -1
  190. package/dist/component/types.d.ts +0 -4
  191. package/dist/component/types.d.ts.map +0 -1
  192. package/dist/component/types.js +0 -2
  193. package/dist/component/types.js.map +0 -1
  194. package/src/cli/commands/admin.ts +0 -104
  195. package/src/cli/index.ts +0 -21
  196. package/src/cli/utils/detectConvexUrl.ts +0 -54
  197. package/src/cli/utils/openBrowser.ts +0 -16
  198. package/src/client/admin-config.ts +0 -138
  199. package/src/client/adminApi.ts +0 -942
  200. package/src/client/agentTools.ts +0 -1311
  201. package/src/client/argTypes.ts +0 -316
  202. package/src/client/field-types.ts +0 -187
  203. package/src/client/index.ts +0 -1301
  204. package/src/client/queryBuilder.ts +0 -1100
  205. package/src/client/schema/codegen.ts +0 -500
  206. package/src/client/schema/defineContentType.ts +0 -501
  207. package/src/client/schema/index.ts +0 -169
  208. package/src/client/schema/schemaDrift.ts +0 -574
  209. package/src/client/schema/typedClient.ts +0 -688
  210. package/src/client/schema/types.ts +0 -666
  211. package/src/client/types.ts +0 -723
  212. package/src/client/workflows.ts +0 -141
  213. package/src/client/wrapper.ts +0 -4304
  214. package/src/component/_generated/api.ts +0 -140
  215. package/src/component/_generated/component.ts +0 -5029
  216. package/src/component/_generated/dataModel.ts +0 -60
  217. package/src/component/_generated/server.ts +0 -156
  218. package/src/component/authorization.ts +0 -647
  219. package/src/component/authorizationHooks.ts +0 -668
  220. package/src/component/bulkOperations.ts +0 -687
  221. package/src/component/contentEntries.ts +0 -1976
  222. package/src/component/contentEntryMutations.ts +0 -1223
  223. package/src/component/contentEntryValidation.ts +0 -707
  224. package/src/component/contentLock.ts +0 -550
  225. package/src/component/contentTypeMigration.ts +0 -1064
  226. package/src/component/contentTypeMutations.ts +0 -969
  227. package/src/component/contentTypes.ts +0 -346
  228. package/src/component/convex.config.ts +0 -44
  229. package/src/component/documentTypes.ts +0 -240
  230. package/src/component/eventEmitter.ts +0 -485
  231. package/src/component/exportImport.ts +0 -1169
  232. package/src/component/index.ts +0 -491
  233. package/src/component/lib/deepReferenceResolver.ts +0 -999
  234. package/src/component/lib/errors.ts +0 -816
  235. package/src/component/lib/index.ts +0 -145
  236. package/src/component/lib/mediaReferenceResolver.ts +0 -495
  237. package/src/component/lib/metadataExtractor.ts +0 -792
  238. package/src/component/lib/mutationAuth.ts +0 -199
  239. package/src/component/lib/queries.ts +0 -79
  240. package/src/component/lib/ragContentChunker.ts +0 -1371
  241. package/src/component/lib/referenceResolver.ts +0 -430
  242. package/src/component/lib/slugGenerator.ts +0 -262
  243. package/src/component/lib/slugUniqueness.ts +0 -333
  244. package/src/component/lib/softDelete.ts +0 -44
  245. package/src/component/localeFallbackChain.ts +0 -673
  246. package/src/component/localeFields.ts +0 -896
  247. package/src/component/mediaAssetMutations.ts +0 -725
  248. package/src/component/mediaAssets.ts +0 -932
  249. package/src/component/mediaFolderMutations.ts +0 -1046
  250. package/src/component/mediaUploadMutations.ts +0 -224
  251. package/src/component/mediaVariantMutations.ts +0 -900
  252. package/src/component/mediaVariants.ts +0 -793
  253. package/src/component/ragContentIndexer.ts +0 -1067
  254. package/src/component/rateLimitHooks.ts +0 -572
  255. package/src/component/roles.ts +0 -1360
  256. package/src/component/scheduledPublish.ts +0 -358
  257. package/src/component/schema.ts +0 -617
  258. package/src/component/taxonomies.ts +0 -949
  259. package/src/component/taxonomyMutations.ts +0 -1210
  260. package/src/component/trash.ts +0 -724
  261. package/src/component/userContext.ts +0 -898
  262. package/src/component/validation.ts +0 -1388
  263. package/src/component/validators.ts +0 -949
  264. package/src/component/versionMutations.ts +0 -392
  265. package/src/component/webhookTrigger.ts +0 -1922
  266. package/src/react/index.ts +0 -898
  267. 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
- });