@coursebuilder/adapter-drizzle
Version:
Drizzle adapter for Course Builder.
1,714 lines (1,572 loc) • 81.8 kB
text/typescript
import type { AdapterSession, AdapterUser } from '@auth/core/adapters'
import slugify from '@sindresorhus/slugify'
import { addSeconds, isAfter } from 'date-fns'
import {
and,
asc,
count,
desc,
eq,
gte,
inArray,
isNotNull,
isNull,
not,
or,
sql,
} from 'drizzle-orm'
import {
mysqlTable as defaultMySqlTableFn,
MySqlDatabase,
MySqlTableFn,
} from 'drizzle-orm/mysql-core'
import { customAlphabet } from 'nanoid'
import { v4 } from 'uuid'
import { z } from 'zod'
import { type CourseBuilderAdapter } from '@coursebuilder/core/adapters'
import {
Coupon,
couponSchema,
MerchantCharge,
merchantChargeSchema,
MerchantCoupon,
merchantCouponSchema,
MerchantCustomer,
merchantPriceSchema,
MerchantProduct,
merchantProductSchema,
NewProduct,
Price,
priceSchema,
Product,
productSchema,
Purchase,
purchaseSchema,
PurchaseUserTransfer,
purchaseUserTransferSchema,
PurchaseUserTransferState,
ResourceProgress,
resourceProgressSchema,
UpgradableProduct,
upgradableProductSchema,
User,
userSchema,
} from '@coursebuilder/core/schemas'
import {
ContentResourceProductSchema,
ContentResourceResourceSchema,
ContentResourceSchema,
type ContentResource,
} from '@coursebuilder/core/schemas/content-resource-schema'
import { merchantAccountSchema } from '@coursebuilder/core/schemas/merchant-account-schema'
import { merchantCustomerSchema } from '@coursebuilder/core/schemas/merchant-customer-schema'
import {
MerchantSession,
MerchantSessionSchema,
} from '@coursebuilder/core/schemas/merchant-session'
import { MerchantSubscriptionSchema } from '@coursebuilder/core/schemas/merchant-subscription'
import { OrganizationMemberSchema } from '@coursebuilder/core/schemas/organization-member'
import { OrganizationSchema } from '@coursebuilder/core/schemas/organization-schema'
import { type ModuleProgress } from '@coursebuilder/core/schemas/resource-progress-schema'
import { SubscriptionSchema } from '@coursebuilder/core/schemas/subscription'
import { VideoResourceSchema } from '@coursebuilder/core/schemas/video-resource'
import { PaymentsProviderConfig } from '@coursebuilder/core/types'
import { logger } from '@coursebuilder/core/utils/logger'
import { validateCoupon } from '@coursebuilder/core/utils/validate-coupon'
import {
getAccountsRelationsSchema,
getAccountsSchema,
} from './schemas/auth/accounts.js'
import {
getDeviceAccessTokenRelationsSchema,
getDeviceAccessTokenSchema,
} from './schemas/auth/device-access-token.js'
import {
getDeviceVerificationRelationsSchema,
getDeviceVerificationSchema,
} from './schemas/auth/device-verification.js'
import {
getPermissionsRelationsSchema,
getPermissionsSchema,
} from './schemas/auth/permissions.js'
import {
getProfilesRelationsSchema,
getProfilesSchema,
} from './schemas/auth/profiles.js'
import {
getRolePermissionsRelationsSchema,
getRolePermissionsSchema,
} from './schemas/auth/role-permissions.js'
import {
getRolesRelationsSchema,
getRolesSchema,
} from './schemas/auth/roles.js'
import {
getSessionRelationsSchema,
getSessionsSchema,
} from './schemas/auth/sessions.js'
import {
getUserPermissionsRelationsSchema,
getUserPermissionsSchema,
} from './schemas/auth/user-permissions.js'
import {
getUserPrefsRelationsSchema,
getUserPrefsSchema,
} from './schemas/auth/user-prefs.js'
import {
getUserRolesRelationsSchema,
getUserRolesSchema,
} from './schemas/auth/user-roles.js'
import {
getUsersRelationsSchema,
getUsersSchema,
} from './schemas/auth/users.js'
import { getVerificationTokensSchema } from './schemas/auth/verification-tokens.js'
import {
getCouponRelationsSchema,
getCouponSchema,
} from './schemas/commerce/coupon.js'
import { getMerchantAccountSchema } from './schemas/commerce/merchant-account.js'
import {
getMerchantChargeRelationsSchema,
getMerchantChargeSchema,
} from './schemas/commerce/merchant-charge.js'
import { getMerchantCouponSchema } from './schemas/commerce/merchant-coupon.js'
import { getMerchantCustomerSchema } from './schemas/commerce/merchant-customer.js'
import { getMerchantPriceSchema } from './schemas/commerce/merchant-price.js'
import { getMerchantProductSchema } from './schemas/commerce/merchant-product.js'
import { getMerchantSessionSchema } from './schemas/commerce/merchant-session.js'
import {
getMerchantSubscriptionRelationsSchema,
getMerchantSubscriptionSchema,
} from './schemas/commerce/merchant-subscription.js'
import { getPriceSchema } from './schemas/commerce/price.js'
import {
getProductRelationsSchema,
getProductSchema,
} from './schemas/commerce/product.js'
import {
getPurchaseUserTransferRelationsSchema,
getPurchaseUserTransferSchema,
} from './schemas/commerce/purchase-user-transfer.js'
import {
getPurchaseRelationsSchema,
getPurchaseSchema,
} from './schemas/commerce/purchase.js'
import {
getSubscriptionRelationsSchema,
getSubscriptionSchema,
} from './schemas/commerce/subscription.js'
import {
getUpgradableProductsRelationsSchema,
getUpgradableProductsSchema,
} from './schemas/commerce/upgradable-products.js'
import {
getCommentRelationsSchema,
getCommentsSchema,
} from './schemas/communication/comment.js'
import { getCommunicationChannelSchema } from './schemas/communication/communication-channel.js'
import { getCommunicationPreferenceTypesSchema } from './schemas/communication/communication-preference-types.js'
import {
getCommunicationPreferencesRelationsSchema,
getCommunicationPreferencesSchema,
} from './schemas/communication/communication-preferences.js'
import {
getContentContributionRelationsSchema,
getContentContributionsSchema,
} from './schemas/content/content-contributions.js'
import {
getContentResourceProductRelationsSchema,
getContentResourceProductSchema,
} from './schemas/content/content-resource-product.js'
import {
getContentResourceResourceRelationsSchema,
getContentResourceResourceSchema,
} from './schemas/content/content-resource-resource.js'
import {
getContentResourceTagRelationsSchema,
getContentResourceTagSchema,
} from './schemas/content/content-resource-tag.js'
import {
getContentResourceVersionRelationsSchema,
getContentResourceVersionSchema,
} from './schemas/content/content-resource-version.js'
import {
getContentResourceRelationsSchema,
getContentResourceSchema,
} from './schemas/content/content-resource.js'
import {
getContributionTypesRelationsSchema,
getContributionTypesSchema,
} from './schemas/content/contribution-types.js'
import { getLessonProgressSchema } from './schemas/content/lesson-progress.js'
import { getResourceProgressSchema } from './schemas/content/resource-progress.js'
import {
getTagTagRelationsSchema,
getTagTagSchema,
} from './schemas/content/tag-tag.js'
import { getTagRelationsSchema, getTagSchema } from './schemas/content/tag.js'
import { getEntitlementTypesSchema } from './schemas/entitlements/entitlement-type.js'
import {
getEntitlementRelationsSchema,
getEntitlementsSchema,
} from './schemas/entitlements/entitlement.js'
import {
getOrganizationMembershipRolesRelationsSchema,
getOrganizationMembershipRolesSchema,
} from './schemas/org/organization-membership-roles.js'
import {
getOrganizationMembershipsRelationsSchema,
getOrganizationMembershipsSchema,
} from './schemas/org/organization-memberships.js'
import {
getOrganizationsRelationsSchema,
getOrganizationsSchema,
} from './schemas/org/organizations.js'
export const guid = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz', 5)
export function getCourseBuilderSchema(mysqlTable: MySqlTableFn) {
return {
accounts: getAccountsSchema(mysqlTable),
accountsRelations: getAccountsRelationsSchema(mysqlTable),
permissions: getPermissionsSchema(mysqlTable),
permissionsRelations: getPermissionsRelationsSchema(mysqlTable),
rolePermissions: getRolePermissionsSchema(mysqlTable),
rolePermissionsRelations: getRolePermissionsRelationsSchema(mysqlTable),
roles: getRolesSchema(mysqlTable),
rolesRelations: getRolesRelationsSchema(mysqlTable),
sessions: getSessionsSchema(mysqlTable),
sessionsRelations: getSessionRelationsSchema(mysqlTable),
userPermissions: getUserPermissionsSchema(mysqlTable),
userPermissionsRelations: getUserPermissionsRelationsSchema(mysqlTable),
userRoles: getUserRolesSchema(mysqlTable),
userRolesRelations: getUserRolesRelationsSchema(mysqlTable),
users: getUsersSchema(mysqlTable),
usersRelations: getUsersRelationsSchema(mysqlTable),
verificationTokens: getVerificationTokensSchema(mysqlTable),
coupon: getCouponSchema(mysqlTable),
couponRelations: getCouponRelationsSchema(mysqlTable),
lessonProgress: getLessonProgressSchema(mysqlTable),
merchantAccount: getMerchantAccountSchema(mysqlTable),
merchantCharge: getMerchantChargeSchema(mysqlTable),
merchantChargeRelations: getMerchantChargeRelationsSchema(mysqlTable),
merchantCoupon: getMerchantCouponSchema(mysqlTable),
merchantCustomer: getMerchantCustomerSchema(mysqlTable),
merchantPrice: getMerchantPriceSchema(mysqlTable),
merchantProduct: getMerchantProductSchema(mysqlTable),
merchantSession: getMerchantSessionSchema(mysqlTable),
prices: getPriceSchema(mysqlTable),
products: getProductSchema(mysqlTable),
purchases: getPurchaseSchema(mysqlTable),
purchaseRelations: getPurchaseRelationsSchema(mysqlTable),
purchaseUserTransfer: getPurchaseUserTransferSchema(mysqlTable),
purchaseUserTransferRelations:
getPurchaseUserTransferRelationsSchema(mysqlTable),
communicationChannel: getCommunicationChannelSchema(mysqlTable),
communicationPreferenceTypes:
getCommunicationPreferenceTypesSchema(mysqlTable),
communicationPreferences: getCommunicationPreferencesSchema(mysqlTable),
communicationPreferencesRelations:
getCommunicationPreferencesRelationsSchema(mysqlTable),
contentContributions: getContentContributionsSchema(mysqlTable),
contentContributionRelations:
getContentContributionRelationsSchema(mysqlTable),
contentResource: getContentResourceSchema(mysqlTable),
contentResourceVersion: getContentResourceVersionSchema(mysqlTable),
contentResourceVersionRelations:
getContentResourceVersionRelationsSchema(mysqlTable),
contentResourceRelations: getContentResourceRelationsSchema(mysqlTable),
contentResourceResource: getContentResourceResourceSchema(mysqlTable),
contentResourceResourceRelations:
getContentResourceResourceRelationsSchema(mysqlTable),
contentResourceTag: getContentResourceTagSchema(mysqlTable),
contentResourceTagRelations:
getContentResourceTagRelationsSchema(mysqlTable),
contributionTypes: getContributionTypesSchema(mysqlTable),
contributionTypesRelations: getContributionTypesRelationsSchema(mysqlTable),
resourceProgress: getResourceProgressSchema(mysqlTable),
upgradableProducts: getUpgradableProductsSchema(mysqlTable),
upgradableProductsRelations:
getUpgradableProductsRelationsSchema(mysqlTable),
contentResourceProduct: getContentResourceProductSchema(mysqlTable),
contentResourceProductRelations:
getContentResourceProductRelationsSchema(mysqlTable),
productRelations: getProductRelationsSchema(mysqlTable),
comments: getCommentsSchema(mysqlTable),
commentsRelations: getCommentRelationsSchema(mysqlTable),
deviceVerifications: getDeviceVerificationSchema(mysqlTable),
deviceVerificationRelations:
getDeviceVerificationRelationsSchema(mysqlTable),
deviceAccessToken: getDeviceAccessTokenSchema(mysqlTable),
deviceAccessTokenRelations: getDeviceAccessTokenRelationsSchema(mysqlTable),
tag: getTagSchema(mysqlTable),
tagRelations: getTagRelationsSchema(mysqlTable),
tagTag: getTagTagSchema(mysqlTable),
tagTagRelations: getTagTagRelationsSchema(mysqlTable),
userPrefs: getUserPrefsSchema(mysqlTable),
userPrefsRelations: getUserPrefsRelationsSchema(mysqlTable),
organization: getOrganizationsSchema(mysqlTable),
organizationRelations: getOrganizationsRelationsSchema(mysqlTable),
organizationMemberships: getOrganizationMembershipsSchema(mysqlTable),
organizationMembershipRelations:
getOrganizationMembershipsRelationsSchema(mysqlTable),
organizationMembershipRoles:
getOrganizationMembershipRolesSchema(mysqlTable),
organizationMembershipRolesRelations:
getOrganizationMembershipRolesRelationsSchema(mysqlTable),
merchantSubscription: getMerchantSubscriptionSchema(mysqlTable),
merchantSubscriptionRelations:
getMerchantSubscriptionRelationsSchema(mysqlTable),
subscription: getSubscriptionSchema(mysqlTable),
subscriptionRelations: getSubscriptionRelationsSchema(mysqlTable),
profiles: getProfilesSchema(mysqlTable),
profilesRelations: getProfilesRelationsSchema(mysqlTable),
entitlementTypes: getEntitlementTypesSchema(mysqlTable),
entitlements: getEntitlementsSchema(mysqlTable),
entitlementsRelations: getEntitlementRelationsSchema(mysqlTable),
} as const
}
export function createTables(mySqlTable: MySqlTableFn) {
return getCourseBuilderSchema(mySqlTable)
}
export type DefaultSchema = ReturnType<typeof createTables>
export function mySqlDrizzleAdapter(
client: InstanceType<typeof MySqlDatabase>,
tableFn = defaultMySqlTableFn,
paymentProvider?: PaymentsProviderConfig,
): CourseBuilderAdapter<typeof MySqlDatabase> {
const {
users,
accounts,
sessions,
verificationTokens,
contentResource,
contentResourceResource,
contentResourceProduct,
purchases: purchaseTable,
purchaseUserTransfer,
coupon,
merchantCoupon,
merchantCharge,
merchantAccount,
merchantPrice,
merchantCustomer,
merchantSession,
merchantProduct,
prices,
products,
upgradableProducts,
resourceProgress,
comments,
organization: organizationTable,
organizationMemberships: organizationMembershipTable,
organizationMembershipRoles: organizationMembershipRoleTable,
roles: roleTable,
merchantSubscription: merchantSubscriptionTable,
subscription: subscriptionTable,
} = createTables(tableFn)
const adapter: CourseBuilderAdapter = {
client,
async redeemFullPriceCoupon(options) {
const {
email: baseEmail,
couponId,
productIds,
currentUserId,
redeemingProductId,
} = options
const email = String(baseEmail).replace(' ', '+')
const coupon = await adapter.getCouponWithBulkPurchases(couponId)
const productId =
(coupon && (coupon.restrictedToProductId as string)) ||
redeemingProductId
if (!productId) throw new Error(`unable-to-find-any-product-id`)
const couponValidation = validateCoupon(coupon, productIds)
if (coupon && couponValidation.isRedeemable) {
// if the Coupon is the Bulk Coupon of a Bulk Purchase,
// then a bulk coupon is being redeemed
const bulkCouponRedemption = Boolean(coupon.maxUses > 1)
const { user } = await adapter.findOrCreateUser(email)
if (!user) throw new Error(`unable-to-create-user-${email}`)
const currentUser = currentUserId
? await adapter.getUserById(currentUserId)
: null
const redeemingForCurrentUser = currentUser?.id === user.id
// To prevent double-purchasing, check if this user already has a
// Purchase record for this product that is valid and wasn't a bulk
// coupon purchase.
const existingPurchases =
await adapter.getExistingNonBulkValidPurchasesOfProduct({
userId: user.id,
productId,
})
if (existingPurchases.length > 0) {
const errorMessage = `already-purchased-${email}`
console.error(errorMessage)
return {
error: {
message: errorMessage,
},
redeemingForCurrentUser,
purchase: null,
}
throw new Error(errorMessage)
}
const purchaseId = `purchase-${v4()}`
const userMemberships = await adapter.getMembershipsForUser(user.id)
const organizationId =
coupon.organizationId ||
userMemberships.find((m) => m.organization.name?.includes(user.email))
?.organizationId // safer way to make sure we are using personal organization
await adapter.createPurchase({
id: purchaseId,
userId: user.id,
couponId: bulkCouponRedemption ? null : coupon.id,
redeemedBulkCouponId: bulkCouponRedemption ? coupon.id : null,
productId,
totalAmount: '0',
organizationId,
metadata: {
couponUsedId: bulkCouponRedemption ? null : coupon.id,
},
})
const newPurchase = await adapter.getPurchase(purchaseId)
await adapter.incrementCouponUsedCount(coupon.id)
await adapter.createPurchaseTransfer({
sourceUserId: user.id,
purchaseId: purchaseId,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
})
return { purchase: newPurchase, redeemingForCurrentUser }
}
return null
},
createPurchaseTransfer: async (options) => {
const id = `put_${v4()}`
await client.insert(purchaseUserTransfer).values({
id,
purchaseId: options.purchaseId,
sourceUserId: options.sourceUserId,
expiresAt: options.expiresAt,
})
},
incrementCouponUsedCount: async (couponId) => {
await client
.update(coupon)
.set({ usedCount: sql`${coupon.usedCount} + 1` })
.where(eq(coupon.id, couponId))
},
getExistingNonBulkValidPurchasesOfProduct: async ({
userId,
productId,
}) => {
const existingPurchases = await client.query.purchases.findMany({
where: and(
eq(purchaseTable.userId, userId),
productId ? eq(purchaseTable.productId, productId) : undefined,
eq(purchaseTable.status, 'Valid'),
isNull(purchaseTable.bulkCouponId),
),
})
return z.array(purchaseSchema).parse(existingPurchases)
},
createMerchantCustomer: async (options) => {
await client.insert(merchantCustomer).values({
id: `mc_${v4()}`,
identifier: options.identifier,
merchantAccountId: options.merchantAccountId,
userId: options.userId,
status: 1,
})
return merchantCustomerSchema.parse(
await client.query.merchantCustomer.findFirst({
where: eq(merchantCustomer.identifier, options.identifier),
}),
)
},
getMerchantAccount: async (options) => {
return merchantAccountSchema.parse(
await client.query.merchantAccount.findFirst({
where: eq(merchantAccount.label, options.provider),
}),
)
},
getMerchantPriceForProductId: async (productId) => {
const merchantPriceData = await client.query.merchantPrice.findFirst({
where: and(
eq(merchantPrice.merchantProductId, productId),
eq(merchantPrice.status, 1),
),
})
const parsedMerchantPrice =
merchantPriceSchema.safeParse(merchantPriceData)
if (!parsedMerchantPrice.success) {
console.error(
'Error parsing merchant price',
JSON.stringify(parsedMerchantPrice.error),
)
return null
}
return parsedMerchantPrice.data
},
getMerchantProductForProductId: async (productId) => {
const merchantProductData = await client.query.merchantProduct.findFirst({
where: eq(merchantProduct.productId, productId),
})
if (!merchantProductData) return null
return merchantProductSchema.parse(merchantProductData)
},
getMerchantCustomerForUserId: async (userId) => {
const merchantCustomerData =
await client.query.merchantCustomer.findFirst({
where: eq(merchantCustomer.userId, userId),
})
if (!merchantCustomerData) return null
return merchantCustomerSchema.parse(merchantCustomerData)
},
getUpgradableProducts: async (options) => {
const { upgradableFromId, upgradableToId } = options
return z.array(upgradableProductSchema).parse(
await client.query.upgradableProducts.findMany({
where: and(
eq(upgradableProducts.upgradableFromId, upgradableFromId),
eq(upgradableProducts.upgradableToId, upgradableToId),
),
}),
)
},
async availableUpgradesForProduct(
purchases: any,
productId: string,
): Promise<any[]> {
const previousPurchaseProductIds = purchases.map(
({ productId }: Purchase) => productId,
)
if (previousPurchaseProductIds.length > 0) {
return client.query.upgradableProducts.findMany({
where: and(
eq(upgradableProducts.upgradableToId, productId),
inArray(
upgradableProducts.upgradableFromId,
previousPurchaseProductIds,
),
),
})
}
return []
},
clearLessonProgressForUser(options: {
userId: string
lessons: { id: string; slug: string }[]
}): Promise<void> {
throw new Error('clearLessonProgressForUser Method not implemented.')
},
async completeLessonProgressForUser(options: {
userId: string
lessonId?: string
}): Promise<ResourceProgress | null> {
if (!options.lessonId) {
throw new Error('No lessonId provided')
}
let lessonProgress = await client.query.resourceProgress.findFirst({
where: and(
eq(resourceProgress.userId, options.userId),
eq(resourceProgress.resourceId, options.lessonId),
),
})
const now = new Date()
if (lessonProgress) {
if (!lessonProgress.completedAt) {
await client
.update(resourceProgress)
.set({
completedAt: now,
updatedAt: now,
})
.where(eq(resourceProgress.resourceId, options.lessonId))
}
} else {
await client.insert(resourceProgress).values({
userId: options.userId,
resourceId: options.lessonId,
completedAt: now,
updatedAt: now,
})
}
lessonProgress = await client.query.resourceProgress.findFirst({
where: and(
eq(resourceProgress.userId, options.userId),
eq(resourceProgress.resourceId, options.lessonId),
),
})
const parsedLessonProgress =
resourceProgressSchema.safeParse(lessonProgress)
if (!parsedLessonProgress.success) {
console.error('Error parsing lesson progress', lessonProgress)
return null
}
return parsedLessonProgress.data
},
async couponForIdOrCode(options: {
code?: string
couponId?: string
}): Promise<(Coupon & { merchantCoupon: MerchantCoupon }) | null> {
if (!options.couponId && !options.code) return null
const couponForIdOrCode = await client.query.coupon.findFirst({
where: or(
and(
or(
options.code ? eq(coupon.code, options.code) : undefined,
options.couponId ? eq(coupon.id, options.couponId) : undefined,
),
gte(coupon.expires, new Date()),
),
and(
or(
options.code ? eq(coupon.code, options.code) : undefined,
options.couponId ? eq(coupon.id, options.couponId) : undefined,
),
isNull(coupon.expires),
),
),
with: {
merchantCoupon: true,
},
})
if (!couponForIdOrCode) return null
const parsedCoupon = couponSchema
.extend({
merchantCoupon: merchantCouponSchema,
})
.safeParse(couponForIdOrCode)
if (!parsedCoupon.success) {
console.error(
'Error parsing coupon',
JSON.stringify(parsedCoupon.error),
)
return null
}
return parsedCoupon.data
},
async createMerchantSession(options): Promise<MerchantSession> {
const id = `ms_${v4()}`
await client.insert(merchantSession).values({
id,
identifier: options.identifier,
merchantAccountId: options.merchantAccountId,
...(options.organizationId
? { organizationId: options.organizationId }
: {}),
})
return MerchantSessionSchema.parse(
await client.query.merchantSession.findFirst({
where: eq(merchantSession.id, id),
}),
)
},
async createMerchantChargeAndPurchase(options): Promise<Purchase> {
const purchaseId = await client.transaction(async (trx) => {
try {
const {
userId,
stripeChargeId,
stripeCouponId,
merchantAccountId,
merchantProductId,
merchantCustomerId,
productId,
stripeChargeAmount,
quantity = 1,
checkoutSessionId,
appliedPPPStripeCouponId,
upgradedFromPurchaseId,
country,
usedCouponId,
organizationId,
} = options
const existingMerchantCharge = merchantChargeSchema.nullable().parse(
(await client.query.merchantCharge.findFirst({
where: eq(merchantCharge.identifier, stripeChargeId),
})) || null,
)
const existingPurchaseForCharge = existingMerchantCharge
? await client.query.purchases.findFirst({
where: eq(
purchaseTable.merchantChargeId,
existingMerchantCharge.id,
),
with: {
user: true,
product: true,
bulkCoupon: true,
},
})
: null
if (existingPurchaseForCharge) {
return existingPurchaseForCharge.id
}
const merchantChargeId = `mc_${v4()}`
const purchaseId = `purch_${v4()}`
const newMerchantCharge = await client.insert(merchantCharge).values({
id: merchantChargeId,
userId,
identifier: stripeChargeId,
merchantAccountId,
merchantProductId,
merchantCustomerId,
})
const existingPurchase = purchaseSchema.nullable().parse(
(await client.query.purchases.findFirst({
where: and(
eq(purchaseTable.productId, productId),
eq(purchaseTable.userId, userId),
inArray(purchaseTable.status, ['Valid', 'Restricted']),
),
})) || null,
)
const existingBulkCoupon = couponSchema.nullable().parse(
await client
.select()
.from(coupon)
.leftJoin(
purchaseTable,
and(
eq(coupon.id, purchaseTable.bulkCouponId),
eq(purchaseTable.userId, userId),
),
)
.where(
and(
eq(coupon.restrictedToProductId, productId),
eq(purchaseTable.userId, userId),
),
)
.then((res) => {
return res[0]?.Coupon ?? null
}),
)
const isBulkPurchase =
quantity > 1 ||
Boolean(existingBulkCoupon) ||
options.bulk ||
Boolean(existingPurchase?.status === 'Valid')
let bulkCouponId: string | null = null
let couponToUpdate = null
if (isBulkPurchase) {
bulkCouponId =
existingBulkCoupon !== null ? existingBulkCoupon.id : v4()
if (existingBulkCoupon !== null) {
couponToUpdate = await client
.update(coupon)
.set({
maxUses: (existingBulkCoupon?.maxUses || 0) + quantity,
...(organizationId ? { organizationId } : {}),
})
.where(eq(coupon.id, bulkCouponId))
} else {
const merchantCouponToUse = stripeCouponId
? merchantCouponSchema.nullable().parse(
await client.query.merchantCoupon.findFirst({
where: eq(merchantCoupon.identifier, stripeCouponId),
}),
)
: null
couponToUpdate = await client.insert(coupon).values({
id: bulkCouponId as string,
percentageDiscount: '1.0',
restrictedToProductId: productId,
maxUses: quantity,
status: 1,
...(organizationId ? { organizationId } : {}),
...(merchantCouponToUse
? {
merchantCouponId: merchantCouponToUse.id,
}
: {}),
})
}
}
// create a new merchant session
const merchantSessionId = `ms_${v4()}`
await client.insert(merchantSession).values({
id: merchantSessionId,
identifier: checkoutSessionId,
merchantAccountId,
})
const merchantCouponUsed = stripeCouponId
? await client.query.merchantCoupon.findFirst({
where: eq(merchantCoupon.identifier, stripeCouponId),
})
: null
const pppMerchantCoupon = appliedPPPStripeCouponId
? await client.query.merchantCoupon.findFirst({
where: and(
eq(merchantCoupon.identifier, appliedPPPStripeCouponId),
eq(merchantCoupon.type, 'ppp'),
),
})
: null
const newPurchaseStatus =
merchantCouponUsed?.type === 'ppp' || pppMerchantCoupon
? 'Restricted'
: 'Valid'
await client.insert(purchaseTable).values({
id: purchaseId,
status: newPurchaseStatus,
userId,
productId,
merchantChargeId,
totalAmount: (stripeChargeAmount / 100).toFixed(),
bulkCouponId,
merchantSessionId,
country,
upgradedFromId: upgradedFromPurchaseId || null,
couponId: usedCouponId || null,
...(organizationId ? { organizationId } : {}),
})
const oneWeekInMilliseconds = 1000 * 60 * 60 * 24 * 7
await client.insert(purchaseUserTransfer).values({
id: `put_${v4()}`,
purchaseId: purchaseId as string,
expiresAt: existingPurchase
? new Date()
: new Date(Date.now() + oneWeekInMilliseconds),
sourceUserId: userId,
...(organizationId ? { organizationId } : {}),
})
// const result = await Promise.all([
// newMerchantCharge,
// newPurchase,
// newPurchaseUserTransfer,
// newMerchantSession,
// ...(couponToUpdate ? [couponToUpdate] : []),
// ])
//
// console.log('result', { result })
return purchaseId
} catch (error) {
console.error(error)
trx.rollback()
throw error
}
})
const parsedPurchase = purchaseSchema.safeParse(
await client.query.purchases.findFirst({
where: eq(purchaseTable.id, purchaseId as string),
}),
)
if (!parsedPurchase.success) {
console.error(
'Error parsing purchase',
parsedPurchase,
JSON.stringify(parsedPurchase, null, 2),
)
throw new Error('Error parsing purchase')
}
return parsedPurchase.data
},
async findOrCreateMerchantCustomer(options: {
user: User
identifier: string
merchantAccountId: string
}): Promise<MerchantCustomer | null> {
const merchantCustomer = merchantCustomerSchema
.nullable()
.optional()
.parse(
await client.query.merchantCustomer.findFirst({
where: (merchantCustomer, { eq }) =>
eq(merchantCustomer.identifier, options.identifier),
}),
)
if (merchantCustomer) {
return merchantCustomer
}
return await adapter.createMerchantCustomer({
identifier: options.identifier,
merchantAccountId: options.merchantAccountId,
userId: options.user.id,
})
},
async findOrCreateUser(
email: string,
name?: string | null,
): Promise<{
user: User
isNewUser: boolean
}> {
const user = await adapter.getUserByEmail?.(email)
if (!user) {
const newUser = await adapter.createUser?.({
id: `u_${v4()}`,
email,
name,
emailVerified: null,
})
console.log('newUser', { newUser })
if (!newUser) {
throw new Error('Could not create user')
}
return {
user: newUser as User,
isNewUser: true,
}
}
return {
user: user as User,
isNewUser: false,
}
},
async getCoupon(couponIdOrCode: string): Promise<Coupon | null> {
const loadedCoupon =
(await client.query.coupon.findFirst({
where: or(
eq(coupon.id, couponIdOrCode),
eq(coupon.code, couponIdOrCode),
),
})) || null
logger.debug('loadedCoupon', { loadedCoupon })
return couponSchema.nullable().parse(loadedCoupon)
},
async getPurchasesForBulkCouponId(
bulkCouponId: string,
): Promise<(Purchase & { user: User })[]> {
return z.array(purchaseSchema.extend({ user: userSchema })).parse(
await client.query.purchases.findMany({
where: eq(purchaseTable.bulkCouponId, bulkCouponId),
with: {
user: true,
},
}),
)
},
async getCouponWithBulkPurchases(couponId: string): Promise<
| (Coupon & {
bulkPurchases?: Purchase[] | null
redeemedBulkCouponPurchases: { bulkCouponId?: string | null }[]
})
| null
> {
logger.debug('getCouponWithBulkPurchases', { couponId })
let couponData
let bulkCouponPurchases
try {
couponData =
(await client.query.coupon.findFirst({
where: eq(coupon.id, couponId),
with: {
bulkPurchases: true,
redeemedBulkCouponPurchases: true,
},
})) || null
} catch (e) {
console.log('getCouponWithBulkPurchases')
logger.error(e as Error)
}
try {
bulkCouponPurchases = await client.query.purchases.findMany({
where: eq(purchaseTable.redeemedBulkCouponId, couponId),
with: {
user: true,
},
})
console.log('purchases with redeemedBulkCouponId', bulkCouponPurchases)
} catch (e) {
console.log('getCouponWithBulkPurchases')
logger.error(e as Error)
}
if (!couponData) {
logger.debug('getCouponWithBulkPurchases', {
couponId,
error: 'no coupon found',
})
return null
}
const couponWithBulkPurchases = {
...couponData,
redeemedBulkCouponPurchases: bulkCouponPurchases || [],
}
const parsedCoupon = couponSchema
.merge(
z.object({
redeemedBulkCouponPurchases: z.array(purchaseSchema),
}),
)
.nullable()
.safeParse(couponWithBulkPurchases)
if (!parsedCoupon.success) {
console.error(
'Error parsing coupon',
JSON.stringify(parsedCoupon.error),
couponData,
)
return null
}
return parsedCoupon.data
},
async getDefaultCoupon(productIds?: string[]): Promise<{
defaultMerchantCoupon: MerchantCoupon
defaultCoupon: Coupon
} | null> {
const activeSaleCoupon = await client.query.coupon.findFirst({
where: and(
eq(coupon.status, 1),
eq(coupon.default, true),
gte(coupon.expires, new Date()),
or(
productIds
? inArray(coupon.restrictedToProductId, productIds)
: undefined,
isNull(coupon.restrictedToProductId),
),
),
orderBy: desc(coupon.percentageDiscount),
with: {
merchantCoupon: true,
product: true,
},
})
if (activeSaleCoupon) {
const { restrictedToProductId } = activeSaleCoupon
const validForProdcutId = restrictedToProductId
? productIds?.includes(restrictedToProductId as string)
: true
const { merchantCoupon: defaultMerchantCoupon, ...defaultCoupon } =
activeSaleCoupon
if (validForProdcutId) {
return {
defaultMerchantCoupon: merchantCouponSchema.parse(
defaultMerchantCoupon,
),
defaultCoupon: couponSchema.parse(defaultCoupon),
}
}
}
return null
},
getLessonProgressCountsByDate(): Promise<
{
count: number
completedAt: string
}[]
> {
throw new Error('getLessonProgressCountsByDate Method not implemented.')
},
async getLessonProgressForUser(
userId: string,
): Promise<ResourceProgress[]> {
const userProgress = await client.query.resourceProgress.findMany({
where: eq(resourceProgress.userId, userId),
})
const parsed = z.array(resourceProgressSchema).safeParse(userProgress)
if (!parsed.success) {
console.error('Error parsing user progress', userProgress)
return []
}
return parsed.data
},
async getModuleProgressForUser(
userIdOrEmail: string,
moduleIdOrSlug: string,
): Promise<ModuleProgress | null> {
// First, get the user ID
const user = await client.query.users.findFirst({
where: or(eq(users.id, userIdOrEmail), eq(users.email, userIdOrEmail)),
columns: {
id: true,
},
})
if (!user) {
return null
}
const ResultRowSchema = z.object({
resource_id: z.string(),
resource_type: z.enum(['lesson', 'exercise', 'post']),
resource_slug: z.string().nullable(),
completed_at: z
.string()
.nullable()
.transform((val) => (val ? new Date(val) : null)),
})
// Execute the optimized query
const results: any = await client.execute(sql`
SELECT
cr.id AS resource_id,
cr.type AS resource_type,
cr.fields->>'$.slug' AS resource_slug,
rp.completedAt AS completed_at
FROM
(SELECT id, fields->>'$.slug' AS slug
FROM ${contentResource}
WHERE id = ${moduleIdOrSlug}
OR fields->>'$.slug' = ${moduleIdOrSlug}) AS w
LEFT JOIN
(SELECT
crr.resourceOfId AS workshop_id,
crr.resourceId,
crr.position
FROM ${contentResourceResource} crr) AS tlr ON w.id = tlr.workshop_id
LEFT JOIN ${contentResource} sections ON sections.id = tlr.resourceId AND sections.type = 'section'
LEFT JOIN
(SELECT
crr.resourceOfId AS section_id,
crr.resourceId AS resource_id,
crr.position AS position_in_section,
section_crr.position AS section_position
FROM ${contentResourceResource} crr
JOIN ${contentResourceResource} section_crr ON crr.resourceOfId = section_crr.resourceId) AS sr
ON sr.section_id = sections.id
JOIN ${contentResource} cr ON cr.id = COALESCE(sr.resource_id, tlr.resourceId)
LEFT JOIN ${resourceProgress} rp ON rp.resourceId = cr.id
AND rp.userId = ${user.id}
WHERE
cr.type IN ('lesson', 'exercise', 'post')
`)
// Process the results
const completedLessons: ResourceProgress[] = []
let nextResource: Partial<ContentResource> | null = null
let completedLessonsCount = 0
let totalLessonsCount = results.rows.length
const parsedRows = z.array(ResultRowSchema).safeParse(results.rows)
if (!parsedRows.success) {
console.error('Error parsing rows', parsedRows.error)
return {
completedLessons: [],
nextResource: null,
percentCompleted: 0,
completedLessonsCount: 0,
totalLessonsCount,
}
}
for (const row of parsedRows.data) {
if (row.completed_at) {
completedLessonsCount++
completedLessons.push({
userId: user.id as string,
resourceId: row.resource_id,
completedAt: new Date(row.completed_at),
// Add other fields as needed
})
} else if (!nextResource) {
nextResource = {
id: row.resource_id,
type: row.resource_type,
fields: {
slug: row.resource_slug,
},
}
}
}
const percentCompleted =
totalLessonsCount > 0
? Math.ceil((completedLessonsCount / totalLessonsCount) * 100)
: 0
return {
completedLessons,
nextResource,
percentCompleted,
completedLessonsCount,
totalLessonsCount,
}
},
getLessonProgresses(): Promise<ResourceProgress[]> {
throw new Error('getLessonProgresses Method not implemented.')
},
async getMerchantCharge(
merchantChargeId: string,
): Promise<MerchantCharge | null> {
const mCharge = await client.query.merchantCharge.findFirst({
where: eq(merchantCharge.id, merchantChargeId),
})
const parsed = merchantChargeSchema.safeParse(mCharge)
if (!parsed.success) {
console.info('Error parsing merchantCharge', mCharge)
return null
}
return parsed.data
},
async getMerchantCouponsForTypeAndPercent(params: {
type: string
percentageDiscount: number
}): Promise<MerchantCoupon[]> {
return z.array(merchantCouponSchema).parse(
await client.query.merchantCoupon.findMany({
where: and(
eq(merchantCoupon.type, params.type),
eq(
merchantCoupon.percentageDiscount,
params.percentageDiscount.toString(),
),
),
}),
)
},
async getMerchantCouponForTypeAndPercent(params: {
type: string
percentageDiscount: number
}): Promise<MerchantCoupon | null> {
const foundMerchantCoupon = await client.query.merchantCoupon.findFirst({
where: and(
eq(merchantCoupon.type, params.type),
eq(
merchantCoupon.percentageDiscount,
params.percentageDiscount.toString(),
),
),
})
const parsed = merchantCouponSchema
.nullable()
.safeParse(foundMerchantCoupon)
if (parsed.success) {
return parsed.data
}
return null
},
async getMerchantCoupon(
merchantCouponId: string,
): Promise<MerchantCoupon | null> {
const foundMerchantCoupon = await client.query.merchantCoupon.findFirst({
where: eq(merchantCoupon.id, merchantCouponId),
})
const parsed = merchantCouponSchema
.nullable()
.safeParse(foundMerchantCoupon)
if (parsed.success) {
return parsed.data
}
return null
},
async getMerchantProduct(
stripeProductId: string,
): Promise<MerchantProduct | null> {
return merchantProductSchema.nullable().parse(
await client.query.merchantProduct.findFirst({
where: eq(merchantProduct.identifier, stripeProductId),
}),
)
},
getPrice(productId: string): Promise<Price | null> {
throw new Error('getPrice not implemented.')
},
async getPriceForProduct(productId: string): Promise<Price | null> {
return priceSchema.nullable().parse(
await client.query.prices.findFirst({
where: eq(prices.productId, productId),
}),
)
},
async archiveProduct(productId) {
if (!paymentProvider) throw new Error('Payment provider not found')
const product = await adapter.getProduct(productId)
if (!product) {
throw new Error(`Product not found for id (${productId})`)
}
if (!product.price) {
throw new Error(`Product has no price`)
}
await client
.update(products)
.set({ status: 0, name: `${product.name} (Archived)` })
.where(eq(products.id, productId))
await client
.update(prices)
.set({ status: 0, nickname: `${product.name} (Archived)` })
.where(eq(prices.productId, productId))
await client
.update(merchantProduct)
.set({ status: 0 })
.where(eq(merchantProduct.productId, productId))
await client
.update(merchantPrice)
.set({ status: 0 })
.where(eq(merchantPrice.priceId, product.price.id))
const currentMerchantProduct = merchantProductSchema.nullish().parse(
await client.query.merchantProduct.findFirst({
where: eq(merchantProduct.productId, productId),
}),
)
if (!currentMerchantProduct || !currentMerchantProduct.identifier) {
throw new Error(`Merchant product not found for id (${productId})`)
}
await paymentProvider.updateProduct(currentMerchantProduct.identifier, {
active: false,
})
const currentMerchantPrice = merchantPriceSchema.nullish().parse(
await client.query.merchantPrice.findFirst({
where: and(
eq(merchantPrice.priceId, product.price.id),
eq(merchantPrice.status, 1),
),
}),
)
if (!currentMerchantPrice || !currentMerchantPrice.identifier) {
throw new Error(`Merchant price not found for id (${productId})`)
}
await paymentProvider.updatePrice(currentMerchantPrice.identifier, {
active: false,
})
return adapter.getProduct(productId)
},
async updateProduct(input: Product) {
if (!paymentProvider) throw new Error('Payment provider not found')
const currentProduct = await adapter.getProduct(input.id)
if (!currentProduct) {
throw new Error(`Product not found`)
}
if (!currentProduct.price) {
throw new Error(`Product has no price`)
}
const merchantProduct = merchantProductSchema.nullish().parse(
await client.query.merchantProduct.findFirst({
where: (merchantProduct, { eq }) =>
eq(merchantProduct.productId, input.id),
}),
)
if (!merchantProduct || !merchantProduct.identifier) {
throw new Error(`Merchant product not found`)
}
// TODO: handle upgrades
const stripeProduct = await paymentProvider.getProduct(
merchantProduct.identifier,
)
const priceChanged =
currentProduct.price.unitAmount.toString() !==
input.price?.unitAmount.toString()
if (priceChanged) {
const currentMerchantPrice = merchantPriceSchema.nullish().parse(
await client.query.merchantPrice.findFirst({
where: (merchantPrice, { eq, and }) =>
and(
eq(merchantPrice.merchantProductId, merchantProduct.id),
eq(merchantPrice.status, 1),
),
}),
)
if (!currentMerchantPrice || !currentMerchantPrice.identifier) {
throw new Error(`Merchant price not found`)
}
const currentStripePrice = await paymentProvider.getPrice(
currentMerchantPrice.identifier,
)
const newStripePrice = await paymentProvider.createPrice({
product: stripeProduct.id,
unit_amount: Math.floor(Number(input.price?.unitAmount || 0) * 100),
currency: 'usd',
metadata: {
slug: input.fields.slug,
},
active: true,
})
await paymentProvider.updateProduct(stripeProduct.id, {
default_price: newStripePrice.id,
})
const newMerchantPriceId = `mprice_${v4()}`
await client.insert(merchantPrice).values({
id: newMerchantPriceId,
merchantProductId: merchantProduct.id,
merchantAccountId: merchantProduct.merchantAccountId,
priceId: currentProduct.price.id,
status: 1,
identifier: newStripePrice.id,
})
if (currentMerchantPrice) {
await client
.update(merchantPrice)
.set({
status: 0,
})
.where(eq(merchantPrice.id, currentMerchantPrice.id))
}
await client
.update(prices)
.set({
unitAmount: Math.floor(
Number(input.price?.unitAmount || 0),
).toString(),
nickname: input.name,
})
.where(eq(prices.id, currentProduct.price.id))
if (currentStripePrice) {
await paymentProvider.updatePrice(currentStripePrice.id, {
active: false,
})
}
}
await paymentProvider.updateProduct(stripeProduct.id, {
name: input.name,
active: true,
images: input.fields.image?.url ? [input.fields.image.url] : undefined,
description: input.fields.description || '',
metadata: {
slug: input.fields.slug,
},
})
const { image, ...fieldsNoImage } = input.fields
await client
.update(products)
.set({
name: input.name,
quantityAvailable: input.quantityAvailable,
status: 1,
fields: {
...fieldsNoImage,
...(image?.url && { image }),
},
type: input.type,
})
.where(eq(products.id, currentProduct.id))
return adapter.getProduct(currentProduct.id)
},
async createProduct(input: NewProduct) {
if (!paymentProvider) throw new Error('Payment provider not found')
const merchantAccount = merchantAccountSchema.nullish().parse(
await client.query.merchantAccount.findFirst({
where: (merchantAccount, { eq }) =>
eq(merchantAccount.label, 'stripe'),
}),
)
if (!merchantAccount) {
throw new Error('Merchant account not found')
}
const hash = guid()
const newProductId = slugify(`product-${hash}`)
const newProduct = {
id: newProductId,
name: input.name,
status: 1,
type: input.type || 'self-paced',
quantityAvailable: input.quantityAvailable,
fields: {
state: input.state || 'draft',
visibility: input.visibility || 'unlisted',
slug: slugify(`${input.name}-${hash}`),
},
}
await client.insert(products).values(newProduct)
const priceHash = guid()
const newPriceId = `price-${priceHash}`
await client.insert(prices).values({
id: newPriceId,
productId: newProductId,
unitAmount: input.price.toString(),
status: 1,
})
const product = await adapter.getProduct(newProductId)
const stripeProduct = await paymentProvider.createProduct({
name: input.name,
metadata: {
slug: product?.fields?.slug || null,
},
})
const stripePrice = await paymentProvider.createPrice({
product: stripeProduct.id,
unit_amount: Math.floor(Number(input.price) * 100),
currency: 'usd',
nickname: input.name,
metadata: {
slug: product?.fields?.slug || null,
},
})
const newMerchantProductId = `mproduct_${v4()}`
await client.insert(merchantProduct).values({
id: newMerchantProductId,
merchantAccountId: merchantAccount.id,
productId: newProductId,
identifier: stripeProduct.id,
status: 1,
})
const newMerchantPriceId = `mprice_${v4()}`
await client.insert(merchantPrice).values({
id: newMerchantPriceId,
merchantAccountId: merchantAccount.id,
merchantProductId: newMerchantProductId,
priceId: newPriceId,
identifier: stripePrice.id,
status: 1,
})
// TODO: handle upgrades
return product
},
async getProduct(
productSlugOrId?: string,
withResources: boolean = true,
): Promise<Product | null> {
if (!productSlugOrId) {
return null
}
try {
const productData = await client.query.products.findFirst({
where: and(
or(
eq(
sql`JSON_EXTRACT (${products.fields}, "$.slug")`,
`${productSlugOrId}`,
),
eq(products.id, productSlugOrId),
),
),
with: {
price: true,
...(withResources && {
resources: {
with: {
resource: {
with: {
resources: true,
},
},
},
},
}),
},
})
const parsedProduct = productSchema.safeParse(productData)
if (!parsedProduct.success) {
console.error(
'Error parsing product',
JSON.stringify(parsedProduct.error),
JSON.stringify(productData),
)
return null
}
return parsedProduct.data
} catch (e) {
console.log('getProduct error', e)
return null
}
},
async getProductResources(
productId: string,
): Promise<ContentResource[] | null> {
const contentResourceProductsForProduct = z
.array(ContentResourceProductSchema)
.nullable()
.parse(
await client.query.contentResourceProduct.findMany({
where: eq(contentResourceProduct.productId, productId),
}),
)
if (!contentResourceProductsForProduct) {
return null
} else {
const contentResources = z.array(ContentResourceSchema).parse(
await client.query.contentResource.findMany({
where: inArray(
contentResource.id,
contentResourceProductsForProduct.map((crp) => crp.resourceId),
),
}),
)
return contentResources
}
},
async getPurchaseCountForProduct(productId: string): Promise<number> {
return await client.query.purchases
.findMany({
where: and(
eq(purchaseTable.productId, productId),
inArray(purchaseTable.status, ['Valid', 'Restricted']),
),
})
.then((res) => res.length)
},
async getPurchase(purchaseI