1 Commits
Author SHA1 Message Date
Mahir-Mohamed 00cf7aed76 feat: improve product catalog backend configuration 2026-09-08 14:23:58 +05:30
24 changed files with 134 additions and 299 deletions
@@ -14,8 +14,8 @@ export class AttributeGroupRepository {
through: { attributes: ['display_order'] }
}
],
order: [
['name', 'ASC']
order: options.order || [
['created_at', 'DESC']
]
});
}
@@ -28,8 +28,8 @@ export class AttributeSetRepository {
]
}
],
order: [
['name', 'ASC']
order: options.order || [
['created_at', 'DESC']
]
});
}
@@ -4,6 +4,7 @@ import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class AttributeRepository {
async findAll(options = {}, context = {}) {
const queryOptions = {
order: [['created_at', 'DESC']],
...options,
where: applyTenantScope(options.where || {}, context)
};
@@ -54,7 +54,7 @@ export class AttributeService {
}
// Sorting
let order = [['display_order', 'ASC']];
let order = [['created_at', 'DESC']];
if (query.sortBy) {
const direction = query.sortDir?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
if (query.sortBy === 'name') order = [['name', direction]];
@@ -15,6 +15,7 @@ export class RoleRepository {
};
return await models.Role.findAll({
order: [['created_at', 'DESC']],
include: [
{
model: models.PermissionNode,
@@ -8,6 +8,7 @@ export class UserRepository {
? { tenant_id: context.tenantId }
: {};
return await models.User.findAll({
order: [['created_at', 'DESC']],
attributes: { exclude: ['password_hash'] },
include: [
{
@@ -4,6 +4,7 @@ import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class BrandRepository {
async findAll(options = {}, context = {}) {
const queryOptions = {
order: [['created_at', 'DESC']],
...options,
where: applyTenantScope(options.where || {}, context)
};
@@ -4,6 +4,7 @@ import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class UnitRepository {
async findAll(options = {}, context = {}) {
const queryOptions = {
order: [['created_at', 'DESC']],
...options,
where: applyTenantScope(options.where || {}, context)
};
@@ -18,6 +18,7 @@ export class CatalogController {
: (raw.category_id || ''),
attributeSetId: raw.attribute_set_id || (raw.attributeSet ? raw.attributeSet.id : null) || '',
workflowCode: raw.workflow_code || 'standard',
productType: raw.completeness_rules?.productType || raw.completenessRules?.productType || raw.productType || raw.product_type || null,
completenessRules: raw.completeness_rules || {},
allowedBrands: raw.allowedBrands || [],
allowedUnits: raw.allowedUnits || [],
@@ -57,8 +57,8 @@ export class CatalogRepository {
]
}
],
order: [
['name', 'ASC']
order: options.order || [
['created_at', 'DESC']
],
...queryOptions
});
@@ -243,11 +243,15 @@ export class CatalogService {
const completenessRules = data.completenessRules || data.completeness_rules || {};
completenessRules.allowedBrands = data.allowedBrands || data.allowed_brands || [];
completenessRules.allowedUnits = data.allowedUnits || data.allowed_units || [];
const incomingProductType = data.productType || data.product_type || data.type;
if (incomingProductType) {
completenessRules.productType = incomingProductType;
}
let totalWeight = 0;
let hasRules = false;
for (const [key, val] of Object.entries(completenessRules)) {
if (key === 'allowedBrands' || key === 'allowedUnits') continue;
if (key === 'allowedBrands' || key === 'allowedUnits' || key === 'productType') continue;
const weight = Number(val);
if (isNaN(weight)) {
throw new Error(`Completeness rule weight for "${key}" must be a number`);
@@ -515,11 +519,17 @@ export class CatalogService {
if (data.hasOwnProperty('allowedUnits') || data.hasOwnProperty('allowed_units')) {
completenessRules.allowedUnits = data.allowedUnits || data.allowed_units || [];
}
if (data.hasOwnProperty('productType') || data.hasOwnProperty('product_type') || data.hasOwnProperty('type')) {
const pType = data.productType || data.product_type || data.type;
if (pType) {
completenessRules.productType = pType;
}
}
let totalWeight = 0;
let hasRules = false;
for (const [key, val] of Object.entries(completenessRules)) {
if (key === 'allowedBrands' || key === 'allowedUnits') continue;
if (key === 'allowedBrands' || key === 'allowedUnits' || key === 'productType') continue;
const weight = Number(val);
if (isNaN(weight)) {
throw new Error(`Completeness rule weight for "${key}" must be a number`);
@@ -748,6 +758,7 @@ export class CatalogService {
if (!family) throw new Error('Product Family not found');
let groups = [];
let attributeSetObj = null;
if (family.attribute_set_id) {
const setRecord = await models.AttributeSet.findByPk(family.attribute_set_id, {
include: [
@@ -770,8 +781,13 @@ export class CatalogService {
}
]
});
if (setRecord && setRecord.groups) {
groups = setRecord.groups;
if (setRecord) {
const setObj = setRecord.toJSON
? setRecord.toJSON()
: JSON.parse(JSON.stringify(setRecord));
attributeSetObj = setObj;
groups = setObj.groups || [];
}
}
@@ -826,7 +842,9 @@ export class CatalogService {
name: family.name,
description: family.description,
category: family.category,
attributeSet: family.attributeSet,
attributeSet: attributeSetObj || family.attributeSet || null,
attribute_set_id: family.attribute_set_id || null,
attributeSetId: family.attribute_set_id || null,
groups,
attributes: family.attributes || [],
variantAxes: family.variantAxes || [],
@@ -837,7 +855,8 @@ export class CatalogService {
workflow: workflow,
allowedBrands: completenessRules.allowedBrands || [],
allowedUnits: completenessRules.allowedUnits || [],
completenessRules: completenessRules
completenessRules: completenessRules,
productType: completenessRules.productType || null
};
}
@@ -48,7 +48,17 @@ export const createValidation = [
.isString(),
body('categoryId')
.optional({ nullable: true })
.isString()
.isString(),
body('productType')
.optional({ nullable: true })
.isIn(['simple', 'variant'])
.withMessage('Product type must be either simple or variant'),
body('product_type')
.optional({ nullable: true })
.isIn(['simple', 'variant']),
body('type')
.optional({ nullable: true })
.isIn(['simple', 'variant'])
];
export const updateValidation = [
@@ -103,7 +113,17 @@ export const updateValidation = [
.isString(),
body('categoryId')
.optional({ nullable: true })
.isString()
.isString(),
body('productType')
.optional({ nullable: true })
.isIn(['simple', 'variant'])
.withMessage('Product type must be either simple or variant'),
body('product_type')
.optional({ nullable: true })
.isIn(['simple', 'variant']),
body('type')
.optional({ nullable: true })
.isIn(['simple', 'variant'])
];
export const deleteValidation = [
@@ -4,7 +4,11 @@ import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class ChannelTypeRepository {
async findAll(options = {}, context = {}) {
const where = applyTenantScope(options.where || {}, context);
return await models.ChannelType.findAll({ ...options, where });
return await models.ChannelType.findAll({
order: [['created_at', 'DESC']],
...options,
where
});
}
async findById(id, options = {}, context = {}) {
@@ -4,7 +4,11 @@ import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class ChannelRepository {
async findAll(options = {}, context = {}) {
const where = applyTenantScope(options.where || {}, context);
return await models.Channel.findAll({ ...options, where });
return await models.Channel.findAll({
order: [['created_at', 'DESC']],
...options,
where
});
}
async findById(id, options = {}, context = {}) {
@@ -16,7 +16,7 @@ export const shopifyMapper = {
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
const uniqueSuffix = String(identity.id).replace(/[^a-z0-9]/gi, '').slice(0, 8) + '-' + Date.now().toString(36);
const uniqueSuffix = String(identity.id).replace(/[^a-z0-9]/gi, '').slice(0, 10);
input.handle = `${baseSlug}-${uniqueSuffix}`;
}
@@ -30,16 +30,7 @@ export const shopifyMapper = {
});
if (optionKeys.size > 0) {
const priorityOrder = ['color', 'size', 'storage', 'ram', 'rom', 'material', 'style'];
const top3OptionKeys = Array.from(optionKeys).sort((a, b) => {
const idxA = priorityOrder.indexOf(a.toLowerCase());
const idxB = priorityOrder.indexOf(b.toLowerCase());
const pA = idxA === -1 ? 99 : idxA;
const pB = idxB === -1 ? 99 : idxB;
return pA - pB;
}).slice(0, 3);
input.productOptions = top3OptionKeys.map(optName => ({
input.productOptions = Array.from(optionKeys).slice(0, 3).map(optName => ({
name: optName,
values: Array.from(new Set(
variants
@@ -9,16 +9,6 @@ const PRODUCT_CREATE_MUTATION = `
title
handle
onlineStoreUrl
variants(first: 50) {
nodes {
id
title
selectedOptions {
name
value
}
}
}
}
userErrors {
field
@@ -36,16 +26,6 @@ const PRODUCT_UPDATE_MUTATION = `
title
handle
onlineStoreUrl
variants(first: 50) {
nodes {
id
title
selectedOptions {
name
value
}
}
}
}
userErrors {
field
@@ -82,38 +62,6 @@ const PRODUCT_CREATE_MEDIA_MUTATION = `
}
`;
const BULK_VARIANTS_CREATE_MUTATION = `
mutation productVariantsBulkCreate($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkCreate(productId: $productId, variants: $variants) {
productVariants {
id
title
price
}
userErrors {
field
message
}
}
}
`;
const BULK_VARIANTS_UPDATE_MUTATION = `
mutation productVariantsBulkUpdate($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
productVariants {
id
title
price
}
userErrors {
field
message
}
}
}
`;
export class ShopifyPublisher {
constructor(shopifyClient) {
this.client = shopifyClient;
@@ -159,119 +107,14 @@ export class ShopifyPublisher {
throw new Error('Shopify GraphQL returned empty product response');
}
// ── Multi-Variant Bulk Creation & Synchronization ──
const pimVariants = canonicalProduct.variants || [];
if (Array.isArray(pimVariants) && pimVariants.length > 0) {
const existingNodes = shopifyProduct.variants?.nodes || [];
// Allowed option names in Shopify product
const allowedOptionNames = (input.productOptions || []).map(o => o.name);
const newVariantsInput = [];
const updateVariantsInput = [];
for (const v of pimVariants) {
let optionValues = Object.entries(v.attributes || {}).map(([optName, optVal]) => ({
optionName: optName,
name: String(optVal)
}));
if (allowedOptionNames.length > 0) {
optionValues = optionValues.filter(ov =>
allowedOptionNames.some(aName => aName.toLowerCase() === ov.optionName.toLowerCase())
);
}
if (optionValues.length === 0) continue;
// Try matching with existing Shopify variant node
const matchedNode = existingNodes.find(node => {
const nodeOptions = node.selectedOptions || [];
return optionValues.every(ov =>
nodeOptions.some(no => no.name.toLowerCase() === ov.optionName.toLowerCase() && no.value.toLowerCase() === ov.name.toLowerCase())
);
});
if (matchedNode) {
updateVariantsInput.push({
id: matchedNode.id,
price: String(v.price || 0),
compareAtPrice: v.costPrice ? String(v.costPrice) : null,
inventoryItem: {
sku: v.sku || '',
tracked: true
}
});
} else {
newVariantsInput.push({
optionValues,
price: String(v.price || 0),
compareAtPrice: v.costPrice ? String(v.costPrice) : null,
inventoryItem: {
sku: v.sku || '',
tracked: true
}
});
}
}
// Create new variants
if (newVariantsInput.length > 0) {
try {
const createRes = await this.client.graphql(BULK_VARIANTS_CREATE_MUTATION, {
productId: shopifyProduct.id,
variants: newVariantsInput
});
const createErrors = createRes.data?.productVariantsBulkCreate?.userErrors;
if (createErrors?.length > 0) {
console.warn('[ShopifyPublisher] Variant create warnings:', createErrors.map(e => e.message).join(', '));
}
} catch (vErr) {
console.warn('[ShopifyPublisher] Variant bulk create error:', vErr.message);
}
}
// Update existing variants
if (updateVariantsInput.length > 0) {
try {
const updateRes = await this.client.graphql(BULK_VARIANTS_UPDATE_MUTATION, {
productId: shopifyProduct.id,
variants: updateVariantsInput
});
const updateErrors = updateRes.data?.productVariantsBulkUpdate?.userErrors;
if (updateErrors?.length > 0) {
console.warn('[ShopifyPublisher] Variant update warnings:', updateErrors.map(e => e.message).join(', '));
}
} catch (vErr) {
console.warn('[ShopifyPublisher] Variant bulk update error:', vErr.message);
}
}
}
// ── Attach Media/Images to Shopify Product ──
const allMediaUrls = new Set();
// Product level media
(canonicalProduct.media || []).forEach(m => {
if (m.url && (m.url.startsWith('http://') || m.url.startsWith('https://'))) {
allMediaUrls.add(m.url);
}
});
// Variant level media
(pimVariants).forEach(v => {
(v.media || []).forEach(m => {
if (m.url && (m.url.startsWith('http://') || m.url.startsWith('https://'))) {
allMediaUrls.add(m.url);
}
});
});
const validMedia = Array.from(allMediaUrls).map(url => ({
mediaContentType: 'IMAGE',
originalSource: url,
alt: canonicalProduct.content.name
}));
// Attach media/image URLs to Shopify product
const validMedia = (canonicalProduct.media || [])
.filter(m => m.url && (m.url.startsWith('http://') || m.url.startsWith('https://')))
.map(m => ({
mediaContentType: 'IMAGE',
originalSource: m.url,
alt: canonicalProduct.content.name
}));
if (validMedia.length > 0 && shopifyProduct?.id) {
try {
@@ -315,8 +158,7 @@ export class ShopifyPublisher {
}
async deleteProduct(context) {
const { tenantId, integrationId, canonicalProduct } = context;
const productId = canonicalProduct.identity.id;
const { tenantId, integrationId, productId } = context;
const existingResource = await models.ExternalResource.findOne({
where: {
@@ -328,24 +170,19 @@ export class ShopifyPublisher {
});
if (!existingResource) {
return { success: true, message: 'No external mapping found for deletion' };
return { success: true, message: 'Resource was not published to Shopify' };
}
const response = await this.client.graphql(PRODUCT_DELETE_MUTATION, {
input: { id: existingResource.external_id }
});
const userErrors = response.data?.productDelete?.userErrors;
if (userErrors?.length > 0) {
throw new Error(`Shopify productDelete user errors: ${userErrors.map(e => e.message).join(', ')}`);
const result = response.data?.productDelete;
if (result?.userErrors?.length > 0) {
throw new Error(`Shopify productDelete user errors: ${result.userErrors.map(e => e.message).join(', ')}`);
}
await existingResource.destroy();
return {
success: true,
externalId: existingResource.external_id,
operation: 'DELETE'
};
return { success: true, deletedProductId: result.deletedProductId };
}
}
@@ -80,31 +80,8 @@ export const canonicalProductBuilder = {
const vAttrs = {};
if (Array.isArray(v.values)) {
v.values.forEach(val => {
const rawKey = val.axis?.name || val.axis?.code || '';
let key = rawKey
.replace(/_variant$/i, '')
.replace(/_/g, ' ')
.trim()
.replace(/\b\w/g, l => l.toUpperCase());
// Normalize common option names
if (/^colors?$/i.test(key)) key = 'Color';
if (/^sizes?$/i.test(key)) key = 'Size';
if (/^ram$/i.test(key)) key = 'RAM';
// Skip brand / vendor from variant options
if (!key || /^brand$/i.test(key) || /^vendor$/i.test(key) || key.toLowerCase().includes('brand_id')) {
return;
}
let valStr = String(val.value_text || val.value_number || '').trim();
// Skip if value looks like a UUID
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(valStr)) {
return;
}
if (valStr.length > 0) {
vAttrs[key] = valStr.length <= 3 ? valStr.toUpperCase() : valStr.charAt(0).toUpperCase() + valStr.slice(1);
}
const key = val.axis?.code || val.attribute_id;
vAttrs[key] = val.value_text || val.value_number;
});
}
@@ -79,6 +79,21 @@ export const processOutboxEvents = async () => {
}
};
let isProcessing = false;
export const guardedProcessOutboxEvents = async () => {
if (isProcessing) {
return;
}
isProcessing = true;
try {
await processOutboxEvents();
} finally {
isProcessing = false;
}
};
export const startOutboxWorker = (intervalMs = 10000) => {
setInterval(processOutboxEvents, intervalMs);
setInterval(guardedProcessOutboxEvents, intervalMs);
};
@@ -14,7 +14,11 @@ export class AssetFamilyRepository {
async findAll(options = {}, context = {}) {
const where = applyTenantScope(options.where || {}, context);
const queryOptions = { ...options, where };
const queryOptions = {
order: [['created_at', 'DESC']],
...options,
where
};
try {
return await models.AssetFamily.findAll(queryOptions);
} catch (err) {
@@ -4,7 +4,11 @@ import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class AssetTypeRepository {
async findAll(options = {}, context = {}) {
const where = applyTenantScope(options.where || {}, context);
return await models.AssetType.findAll({ ...options, where });
return await models.AssetType.findAll({
order: [['created_at', 'DESC']],
...options,
where
});
}
async findById(id, options = {}, context = {}) {
@@ -4,6 +4,7 @@ import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class ProductRepository {
async findAll(options = {}, context = {}) {
const queryOptions = {
order: [['created_at', 'DESC']],
...options,
where: applyTenantScope(options.where || {}, context)
};
@@ -62,79 +63,16 @@ export class ProductRepository {
as: 'family',
required: false,
include: [
{
model: models.Attribute,
as: 'attributes',
through: { attributes: [] },
required: false,
include: [
{
model: models.AttributeOption,
as: 'optionsList',
attributes: ['id', 'code', 'label', 'sort_order', 'status'],
required: false
}
]
},
{
model: models.Attribute,
as: 'variantAxes',
through: { attributes: [] },
required: false,
include: [
{
model: models.AttributeOption,
as: 'optionsList',
attributes: ['id', 'code', 'label', 'sort_order', 'status'],
required: false
}
]
},
{
model: models.AssetFamily,
as: 'assetRequirements',
through: { attributes: [] },
required: false
},
{
model: models.FamilyChannel,
as: 'channels',
required: false
},
{
model: models.Categorie,
as: 'category',
attributes: ['id', 'name', 'code'],
required: false
},
{
model: models.AttributeSet,
as: 'attributeSet',
required: false,
include: [
{
model: models.AttributeGroup,
as: 'groups',
through: { attributes: [] },
required: false,
include: [
{
model: models.Attribute,
as: 'attributes',
through: { attributes: [] },
required: false,
include: [
{
model: models.AttributeOption,
as: 'optionsList',
attributes: ['id', 'code', 'label', 'sort_order', 'status'],
required: false
}
]
}
]
}
]
attributes: ['id', 'name']
}
]
},
@@ -44,7 +44,7 @@ export class VariantRepository {
async findAll(options = {}, context = {}) {
const where = applyTenantScope(options.where || {}, context);
return await models.Variant.findAll({
order: [['sku', 'ASC']],
order: options.order || [['created_at', 'DESC']],
include: options.include || getDefaultVariantIncludes(),
...options,
where
@@ -4,7 +4,11 @@ import { applyTenantScope } from '../../utils/helpers/common.helper.js';
export class WorkflowRepository {
async findAll(options = {}, context = {}) {
const where = applyTenantScope(options.where || {}, context);
return await models.WorkflowRegistry.findAll({ ...options, where });
return await models.WorkflowRegistry.findAll({
order: [['created_at', 'DESC']],
...options,
where
});
}
async findById(id, options = {}, context = {}) {
+12
View File
@@ -20,6 +20,12 @@ module.exports = {
port: process.env.DB_PORT || 5432,
dialect: process.env.DB_DIALECT || "postgres",
logging: false,
pool: {
max: 15,
min: 2,
acquire: 30000,
idle: 10000,
},
},
development: {
@@ -30,6 +36,12 @@ module.exports = {
port: process.env.DB_PORT || 5432,
dialect: process.env.DB_DIALECT || "postgres",
logging: false,
pool: {
max: 15,
min: 2,
acquire: 30000,
idle: 10000,
},
},
test: {