Skip to content

Commit bd6f44a

Browse files
fix(ai-hero): fix product transfer workflow and entitlement handling for multiple attachments resources
1 parent a5b067c commit bd6f44a

File tree

2 files changed

+177
-68
lines changed

2 files changed

+177
-68
lines changed

apps/ai-hero/src/inngest/functions/product-transfer-workflow.ts

Lines changed: 167 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ import {
2424

2525
// Import shared configuration
2626
import {
27+
gatherResourceContexts,
2728
getResourceData,
2829
PRODUCT_TYPE_CONFIG,
2930
ProductType,
31+
type ResourceContext,
3032
} from '../config/product-types'
3133

3234
/**
@@ -399,73 +401,47 @@ async function handleProductTransfer({
399401
throw new Error('Source and target users cannot be the same')
400402
}
401403

402-
// Get the primary resource
403-
const primaryResource = await step.run(
404-
`get ${config.resourceType} resource`,
404+
// Gather all resource contexts from the product
405+
const resourceContexts = await step.run(
406+
`gather all resource contexts`,
405407
async () => {
406-
return getProductResource(product, productType)
408+
return gatherResourceContexts(product, productType)
407409
},
408410
)
409411

410-
if (!primaryResource) {
411-
throw new Error(`${config.resourceType} resource not found`)
412+
if (resourceContexts.length === 0) {
413+
throw new Error(`No resources found for product`)
412414
}
413415

414-
log.info(`${config.logPrefix} resource details`, {
415-
id: primaryResource.id,
416-
title: primaryResource.fields?.title,
417-
organizationId: primaryResource.organizationId,
418-
resourceCount: primaryResource.resources?.length,
416+
log.info('Resource contexts gathered', {
417+
resourceContextsCount: resourceContexts.length,
418+
resourceContexts: resourceContexts.map((ctx: ResourceContext) => ({
419+
resourceId: ctx.resourceId,
420+
resourceType: ctx.resourceType,
421+
productType: ctx.productType,
422+
})),
419423
transferSource,
420424
})
421425

426+
// Load full resource data for each context
427+
const resourceDataMap = await step.run(
428+
`load resource data for all contexts`,
429+
async () => {
430+
const dataMap: Record<string, any> = {}
431+
for (const context of resourceContexts) {
432+
const resourceData = await getResourceData(
433+
context.resourceId,
434+
context.productType,
435+
)
436+
dataMap[context.resourceId] = resourceData
437+
}
438+
return dataMap
439+
},
440+
)
441+
422442
const isTeamPurchase = Boolean(purchase.bulkCouponId)
423443

424444
if (!isTeamPurchase && ['Valid', 'Restricted'].includes(purchase.status)) {
425-
// Get entitlement types
426-
const contentAccessEntitlementType = await step.run(
427-
`get ${config.logPrefix} content access entitlement type`,
428-
async () => {
429-
return await db.query.entitlementTypes.findFirst({
430-
where: eq(entitlementTypes.name, config.contentAccess),
431-
})
432-
},
433-
)
434-
435-
if (!contentAccessEntitlementType) {
436-
throw new Error(`Entitlement type not found: ${config.contentAccess}`)
437-
}
438-
439-
const discordRoleEntitlementType = await step.run(
440-
`get ${config.logPrefix} discord role entitlement type`,
441-
async () => {
442-
return await db.query.entitlementTypes.findFirst({
443-
where: eq(entitlementTypes.name, config.discordRole),
444-
})
445-
},
446-
)
447-
448-
// Remove entitlements from source user
449-
await removeEntitlementsFromSource(
450-
step,
451-
config,
452-
sourceUser,
453-
purchase,
454-
contentAccessEntitlementType,
455-
discordRoleEntitlementType,
456-
)
457-
458-
// Remove Discord role from source user and add to target user
459-
await transferDiscordRole(
460-
step,
461-
productType,
462-
config,
463-
product,
464-
sourceUser,
465-
targetUser,
466-
primaryResource,
467-
)
468-
469445
// Get target user's personal organization
470446
const targetUserOrganization = await step.run(
471447
`get target user personal organization`,
@@ -542,17 +518,140 @@ async function handleProductTransfer({
542518
},
543519
)
544520

545-
// Add entitlements to target user
546-
await addEntitlementsToTarget(step, productType, config, {
547-
targetUser,
548-
purchase,
549-
resource: primaryResource,
550-
targetOrganization: targetUserOrganization,
551-
targetMembership: targetUserOrgMembership,
552-
contentAccessEntitlementType,
553-
discordRoleEntitlementType,
554-
product,
555-
})
521+
const primaryResourceContext = resourceContexts.find(
522+
(ctx: ResourceContext) => ctx.productType === productType,
523+
)
524+
const primaryResourceData = primaryResourceContext
525+
? resourceDataMap[primaryResourceContext.resourceId]
526+
: null
527+
528+
if (primaryResourceContext && primaryResourceData) {
529+
const contentAccessEntitlementType = await step.run(
530+
`get ${config.logPrefix} content access entitlement type`,
531+
async () => {
532+
return await db.query.entitlementTypes.findFirst({
533+
where: eq(entitlementTypes.name, config.contentAccess),
534+
})
535+
},
536+
)
537+
538+
if (!contentAccessEntitlementType) {
539+
throw new Error(`Entitlement type not found: ${config.contentAccess}`)
540+
}
541+
542+
const discordRoleEntitlementType = await step.run(
543+
`get ${config.logPrefix} discord role entitlement type`,
544+
async () => {
545+
return await db.query.entitlementTypes.findFirst({
546+
where: eq(entitlementTypes.name, config.discordRole),
547+
})
548+
},
549+
)
550+
551+
await removeEntitlementsFromSource(
552+
step,
553+
config,
554+
sourceUser,
555+
purchase,
556+
contentAccessEntitlementType,
557+
discordRoleEntitlementType,
558+
)
559+
560+
await transferDiscordRole(
561+
step,
562+
productType,
563+
config,
564+
product,
565+
sourceUser,
566+
targetUser,
567+
primaryResourceData,
568+
)
569+
570+
// Add entitlements to target user
571+
await addEntitlementsToTarget(step, productType, config, {
572+
targetUser,
573+
purchase,
574+
resource: primaryResourceData,
575+
targetOrganization: targetUserOrganization,
576+
targetMembership: targetUserOrgMembership,
577+
contentAccessEntitlementType,
578+
discordRoleEntitlementType,
579+
product,
580+
})
581+
}
582+
583+
for (const context of resourceContexts) {
584+
// Skip primary resource - already handled above with Discord role transfer
585+
if (context.productType === productType) continue
586+
587+
const resourceData = resourceDataMap[context.resourceId]
588+
if (!resourceData) continue
589+
590+
const resourceConfig =
591+
PRODUCT_TYPE_CONFIG[
592+
context.productType as keyof typeof PRODUCT_TYPE_CONFIG
593+
]
594+
if (!resourceConfig) continue
595+
596+
// Get entitlement types for this resource's product type
597+
const contentAccessEntitlementType = await step.run(
598+
`get ${resourceConfig.logPrefix} content access entitlement type for ${context.resourceId}`,
599+
async () => {
600+
return await db.query.entitlementTypes.findFirst({
601+
where: eq(entitlementTypes.name, resourceConfig.contentAccess),
602+
})
603+
},
604+
)
605+
606+
if (!contentAccessEntitlementType) {
607+
log.warn('Entitlement type not found, skipping resource', {
608+
resourceId: context.resourceId,
609+
entitlementTypeName: resourceConfig.contentAccess,
610+
transferSource,
611+
})
612+
continue
613+
}
614+
615+
// Remove content access entitlements from source user for this resource
616+
await step.run(
617+
`remove ${resourceConfig.logPrefix} entitlements from source user for ${context.resourceId}`,
618+
async () => {
619+
await db
620+
.update(entitlements)
621+
.set({ deletedAt: new Date() })
622+
.where(
623+
and(
624+
eq(entitlements.userId, sourceUser.id),
625+
eq(
626+
entitlements.entitlementType,
627+
contentAccessEntitlementType.id,
628+
),
629+
eq(entitlements.sourceType, EntitlementSourceType.PURCHASE),
630+
eq(entitlements.sourceId, purchase.id),
631+
),
632+
)
633+
634+
log.info('Removed entitlements from source user', {
635+
sourceUserId: sourceUser.id,
636+
purchaseId: purchase.id,
637+
productType: resourceConfig.logPrefix,
638+
resourceId: context.resourceId,
639+
})
640+
},
641+
)
642+
643+
// Add entitlements to target user for this resource
644+
await addEntitlementsToTarget(step, context.productType, resourceConfig, {
645+
targetUser,
646+
purchase,
647+
resource: resourceData,
648+
targetOrganization: targetUserOrganization,
649+
targetMembership: targetUserOrgMembership,
650+
contentAccessEntitlementType,
651+
discordRoleEntitlementType: null, // Discord roles handled separately for primary resource only
652+
product: context.productForResource || product,
653+
})
654+
}
556655

557656
// Transfer coupon entitlements from source to target user
558657
// Uses eligibilityProductId in metadata to match entitlements to this purchase
@@ -572,7 +671,7 @@ async function handleProductTransfer({
572671
purchaseId: purchase.id,
573672
sourceUserId: sourceUser.id,
574673
targetUserId: targetUser.id,
575-
[`${config.logPrefix}Id`]: primaryResource?.id,
674+
resourceContextsCount: resourceContexts.length,
576675
productId: product.id,
577676
transferSource,
578677
})
@@ -583,7 +682,7 @@ async function handleProductTransfer({
583682
product,
584683
sourceUser,
585684
targetUser,
586-
primaryResource,
685+
resourceContexts,
587686
isTeamPurchase,
588687
transferSource,
589688
}

apps/ai-hero/src/lib/entitlements-query.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,16 @@ export async function createResourceEntitlements(
9292
if (productType === 'cohort') {
9393
// Loop through cohort resources
9494
for (const resourceItem of resource.resources || []) {
95+
// Skip items where resource is null
96+
if (!resourceItem.resource) {
97+
await log.warn('entitlement.resource_item_skipped', {
98+
userId: user.id,
99+
purchaseId: purchase.id,
100+
reason: 'Resource item has null resource',
101+
})
102+
continue
103+
}
104+
95105
const resourceId = resourceItem.resource.id
96106

97107
// Check for existing entitlement before creating

0 commit comments

Comments
 (0)