@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.
- package/LICENSE +21 -0
- package/dist/actions/checkout.d.ts +6 -0
- package/dist/actions/checkout.js +9 -0
- package/dist/actions/checkout.js.map +1 -0
- package/dist/actions/claimed.d.ts +6 -0
- package/dist/actions/claimed.js +8 -0
- package/dist/actions/claimed.js.map +1 -0
- package/dist/actions/create-magic-link.d.ts +6 -0
- package/dist/actions/create-magic-link.js +9 -0
- package/dist/actions/create-magic-link.js.map +1 -0
- package/dist/actions/index.d.ts +11 -0
- package/dist/actions/index.js +18 -0
- package/dist/actions/index.js.map +1 -0
- package/dist/actions/prices-coupon-for-code.d.ts +6 -0
- package/dist/actions/prices-coupon-for-code.js +63 -0
- package/dist/actions/prices-coupon-for-code.js.map +1 -0
- package/dist/actions/prices-formatted.d.ts +6 -0
- package/dist/actions/prices-formatted.js +8 -0
- package/dist/actions/prices-formatted.js.map +1 -0
- package/dist/actions/process-refund.d.ts +1 -0
- package/dist/actions/process-refund.js +8 -0
- package/dist/actions/process-refund.js.map +1 -0
- package/dist/actions/redeem.d.ts +1 -0
- package/dist/actions/redeem.js +8 -0
- package/dist/actions/redeem.js.map +1 -0
- package/dist/actions/transfer-purchase.d.ts +1 -0
- package/dist/actions/transfer-purchase.js +8 -0
- package/dist/actions/transfer-purchase.js.map +1 -0
- package/dist/actions/user-lookup.d.ts +6 -0
- package/dist/actions/user-lookup.js +9 -0
- package/dist/actions/user-lookup.js.map +1 -0
- package/dist/adapter-BtDS6LDw.d.ts +9 -0
- package/dist/chunk-22AMOZYZ.js +67 -0
- package/dist/chunk-22AMOZYZ.js.map +1 -0
- package/dist/chunk-4EQQSW57.js +32 -0
- package/dist/chunk-4EQQSW57.js.map +1 -0
- package/dist/chunk-4LZZLN6T.js +52 -0
- package/dist/chunk-4LZZLN6T.js.map +1 -0
- package/dist/chunk-4QQA3TC2.js +29 -0
- package/dist/chunk-4QQA3TC2.js.map +1 -0
- package/dist/chunk-4TSEPUSC.js +160 -0
- package/dist/chunk-4TSEPUSC.js.map +1 -0
- package/dist/chunk-5O4UO4IR.js +93 -0
- package/dist/chunk-5O4UO4IR.js.map +1 -0
- package/dist/chunk-7FCQOLKL.js +26 -0
- package/dist/chunk-7FCQOLKL.js.map +1 -0
- package/dist/chunk-AZLUMIIF.js +8 -0
- package/dist/chunk-AZLUMIIF.js.map +1 -0
- package/dist/chunk-E6HV6TNI.js +38 -0
- package/dist/chunk-E6HV6TNI.js.map +1 -0
- package/dist/chunk-ECNJPNPU.js +54 -0
- package/dist/chunk-ECNJPNPU.js.map +1 -0
- package/dist/chunk-F3KUYN5I.js +111 -0
- package/dist/chunk-F3KUYN5I.js.map +1 -0
- package/dist/chunk-G63YAJF2.js +107 -0
- package/dist/chunk-G63YAJF2.js.map +1 -0
- package/dist/chunk-JFR7XO72.js +7 -0
- package/dist/chunk-JFR7XO72.js.map +1 -0
- package/dist/chunk-KNC7QVGZ.js +26 -0
- package/dist/chunk-KNC7QVGZ.js.map +1 -0
- package/dist/chunk-ODSSRMD2.js +270 -0
- package/dist/chunk-ODSSRMD2.js.map +1 -0
- package/dist/chunk-VEKYIR7I.js +26 -0
- package/dist/chunk-VEKYIR7I.js.map +1 -0
- package/dist/chunk-W7YAT4NR.js +12 -0
- package/dist/chunk-W7YAT4NR.js.map +1 -0
- package/dist/chunk-WGAPYIUP.js +56 -0
- package/dist/chunk-WGAPYIUP.js.map +1 -0
- package/dist/chunk-WYNH2AWD.js +7 -0
- package/dist/chunk-WYNH2AWD.js.map +1 -0
- package/dist/chunk-Y5X5J55Z.js +384 -0
- package/dist/chunk-Y5X5J55Z.js.map +1 -0
- package/dist/chunk-YFLGJIMA.js +216 -0
- package/dist/chunk-YFLGJIMA.js.map +1 -0
- package/dist/chunk-ZFZNN4E5.js +7 -0
- package/dist/chunk-ZFZNN4E5.js.map +1 -0
- package/dist/create-inngest-middleware.d.ts +635 -0
- package/dist/create-inngest-middleware.js +12 -0
- package/dist/create-inngest-middleware.js.map +1 -0
- package/dist/http-router.d.ts +20 -0
- package/dist/http-router.js +24 -0
- package/dist/http-router.js.map +1 -0
- package/dist/http.d.ts +38 -0
- package/dist/http.js +32 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +365 -0
- package/dist/index.js.map +1 -0
- package/dist/magic-string.es-ENO43NON.js +1389 -0
- package/dist/magic-string.es-ENO43NON.js.map +1 -0
- package/dist/runtime/actions.d.ts +5 -0
- package/dist/runtime/actions.js +8 -0
- package/dist/runtime/actions.js.map +1 -0
- package/dist/runtime/assert.d.ts +15 -0
- package/dist/runtime/assert.js +8 -0
- package/dist/runtime/assert.js.map +1 -0
- package/dist/runtime/cookie.d.ts +8 -0
- package/dist/runtime/cookie.js +8 -0
- package/dist/runtime/cookie.js.map +1 -0
- package/dist/runtime/env.d.ts +9 -0
- package/dist/runtime/env.js +11 -0
- package/dist/runtime/env.js.map +1 -0
- package/dist/runtime/env.test.d.ts +2 -0
- package/dist/runtime/env.test.js +14956 -0
- package/dist/runtime/env.test.js.map +1 -0
- package/dist/runtime/init.d.ts +26 -0
- package/dist/runtime/init.js +14 -0
- package/dist/runtime/init.js.map +1 -0
- package/dist/runtime/logger.d.ts +4 -0
- package/dist/runtime/logger.js +10 -0
- package/dist/runtime/logger.js.map +1 -0
- package/dist/runtime/merge.d.ts +4 -0
- package/dist/runtime/merge.js +8 -0
- package/dist/runtime/merge.js.map +1 -0
- package/dist/runtime/providers.d.ts +21 -0
- package/dist/runtime/providers.js +9 -0
- package/dist/runtime/providers.js.map +1 -0
- package/dist/runtime/web.d.ts +29 -0
- package/dist/runtime/web.js +16 -0
- package/dist/runtime/web.js.map +1 -0
- package/package.json +65 -0
- package/src/actions/actions.test.ts +232 -0
- package/src/actions/checkout.ts +160 -0
- package/src/actions/claimed.ts +40 -0
- package/src/actions/create-magic-link.ts +78 -0
- package/src/actions/index.ts +401 -0
- package/src/actions/prices-coupon-for-code.ts +91 -0
- package/src/actions/prices-formatted.ts +313 -0
- package/src/actions/process-refund.ts +2 -0
- package/src/actions/redeem.ts +2 -0
- package/src/actions/transfer-purchase.ts +2 -0
- package/src/actions/user-lookup.ts +37 -0
- package/src/adapter.ts +28 -0
- package/src/create-inngest-middleware.ts +157 -0
- package/src/http-actions.ts +29 -0
- package/src/http-router.ts +79 -0
- package/src/http.test.ts +46 -0
- package/src/http.ts +83 -0
- package/src/index.test.ts +21 -0
- package/src/index.ts +44 -0
- package/src/mock-adapter.ts +398 -0
- package/src/mock-payment-provider.ts +56 -0
- package/src/resource-chat.ts +371 -0
- package/src/runtime/actions.ts +25 -0
- package/src/runtime/assert.ts +55 -0
- package/src/runtime/cookie.ts +12 -0
- package/src/runtime/env.test.ts +49 -0
- package/src/runtime/env.ts +63 -0
- package/src/runtime/init.ts +112 -0
- package/src/runtime/logger.ts +6 -0
- package/src/runtime/merge.ts +25 -0
- package/src/runtime/providers.ts +38 -0
- package/src/runtime/web.ts +199 -0
- package/src/streaming-chat-prompt-executor.ts +43 -0
- package/src/type-contracts.test.ts +167 -0
- 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,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
|
+
}
|
package/src/http.test.ts
ADDED
|
@@ -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
|
+
})
|