@coursebuilder/server 0.1.0-canary.0.0c9beb2ae

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 (156) hide show
  1. package/LICENSE +21 -0
  2. package/dist/actions/checkout.d.ts +6 -0
  3. package/dist/actions/checkout.js +9 -0
  4. package/dist/actions/checkout.js.map +1 -0
  5. package/dist/actions/claimed.d.ts +6 -0
  6. package/dist/actions/claimed.js +8 -0
  7. package/dist/actions/claimed.js.map +1 -0
  8. package/dist/actions/create-magic-link.d.ts +6 -0
  9. package/dist/actions/create-magic-link.js +9 -0
  10. package/dist/actions/create-magic-link.js.map +1 -0
  11. package/dist/actions/index.d.ts +11 -0
  12. package/dist/actions/index.js +18 -0
  13. package/dist/actions/index.js.map +1 -0
  14. package/dist/actions/prices-coupon-for-code.d.ts +6 -0
  15. package/dist/actions/prices-coupon-for-code.js +63 -0
  16. package/dist/actions/prices-coupon-for-code.js.map +1 -0
  17. package/dist/actions/prices-formatted.d.ts +6 -0
  18. package/dist/actions/prices-formatted.js +8 -0
  19. package/dist/actions/prices-formatted.js.map +1 -0
  20. package/dist/actions/process-refund.d.ts +1 -0
  21. package/dist/actions/process-refund.js +8 -0
  22. package/dist/actions/process-refund.js.map +1 -0
  23. package/dist/actions/redeem.d.ts +1 -0
  24. package/dist/actions/redeem.js +8 -0
  25. package/dist/actions/redeem.js.map +1 -0
  26. package/dist/actions/transfer-purchase.d.ts +1 -0
  27. package/dist/actions/transfer-purchase.js +8 -0
  28. package/dist/actions/transfer-purchase.js.map +1 -0
  29. package/dist/actions/user-lookup.d.ts +6 -0
  30. package/dist/actions/user-lookup.js +9 -0
  31. package/dist/actions/user-lookup.js.map +1 -0
  32. package/dist/adapter-BtDS6LDw.d.ts +9 -0
  33. package/dist/chunk-22AMOZYZ.js +67 -0
  34. package/dist/chunk-22AMOZYZ.js.map +1 -0
  35. package/dist/chunk-4EQQSW57.js +32 -0
  36. package/dist/chunk-4EQQSW57.js.map +1 -0
  37. package/dist/chunk-4LZZLN6T.js +52 -0
  38. package/dist/chunk-4LZZLN6T.js.map +1 -0
  39. package/dist/chunk-4QQA3TC2.js +29 -0
  40. package/dist/chunk-4QQA3TC2.js.map +1 -0
  41. package/dist/chunk-4TSEPUSC.js +160 -0
  42. package/dist/chunk-4TSEPUSC.js.map +1 -0
  43. package/dist/chunk-5O4UO4IR.js +93 -0
  44. package/dist/chunk-5O4UO4IR.js.map +1 -0
  45. package/dist/chunk-7FCQOLKL.js +26 -0
  46. package/dist/chunk-7FCQOLKL.js.map +1 -0
  47. package/dist/chunk-AZLUMIIF.js +8 -0
  48. package/dist/chunk-AZLUMIIF.js.map +1 -0
  49. package/dist/chunk-E6HV6TNI.js +38 -0
  50. package/dist/chunk-E6HV6TNI.js.map +1 -0
  51. package/dist/chunk-ECNJPNPU.js +54 -0
  52. package/dist/chunk-ECNJPNPU.js.map +1 -0
  53. package/dist/chunk-F3KUYN5I.js +111 -0
  54. package/dist/chunk-F3KUYN5I.js.map +1 -0
  55. package/dist/chunk-G63YAJF2.js +107 -0
  56. package/dist/chunk-G63YAJF2.js.map +1 -0
  57. package/dist/chunk-JFR7XO72.js +7 -0
  58. package/dist/chunk-JFR7XO72.js.map +1 -0
  59. package/dist/chunk-KNC7QVGZ.js +26 -0
  60. package/dist/chunk-KNC7QVGZ.js.map +1 -0
  61. package/dist/chunk-ODSSRMD2.js +270 -0
  62. package/dist/chunk-ODSSRMD2.js.map +1 -0
  63. package/dist/chunk-VEKYIR7I.js +26 -0
  64. package/dist/chunk-VEKYIR7I.js.map +1 -0
  65. package/dist/chunk-W7YAT4NR.js +12 -0
  66. package/dist/chunk-W7YAT4NR.js.map +1 -0
  67. package/dist/chunk-WGAPYIUP.js +56 -0
  68. package/dist/chunk-WGAPYIUP.js.map +1 -0
  69. package/dist/chunk-WYNH2AWD.js +7 -0
  70. package/dist/chunk-WYNH2AWD.js.map +1 -0
  71. package/dist/chunk-Y5X5J55Z.js +384 -0
  72. package/dist/chunk-Y5X5J55Z.js.map +1 -0
  73. package/dist/chunk-YFLGJIMA.js +216 -0
  74. package/dist/chunk-YFLGJIMA.js.map +1 -0
  75. package/dist/chunk-ZFZNN4E5.js +7 -0
  76. package/dist/chunk-ZFZNN4E5.js.map +1 -0
  77. package/dist/create-inngest-middleware.d.ts +635 -0
  78. package/dist/create-inngest-middleware.js +12 -0
  79. package/dist/create-inngest-middleware.js.map +1 -0
  80. package/dist/http-router.d.ts +20 -0
  81. package/dist/http-router.js +24 -0
  82. package/dist/http-router.js.map +1 -0
  83. package/dist/http.d.ts +38 -0
  84. package/dist/http.js +32 -0
  85. package/dist/http.js.map +1 -0
  86. package/dist/index.d.ts +34 -0
  87. package/dist/index.js +365 -0
  88. package/dist/index.js.map +1 -0
  89. package/dist/magic-string.es-ENO43NON.js +1389 -0
  90. package/dist/magic-string.es-ENO43NON.js.map +1 -0
  91. package/dist/runtime/actions.d.ts +5 -0
  92. package/dist/runtime/actions.js +8 -0
  93. package/dist/runtime/actions.js.map +1 -0
  94. package/dist/runtime/assert.d.ts +15 -0
  95. package/dist/runtime/assert.js +8 -0
  96. package/dist/runtime/assert.js.map +1 -0
  97. package/dist/runtime/cookie.d.ts +8 -0
  98. package/dist/runtime/cookie.js +8 -0
  99. package/dist/runtime/cookie.js.map +1 -0
  100. package/dist/runtime/env.d.ts +9 -0
  101. package/dist/runtime/env.js +11 -0
  102. package/dist/runtime/env.js.map +1 -0
  103. package/dist/runtime/env.test.d.ts +2 -0
  104. package/dist/runtime/env.test.js +14956 -0
  105. package/dist/runtime/env.test.js.map +1 -0
  106. package/dist/runtime/init.d.ts +26 -0
  107. package/dist/runtime/init.js +14 -0
  108. package/dist/runtime/init.js.map +1 -0
  109. package/dist/runtime/logger.d.ts +4 -0
  110. package/dist/runtime/logger.js +10 -0
  111. package/dist/runtime/logger.js.map +1 -0
  112. package/dist/runtime/merge.d.ts +4 -0
  113. package/dist/runtime/merge.js +8 -0
  114. package/dist/runtime/merge.js.map +1 -0
  115. package/dist/runtime/providers.d.ts +21 -0
  116. package/dist/runtime/providers.js +9 -0
  117. package/dist/runtime/providers.js.map +1 -0
  118. package/dist/runtime/web.d.ts +29 -0
  119. package/dist/runtime/web.js +16 -0
  120. package/dist/runtime/web.js.map +1 -0
  121. package/package.json +65 -0
  122. package/src/actions/actions.test.ts +232 -0
  123. package/src/actions/checkout.ts +160 -0
  124. package/src/actions/claimed.ts +40 -0
  125. package/src/actions/create-magic-link.ts +78 -0
  126. package/src/actions/index.ts +401 -0
  127. package/src/actions/prices-coupon-for-code.ts +91 -0
  128. package/src/actions/prices-formatted.ts +313 -0
  129. package/src/actions/process-refund.ts +2 -0
  130. package/src/actions/redeem.ts +2 -0
  131. package/src/actions/transfer-purchase.ts +2 -0
  132. package/src/actions/user-lookup.ts +37 -0
  133. package/src/adapter.ts +28 -0
  134. package/src/create-inngest-middleware.ts +157 -0
  135. package/src/http-actions.ts +29 -0
  136. package/src/http-router.ts +79 -0
  137. package/src/http.test.ts +46 -0
  138. package/src/http.ts +83 -0
  139. package/src/index.test.ts +21 -0
  140. package/src/index.ts +44 -0
  141. package/src/mock-adapter.ts +398 -0
  142. package/src/mock-payment-provider.ts +56 -0
  143. package/src/resource-chat.ts +371 -0
  144. package/src/runtime/actions.ts +25 -0
  145. package/src/runtime/assert.ts +55 -0
  146. package/src/runtime/cookie.ts +12 -0
  147. package/src/runtime/env.test.ts +49 -0
  148. package/src/runtime/env.ts +63 -0
  149. package/src/runtime/init.ts +112 -0
  150. package/src/runtime/logger.ts +6 -0
  151. package/src/runtime/merge.ts +25 -0
  152. package/src/runtime/providers.ts +38 -0
  153. package/src/runtime/web.ts +199 -0
  154. package/src/streaming-chat-prompt-executor.ts +43 -0
  155. package/src/type-contracts.test.ts +167 -0
  156. package/src/web-body-parse.test.ts +55 -0
@@ -0,0 +1,313 @@
1
+ import { isAfter } from 'date-fns'
2
+ import { z } from 'zod'
3
+
4
+ import { formatPricesForProduct } from '@coursebuilder/commerce/format-prices-for-product'
5
+ import type { ServerAdapter } from '../adapter'
6
+ import { Coupon, Purchase } from '@coursebuilder/core/schemas'
7
+ import { PricingFormattedInputSchema } from '@coursebuilder/core/schemas/pricing-formatted-input-schema'
8
+ import type {
9
+ InternalOptions,
10
+ RequestInternal,
11
+ ResponseInternal,
12
+ } from '@coursebuilder/core/types'
13
+ import { find } from '@coursebuilder/nodash'
14
+
15
+ import type { Cookie } from '../runtime/cookie'
16
+
17
+ const checkForAnyAvailableUpgrades = async ({
18
+ upgradeFromPurchaseId,
19
+ productId,
20
+ purchases,
21
+ courseBuilderAdapter,
22
+ }: {
23
+ upgradeFromPurchaseId: string | undefined
24
+ productId: string
25
+ purchases: Array<{ id: string; productId: string; status: string }>
26
+ country: string
27
+ courseBuilderAdapter: ServerAdapter
28
+ }) => {
29
+ if (upgradeFromPurchaseId) return upgradeFromPurchaseId
30
+
31
+ const validPurchases = getValidPurchases(purchases)
32
+ const productIdsAlreadyPurchased = validPurchases.map(
33
+ (purchase) => purchase.productId,
34
+ )
35
+
36
+ const potentialUpgrades =
37
+ await courseBuilderAdapter.availableUpgradesForProduct(
38
+ validPurchases,
39
+ productId,
40
+ )
41
+
42
+ type AvailableUpgrade = Awaited<
43
+ ReturnType<typeof courseBuilderAdapter.availableUpgradesForProduct>
44
+ >[0]
45
+ // filter out potential upgrades that have already been purchased
46
+ const availableUpgrades = potentialUpgrades.filter<AvailableUpgrade>(
47
+ (
48
+ availableUpgrade: AvailableUpgrade,
49
+ ): availableUpgrade is AvailableUpgrade => {
50
+ return !productIdsAlreadyPurchased.includes(
51
+ availableUpgrade.upgradableTo.id,
52
+ )
53
+ },
54
+ )
55
+
56
+ return find(validPurchases, (purchase) => {
57
+ const upgradeProductIds = availableUpgrades.map(
58
+ (upgrade) => upgrade.upgradableFrom.id,
59
+ )
60
+ return upgradeProductIds.includes(purchase.productId)
61
+ })?.id
62
+ }
63
+
64
+ const getValidPurchases = (purchases: any[]): Purchase[] => {
65
+ return purchases.filter((purchase: Purchase) =>
66
+ ['Valid', 'Restricted'].includes(purchase.status),
67
+ )
68
+ }
69
+
70
+ function couponIsValid(coupon?: Coupon | null) {
71
+ if (coupon) {
72
+ const unlimitedUse = coupon.maxUses === -1
73
+ const now = new Date()
74
+ if (!unlimitedUse && coupon.usedCount >= coupon.maxUses) return false
75
+ if (coupon.expires && isAfter(now, coupon.expires)) return false
76
+ } else {
77
+ return false
78
+ }
79
+ return true
80
+ }
81
+
82
+ async function getActiveMerchantCoupon({
83
+ productId,
84
+ siteCouponId,
85
+ code,
86
+ courseBuilderAdapter,
87
+ }: {
88
+ productId: string | undefined
89
+ siteCouponId: string | null | undefined
90
+ code: string | undefined
91
+ courseBuilderAdapter: ServerAdapter
92
+ }) {
93
+ let activeMerchantCoupon = null
94
+ let usedCouponId
95
+
96
+ const defaultCoupons = productId
97
+ ? await courseBuilderAdapter.getDefaultCoupon([productId])
98
+ : undefined
99
+
100
+ const defaultMerchantCoupon = defaultCoupons
101
+ ? defaultCoupons.defaultMerchantCoupon
102
+ : null
103
+
104
+ const incomingCoupon = await courseBuilderAdapter.couponForIdOrCode({
105
+ couponId: siteCouponId,
106
+ code,
107
+ })
108
+
109
+ if (
110
+ // compare the discounts if there is a coupon and site/sale running
111
+ incomingCoupon?.merchantCoupon &&
112
+ couponIsValid(incomingCoupon) &&
113
+ defaultMerchantCoupon
114
+ ) {
115
+ // use whichever coupon provides the bigger discount
116
+ const { merchantCoupon: incomingMerchantCoupon } = incomingCoupon
117
+
118
+ // Get product price to compare actual discount amounts
119
+ const product = productId
120
+ ? await courseBuilderAdapter.getProduct(productId)
121
+ : null
122
+ const price = product
123
+ ? await courseBuilderAdapter.getPriceForProduct(productId!)
124
+ : null
125
+ const unitPrice = price?.unitAmount || 0
126
+
127
+ // Calculate actual discount amounts in dollars
128
+ const incomingDiscountAmount =
129
+ incomingMerchantCoupon.amountDiscount !== null &&
130
+ incomingMerchantCoupon.amountDiscount !== undefined &&
131
+ incomingMerchantCoupon.amountDiscount > 0
132
+ ? incomingMerchantCoupon.amountDiscount / 100 // Convert cents to dollars
133
+ : (incomingMerchantCoupon.percentageDiscount ?? 0) * unitPrice
134
+
135
+ const defaultDiscountAmount =
136
+ defaultMerchantCoupon.amountDiscount !== null &&
137
+ defaultMerchantCoupon.amountDiscount !== undefined &&
138
+ defaultMerchantCoupon.amountDiscount > 0
139
+ ? defaultMerchantCoupon.amountDiscount / 100 // Convert cents to dollars
140
+ : (defaultMerchantCoupon.percentageDiscount ?? 0) * unitPrice
141
+
142
+ if (incomingDiscountAmount >= defaultDiscountAmount) {
143
+ activeMerchantCoupon = incomingMerchantCoupon
144
+ usedCouponId = incomingCoupon.id
145
+ } else {
146
+ activeMerchantCoupon = defaultMerchantCoupon
147
+ usedCouponId = defaultCoupons?.defaultCoupon?.id
148
+ }
149
+ } else if (
150
+ // if it's a coupon, use it
151
+ incomingCoupon?.merchantCoupon &&
152
+ couponIsValid(incomingCoupon)
153
+ ) {
154
+ activeMerchantCoupon = incomingCoupon.merchantCoupon
155
+ usedCouponId = incomingCoupon.id
156
+ } else if (
157
+ // if a sale is running, use that
158
+ defaultMerchantCoupon
159
+ ) {
160
+ activeMerchantCoupon = defaultMerchantCoupon
161
+ usedCouponId = defaultCoupons?.defaultCoupon?.id
162
+ }
163
+
164
+ const defaultCoupon = defaultCoupons?.defaultCoupon
165
+
166
+ return {
167
+ usedCouponId,
168
+ activeMerchantCoupon,
169
+ ...(defaultCoupon &&
170
+ defaultCoupon.merchantCouponId === activeMerchantCoupon?.id && {
171
+ defaultCoupon,
172
+ }),
173
+ }
174
+ }
175
+
176
+ const CheckForAvailableCouponsSchema = PricingFormattedInputSchema.pick({
177
+ merchantCoupon: true,
178
+ couponId: true,
179
+ code: true,
180
+ productId: true,
181
+ })
182
+ type CheckForAvailableCoupons = z.infer<typeof CheckForAvailableCouponsSchema>
183
+
184
+ const checkForAvailableCoupons = async ({
185
+ merchantCoupon,
186
+ couponId,
187
+ productId,
188
+ courseBuilderAdapter,
189
+ }: CheckForAvailableCoupons & {
190
+ courseBuilderAdapter: ServerAdapter
191
+ }) => {
192
+ // explicit incoming merchant coupons are honored
193
+ // without checking for other potential coupons
194
+ // if there is no explicit incoming merchant coupon
195
+ // we check for default/global coupon or an incoming code
196
+ if (merchantCoupon?.id) {
197
+ return {
198
+ activeMerchantCoupon: merchantCoupon,
199
+ defaultCoupon: undefined,
200
+ }
201
+ } else {
202
+ const { activeMerchantCoupon, defaultCoupon, usedCouponId } =
203
+ await getActiveMerchantCoupon({
204
+ siteCouponId: couponId,
205
+ productId,
206
+ code: undefined,
207
+ courseBuilderAdapter,
208
+ })
209
+
210
+ const minimalDefaultCoupon = defaultCoupon && {
211
+ expires: defaultCoupon.expires?.toISOString(),
212
+ percentageDiscount: defaultCoupon.percentageDiscount?.toString() || '0',
213
+ }
214
+
215
+ return {
216
+ activeMerchantCoupon,
217
+ defaultCoupon,
218
+ usedCouponId,
219
+ }
220
+ }
221
+ }
222
+
223
+ export async function getPricesFormatted(
224
+ request: RequestInternal,
225
+ cookies: Cookie[],
226
+ options: InternalOptions,
227
+ ): Promise<ResponseInternal> {
228
+ const currentUser = options.getCurrentUser
229
+ ? await options.getCurrentUser()
230
+ : null
231
+ const {
232
+ productId,
233
+ quantity = 1,
234
+ couponId,
235
+ merchantCoupon,
236
+ upgradeFromPurchaseId: _upgradeFromPurchaseId,
237
+ autoApplyPPP,
238
+ userId = currentUser?.id,
239
+ } = PricingFormattedInputSchema.parse(request.body)
240
+
241
+ if (!productId) return { status: 400, body: 'productId is required' }
242
+
243
+ if (!options.adapter) return { status: 400, body: 'Adapter not found' }
244
+
245
+ const purchases = getValidPurchases(
246
+ await options.adapter.getPurchasesForUser(userId),
247
+ )
248
+
249
+ const country =
250
+ request.headers?.['x-vercel-ip-country'] ||
251
+ request.body?.country ||
252
+ process.env.DEFAULT_COUNTRY ||
253
+ 'US'
254
+
255
+ let upgradeFromPurchaseId = await checkForAnyAvailableUpgrades({
256
+ upgradeFromPurchaseId: _upgradeFromPurchaseId,
257
+ productId,
258
+ purchases,
259
+ country,
260
+ courseBuilderAdapter: options.adapter,
261
+ })
262
+
263
+ const restrictedPurchase = purchases.find((purchase) => {
264
+ return purchase.productId === productId && purchase.status === 'Restricted'
265
+ })
266
+
267
+ if (restrictedPurchase) {
268
+ const validPurchase = purchases.find((purchase) => {
269
+ return purchase.productId === productId && purchase.status === 'Valid'
270
+ })
271
+
272
+ if (!validPurchase) {
273
+ upgradeFromPurchaseId = restrictedPurchase.id
274
+ }
275
+ }
276
+
277
+ const { activeMerchantCoupon, defaultCoupon, usedCouponId } =
278
+ await checkForAvailableCoupons({
279
+ merchantCoupon,
280
+ couponId,
281
+ productId,
282
+ courseBuilderAdapter: options.adapter,
283
+ })
284
+ const usedCoupon = usedCouponId
285
+ ? await options.adapter.getCoupon(usedCouponId)
286
+ : null
287
+ const productPrices = await formatPricesForProduct({
288
+ productId,
289
+ country,
290
+ quantity,
291
+ merchantCouponId: activeMerchantCoupon?.id,
292
+ ...(upgradeFromPurchaseId && { upgradeFromPurchaseId }),
293
+ userId,
294
+ autoApplyPPP,
295
+ usedCouponId,
296
+ ctx: options.adapter,
297
+ })
298
+
299
+ return {
300
+ body: {
301
+ ...productPrices,
302
+ ...(defaultCoupon && { defaultCoupon }),
303
+ ...(usedCoupon && {
304
+ usedCoupon,
305
+ usedCouponId,
306
+ }),
307
+ },
308
+ headers: {
309
+ 'Content-Type': 'application/json',
310
+ },
311
+ cookies,
312
+ }
313
+ }
@@ -0,0 +1,2 @@
1
+ /** HTTP-compatible refund operation owned by the commerce engine. */
2
+ export { processRefund } from '@coursebuilder/commerce/process-refund'
@@ -0,0 +1,2 @@
1
+ /** HTTP-compatible redemption operation owned by the commerce engine. */
2
+ export { redeem } from '@coursebuilder/commerce/redeem'
@@ -0,0 +1,2 @@
1
+ /** HTTP-compatible purchase transfer operation owned by the commerce engine. */
2
+ export { transferPurchase } from '@coursebuilder/commerce/transfer-purchase'
@@ -0,0 +1,37 @@
1
+ import type {
2
+ InternalOptions,
3
+ RequestInternal,
4
+ ResponseInternal,
5
+ } from '@coursebuilder/core/types'
6
+
7
+ import type { Cookie } from '../runtime/cookie'
8
+ import { logger } from '../runtime/logger'
9
+
10
+ export async function userLookup(
11
+ request: RequestInternal,
12
+ cookies: Cookie[],
13
+ options: InternalOptions,
14
+ ): Promise<ResponseInternal> {
15
+ if (!options.adapter) throw new Error('Adapter not found')
16
+ if (request.headers?.['x-skill-secret'] !== process.env.SKILL_SECRET) {
17
+ return { status: 401, body: 'unauthorized' }
18
+ }
19
+
20
+ const email =
21
+ (request.query?.email as string) || (request.body?.email as string)
22
+
23
+ try {
24
+ const user = await options.adapter.getUserWithPurchasersByEmail(email)
25
+ return {
26
+ status: 200,
27
+ body: JSON.stringify(user),
28
+ }
29
+ } catch (e) {
30
+ logger.error(e instanceof Error ? e : new Error(String(e)), {
31
+ context: 'user-lookup.by-email',
32
+ hasEmail: Boolean(email),
33
+ emailSource: request.query?.email ? 'query' : 'body',
34
+ })
35
+ throw new Error('unable-to-lookup-user')
36
+ }
37
+ }
package/src/adapter.ts ADDED
@@ -0,0 +1,28 @@
1
+ import type { AuthStoragePort } from '@coursebuilder/core/auth'
2
+ import type {
3
+ CatalogPort,
4
+ ContentResourcePort,
5
+ CouponPort,
6
+ EntitlementPort,
7
+ IdentityPort,
8
+ MerchantPort,
9
+ OrganizationPort,
10
+ ProgressPort,
11
+ PurchasePort,
12
+ SubscriptionPort,
13
+ } from '@coursebuilder/core/ports'
14
+
15
+ /** Capability composition required by the Course Builder server facade. */
16
+ export type ServerAdapter = AuthStoragePort &
17
+ CatalogPort &
18
+ ContentResourcePort &
19
+ CouponPort &
20
+ EntitlementPort &
21
+ IdentityPort &
22
+ MerchantPort &
23
+ OrganizationPort &
24
+ ProgressPort &
25
+ PurchasePort &
26
+ SubscriptionPort & {
27
+ client: unknown
28
+ }
@@ -0,0 +1,157 @@
1
+ import {
2
+ EventSchemas,
3
+ GetEvents,
4
+ GetFunctionInput,
5
+ Handler,
6
+ Inngest,
7
+ InngestFunction,
8
+ InngestMiddleware,
9
+ } from 'inngest'
10
+
11
+ import type { ServerAdapter } from './adapter'
12
+ import { mockServerAdapter } from './mock-adapter'
13
+ import { mockPaymentProvider } from './mock-payment-provider'
14
+ import type {
15
+ CourseBuilderAuthConfig,
16
+ CourseBuilderEmailProvider,
17
+ } from '@coursebuilder/core/auth'
18
+ import type { CourseBuilderCoreEvents } from '@coursebuilder/core/events'
19
+ import type { TranscriptionConfig } from '@coursebuilder/core/providers'
20
+ import {
21
+ LlmProviderConfig,
22
+ MockOpenAIProvider,
23
+ } from '@coursebuilder/core/providers/openai'
24
+ import {
25
+ MockPartykitProvider,
26
+ PartyProviderConfig,
27
+ } from '@coursebuilder/core/providers/partykit'
28
+ import {
29
+ mockSlackProvider,
30
+ NotificationProviderConfig,
31
+ } from '@coursebuilder/core/providers/slack'
32
+ import { PaymentsProviderConfig } from '@coursebuilder/core/types'
33
+
34
+ const MockTranscriptionProvider: TranscriptionConfig = {
35
+ id: 'mock-transcription',
36
+ name: 'Mock Transcription',
37
+ type: 'transcription',
38
+ options: {
39
+ apiKey: 'mock-api-key',
40
+ callbackUrl: 'mock-callback-url',
41
+ },
42
+ apiKey: 'mock-api-key',
43
+ callbackUrl: 'mock-callback-url',
44
+ getCallbackUrl: () => 'mock-callback-url',
45
+ initiateTranscription: async () => ({ mock: 'transcription' }),
46
+ handleCallback: () => ({
47
+ srt: 'mock-srt',
48
+ transcript: 'mock-transcript',
49
+ wordLevelSrt: 'mock-word-level-srt',
50
+ }),
51
+ }
52
+
53
+ export interface CoreInngestContext {
54
+ db: ServerAdapter
55
+ siteRootUrl: string
56
+ transcriptProvider: TranscriptionConfig
57
+ openaiProvider: LlmProviderConfig
58
+ partyProvider: PartyProviderConfig
59
+ paymentProvider?: PaymentsProviderConfig
60
+ emailProvider?: CourseBuilderEmailProvider
61
+ notificationProvider?: NotificationProviderConfig
62
+ getAuthConfig: () => CourseBuilderAuthConfig
63
+ mediaUploadProvider: {
64
+ deleteFiles: (fileKey: string) => Promise<{ success: boolean }>
65
+ }
66
+ }
67
+
68
+ export type CoreInngestFunctionInput = GetFunctionInput<CoreInngest>
69
+
70
+ /** A server composition input with a statically limited context and event. */
71
+ export type CourseBuilderHandlerInput<
72
+ TContext extends Partial<CoreInngestContext>,
73
+ TEventName extends Extract<keyof CourseBuilderCoreEvents, string>,
74
+ TEvent extends CourseBuilderCoreEvents[TEventName] =
75
+ CourseBuilderCoreEvents[TEventName],
76
+ > = Omit<CoreInngestFunctionInput, keyof CoreInngestContext | 'event'> &
77
+ TContext & {
78
+ event: TEvent
79
+ }
80
+
81
+ export type CoreInngestFunctionInputFor<
82
+ TContext extends Partial<CoreInngestContext>,
83
+ TEventName extends Extract<keyof CourseBuilderCoreEvents, string>,
84
+ TEvent extends CourseBuilderCoreEvents[TEventName] =
85
+ CourseBuilderCoreEvents[TEventName],
86
+ > = CourseBuilderHandlerInput<TContext, TEventName, TEvent>
87
+
88
+ export type CoreInngestTrigger = InngestFunction.Trigger<
89
+ keyof GetEvents<CoreInngest>
90
+ >
91
+ export type CoreInngestHandler = Handler.Any
92
+
93
+ export type CourseBuilderFunctionDefinition = {
94
+ config: { id: string }
95
+ trigger: CoreInngestTrigger
96
+ handler: CoreInngestHandler
97
+ }
98
+
99
+ /** Validates function definitions without widening their inferred config types. */
100
+ export const defineCourseBuilderFunctions = <TFunctions extends unknown[]>(
101
+ functions: TFunctions &
102
+ (TFunctions[number] extends CourseBuilderFunctionDefinition
103
+ ? unknown
104
+ : never),
105
+ ): TFunctions => functions
106
+
107
+ export const createInngestMiddleware = <
108
+ TCourseBuilderContext extends CoreInngestContext = CoreInngestContext,
109
+ >(
110
+ context: TCourseBuilderContext,
111
+ ) => {
112
+ return new InngestMiddleware({
113
+ name: 'Course Builder Middleware',
114
+ init() {
115
+ return {
116
+ onFunctionRun() {
117
+ return {
118
+ transformInput() {
119
+ // kill me - this middleware is just for types
120
+ type MappedCoreContext = {
121
+ [K in keyof TCourseBuilderContext]: TCourseBuilderContext[K]
122
+ }
123
+
124
+ return { ctx: context as unknown as MappedCoreContext }
125
+ },
126
+ }
127
+ },
128
+ }
129
+ },
130
+ })
131
+ }
132
+
133
+ const schemas = new EventSchemas().fromRecord<CourseBuilderCoreEvents>()
134
+
135
+ export const coreInngest = new Inngest({
136
+ id: 'core-inngest',
137
+ schemas,
138
+ middleware: [
139
+ createInngestMiddleware({
140
+ db: mockServerAdapter,
141
+ siteRootUrl: '',
142
+ partyKitRootUrl: '',
143
+ transcriptProvider: MockTranscriptionProvider,
144
+ openaiProvider: MockOpenAIProvider,
145
+ partyProvider: MockPartykitProvider,
146
+ paymentProvider: mockPaymentProvider,
147
+ emailProvider: {} as CourseBuilderEmailProvider,
148
+ notificationProvider: mockSlackProvider,
149
+ getAuthConfig: () => ({}) as CourseBuilderAuthConfig,
150
+ mediaUploadProvider: {
151
+ deleteFiles: async (_) => Promise.resolve({ success: true }),
152
+ },
153
+ }),
154
+ ],
155
+ })
156
+
157
+ export type CoreInngest = typeof coreInngest
@@ -0,0 +1,29 @@
1
+ import type {
2
+ InternalOptions,
3
+ RequestInternal,
4
+ ResponseInternal,
5
+ } from '@coursebuilder/core/types'
6
+
7
+ import type { Cookie } from './runtime/cookie'
8
+
9
+ /** Serve the persisted SRT body for Mux's HTTP callback. */
10
+ export async function srt(
11
+ request: RequestInternal,
12
+ cookies: Cookie[],
13
+ options: InternalOptions,
14
+ ): Promise<ResponseInternal> {
15
+ const client = options.adapter
16
+
17
+ const resource = await client?.getContentResource(
18
+ request.query?.videoResourceId,
19
+ )
20
+
21
+ if (!resource) throw new Error('Resource not found')
22
+
23
+ return {
24
+ status: 200,
25
+ body: resource.fields?.srt,
26
+ headers: { 'Content-Type': 'application/text' },
27
+ cookies,
28
+ }
29
+ }
@@ -0,0 +1,79 @@
1
+ import type {
2
+ RequestInternal,
3
+ ResponseInternal,
4
+ } from '@coursebuilder/core/types'
5
+
6
+ import { checkout } from './actions/checkout'
7
+ import { claimed } from './actions/claimed'
8
+ import { createMagicLink } from './actions/create-magic-link'
9
+ import * as actions from './actions/index'
10
+ import { getPricesFormatted } from './actions/prices-formatted'
11
+ import { processRefund } from './actions/process-refund'
12
+ import { redeem } from './actions/redeem'
13
+ import { transferPurchase } from './actions/transfer-purchase'
14
+ import { userLookup } from './actions/user-lookup'
15
+ import type { CourseBuilderConfig } from './http'
16
+ import { srt } from './http-actions'
17
+ import { init } from './runtime/init'
18
+ import { UnknownAction } from './runtime/web'
19
+
20
+ /**
21
+ * Route an internal Course Builder request to its runtime action.
22
+ *
23
+ * @param request - Parsed internal request.
24
+ * @param courseBuilderOptions - Server runtime configuration.
25
+ * @returns The internal response emitted by the selected action.
26
+ */
27
+ export async function CourseBuilderInternal(
28
+ request: RequestInternal,
29
+ courseBuilderOptions: CourseBuilderConfig,
30
+ ): Promise<ResponseInternal> {
31
+ const { action, providerId, method } = request
32
+
33
+ const { options, cookies } = await init({
34
+ courseBuilderOptions,
35
+ action,
36
+ providerId,
37
+ url: request.url,
38
+ cookies: request.cookies,
39
+ isPost: method === 'POST',
40
+ })
41
+
42
+ if (method === 'GET') {
43
+ switch (action) {
44
+ case 'srt':
45
+ return await srt(request, cookies, options)
46
+ case 'session':
47
+ return await actions.session(options, cookies)
48
+ case 'subscriber':
49
+ return await actions.getSubscriber(options, cookies)
50
+ case 'purchases':
51
+ return await actions.getUserPurchases(request, cookies, options)
52
+ }
53
+ } else {
54
+ switch (action) {
55
+ case 'prices-formatted':
56
+ return await getPricesFormatted(request, cookies, options)
57
+ case 'redeem':
58
+ return await redeem(request, cookies, options)
59
+ case 'refund':
60
+ return await processRefund(request, cookies, options)
61
+ case 'checkout':
62
+ return await checkout(request, cookies, options)
63
+ case 'claimed':
64
+ return await claimed(request, cookies, options)
65
+ case 'webhook':
66
+ return await actions.webhook(request, cookies, options)
67
+ case 'subscribe-to-list':
68
+ return await actions.subscribeToList(request, cookies, options)
69
+ case 'lookup':
70
+ return await userLookup(request, cookies, options)
71
+ case 'create-magic-link':
72
+ return await createMagicLink(request, cookies, options)
73
+ case 'transfer':
74
+ return await transferPurchase(request, cookies, options)
75
+ }
76
+ }
77
+
78
+ throw new UnknownAction(`Cannot handle action: ${action}`)
79
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import type { ServerAdapter } from './adapter'
3
+
4
+ import { CourseBuilder, type CourseBuilderConfig } from './http'
5
+ import { init } from './runtime/init'
6
+
7
+ const createConfig = (): CourseBuilderConfig => ({
8
+ adapter: {
9
+ getContentResource: vi.fn().mockResolvedValue({
10
+ fields: { srt: '1\n00:00:00,000 --> 00:00:01,000\nHello' },
11
+ }),
12
+ } as unknown as ServerAdapter,
13
+ providers: [],
14
+ baseUrl: 'http://test.com',
15
+ basePath: '/coursebuilder',
16
+ authConfig: {},
17
+ })
18
+
19
+ describe('@coursebuilder/server HTTP runtime', () => {
20
+ it('preserves the legacy HTTP SRT callback response', async () => {
21
+ const url =
22
+ 'http://test.com/coursebuilder/srt/internal?videoResourceId=video-1'
23
+ const serverResponse = await CourseBuilder(new Request(url), createConfig())
24
+
25
+ expect(serverResponse.status).toBe(200)
26
+ expect(serverResponse.headers.get('content-type')).toBe('application/text')
27
+ expect(await serverResponse.text()).toContain('Hello')
28
+ })
29
+
30
+ it('preserves request cookie conversion', async () => {
31
+ const { cookies } = await init({
32
+ url: new URL('http://test.com/coursebuilder/session'),
33
+ courseBuilderOptions: createConfig(),
34
+ action: 'session',
35
+ isPost: false,
36
+ cookies: {
37
+ sl_ref: 'workshop-promo',
38
+ ignored: undefined,
39
+ },
40
+ })
41
+
42
+ expect(cookies).toEqual([
43
+ { name: 'sl_ref', value: 'workshop-promo', options: {} },
44
+ ])
45
+ })
46
+ })