@@ -24,9 +24,11 @@ import {
2424
2525// Import shared configuration
2626import {
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 }
0 commit comments