UNPKG

@coursebuilder/adapter-drizzle

Version:

Drizzle adapter for Course Builder.

1,714 lines (1,572 loc) 81.8 kB
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