diff --git a/localization-contentful/src/modules/contentful/loader/create-content-models.ts b/localization-contentful/src/modules/contentful/loader/create-content-models.ts index 7ec5aebd..8bd8811d 100644 --- a/localization-contentful/src/modules/contentful/loader/create-content-models.ts +++ b/localization-contentful/src/modules/contentful/loader/create-content-models.ts @@ -283,7 +283,7 @@ export default async function syncContentModelsLoader({ id: "product", name: "Product", type: "Link", - required: true, + required: false, localized: false, validations: [ { diff --git a/localization-contentful/src/modules/contentful/service.ts b/localization-contentful/src/modules/contentful/service.ts index 77a8550c..c639bde5 100644 --- a/localization-contentful/src/modules/contentful/service.ts +++ b/localization-contentful/src/modules/contentful/service.ts @@ -99,6 +99,25 @@ export default class ContentfulModuleService { return this.options.default_locale } + async getOptions(optionIds: string[]): Promise { + const options: EntryProps[] = [] + + for (const optionId of optionIds) { + try { + const option = await this.managementClient.entry.get({ + environmentId: this.options.environment, + entryId: optionId, + }) + options.push(option) + } catch (error) { + // Option entry doesn't exist, skip it + continue + } + } + + return options + } + async createProduct( product: ProductDTO ) { @@ -156,9 +175,35 @@ export default class ContentfulModuleService { } ) - // Create options if they exist + // Reference existing options (options are now global and created separately) + // We just link to them if they exist + const optionLinks: { + sys: { + type: "Link", + linkType: "Entry", + id: string + } + }[] = [] + if (product.options?.length) { - await this.createProductOption(product.options, productEntry) + for (const option of product.options) { + try { + // Check if option exists in Contentful + await this.managementClient.entry.get({ + environmentId: this.options.environment, + entryId: option.id, + }) + optionLinks.push({ + sys: { + type: "Link", + linkType: "Entry", + id: option.id + } + }) + } catch (e) { + // Option doesn't exist yet, skip it (should be created separately) + } + } } // Create variants if they exist @@ -185,13 +230,7 @@ export default class ContentfulModuleService { })) }, productOptions: { - [this.options.default_locale!]: product.options?.map(option => ({ - sys: { - type: "Link", - linkType: "Entry", - id: option.id - } - })) + [this.options.default_locale!]: optionLinks } } } @@ -236,43 +275,163 @@ export default class ContentfulModuleService { }) } - // Delete the product options entries and values - for (const option of productEntry.fields.productOptions[this.options.default_locale!]) { - for (const value of option.fields.values[this.options.default_locale!]) { + // Note: Product options are now global and should not be deleted when deleting a product + // They may be used by other products + } catch (error) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Failed to delete product from Contentful: ${error.message}` + ) + } + } + + async deleteProductOption(optionId: string) { + try { + // Get the option entry + const optionEntry = await this.managementClient.entry.get({ + environmentId: this.options.environment, + entryId: optionId, + }) + + if (!optionEntry) { + return + } + + // Delete option values first + const values = optionEntry.fields.values?.[this.options.default_locale!] || [] + for (const value of values) { + try { await this.managementClient.entry.unpublish({ environmentId: this.options.environment, entryId: value.sys.id, - }) + }) - await this.managementClient.entry.delete({ - environmentId: this.options.environment, + await this.managementClient.entry.delete({ + environmentId: this.options.environment, entryId: value.sys.id, }) + } catch (error) { + // Value might not exist or already deleted, continue } + } - await this.managementClient.entry.unpublish({ - environmentId: this.options.environment, - entryId: option.sys.id, - }) + // Delete the option entry + await this.managementClient.entry.unpublish({ + environmentId: this.options.environment, + entryId: optionId, + }) - await this.managementClient.entry.delete({ - environmentId: this.options.environment, - entryId: option.sys.id, - }) - } + await this.managementClient.entry.delete({ + environmentId: this.options.environment, + entryId: optionId, + }) } catch (error) { throw new MedusaError( MedusaError.Types.INVALID_DATA, - `Failed to delete product from Contentful: ${error.message}` + `Failed to delete product option from Contentful: ${error.message}` ) } } - private async createProductOption( - options: ProductOptionDTO[], - productEntry: EntryProps - ) { - for (const option of options) { + async createOrUpdateProductOption(option: ProductOptionDTO): Promise { + // Check if option already exists + let optionEntry: EntryProps + try { + optionEntry = await this.managementClient.entry.get({ + environmentId: this.options.environment, + entryId: option.id, + }) + + // Update existing option + const valueIds: { + sys: { + type: "Link", + linkType: "Entry", + id: string + } + }[] = [] + + for (const value of option.values) { + // Create or get value entry + let valueEntry: EntryProps + try { + valueEntry = await this.managementClient.entry.get({ + environmentId: this.options.environment, + entryId: value.id, + }) + + // Update existing value + await this.managementClient.entry.update( + { + entryId: value.id, + }, + { + sys: valueEntry.sys, + fields: { + ...valueEntry.fields, + value: { + [this.options.default_locale!]: value.value + }, + medusaId: { + [this.options.default_locale!]: value.id + }, + } + } + ) + } catch (e) { + // Create new value + await this.managementClient.entry.createWithId( + { + contentTypeId: "productOptionValue", + entryId: value.id, + }, + { + fields: { + value: { + [this.options.default_locale!]: value.value + }, + medusaId: { + [this.options.default_locale!]: value.id + }, + } + } + ) + } + + valueIds.push({ + sys: { + type: "Link", + linkType: "Entry", + id: value.id + } + }) + } + + // Update option entry (without product link since it's global) + await this.managementClient.entry.update( + { + entryId: option.id, + }, + { + sys: optionEntry.sys, + fields: { + ...optionEntry.fields, + medusaId: { + [this.options.default_locale!]: option.id + }, + title: { + [this.options.default_locale!]: option.title + }, + values: { + [this.options.default_locale!]: valueIds + } + } + } + ) + + return optionEntry + } catch (e) { + // Create new option const valueIds: { sys: { type: "Link", @@ -280,7 +439,9 @@ export default class ContentfulModuleService { id: string } }[] = [] + for (const value of option.values) { + // Create value entry await this.managementClient.entry.createWithId( { contentTypeId: "productOptionValue", @@ -297,6 +458,7 @@ export default class ContentfulModuleService { } } ) + valueIds.push({ sys: { type: "Link", @@ -305,7 +467,9 @@ export default class ContentfulModuleService { } }) } - await this.managementClient.entry.createWithId( + + // Create option entry (without product link since it's global) + optionEntry = await this.managementClient.entry.createWithId( { contentTypeId: "productOption", entryId: option.id, @@ -318,21 +482,14 @@ export default class ContentfulModuleService { title: { [this.options.default_locale!]: option.title }, - product: { - [this.options.default_locale!]: { - sys: { - type: "Link", - linkType: "Entry", - id: productEntry.sys.id - } - } - }, values: { [this.options.default_locale!]: valueIds } } } ) + + return optionEntry } } diff --git a/localization-contentful/src/workflows/create-products-contentful.ts b/localization-contentful/src/workflows/create-products-contentful.ts index 77aa91dc..cded5987 100644 --- a/localization-contentful/src/workflows/create-products-contentful.ts +++ b/localization-contentful/src/workflows/create-products-contentful.ts @@ -1,7 +1,9 @@ -import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk" +import { createWorkflow, WorkflowResponse, transform, when } from "@medusajs/framework/workflows-sdk" import { useQueryGraphStep } from "@medusajs/medusa/core-flows" import { createProductsContentfulStep } from "./steps/create-products-contentful" -import { ProductDTO } from "@medusajs/framework/types" +import { createProductOptionsContentfulStep } from "./steps/create-product-options-contentful" +import { getOptionsContentfulStep } from "./steps/get-options-contentful" +import { ProductDTO, ProductOptionDTO } from "@medusajs/framework/types" type WorkflowInput = { product_ids: string[] @@ -10,27 +12,81 @@ type WorkflowInput = { export const createProductsContentfulWorkflow = createWorkflow( { name: "create-products-contentful-workflow" }, (input: WorkflowInput) => { - const { data } = useQueryGraphStep({ + const { data: products } = useQueryGraphStep({ entity: "product", fields: [ "id", "title", "description", "subtitle", - "status", "handle", - "variants.*", - "variants.options.*", - "options.*", - "options.values.*", + "variants.id", + "variants.title", + "variants.options.id", + "variants.options.value", + "variants.options.option_id", + "options.id", + "options.title", + "options.product_id", + "options.values.id", + "options.values.value", ], filters: { id: input.product_ids, }, }) + // Extract unique options from products using transform + const uniqueOptions = transform( + { products }, + (data): ProductOptionDTO[] => { + const productData = data.products as unknown as ProductDTO[] + const optionsMap = new Map() + + productData.forEach((product) => { + product.options?.forEach((option) => { + if (!optionsMap.has(option.id)) { + optionsMap.set(option.id, option) + } + }) + }) + + return Array.from(optionsMap.values()) + } + ) + + // Get existing options from Contentful + const optionIds = transform( + { uniqueOptions }, + (data) => data.uniqueOptions.map((option) => option.id) + ) + + const existingOptions = getOptionsContentfulStep({ + option_ids: optionIds, + }) + + // Filter out existing options and only create new ones + const newOptions = transform( + { uniqueOptions, existingOptions }, + (data) => { + const existingIdsSet = new Set(data.existingOptions.map(option => option.sys.id)) + return data.uniqueOptions.filter( + (option) => !existingIdsSet.has(option.id) + ) + } + ) + + // Only create options that don't already exist in Contentful + when({ newOptions }, (data) => data.newOptions.length > 0) + .then(() => { + createProductOptionsContentfulStep({ + options: newOptions, + }) + }) + + // Then sync products (which will reference the options) const contentfulProducts = createProductsContentfulStep({ - products: data as unknown as ProductDTO[], + products: products as unknown as ProductDTO[], }) return new WorkflowResponse(contentfulProducts) diff --git a/localization-contentful/src/workflows/steps/create-product-options-contentful.ts b/localization-contentful/src/workflows/steps/create-product-options-contentful.ts new file mode 100644 index 00000000..3e51eef1 --- /dev/null +++ b/localization-contentful/src/workflows/steps/create-product-options-contentful.ts @@ -0,0 +1,53 @@ +import { ProductOptionDTO } from "@medusajs/framework/types" +import { CONTENTFUL_MODULE } from "../../modules/contentful" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import ContentfulModuleService from "../../modules/contentful/service" +import { EntryProps } from "contentful-management" + +type StepInput = { + options: ProductOptionDTO[] +} + +export const createProductOptionsContentfulStep = createStep( + "create-product-options-contentful-step", + async (input: StepInput, { container }) => { + const contentfulModuleService: ContentfulModuleService = + container.resolve(CONTENTFUL_MODULE) + + const options: EntryProps[] = [] + + try { + for (const option of input.options) { + options.push(await contentfulModuleService.createOrUpdateProductOption(option)) + } + } catch(e) { + return StepResponse.permanentFailure( + `Error creating product options in Contentful: ${e.message}`, + options + ) + } + + return new StepResponse( + options, + options + ) + }, + async (options, { container }) => { + if (!options) { + return + } + + const contentfulModuleService: ContentfulModuleService = + container.resolve(CONTENTFUL_MODULE) + + // Delete options that were created during this step + for (const option of options) { + try { + await contentfulModuleService.deleteProductOption(option.sys.id) + } catch (error) { + // Option might not exist or already deleted, continue + } + } + } +) + diff --git a/localization-contentful/src/workflows/steps/get-options-contentful.ts b/localization-contentful/src/workflows/steps/get-options-contentful.ts new file mode 100644 index 00000000..b7f65006 --- /dev/null +++ b/localization-contentful/src/workflows/steps/get-options-contentful.ts @@ -0,0 +1,19 @@ +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import ContentfulModuleService from "../../modules/contentful/service" +import { CONTENTFUL_MODULE } from "../../modules/contentful" + +type StepInput = { + option_ids: string[] +} + +export const getOptionsContentfulStep = createStep( + "get-options-contentful-step", + async (input: StepInput, { container }) => { + const contentfulModuleService: ContentfulModuleService = + container.resolve(CONTENTFUL_MODULE) + + const options = await contentfulModuleService.getOptions(input.option_ids) + + return new StepResponse(options) + } +) diff --git a/localization-contentful/src/workflows/steps/prepare-update-data.ts b/localization-contentful/src/workflows/steps/prepare-update-data.ts index 01e077cf..55d01d92 100644 --- a/localization-contentful/src/workflows/steps/prepare-update-data.ts +++ b/localization-contentful/src/workflows/steps/prepare-update-data.ts @@ -19,11 +19,21 @@ export const prepareUpdateDataStep = createStep( switch (entry.sys.contentType.sys.id) { case "product": + // Extract Medusa option IDs from productOptions field + // We need to fetch the option entries to get their medusaId values + const optionLinks = entry.fields.productOptions?.[defaultLocale!] || [] + const optionEntryIds = optionLinks.map( + (optionLink: { sys: { id: string } }) => optionLink.sys.id + ) + const options = await contentfulModuleService.getOptions(optionEntryIds) + const optionsMedusaIds = options.map(option => option.fields.medusaId[defaultLocale!]) + data = { id: entry.fields.medusaId[defaultLocale!], title: entry.fields.title[defaultLocale!], subtitle: entry.fields.subtitle?.[defaultLocale!] || undefined, handle: entry.fields.handle[defaultLocale!], + option_ids: optionsMedusaIds, } break case "productVariant": diff --git a/migrate-from-magento/src/subscribers/migrate-magento.ts b/migrate-from-magento/src/subscribers/migrate-magento.ts index 4821e66b..19be319b 100644 --- a/migrate-from-magento/src/subscribers/migrate-magento.ts +++ b/migrate-from-magento/src/subscribers/migrate-magento.ts @@ -21,7 +21,7 @@ export default async function productCreateHandler({ logger.info("Migrating products from Magento...") let currentPage = 0 - const pageSize = 100 + const pageSize = 10 let totalCount = 0 do { diff --git a/migrate-from-magento/src/workflows/migrate-products-from-magento.ts b/migrate-from-magento/src/workflows/migrate-products-from-magento.ts index af5e2869..69e0e23a 100644 --- a/migrate-from-magento/src/workflows/migrate-products-from-magento.ts +++ b/migrate-from-magento/src/workflows/migrate-products-from-magento.ts @@ -1,7 +1,8 @@ import { createWorkflow, transform, WorkflowResponse } from "@medusajs/framework/workflows-sdk" -import { CreateProductWorkflowInputDTO, UpsertProductDTO } from "@medusajs/framework/types" -import { createProductsWorkflow, updateProductsWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows" +import { CreateProductWorkflowInputDTO, CreateProductOptionDTO, UpsertProductDTO } from "@medusajs/framework/types" +import { createProductsWorkflow, createProductOptionsWorkflow, updateProductsWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows" import { getMagentoProductsStep } from "./steps/get-magento-products" +import { MagentoAttribute } from "../modules/magento/types" type MigrateProductsFromMagentoWorkflowInput = { currentPage: number @@ -77,6 +78,69 @@ export const migrateProductsFromMagentoWorkflow = createWorkflow( } }).config({ name: "get-existing-products" }) + const attributeExternalIds = transform({ + attributes + }, (data) => { + return data.attributes.map((attr) => attr.attribute_id.toString()) + }) + + const { data: existingOptions } = useQueryGraphStep({ + entity: "product_option", + fields: ["id", "title", "metadata", "values.id", "values.value"], + filters: { + metadata: { + external_id: attributeExternalIds + } + } + }).config({ name: "get-existing-options" }) + + const { optionsToCreate } = transform({ + attributes, + existingOptions + }, (data) => { + const optionsToCreate = new Map }>() + + data.attributes.forEach((attr: MagentoAttribute) => { + const existingOption = data.existingOptions.find( + (option) => option.metadata?.external_id === attr.attribute_id.toString() + ) + + // If option already exists, skip it + if (existingOption) { + return + } + + const optionData: CreateProductOptionDTO & { metadata?: Record } = { + title: attr.default_frontend_label, + values: attr.options.map((opt) => opt.label), + metadata: { + external_id: attr.attribute_id.toString() + } + } + + optionsToCreate.set(attr.attribute_id.toString(), optionData) + }) + + return { + optionsToCreate: Array.from(optionsToCreate.values()) + } + }) + + // Create missing options if any + const newOptions = createProductOptionsWorkflow.runAsStep({ + input: { + product_options: optionsToCreate + } + }) + + // Merge existing and newly created options + const allOptions = transform({ + existingOptions, + newOptions + }, (data) => { + return [...(data.existingOptions || []), ...(data.newOptions || [])] + }) + const { productsToCreate, productsToUpdate @@ -86,7 +150,8 @@ export const migrateProductsFromMagentoWorkflow = createWorkflow( stores, categories, shippingProfiles, - existingProducts + existingProducts, + allOptions }, (data) => { const productsToCreate = new Map() const productsToUpdate = new Map() @@ -115,15 +180,38 @@ export const migrateProductsFromMagentoWorkflow = createWorkflow( return category?.id }).filter(Boolean) - productData.options = magentoProduct.extension_attributes.configurable_product_options?.map((option) => { - const attribute = data.attributes.find((attr) => attr.attribute_id === parseInt(option.attribute_id)) - return { - title: option.label, - values: attribute?.options.filter((opt) => { - return option.values.find((v) => v.value_index === parseInt(opt.value)) - }).map((opt) => opt.label) || [] - } - }) || [] + productData.options = magentoProduct.extension_attributes.configurable_product_options + ?.map((option) => { + const attribute = data.attributes.find((attr) => attr.attribute_id === parseInt(option.attribute_id)) + const existingOption = data.allOptions.find( + (opt) => opt.metadata?.external_id === option.attribute_id + ) + + if (!existingOption || !attribute) { + return null + } + + // Map option values to their IDs + const valueIds = option.values + .map((v) => { + const attributeOption = attribute.options.find((opt) => parseInt(opt.value) === v.value_index) + if (!attributeOption) { + return null + } + + const optionValue = existingOption.values.find( + (val) => val.value === attributeOption.label + ) + return optionValue?.id + }) + .filter((id): id is string => Boolean(id)) + + return { + id: existingOption.id, + value_ids: valueIds + } + }) + .filter((opt): opt is { id: string; value_ids: string[] } => opt !== null) || [] productData.variants = magentoProduct.children?.map((child) => { const childOptions: Record = {} diff --git a/payload-integration/medusa/src/subscribers/option-updated.ts b/payload-integration/medusa/src/subscribers/option-updated.ts new file mode 100644 index 00000000..c0f2ca3c --- /dev/null +++ b/payload-integration/medusa/src/subscribers/option-updated.ts @@ -0,0 +1,20 @@ +import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework" +import { updatePayloadProductOptionsWorkflow } from "../workflows/update-payload-product-options" + +export default async function productOptionUpdatedHandler({ + event: { data }, + container, +}: SubscriberArgs<{ + id: string +}>) { + await updatePayloadProductOptionsWorkflow(container) + .run({ + input: { + option_ids: [data.id], + } + }) +} + +export const config: SubscriberConfig = { + event: "product-option.updated", +} diff --git a/payload-integration/medusa/src/subscribers/product-updated.ts b/payload-integration/medusa/src/subscribers/product-updated.ts new file mode 100644 index 00000000..26276961 --- /dev/null +++ b/payload-integration/medusa/src/subscribers/product-updated.ts @@ -0,0 +1,20 @@ +import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework" +import { updatePayloadProductsWorkflow } from "../workflows/update-payload-products" + +export default async function productUpdatedHandler({ + event: { data }, + container, +}: SubscriberArgs<{ + id: string +}>) { + await updatePayloadProductsWorkflow(container) + .run({ + input: { + product_ids: [data.id], + } + }) +} + +export const config: SubscriberConfig = { + event: "product.updated", +} diff --git a/payload-integration/medusa/src/workflows/create-payload-product-options.ts b/payload-integration/medusa/src/workflows/create-payload-product-options.ts index d1a5dde4..e52ed7d1 100644 --- a/payload-integration/medusa/src/workflows/create-payload-product-options.ts +++ b/payload-integration/medusa/src/workflows/create-payload-product-options.ts @@ -1,7 +1,8 @@ -import { createWorkflow, transform, when, WorkflowResponse } from "@medusajs/framework/workflows-sdk"; +import { createWorkflow, transform, WorkflowResponse } from "@medusajs/framework/workflows-sdk"; import { useQueryGraphStep } from "@medusajs/medusa/core-flows"; -import { PayloadCollectionItem, PayloadUpsertData } from "../modules/payload/types"; +import { createPayloadItemsStep } from "./steps/create-payload-items"; import { updatePayloadItemsStep } from "./steps/update-payload-items"; +import { PayloadCollectionItem, PayloadUpsertData } from "../modules/payload/types"; type WorkflowInput = { option_ids: string[]; @@ -15,7 +16,8 @@ export const createPayloadProductOptionsWorkflow = createWorkflow( fields: [ "id", "title", - "product.payload_product.*" + "values.id", + "values.value" ], filters: { id: option_ids, @@ -25,56 +27,96 @@ export const createPayloadProductOptionsWorkflow = createWorkflow( } }) - const updateData = transform({ + // Create ProductOptions (without values initially) + const createOptionsData = transform({ productOptions }, (data) => { - const items: Record = {} + return { + collection: "product-options", + items: data.productOptions.map((option) => ({ + medusa_id: option.id, + title: option.title, + values: [], // Will be populated after values are created + })) + } + }) + + const { items: createdOptions } = createPayloadItemsStep(createOptionsData) + + // Create option values with option references + const createValuesData = transform({ + productOptions, + createdOptions + }, (data) => { + const optionMap = new Map() + data.createdOptions.forEach((opt) => { + optionMap.set(opt.medusa_id, opt) + }) + const valuesToCreate: PayloadUpsertData[] = [] data.productOptions.forEach((option) => { - // @ts-expect-error - const payloadProduct = option.product?.payload_product as PayloadCollectionItem - if (!payloadProduct) return - - if (!items[payloadProduct.id]) { - items[payloadProduct.id] = { - options: payloadProduct.options || [], - } - } - - // Add the new option to the payload product - const newOption = { - title: option.title, - medusa_id: option.id, - } - - // Check if option already exists, if not add it - const existingOptionIndex = items[payloadProduct.id].options.findIndex( - (o: any) => o.medusa_id === option.id - ) - - if (existingOptionIndex === -1) { - items[payloadProduct.id].options.push(newOption) - } + const optionPayload = optionMap.get(option.id) + if (!optionPayload) return + + option.values.forEach((value) => { + valuesToCreate.push({ + medusa_id: value.id, + value: value.value, + option: optionPayload.id, + }) + }) }) - + return { - collection: "products", - items: Object.keys(items).map((id) => ({ - id, - ...items[id], - })), + collection: "option-values", + items: valuesToCreate } }) - const result = when({ updateData }, (data) => data.updateData.items.length > 0) - .then(() => { - return updatePayloadItemsStep(updateData) + const { items: createdValues } = createPayloadItemsStep(createValuesData) + .config({ name: "create-payload-product-options-values" }) + + // Update ProductOptions with value references + const updateOptionsWithValues = transform({ + productOptions, + createdOptions, + createdValues + }, (data) => { + const optionMap = new Map() + data.createdOptions.forEach((opt) => { + optionMap.set(opt.medusa_id, opt) + }) + + const valuesMap = new Map() + data.createdValues.forEach((v) => { + valuesMap.set(v.medusa_id, v) + }) + + const items: PayloadUpsertData[] = [] + data.productOptions.forEach((option) => { + const payloadOption = optionMap.get(option.id) + if (!payloadOption) return + + const valuePayloadIds = option.values + .map((value) => valuesMap.get(value.id)?.id) + .filter(Boolean) as string[] + + items.push({ + id: payloadOption.id, + values: valuePayloadIds, + }) }) - const items = transform({ result }, (data) => data.result?.items || []) + return { + collection: "product-options", + items + } + }) + + updatePayloadItemsStep(updateOptionsWithValues) return new WorkflowResponse({ - items + items: createdOptions }) } ) diff --git a/payload-integration/medusa/src/workflows/create-payload-product-variant.ts b/payload-integration/medusa/src/workflows/create-payload-product-variant.ts index 3ccc3034..a794fc44 100644 --- a/payload-integration/medusa/src/workflows/create-payload-product-variant.ts +++ b/payload-integration/medusa/src/workflows/create-payload-product-variant.ts @@ -1,6 +1,7 @@ import { createWorkflow, transform, when, WorkflowResponse } from "@medusajs/framework/workflows-sdk"; import { useQueryGraphStep } from "@medusajs/medusa/core-flows"; import { PayloadCollectionItem, PayloadUpsertData } from "../modules/payload/types"; +import { retrievePayloadItemsStep } from "./steps/retrieve-payload-items"; import { updatePayloadItemsStep } from "./steps/update-payload-items"; type WorkflowInput = { @@ -15,8 +16,8 @@ export const createPayloadProductVariantWorkflow = createWorkflow( fields: [ "id", "title", - "options.*", - "options.option.*", + "options.id", + "options.option.id", "product.payload_product.*" ], filters: { @@ -27,29 +28,54 @@ export const createPayloadProductVariantWorkflow = createWorkflow( } }) - const updateData = transform({ + // Retrieve existing option values + const retrieveValuesData = transform({ productVariants }, (data) => { - const items: Record = {} + const valueIds = new Set() + data.productVariants.forEach((variant) => { + variant.options.forEach((opt) => valueIds.add(opt.id)) + }) + return { + collection: "option-values", + where: { + medusa_id: { + in: Array.from(valueIds).join(",") + } + } + } + }) + + const { items: existingValues } = retrievePayloadItemsStep(retrieveValuesData) + + const updateData = transform({ + productVariants, + existingValues + }, (data) => { + const valuesMap = new Map() + ;(data.existingValues || []).forEach((v) => { + valuesMap.set(v.medusa_id, v.id) + }) + const items: Record = {} data.productVariants.forEach((variant) => { - // @ts-expect-error - const payloadProduct = variant.product?.payload_product as PayloadCollectionItem + const payloadProduct = (variant.product as any)?.payload_product as PayloadCollectionItem if (!payloadProduct) return + if (!items[payloadProduct.id]) { items[payloadProduct.id] = { variants: payloadProduct.variants || [], } } + const optionValueIds = (variant.options || []) + .map((opt) => valuesMap.get(opt.id)) + .filter(Boolean) as string[] + items[payloadProduct.id].variants.push({ title: variant.title, medusa_id: variant.id, - option_values: variant.options.map((option) => ({ - medusa_id: option.id, - medusa_option_id: option.option?.id, - value: option.value, - })), + option_values: optionValueIds, }) }) diff --git a/payload-integration/medusa/src/workflows/create-payload-products.ts b/payload-integration/medusa/src/workflows/create-payload-products.ts index 968d00a0..c7ff91f6 100644 --- a/payload-integration/medusa/src/workflows/create-payload-products.ts +++ b/payload-integration/medusa/src/workflows/create-payload-products.ts @@ -1,6 +1,9 @@ -import { createWorkflow, transform, WorkflowResponse } from "@medusajs/framework/workflows-sdk"; +import { createWorkflow, transform, when, WorkflowResponse } from "@medusajs/framework/workflows-sdk"; import { createPayloadItemsStep } from "./steps/create-payload-items"; import { updateProductsWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows"; +import { retrievePayloadItemsStep } from "./steps/retrieve-payload-items"; +import { createPayloadProductOptionsWorkflow } from "./create-payload-product-options"; +import { PayloadCollectionItem } from "../modules/payload/types"; type WorkflowInput = { product_ids: string[] @@ -19,11 +22,16 @@ export const createPayloadProductsWorkflow = createWorkflow( "description", "created_at", "updated_at", - "options.*", - "variants.*", - "variants.options.*", - "thumbnail", - "images.*" + "options.id", + "options.title", + "options.values.id", + "options.values.value", + "variants.title", + "variants.id", + "variants.options.id", + "variants.options.option.id", + "variants.options.option.value", + "thumbnail" ], filters: { id: input.product_ids, @@ -33,46 +41,157 @@ export const createPayloadProductsWorkflow = createWorkflow( } }) - const createData = transform({ + // Check which options already exist in Payload + const { + retrieve_data: retrieveOptionsData, + option_ids: allOptionIds + } = transform({ products }, (data) => { + const optionIds = new Set() + data.products.forEach((product) => { + product.options.forEach((option) => { + if (option.id) { + optionIds.add(option.id) + } + }) + }) + const allOptionIds = Array.from(optionIds) + return { + retrieve_data: { + collection: "product-options", + where: { + medusa_id: { + in: allOptionIds.join(",") + } + } + }, + option_ids: allOptionIds + } + }) + + const { items: existingOptions } = retrievePayloadItemsStep(retrieveOptionsData) + + // Create missing options + const createOptionIds = transform({ + allOptionIds, + existingOptions + }, (data) => { + const existingMedusaIds = new Set( + (data.existingOptions || []).map((o) => o.medusa_id) + ) + + const optionIdsToCreate = data.allOptionIds.filter( + (optionId: string) => !existingMedusaIds.has(optionId) + ) + + return optionIdsToCreate + }) + + const createdOptionsResult = when({ createOptionIds }, (data) => data.createOptionIds.length > 0) + .then(() => { + return createPayloadProductOptionsWorkflow.runAsStep({ + input: { + option_ids: createOptionIds + } + }) + }) + + // Combine existing and created options + const allPayloadOptions = transform({ + existingOptions, + createdOptionsResult + }, (data) => { + return [ + ...(data.existingOptions || []), + ...(data.createdOptionsResult?.items || []) + ] + }) + + // Retrieve existing option values and create products + const retrieveValuesData = transform({ + products + }, (data) => { + const valueIds = new Set() + data.products.forEach((product) => { + product.variants.forEach((variant) => { + variant.options.forEach((opt) => { + valueIds.add(opt.id) + }) + }) + }) + return { + collection: "option-values", + where: { + medusa_id: { + in: Array.from(valueIds).join(",") + } + } + } + }) + + const { items: existingValues } = retrievePayloadItemsStep(retrieveValuesData) + .config({ name: "retrieve-payload-option-values" }) + + // Create products with option references + const createData = transform({ + products, + allPayloadOptions, + existingValues + }, (data) => { + const optionMap = new Map() + data.allPayloadOptions.forEach((option) => { + optionMap.set(option.medusa_id, option) + }) + + const valuesMap = new Map() + ;(data.existingValues || []).forEach((v) => { + valuesMap.set(v.medusa_id, v.id) + }) + return { collection: "products", - items: data.products.map(product => ({ - medusa_id: product.id, - createdAt: product.created_at as string, - updatedAt: product.updated_at as string, - title: product.title, - subtitle: product.subtitle, - description: product.description || "", - options: product.options.map((option) => ({ - title: option.title, - medusa_id: option.id, - })), - variants: product.variants.map((variant) => ({ - title: variant.title, - medusa_id: variant.id, - option_values: variant.options.map((option) => ({ - medusa_id: option.id, - medusa_option_id: option.option?.id, - value: option.value, - })), - })), - })) + items: data.products.map((product) => { + const productOptionIds = (product.options || []) + .map((option) => optionMap.get(option.id)?.id) + .filter(Boolean) as string[] + + return { + medusa_id: product.id, + createdAt: product.created_at as string, + updatedAt: product.updated_at as string, + title: product.title, + subtitle: product.subtitle, + description: product.description || "", + options: productOptionIds, + variants: product.variants.map((variant) => { + const optionValueIds = (variant.options || []) + .map((opt) => valuesMap.get(opt.id)) + .filter(Boolean) as string[] + + return { + title: variant.title, + medusa_id: variant.id, + option_values: optionValueIds, + } + }), + } + }) } }) - const { items } = createPayloadItemsStep( + const { items: createdProducts } = createPayloadItemsStep( createData ) + // Store payload_id in metadata const updateData = transform({ - items + createdProducts }, (data) => { - return data.items.map(item => ({ + return data.createdProducts.map((item) => ({ id: item.medusa_id, metadata: { - payload_id: item.id + payload_id: item.id, } })) }) @@ -84,7 +203,7 @@ export const createPayloadProductsWorkflow = createWorkflow( }) return new WorkflowResponse({ - items + items: createdProducts }) } ) \ No newline at end of file diff --git a/payload-integration/medusa/src/workflows/delete-payload-product-options.ts b/payload-integration/medusa/src/workflows/delete-payload-product-options.ts index b9761cd1..2d0af572 100644 --- a/payload-integration/medusa/src/workflows/delete-payload-product-options.ts +++ b/payload-integration/medusa/src/workflows/delete-payload-product-options.ts @@ -1,6 +1,6 @@ import { createWorkflow, transform, when, WorkflowResponse } from "@medusajs/framework/workflows-sdk" -import { updatePayloadItemsStep } from "./steps/update-payload-items"; -import { retrievePayloadItemsStep } from "./steps/retrieve-payload-items"; +import { retrievePayloadItemsStep } from "./steps/retrieve-payload-items" +import { deletePayloadItemsStep } from "./steps/delete-payload-items" type WorkflowInput = { option_ids: string[] @@ -9,49 +9,68 @@ type WorkflowInput = { export const deletePayloadProductOptionsWorkflow = createWorkflow( "delete-payload-product-options", ({ option_ids }: WorkflowInput) => { - const retrieveData = transform({ + // Retrieve the options to get their Payload IDs + const retrieveOptionsData = transform({ option_ids }, (data) => { return { - collection: "products", + collection: "product-options", where: { - "options.medusa_id": { + medusa_id: { in: data.option_ids.join(",") } } } }) - const { items: payloadProducts } = retrievePayloadItemsStep(retrieveData) + const { items: payloadOptions } = retrievePayloadItemsStep(retrieveOptionsData) - const updateData = transform({ - payloadProducts, - option_ids + // Step 2: Delete OptionValues (after products are updated, if any) + const deleteValuesData = transform({ + payloadOptions, }, (data) => { - const items = data.payloadProducts.map((payloadProducts) => ({ - id: payloadProducts.id, - options: payloadProducts.options.filter((o: any) => !data.option_ids.includes(o.medusa_id)), - variants: payloadProducts.variants.map((variant: any) => ({ - ...variant, - option_values: variant.option_values.filter((ov: any) => !data.option_ids.includes(ov.medusa_option_id)) - })) - })) - + if (!data.payloadOptions || data.payloadOptions.length === 0) { + return null + } + const valueIds: string[] = [] + data.payloadOptions.forEach((option) => { + option.values.forEach((value) => { + valueIds.push(value.id) + }) + }) return { - collection: "products", - items, + collection: "option-values", + where: { + id: { + in: valueIds.join(",") + } + } } }) - const result = when({ updateData }, (data) => data.updateData.items.length > 0) + const deleteValuesResult = when({ deleteValuesData }, (data) => data.deleteValuesData !== null) .then(() => { - return updatePayloadItemsStep(updateData) + return deletePayloadItemsStep(deleteValuesData).config({ name: "delete-payload-option-values" }) }) - const items = transform({ result }, (data) => data.result?.items || []) - - return new WorkflowResponse({ - items + // Step 3: Delete ProductOptions (after values are deleted, if any) + // This step always runs, ensuring sequential execution + const deleteOptionsData = transform({ + payloadOptions, + deleteValuesResult + }, (data) => { + return { + collection: "product-options", + where: { + id: { + in: data.payloadOptions.map((opt: any) => opt.id).join(",") + } + } + } }) + + deletePayloadItemsStep(deleteOptionsData) + + return new WorkflowResponse(void 0) } ) diff --git a/payload-integration/medusa/src/workflows/update-payload-product-options.ts b/payload-integration/medusa/src/workflows/update-payload-product-options.ts new file mode 100644 index 00000000..f6e9bf11 --- /dev/null +++ b/payload-integration/medusa/src/workflows/update-payload-product-options.ts @@ -0,0 +1,193 @@ +import { createWorkflow, transform, when, WorkflowResponse } from "@medusajs/framework/workflows-sdk"; +import { useQueryGraphStep } from "@medusajs/medusa/core-flows"; +import { retrievePayloadItemsStep } from "./steps/retrieve-payload-items"; +import { createPayloadItemsStep } from "./steps/create-payload-items"; +import { updatePayloadItemsStep } from "./steps/update-payload-items"; +import { deletePayloadItemsStep } from "./steps/delete-payload-items"; +import { PayloadCollectionItem, PayloadUpsertData } from "../modules/payload/types"; + +type WorkflowInput = { + option_ids: string[]; +} + +export const updatePayloadProductOptionsWorkflow = createWorkflow( + "update-payload-product-options", + ({ option_ids }: WorkflowInput) => { + const { data: productOptions } = useQueryGraphStep({ + entity: "product_option", + fields: [ + "id", + "title", + "values.id", + "values.value" + ], + filters: { + id: option_ids, + }, + options: { + throwIfKeyNotFound: true, + } + }) + + // Retrieve existing options from Payload + const retrieveOptionsData = transform({ + productOptions + }, (data) => { + return { + collection: "product-options", + where: { + medusa_id: { + in: data.productOptions.map((o: any) => o.id).join(",") + } + } + } + }) + + const { items: existingOptions } = retrievePayloadItemsStep(retrieveOptionsData) + + // Retrieve existing option values for these options + const retrieveValuesData = transform({ + existingOptions + }, (data) => { + if (data.existingOptions.length === 0) { + return null + } + const optionPayloadIds = data.existingOptions.map((opt: any) => opt.id) + return { + collection: "option-values", + where: { + option: { + in: optionPayloadIds.join(",") + } + } + } + }) + + const { items: existingValues } = retrievePayloadItemsStep(retrieveValuesData) + .config({ name: "retrieve-payload-option-values" }) + + // Create new OptionValues and identify values to delete + const { createValuesData, deleteValuesData } = transform({ + productOptions, + existingOptions, + existingValues + }, (data) => { + const existingValueIds = new Set( + (data.existingValues || []).map((v) => v.medusa_id) + ) + const optionToPayloadId = new Map() + data.existingOptions.forEach((opt) => { + optionToPayloadId.set(opt.medusa_id, opt.id) + }) + + const valuesToCreate: PayloadUpsertData[] = [] + const currentValueIds = new Set() + + data.productOptions.forEach((option) => { + const optionPayloadId = optionToPayloadId.get(option.id) + if (!optionPayloadId) return + + option.values.forEach((value) => { + currentValueIds.add(value.id) + + // Only create if it doesn't exist + if (!existingValueIds.has(value.id)) { + valuesToCreate.push({ + medusa_id: value.id, + value: value.value, + option: optionPayloadId, + }) + } + }) + }) + + // Find values to delete (exist in Payload but not in current Medusa option values) + const valuesToDelete = (data.existingValues || []).filter((v) => { + return !currentValueIds.has(v.medusa_id) + }) + + const deletedMedusaIdsSet = new Set(valuesToDelete.map((v) => v.medusa_id)) + + return { + createValuesData: { + collection: "option-values", + items: valuesToCreate + }, + deleteValuesData: { + collection: "option-values", + where: { + medusa_id: { + in: Array.from(deletedMedusaIdsSet).join(",") + } + } + }, + } + }) + + const createdValuesResult = when({ createValuesData }, (data) => data.createValuesData.items.length > 0) + .then(() => { + return createPayloadItemsStep(createValuesData) + }) + + when({ deleteValuesData }, (data) => data.deleteValuesData.where.medusa_id.in.length > 0) + .then(() => { + return deletePayloadItemsStep(deleteValuesData) + }) + + // Update ProductOptions with value references + const updateOptionsData = transform({ + productOptions, + existingOptions, + createdValuesResult, + existingValues, + deleteValuesData + }, (data) => { + const deletedMedusaIds = new Set( + data.deleteValuesData.where.medusa_id.in.split(",") + ) + + const allValues = [ + ...(data.createdValuesResult?.items || []), + ...(data.existingValues || []) + ].filter((v) => !deletedMedusaIds.has(v.medusa_id)) + + const optionMap = new Map() + data.existingOptions.forEach((opt) => { + optionMap.set(opt.medusa_id, opt) + }) + + const items = data.productOptions + .map((option) => { + const existing = optionMap.get(option.id) + if (!existing) return null + + const valueIds = allValues + .filter((v) => v.option?.id === existing.id) + .map((v) => v.id) + + return { + id: existing.id, + values: valueIds, + } + }) + .filter((item): item is NonNullable => item !== null) + + return { + collection: "product-options", + items, + } + }) + + const result = when({ updateOptionsData }, (data) => data.updateOptionsData.items.length > 0) + .then(() => { + return updatePayloadItemsStep(updateOptionsData) + }) + + const items = transform({ result }, (data) => data.result?.items || []) + + return new WorkflowResponse({ + items + }) + } +) + diff --git a/payload-integration/medusa/src/workflows/update-payload-product-variants.ts b/payload-integration/medusa/src/workflows/update-payload-product-variants.ts index 7f869528..f115ac4e 100644 --- a/payload-integration/medusa/src/workflows/update-payload-product-variants.ts +++ b/payload-integration/medusa/src/workflows/update-payload-product-variants.ts @@ -1,6 +1,7 @@ import { createWorkflow, transform, when, WorkflowResponse } from "@medusajs/framework/workflows-sdk"; import { useQueryGraphStep } from "@medusajs/medusa/core-flows"; import { PayloadCollectionItem, PayloadUpsertData } from "../modules/payload/types"; +import { retrievePayloadItemsStep } from "./steps/retrieve-payload-items"; import { updatePayloadItemsStep } from "./steps/update-payload-items"; type WorkflowInput = { @@ -15,8 +16,8 @@ export const updatePayloadProductVariantsWorkflow = createWorkflow( fields: [ "id", "title", - "options.*", - "options.option.*", + "options.id", + "options.option.id", "product.payload_product.*" ], filters: { @@ -27,14 +28,38 @@ export const updatePayloadProductVariantsWorkflow = createWorkflow( } }) - const updateData = transform({ + // Retrieve existing option values + const retrieveValuesData = transform({ productVariants }, (data) => { - const items: Record = {} + const valueIds = new Set() + data.productVariants.forEach((variant) => { + variant.options.forEach((opt) => valueIds.add(opt.id)) + }) + return { + collection: "option-values", + where: { + medusa_id: { + in: Array.from(valueIds).join(",") + } + } + } + }) + const { items: existingValues } = retrievePayloadItemsStep(retrieveValuesData) + + const updateData = transform({ + productVariants, + existingValues + }, (data) => { + const valuesMap = new Map() + ;(data.existingValues || []).forEach((v) => { + valuesMap.set(v.medusa_id, v.id) + }) + + const items: Record = {} data.productVariants.forEach((variant) => { - // @ts-expect-error - const payloadProduct = variant.product?.payload_product as PayloadCollectionItem + const payloadProduct = (variant.product as any)?.payload_product as PayloadCollectionItem | undefined if (!payloadProduct) return if (!items[payloadProduct.id]) { @@ -43,35 +68,25 @@ export const updatePayloadProductVariantsWorkflow = createWorkflow( } } - // Find and update the existing variant in the payload product + const optionValueIds = (variant.options || []) + .map((opt) => valuesMap.get(opt.id)) + .filter(Boolean) as string[] + const existingVariantIndex = items[payloadProduct.id].variants.findIndex( - (v: any) => v.medusa_id === variant.id + (v) => v.medusa_id === variant.id ) if (existingVariantIndex >= 0) { - // check if option values need to be updated - const existingVariant = items[payloadProduct.id].variants[existingVariantIndex]; - const updatedOptionValues = variant.options.map((option) => ({ - medusa_id: option.id, - medusa_option_id: option.option?.id, - value: existingVariant.option_values.find((ov: any) => ov.medusa_id === option.id)?.value || - option.value, - })) - items[payloadProduct.id].variants[existingVariantIndex] = { - ...existingVariant, - option_values: updatedOptionValues, + ...items[payloadProduct.id].variants[existingVariantIndex], + title: variant.title, + option_values: optionValueIds, } } else { - // Add the new variant to the payload product items[payloadProduct.id].variants.push({ title: variant.title, medusa_id: variant.id, - option_values: variant.options.map((option) => ({ - medusa_id: option.id, - medusa_option_id: option.option?.id, - value: option.value, - })), + option_values: optionValueIds, }) } }) diff --git a/payload-integration/medusa/src/workflows/update-payload-products.ts b/payload-integration/medusa/src/workflows/update-payload-products.ts new file mode 100644 index 00000000..f7f15fbb --- /dev/null +++ b/payload-integration/medusa/src/workflows/update-payload-products.ts @@ -0,0 +1,140 @@ +import { createWorkflow, transform, when, WorkflowResponse } from "@medusajs/framework/workflows-sdk"; +import { useQueryGraphStep } from "@medusajs/medusa/core-flows"; +import { retrievePayloadItemsStep } from "./steps/retrieve-payload-items"; +import { updatePayloadItemsStep } from "./steps/update-payload-items"; +import { PayloadUpsertData } from "../modules/payload/types"; +import { createPayloadProductOptionsWorkflow } from "./create-payload-product-options"; + +type WorkflowInput = { + product_ids: string[] +} + +export const updatePayloadProductsWorkflow = createWorkflow( + "update-payload-products", + (input: WorkflowInput) => { + const { data: products } = useQueryGraphStep({ + entity: "product", + fields: [ + "id", + "title", + "handle", + "subtitle", + "description", + "updated_at", + "options.id", + "options.title", + "options.values.id", + "options.values.value", + "metadata.payload_id", + ], + filters: { + id: input.product_ids, + }, + options: { + throwIfKeyNotFound: true, + } + }) + + // Check which options already exist in Payload + const retrieveOptionsData = transform({ + products + }, (data) => { + const optionIds = new Set() + data.products.forEach((product) => { + product.options.forEach((option) => { + optionIds.add(option.id) + }) + }) + return { + collection: "product-options", + where: { + medusa_id: { + in: Array.from(optionIds).join(",") + } + } + } + }) + + const { items: existingOptions } = retrievePayloadItemsStep(retrieveOptionsData) + + // Create missing options + const optionsToCreate = transform({ + products, + existingOptions + }, (data) => { + const existingMedusaIds = new Set( + (data.existingOptions || []).map((o) => o.medusa_id) + ) + + const optionsToCreate: Set = new Set() + data.products.forEach((product) => { + product.options.forEach((option) => { + if (!existingMedusaIds.has(option.id)) { + optionsToCreate.add(option.id) + } + }) + }) + + return Array.from(optionsToCreate) + }) + + const createdOptionsResult = when({ optionsToCreate }, (data) => data.optionsToCreate.length > 0) + .then(() => { + return createPayloadProductOptionsWorkflow.runAsStep({ + input: { + option_ids: optionsToCreate + } + }) + }) + + // Combine existing and created options + const allPayloadOptions = transform({ + existingOptions, + createdOptionsResult + }, (data) => { + return [ + ...(data.existingOptions || []), + ...(data.createdOptionsResult?.items || []) + ] + }) + + // Update products with option references + const updateData = transform({ + products, + allPayloadOptions + }, (data) => { + const optionMap = new Map() + data.allPayloadOptions.forEach((option: any) => { + optionMap.set(option.medusa_id, option.id) + }) + + const items = data.products + .map((product) => { + const payloadProductId = product.metadata?.payload_id + if (!payloadProductId) return null + + const productOptionIds = (product.options || []) + .map((option) => optionMap.get(option.id)) + .filter(Boolean) as string[] + + return { + id: payloadProductId, + options: productOptionIds, + } + }) + .filter((item): item is NonNullable => item !== null) + + return { + collection: "products", + items: items as PayloadUpsertData[], + } + }) + + const { items: updatedProducts } = updatePayloadItemsStep(updateData) + + return new WorkflowResponse({ + items: updatedProducts + }) + } +) + diff --git a/payload-integration/storefront/src/collections/OptionValues.ts b/payload-integration/storefront/src/collections/OptionValues.ts new file mode 100644 index 00000000..14740f0b --- /dev/null +++ b/payload-integration/storefront/src/collections/OptionValues.ts @@ -0,0 +1,47 @@ +import { CollectionConfig } from 'payload' + +export const OptionValues: CollectionConfig = { + slug: 'option-values', + admin: { + useAsTitle: 'value', + }, + fields: [ + { + name: 'medusa_id', + type: 'text', + label: 'Medusa Option Value ID', + required: true, + unique: true, + admin: { + description: 'The unique identifier from Medusa', + hidden: true, + }, + access: { + update: ({ req }) => !!req.query.is_from_medusa, + } + }, + { + name: 'value', + type: 'text', + label: 'Value', + required: true, + admin: { + description: 'The option value (e.g., Small, Red)', + }, + }, + { + name: 'option', + type: 'relationship', + relationTo: 'product-options' as any, + required: true, + admin: { + description: 'The product option this value belongs to', + }, + }, + ], + access: { + create: ({ req }) => !!req.query.is_from_medusa, + delete: ({ req }) => !!req.query.is_from_medusa, + } +} + diff --git a/payload-integration/storefront/src/collections/ProductOptions.ts b/payload-integration/storefront/src/collections/ProductOptions.ts new file mode 100644 index 00000000..1b07b51a --- /dev/null +++ b/payload-integration/storefront/src/collections/ProductOptions.ts @@ -0,0 +1,48 @@ +import { CollectionConfig } from 'payload' + +export const ProductOptions: CollectionConfig = { + slug: 'product-options', + admin: { + useAsTitle: 'title', + }, + fields: [ + { + name: 'medusa_id', + type: 'text', + label: 'Medusa Option ID', + required: true, + unique: true, + admin: { + description: 'The unique identifier from Medusa', + hidden: true, + }, + access: { + update: ({ req }) => !!req.query.is_from_medusa, + } + }, + { + name: 'title', + type: 'text', + label: 'Title', + required: true, + admin: { + description: 'The option title (e.g., Size, Color)', + }, + }, + { + name: 'values', + type: 'relationship', + relationTo: 'option-values' as any, + hasMany: true, + required: false, + admin: { + description: 'Option values (e.g., Small, Medium, Large)', + }, + }, + ], + access: { + create: ({ req }) => !!req.query.is_from_medusa, + delete: ({ req }) => !!req.query.is_from_medusa, + } +} + diff --git a/payload-integration/storefront/src/collections/Products.ts b/payload-integration/storefront/src/collections/Products.ts index 6bea8aaf..3383db0d 100644 --- a/payload-integration/storefront/src/collections/Products.ts +++ b/payload-integration/storefront/src/collections/Products.ts @@ -105,44 +105,12 @@ export const Products: CollectionConfig = { }, { name: "options", - type: "array", - fields: [ - { - name: "title", - type: "text", - label: "Option Title", - required: true, - }, - { - name: "medusa_id", - type: "text", - label: "Medusa Option ID", - required: true, + type: "relationship", + relationTo: "product-options" as any, + hasMany: true, + required: false, admin: { - description: 'The unique identifier for the option from Medusa', - hidden: true, // Hide this field in the admin UI - }, - access: { - update: ({ req }) => !!req.query.is_from_medusa, - } - } - ], - validate: (value: any, { req, previousValue }) => { - if (req.query.is_from_medusa) { - return true; // Skip validation if the request is from Medusa - } - - if (!Array.isArray(value)) { - return 'Options must be an array'; - } - - const optionsChanged = value.length !== previousValue?.length || value.some((option) => { - return !option.medusa_id || !previousValue?.some( - (prevOption) => (prevOption as any).medusa_id === option.medusa_id - ); - }) - - return !optionsChanged || "Options cannot be changed in number"; // Prevent update if the number of options is changed + description: 'Product options (e.g., Size, Color)', }, }, { @@ -170,41 +138,13 @@ export const Products: CollectionConfig = { }, { name: "option_values", - type: "array", - fields: [ - { - name: "medusa_id", - type: "text", - label: "Medusa Option Value ID", - required: true, - admin: { - description: 'The unique identifier for the option value from Medusa', - hidden: true, // Hide this field in the admin UI - }, - access: { - update: ({ req }) => !!req.query.is_from_medusa, - } - }, - { - name: "medusa_option_id", - type: "text", - label: "Medusa Option ID", - required: true, + type: "relationship", + relationTo: "option-values" as any, + hasMany: true, + required: false, admin: { - description: 'The unique identifier for the option from Medusa', - hidden: true, // Hide this field in the admin UI - }, - access: { - update: ({ req }) => !!req.query.is_from_medusa, - } - }, - { - name: "value", - type: "text", - label: "Value", - required: true, - } - ] + description: 'Option values for this variant', + }, } ], validate: (value: any, { req, previousValue }) => { diff --git a/payload-integration/storefront/src/payload.config.ts b/payload-integration/storefront/src/payload.config.ts index 9ec09042..5ff84f46 100644 --- a/payload-integration/storefront/src/payload.config.ts +++ b/payload-integration/storefront/src/payload.config.ts @@ -5,6 +5,8 @@ import { buildConfig } from 'payload' import { Users } from './collections/Users' import { Products } from './collections/Products' import { Media } from './collections/Media' +import { ProductOptions } from './collections/ProductOptions' +import { OptionValues } from './collections/OptionValues' export default buildConfig({ // If you'd like to use Rich Text, pass your editor here @@ -14,7 +16,9 @@ export default buildConfig({ collections: [ Users, Products, - Media + Media, + ProductOptions, + OptionValues ], // Your Payload secret - should be a complex and secure string, unguessable diff --git a/strapi-integration/medusa/src/modules/strapi/service.ts b/strapi-integration/medusa/src/modules/strapi/service.ts index bbee2d9b..70e67ca2 100644 --- a/strapi-integration/medusa/src/modules/strapi/service.ts +++ b/strapi-integration/medusa/src/modules/strapi/service.ts @@ -171,20 +171,20 @@ export default class StrapiModuleService { async findByMedusaId( collection: Collection, - medusaId: string, + medusaId: string | string[], populate?: string[] ) { try { const result = await this.client_.collection(collection).find({ filters: { medusaId: { - $eq: medusaId, + $in: Array.isArray(medusaId) ? medusaId : [medusaId], }, }, populate, }) - return result.data[0] + return result.data } catch (error) { throw new MedusaError( diff --git a/strapi-integration/medusa/src/workflows/create-options-in-strapi.ts b/strapi-integration/medusa/src/workflows/create-options-in-strapi.ts index e84a54ae..23c9d843 100644 --- a/strapi-integration/medusa/src/workflows/create-options-in-strapi.ts +++ b/strapi-integration/medusa/src/workflows/create-options-in-strapi.ts @@ -16,16 +16,16 @@ export const createOptionsInStrapiWorkflow = createWorkflow( "create-options-in-strapi", (input: CreateOptionsInStrapiWorkflowInput) => { // Fetch the option with all necessary fields - // including metadata and product metadata + // including metadata const { data: options } = useQueryGraphStep({ entity: "product_option", fields: [ "id", "title", - "product_id", "metadata", - "product.metadata", - "values.*" + "values.id", + "values.value", + "values.metadata", ], filters: { id: input.ids, @@ -40,7 +40,6 @@ export const createOptionsInStrapiWorkflow = createWorkflow( return data.options.map((option) => ({ id: option.id, title: option.title, - strapiProductId: Number(option.product?.metadata?.strapi_id), })) }) diff --git a/strapi-integration/medusa/src/workflows/create-product-in-strapi.ts b/strapi-integration/medusa/src/workflows/create-product-in-strapi.ts index b9c72f1b..0b0d3609 100644 --- a/strapi-integration/medusa/src/workflows/create-product-in-strapi.ts +++ b/strapi-integration/medusa/src/workflows/create-product-in-strapi.ts @@ -17,6 +17,9 @@ import { } from "@medusajs/medusa/core-flows" import { createOptionsInStrapiWorkflow } from "./create-options-in-strapi" import { createVariantsInStrapiWorkflow } from "./create-variants-in-strapi" +import { updateProductInStrapiStep } from "./steps/update-product-in-strapi" +import { retrieveFromStrapiStep } from "./steps/retrieve-from-strapi" +import { Collection } from "../modules/strapi/service" export type CreateProductInStrapiWorkflowInput = { id: string @@ -118,10 +121,37 @@ export const createProductInStrapiWorkflow = createWorkflow( products }, (data) => data.products[0].options.map((option) => option.id)) - createOptionsInStrapiWorkflow.runAsStep({ - input: { - ids: optionIds, - } + const existingStrapiOptions = retrieveFromStrapiStep({ + collection: Collection.PRODUCT_OPTIONS, + ids: optionIds, + }) + + const optionsToCreate = transform({ existingStrapiOptions, optionIds }, (data) => { + const existingMedusaIds = new Set(data.existingStrapiOptions.map((option) => option.medusaId)) + return data.optionIds.filter((id: string) => !existingMedusaIds.has(id)) + }) + + const newStrapiOptions = when({ optionsToCreate }, (data) => data.optionsToCreate.length > 0).then(() => { + return createOptionsInStrapiWorkflow.runAsStep({ + input: { + ids: optionsToCreate, + } + }) + }) + + const strapiOptionIds = transform({ existingStrapiOptions, newStrapiOptions }, (data): number[] => { + const existingIds = (data.existingStrapiOptions || []).map((option) => option.id).filter(Boolean) as number[] + const newIds = data.newStrapiOptions ? (data.newStrapiOptions.strapi_options || []).map((option) => option.id).filter(Boolean) as number[] : [] + return [...existingIds, ...newIds] + }) + + when({ strapiOptionIds }, (data) => data.strapiOptionIds.length > 0).then(() => { + return updateProductInStrapiStep({ + product: { + id: input.id, + optionIds: strapiOptionIds, + }, + }) }) releaseLockStep({ diff --git a/strapi-integration/medusa/src/workflows/handle-strapi-webhook.ts b/strapi-integration/medusa/src/workflows/handle-strapi-webhook.ts index 03fd13cc..fba8c7fa 100644 --- a/strapi-integration/medusa/src/workflows/handle-strapi-webhook.ts +++ b/strapi-integration/medusa/src/workflows/handle-strapi-webhook.ts @@ -59,12 +59,29 @@ export const handleStrapiWebhookWorkflow = createWorkflow( when(input, (input) => input.entry.model === "product-option") .then(() => { - const options = updateProductOptionsWorkflow.runAsStep({ + updateProductOptionsWorkflow.runAsStep({ input: preparedData.data as any, }) + // Since options can belong to multiple products (manyToMany), + // we need to find all products that use this option + const { data: products } = useQueryGraphStep({ + entity: "product", + fields: ["id"], + filters: { + options: { + id: (preparedData.data.selector as Record).id, + }, + }, + }).config({ name: "get-products-from-option" }) + + // Clear cache for all products that use this option + const productIds = transform({ products }, (data) => { + return data.products.map((p) => p.id) as string[] + }) + clearProductCacheStep({ - productId: options[0].product_id! + productId: productIds }).config({ name: "clear-product-cache-option" }) }) diff --git a/strapi-integration/medusa/src/workflows/steps/create-options-in-strapi.ts b/strapi-integration/medusa/src/workflows/steps/create-options-in-strapi.ts index ec78c697..a510f53a 100644 --- a/strapi-integration/medusa/src/workflows/steps/create-options-in-strapi.ts +++ b/strapi-integration/medusa/src/workflows/steps/create-options-in-strapi.ts @@ -6,7 +6,6 @@ export type CreateOptionsInStrapiInput = { options: { id: string title: string - strapiProductId: number }[] } @@ -19,11 +18,9 @@ export const createOptionsInStrapiStep = createStep( try { for (const option of options) { - // Create option in Strapi const strapiOption = await strapiService.create(Collection.PRODUCT_OPTIONS, { medusaId: option.id, title: option.title, - product: option.strapiProductId, }) results.push(strapiOption.data) diff --git a/strapi-integration/medusa/src/workflows/steps/delete-option-from-strapi.ts b/strapi-integration/medusa/src/workflows/steps/delete-option-from-strapi.ts index 2431862f..e56ed23f 100644 --- a/strapi-integration/medusa/src/workflows/steps/delete-option-from-strapi.ts +++ b/strapi-integration/medusa/src/workflows/steps/delete-option-from-strapi.ts @@ -12,7 +12,7 @@ export const deleteOptionFromStrapiStep = createStep( const strapiService: StrapiModuleService = container.resolve(STRAPI_MODULE) // Find the Strapi option - const strapiOption = await strapiService.findByMedusaId(Collection.PRODUCT_OPTIONS, id) + const [strapiOption] = await strapiService.findByMedusaId(Collection.PRODUCT_OPTIONS, id) // Delete option from Strapi await strapiService.delete(Collection.PRODUCT_OPTIONS, strapiOption.documentId) @@ -32,7 +32,6 @@ export const deleteOptionFromStrapiStep = createStep( await strapiService.create(Collection.PRODUCT_OPTIONS, { medusaId: compensationData.medusaId, title: compensationData.title, - product: compensationData.product, locale: compensationData.locale, }) } diff --git a/strapi-integration/medusa/src/workflows/steps/delete-option-value-from-strapi.ts b/strapi-integration/medusa/src/workflows/steps/delete-option-value-from-strapi.ts index 7f6f64fa..c100f543 100644 --- a/strapi-integration/medusa/src/workflows/steps/delete-option-value-from-strapi.ts +++ b/strapi-integration/medusa/src/workflows/steps/delete-option-value-from-strapi.ts @@ -11,20 +11,15 @@ export const deleteOptionValueFromStrapiStep = createStep( async ({ ids }: DeleteOptionValueFromStrapiInput, { container }) => { const strapiService: StrapiModuleService = container.resolve(STRAPI_MODULE) - const originalData: Record[] = [] - - for (const id of ids) { - // Find the Strapi option value - const strapiOptionValue = await strapiService.findByMedusaId( - Collection.PRODUCT_OPTION_VALUES, - id, - ["option"] - ) - - originalData.push(strapiOptionValue) + const originalData = await strapiService.findByMedusaId( + Collection.PRODUCT_OPTION_VALUES, + ids, + ["option"] + ) + for (const data of originalData) { // Delete option value from Strapi - await strapiService.delete(Collection.PRODUCT_OPTION_VALUES, strapiOptionValue.documentId) + await strapiService.delete(Collection.PRODUCT_OPTION_VALUES, data.documentId) } return new StepResponse( diff --git a/strapi-integration/medusa/src/workflows/steps/delete-product-from-strapi.ts b/strapi-integration/medusa/src/workflows/steps/delete-product-from-strapi.ts index fbf282c2..f5f95a45 100644 --- a/strapi-integration/medusa/src/workflows/steps/delete-product-from-strapi.ts +++ b/strapi-integration/medusa/src/workflows/steps/delete-product-from-strapi.ts @@ -12,7 +12,7 @@ export const deleteProductFromStrapiStep = createStep( const strapiService: StrapiModuleService = container.resolve(STRAPI_MODULE) // Find the Strapi product - const strapiProduct = await strapiService.findByMedusaId(Collection.PRODUCTS, id) + const [strapiProduct] = await strapiService.findByMedusaId(Collection.PRODUCTS, id) // Delete product from Strapi await strapiService.delete(Collection.PRODUCTS, strapiProduct.documentId) diff --git a/strapi-integration/medusa/src/workflows/steps/delete-variant-from-strapi.ts b/strapi-integration/medusa/src/workflows/steps/delete-variant-from-strapi.ts index db55500b..8722f0b6 100644 --- a/strapi-integration/medusa/src/workflows/steps/delete-variant-from-strapi.ts +++ b/strapi-integration/medusa/src/workflows/steps/delete-variant-from-strapi.ts @@ -12,7 +12,7 @@ export const deleteVariantFromStrapiStep = createStep( const strapiService: StrapiModuleService = container.resolve(STRAPI_MODULE) // Find the Strapi variant - const strapiVariant = await strapiService.findByMedusaId( + const [strapiVariant] = await strapiService.findByMedusaId( Collection.PRODUCT_VARIANTS, id, ["option_values"] diff --git a/strapi-integration/medusa/src/workflows/steps/retrieve-from-strapi.ts b/strapi-integration/medusa/src/workflows/steps/retrieve-from-strapi.ts new file mode 100644 index 00000000..b3ce91da --- /dev/null +++ b/strapi-integration/medusa/src/workflows/steps/retrieve-from-strapi.ts @@ -0,0 +1,21 @@ +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { STRAPI_MODULE } from "../../modules/strapi" +import StrapiModuleService, { Collection } from "../../modules/strapi/service" + +export type RetrieveFromStrapiInput = { + collection: Collection + ids: string[] + populate?: string[] +} + +export const retrieveFromStrapiStep = createStep( + "retrieve-from-strapi", + async ({ collection, ids, populate }: RetrieveFromStrapiInput, { container }) => { + const strapiService: StrapiModuleService = container.resolve(STRAPI_MODULE) + + const items = await strapiService.findByMedusaId(collection, ids, populate) + + return new StepResponse(items) + } +) + diff --git a/strapi-integration/medusa/src/workflows/steps/update-option-in-strapi.ts b/strapi-integration/medusa/src/workflows/steps/update-option-in-strapi.ts index 600e07d2..e9e40c1b 100644 --- a/strapi-integration/medusa/src/workflows/steps/update-option-in-strapi.ts +++ b/strapi-integration/medusa/src/workflows/steps/update-option-in-strapi.ts @@ -15,7 +15,7 @@ export const updateOptionInStrapiStep = createStep( const strapiService: StrapiModuleService = container.resolve(STRAPI_MODULE) // Find the Strapi option - const originalData = await strapiService.findByMedusaId(Collection.PRODUCT_OPTIONS, option.id) + const [originalData] = await strapiService.findByMedusaId(Collection.PRODUCT_OPTIONS, option.id) // Update option in Strapi const updated = await strapiService.update(Collection.PRODUCT_OPTIONS, originalData.documentId, { diff --git a/strapi-integration/medusa/src/workflows/steps/update-option-value-in-strapi.ts b/strapi-integration/medusa/src/workflows/steps/update-option-value-in-strapi.ts index 832da468..d7871c4e 100644 --- a/strapi-integration/medusa/src/workflows/steps/update-option-value-in-strapi.ts +++ b/strapi-integration/medusa/src/workflows/steps/update-option-value-in-strapi.ts @@ -14,22 +14,23 @@ export const updateOptionValueInStrapiStep = createStep( async ({ optionValues }: UpdateOptionValueInStrapiInput, { container }) => { const strapiService: StrapiModuleService = container.resolve(STRAPI_MODULE) - const originalData: Record[] = [] const updatedData: Record[] = [] + const originalData = await strapiService.findByMedusaId( + Collection.PRODUCT_OPTION_VALUES, + optionValues.map((optionValue) => optionValue.id), + ) + const originalDataMap = new Map(originalData.map((data) => [data.medusaId, data])) + try { for (const optionValue of optionValues) { - // Find the Strapi option value - const strapiOptionValue = await strapiService.findByMedusaId( - Collection.PRODUCT_OPTION_VALUES, - optionValue.id, - ) - - // Store original data for compensation - originalData.push(strapiOptionValue) + const originalData = originalDataMap.get(optionValue.id) + if (!originalData) { + continue + } // Update option value in Strapi - const updated = await strapiService.update(Collection.PRODUCT_OPTION_VALUES, strapiOptionValue.documentId, { + const updated = await strapiService.update(Collection.PRODUCT_OPTION_VALUES, originalData.documentId, { value: optionValue.value, }) @@ -39,7 +40,7 @@ export const updateOptionValueInStrapiStep = createStep( // If error occurs, pass original data created so far to compensation return StepResponse.permanentFailure( strapiService.formatStrapiError(error, 'Failed to update option values in Strapi'), - originalData + originalData.filter((data) => updatedData.some((updated) => updated.medusaId === data.medusaId)) ) } diff --git a/strapi-integration/medusa/src/workflows/steps/update-product-in-strapi.ts b/strapi-integration/medusa/src/workflows/steps/update-product-in-strapi.ts index 571f4536..980b07fa 100644 --- a/strapi-integration/medusa/src/workflows/steps/update-product-in-strapi.ts +++ b/strapi-integration/medusa/src/workflows/steps/update-product-in-strapi.ts @@ -9,6 +9,7 @@ export type UpdateProductInStrapiInput = { subtitle?: string description?: string handle?: string + optionIds?: number[] } } @@ -18,15 +19,17 @@ export const updateProductInStrapiStep = createStep( const strapiService: StrapiModuleService = container.resolve(STRAPI_MODULE) // Fetch current Strapi product data for compensation - const originalProduct = await strapiService.findByMedusaId(Collection.PRODUCTS, product.id) + const [originalProduct] = await strapiService.findByMedusaId(Collection.PRODUCTS, product.id) // Update product in Strapi - const updated = await strapiService.update(Collection.PRODUCTS, originalProduct.documentId, { - title: product.title, - subtitle: product.subtitle, - description: product.description, - handle: product.handle, - }) + const updateData: Record = {} + if (product.title !== undefined) updateData.title = product.title + if (product.subtitle !== undefined) updateData.subtitle = product.subtitle + if (product.description !== undefined) updateData.description = product.description + if (product.handle !== undefined) updateData.handle = product.handle + if (product.optionIds !== undefined) updateData.options = product.optionIds + + const updated = await strapiService.update(Collection.PRODUCTS, originalProduct.documentId, updateData) return new StepResponse( updated.data, @@ -45,6 +48,7 @@ export const updateProductInStrapiStep = createStep( subtitle: compensationData.subtitle, description: compensationData.description, handle: compensationData.handle, + options: compensationData.options?.map((opt) => opt.id) || [], }) } ) diff --git a/strapi-integration/medusa/src/workflows/steps/update-variant-in-strapi.ts b/strapi-integration/medusa/src/workflows/steps/update-variant-in-strapi.ts index f37e053b..36b3374b 100644 --- a/strapi-integration/medusa/src/workflows/steps/update-variant-in-strapi.ts +++ b/strapi-integration/medusa/src/workflows/steps/update-variant-in-strapi.ts @@ -7,7 +7,7 @@ export type UpdateVariantInStrapiInput = { id: string title?: string sku?: string - optionValueIds?: number[] + optionValues?: number[] } } @@ -16,7 +16,7 @@ export const updateVariantInStrapiStep = createStep( async ({ variant }: UpdateVariantInStrapiInput, { container }) => { const strapiService: StrapiModuleService = container.resolve(STRAPI_MODULE) - const originalVariant = await strapiService.findByMedusaId( + const [originalVariant] = await strapiService.findByMedusaId( Collection.PRODUCT_VARIANTS, variant.id, ["option_values"] @@ -26,7 +26,7 @@ export const updateVariantInStrapiStep = createStep( const updated = await strapiService.update(Collection.PRODUCT_VARIANTS, originalVariant.documentId, { title: variant.title, sku: variant.sku, - ...(variant.optionValueIds && { optionValueIds: variant.optionValueIds }), + ...(variant.optionValues && { option_values: variant.optionValues }), }) return new StepResponse( diff --git a/strapi-integration/medusa/src/workflows/update-option-in-strapi.ts b/strapi-integration/medusa/src/workflows/update-option-in-strapi.ts index 32d7f1b0..155c6162 100644 --- a/strapi-integration/medusa/src/workflows/update-option-in-strapi.ts +++ b/strapi-integration/medusa/src/workflows/update-option-in-strapi.ts @@ -11,6 +11,8 @@ import { createOptionValuesInStrapiStep } from "./steps/create-option-values-in- import { updateProductOptionValuesMetadataStep } from "./steps/update-product-option-values-metadata" import { updateOptionValueInStrapiStep } from "./steps/update-option-value-in-strapi" import { deleteOptionValueFromStrapiStep } from "./steps/delete-option-value-from-strapi" +import { retrieveFromStrapiStep } from "./steps/retrieve-from-strapi" +import { Collection } from "../modules/strapi/service" export type UpdateOptionInStrapiWorkflowInput = { id: string @@ -19,13 +21,12 @@ export type UpdateOptionInStrapiWorkflowInput = { export const updateOptionInStrapiWorkflow = createWorkflow( "update-option-in-strapi", (input: UpdateOptionInStrapiWorkflowInput) => { - // Fetch the option with all necessary fields including metadata + // Fetch the option with all necessary fields const { data: options } = useQueryGraphStep({ entity: "product_option", fields: [ "id", "title", - "product.strapi_product.*", "values.*" ], filters: { @@ -36,13 +37,21 @@ export const updateOptionInStrapiWorkflow = createWorkflow( } }) - const strapiOptionId = transform({ options }, (data) => { - return (data.options[0].product as any)?.strapi_product?.options?.find( - (option) => option.medusaId === data.options[0].id - )?.documentId as number + const existingStrapiOptions = retrieveFromStrapiStep({ + collection: Collection.PRODUCT_OPTIONS, + ids: [input.id], + populate: ["values"], }) - const updateResult = when({ strapiOptionId }, (data) => !!data.strapiOptionId).then(() => { + const createResult = when({ existingStrapiOptions }, (data) => !data.existingStrapiOptions.length).then(() => { + return createOptionsInStrapiWorkflow.runAsStep({ + input: { + ids: [input.id], + } + }) + }) + + const updateResult = when({ existingStrapiOptions }, (data) => !!data.existingStrapiOptions.length).then(() => { return updateOptionInStrapiStep({ option: options[0], }) @@ -104,16 +113,22 @@ export const updateOptionInStrapiWorkflow = createWorkflow( }) }) - const optionValuesToDelete = transform({ options, updateResult }, (data) => { - if (!data.updateResult) { + const optionValuesToDelete = transform({ options, existingStrapiOption: existingStrapiOptions, updateResult }, (data) => { + if ( + !data.updateResult || + !data.existingStrapiOption || + !data.existingStrapiOption[0] || + !data.existingStrapiOption[0].values + ) { return [] } - return (data.options[0].product as any)?.strapi_product?.options?.find( - (option) => option.medusaId === data.options[0].id - )?.values.filter((value) => !data.options[0].values.some((v) => v.id === value.medusaId)) - .map((value) => { - return value.medusaId - }) + + const currentMedusaValueIds = new Set(data.options[0].values.map((value) => value.id)) + const strapiValues = data.existingStrapiOption[0].values || [] + + return strapiValues + .filter((strapiValue) => !currentMedusaValueIds.has(strapiValue.medusaId)) + .map((strapiValue) => strapiValue.medusaId) }) when({ updateResult, optionValuesToDelete }, (data) => !!data.updateResult && !!data.optionValuesToDelete.length) @@ -123,14 +138,6 @@ export const updateOptionInStrapiWorkflow = createWorkflow( }) }) - const createResult = when({ strapiOptionId }, (data) => !data.strapiOptionId).then(() => { - return createOptionsInStrapiWorkflow.runAsStep({ - input: { - ids: [input.id], - } - }) - }) - const result = transform({ updateResult, createResult, diff --git a/strapi-integration/medusa/src/workflows/update-product-in-strapi.ts b/strapi-integration/medusa/src/workflows/update-product-in-strapi.ts index a7b20976..51c03a53 100644 --- a/strapi-integration/medusa/src/workflows/update-product-in-strapi.ts +++ b/strapi-integration/medusa/src/workflows/update-product-in-strapi.ts @@ -5,9 +5,11 @@ import { transform, } from "@medusajs/framework/workflows-sdk" import { UpdateProductInStrapiInput, updateProductInStrapiStep } from "./steps/update-product-in-strapi" -import { UploadImagesToStrapiInput, uploadImagesToStrapiStep } from "./steps/upload-images-to-strapi" import { useQueryGraphStep } from "@medusajs/medusa/core-flows" import { createProductInStrapiWorkflow } from "./create-product-in-strapi" +import { createOptionsInStrapiWorkflow } from "./create-options-in-strapi" +import { retrieveFromStrapiStep } from "./steps/retrieve-from-strapi" +import { Collection } from "../modules/strapi/service" export type UpdateProductInStrapiWorkflowInput = { id: string @@ -16,7 +18,7 @@ export type UpdateProductInStrapiWorkflowInput = { export const updateProductInStrapiWorkflow = createWorkflow( "update-product-in-strapi", (input: UpdateProductInStrapiWorkflowInput) => { - // Fetch the product with all necessary fields including metadata + // Fetch the product with all necessary fields including metadata and options const { data: products } = useQueryGraphStep({ entity: "product", fields: [ @@ -26,6 +28,7 @@ export const updateProductInStrapiWorkflow = createWorkflow( "description", "handle", "metadata", + "options.id", ], filters: { id: input.id, @@ -34,6 +37,36 @@ export const updateProductInStrapiWorkflow = createWorkflow( throwIfKeyNotFound: true, } }) + const optionIds = transform({ products }, (data) => { + return data.products[0].options.map((option) => option.id) + }) + + // Retrieve existing options from Strapi + const existingStrapiOptions = retrieveFromStrapiStep({ + collection: Collection.PRODUCT_OPTIONS, + ids: optionIds, + }) + + // Calculate which options need to be created + const optionsToCreate = transform({ existingStrapiOptions, optionIds }, (data) => { + const existingMedusaIds = new Set(data.existingStrapiOptions.map((option) => option.medusaId)) + return data.optionIds.filter((id) => !existingMedusaIds.has(id)) + }) + + // Create missing options (returns undefined if no options to create) + const newStrapiOptions = when({ optionsToCreate }, (data) => data.optionsToCreate.length > 0).then(() => { + return createOptionsInStrapiWorkflow.runAsStep({ + input: { + ids: optionsToCreate, + } + }) + }) + + const strapiOptionIds = transform({ existingStrapiOptions, newStrapiOptions }, (data) => { + const existingIds = (data.existingStrapiOptions || []).map((option) => option.id).filter(Boolean) as number[] + const newIds = data.newStrapiOptions ? (data.newStrapiOptions.strapi_options || []).map((option) => option.id).filter(Boolean) as number[] : [] + return [...existingIds, ...newIds] + }) // If product doesn't exist in Strapi, create it const createResult = when({ products }, (data) => !data.products[0].metadata?.strapi_id).then(() => { @@ -45,11 +78,23 @@ export const updateProductInStrapiWorkflow = createWorkflow( }) const updateResult = when({ products }, (data) => !!data.products[0].metadata?.strapi_id).then(() => { - + // Combine existing and newly created options + const updateData = transform({ strapiOptionIds, products }, (data) => { + return { + id: data.products[0].id, + optionIds: data.strapiOptionIds, + title: data.products[0].title, + subtitle: data.products[0].subtitle, + description: data.products[0].description, + handle: data.products[0].handle, + } + }) // Try to update the product in Strapi - return updateProductInStrapiStep({ - product: products[0], + const updated = updateProductInStrapiStep({ + product: updateData, } as UpdateProductInStrapiInput) + + return updated }) const result = transform({ diff --git a/strapi-integration/medusa/src/workflows/update-variant-in-strapi.ts b/strapi-integration/medusa/src/workflows/update-variant-in-strapi.ts index ee7a44f8..43bc1248 100644 --- a/strapi-integration/medusa/src/workflows/update-variant-in-strapi.ts +++ b/strapi-integration/medusa/src/workflows/update-variant-in-strapi.ts @@ -50,31 +50,12 @@ export const updateVariantInStrapiWorkflow = createWorkflow( }) const updateResult = when({ variants }, (data) => !!data.variants[0].metadata?.strapi_id).then(() => { - const { - variantImages, - variantThumbnail, - } = transform({ variants }, (data) => { - return { - variantImages: data.variants[0].images?.map((image) => { - return { - entity_id: data.variants[0].id, - url: image.url, - } - }) || [], - variantThumbnail: [{ - entity_id: data.variants[0].id, - // @ts-ignore - url: data.variants[0].thumbnail, - }].filter((image) => image.url !== null), - } - }) - const variantData = transform({ variants, }, (data) => { return { ...data.variants[0], - optionValueIds: data.variants[0].options.flatMap((option) => { + optionValues: data.variants[0].options.flatMap((option) => { // find the strapi option value id for the option value return data.variants[0].product?.options.flatMap( (productOption) => productOption.values.find( diff --git a/strapi-integration/strapi/src/api/product-option/content-types/product-option/schema.json b/strapi-integration/strapi/src/api/product-option/content-types/product-option/schema.json index 88807a9d..dceb6fb2 100644 --- a/strapi-integration/strapi/src/api/product-option/content-types/product-option/schema.json +++ b/strapi-integration/strapi/src/api/product-option/content-types/product-option/schema.json @@ -25,9 +25,9 @@ "type": "string", "default": "en" }, - "product": { + "products": { "type": "relation", - "relation": "manyToOne", + "relation": "manyToMany", "target": "api::product.product", "inversedBy": "options" }, diff --git a/strapi-integration/strapi/src/api/product/content-types/product/schema.json b/strapi-integration/strapi/src/api/product/content-types/product/schema.json index 847f207d..20a10ef9 100644 --- a/strapi-integration/strapi/src/api/product/content-types/product/schema.json +++ b/strapi-integration/strapi/src/api/product/content-types/product/schema.json @@ -55,9 +55,9 @@ }, "options": { "type": "relation", - "relation": "oneToMany", + "relation": "manyToMany", "target": "api::product-option.product-option", - "mappedBy": "product" + "inversedBy": "products" } } } diff --git a/strapi-integration/strapi/types/generated/contentTypes.d.ts b/strapi-integration/strapi/types/generated/contentTypes.d.ts index 2eeae36e..8cab60eb 100644 --- a/strapi-integration/strapi/types/generated/contentTypes.d.ts +++ b/strapi-integration/strapi/types/generated/contentTypes.d.ts @@ -496,7 +496,7 @@ export interface ApiProductOptionProductOption medusaId: Schema.Attribute.String & Schema.Attribute.Required & Schema.Attribute.Unique; - product: Schema.Attribute.Relation<'manyToOne', 'api::product.product'>; + products: Schema.Attribute.Relation<'manyToMany', 'api::product.product'>; publishedAt: Schema.Attribute.DateTime; title: Schema.Attribute.String & Schema.Attribute.Required; updatedAt: Schema.Attribute.DateTime; @@ -578,7 +578,7 @@ export interface ApiProductProduct extends Struct.CollectionTypeSchema { Schema.Attribute.Required & Schema.Attribute.Unique; options: Schema.Attribute.Relation< - 'oneToMany', + 'manyToMany', 'api::product-option.product-option' >; publishedAt: Schema.Attribute.DateTime;