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.
|
} // 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:
|
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.
|
- `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.
|
- `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`.
|
- `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/icons": "^4.11.3",
|
||||||
"@material-ui/lab": "4.0.0-alpha.61",
|
"@material-ui/lab": "4.0.0-alpha.61",
|
||||||
"@saleor/app-sdk": "0.37.1",
|
"@saleor/app-sdk": "0.37.1",
|
||||||
|
"@saleor/apps-shared": "workspace:*",
|
||||||
"@saleor/macaw-ui": "^0.6.7",
|
"@saleor/macaw-ui": "^0.6.7",
|
||||||
"@sentry/nextjs": "^7.43.0",
|
"@sentry/nextjs": "^7.43.0",
|
||||||
"@urql/exchange-auth": "^1.0.0",
|
"@urql/exchange-auth": "^1.0.0",
|
||||||
|
@ -38,8 +39,7 @@
|
||||||
"usehooks-ts": "^2.9.1",
|
"usehooks-ts": "^2.9.1",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
"vite": "^4.1.4",
|
"vite": "^4.1.4",
|
||||||
"zod": "^3.19.1",
|
"zod": "^3.19.1"
|
||||||
"@saleor/apps-shared": "workspace:*"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@graphql-codegen/cli": "2.13.3",
|
"@graphql-codegen/cli": "2.13.3",
|
||||||
|
|
|
@ -2,7 +2,7 @@ import {
|
||||||
ProductVariantUpdatedWebhookPayloadFragment,
|
ProductVariantUpdatedWebhookPayloadFragment,
|
||||||
WebhookProductVariantFragment,
|
WebhookProductVariantFragment,
|
||||||
} from "../../../../generated/graphql";
|
} from "../../../../generated/graphql";
|
||||||
import { CmsClientOperations } from "../types";
|
import { CmsClientBatchOperations, CmsClientOperations, ProductResponseSuccess } from "../types";
|
||||||
import { getCmsIdFromSaleorItem } from "./metadata";
|
import { getCmsIdFromSaleorItem } from "./metadata";
|
||||||
import { logger as pinoLogger } from "../../logger";
|
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 ({
|
export const executeCmsOperations = async ({
|
||||||
cmsOperations,
|
cmsOperations,
|
||||||
productVariant,
|
productVariant,
|
||||||
|
|
|
@ -33,8 +33,10 @@ export const createCmsOperations = async ({
|
||||||
|
|
||||||
const settingsManager = createSettingsManager(client);
|
const settingsManager = createSettingsManager(client);
|
||||||
|
|
||||||
const channelsSettingsParsed = await getChannelsSettings(settingsManager);
|
const [channelsSettingsParsed, providerInstancesSettingsParsed] = await Promise.all([
|
||||||
const providerInstancesSettingsParsed = await getProviderInstancesSettings(settingsManager);
|
getChannelsSettings(settingsManager),
|
||||||
|
getProviderInstancesSettings(settingsManager),
|
||||||
|
]);
|
||||||
|
|
||||||
const productVariantCmsProviderInstances = productVariantCmsKeys.map((cmsKey) =>
|
const productVariantCmsProviderInstances = productVariantCmsKeys.map((cmsKey) =>
|
||||||
getCmsIdFromSaleorItemKey(cmsKey)
|
getCmsIdFromSaleorItemKey(cmsKey)
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
export { createCmsOperations } from "./clients-operations";
|
export { createCmsOperations } from "./clients-operations";
|
||||||
export { executeCmsOperations } from "./clients-execution";
|
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 { NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next";
|
||||||
|
import { Client } from "urql";
|
||||||
import {
|
import {
|
||||||
DeleteMetadataDocument,
|
DeleteMetadataDocument,
|
||||||
UpdateMetadataDocument,
|
UpdateMetadataDocument,
|
||||||
|
@ -9,7 +10,45 @@ import { createCmsKeyForSaleorItem } from "./metadata";
|
||||||
|
|
||||||
type WebhookContext = Parameters<NextWebhookApiHandler>["2"];
|
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,
|
context,
|
||||||
productVariant,
|
productVariant,
|
||||||
cmsProviderInstanceIdsToCreate,
|
cmsProviderInstanceIdsToCreate,
|
||||||
|
@ -23,27 +62,70 @@ export const executeMetadataUpdate = async ({
|
||||||
const { token, saleorApiUrl } = context.authData;
|
const { token, saleorApiUrl } = context.authData;
|
||||||
const apiClient = createClient(saleorApiUrl, async () => ({ token }));
|
const apiClient = createClient(saleorApiUrl, async () => ({ token }));
|
||||||
|
|
||||||
if (Object.keys(cmsProviderInstanceIdsToCreate).length) {
|
await executeMetadataUpdateMutation({
|
||||||
await apiClient
|
apiClient,
|
||||||
.mutation(UpdateMetadataDocument, {
|
itemId: productVariant.id,
|
||||||
id: productVariant.id,
|
cmsProviderInstanceIdsToCreate,
|
||||||
input: Object.entries(cmsProviderInstanceIdsToCreate).map(
|
cmsProviderInstanceIdsToDelete,
|
||||||
([cmsProviderInstanceId, cmsProductVariantId]) => ({
|
});
|
||||||
key: createCmsKeyForSaleorItem(cmsProviderInstanceId),
|
};
|
||||||
value: cmsProductVariantId,
|
|
||||||
})
|
type ItemMetadataRecord = {
|
||||||
),
|
id: string;
|
||||||
})
|
cmsProviderInstanceIds: MetadataRecord;
|
||||||
.toPromise();
|
};
|
||||||
}
|
|
||||||
if (Object.keys(cmsProviderInstanceIdsToDelete).length) {
|
export const batchUpdateMetadata = async ({
|
||||||
await apiClient
|
context,
|
||||||
.mutation(DeleteMetadataDocument, {
|
variantCMSProviderInstanceIdsToCreate,
|
||||||
id: productVariant.id,
|
variantCMSProviderInstanceIdsToDelete,
|
||||||
keys: Object.entries(cmsProviderInstanceIdsToDelete).map(([cmsProviderInstanceId]) =>
|
}: {
|
||||||
createCmsKeyForSaleorItem(cmsProviderInstanceId)
|
context: Pick<WebhookContext, "authData">;
|
||||||
),
|
variantCMSProviderInstanceIdsToCreate: ItemMetadataRecord[];
|
||||||
})
|
variantCMSProviderInstanceIdsToDelete: ItemMetadataRecord[];
|
||||||
.toPromise();
|
}) => {
|
||||||
}
|
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
|
export const channelSchema = z
|
||||||
.object({
|
.object({
|
||||||
enabledProviderInstances: z.array(z.string()),
|
enabledProviderInstances: z.array(z.string()),
|
||||||
|
requireSyncProviderInstances: z.array(z.string()).optional(),
|
||||||
})
|
})
|
||||||
.merge(channelCommonSchema);
|
.merge(channelCommonSchema);
|
||||||
|
|
||||||
|
|
|
@ -67,6 +67,12 @@ export const providersConfig = {
|
||||||
helpText:
|
helpText:
|
||||||
"Content management API URL of your Contentful project. If you leave this blank, default https://api.contentful.com will be used.",
|
"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: {
|
strapi: {
|
||||||
|
@ -152,6 +158,7 @@ export const contentfulConfigSchema = z.object({
|
||||||
locale: z.string().min(1),
|
locale: z.string().min(1),
|
||||||
contentId: z.string().min(1),
|
contentId: z.string().min(1),
|
||||||
baseUrl: z.string(),
|
baseUrl: z.string(),
|
||||||
|
apiRequestsPerSecond: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const datocmsConfigSchema = z.object({
|
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 { ContentfulConfig, contentfulConfigSchema } from "../config";
|
||||||
import { logger as pinoLogger } from "../../logger";
|
import { logger as pinoLogger } from "../../logger";
|
||||||
|
|
||||||
import { CreateOperations, CreateProductResponse, ProductInput } from "../types";
|
import { CreateOperations, ProductResponse, ProductInput } from "../types";
|
||||||
import { createProvider } from "./create";
|
import { createProvider } from "./create";
|
||||||
|
import { fetchWithRateLimit } from "../data-sync";
|
||||||
|
|
||||||
const contentfulFetch = (endpoint: string, config: ContentfulConfig, options?: RequestInit) => {
|
const contentfulFetch = (endpoint: string, config: ContentfulConfig, options?: RequestInit) => {
|
||||||
const baseUrl = config.baseUrl || "https://api.contentful.com";
|
const baseUrl = config.baseUrl || "https://api.contentful.com";
|
||||||
|
@ -30,6 +31,8 @@ type ContentfulResponse = {
|
||||||
id: string;
|
id: string;
|
||||||
version?: number;
|
version?: number;
|
||||||
};
|
};
|
||||||
|
statusCode: number;
|
||||||
|
input: ProductInput;
|
||||||
};
|
};
|
||||||
|
|
||||||
const transformInputToBody = ({
|
const transformInputToBody = ({
|
||||||
|
@ -64,7 +67,7 @@ const transformInputToBody = ({
|
||||||
return body;
|
return body;
|
||||||
};
|
};
|
||||||
|
|
||||||
const transformCreateProductResponse = (response: ContentfulResponse): CreateProductResponse => {
|
const transformCreateProductResponse = (response: ContentfulResponse): ProductResponse => {
|
||||||
if (response.message) {
|
if (response.message) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
|
@ -76,6 +79,7 @@ const transformCreateProductResponse = (response: ContentfulResponse): CreatePro
|
||||||
ok: true,
|
ok: true,
|
||||||
data: {
|
data: {
|
||||||
id: response.sys.id,
|
id: response.sys.id,
|
||||||
|
saleorId: response.input.saleorId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -93,66 +97,140 @@ const getEntryEndpoint = ({
|
||||||
const contentfulOperations: CreateOperations<ContentfulConfig> = (config) => {
|
const contentfulOperations: CreateOperations<ContentfulConfig> = (config) => {
|
||||||
const logger = pinoLogger.child({ cms: "strapi" });
|
const logger = pinoLogger.child({ cms: "strapi" });
|
||||||
|
|
||||||
const { environment, spaceId, contentId, locale } = config;
|
const { environment, spaceId, contentId, locale, apiRequestsPerSecond } = config;
|
||||||
|
|
||||||
|
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, locale });
|
||||||
|
const endpoint = getEntryEndpoint({
|
||||||
|
resourceId,
|
||||||
|
environment,
|
||||||
|
spaceId,
|
||||||
|
});
|
||||||
|
const response = await contentfulFetch(endpoint, config, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: {
|
||||||
|
"X-Contentful-Content-Type": contentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.debug({ response }, "createProduct response");
|
||||||
|
const json = await response.json();
|
||||||
|
return {
|
||||||
|
...json,
|
||||||
|
statusCode: response.status,
|
||||||
|
input,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateProductInCMS = async (id: string, input: ProductInput) => {
|
||||||
|
const body = transformInputToBody({ input, locale });
|
||||||
|
const endpoint = getEntryEndpoint({
|
||||||
|
resourceId: id,
|
||||||
|
environment,
|
||||||
|
spaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getEntryResponse = await contentfulFetch(endpoint, config, { method: "GET" });
|
||||||
|
logger.debug({ getEntryResponse }, "updateProduct getEntryResponse");
|
||||||
|
const entry = await getEntryResponse.json();
|
||||||
|
logger.debug({ entry }, "updateProduct entry");
|
||||||
|
|
||||||
|
const response = await contentfulFetch(endpoint, config, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: {
|
||||||
|
"X-Contentful-Version": entry.sys.version,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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 {
|
return {
|
||||||
createProduct: async (params) => {
|
createProduct: async ({ input }) => {
|
||||||
// Contentful API does not auto generate resource ID during creation, it has to be provided.
|
const result = await createProductInCMS(input);
|
||||||
const resourceId = uuidv4();
|
logger.debug({ result }, "createProduct result");
|
||||||
const body = transformInputToBody({ input: params.input, locale });
|
|
||||||
const endpoint = getEntryEndpoint({
|
|
||||||
resourceId,
|
|
||||||
environment,
|
|
||||||
spaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await contentfulFetch(endpoint, config, {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
headers: {
|
|
||||||
"X-Contentful-Content-Type": contentId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
logger.debug("createProduct response", { response });
|
|
||||||
const result = await response.json();
|
|
||||||
logger.debug("createProduct result", { result });
|
|
||||||
|
|
||||||
return transformCreateProductResponse(result);
|
return transformCreateProductResponse(result);
|
||||||
},
|
},
|
||||||
updateProduct: async ({ id, input }) => {
|
updateProduct: async ({ id, input }) => {
|
||||||
const body = transformInputToBody({ input, locale });
|
const result = await updateProductInCMS(id, input);
|
||||||
const endpoint = getEntryEndpoint({
|
logger.debug({ result }, "updateProduct result");
|
||||||
resourceId: id,
|
|
||||||
environment,
|
|
||||||
spaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const getEntryResponse = await contentfulFetch(endpoint, config, { method: "GET" });
|
|
||||||
logger.debug("updateProduct getEntryResponse", { getEntryResponse });
|
|
||||||
const entry = await getEntryResponse.json();
|
|
||||||
logger.debug("updateProduct entry", { entry });
|
|
||||||
|
|
||||||
const response = await contentfulFetch(endpoint, config, {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
headers: {
|
|
||||||
"X-Contentful-Version": entry.sys.version,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
logger.debug("updateProduct response", { response });
|
|
||||||
const result = await response.json();
|
|
||||||
logger.debug("updateProduct result", { result });
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
deleteProduct: async ({ id }) => {
|
deleteProduct: async ({ id }) => {
|
||||||
const endpoint = getEntryEndpoint({ resourceId: id, environment, spaceId });
|
const response = await deleteProductInCMS(id);
|
||||||
|
logger.debug({ response }, "deleteProduct response");
|
||||||
const response = await contentfulFetch(endpoint, config, { method: "DELETE" });
|
|
||||||
logger.debug("deleteProduct response", { response });
|
|
||||||
|
|
||||||
return 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 { createProvider } from "./create";
|
||||||
import { CreateOperations, CreateProductResponse } from "../types";
|
import { CreateOperations, ProductInput, ProductResponse } from "../types";
|
||||||
import { logger as pinoLogger } from "../../logger";
|
import { logger as pinoLogger } from "../../logger";
|
||||||
|
|
||||||
import { ApiError, buildClient, SimpleSchemaTypes } from "@datocms/cma-client-node";
|
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) {
|
if (error instanceof ApiError) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
data: {
|
data: {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
|
saleorId: input.saleorId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -44,48 +48,76 @@ const transformResponseItem = (item: SimpleSchemaTypes.Item): CreateProductRespo
|
||||||
const datocmsOperations: CreateOperations<DatocmsConfig> = (config) => {
|
const datocmsOperations: CreateOperations<DatocmsConfig> = (config) => {
|
||||||
const logger = pinoLogger.child({ cms: "strapi" });
|
const logger = pinoLogger.child({ cms: "strapi" });
|
||||||
|
|
||||||
|
const client = datocmsClient(config);
|
||||||
|
|
||||||
|
const createProductInCMS = async (input: ProductInput) =>
|
||||||
|
client.items.create({
|
||||||
|
item_type: {
|
||||||
|
id: config.itemTypeId,
|
||||||
|
type: "item_type",
|
||||||
|
},
|
||||||
|
saleor_id: input.saleorId,
|
||||||
|
name: input.name,
|
||||||
|
channels: JSON.stringify(input.channels),
|
||||||
|
product_id: input.productId,
|
||||||
|
product_name: input.productName,
|
||||||
|
product_slug: input.productSlug,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateProductInCMS = async (id: string, input: ProductInput) =>
|
||||||
|
client.items.update(id, {
|
||||||
|
saleor_id: input.saleorId,
|
||||||
|
name: input.name,
|
||||||
|
channels: JSON.stringify(input.channels),
|
||||||
|
product_id: input.productId,
|
||||||
|
product_name: input.productName,
|
||||||
|
product_slug: input.productSlug,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
createProduct: async ({ input }) => {
|
createProduct: async ({ input }) => {
|
||||||
const client = datocmsClient(config);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const item = await client.items.create({
|
const item = await createProductInCMS(input);
|
||||||
item_type: {
|
logger.debug({ item }, "createProduct response");
|
||||||
id: config.itemTypeId,
|
|
||||||
type: "item_type",
|
|
||||||
},
|
|
||||||
saleor_id: input.saleorId,
|
|
||||||
name: input.name,
|
|
||||||
channels: JSON.stringify(input.channels),
|
|
||||||
product_id: input.productId,
|
|
||||||
product_name: input.productName,
|
|
||||||
product_slug: input.productSlug,
|
|
||||||
});
|
|
||||||
logger.debug("createProduct response", { item });
|
|
||||||
|
|
||||||
return transformResponseItem(item);
|
return transformResponseItem(item, input);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return transformResponseError(error);
|
return transformResponseError(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateProduct: async ({ id, input }) => {
|
updateProduct: async ({ id, input }) => {
|
||||||
const client = datocmsClient(config);
|
const item = await updateProductInCMS(id, input);
|
||||||
|
logger.debug({ item }, "updateProduct response");
|
||||||
const item = await client.items.update(id, {
|
|
||||||
saleor_id: input.saleorId,
|
|
||||||
name: input.name,
|
|
||||||
channels: JSON.stringify(input.channels),
|
|
||||||
product_id: input.productId,
|
|
||||||
product_name: input.productName,
|
|
||||||
product_slug: input.productSlug,
|
|
||||||
});
|
|
||||||
logger.debug("updateProduct response", { item });
|
|
||||||
},
|
},
|
||||||
deleteProduct: async ({ id }) => {
|
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);
|
return items.map((item) => transformResponseItem(item.id, item.input));
|
||||||
logger.debug("deleteProduct response", { item });
|
},
|
||||||
|
deleteBatchProducts: async ({ ids }) => {
|
||||||
|
const items = await deleteBatchProductsInCMS(ids);
|
||||||
|
logger.debug({ items }, "deleteBatchProducts response");
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { StrapiConfig, strapiConfigSchema } from "../config";
|
import { StrapiConfig, strapiConfigSchema } from "../config";
|
||||||
import { CmsOperations, CreateOperations, CreateProductResponse, ProductInput } from "../types";
|
import { CreateOperations, ProductResponse, ProductInput } from "../types";
|
||||||
import { createProvider } from "./create";
|
import { createProvider } from "./create";
|
||||||
import { logger as pinoLogger } from "../../logger";
|
import { logger as pinoLogger } from "../../logger";
|
||||||
|
|
||||||
|
@ -20,7 +20,7 @@ type StrapiBody = {
|
||||||
data: Record<string, any> & { saleor_id: string };
|
data: Record<string, any> & { saleor_id: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
const transformInputToBody = ({ input }: { input: ProductInput }): StrapiBody => {
|
const transformInputToBody = (input: ProductInput): StrapiBody => {
|
||||||
const body = {
|
const body = {
|
||||||
data: {
|
data: {
|
||||||
saleor_id: input.saleorId,
|
saleor_id: input.saleorId,
|
||||||
|
@ -55,7 +55,10 @@ type StrapiResponse =
|
||||||
error: null;
|
error: null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const transformCreateProductResponse = (response: StrapiResponse): CreateProductResponse => {
|
const transformCreateProductResponse = (
|
||||||
|
response: StrapiResponse,
|
||||||
|
input: ProductInput
|
||||||
|
): ProductResponse => {
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
|
@ -67,47 +70,86 @@ const transformCreateProductResponse = (response: StrapiResponse): CreateProduct
|
||||||
ok: true,
|
ok: true,
|
||||||
data: {
|
data: {
|
||||||
id: response.data.id,
|
id: response.data.id,
|
||||||
|
saleorId: input.saleorId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type CreateStrapiOperations = CreateOperations<StrapiConfig>;
|
type CreateStrapiOperations = CreateOperations<StrapiConfig>;
|
||||||
|
|
||||||
export const strapiOperations: CreateStrapiOperations = (config): CmsOperations => {
|
export const strapiOperations: CreateStrapiOperations = (config) => {
|
||||||
const logger = pinoLogger.child({ cms: "strapi" });
|
const logger = pinoLogger.child({ cms: "strapi" });
|
||||||
|
|
||||||
const { contentTypeId } = config;
|
const { contentTypeId } = config;
|
||||||
|
|
||||||
|
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({ response }, "createProduct response");
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateProductInCMS = async (id: string, input: ProductInput) => {
|
||||||
|
const body = transformInputToBody(input);
|
||||||
|
return await strapiFetch(`/${contentTypeId}/${id}`, config, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
createProduct: async (params) => {
|
createProduct: async ({ input }) => {
|
||||||
const body = transformInputToBody(params);
|
const result = await createProductInCMS(input);
|
||||||
const response = await strapiFetch(`/${contentTypeId}`, config, {
|
logger.debug({ result }, "createProduct result");
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
logger.debug("createProduct response", { response });
|
|
||||||
|
|
||||||
const result = await response.json();
|
return transformCreateProductResponse(result, input);
|
||||||
logger.debug("createProduct result", { result });
|
|
||||||
|
|
||||||
return transformCreateProductResponse(result);
|
|
||||||
},
|
},
|
||||||
updateProduct: async ({ id, input }) => {
|
updateProduct: async ({ id, input }) => {
|
||||||
const body = transformInputToBody({ input });
|
const response = await updateProductInCMS(id, input);
|
||||||
const response = await strapiFetch(`/${contentTypeId}/${id}`, config, {
|
logger.debug({ response }, "updateProduct response");
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
logger.debug("updateProduct response", { response });
|
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
deleteProduct: async ({ id }) => {
|
deleteProduct: async ({ id }) => {
|
||||||
const response = await strapiFetch(`/${contentTypeId}/${id}`, config, { method: "DELETE" });
|
const response = await deleteProductInCMS(id);
|
||||||
logger.debug("deleteProduct response", { response });
|
logger.debug({ response }, "deleteProduct response");
|
||||||
|
|
||||||
return 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;
|
image?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateProductResponse =
|
export type ProductResponseSuccess = { ok: true; data: { id: string; saleorId: string } };
|
||||||
| { ok: true; data: { id: string } }
|
export type ProductResponseError = { ok: false; error: string };
|
||||||
| { ok: false; error: string };
|
export type ProductResponse = ProductResponseSuccess | ProductResponseError;
|
||||||
|
|
||||||
export type CmsOperations = {
|
export type CmsOperations = {
|
||||||
getAllProducts?: () => Promise<Response>;
|
|
||||||
getProduct?: ({ id }: { id: string }) => 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>;
|
updateProduct: ({ id, input }: { id: string; input: ProductInput }) => Promise<Response | void>;
|
||||||
deleteProduct: ({ id }: { id: string }) => 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 = {
|
export type CmsClientOperations = {
|
||||||
cmsProviderInstanceId: string;
|
cmsProviderInstanceId: string;
|
||||||
operations: CmsOperations;
|
operations: CmsOperations;
|
||||||
operationType: keyof CmsOperations;
|
operationType: keyof CmsOperations;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CmsClientBatchOperations = {
|
||||||
|
cmsProviderInstanceId: string;
|
||||||
|
operations: CmsBatchOperations;
|
||||||
|
operationType: keyof CmsBatchOperations;
|
||||||
|
};
|
||||||
|
|
||||||
export type GetProviderTokens<TProviderName extends keyof typeof providersConfig> =
|
export type GetProviderTokens<TProviderName extends keyof typeof providersConfig> =
|
||||||
(typeof providersConfig)[TProviderName]["tokens"][number];
|
(typeof providersConfig)[TProviderName]["tokens"][number];
|
||||||
|
|
||||||
|
@ -43,7 +54,9 @@ export type CreateProviderConfig<TProviderName extends keyof typeof providersCon
|
||||||
> &
|
> &
|
||||||
BaseConfig;
|
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> = {
|
export type Provider<TConfig extends BaseConfig> = {
|
||||||
create: CreateOperations<TConfig>;
|
create: CreateOperations<TConfig>;
|
||||||
|
|
|
@ -10,39 +10,41 @@ interface IAuthState {
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getExchanges = (getAuth: AuthConfig<IAuthState>["getAuth"]) => [
|
||||||
|
dedupExchange,
|
||||||
|
cacheExchange,
|
||||||
|
authExchange<IAuthState>({
|
||||||
|
addAuthToOperation: ({ authState, operation }) => {
|
||||||
|
if (!authState || !authState?.token) {
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchOptions =
|
||||||
|
typeof operation.context.fetchOptions === "function"
|
||||||
|
? operation.context.fetchOptions()
|
||||||
|
: operation.context.fetchOptions || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...operation,
|
||||||
|
context: {
|
||||||
|
...operation.context,
|
||||||
|
fetchOptions: {
|
||||||
|
...fetchOptions,
|
||||||
|
headers: {
|
||||||
|
...fetchOptions.headers,
|
||||||
|
"Authorization-Bearer": authState.token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getAuth,
|
||||||
|
}),
|
||||||
|
fetchExchange,
|
||||||
|
];
|
||||||
|
|
||||||
export const createClient = (url: string, getAuth: AuthConfig<IAuthState>["getAuth"]) =>
|
export const createClient = (url: string, getAuth: AuthConfig<IAuthState>["getAuth"]) =>
|
||||||
urqlCreateClient({
|
urqlCreateClient({
|
||||||
url,
|
url,
|
||||||
exchanges: [
|
exchanges: getExchanges(getAuth),
|
||||||
dedupExchange,
|
|
||||||
cacheExchange,
|
|
||||||
authExchange<IAuthState>({
|
|
||||||
addAuthToOperation: ({ authState, operation }) => {
|
|
||||||
if (!authState || !authState?.token) {
|
|
||||||
return operation;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchOptions =
|
|
||||||
typeof operation.context.fetchOptions === "function"
|
|
||||||
? operation.context.fetchOptions()
|
|
||||||
: operation.context.fetchOptions || {};
|
|
||||||
|
|
||||||
return {
|
|
||||||
...operation,
|
|
||||||
context: {
|
|
||||||
...operation.context,
|
|
||||||
fetchOptions: {
|
|
||||||
...fetchOptions,
|
|
||||||
headers: {
|
|
||||||
...fetchOptions.headers,
|
|
||||||
"Authorization-Bearer": authState.token,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
getAuth,
|
|
||||||
}),
|
|
||||||
fetchExchange,
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -9,6 +9,9 @@ import {
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemCell,
|
ListItemCell,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
|
Notification,
|
||||||
|
Alert,
|
||||||
|
IconButton,
|
||||||
} from "@saleor/macaw-ui";
|
} from "@saleor/macaw-ui";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
@ -20,19 +23,30 @@ import {
|
||||||
SingleProviderSchema,
|
SingleProviderSchema,
|
||||||
} from "../../../lib/cms/config";
|
} from "../../../lib/cms/config";
|
||||||
import { ProviderIcon } from "../../provider-instances/ui/provider-icon";
|
import { ProviderIcon } from "../../provider-instances/ui/provider-icon";
|
||||||
|
import { ChannelsLoading } from "./types";
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => {
|
const useStyles = makeStyles((theme) => {
|
||||||
return {
|
return {
|
||||||
item: {
|
item: {
|
||||||
height: "auto !important",
|
height: "auto !important",
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "1fr 80px",
|
gridTemplateColumns: "1fr 80px 80px",
|
||||||
},
|
},
|
||||||
itemCell: {
|
itemCell: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing(2),
|
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: {
|
footer: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
|
@ -48,8 +62,9 @@ const useStyles = makeStyles((theme) => {
|
||||||
interface ChannelConfigurationFormProps {
|
interface ChannelConfigurationFormProps {
|
||||||
channel?: MergedChannelSchema | null;
|
channel?: MergedChannelSchema | null;
|
||||||
providerInstances: SingleProviderSchema[];
|
providerInstances: SingleProviderSchema[];
|
||||||
loading: boolean;
|
loading: ChannelsLoading;
|
||||||
onSubmit: (channel: SingleChannelSchema) => any;
|
onSubmit: (channel: SingleChannelSchema) => any;
|
||||||
|
onSync: (providerInstanceId: string) => any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChannelConfigurationForm = ({
|
export const ChannelConfigurationForm = ({
|
||||||
|
@ -57,6 +72,7 @@ export const ChannelConfigurationForm = ({
|
||||||
providerInstances,
|
providerInstances,
|
||||||
loading,
|
loading,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
onSync,
|
||||||
}: ChannelConfigurationFormProps) => {
|
}: ChannelConfigurationFormProps) => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
|
|
||||||
|
@ -85,6 +101,9 @@ export const ChannelConfigurationForm = ({
|
||||||
resetField("enabledProviderInstances", {
|
resetField("enabledProviderInstances", {
|
||||||
defaultValue: channel?.enabledProviderInstances || [],
|
defaultValue: channel?.enabledProviderInstances || [],
|
||||||
});
|
});
|
||||||
|
resetField("requireSyncProviderInstances", {
|
||||||
|
defaultValue: channel?.requireSyncProviderInstances || [],
|
||||||
|
});
|
||||||
}, [channel, providerInstances]);
|
}, [channel, providerInstances]);
|
||||||
|
|
||||||
const errors = formState.errors;
|
const errors = formState.errors;
|
||||||
|
@ -97,56 +116,92 @@ export const ChannelConfigurationForm = ({
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<input type="hidden" {...register("channelSlug")} value={channel?.channelSlug} />
|
<input type="hidden" {...register("channelSlug")} value={channel?.channelSlug} />
|
||||||
<List gridTemplate={["1fr", "checkbox"]}>
|
|
||||||
|
<List gridTemplate={["1fr", "80px", "checkbox"]}>
|
||||||
<ListHeader>
|
<ListHeader>
|
||||||
<ListItem className={styles.item}>
|
<ListItem className={styles.item}>
|
||||||
<ListItemCell>CMS provider configuration</ListItemCell>
|
<ListItemCell>CMS provider configuration</ListItemCell>
|
||||||
<ListItemCell>Active</ListItemCell>
|
<ListItemCell className={styles.itemCellCenter}>Active</ListItemCell>
|
||||||
|
<ListItemCell className={styles.itemCellCenter}>Sync</ListItemCell>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListHeader>
|
</ListHeader>
|
||||||
<ListBody>
|
<ListBody>
|
||||||
{providerInstances.map((providerInstance) => (
|
{providerInstances.map((providerInstance) => {
|
||||||
<ListItem key={providerInstance.name} className={styles.item}>
|
const enabledProviderInstances = watch("enabledProviderInstances");
|
||||||
<ListItemCell className={styles.itemCell}>
|
const requireSyncProviderInstances = watch("requireSyncProviderInstances");
|
||||||
<ProviderIcon providerName={providerInstance.providerName} />
|
const isEnabled = enabledProviderInstances?.some(
|
||||||
{providerInstance.name}
|
(formOption) => formOption === providerInstance.id
|
||||||
</ListItemCell>
|
);
|
||||||
<ListItemCell padding="checkbox">
|
const requireSync = requireSyncProviderInstances?.some(
|
||||||
<FormControl
|
(formOption) => formOption === providerInstance.id
|
||||||
{...register("enabledProviderInstances")}
|
);
|
||||||
name="enabledProviderInstances"
|
|
||||||
checked={watch("enabledProviderInstances")?.some(
|
return (
|
||||||
(formOption) => formOption === providerInstance.id
|
<ListItem key={providerInstance.name} className={styles.item}>
|
||||||
)}
|
<ListItemCell className={styles.itemCell}>
|
||||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
<ProviderIcon providerName={providerInstance.providerName} />
|
||||||
const valueCopy = getValues("enabledProviderInstances")
|
{providerInstance.name}
|
||||||
? [...getValues("enabledProviderInstances")]
|
</ListItemCell>
|
||||||
: [];
|
<ListItemCell padding="checkbox" className={styles.itemCellCenter}>
|
||||||
if (event.target.checked) {
|
<FormControl
|
||||||
valueCopy.push(providerInstance.id);
|
{...register("enabledProviderInstances")}
|
||||||
} else {
|
name="enabledProviderInstances"
|
||||||
const idx = valueCopy.findIndex(
|
checked={isEnabled}
|
||||||
(formOption) => formOption === providerInstance.id
|
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
);
|
const valueCopy = getValues("enabledProviderInstances")
|
||||||
valueCopy.splice(idx, 1);
|
? [...getValues("enabledProviderInstances")]
|
||||||
|
: [];
|
||||||
|
if (event.target.checked) {
|
||||||
|
valueCopy.push(providerInstance.id);
|
||||||
|
} else {
|
||||||
|
const idx = valueCopy.findIndex(
|
||||||
|
(formOption) => formOption === providerInstance.id
|
||||||
|
);
|
||||||
|
valueCopy.splice(idx, 1);
|
||||||
|
}
|
||||||
|
resetField("enabledProviderInstances", {
|
||||||
|
defaultValue: valueCopy,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
value={providerInstance.name}
|
||||||
|
component={(props) => <Checkbox {...props} />}
|
||||||
|
/>
|
||||||
|
</ListItemCell>
|
||||||
|
<ListItemCell className={styles.itemCellCenter}>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={
|
||||||
|
!requireSync || !!loading.productsVariantsSync.syncingProviderInstanceId
|
||||||
}
|
}
|
||||||
resetField("enabledProviderInstances", {
|
onClick={() => onSync(providerInstance.id)}
|
||||||
defaultValue: valueCopy,
|
>
|
||||||
});
|
Sync
|
||||||
}}
|
</Button>
|
||||||
value={providerInstance.name}
|
</ListItemCell>
|
||||||
component={(props) => <Checkbox {...props} />}
|
{loading.productsVariantsSync.syncingProviderInstanceId === providerInstance.id && (
|
||||||
/>
|
<ListItemCell className={styles.itemCellProgress}>
|
||||||
</ListItemCell>
|
Syncing products...
|
||||||
</ListItem>
|
<progress
|
||||||
))}
|
value={loading.productsVariantsSync.currentProductIndex}
|
||||||
|
max={loading.productsVariantsSync.totalProductsCount}
|
||||||
|
style={{
|
||||||
|
height: "30px",
|
||||||
|
width: "500px",
|
||||||
|
maxWidth: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItemCell>
|
||||||
|
)}
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{/* </>
|
{/* </>
|
||||||
)}
|
)}
|
||||||
/> */}
|
/> */}
|
||||||
</ListBody>
|
</ListBody>
|
||||||
<ListFooter className={styles.footer}>
|
<ListFooter className={styles.footer}>
|
||||||
<Button variant="primary" disabled={loading} type="submit">
|
<Button variant="primary" disabled={loading.channels.saving} type="submit">
|
||||||
{loading ? "..." : "Save"}
|
{loading.channels.saving ? "..." : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
</ListFooter>
|
</ListFooter>
|
||||||
</List>
|
</List>
|
||||||
|
|
|
@ -7,9 +7,10 @@ import {
|
||||||
SingleChannelSchema,
|
SingleChannelSchema,
|
||||||
SingleProviderSchema,
|
SingleProviderSchema,
|
||||||
} from "../../../lib/cms/config";
|
} from "../../../lib/cms/config";
|
||||||
import { ChannelsErrors, ChannelsLoading } from "./types";
|
import { ChannelsLoading } from "./types";
|
||||||
import { makeStyles } from "@saleor/macaw-ui";
|
import { makeStyles } from "@saleor/macaw-ui";
|
||||||
import { AppTabNavButton } from "../../ui/app-tab-nav-button";
|
import { AppTabNavButton } from "../../ui/app-tab-nav-button";
|
||||||
|
import { ChannelsDataErrors } from "./hooks/useChannels";
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => ({
|
const useStyles = makeStyles((theme) => ({
|
||||||
textCenter: {
|
textCenter: {
|
||||||
|
@ -55,20 +56,22 @@ interface ChannelConfigurationProps {
|
||||||
activeChannel?: MergedChannelSchema | null;
|
activeChannel?: MergedChannelSchema | null;
|
||||||
providerInstances: SingleProviderSchema[];
|
providerInstances: SingleProviderSchema[];
|
||||||
saveChannel: (channel: SingleChannelSchema) => any;
|
saveChannel: (channel: SingleChannelSchema) => any;
|
||||||
|
syncChannelProviderInstance: (providerInstanceId: string) => any;
|
||||||
loading: ChannelsLoading;
|
loading: ChannelsLoading;
|
||||||
errors: ChannelsErrors;
|
errors: ChannelsDataErrors;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChannelConfiguration = ({
|
export const ChannelConfiguration = ({
|
||||||
activeChannel,
|
activeChannel,
|
||||||
providerInstances,
|
providerInstances,
|
||||||
saveChannel,
|
saveChannel,
|
||||||
|
syncChannelProviderInstance,
|
||||||
loading,
|
loading,
|
||||||
errors,
|
errors,
|
||||||
}: ChannelConfigurationProps) => {
|
}: ChannelConfigurationProps) => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
|
|
||||||
if (loading.fetching || loading.saving) {
|
if (loading.channels.fetching || loading.channels.saving) {
|
||||||
return <ChannelConfigurationSkeleton />;
|
return <ChannelConfigurationSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -110,8 +113,9 @@ export const ChannelConfiguration = ({
|
||||||
<ChannelConfigurationForm
|
<ChannelConfigurationForm
|
||||||
channel={activeChannel}
|
channel={activeChannel}
|
||||||
providerInstances={providerInstances}
|
providerInstances={providerInstances}
|
||||||
loading={loading.saving}
|
loading={loading}
|
||||||
onSubmit={saveChannel}
|
onSubmit={saveChannel}
|
||||||
|
onSync={syncChannelProviderInstance}
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|
|
@ -2,8 +2,9 @@ import { Skeleton } from "@material-ui/lab";
|
||||||
import { MergedChannelSchema } from "../../../lib/cms";
|
import { MergedChannelSchema } from "../../../lib/cms";
|
||||||
import { AppPaper } from "../../ui/app-paper";
|
import { AppPaper } from "../../ui/app-paper";
|
||||||
|
|
||||||
import { ChannelsErrors, ChannelsLoading } from "./types";
|
import { ChannelsLoading } from "./types";
|
||||||
import { ChannelsSelect } from "./channels-select";
|
import { ChannelsSelect } from "./channels-select";
|
||||||
|
import { ChannelsDataErrors } from "./hooks/useChannels";
|
||||||
|
|
||||||
const ChannelsListSkeleton = () => {
|
const ChannelsListSkeleton = () => {
|
||||||
return (
|
return (
|
||||||
|
@ -18,7 +19,7 @@ interface ChannelsListProps {
|
||||||
activeChannel?: MergedChannelSchema | null;
|
activeChannel?: MergedChannelSchema | null;
|
||||||
setActiveChannel: (channel: MergedChannelSchema | null) => void;
|
setActiveChannel: (channel: MergedChannelSchema | null) => void;
|
||||||
loading: ChannelsLoading;
|
loading: ChannelsLoading;
|
||||||
errors: ChannelsErrors;
|
errors: ChannelsDataErrors;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChannelsList = ({
|
export const ChannelsList = ({
|
||||||
|
@ -28,7 +29,7 @@ export const ChannelsList = ({
|
||||||
loading,
|
loading,
|
||||||
errors,
|
errors,
|
||||||
}: ChannelsListProps) => {
|
}: ChannelsListProps) => {
|
||||||
if (loading.fetching) {
|
if (loading.channels.fetching) {
|
||||||
return <ChannelsListSkeleton />;
|
return <ChannelsListSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,16 @@
|
||||||
|
import { makeStyles } from "@saleor/macaw-ui";
|
||||||
import { useEffect, useState } from "react";
|
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 { useProviderInstances } from "../../provider-instances/ui/hooks/useProviderInstances";
|
||||||
|
import { AppTabs } from "../../ui/app-tabs";
|
||||||
import { ChannelConfiguration } from "./channel-configuration";
|
import { ChannelConfiguration } from "./channel-configuration";
|
||||||
import { ChannelsList } from "./channels-list";
|
import { ChannelsList } from "./channels-list";
|
||||||
import { useChannels } from "./hooks/useChannels";
|
import { useChannels } from "./hooks/useChannels";
|
||||||
import { AppTabs } from "../../ui/app-tabs";
|
import { ChannelsLoading } from "./types";
|
||||||
import { makeStyles } from "@saleor/macaw-ui";
|
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
wrapper: {
|
wrapper: {
|
||||||
|
@ -17,7 +22,7 @@ const useStyles = makeStyles({
|
||||||
|
|
||||||
export const Channels = () => {
|
export const Channels = () => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const { channels, saveChannel, loading, errors } = useChannels();
|
const { channels, saveChannel, loading: loadingChannels, errors } = useChannels();
|
||||||
const { providerInstances } = useProviderInstances();
|
const { providerInstances } = useProviderInstances();
|
||||||
|
|
||||||
const [activeChannelSlug, setActiveChannelSlug] = useState<string | null>(
|
const [activeChannelSlug, setActiveChannelSlug] = useState<string | null>(
|
||||||
|
@ -36,6 +41,40 @@ export const Channels = () => {
|
||||||
}
|
}
|
||||||
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppTabs activeTab="channels" />
|
<AppTabs activeTab="channels" />
|
||||||
|
@ -52,6 +91,7 @@ export const Channels = () => {
|
||||||
activeChannel={activeChannel}
|
activeChannel={activeChannel}
|
||||||
providerInstances={providerInstances}
|
providerInstances={providerInstances}
|
||||||
saveChannel={saveChannel}
|
saveChannel={saveChannel}
|
||||||
|
syncChannelProviderInstance={handleSync}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,10 +1,19 @@
|
||||||
import { useChannelsFetch } from "./useChannelsFetch";
|
import { useChannelsFetch } from "./useChannelsFetch";
|
||||||
import { MergedChannelSchema, SingleChannelSchema } from "../../../../lib/cms/config";
|
import { MergedChannelSchema, SingleChannelSchema } from "../../../../lib/cms/config";
|
||||||
import { ChannelsErrors, ChannelsLoading } from "../types";
|
|
||||||
import { useChannelsQuery } from "../../../../../generated/graphql";
|
import { useChannelsQuery } from "../../../../../generated/graphql";
|
||||||
import { useIsMounted } from "usehooks-ts";
|
import { useIsMounted } from "usehooks-ts";
|
||||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
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 = () => {
|
export const useChannels = () => {
|
||||||
const { appBridge } = useAppBridge();
|
const { appBridge } = useAppBridge();
|
||||||
const isMounted = useIsMounted();
|
const isMounted = useIsMounted();
|
||||||
|
@ -19,10 +28,33 @@ export const useChannels = () => {
|
||||||
isFetching,
|
isFetching,
|
||||||
} = useChannelsFetch();
|
} = useChannelsFetch();
|
||||||
|
|
||||||
const saveChannel = (channelToSave: SingleChannelSchema) => {
|
const saveChannel = async (channelToSave: SingleChannelSchema) => {
|
||||||
console.log("saveChannel", channelToSave);
|
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(
|
appBridge?.dispatch(
|
||||||
actions.Notification({
|
actions.Notification({
|
||||||
title: "Success",
|
title: "Success",
|
||||||
|
@ -30,15 +62,23 @@ export const useChannels = () => {
|
||||||
text: "Configuration saved",
|
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,
|
fetching: isFetching || channelsQueryData.fetching,
|
||||||
saving: isSaving,
|
saving: isSaving,
|
||||||
};
|
};
|
||||||
|
|
||||||
const errors: ChannelsErrors = {
|
const errors: ChannelsDataErrors = {
|
||||||
fetching: fetchingError ? Error(fetchingError) : null,
|
fetching: fetchingError ? Error(fetchingError) : null,
|
||||||
saving: null,
|
saving: null,
|
||||||
};
|
};
|
||||||
|
@ -51,6 +91,9 @@ export const useChannels = () => {
|
||||||
enabledProviderInstances: settings
|
enabledProviderInstances: settings
|
||||||
? settings[`${channel.slug}`]?.enabledProviderInstances
|
? settings[`${channel.slug}`]?.enabledProviderInstances
|
||||||
: [],
|
: [],
|
||||||
|
requireSyncProviderInstances: settings
|
||||||
|
? settings[`${channel.slug}`]?.requireSyncProviderInstances
|
||||||
|
: [],
|
||||||
channel: channel,
|
channel: channel,
|
||||||
} as MergedChannelSchema)
|
} as MergedChannelSchema)
|
||||||
) || [];
|
) || [];
|
||||||
|
|
|
@ -64,9 +64,19 @@ export const useChannelsFetch = () => {
|
||||||
console.log("saveSettings config", config);
|
console.log("saveSettings config", config);
|
||||||
|
|
||||||
setConfig(config);
|
setConfig(config);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
export interface ChannelsLoading {
|
import { ProductsVariantsSyncLoading } from "../../cms/hooks/useProductsVariantsSync";
|
||||||
fetching: boolean;
|
import { ChannelsDataLoading } from "./hooks/useChannels";
|
||||||
saving: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChannelsErrors {
|
export interface ChannelsLoading {
|
||||||
fetching?: Error | null;
|
channels: ChannelsDataLoading;
|
||||||
saving?: Error | null;
|
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 { saleorApp } from "../../../../saleor-app";
|
||||||
import { getCmsKeysFromSaleorItem } from "../../../lib/cms/client/metadata";
|
import { getCmsKeysFromSaleorItem } from "../../../lib/cms/client/metadata";
|
||||||
import { getChannelsSlugsFromSaleorItem } from "../../../lib/cms/client/channels";
|
import { getChannelsSlugsFromSaleorItem } from "../../../lib/cms/client/channels";
|
||||||
import {
|
import { createCmsOperations, executeCmsOperations, updateMetadata } from "../../../lib/cms/client";
|
||||||
createCmsOperations,
|
|
||||||
executeCmsOperations,
|
|
||||||
executeMetadataUpdate,
|
|
||||||
} from "../../../lib/cms/client";
|
|
||||||
import { logger as pinoLogger } from "../../../lib/logger";
|
import { logger as pinoLogger } from "../../../lib/logger";
|
||||||
import { createClient } from "../../../lib/graphql";
|
import { createClient } from "../../../lib/graphql";
|
||||||
import { fetchProductVariantMetadata } from "../../../lib/metadata";
|
import { fetchProductVariantMetadata } from "../../../lib/metadata";
|
||||||
|
@ -109,7 +105,7 @@ export const handler: NextWebhookApiHandler<ProductUpdatedWebhookPayloadFragment
|
||||||
|
|
||||||
allCMSErrors.push(...cmsErrors);
|
allCMSErrors.push(...cmsErrors);
|
||||||
|
|
||||||
await executeMetadataUpdate({
|
await updateMetadata({
|
||||||
context,
|
context,
|
||||||
productVariant,
|
productVariant,
|
||||||
cmsProviderInstanceIdsToCreate: cmsProviderInstanceProductVariantIdsToCreate,
|
cmsProviderInstanceIdsToCreate: cmsProviderInstanceProductVariantIdsToCreate,
|
||||||
|
@ -120,7 +116,8 @@ export const handler: NextWebhookApiHandler<ProductUpdatedWebhookPayloadFragment
|
||||||
if (!allCMSErrors.length) {
|
if (!allCMSErrors.length) {
|
||||||
return res.status(200).end();
|
return res.status(200).end();
|
||||||
} else {
|
} 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";
|
} from "../../../../generated/graphql";
|
||||||
import { saleorApp } from "../../../../saleor-app";
|
import { saleorApp } from "../../../../saleor-app";
|
||||||
import { getChannelsSlugsFromSaleorItem } from "../../../lib/cms/client/channels";
|
import { getChannelsSlugsFromSaleorItem } from "../../../lib/cms/client/channels";
|
||||||
import {
|
import { createCmsOperations, executeCmsOperations, updateMetadata } from "../../../lib/cms/client";
|
||||||
createCmsOperations,
|
|
||||||
executeCmsOperations,
|
|
||||||
executeMetadataUpdate,
|
|
||||||
} from "../../../lib/cms/client";
|
|
||||||
import { logger as pinoLogger } from "../../../lib/logger";
|
import { logger as pinoLogger } from "../../../lib/logger";
|
||||||
import { createClient } from "../../../lib/graphql";
|
import { createClient } from "../../../lib/graphql";
|
||||||
import { fetchProductVariantMetadata } from "../../../lib/metadata";
|
import { fetchProductVariantMetadata } from "../../../lib/metadata";
|
||||||
|
@ -93,7 +89,7 @@ export const handler: NextWebhookApiHandler<ProductVariantCreatedWebhookPayloadF
|
||||||
productVariant,
|
productVariant,
|
||||||
});
|
});
|
||||||
|
|
||||||
await executeMetadataUpdate({
|
await updateMetadata({
|
||||||
context,
|
context,
|
||||||
productVariant,
|
productVariant,
|
||||||
cmsProviderInstanceIdsToCreate: cmsProviderInstanceProductVariantIdsToCreate,
|
cmsProviderInstanceIdsToCreate: cmsProviderInstanceProductVariantIdsToCreate,
|
||||||
|
@ -103,7 +99,8 @@ export const handler: NextWebhookApiHandler<ProductVariantCreatedWebhookPayloadF
|
||||||
if (!cmsErrors.length) {
|
if (!cmsErrors.length) {
|
||||||
return res.status(200).end();
|
return res.status(200).end();
|
||||||
} else {
|
} 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";
|
} from "../../../../generated/graphql";
|
||||||
import { saleorApp } from "../../../../saleor-app";
|
import { saleorApp } from "../../../../saleor-app";
|
||||||
import { getCmsKeysFromSaleorItem } from "../../../lib/cms/client/metadata";
|
import { getCmsKeysFromSaleorItem } from "../../../lib/cms/client/metadata";
|
||||||
import {
|
import { createCmsOperations, executeCmsOperations, updateMetadata } from "../../../lib/cms/client";
|
||||||
createCmsOperations,
|
|
||||||
executeCmsOperations,
|
|
||||||
executeMetadataUpdate,
|
|
||||||
} from "../../../lib/cms/client";
|
|
||||||
import { logger as pinoLogger } from "../../../lib/logger";
|
import { logger as pinoLogger } from "../../../lib/logger";
|
||||||
import { createClient } from "../../../lib/graphql";
|
import { createClient } from "../../../lib/graphql";
|
||||||
|
|
||||||
|
@ -89,7 +85,7 @@ export const handler: NextWebhookApiHandler<ProductVariantDeletedWebhookPayloadF
|
||||||
productVariant,
|
productVariant,
|
||||||
});
|
});
|
||||||
|
|
||||||
await executeMetadataUpdate({
|
await updateMetadata({
|
||||||
context,
|
context,
|
||||||
productVariant,
|
productVariant,
|
||||||
cmsProviderInstanceIdsToCreate: cmsProviderInstanceProductVariantIdsToCreate,
|
cmsProviderInstanceIdsToCreate: cmsProviderInstanceProductVariantIdsToCreate,
|
||||||
|
@ -99,7 +95,8 @@ export const handler: NextWebhookApiHandler<ProductVariantDeletedWebhookPayloadF
|
||||||
if (!cmsErrors.length) {
|
if (!cmsErrors.length) {
|
||||||
return res.status(200).end();
|
return res.status(200).end();
|
||||||
} else {
|
} 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 { saleorApp } from "../../../../saleor-app";
|
||||||
import { getCmsKeysFromSaleorItem } from "../../../lib/cms/client/metadata";
|
import { getCmsKeysFromSaleorItem } from "../../../lib/cms/client/metadata";
|
||||||
import { getChannelsSlugsFromSaleorItem } from "../../../lib/cms/client/channels";
|
import { getChannelsSlugsFromSaleorItem } from "../../../lib/cms/client/channels";
|
||||||
import {
|
import { createCmsOperations, executeCmsOperations, updateMetadata } from "../../../lib/cms/client";
|
||||||
createCmsOperations,
|
|
||||||
executeCmsOperations,
|
|
||||||
executeMetadataUpdate,
|
|
||||||
} from "../../../lib/cms/client";
|
|
||||||
import { logger as pinoLogger } from "../../../lib/logger";
|
import { logger as pinoLogger } from "../../../lib/logger";
|
||||||
import { createClient } from "../../../lib/graphql";
|
import { createClient } from "../../../lib/graphql";
|
||||||
import { fetchProductVariantMetadata } from "../../../lib/metadata";
|
import { fetchProductVariantMetadata } from "../../../lib/metadata";
|
||||||
|
@ -93,7 +89,7 @@ export const handler: NextWebhookApiHandler<ProductVariantUpdatedWebhookPayloadF
|
||||||
productVariant,
|
productVariant,
|
||||||
});
|
});
|
||||||
|
|
||||||
await executeMetadataUpdate({
|
await updateMetadata({
|
||||||
context,
|
context,
|
||||||
productVariant,
|
productVariant,
|
||||||
cmsProviderInstanceIdsToCreate: cmsProviderInstanceProductVariantIdsToCreate,
|
cmsProviderInstanceIdsToCreate: cmsProviderInstanceProductVariantIdsToCreate,
|
||||||
|
@ -103,7 +99,8 @@ export const handler: NextWebhookApiHandler<ProductVariantUpdatedWebhookPayloadF
|
||||||
if (!cmsErrors.length) {
|
if (!cmsErrors.length) {
|
||||||
return res.status(200).end();
|
return res.status(200).end();
|
||||||
} else {
|
} 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