diff --git a/src/features/channels/channels/channel.controller.js b/src/features/channels/channels/channel.controller.js index efaff26..43fa7ee 100644 --- a/src/features/channels/channels/channel.controller.js +++ b/src/features/channels/channels/channel.controller.js @@ -112,6 +112,15 @@ export class ChannelController { next(error); } } + + async previewPayload(req, res, next) { + try { + const data = await syndicationService.previewPayload(req.params.id, req.query.productId); + return res.status(200).json({ success: true, data }); + } catch (error) { + next(error); + } + } } export default new ChannelController(); diff --git a/src/features/channels/channels/channel.routes.js b/src/features/channels/channels/channel.routes.js index f331b14..78e4da7 100644 --- a/src/features/channels/channels/channel.routes.js +++ b/src/features/channels/channels/channel.routes.js @@ -182,4 +182,11 @@ router.get( controller.getJobById ); +router.post( + '/:id/preview', + authenticate, + authorize(['settings.integrations']), + controller.previewPayload +); + export default router; diff --git a/src/features/channels/syndication/channelAdapter.service.js b/src/features/channels/syndication/channelAdapter.service.js new file mode 100644 index 0000000..370b1bc --- /dev/null +++ b/src/features/channels/syndication/channelAdapter.service.js @@ -0,0 +1,58 @@ +export class ChannelAdapterService { + /** + * Format transformed payload into a Shopify GraphQL productCreate mutation + */ + formatShopifyGraphQL(transformedPayload) { + return { + query: ` + mutation productCreate($input: ProductInput!) { + productCreate(input: $input) { + product { + id + title + handle + status + } + userErrors { + field + message + } + } + } + `, + variables: { + input: { + title: transformedPayload.title || transformedPayload.name || 'Untitled Product', + bodyHtml: transformedPayload.body_html || transformedPayload.description || '', + vendor: transformedPayload.vendor || transformedPayload.brand || 'Generic', + productType: transformedPayload.product_type || 'General', + status: transformedPayload.published_status === 'published' ? 'ACTIVE' : 'DRAFT', + variants: [ + { + sku: transformedPayload.variant_sku || transformedPayload.code || 'SKU-DEFAULT', + price: String(transformedPayload.price || '0.00'), + } + ] + } + } + }; + } + + /** + * Format payload for custom Webhook HTTP POST dispatches + */ + formatWebhookPayload(channel, transformedPayload) { + return { + event: 'product.syndicated', + channel: { + id: channel.id, + name: channel.name, + code: channel.code + }, + timestamp: new Date().toISOString(), + data: transformedPayload + }; + } +} + +export default new ChannelAdapterService(); diff --git a/src/features/channels/syndication/syndication.service.js b/src/features/channels/syndication/syndication.service.js index caee8e8..fa2f8db 100644 --- a/src/features/channels/syndication/syndication.service.js +++ b/src/features/channels/syndication/syndication.service.js @@ -1,6 +1,7 @@ import { models } from '../../../shared/database/models.js'; import { ApiError } from '../../../utils/helpers/ApiError.utils.js'; import { AuditService } from '../../../shared/services/audit.service.js'; +import channelAdapterService from './channelAdapter.service.js'; export class SyndicationService { applyTransformation(val, rule, defaultValue) { @@ -26,6 +27,49 @@ export class SyndicationService { } } + async previewPayload(channelId, productId) { + const channel = await models.Channel.findByPk(channelId); + if (!channel) { + throw new ApiError(404, 'Channel not found'); + } + + const mappings = await models.ChannelMapping.findAll({ + where: { channel_id: channelId } + }); + + let product = null; + if (productId) { + product = await models.Product.findByPk(productId); + } else { + product = await models.Product.findOne(); + } + + if (!product) { + throw new ApiError(404, 'No product available for payload transformation preview'); + } + + const transformed = {}; + for (const mapItem of mappings) { + const rawVal = product[mapItem.pim_attribute_code]; + transformed[mapItem.channel_field_code] = this.applyTransformation( + rawVal, + mapItem.transformation_rule, + mapItem.default_value + ); + } + + const formattedAdapterPayload = (channel.code === 'shopify' || channel.channelType === 'ecommerce') + ? channelAdapterService.formatShopifyGraphQL(transformed) + : channelAdapterService.formatWebhookPayload(channel, transformed); + + return { + channel: { id: channel.id, name: channel.name, code: channel.code }, + pimProductRaw: product, + transformedFields: transformed, + adapterOutput: formattedAdapterPayload + }; + } + async triggerSyndication(channelId, userContext = {}) { const channel = await models.Channel.findByPk(channelId); if (!channel) { @@ -73,7 +117,7 @@ export class SyndicationService { if (mapItem.is_required && (rawVal === null || rawVal === undefined || rawVal === '')) { errorLogs.push({ productId: prod.id, - sku: prod.sku, + sku: prod.code || prod.sku, error: `Required attribute "${mapItem.pim_attribute_code}" is missing or null` }); hasError = true; @@ -95,7 +139,7 @@ export class SyndicationService { failedCount++; errorLogs.push({ productId: prod.id, - sku: prod.sku, + sku: prod.code || prod.sku, error: err.message }); }