Bulk product export to CMS providers (#351)
* Add sync all channel products feature * Implement batch create and delete product variants CMSes sync methods * Fix pnpm-lock file * Update UI * Update imports * Add fetch rate limit to Contentful provider * Small refactor of functions * Update logging
This commit is contained in:
parent
9730edb971
commit
bec8d812e8
30 changed files with 1197 additions and 266 deletions
|
@ -81,7 +81,7 @@ const payloadOperations: CreateOperations<PayloadConfig> = (config) => {
|
|||
} // This is where you write logic for all the supported operations (e.g. creating a product). This function runs only if the config was successfully validated.
|
||||
|
||||
|
||||
export default createProvider(payloadOperations, payloadConfigSchema); // `createProvider` combines everything together.
|
||||
export const payloadProvider = createProvider(payloadOperations, payloadConfigSchema); // `createProvider` combines everything together.
|
||||
```
|
||||
|
||||
5. Implement the operations:
|
||||
|
|
|
@ -12,3 +12,4 @@ Here is the list of the tokens and instructions on how to obtain them:
|
|||
- `spaceId`: id of the Contentful space. To find it, go to _Settings -> General settings_ in the Contentful dashboard.
|
||||
- `contentId`: the id of the content model. To obtain it, go to _Content model_ and to the view of a single product in your Contentful dashboard. Your URL may look something like: "https://app.contentful.com/spaces/xxxx/content_types/product/fields". Then, look to the right side of the screen. You will find a copyable "CONTENT TYPE ID" box there.
|
||||
- `locale`: the localization code for your content. E.g.: `en-US`.
|
||||
- `apiRequestsPerSecond`: API rate limits (API requests per second). The default is 7. Used in bulk products variants sync. Higher rate limits may speed up a little products variants bulk sync. Higher rate limit may apply depending on different Contentful plan, learn more at https://www.contentful.com/developers/docs/references/content-management-api/#/introduction/api-rate-limits.
|
||||
|
|
13
apps/cms/graphql/queries/ProductsData.graphql
Normal file
13
apps/cms/graphql/queries/ProductsData.graphql
Normal file
|
@ -0,0 +1,13 @@
|
|||
query ProductsDataForImport($first: Int, $channel: String, $after: String) {
|
||||
products(first: $first, channel: $channel, after: $after) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
node {
|
||||
...WebhookProduct
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -21,6 +21,7 @@
|
|||
"@material-ui/icons": "^4.11.3",
|
||||
"@material-ui/lab": "4.0.0-alpha.61",
|
||||
"@saleor/app-sdk": "0.37.1",
|
||||
"@saleor/apps-shared": "workspace:*",
|
||||
"@saleor/macaw-ui": "^0.6.7",
|
||||
"@sentry/nextjs": "^7.43.0",
|
||||
"@urql/exchange-auth": "^1.0.0",
|
||||
|
@ -38,8 +39,7 @@
|
|||
"usehooks-ts": "^2.9.1",
|
||||
"uuid": "^9.0.0",
|
||||
"vite": "^4.1.4",
|
||||
"zod": "^3.19.1",
|
||||
"@saleor/apps-shared": "workspace:*"
|
||||
"zod": "^3.19.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "2.13.3",
|
||||
|
|
|
@ -2,7 +2,7 @@ import {
|
|||
ProductVariantUpdatedWebhookPayloadFragment,
|
||||
WebhookProductVariantFragment,
|
||||
} from "../../../../generated/graphql";
|
||||
import { CmsClientOperations } from "../types";
|
||||
import { CmsClientBatchOperations, CmsClientOperations, ProductResponseSuccess } from "../types";
|
||||
import { getCmsIdFromSaleorItem } from "./metadata";
|
||||
import { logger as pinoLogger } from "../../logger";
|
||||
|
||||
|
@ -104,6 +104,121 @@ const executeCmsClientOperation = async ({
|
|||
}
|
||||
};
|
||||
|
||||
interface CmsClientBatchOperationResult {
|
||||
createdCmsIds?: ProductResponseSuccess["data"][];
|
||||
deletedCmsIds?: ProductResponseSuccess["data"][];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const executeCmsClientBatchOperation = async ({
|
||||
cmsClient,
|
||||
productsVariants,
|
||||
verifyIfProductVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance,
|
||||
}: {
|
||||
cmsClient: CmsClientBatchOperations;
|
||||
productsVariants: WebhookProductVariantFragment[];
|
||||
/**
|
||||
* Lookup function with purposely long name like in Java Spring ORM to verify condition against unintended deletion of product variant from CMS.
|
||||
* On purpose passed as an argument, for inversion of control.
|
||||
*/
|
||||
verifyIfProductVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance: (
|
||||
productVariant: WebhookProductVariantFragment
|
||||
) => boolean;
|
||||
}): Promise<CmsClientBatchOperationResult | undefined> => {
|
||||
const logger = pinoLogger.child({ cmsClient });
|
||||
logger.debug({ operations: cmsClient.operations }, "Execute CMS client operation called");
|
||||
|
||||
if (cmsClient.operationType === "createBatchProducts") {
|
||||
const productsVariansToCreate = productsVariants.reduce<WebhookProductVariantFragment[]>(
|
||||
(productsVariansToCreate, productVariant) => {
|
||||
const cmsId = getCmsIdFromSaleorItem(productVariant, cmsClient.cmsProviderInstanceId);
|
||||
|
||||
if (!cmsId) {
|
||||
return [...productsVariansToCreate, productVariant];
|
||||
}
|
||||
|
||||
return productsVariansToCreate;
|
||||
},
|
||||
[] as WebhookProductVariantFragment[]
|
||||
);
|
||||
|
||||
if (productsVariansToCreate.length) {
|
||||
logger.debug("CMS creating batch items called");
|
||||
|
||||
try {
|
||||
const createBatchProductsResponse = await cmsClient.operations.createBatchProducts({
|
||||
input: productsVariansToCreate.map((productVariant) => ({
|
||||
saleorId: productVariant.id,
|
||||
sku: productVariant.sku,
|
||||
name: productVariant.name,
|
||||
image: productVariant.product.media?.[0]?.url ?? "",
|
||||
productId: productVariant.product.id,
|
||||
productName: productVariant.product.name,
|
||||
productSlug: productVariant.product.slug,
|
||||
channels: productVariant.channelListings?.map((cl) => cl.channel.slug) || [],
|
||||
})),
|
||||
});
|
||||
|
||||
return {
|
||||
createdCmsIds:
|
||||
createBatchProductsResponse
|
||||
?.filter((item) => item.ok && "data" in item)
|
||||
.map((item) => (item as ProductResponseSuccess).data) || [],
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Error creating batch items");
|
||||
|
||||
return {
|
||||
error: "Error creating batch items.",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cmsClient.operationType === "deleteBatchProducts") {
|
||||
const CMSIdsToRemove = productsVariants.reduce((CMSIdsToRemove, productVariant) => {
|
||||
const cmsId = getCmsIdFromSaleorItem(productVariant, cmsClient.cmsProviderInstanceId);
|
||||
|
||||
const productVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance =
|
||||
verifyIfProductVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance(
|
||||
productVariant
|
||||
);
|
||||
|
||||
if (cmsId && !productVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance) {
|
||||
return [
|
||||
...CMSIdsToRemove,
|
||||
{
|
||||
id: cmsId,
|
||||
saleorId: productVariant.id,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return CMSIdsToRemove;
|
||||
}, [] as ProductResponseSuccess["data"][]);
|
||||
|
||||
if (CMSIdsToRemove.length) {
|
||||
logger.debug("CMS removing batch items called");
|
||||
|
||||
try {
|
||||
await cmsClient.operations.deleteBatchProducts({
|
||||
ids: CMSIdsToRemove.map((item) => item.id),
|
||||
});
|
||||
|
||||
return {
|
||||
deletedCmsIds: CMSIdsToRemove,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Error removing batch items");
|
||||
|
||||
return {
|
||||
error: "Error removing batch items.",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const executeCmsOperations = async ({
|
||||
cmsOperations,
|
||||
productVariant,
|
||||
|
|
|
@ -33,8 +33,10 @@ export const createCmsOperations = async ({
|
|||
|
||||
const settingsManager = createSettingsManager(client);
|
||||
|
||||
const channelsSettingsParsed = await getChannelsSettings(settingsManager);
|
||||
const providerInstancesSettingsParsed = await getProviderInstancesSettings(settingsManager);
|
||||
const [channelsSettingsParsed, providerInstancesSettingsParsed] = await Promise.all([
|
||||
getChannelsSettings(settingsManager),
|
||||
getProviderInstancesSettings(settingsManager),
|
||||
]);
|
||||
|
||||
const productVariantCmsProviderInstances = productVariantCmsKeys.map((cmsKey) =>
|
||||
getCmsIdFromSaleorItemKey(cmsKey)
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
export { createCmsOperations } from "./clients-operations";
|
||||
export { executeCmsOperations } from "./clients-execution";
|
||||
export { executeMetadataUpdate } from "./metadata-execution";
|
||||
export { updateMetadata, batchUpdateMetadata } from "./metadata-execution";
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next";
|
||||
import { Client } from "urql";
|
||||
import {
|
||||
DeleteMetadataDocument,
|
||||
UpdateMetadataDocument,
|
||||
|
@ -9,7 +10,45 @@ import { createCmsKeyForSaleorItem } from "./metadata";
|
|||
|
||||
type WebhookContext = Parameters<NextWebhookApiHandler>["2"];
|
||||
|
||||
export const executeMetadataUpdate = async ({
|
||||
export type MetadataRecord = Record<string, string>;
|
||||
|
||||
const executeMetadataUpdateMutation = async ({
|
||||
apiClient,
|
||||
itemId,
|
||||
cmsProviderInstanceIdsToCreate = {},
|
||||
cmsProviderInstanceIdsToDelete = {},
|
||||
}: {
|
||||
apiClient: Client;
|
||||
itemId: string;
|
||||
cmsProviderInstanceIdsToCreate?: Record<string, string>;
|
||||
cmsProviderInstanceIdsToDelete?: Record<string, string>;
|
||||
}) => {
|
||||
if (Object.keys(cmsProviderInstanceIdsToCreate).length) {
|
||||
await apiClient
|
||||
.mutation(UpdateMetadataDocument, {
|
||||
id: itemId,
|
||||
input: Object.entries(cmsProviderInstanceIdsToCreate).map(
|
||||
([cmsProviderInstanceId, cmsProductVariantId]) => ({
|
||||
key: createCmsKeyForSaleorItem(cmsProviderInstanceId),
|
||||
value: cmsProductVariantId,
|
||||
})
|
||||
),
|
||||
})
|
||||
.toPromise();
|
||||
}
|
||||
if (Object.keys(cmsProviderInstanceIdsToDelete).length) {
|
||||
await apiClient
|
||||
.mutation(DeleteMetadataDocument, {
|
||||
id: itemId,
|
||||
keys: Object.entries(cmsProviderInstanceIdsToDelete).map(([cmsProviderInstanceId]) =>
|
||||
createCmsKeyForSaleorItem(cmsProviderInstanceId)
|
||||
),
|
||||
})
|
||||
.toPromise();
|
||||
}
|
||||
};
|
||||
|
||||
export const updateMetadata = async ({
|
||||
context,
|
||||
productVariant,
|
||||
cmsProviderInstanceIdsToCreate,
|
||||
|
@ -23,27 +62,70 @@ export const executeMetadataUpdate = async ({
|
|||
const { token, saleorApiUrl } = context.authData;
|
||||
const apiClient = createClient(saleorApiUrl, async () => ({ token }));
|
||||
|
||||
if (Object.keys(cmsProviderInstanceIdsToCreate).length) {
|
||||
await apiClient
|
||||
.mutation(UpdateMetadataDocument, {
|
||||
id: productVariant.id,
|
||||
input: Object.entries(cmsProviderInstanceIdsToCreate).map(
|
||||
([cmsProviderInstanceId, cmsProductVariantId]) => ({
|
||||
key: createCmsKeyForSaleorItem(cmsProviderInstanceId),
|
||||
value: cmsProductVariantId,
|
||||
})
|
||||
),
|
||||
})
|
||||
.toPromise();
|
||||
}
|
||||
if (Object.keys(cmsProviderInstanceIdsToDelete).length) {
|
||||
await apiClient
|
||||
.mutation(DeleteMetadataDocument, {
|
||||
id: productVariant.id,
|
||||
keys: Object.entries(cmsProviderInstanceIdsToDelete).map(([cmsProviderInstanceId]) =>
|
||||
createCmsKeyForSaleorItem(cmsProviderInstanceId)
|
||||
),
|
||||
})
|
||||
.toPromise();
|
||||
}
|
||||
await executeMetadataUpdateMutation({
|
||||
apiClient,
|
||||
itemId: productVariant.id,
|
||||
cmsProviderInstanceIdsToCreate,
|
||||
cmsProviderInstanceIdsToDelete,
|
||||
});
|
||||
};
|
||||
|
||||
type ItemMetadataRecord = {
|
||||
id: string;
|
||||
cmsProviderInstanceIds: MetadataRecord;
|
||||
};
|
||||
|
||||
export const batchUpdateMetadata = async ({
|
||||
context,
|
||||
variantCMSProviderInstanceIdsToCreate,
|
||||
variantCMSProviderInstanceIdsToDelete,
|
||||
}: {
|
||||
context: Pick<WebhookContext, "authData">;
|
||||
variantCMSProviderInstanceIdsToCreate: ItemMetadataRecord[];
|
||||
variantCMSProviderInstanceIdsToDelete: ItemMetadataRecord[];
|
||||
}) => {
|
||||
const { token, saleorApiUrl } = context.authData;
|
||||
const apiClient = createClient(saleorApiUrl, async () => ({ token }));
|
||||
|
||||
const variantCMSProviderInstanceIdsToCreateMap = variantCMSProviderInstanceIdsToCreate.reduce(
|
||||
(acc, { id, cmsProviderInstanceIds }) => ({
|
||||
...acc,
|
||||
[id]: {
|
||||
...(acc[id] || {}),
|
||||
...cmsProviderInstanceIds,
|
||||
},
|
||||
}),
|
||||
{} as Record<string, MetadataRecord>
|
||||
);
|
||||
const variantCMSProviderInstanceIdsToDeleteMap = variantCMSProviderInstanceIdsToDelete.reduce(
|
||||
(acc, { id, cmsProviderInstanceIds }) => ({
|
||||
...acc,
|
||||
[id]: {
|
||||
...(acc[id] || {}),
|
||||
...cmsProviderInstanceIds,
|
||||
},
|
||||
}),
|
||||
{} as Record<string, MetadataRecord>
|
||||
);
|
||||
|
||||
const mutationsToExecute = [
|
||||
Object.entries(variantCMSProviderInstanceIdsToCreateMap).map(
|
||||
([itemId, cmsProviderInstanceIdsToCreate]) =>
|
||||
executeMetadataUpdateMutation({
|
||||
apiClient,
|
||||
itemId,
|
||||
cmsProviderInstanceIdsToCreate,
|
||||
})
|
||||
),
|
||||
Object.entries(variantCMSProviderInstanceIdsToDeleteMap).map(
|
||||
([itemId, cmsProviderInstanceIdsToDelete]) =>
|
||||
executeMetadataUpdateMutation({
|
||||
apiClient,
|
||||
itemId,
|
||||
cmsProviderInstanceIdsToDelete,
|
||||
})
|
||||
),
|
||||
];
|
||||
|
||||
await Promise.all(mutationsToExecute);
|
||||
};
|
||||
|
|
|
@ -10,6 +10,7 @@ export type ChannelCommonSchema = z.infer<typeof channelCommonSchema>;
|
|||
export const channelSchema = z
|
||||
.object({
|
||||
enabledProviderInstances: z.array(z.string()),
|
||||
requireSyncProviderInstances: z.array(z.string()).optional(),
|
||||
})
|
||||
.merge(channelCommonSchema);
|
||||
|
||||
|
|
|
@ -67,6 +67,12 @@ export const providersConfig = {
|
|||
helpText:
|
||||
"Content management API URL of your Contentful project. If you leave this blank, default https://api.contentful.com will be used.",
|
||||
},
|
||||
{
|
||||
name: "apiRequestsPerSecond",
|
||||
label: "API requests per second",
|
||||
helpText:
|
||||
"API rate limits. Default 7. Used in bulk products variants sync. Higher rate limits may speed up a little products variants bulk sync. Higher rate limit may apply depending on different Contentful plan, learn more at https://www.contentful.com/developers/docs/references/content-management-api/#/introduction/api-rate-limits.",
|
||||
},
|
||||
],
|
||||
},
|
||||
strapi: {
|
||||
|
@ -152,6 +158,7 @@ export const contentfulConfigSchema = z.object({
|
|||
locale: z.string().min(1),
|
||||
contentId: z.string().min(1),
|
||||
baseUrl: z.string(),
|
||||
apiRequestsPerSecond: z.string(),
|
||||
});
|
||||
|
||||
export const datocmsConfigSchema = z.object({
|
||||
|
|
16
apps/cms/src/lib/cms/data-sync.ts
Normal file
16
apps/cms/src/lib/cms/data-sync.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
export const fetchWithRateLimit = async <A, R>(
|
||||
args: A[],
|
||||
fun: (arg: A) => Promise<R>,
|
||||
requestPerSecondLimit: number
|
||||
) => {
|
||||
const delay = 1000 / requestPerSecondLimit;
|
||||
const results: Promise<R>[] = [];
|
||||
|
||||
for (const arg of args) {
|
||||
const result = fun(arg);
|
||||
results.push(result);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
return await Promise.all(results);
|
||||
};
|
|
@ -2,8 +2,9 @@ import { v4 as uuidv4 } from "uuid";
|
|||
import { ContentfulConfig, contentfulConfigSchema } from "../config";
|
||||
import { logger as pinoLogger } from "../../logger";
|
||||
|
||||
import { CreateOperations, CreateProductResponse, ProductInput } from "../types";
|
||||
import { CreateOperations, ProductResponse, ProductInput } from "../types";
|
||||
import { createProvider } from "./create";
|
||||
import { fetchWithRateLimit } from "../data-sync";
|
||||
|
||||
const contentfulFetch = (endpoint: string, config: ContentfulConfig, options?: RequestInit) => {
|
||||
const baseUrl = config.baseUrl || "https://api.contentful.com";
|
||||
|
@ -30,6 +31,8 @@ type ContentfulResponse = {
|
|||
id: string;
|
||||
version?: number;
|
||||
};
|
||||
statusCode: number;
|
||||
input: ProductInput;
|
||||
};
|
||||
|
||||
const transformInputToBody = ({
|
||||
|
@ -64,7 +67,7 @@ const transformInputToBody = ({
|
|||
return body;
|
||||
};
|
||||
|
||||
const transformCreateProductResponse = (response: ContentfulResponse): CreateProductResponse => {
|
||||
const transformCreateProductResponse = (response: ContentfulResponse): ProductResponse => {
|
||||
if (response.message) {
|
||||
return {
|
||||
ok: false,
|
||||
|
@ -76,6 +79,7 @@ const transformCreateProductResponse = (response: ContentfulResponse): CreatePro
|
|||
ok: true,
|
||||
data: {
|
||||
id: response.sys.id,
|
||||
saleorId: response.input.saleorId,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
@ -93,19 +97,19 @@ const getEntryEndpoint = ({
|
|||
const contentfulOperations: CreateOperations<ContentfulConfig> = (config) => {
|
||||
const logger = pinoLogger.child({ cms: "strapi" });
|
||||
|
||||
const { environment, spaceId, contentId, locale } = config;
|
||||
const { environment, spaceId, contentId, locale, apiRequestsPerSecond } = config;
|
||||
|
||||
return {
|
||||
createProduct: async (params) => {
|
||||
const requestPerSecondLimit = Number(apiRequestsPerSecond || 7);
|
||||
|
||||
const createProductInCMS = async (input: ProductInput): Promise<ContentfulResponse> => {
|
||||
// Contentful API does not auto generate resource ID during creation, it has to be provided.
|
||||
const resourceId = uuidv4();
|
||||
const body = transformInputToBody({ input: params.input, locale });
|
||||
const body = transformInputToBody({ input, locale });
|
||||
const endpoint = getEntryEndpoint({
|
||||
resourceId,
|
||||
environment,
|
||||
spaceId,
|
||||
});
|
||||
|
||||
const response = await contentfulFetch(endpoint, config, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
|
@ -113,13 +117,16 @@ const contentfulOperations: CreateOperations<ContentfulConfig> = (config) => {
|
|||
"X-Contentful-Content-Type": contentId,
|
||||
},
|
||||
});
|
||||
logger.debug("createProduct response", { response });
|
||||
const result = await response.json();
|
||||
logger.debug("createProduct result", { result });
|
||||
logger.debug({ response }, "createProduct response");
|
||||
const json = await response.json();
|
||||
return {
|
||||
...json,
|
||||
statusCode: response.status,
|
||||
input,
|
||||
};
|
||||
};
|
||||
|
||||
return transformCreateProductResponse(result);
|
||||
},
|
||||
updateProduct: async ({ id, input }) => {
|
||||
const updateProductInCMS = async (id: string, input: ProductInput) => {
|
||||
const body = transformInputToBody({ input, locale });
|
||||
const endpoint = getEntryEndpoint({
|
||||
resourceId: id,
|
||||
|
@ -128,9 +135,9 @@ const contentfulOperations: CreateOperations<ContentfulConfig> = (config) => {
|
|||
});
|
||||
|
||||
const getEntryResponse = await contentfulFetch(endpoint, config, { method: "GET" });
|
||||
logger.debug("updateProduct getEntryResponse", { getEntryResponse });
|
||||
logger.debug({ getEntryResponse }, "updateProduct getEntryResponse");
|
||||
const entry = await getEntryResponse.json();
|
||||
logger.debug("updateProduct entry", { entry });
|
||||
logger.debug({ entry }, "updateProduct entry");
|
||||
|
||||
const response = await contentfulFetch(endpoint, config, {
|
||||
method: "PUT",
|
||||
|
@ -139,20 +146,91 @@ const contentfulOperations: CreateOperations<ContentfulConfig> = (config) => {
|
|||
"X-Contentful-Version": entry.sys.version,
|
||||
},
|
||||
});
|
||||
logger.debug("updateProduct response", { response });
|
||||
const result = await response.json();
|
||||
logger.debug("updateProduct result", { result });
|
||||
logger.debug({ response }, "updateProduct response");
|
||||
const json = await response.json();
|
||||
return {
|
||||
...json,
|
||||
statusCode: response.status,
|
||||
};
|
||||
};
|
||||
|
||||
const deleteProductInCMS = async (id: string) => {
|
||||
const endpoint = getEntryEndpoint({ resourceId: id, environment, spaceId });
|
||||
|
||||
return await contentfulFetch(endpoint, config, { method: "DELETE" });
|
||||
};
|
||||
|
||||
const createBatchProductsInCMS = async (input: ProductInput[]) => {
|
||||
// Contentful doesn't support batch creation of items, so we need to create them one by one
|
||||
|
||||
// Take into account rate limit
|
||||
const firstResults = await fetchWithRateLimit(input, createProductInCMS, requestPerSecondLimit);
|
||||
const failedWithLimitResults = firstResults.filter((result) => result.statusCode === 429);
|
||||
|
||||
// Retry with delay x2 if by any chance hit rate limit with HTTP 429
|
||||
let secondResults: ContentfulResponse[] = [];
|
||||
if (failedWithLimitResults.length > 0) {
|
||||
logger.debug("createBatchProductsInCMS retrying failed by rate limit with delay x2");
|
||||
secondResults = await fetchWithRateLimit(
|
||||
failedWithLimitResults,
|
||||
(result) => createProductInCMS(result.input),
|
||||
requestPerSecondLimit / 2
|
||||
);
|
||||
}
|
||||
|
||||
return [...firstResults.filter((result) => result.statusCode !== 429), ...secondResults];
|
||||
};
|
||||
|
||||
const deleteBatchProductsInCMS = async (ids: string[]) => {
|
||||
// Contentful doesn't support batch deletion of items, so we need to delete them one by one
|
||||
|
||||
// Take into account rate limit
|
||||
const firstResults = await fetchWithRateLimit(ids, deleteProductInCMS, requestPerSecondLimit);
|
||||
const failedWithLimitResults = firstResults.filter((result) => result.status === 429);
|
||||
|
||||
// Retry with delay x2 if by any chance hit rate limit with HTTP 429
|
||||
let secondResults: Response[] = [];
|
||||
if (failedWithLimitResults.length > 0) {
|
||||
logger.debug("deleteBatchProductsInCMS retrying failed by rate limit with delay x2");
|
||||
secondResults = await fetchWithRateLimit(
|
||||
failedWithLimitResults,
|
||||
(result) => deleteProductInCMS(result.url),
|
||||
requestPerSecondLimit / 2
|
||||
);
|
||||
}
|
||||
|
||||
return [...firstResults.filter((result) => result.status !== 429), ...secondResults];
|
||||
};
|
||||
|
||||
return {
|
||||
createProduct: async ({ input }) => {
|
||||
const result = await createProductInCMS(input);
|
||||
logger.debug({ result }, "createProduct result");
|
||||
|
||||
return transformCreateProductResponse(result);
|
||||
},
|
||||
updateProduct: async ({ id, input }) => {
|
||||
const result = await updateProductInCMS(id, input);
|
||||
logger.debug({ result }, "updateProduct result");
|
||||
|
||||
return result;
|
||||
},
|
||||
deleteProduct: async ({ id }) => {
|
||||
const endpoint = getEntryEndpoint({ resourceId: id, environment, spaceId });
|
||||
|
||||
const response = await contentfulFetch(endpoint, config, { method: "DELETE" });
|
||||
logger.debug("deleteProduct response", { response });
|
||||
const response = await deleteProductInCMS(id);
|
||||
logger.debug({ response }, "deleteProduct response");
|
||||
|
||||
return response;
|
||||
},
|
||||
createBatchProducts: async ({ input }) => {
|
||||
const results = await createBatchProductsInCMS(input);
|
||||
logger.debug({ results }, "createBatchProducts results");
|
||||
|
||||
return results.map((result) => transformCreateProductResponse(result));
|
||||
},
|
||||
deleteBatchProducts: async ({ ids }) => {
|
||||
const results = await deleteBatchProductsInCMS(ids);
|
||||
logger.debug({ results }, "deleteBatchProducts results");
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { createProvider } from "./create";
|
||||
import { CreateOperations, CreateProductResponse } from "../types";
|
||||
import { CreateOperations, ProductInput, ProductResponse } from "../types";
|
||||
import { logger as pinoLogger } from "../../logger";
|
||||
|
||||
import { ApiError, buildClient, SimpleSchemaTypes } from "@datocms/cma-client-node";
|
||||
|
@ -18,7 +18,7 @@ const datocmsClient = (config: DatocmsConfig, options?: RequestInit) => {
|
|||
});
|
||||
};
|
||||
|
||||
const transformResponseError = (error: unknown): CreateProductResponse => {
|
||||
const transformResponseError = (error: unknown): ProductResponse => {
|
||||
if (error instanceof ApiError) {
|
||||
return {
|
||||
ok: false,
|
||||
|
@ -32,11 +32,15 @@ const transformResponseError = (error: unknown): CreateProductResponse => {
|
|||
}
|
||||
};
|
||||
|
||||
const transformResponseItem = (item: SimpleSchemaTypes.Item): CreateProductResponse => {
|
||||
const transformResponseItem = (
|
||||
item: SimpleSchemaTypes.Item,
|
||||
input: ProductInput
|
||||
): ProductResponse => {
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
id: item.id,
|
||||
saleorId: input.saleorId,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
@ -44,12 +48,10 @@ const transformResponseItem = (item: SimpleSchemaTypes.Item): CreateProductRespo
|
|||
const datocmsOperations: CreateOperations<DatocmsConfig> = (config) => {
|
||||
const logger = pinoLogger.child({ cms: "strapi" });
|
||||
|
||||
return {
|
||||
createProduct: async ({ input }) => {
|
||||
const client = datocmsClient(config);
|
||||
|
||||
try {
|
||||
const item = await client.items.create({
|
||||
const createProductInCMS = async (input: ProductInput) =>
|
||||
client.items.create({
|
||||
item_type: {
|
||||
id: config.itemTypeId,
|
||||
type: "item_type",
|
||||
|
@ -61,17 +63,9 @@ const datocmsOperations: CreateOperations<DatocmsConfig> = (config) => {
|
|||
product_name: input.productName,
|
||||
product_slug: input.productSlug,
|
||||
});
|
||||
logger.debug("createProduct response", { item });
|
||||
|
||||
return transformResponseItem(item);
|
||||
} catch (error) {
|
||||
return transformResponseError(error);
|
||||
}
|
||||
},
|
||||
updateProduct: async ({ id, input }) => {
|
||||
const client = datocmsClient(config);
|
||||
|
||||
const item = await client.items.update(id, {
|
||||
const updateProductInCMS = async (id: string, input: ProductInput) =>
|
||||
client.items.update(id, {
|
||||
saleor_id: input.saleorId,
|
||||
name: input.name,
|
||||
channels: JSON.stringify(input.channels),
|
||||
|
@ -79,13 +73,51 @@ const datocmsOperations: CreateOperations<DatocmsConfig> = (config) => {
|
|||
product_name: input.productName,
|
||||
product_slug: input.productSlug,
|
||||
});
|
||||
logger.debug("updateProduct response", { item });
|
||||
|
||||
const deleteProductInCMS = async (id: string) => client.items.destroy(id);
|
||||
|
||||
const createBatchProductsInCMS = async (input: ProductInput[]) =>
|
||||
// DatoCMS doesn't support batch creation of items, so we need to create them one by one
|
||||
Promise.all(
|
||||
input.map(async (item) => ({
|
||||
id: await createProductInCMS(item),
|
||||
input: item,
|
||||
}))
|
||||
);
|
||||
|
||||
const deleteBatchProductsInCMS = async (ids: string[]) =>
|
||||
client.items.bulkDestroy({
|
||||
items: ids.map((id) => ({ id, type: "item" })),
|
||||
});
|
||||
|
||||
return {
|
||||
createProduct: async ({ input }) => {
|
||||
try {
|
||||
const item = await createProductInCMS(input);
|
||||
logger.debug({ item }, "createProduct response");
|
||||
|
||||
return transformResponseItem(item, input);
|
||||
} catch (error) {
|
||||
return transformResponseError(error);
|
||||
}
|
||||
},
|
||||
updateProduct: async ({ id, input }) => {
|
||||
const item = await updateProductInCMS(id, input);
|
||||
logger.debug({ item }, "updateProduct response");
|
||||
},
|
||||
deleteProduct: async ({ id }) => {
|
||||
const client = datocmsClient(config);
|
||||
const item = await deleteProductInCMS(id);
|
||||
logger.debug({ item }, "deleteProduct response");
|
||||
},
|
||||
createBatchProducts: async ({ input }) => {
|
||||
const items = await createBatchProductsInCMS(input);
|
||||
logger.debug({ items }, "createBatchProducts response");
|
||||
|
||||
const item = await client.items.destroy(id);
|
||||
logger.debug("deleteProduct response", { item });
|
||||
return items.map((item) => transformResponseItem(item.id, item.input));
|
||||
},
|
||||
deleteBatchProducts: async ({ ids }) => {
|
||||
const items = await deleteBatchProductsInCMS(ids);
|
||||
logger.debug({ items }, "deleteBatchProducts response");
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { StrapiConfig, strapiConfigSchema } from "../config";
|
||||
import { CmsOperations, CreateOperations, CreateProductResponse, ProductInput } from "../types";
|
||||
import { CreateOperations, ProductResponse, ProductInput } from "../types";
|
||||
import { createProvider } from "./create";
|
||||
import { logger as pinoLogger } from "../../logger";
|
||||
|
||||
|
@ -20,7 +20,7 @@ type StrapiBody = {
|
|||
data: Record<string, any> & { saleor_id: string };
|
||||
};
|
||||
|
||||
const transformInputToBody = ({ input }: { input: ProductInput }): StrapiBody => {
|
||||
const transformInputToBody = (input: ProductInput): StrapiBody => {
|
||||
const body = {
|
||||
data: {
|
||||
saleor_id: input.saleorId,
|
||||
|
@ -55,7 +55,10 @@ type StrapiResponse =
|
|||
error: null;
|
||||
};
|
||||
|
||||
const transformCreateProductResponse = (response: StrapiResponse): CreateProductResponse => {
|
||||
const transformCreateProductResponse = (
|
||||
response: StrapiResponse,
|
||||
input: ProductInput
|
||||
): ProductResponse => {
|
||||
if (response.error) {
|
||||
return {
|
||||
ok: false,
|
||||
|
@ -67,47 +70,86 @@ const transformCreateProductResponse = (response: StrapiResponse): CreateProduct
|
|||
ok: true,
|
||||
data: {
|
||||
id: response.data.id,
|
||||
saleorId: input.saleorId,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type CreateStrapiOperations = CreateOperations<StrapiConfig>;
|
||||
|
||||
export const strapiOperations: CreateStrapiOperations = (config): CmsOperations => {
|
||||
export const strapiOperations: CreateStrapiOperations = (config) => {
|
||||
const logger = pinoLogger.child({ cms: "strapi" });
|
||||
|
||||
const { contentTypeId } = config;
|
||||
|
||||
return {
|
||||
createProduct: async (params) => {
|
||||
const body = transformInputToBody(params);
|
||||
const createProductInCMS = async (input: ProductInput): Promise<StrapiResponse> => {
|
||||
const body = transformInputToBody(input);
|
||||
const response = await strapiFetch(`/${contentTypeId}`, config, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
logger.debug("createProduct response", { response });
|
||||
logger.debug({ response }, "createProduct response");
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
const result = await response.json();
|
||||
logger.debug("createProduct result", { result });
|
||||
|
||||
return transformCreateProductResponse(result);
|
||||
},
|
||||
updateProduct: async ({ id, input }) => {
|
||||
const body = transformInputToBody({ input });
|
||||
const response = await strapiFetch(`/${contentTypeId}/${id}`, config, {
|
||||
const updateProductInCMS = async (id: string, input: ProductInput) => {
|
||||
const body = transformInputToBody(input);
|
||||
return await strapiFetch(`/${contentTypeId}/${id}`, config, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
logger.debug("updateProduct response", { response });
|
||||
};
|
||||
|
||||
const deleteProductInCMS = async (id: string) => {
|
||||
return await strapiFetch(`/${contentTypeId}/${id}`, config, { method: "DELETE" });
|
||||
};
|
||||
|
||||
const createBatchProductsInCMS = async (input: ProductInput[]) => {
|
||||
// Strapi doesn't support batch creation of items, so we need to create them one by one
|
||||
return await Promise.all(
|
||||
input.map(async (product) => ({
|
||||
response: await createProductInCMS(product),
|
||||
input: product,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const deleteBatchProductsInCMS = async (ids: string[]) => {
|
||||
// Strapi doesn't support batch deletion of items, so we need to delete them one by one
|
||||
return await Promise.all(ids.map((id) => deleteProductInCMS(id)));
|
||||
};
|
||||
|
||||
return {
|
||||
createProduct: async ({ input }) => {
|
||||
const result = await createProductInCMS(input);
|
||||
logger.debug({ result }, "createProduct result");
|
||||
|
||||
return transformCreateProductResponse(result, input);
|
||||
},
|
||||
updateProduct: async ({ id, input }) => {
|
||||
const response = await updateProductInCMS(id, input);
|
||||
logger.debug({ response }, "updateProduct response");
|
||||
|
||||
return response;
|
||||
},
|
||||
deleteProduct: async ({ id }) => {
|
||||
const response = await strapiFetch(`/${contentTypeId}/${id}`, config, { method: "DELETE" });
|
||||
logger.debug("deleteProduct response", { response });
|
||||
const response = await deleteProductInCMS(id);
|
||||
logger.debug({ response }, "deleteProduct response");
|
||||
|
||||
return response;
|
||||
},
|
||||
createBatchProducts: async ({ input }) => {
|
||||
const results = await createBatchProductsInCMS(input);
|
||||
logger.debug({ results }, "createBatchProducts results");
|
||||
|
||||
return results.map((result) => transformCreateProductResponse(result.response, result.input));
|
||||
},
|
||||
deleteBatchProducts: async ({ ids }) => {
|
||||
const responses = await deleteBatchProductsInCMS(ids);
|
||||
logger.debug({ responses }, "deleteBatchProducts responses");
|
||||
|
||||
return responses;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -11,24 +11,35 @@ export type ProductInput = Record<string, any> & {
|
|||
image?: string;
|
||||
};
|
||||
|
||||
export type CreateProductResponse =
|
||||
| { ok: true; data: { id: string } }
|
||||
| { ok: false; error: string };
|
||||
export type ProductResponseSuccess = { ok: true; data: { id: string; saleorId: string } };
|
||||
export type ProductResponseError = { ok: false; error: string };
|
||||
export type ProductResponse = ProductResponseSuccess | ProductResponseError;
|
||||
|
||||
export type CmsOperations = {
|
||||
getAllProducts?: () => Promise<Response>;
|
||||
getProduct?: ({ id }: { id: string }) => Promise<Response>;
|
||||
createProduct: ({ input }: { input: ProductInput }) => Promise<CreateProductResponse>;
|
||||
createProduct: ({ input }: { input: ProductInput }) => Promise<ProductResponse>;
|
||||
updateProduct: ({ id, input }: { id: string; input: ProductInput }) => Promise<Response | void>;
|
||||
deleteProduct: ({ id }: { id: string }) => Promise<Response | void>;
|
||||
};
|
||||
|
||||
export type CmsBatchOperations = {
|
||||
getAllProducts?: () => Promise<Response>;
|
||||
createBatchProducts: ({ input }: { input: ProductInput[] }) => Promise<ProductResponse[]>;
|
||||
deleteBatchProducts: ({ ids }: { ids: string[] }) => Promise<Response[] | void>;
|
||||
};
|
||||
|
||||
export type CmsClientOperations = {
|
||||
cmsProviderInstanceId: string;
|
||||
operations: CmsOperations;
|
||||
operationType: keyof CmsOperations;
|
||||
};
|
||||
|
||||
export type CmsClientBatchOperations = {
|
||||
cmsProviderInstanceId: string;
|
||||
operations: CmsBatchOperations;
|
||||
operationType: keyof CmsBatchOperations;
|
||||
};
|
||||
|
||||
export type GetProviderTokens<TProviderName extends keyof typeof providersConfig> =
|
||||
(typeof providersConfig)[TProviderName]["tokens"][number];
|
||||
|
||||
|
@ -43,7 +54,9 @@ export type CreateProviderConfig<TProviderName extends keyof typeof providersCon
|
|||
> &
|
||||
BaseConfig;
|
||||
|
||||
export type CreateOperations<TConfig extends BaseConfig> = (config: TConfig) => CmsOperations;
|
||||
export type CreateOperations<TConfig extends BaseConfig> = (
|
||||
config: TConfig
|
||||
) => CmsOperations & CmsBatchOperations;
|
||||
|
||||
export type Provider<TConfig extends BaseConfig> = {
|
||||
create: CreateOperations<TConfig>;
|
||||
|
|
|
@ -10,10 +10,7 @@ interface IAuthState {
|
|||
token: string;
|
||||
}
|
||||
|
||||
export const createClient = (url: string, getAuth: AuthConfig<IAuthState>["getAuth"]) =>
|
||||
urqlCreateClient({
|
||||
url,
|
||||
exchanges: [
|
||||
const getExchanges = (getAuth: AuthConfig<IAuthState>["getAuth"]) => [
|
||||
dedupExchange,
|
||||
cacheExchange,
|
||||
authExchange<IAuthState>({
|
||||
|
@ -44,5 +41,10 @@ export const createClient = (url: string, getAuth: AuthConfig<IAuthState>["getAu
|
|||
getAuth,
|
||||
}),
|
||||
fetchExchange,
|
||||
],
|
||||
];
|
||||
|
||||
export const createClient = (url: string, getAuth: AuthConfig<IAuthState>["getAuth"]) =>
|
||||
urqlCreateClient({
|
||||
url,
|
||||
exchanges: getExchanges(getAuth),
|
||||
});
|
||||
|
|
|
@ -9,6 +9,9 @@ import {
|
|||
ListItem,
|
||||
ListItemCell,
|
||||
makeStyles,
|
||||
Notification,
|
||||
Alert,
|
||||
IconButton,
|
||||
} from "@saleor/macaw-ui";
|
||||
import React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
@ -20,19 +23,30 @@ import {
|
|||
SingleProviderSchema,
|
||||
} from "../../../lib/cms/config";
|
||||
import { ProviderIcon } from "../../provider-instances/ui/provider-icon";
|
||||
import { ChannelsLoading } from "./types";
|
||||
|
||||
const useStyles = makeStyles((theme) => {
|
||||
return {
|
||||
item: {
|
||||
height: "auto !important",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 80px",
|
||||
gridTemplateColumns: "1fr 80px 80px",
|
||||
},
|
||||
itemCell: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing(2),
|
||||
},
|
||||
itemCellCenter: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing(2),
|
||||
},
|
||||
itemCellProgress: {
|
||||
padding: theme.spacing(0, 4),
|
||||
gridColumn: "1/5",
|
||||
},
|
||||
footer: {
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
|
@ -48,8 +62,9 @@ const useStyles = makeStyles((theme) => {
|
|||
interface ChannelConfigurationFormProps {
|
||||
channel?: MergedChannelSchema | null;
|
||||
providerInstances: SingleProviderSchema[];
|
||||
loading: boolean;
|
||||
loading: ChannelsLoading;
|
||||
onSubmit: (channel: SingleChannelSchema) => any;
|
||||
onSync: (providerInstanceId: string) => any;
|
||||
}
|
||||
|
||||
export const ChannelConfigurationForm = ({
|
||||
|
@ -57,6 +72,7 @@ export const ChannelConfigurationForm = ({
|
|||
providerInstances,
|
||||
loading,
|
||||
onSubmit,
|
||||
onSync,
|
||||
}: ChannelConfigurationFormProps) => {
|
||||
const styles = useStyles();
|
||||
|
||||
|
@ -85,6 +101,9 @@ export const ChannelConfigurationForm = ({
|
|||
resetField("enabledProviderInstances", {
|
||||
defaultValue: channel?.enabledProviderInstances || [],
|
||||
});
|
||||
resetField("requireSyncProviderInstances", {
|
||||
defaultValue: channel?.requireSyncProviderInstances || [],
|
||||
});
|
||||
}, [channel, providerInstances]);
|
||||
|
||||
const errors = formState.errors;
|
||||
|
@ -97,27 +116,37 @@ export const ChannelConfigurationForm = ({
|
|||
</Typography>
|
||||
)}
|
||||
<input type="hidden" {...register("channelSlug")} value={channel?.channelSlug} />
|
||||
<List gridTemplate={["1fr", "checkbox"]}>
|
||||
|
||||
<List gridTemplate={["1fr", "80px", "checkbox"]}>
|
||||
<ListHeader>
|
||||
<ListItem className={styles.item}>
|
||||
<ListItemCell>CMS provider configuration</ListItemCell>
|
||||
<ListItemCell>Active</ListItemCell>
|
||||
<ListItemCell className={styles.itemCellCenter}>Active</ListItemCell>
|
||||
<ListItemCell className={styles.itemCellCenter}>Sync</ListItemCell>
|
||||
</ListItem>
|
||||
</ListHeader>
|
||||
<ListBody>
|
||||
{providerInstances.map((providerInstance) => (
|
||||
{providerInstances.map((providerInstance) => {
|
||||
const enabledProviderInstances = watch("enabledProviderInstances");
|
||||
const requireSyncProviderInstances = watch("requireSyncProviderInstances");
|
||||
const isEnabled = enabledProviderInstances?.some(
|
||||
(formOption) => formOption === providerInstance.id
|
||||
);
|
||||
const requireSync = requireSyncProviderInstances?.some(
|
||||
(formOption) => formOption === providerInstance.id
|
||||
);
|
||||
|
||||
return (
|
||||
<ListItem key={providerInstance.name} className={styles.item}>
|
||||
<ListItemCell className={styles.itemCell}>
|
||||
<ProviderIcon providerName={providerInstance.providerName} />
|
||||
{providerInstance.name}
|
||||
</ListItemCell>
|
||||
<ListItemCell padding="checkbox">
|
||||
<ListItemCell padding="checkbox" className={styles.itemCellCenter}>
|
||||
<FormControl
|
||||
{...register("enabledProviderInstances")}
|
||||
name="enabledProviderInstances"
|
||||
checked={watch("enabledProviderInstances")?.some(
|
||||
(formOption) => formOption === providerInstance.id
|
||||
)}
|
||||
checked={isEnabled}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const valueCopy = getValues("enabledProviderInstances")
|
||||
? [...getValues("enabledProviderInstances")]
|
||||
|
@ -138,15 +167,41 @@ export const ChannelConfigurationForm = ({
|
|||
component={(props) => <Checkbox {...props} />}
|
||||
/>
|
||||
</ListItemCell>
|
||||
<ListItemCell className={styles.itemCellCenter}>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={
|
||||
!requireSync || !!loading.productsVariantsSync.syncingProviderInstanceId
|
||||
}
|
||||
onClick={() => onSync(providerInstance.id)}
|
||||
>
|
||||
Sync
|
||||
</Button>
|
||||
</ListItemCell>
|
||||
{loading.productsVariantsSync.syncingProviderInstanceId === providerInstance.id && (
|
||||
<ListItemCell className={styles.itemCellProgress}>
|
||||
Syncing products...
|
||||
<progress
|
||||
value={loading.productsVariantsSync.currentProductIndex}
|
||||
max={loading.productsVariantsSync.totalProductsCount}
|
||||
style={{
|
||||
height: "30px",
|
||||
width: "500px",
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
/>
|
||||
</ListItemCell>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{/* </>
|
||||
)}
|
||||
/> */}
|
||||
</ListBody>
|
||||
<ListFooter className={styles.footer}>
|
||||
<Button variant="primary" disabled={loading} type="submit">
|
||||
{loading ? "..." : "Save"}
|
||||
<Button variant="primary" disabled={loading.channels.saving} type="submit">
|
||||
{loading.channels.saving ? "..." : "Save"}
|
||||
</Button>
|
||||
</ListFooter>
|
||||
</List>
|
||||
|
|
|
@ -7,9 +7,10 @@ import {
|
|||
SingleChannelSchema,
|
||||
SingleProviderSchema,
|
||||
} from "../../../lib/cms/config";
|
||||
import { ChannelsErrors, ChannelsLoading } from "./types";
|
||||
import { ChannelsLoading } from "./types";
|
||||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
import { AppTabNavButton } from "../../ui/app-tab-nav-button";
|
||||
import { ChannelsDataErrors } from "./hooks/useChannels";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
textCenter: {
|
||||
|
@ -55,20 +56,22 @@ interface ChannelConfigurationProps {
|
|||
activeChannel?: MergedChannelSchema | null;
|
||||
providerInstances: SingleProviderSchema[];
|
||||
saveChannel: (channel: SingleChannelSchema) => any;
|
||||
syncChannelProviderInstance: (providerInstanceId: string) => any;
|
||||
loading: ChannelsLoading;
|
||||
errors: ChannelsErrors;
|
||||
errors: ChannelsDataErrors;
|
||||
}
|
||||
|
||||
export const ChannelConfiguration = ({
|
||||
activeChannel,
|
||||
providerInstances,
|
||||
saveChannel,
|
||||
syncChannelProviderInstance,
|
||||
loading,
|
||||
errors,
|
||||
}: ChannelConfigurationProps) => {
|
||||
const styles = useStyles();
|
||||
|
||||
if (loading.fetching || loading.saving) {
|
||||
if (loading.channels.fetching || loading.channels.saving) {
|
||||
return <ChannelConfigurationSkeleton />;
|
||||
}
|
||||
|
||||
|
@ -110,8 +113,9 @@ export const ChannelConfiguration = ({
|
|||
<ChannelConfigurationForm
|
||||
channel={activeChannel}
|
||||
providerInstances={providerInstances}
|
||||
loading={loading.saving}
|
||||
loading={loading}
|
||||
onSubmit={saveChannel}
|
||||
onSync={syncChannelProviderInstance}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
|
|
|
@ -2,8 +2,9 @@ import { Skeleton } from "@material-ui/lab";
|
|||
import { MergedChannelSchema } from "../../../lib/cms";
|
||||
import { AppPaper } from "../../ui/app-paper";
|
||||
|
||||
import { ChannelsErrors, ChannelsLoading } from "./types";
|
||||
import { ChannelsLoading } from "./types";
|
||||
import { ChannelsSelect } from "./channels-select";
|
||||
import { ChannelsDataErrors } from "./hooks/useChannels";
|
||||
|
||||
const ChannelsListSkeleton = () => {
|
||||
return (
|
||||
|
@ -18,7 +19,7 @@ interface ChannelsListProps {
|
|||
activeChannel?: MergedChannelSchema | null;
|
||||
setActiveChannel: (channel: MergedChannelSchema | null) => void;
|
||||
loading: ChannelsLoading;
|
||||
errors: ChannelsErrors;
|
||||
errors: ChannelsDataErrors;
|
||||
}
|
||||
|
||||
export const ChannelsList = ({
|
||||
|
@ -28,7 +29,7 @@ export const ChannelsList = ({
|
|||
loading,
|
||||
errors,
|
||||
}: ChannelsListProps) => {
|
||||
if (loading.fetching) {
|
||||
if (loading.channels.fetching) {
|
||||
return <ChannelsListSkeleton />;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,11 +1,16 @@
|
|||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
import { useEffect, useState } from "react";
|
||||
import { MergedChannelSchema } from "../../../lib/cms/config";
|
||||
import { MergedChannelSchema, SingleChannelSchema } from "../../../lib/cms/config";
|
||||
import {
|
||||
useProductsVariantsSync,
|
||||
ProductsVariantsSyncOperation,
|
||||
} from "../../cms/hooks/useProductsVariantsSync";
|
||||
import { useProviderInstances } from "../../provider-instances/ui/hooks/useProviderInstances";
|
||||
import { AppTabs } from "../../ui/app-tabs";
|
||||
import { ChannelConfiguration } from "./channel-configuration";
|
||||
import { ChannelsList } from "./channels-list";
|
||||
import { useChannels } from "./hooks/useChannels";
|
||||
import { AppTabs } from "../../ui/app-tabs";
|
||||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
import { ChannelsLoading } from "./types";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
wrapper: {
|
||||
|
@ -17,7 +22,7 @@ const useStyles = makeStyles({
|
|||
|
||||
export const Channels = () => {
|
||||
const styles = useStyles();
|
||||
const { channels, saveChannel, loading, errors } = useChannels();
|
||||
const { channels, saveChannel, loading: loadingChannels, errors } = useChannels();
|
||||
const { providerInstances } = useProviderInstances();
|
||||
|
||||
const [activeChannelSlug, setActiveChannelSlug] = useState<string | null>(
|
||||
|
@ -36,6 +41,40 @@ export const Channels = () => {
|
|||
}
|
||||
}, [channels]);
|
||||
|
||||
const handleOnSyncCompleted = (providerInstanceId: string) => {
|
||||
if (!activeChannel) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveChannel({
|
||||
...activeChannel,
|
||||
requireSyncProviderInstances: activeChannel.requireSyncProviderInstances?.filter(
|
||||
(id) => id !== providerInstanceId
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const { sync, loading: loadingProductsVariantsSync } = useProductsVariantsSync(
|
||||
activeChannelSlug,
|
||||
handleOnSyncCompleted
|
||||
);
|
||||
|
||||
const handleSync = async (providerInstanceId: string) => {
|
||||
if (!activeChannel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const operation: ProductsVariantsSyncOperation =
|
||||
activeChannel.enabledProviderInstances.includes(providerInstanceId) ? "ADD" : "DELETE";
|
||||
|
||||
return sync(providerInstanceId, operation);
|
||||
};
|
||||
|
||||
const loading: ChannelsLoading = {
|
||||
channels: loadingChannels,
|
||||
productsVariantsSync: loadingProductsVariantsSync,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppTabs activeTab="channels" />
|
||||
|
@ -52,6 +91,7 @@ export const Channels = () => {
|
|||
activeChannel={activeChannel}
|
||||
providerInstances={providerInstances}
|
||||
saveChannel={saveChannel}
|
||||
syncChannelProviderInstance={handleSync}
|
||||
loading={loading}
|
||||
errors={errors}
|
||||
/>
|
||||
|
|
|
@ -1,10 +1,19 @@
|
|||
import { useChannelsFetch } from "./useChannelsFetch";
|
||||
import { MergedChannelSchema, SingleChannelSchema } from "../../../../lib/cms/config";
|
||||
import { ChannelsErrors, ChannelsLoading } from "../types";
|
||||
import { useChannelsQuery } from "../../../../../generated/graphql";
|
||||
import { useIsMounted } from "usehooks-ts";
|
||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
|
||||
export interface ChannelsDataLoading {
|
||||
fetching: boolean;
|
||||
saving: boolean;
|
||||
}
|
||||
|
||||
export interface ChannelsDataErrors {
|
||||
fetching?: Error | null;
|
||||
saving?: Error | null;
|
||||
}
|
||||
|
||||
export const useChannels = () => {
|
||||
const { appBridge } = useAppBridge();
|
||||
const isMounted = useIsMounted();
|
||||
|
@ -19,10 +28,33 @@ export const useChannels = () => {
|
|||
isFetching,
|
||||
} = useChannelsFetch();
|
||||
|
||||
const saveChannel = (channelToSave: SingleChannelSchema) => {
|
||||
const saveChannel = async (channelToSave: SingleChannelSchema) => {
|
||||
console.log("saveChannel", channelToSave);
|
||||
|
||||
saveChannelFetch(channelToSave).then(() => {
|
||||
const currentlyEnabledProviderInstances =
|
||||
settings?.[`${channelToSave.channelSlug}`]?.enabledProviderInstances || [];
|
||||
const toEnableProviderInstances = channelToSave.enabledProviderInstances || [];
|
||||
|
||||
const changedSyncProviderInstances = [
|
||||
...currentlyEnabledProviderInstances.filter(
|
||||
(instance) => !toEnableProviderInstances.includes(instance)
|
||||
),
|
||||
...toEnableProviderInstances.filter(
|
||||
(instance) => !currentlyEnabledProviderInstances.includes(instance)
|
||||
),
|
||||
];
|
||||
|
||||
const fetchResult = await saveChannelFetch({
|
||||
...channelToSave,
|
||||
requireSyncProviderInstances: [
|
||||
...(channelToSave.requireSyncProviderInstances || []),
|
||||
...changedSyncProviderInstances.filter(
|
||||
(instance) => !(channelToSave.requireSyncProviderInstances || []).includes(instance)
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
if (fetchResult.success) {
|
||||
appBridge?.dispatch(
|
||||
actions.Notification({
|
||||
title: "Success",
|
||||
|
@ -30,15 +62,23 @@ export const useChannels = () => {
|
|||
text: "Configuration saved",
|
||||
})
|
||||
);
|
||||
});
|
||||
} else {
|
||||
appBridge?.dispatch(
|
||||
actions.Notification({
|
||||
title: "Error",
|
||||
status: "error",
|
||||
text: "Error while saving configuration",
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const loading: ChannelsLoading = {
|
||||
const loading: ChannelsDataLoading = {
|
||||
fetching: isFetching || channelsQueryData.fetching,
|
||||
saving: isSaving,
|
||||
};
|
||||
|
||||
const errors: ChannelsErrors = {
|
||||
const errors: ChannelsDataErrors = {
|
||||
fetching: fetchingError ? Error(fetchingError) : null,
|
||||
saving: null,
|
||||
};
|
||||
|
@ -51,6 +91,9 @@ export const useChannels = () => {
|
|||
enabledProviderInstances: settings
|
||||
? settings[`${channel.slug}`]?.enabledProviderInstances
|
||||
: [],
|
||||
requireSyncProviderInstances: settings
|
||||
? settings[`${channel.slug}`]?.requireSyncProviderInstances
|
||||
: [],
|
||||
channel: channel,
|
||||
} as MergedChannelSchema)
|
||||
) || [];
|
||||
|
|
|
@ -64,9 +64,19 @@ export const useChannelsFetch = () => {
|
|||
console.log("saveSettings config", config);
|
||||
|
||||
setConfig(config);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
export interface ChannelsLoading {
|
||||
fetching: boolean;
|
||||
saving: boolean;
|
||||
}
|
||||
import { ProductsVariantsSyncLoading } from "../../cms/hooks/useProductsVariantsSync";
|
||||
import { ChannelsDataLoading } from "./hooks/useChannels";
|
||||
|
||||
export interface ChannelsErrors {
|
||||
fetching?: Error | null;
|
||||
saving?: Error | null;
|
||||
export interface ChannelsLoading {
|
||||
channels: ChannelsDataLoading;
|
||||
productsVariantsSync: ProductsVariantsSyncLoading;
|
||||
}
|
||||
|
|
138
apps/cms/src/modules/cms/hooks/useProductsVariantsSync.ts
Normal file
138
apps/cms/src/modules/cms/hooks/useProductsVariantsSync.ts
Normal file
|
@ -0,0 +1,138 @@
|
|||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { WebhookProductVariantFragment } from "../../../../generated/graphql";
|
||||
import { Products, useQueryAllProducts } from "./useQueryAllProducts";
|
||||
|
||||
export interface ProductsVariantsSyncLoading {
|
||||
syncingProviderInstanceId?: string;
|
||||
currentProductIndex?: number;
|
||||
totalProductsCount?: number;
|
||||
}
|
||||
|
||||
export type ProductsVariantsSyncOperation = "ADD" | "DELETE";
|
||||
|
||||
const BATCH_SIZE = 100;
|
||||
|
||||
interface UseProductsVariantsSyncHandlers {
|
||||
sync: (providerInstanceId: string, operation: ProductsVariantsSyncOperation) => void;
|
||||
loading: ProductsVariantsSyncLoading;
|
||||
}
|
||||
|
||||
export const useProductsVariantsSync = (
|
||||
channelSlug: string | null,
|
||||
onSyncCompleted: (providerInstanceId: string) => void
|
||||
): UseProductsVariantsSyncHandlers => {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
|
||||
const [startedProviderInstanceId, setStartedProviderInstanceId] = useState<string>();
|
||||
const [startedOperation, setStartedOperation] = useState<ProductsVariantsSyncOperation>();
|
||||
const [currentProductIndex, setCurrentProductIndex] = useState(0);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
const { products, fetchCompleted } = useQueryAllProducts(!startedProviderInstanceId, channelSlug);
|
||||
|
||||
const sync = (providerInstanceId: string, operation: ProductsVariantsSyncOperation) => {
|
||||
setStartedProviderInstanceId(providerInstanceId);
|
||||
setStartedOperation(operation);
|
||||
setCurrentProductIndex(0);
|
||||
};
|
||||
|
||||
const syncFetch = async (
|
||||
providerInstanceId: string,
|
||||
operation: ProductsVariantsSyncOperation,
|
||||
productsBatch: Products
|
||||
) => {
|
||||
const productsVariants = productsBatch.reduce((acc, product) => {
|
||||
const variants = product.variants?.map((variant) => {
|
||||
const { variants: _, ...productFields } = product;
|
||||
return {
|
||||
product: productFields,
|
||||
...variant,
|
||||
};
|
||||
});
|
||||
|
||||
return variants ? [...acc, ...variants] : acc;
|
||||
}, [] as WebhookProductVariantFragment[]);
|
||||
|
||||
try {
|
||||
const syncResponse = await fetch("/api/sync-products-variants", {
|
||||
method: "POST",
|
||||
headers: [
|
||||
["content-type", "application/json"],
|
||||
[SALEOR_API_URL_HEADER, appBridgeState?.saleorApiUrl!],
|
||||
[SALEOR_AUTHORIZATION_BEARER_HEADER, appBridgeState?.token!],
|
||||
],
|
||||
body: JSON.stringify({
|
||||
providerInstanceId,
|
||||
productsVariants,
|
||||
operation,
|
||||
}),
|
||||
});
|
||||
|
||||
const syncResult = await syncResponse.json();
|
||||
|
||||
return syncResult;
|
||||
} catch (error) {
|
||||
console.error("useProductsVariantsSync syncFetch error", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
products.length <= currentProductIndex &&
|
||||
fetchCompleted &&
|
||||
startedProviderInstanceId &&
|
||||
startedOperation
|
||||
) {
|
||||
const completedProviderInstanceIdSync = startedProviderInstanceId;
|
||||
|
||||
setStartedProviderInstanceId(undefined);
|
||||
setStartedOperation(undefined);
|
||||
setCurrentProductIndex(0);
|
||||
|
||||
onSyncCompleted(completedProviderInstanceIdSync);
|
||||
}
|
||||
}, [products.length, currentProductIndex, fetchCompleted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!startedProviderInstanceId || !startedOperation) {
|
||||
return;
|
||||
}
|
||||
if (products.length <= currentProductIndex) {
|
||||
return;
|
||||
}
|
||||
if (isImporting) {
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
setIsImporting(true);
|
||||
const productsBatchStartIndex = currentProductIndex;
|
||||
const productsBatchEndIndex = Math.min(currentProductIndex + BATCH_SIZE, products.length);
|
||||
const productsBatch = products.slice(productsBatchStartIndex, productsBatchEndIndex);
|
||||
|
||||
// temporary solution, cannot use directly backend methods without fetch, due to non-browser Node dependency, like await cmsProvider.updatedBatchProducts(productsBatch);
|
||||
await syncFetch(startedProviderInstanceId, startedOperation, productsBatch);
|
||||
|
||||
setIsImporting(false);
|
||||
setCurrentProductIndex(productsBatchEndIndex);
|
||||
})();
|
||||
}, [
|
||||
startedProviderInstanceId,
|
||||
startedOperation,
|
||||
currentProductIndex,
|
||||
isImporting,
|
||||
products.length,
|
||||
]);
|
||||
|
||||
const loading: ProductsVariantsSyncLoading = {
|
||||
syncingProviderInstanceId: startedProviderInstanceId,
|
||||
currentProductIndex,
|
||||
totalProductsCount: products.length,
|
||||
};
|
||||
|
||||
return {
|
||||
sync,
|
||||
loading,
|
||||
};
|
||||
};
|
81
apps/cms/src/modules/cms/hooks/useQueryAllProducts.tsx
Normal file
81
apps/cms/src/modules/cms/hooks/useQueryAllProducts.tsx
Normal file
|
@ -0,0 +1,81 @@
|
|||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ProductsDataForImportDocument,
|
||||
ProductsDataForImportQuery,
|
||||
} from "../../../../generated/graphql";
|
||||
import { createClient } from "../../../lib/graphql";
|
||||
|
||||
const PER_PAGE = 100;
|
||||
|
||||
export type Products = NonNullable<
|
||||
ProductsDataForImportQuery["products"]
|
||||
>["edges"][number]["node"][];
|
||||
|
||||
export const useQueryAllProducts = (paused: boolean, channelSlug: string | null) => {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
const saleorApiUrl = appBridgeState?.saleorApiUrl!;
|
||||
|
||||
const [products, setProducts] = useState<Products>([]);
|
||||
const [fetchCompleted, setFetchCompleted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) {
|
||||
setProducts([]);
|
||||
setFetchCompleted(false);
|
||||
}
|
||||
}, [paused]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused || !channelSlug || !appBridgeState?.token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = appBridgeState.token;
|
||||
const client = createClient(saleorApiUrl, () => Promise.resolve({ token }));
|
||||
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
const getProducts = async (channelSlug: string, cursor: string): Promise<void> => {
|
||||
const response = await client
|
||||
.query(
|
||||
ProductsDataForImportDocument,
|
||||
{
|
||||
after: cursor,
|
||||
first: PER_PAGE,
|
||||
channel: channelSlug!,
|
||||
},
|
||||
{
|
||||
requestPolicy: "network-only", // Invalidate products data, because it could contain legacy products variants metadata that indicates these products variants existance in CMS providers
|
||||
}
|
||||
)
|
||||
.toPromise();
|
||||
|
||||
const newProducts = response?.data?.products?.edges.map((e) => e.node) ?? [];
|
||||
|
||||
if (newProducts.length > 0) {
|
||||
setProducts((ps) => [...ps, ...newProducts]);
|
||||
}
|
||||
if (
|
||||
response?.data?.products?.pageInfo.hasNextPage &&
|
||||
response?.data?.products?.pageInfo.endCursor
|
||||
) {
|
||||
// get next page of products
|
||||
return getProducts(channelSlug, response.data.products?.pageInfo.endCursor);
|
||||
} else {
|
||||
setFetchCompleted(true);
|
||||
}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
await getProducts(channelSlug, "");
|
||||
})();
|
||||
}, [appBridgeState?.token, saleorApiUrl, paused, channelSlug]);
|
||||
|
||||
return {
|
||||
products,
|
||||
fetchCompleted,
|
||||
};
|
||||
};
|
169
apps/cms/src/pages/api/sync-products-variants.ts
Normal file
169
apps/cms/src/pages/api/sync-products-variants.ts
Normal file
|
@ -0,0 +1,169 @@
|
|||
import { createProtectedHandler, NextProtectedApiHandler } from "@saleor/app-sdk/handlers/next";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { WebhookProductVariantFragment } from "../../../generated/graphql";
|
||||
import { saleorApp } from "../../../saleor-app";
|
||||
import { executeCmsClientBatchOperation } from "../../lib/cms/client/clients-execution";
|
||||
import { getChannelsSettings, getProviderInstancesSettings } from "../../lib/cms/client/settings";
|
||||
import { providersSchemaSet } from "../../lib/cms/config/providers";
|
||||
import { cmsProviders, CMSProvider } from "../../lib/cms/providers";
|
||||
import { logger as pinoLogger } from "../../lib/logger";
|
||||
import { createClient } from "../../lib/graphql";
|
||||
import { createSettingsManager } from "../../lib/metadata";
|
||||
import { batchUpdateMetadata, MetadataRecord } from "../../lib/cms/client/metadata-execution";
|
||||
import { CmsBatchOperations } from "../../lib/cms/types";
|
||||
|
||||
export interface SyncProductsVariantsApiPayload {
|
||||
channelSlug: string;
|
||||
providerInstanceId: string;
|
||||
productsVariants: WebhookProductVariantFragment[];
|
||||
operation: "ADD" | "DELETE";
|
||||
}
|
||||
|
||||
export interface SyncProductsVariantsApiResponse {
|
||||
success: boolean;
|
||||
data?: {
|
||||
createdCMSIds: MetadataRecord[];
|
||||
deletedCMSIds: MetadataRecord[];
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const handler: NextProtectedApiHandler = async (
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<SyncProductsVariantsApiResponse>,
|
||||
context
|
||||
) => {
|
||||
const { authData } = context;
|
||||
|
||||
const logger = pinoLogger.child({
|
||||
endpoint: "sync-products-variants",
|
||||
});
|
||||
logger.debug("Called endpoint sync-products-variants");
|
||||
|
||||
const client = createClient(authData.saleorApiUrl, async () => ({
|
||||
token: authData.token,
|
||||
}));
|
||||
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({
|
||||
success: false,
|
||||
});
|
||||
}
|
||||
|
||||
// todo: change to zod validation
|
||||
const { providerInstanceId, productsVariants, operation } =
|
||||
req.body as SyncProductsVariantsApiPayload;
|
||||
|
||||
if (!providerInstanceId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "The provider instance id is missing.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!productsVariants || productsVariants?.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "The products variants are missing.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!operation || (operation !== "ADD" && operation !== "DELETE")) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "The operation is missing or invalid. Allowed operations: ADD, DELETE.",
|
||||
});
|
||||
}
|
||||
const operationType: keyof CmsBatchOperations =
|
||||
operation === "ADD" ? "createBatchProducts" : "deleteBatchProducts";
|
||||
|
||||
const settingsManager = createSettingsManager(client);
|
||||
const [providerInstancesSettingsParsed, channelsSettingsParsed] = await Promise.all([
|
||||
getProviderInstancesSettings(settingsManager),
|
||||
getChannelsSettings(settingsManager),
|
||||
]);
|
||||
const providerInstanceSettings = providerInstancesSettingsParsed[providerInstanceId];
|
||||
|
||||
const provider = cmsProviders[
|
||||
providerInstanceSettings.providerName as CMSProvider
|
||||
] as (typeof cmsProviders)[keyof typeof cmsProviders];
|
||||
const validation =
|
||||
providersSchemaSet[providerInstanceSettings.providerName as CMSProvider].safeParse(
|
||||
providerInstanceSettings
|
||||
);
|
||||
|
||||
logger.debug({ provider }, "The provider instance settings provider.");
|
||||
|
||||
if (!validation.success) {
|
||||
// todo: use instead: throw new Error(validation.error.message);
|
||||
// continue with other provider instances
|
||||
logger.error(
|
||||
{ error: validation.error.message },
|
||||
"The provider instance settings validation failed."
|
||||
);
|
||||
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
});
|
||||
}
|
||||
|
||||
const config = validation.data;
|
||||
|
||||
logger.debug({ config }, "The provider instance settings validated config.");
|
||||
|
||||
const enabledChannelsForSelectedProviderInstance = Object.entries(channelsSettingsParsed).reduce(
|
||||
(enabledChannels, [channelSlug, channelSettingsParsed]) => {
|
||||
if (channelSettingsParsed.enabledProviderInstances.includes(providerInstanceId)) {
|
||||
return [...enabledChannels, channelSlug];
|
||||
}
|
||||
return enabledChannels;
|
||||
},
|
||||
[] as string[]
|
||||
);
|
||||
|
||||
// todo: make it later a method of kinda ChannelsSettingsRepository instantiated only once
|
||||
const verifyIfProductVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance = (
|
||||
productVariant: WebhookProductVariantFragment
|
||||
) => {
|
||||
const variantAvailableChannels = productVariant.channelListings?.map((cl) => cl.channel.slug);
|
||||
const isAvailable = variantAvailableChannels?.some((channel) =>
|
||||
enabledChannelsForSelectedProviderInstance.includes(channel)
|
||||
);
|
||||
return !!isAvailable;
|
||||
};
|
||||
|
||||
const syncResult = await executeCmsClientBatchOperation({
|
||||
cmsClient: {
|
||||
cmsProviderInstanceId: providerInstanceId,
|
||||
operationType,
|
||||
operations: provider.create(config as any),
|
||||
},
|
||||
productsVariants,
|
||||
verifyIfProductVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance,
|
||||
});
|
||||
|
||||
await batchUpdateMetadata({
|
||||
context,
|
||||
variantCMSProviderInstanceIdsToCreate:
|
||||
syncResult?.createdCmsIds?.map((cmsId) => ({
|
||||
id: cmsId.saleorId,
|
||||
cmsProviderInstanceIds: { [providerInstanceId]: cmsId.id },
|
||||
})) || [],
|
||||
variantCMSProviderInstanceIdsToDelete:
|
||||
syncResult?.deletedCmsIds?.map((cmsId) => ({
|
||||
id: cmsId.saleorId,
|
||||
cmsProviderInstanceIds: { [providerInstanceId]: cmsId.id },
|
||||
})) || [],
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
createdCMSIds: syncResult?.createdCmsIds || [],
|
||||
deletedCMSIds: syncResult?.deletedCmsIds || [],
|
||||
},
|
||||
error: syncResult?.error,
|
||||
});
|
||||
};
|
||||
|
||||
export default createProtectedHandler(handler, saleorApp.apl, ["MANAGE_APPS"]);
|
|
@ -7,11 +7,7 @@ import {
|
|||
import { saleorApp } from "../../../../saleor-app";
|
||||
import { getCmsKeysFromSaleorItem } from "../../../lib/cms/client/metadata";
|
||||
import { getChannelsSlugsFromSaleorItem } from "../../../lib/cms/client/channels";
|
||||
import {
|
||||
createCmsOperations,
|
||||
executeCmsOperations,
|
||||
executeMetadataUpdate,
|
||||
} from "../../../lib/cms/client";
|
||||
import { createCmsOperations, executeCmsOperations, updateMetadata } from "../../../lib/cms/client";
|
||||
import { logger as pinoLogger } from "../../../lib/logger";
|
||||
import { createClient } from "../../../lib/graphql";
|
||||
import { fetchProductVariantMetadata } from "../../../lib/metadata";
|
||||
|
@ -109,7 +105,7 @@ export const handler: NextWebhookApiHandler<ProductUpdatedWebhookPayloadFragment
|
|||
|
||||
allCMSErrors.push(...cmsErrors);
|
||||
|
||||
await executeMetadataUpdate({
|
||||
await updateMetadata({
|
||||
context,
|
||||
productVariant,
|
||||
cmsProviderInstanceIdsToCreate: cmsProviderInstanceProductVariantIdsToCreate,
|
||||
|
@ -120,7 +116,8 @@ export const handler: NextWebhookApiHandler<ProductUpdatedWebhookPayloadFragment
|
|||
if (!allCMSErrors.length) {
|
||||
return res.status(200).end();
|
||||
} else {
|
||||
return res.status(500).json({ errors: allCMSErrors });
|
||||
// Due to undesired webhook events deliveries retries on HTTP 500, we need to return 200 status code instead of 500.
|
||||
return res.status(200).json({ errors: allCMSErrors });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -6,11 +6,7 @@ import {
|
|||
} from "../../../../generated/graphql";
|
||||
import { saleorApp } from "../../../../saleor-app";
|
||||
import { getChannelsSlugsFromSaleorItem } from "../../../lib/cms/client/channels";
|
||||
import {
|
||||
createCmsOperations,
|
||||
executeCmsOperations,
|
||||
executeMetadataUpdate,
|
||||
} from "../../../lib/cms/client";
|
||||
import { createCmsOperations, executeCmsOperations, updateMetadata } from "../../../lib/cms/client";
|
||||
import { logger as pinoLogger } from "../../../lib/logger";
|
||||
import { createClient } from "../../../lib/graphql";
|
||||
import { fetchProductVariantMetadata } from "../../../lib/metadata";
|
||||
|
@ -93,7 +89,7 @@ export const handler: NextWebhookApiHandler<ProductVariantCreatedWebhookPayloadF
|
|||
productVariant,
|
||||
});
|
||||
|
||||
await executeMetadataUpdate({
|
||||
await updateMetadata({
|
||||
context,
|
||||
productVariant,
|
||||
cmsProviderInstanceIdsToCreate: cmsProviderInstanceProductVariantIdsToCreate,
|
||||
|
@ -103,7 +99,8 @@ export const handler: NextWebhookApiHandler<ProductVariantCreatedWebhookPayloadF
|
|||
if (!cmsErrors.length) {
|
||||
return res.status(200).end();
|
||||
} else {
|
||||
return res.status(500).json({ errors: cmsErrors });
|
||||
// Due to undesired webhook events deliveries retries on HTTP 500, we need to return 200 status code instead of 500.
|
||||
return res.status(200).json({ errors: cmsErrors });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -6,11 +6,7 @@ import {
|
|||
} from "../../../../generated/graphql";
|
||||
import { saleorApp } from "../../../../saleor-app";
|
||||
import { getCmsKeysFromSaleorItem } from "../../../lib/cms/client/metadata";
|
||||
import {
|
||||
createCmsOperations,
|
||||
executeCmsOperations,
|
||||
executeMetadataUpdate,
|
||||
} from "../../../lib/cms/client";
|
||||
import { createCmsOperations, executeCmsOperations, updateMetadata } from "../../../lib/cms/client";
|
||||
import { logger as pinoLogger } from "../../../lib/logger";
|
||||
import { createClient } from "../../../lib/graphql";
|
||||
|
||||
|
@ -89,7 +85,7 @@ export const handler: NextWebhookApiHandler<ProductVariantDeletedWebhookPayloadF
|
|||
productVariant,
|
||||
});
|
||||
|
||||
await executeMetadataUpdate({
|
||||
await updateMetadata({
|
||||
context,
|
||||
productVariant,
|
||||
cmsProviderInstanceIdsToCreate: cmsProviderInstanceProductVariantIdsToCreate,
|
||||
|
@ -99,7 +95,8 @@ export const handler: NextWebhookApiHandler<ProductVariantDeletedWebhookPayloadF
|
|||
if (!cmsErrors.length) {
|
||||
return res.status(200).end();
|
||||
} else {
|
||||
return res.status(500).json({ errors: cmsErrors });
|
||||
// Due to undesired webhook events deliveries retries on HTTP 500, we need to return 200 status code instead of 500.
|
||||
return res.status(200).json({ errors: cmsErrors });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -7,11 +7,7 @@ import {
|
|||
import { saleorApp } from "../../../../saleor-app";
|
||||
import { getCmsKeysFromSaleorItem } from "../../../lib/cms/client/metadata";
|
||||
import { getChannelsSlugsFromSaleorItem } from "../../../lib/cms/client/channels";
|
||||
import {
|
||||
createCmsOperations,
|
||||
executeCmsOperations,
|
||||
executeMetadataUpdate,
|
||||
} from "../../../lib/cms/client";
|
||||
import { createCmsOperations, executeCmsOperations, updateMetadata } from "../../../lib/cms/client";
|
||||
import { logger as pinoLogger } from "../../../lib/logger";
|
||||
import { createClient } from "../../../lib/graphql";
|
||||
import { fetchProductVariantMetadata } from "../../../lib/metadata";
|
||||
|
@ -93,7 +89,7 @@ export const handler: NextWebhookApiHandler<ProductVariantUpdatedWebhookPayloadF
|
|||
productVariant,
|
||||
});
|
||||
|
||||
await executeMetadataUpdate({
|
||||
await updateMetadata({
|
||||
context,
|
||||
productVariant,
|
||||
cmsProviderInstanceIdsToCreate: cmsProviderInstanceProductVariantIdsToCreate,
|
||||
|
@ -103,7 +99,8 @@ export const handler: NextWebhookApiHandler<ProductVariantUpdatedWebhookPayloadF
|
|||
if (!cmsErrors.length) {
|
||||
return res.status(200).end();
|
||||
} else {
|
||||
return res.status(500).json({ errors: cmsErrors });
|
||||
// Due to undesired webhook events deliveries retries on HTTP 500, we need to return 200 status code instead of 500.
|
||||
return res.status(200).json({ errors: cmsErrors });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
Loading…
Reference in a new issue