6 Commits
39 changed files with 846 additions and 119 deletions
+3 -2
View File
@@ -30,7 +30,8 @@
"db:seed:undo:dev": "cross-env NODE_ENV=development sequelize-cli db:seed:undo:all --config src/shared/config/database.config.cjs --seeders-path src/seeders --models-path src/shared/database",
"db:migrate:test": "cross-env NODE_ENV=test sequelize-cli db:migrate --config src/shared/config/database.config.cjs --migrations-path src/migrations --models-path src/shared/database",
"db:seed:test": "cross-env NODE_ENV=test sequelize-cli db:seed:all --config src/shared/config/database.config.cjs --seeders-path src/seeders --models-path src/shared/database",
"db:seed:undo:test": "cross-env NODE_ENV=test sequelize-cli db:seed:undo:all --config src/shared/config/database.config.cjs --seeders-path src/seeders --models-path src/shared/database"
"db:seed:undo:test": "cross-env NODE_ENV=test sequelize-cli db:seed:undo:all --config src/shared/config/database.config.cjs --seeders-path src/seeders --models-path src/shared/database",
"seed:tenant": "node scripts/seed-tenant-starter.js"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1113.0",
@@ -68,4 +69,4 @@
"node": ">=18.0.0"
},
"private": true
}
}
+22
View File
@@ -0,0 +1,22 @@
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { spawn } from 'node:child_process';
const __dirname = dirname(fileURLToPath(import.meta.url));
const secret = process.env.SAAS_PIM_MODULE_SECRET;
if (!secret) {
console.error('Set SAAS_PIM_MODULE_SECRET');
process.exit(1);
}
const child = spawn(process.execPath, [join(__dirname, 'test-saas-tenant-provisioning-e2e.mjs')], {
stdio: 'inherit',
env: {
...process.env,
SAAS_PIM_MODULE_SECRET: secret
}
});
child.on('exit', (code) => process.exit(code ?? 0));
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env node
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
dotenv.config({ path: path.resolve(__dirname, '../.env.development') });
dotenv.config({ path: path.resolve(__dirname, '../.env.local') });
dotenv.config({ path: path.resolve(__dirname, '../.env') });
const { models, initializeDatabaseModels, sequelize } = await import('../src/shared/database/models.js');
initializeDatabaseModels();
const { seedTenantStarterData } = await import('../src/seeders/tenantStarterData.service.js');
async function run() {
const args = process.argv.slice(2);
let targetTenantId = null;
let seedAll = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--all') {
seedAll = true;
} else if (args[i] === '--tenant-id' && args[i + 1]) {
targetTenantId = parseInt(args[i + 1], 10);
i++;
} else if (args[i] === '--canonical-id' && args[i + 1]) {
const canonical = args[i + 1].trim().toLowerCase();
const tenant = await models.Tenant.findOne({ where: { canonical_tenant_id: canonical } });
if (!tenant) {
console.error(`Tenant with canonical ID "${canonical}" not found.`);
process.exit(1);
}
targetTenantId = tenant.id;
console.log(`Resolved canonical ID "${canonical}" -> Tenant ID: ${targetTenantId}`);
i++;
} else if (args[i] === '--email' && args[i + 1]) {
const email = args[i + 1].trim().toLowerCase();
const user = await models.User.findOne({ where: { email } });
if (user && user.tenant_id) {
targetTenantId = user.tenant_id;
console.log(`Resolved user "${email}" -> Tenant ID: ${targetTenantId}`);
} else {
const tenant = await models.Tenant.findOne({ where: { contact_email: email } });
if (tenant) {
targetTenantId = tenant.id;
console.log(`Resolved contact email "${email}" -> Tenant ID: ${targetTenantId}`);
} else {
console.error(`Tenant or User with email "${email}" not found.`);
process.exit(1);
}
}
i++;
}
}
let tenantsToSeed = [];
if (targetTenantId) {
const t = await models.Tenant.findByPk(targetTenantId);
if (!t) {
console.error(`Tenant ID ${targetTenantId} not found.`);
process.exit(1);
}
tenantsToSeed = [t];
} else {
// Default to all active tenants
tenantsToSeed = await models.Tenant.findAll({ where: { status: true } });
if (tenantsToSeed.length === 0) {
console.error('No active tenants found in PIM.');
process.exit(1);
}
}
console.log(`\n======================================================`);
console.log(`🌱 PIM TENANT STARTER PACK SEEDER`);
console.log(`Targeting ${tenantsToSeed.length} tenant(s)...`);
console.log(`======================================================\n`);
let successCount = 0;
for (const tenant of tenantsToSeed) {
const tenantLabel = tenant.tenant_name || tenant.tenant_code || `Tenant #${tenant.id}`;
console.log(`▶ Seeding Tenant: "${tenantLabel}" (ID: ${tenant.id}, Canonical: ${tenant.canonical_tenant_id || 'N/A'})...`);
const tx = await sequelize.transaction();
try {
const result = await seedTenantStarterData(tenant.id, { transaction: tx });
await tx.commit();
console.log(` ✅ Units (${result.units.length}): ${result.units.map(u => u.name).join(', ')}`);
console.log(` ✅ Brands (${result.brands.length}): ${result.brands.map(b => b.name).join(', ')}`);
console.log(` ✅ Categories (${result.categories.length}): ${result.categories.map(c => c.name).join(', ')}`);
console.log(` ✅ Families (${result.families.length}): ${result.families.map(f => f.name).join(', ')}`);
console.log(` ✅ Channels (${result.channels.length}): ${result.channels.map(ch => ch.name).join(', ')}`);
console.log(` ✅ Products (${result.products.length}): ${result.products.map(p => p.name).join(', ')}`);
console.log(` 🎉 Successfully seeded starter pack for Tenant ${tenant.id}!\n`);
successCount++;
} catch (err) {
await tx.rollback();
console.error(` ❌ Failed seeding Tenant ${tenant.id}:`, err.message);
}
}
console.log(`======================================================`);
console.log(`✨ Completed: ${successCount}/${tenantsToSeed.length} tenants seeded successfully.`);
console.log(`======================================================\n`);
process.exit(0);
}
run().catch(err => {
console.error('Seeder execution error:', err);
process.exit(1);
});
+1 -1
View File
@@ -59,7 +59,7 @@ export class ApiKeyService {
const record = await models.ApiKey.create({
tenant_id: context.tenantId, name, key_prefix: `pim_live_${prefix}`,
key_hash: digest(plaintext), scopes: [PRODUCT_READ_SCOPE],
expires_at: new Date(Date.now() + expiresInDays * 86_400_000), created_by: context.userId || null
expires_at: new Date(Date.now() + expiresInDays * 86_400_000), created_by: Number.isInteger(Number(context.userId)) ? Number(context.userId) : null
});
return { ...serialize(record), apiKey: plaintext, shownOnce: true };
}
@@ -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, applyTenantWriteScope } from '../../../utils/helpers/
export class AttributeRepository {
async findAll(options = {}, context = {}) {
const queryOptions = {
order: [['created_at', 'DESC']],
...options,
where: applyTenantScope(options.where || {}, context)
};
@@ -55,7 +55,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, applyTenantWriteScope } from '../../../utils/helpers/
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, applyTenantWriteScope } from '../../../utils/helpers/
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
});
@@ -249,11 +249,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`);
@@ -522,11 +526,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`);
@@ -756,6 +766,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: [
@@ -778,8 +789,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 || [];
}
}
@@ -834,7 +850,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 || [],
@@ -845,7 +863,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, applyTenantWriteScope } from '../../../utils/helpers/
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 = {}) {
@@ -47,7 +47,7 @@ export default (sequelize) => {
defaultValue: false,
}
}, {
tableName: 'channel_mappings',
tableName: 'channel_field_mappings',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
@@ -1,3 +1,4 @@
import { Op } from 'sequelize';
import repository from './integration.repository.js';
import { ApiError } from '../../utils/helpers/ApiError.utils.js';
import { AuditService } from '../../shared/services/audit.service.js';
@@ -83,8 +84,8 @@ async function resolveTenantChannel(channelReference, context) {
if (!channelReference) return null;
const channel = await models.Channel.findOne({
where: UUID_PATTERN.test(channelReference)
? { id: channelReference, tenant_id: context.tenantId }
: { code: channelReference, tenant_id: context.tenantId }
? { id: channelReference, [Op.or]: [{ tenant_id: context.tenantId }, { tenant_id: null }] }
: { code: channelReference, [Op.or]: [{ tenant_id: context.tenantId }, { tenant_id: null }] }
});
if (!channel) throw new ApiError(400, 'Selected Channel does not belong to this tenant');
return channel;
@@ -94,11 +95,11 @@ function normalizeGenericRestPayload(payload) {
const endpoint = String(payload.endpoint || '').trim();
let url;
try { url = new URL(endpoint); } catch { throw new ApiError(400, 'Product Delivery URL must be a complete HTTPS URL'); }
if (url.protocol !== 'https:') throw new ApiError(400, 'External Product Delivery URL must use HTTPS');
if (url.protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(url.hostname)) throw new ApiError(400, 'External Product Delivery URL must use HTTPS (or http://localhost for local testing)');
if (payload.testEndpoint) {
let testUrl;
try { testUrl = new URL(payload.testEndpoint); } catch { throw new ApiError(400, 'Connection-test URL must be a complete HTTPS URL'); }
if (testUrl.protocol !== 'https:') throw new ApiError(400, 'External connection-test URL must use HTTPS');
if (testUrl.protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(testUrl.hostname)) throw new ApiError(400, 'External connection-test URL must use HTTPS (or http://localhost for local testing)');
}
const method = String(payload.method || 'POST').toUpperCase();
if (!['POST', 'PUT', 'PATCH'].includes(method)) throw new ApiError(400, 'REST delivery method must be POST, PUT or PATCH');
@@ -2,7 +2,7 @@ import { DataTypes } from 'sequelize';
export default function (sequelize) {
const ChannelMapping = sequelize.define(
'ChannelMapping',
'IntegrationChannelMapping',
{
id: {
type: DataTypes.UUID,
@@ -67,8 +67,8 @@ export default function (sequelize) {
if (models.PublishingRule) {
Integration.hasMany(models.PublishingRule, { foreignKey: 'integration_id', as: 'publishingRules' });
}
if (models.ChannelMapping) {
Integration.hasMany(models.ChannelMapping, { foreignKey: 'integration_id', as: 'channelMappings' });
if (models.IntegrationChannelMapping) {
Integration.hasMany(models.IntegrationChannelMapping, { foreignKey: 'integration_id', as: 'channelMappings' });
}
if (models.SyncJob) {
Integration.hasMany(models.SyncJob, { foreignKey: 'integration_id', as: 'syncJobs' });
@@ -12,25 +12,25 @@ router.get(
);
// ─── Protected routes ─────────────────────────────────────────────────────────
router.use(authenticate);
const guards = [authenticate, authorize(['settings.integrations'])];
router.get('/integrations', authorize(['settings.integrations']), integrationController.listIntegrations);
router.post('/integrations', authorize(['settings.integrations']), integrationController.createIntegration);
router.get('/integrations', ...guards, integrationController.listIntegrations);
router.post('/integrations', ...guards, integrationController.createIntegration);
router.get('/integrations/:id', authorize(['settings.integrations']), integrationController.getIntegration);
router.put('/integrations/:id', authorize(['settings.integrations']), integrationController.updateIntegration);
router.delete('/integrations/:id', authorize(['settings.integrations']), integrationController.deleteIntegration);
router.get('/integrations/:id', ...guards, integrationController.getIntegration);
router.put('/integrations/:id', ...guards, integrationController.updateIntegration);
router.delete('/integrations/:id', ...guards, integrationController.deleteIntegration);
router.get('/integrations/:id/credentials', authorize(['settings.integrations']), integrationController.getCredentials);
router.post('/integrations/:id/credentials', authorize(['settings.integrations']), integrationController.setCredentials);
router.post('/integrations/:id/test-connection', authorize(['settings.integrations']), integrationController.testConnection);
router.post('/integrations/:id/sync', authorize(['settings.integrations']), integrationController.triggerSync);
router.get('/integrations/:id/credentials', ...guards, integrationController.getCredentials);
router.post('/integrations/:id/credentials', ...guards, integrationController.setCredentials);
router.post('/integrations/:id/test-connection', ...guards, integrationController.testConnection);
router.post('/integrations/:id/sync', ...guards, integrationController.triggerSync);
// ─── Shopify OAuth — Start flow (protected; user-initiated) ──────────────────
router.post('/integrations/:id/shopify/oauth/start', authorize(['settings.integrations']), integrationController.startShopifyOAuth);
router.post('/integrations/:id/shopify/oauth/start', ...guards, integrationController.startShopifyOAuth);
router.get('/integrations/jobs/all', authorize(['settings.integrations']), integrationController.listAllSyncJobs);
router.get('/integrations/:id/jobs', authorize(['settings.integrations']), integrationController.listSyncJobs);
router.get('/integrations/jobs/:jobId/items', authorize(['settings.integrations']), integrationController.listSyncItems);
router.get('/integrations/jobs/all', ...guards, integrationController.listAllSyncJobs);
router.get('/integrations/:id/jobs', ...guards, integrationController.listSyncJobs);
router.get('/integrations/jobs/:jobId/items', ...guards, integrationController.listSyncItems);
export default router;
@@ -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 = {}) {
@@ -2,8 +2,11 @@ import { handleSaaSEvent } from "./saasProvisioning.service.js";
export async function provisionTenant(req, res, next) {
try {
const payload = req.body || {};
const explicitEventId = req.get("x-integration-event-id") || req.get("x-event-id") || payload.event_id || payload.provisioning_id;
const body = req.body || {};
// SaaS outbox sends a standard event envelope. Direct callers may still
// send the provisioning payload itself, so accept both contracts.
const payload = body.data || body.payload || body;
const explicitEventId = req.get("x-integration-event-id") || req.get("x-event-id") || body.event_id || payload.event_id || payload.provisioning_id;
const result = await handleSaaSEvent("TENANT_PROVISION_REQUESTED", payload, { eventId: explicitEventId });
if (result.duplicate) {
@@ -246,7 +246,7 @@ export class CompletenessService {
if (variant.sku && variant.sku.trim() !== '') {
variantsFulfilledChecks++;
} else {
missingGeneral.push({ code: `variant_${variant.id}_sku`, name: `Variant "${variant.name.split(' - ')[1] || variant.name}" SKU` });
missingGeneral.push({ code: `variant_${variant.id}_sku`, name: `Variant "${((variant.name && typeof variant.name === 'string') ? (variant.name.split(' - ')[1] || variant.name) : (variant.sku || 'Variant'))}" SKU` });
}
// Check Price
@@ -254,7 +254,7 @@ export class CompletenessService {
if (variant.price !== undefined && variant.price !== null && Number(variant.price) > 0) {
variantsFulfilledChecks++;
} else {
missingGeneral.push({ code: `variant_${variant.id}_price`, name: `Variant "${variant.name.split(' - ')[1] || variant.name}" Price` });
missingGeneral.push({ code: `variant_${variant.id}_price`, name: `Variant "${((variant.name && typeof variant.name === 'string') ? (variant.name.split(' - ')[1] || variant.name) : (variant.sku || 'Variant'))}" Price` });
}
// Check assets: if there are any variant-eligible asset types, they must be assigned to this variant!
@@ -271,7 +271,7 @@ export class CompletenessService {
if (hasAsset) {
variantsFulfilledChecks++;
} else {
missingGeneral.push({ code: `variant_${variant.id}_asset_${at.code}`, name: `Variant "${variant.name.split(' - ')[1] || variant.name}" ${at.name}` });
missingGeneral.push({ code: `variant_${variant.id}_asset_${at.code}`, name: `Variant "${((variant.name && typeof variant.name === 'string') ? (variant.name.split(' - ')[1] || variant.name) : (variant.sku || 'Variant'))}" ${at.name}` });
}
}
}
@@ -10,6 +10,7 @@ const applyProductTenantScope = (where = {}, context = {}) => {
export class ProductRepository {
async findAll(options = {}, context = {}) {
const queryOptions = {
order: [['created_at', 'DESC']],
...options,
where: applyProductTenantScope(options.where || {}, context)
};
@@ -68,79 +69,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']
}
]
},
@@ -46,7 +46,7 @@ export class VariantRepository {
// there is no global baseline variant that tenant workspaces should inherit.
const where = applyTenantWriteScope(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 = {}) {
@@ -0,0 +1,40 @@
"use strict";
const TABLE = "tenants";
module.exports = {
async up(queryInterface, Sequelize) {
const columns = await queryInterface.describeTable(TABLE);
const additions = [
["plan_name", { type: Sequelize.STRING(50), allowNull: false, defaultValue: "STARTER" }],
["max_users", { type: Sequelize.INTEGER, allowNull: false, defaultValue: 10 }],
["max_products", { type: Sequelize.INTEGER, allowNull: false, defaultValue: 5000 }],
["storage_limit_mb", { type: Sequelize.INTEGER, allowNull: false, defaultValue: 5000 }],
["subscription_expires_at", { type: Sequelize.DATE, allowNull: true }]
];
for (const [name, definition] of additions) {
if (!columns[name]) {
await queryInterface.addColumn(TABLE, name, definition);
}
}
},
async down(queryInterface) {
const columns = await queryInterface.describeTable(TABLE);
const removals = [
"subscription_expires_at",
"storage_limit_mb",
"max_products",
"max_users",
"plan_name"
];
for (const name of removals) {
if (columns[name]) {
await queryInterface.removeColumn(TABLE, name);
}
}
}
};
@@ -0,0 +1,34 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
for (const table of ["asset_types", "asset_families"]) {
const columns = await queryInterface.describeTable(table);
if (!columns.tenant_id) {
await queryInterface.addColumn(table, "tenant_id", {
type: Sequelize.INTEGER,
allowNull: true,
references: { model: "tenants", key: "id" },
onUpdate: "CASCADE",
onDelete: "CASCADE"
});
}
await queryInterface.addIndex(table, ["tenant_id"], {
name: `${table}_tenant_id_idx`
}).catch((error) => {
if (!String(error?.message || "").toLowerCase().includes("already exists")) throw error;
});
}
},
async down(queryInterface) {
for (const table of ["asset_families", "asset_types"]) {
await queryInterface.removeIndex(table, `${table}_tenant_id_idx`).catch(() => {});
const columns = await queryInterface.describeTable(table);
if (columns.tenant_id) {
await queryInterface.removeColumn(table, "tenant_id");
}
}
}
};
@@ -0,0 +1,109 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
const definitions = {
users: {
is_saas_user: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
reset_otp: { type: Sequelize.STRING(10), allowNull: true },
reset_otp_expiry: { type: Sequelize.DATE, allowNull: true }
},
products: {
metadata: { type: Sequelize.JSON, allowNull: true, defaultValue: {} }
},
catalogs: {
tenant_id: { type: Sequelize.INTEGER, allowNull: true },
attribute_set_id: {
type: Sequelize.UUID,
allowNull: true,
references: { model: "attribute_sets", key: "id" },
onUpdate: "CASCADE",
onDelete: "SET NULL"
}
},
categories: { tenant_id: { type: Sequelize.INTEGER, allowNull: true } },
brands: { tenant_id: { type: Sequelize.INTEGER, allowNull: true } },
units: { tenant_id: { type: Sequelize.INTEGER, allowNull: true } },
auditlogs: { tenant_id: { type: Sequelize.INTEGER, allowNull: true } },
attributes: {
tenant_id: { type: Sequelize.INTEGER, allowNull: true },
help_text: { type: Sequelize.TEXT, allowNull: true },
placeholder: { type: Sequelize.STRING(255), allowNull: true },
sortable: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
visible_in_grid: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true },
visible_in_product: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true },
api_visible: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true }
},
attribute_groups: { tenant_id: { type: Sequelize.INTEGER, allowNull: true } },
attribute_sets: { tenant_id: { type: Sequelize.INTEGER, allowNull: true } },
syndication_jobs: {
triggered_by: { type: Sequelize.INTEGER, allowNull: true },
total_products: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
success_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
failed_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
error_log: { type: Sequelize.JSONB, allowNull: true, defaultValue: [] },
started_at: { type: Sequelize.DATE, allowNull: true },
completed_at: { type: Sequelize.DATE, allowNull: true }
},
workflow_registries: { tenant_id: { type: Sequelize.INTEGER, allowNull: true } },
product_variants: {
cost_price: { type: Sequelize.DECIMAL(12, 2), allowNull: true, defaultValue: 0 },
currency: { type: Sequelize.STRING(10), allowNull: true, defaultValue: "USD" }
},
assets: { tenant_id: { type: Sequelize.INTEGER, allowNull: true } },
asset_folders: { tenant_id: { type: Sequelize.INTEGER, allowNull: true } }
};
for (const [table, columnsToAdd] of Object.entries(definitions)) {
const existing = await queryInterface.describeTable(table);
for (const [column, definition] of Object.entries(columnsToAdd)) {
if (!existing[column]) {
await queryInterface.addColumn(table, column, definition);
}
}
}
const tenantScopedTables = [
"catalogs", "categories", "brands", "units", "auditlogs", "attributes",
"attribute_groups", "attribute_sets", "workflow_registries", "assets", "asset_folders"
];
for (const table of tenantScopedTables) {
const name = `${table}_tenant_id_idx`;
const indexes = await queryInterface.showIndex(table);
if (!indexes.some((index) => index.name === name)) {
await queryInterface.addIndex(table, ["tenant_id"], { name });
}
}
},
async down(queryInterface) {
const removals = {
asset_folders: ["tenant_id"],
assets: ["tenant_id"],
product_variants: ["currency", "cost_price"],
workflow_registries: ["tenant_id"],
syndication_jobs: ["completed_at", "started_at", "error_log", "failed_count", "success_count", "total_products", "triggered_by"],
attribute_sets: ["tenant_id"],
attribute_groups: ["tenant_id"],
attributes: ["api_visible", "visible_in_product", "visible_in_grid", "sortable", "placeholder", "help_text", "tenant_id"],
auditlogs: ["tenant_id"],
units: ["tenant_id"],
brands: ["tenant_id"],
categories: ["tenant_id"],
catalogs: ["attribute_set_id", "tenant_id"],
products: ["metadata"],
users: ["reset_otp_expiry", "reset_otp", "is_saas_user"]
};
for (const [table, columnsToRemove] of Object.entries(removals)) {
const indexName = `${table}_tenant_id_idx`;
await queryInterface.removeIndex(table, indexName).catch(() => {});
const existing = await queryInterface.describeTable(table);
for (const column of columnsToRemove) {
if (existing[column]) {
await queryInterface.removeColumn(table, column);
}
}
}
}
};
@@ -0,0 +1,23 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.changeColumn("users", "password_hash", {
type: Sequelize.STRING(255),
allowNull: true
});
},
async down(queryInterface, Sequelize) {
const [rows] = await queryInterface.sequelize.query(
"SELECT COUNT(*)::int AS count FROM users WHERE password_hash IS NULL"
);
if (Number(rows?.[0]?.count || 0) > 0) {
throw new Error("Cannot restore NOT NULL password_hash while passwordless SaaS users exist");
}
await queryInterface.changeColumn("users", "password_hash", {
type: Sequelize.STRING(255),
allowNull: false
});
}
};
@@ -0,0 +1,76 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
const existing = await queryInterface.describeTable('channel_field_mappings').catch(() => null);
if (existing) return;
await queryInterface.createTable('channel_field_mappings', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
allowNull: false
},
tenant_id: {
type: Sequelize.INTEGER,
allowNull: true,
references: { model: 'tenants', key: 'id' },
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
},
channel_id: {
type: Sequelize.UUID,
allowNull: false,
references: { model: 'channels', key: 'id' },
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
},
pim_attribute_code: {
type: Sequelize.STRING(100),
allowNull: false
},
channel_field_code: {
type: Sequelize.STRING(100),
allowNull: false
},
transformation_rule: {
type: Sequelize.STRING(50),
allowNull: false,
defaultValue: 'none'
},
default_value: {
type: Sequelize.TEXT,
allowNull: true
},
is_required: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
}
});
await queryInterface.addIndex('channel_field_mappings', ['tenant_id', 'channel_id'], {
name: 'channel_field_mappings_tenant_channel_idx'
});
await queryInterface.addIndex(
'channel_field_mappings',
['tenant_id', 'channel_id', 'pim_attribute_code', 'channel_field_code'],
{ name: 'channel_field_mappings_unique', unique: true }
);
},
async down(queryInterface) {
await queryInterface.dropTable('channel_field_mappings');
}
};
+278
View File
@@ -0,0 +1,278 @@
import { models } from '../shared/database/models.js';
import { generateUniqueCode } from '../utils/helpers/code.utils.js';
export async function seedTenantStarterData(tenantId, { transaction = null } = {}) {
const tId = Number(tenantId);
if (!tId) {
throw new Error('Valid numeric tenantId is required to seed starter data');
}
const results = {
units: [],
brands: [],
categories: [],
families: [],
channels: [],
products: []
};
// 1. Units
const unitsData = [
{ name: 'Pieces', code: 'pcs', symbol: 'pcs', unitType: 'quantity', description: 'Standard piece count' },
{ name: 'Kilograms', code: 'kg', symbol: 'kg', unitType: 'weight', description: 'Standard metric weight' },
{ name: 'Boxes', code: 'box', symbol: 'box', unitType: 'packaging', description: 'Packaged carton or box' },
{ name: 'Meters', code: 'm', symbol: 'm', unitType: 'length', description: 'Linear length measurement' }
];
for (const u of unitsData) {
let unit = await models.Unit.findOne({
where: { tenant_id: tId, name: u.name },
transaction
});
if (!unit) {
const code = await generateUniqueCode(models.Unit, u.code, 'code', transaction);
unit = await models.Unit.create({
tenant_id: tId,
name: u.name,
code,
symbol: u.symbol,
unitType: u.unitType,
description: u.description,
status: 'active'
}, { transaction });
}
results.units.push(unit);
}
const defaultUnit = results.units[0];
// 2. Brands
const brandsData = [
{ name: 'Maskan Tech', code: 'maskan', description: 'Maskan Enterprise Technology & Hardware' },
{ name: 'Apex Essentials', code: 'apex', description: 'Premium computing peripherals and lifestyle accessories' },
{ name: 'Nova Dynamics', code: 'nova', description: 'Smart connected audio and workspace hardware' }
];
for (const b of brandsData) {
let brand = await models.Brand.findOne({
where: { tenant_id: tId, name: b.name },
transaction
});
if (!brand) {
const code = await generateUniqueCode(models.Brand, b.code, 'code', transaction);
brand = await models.Brand.create({
tenant_id: tId,
name: b.name,
code,
description: b.description,
status: 'active'
}, { transaction });
}
results.brands.push(brand);
}
// 3. Categories
let electronicsCat = await models.Categorie.findOne({
where: { tenant_id: tId, name: 'Electronics' },
transaction
});
if (!electronicsCat) {
const code = await generateUniqueCode(models.Categorie, 'electronics', 'code', transaction);
electronicsCat = await models.Categorie.create({
tenant_id: tId,
name: 'Electronics',
code,
path: '/electronics',
level: 0,
status: 'active'
}, { transaction });
}
results.categories.push(electronicsCat);
let compAccCat = await models.Categorie.findOne({
where: { tenant_id: tId, name: 'Computer Accessories' },
transaction
});
if (!compAccCat) {
const code = await generateUniqueCode(models.Categorie, 'comp_accessories', 'code', transaction);
compAccCat = await models.Categorie.create({
tenant_id: tId,
parent_id: electronicsCat.id,
name: 'Computer Accessories',
code,
path: '/electronics/comp_accessories',
level: 1,
status: 'active'
}, { transaction });
}
results.categories.push(compAccCat);
let audioCat = await models.Categorie.findOne({
where: { tenant_id: tId, name: 'Audio & Sound' },
transaction
});
if (!audioCat) {
const code = await generateUniqueCode(models.Categorie, 'audio_sound', 'code', transaction);
audioCat = await models.Categorie.create({
tenant_id: tId,
parent_id: electronicsCat.id,
name: 'Audio & Sound',
code,
path: '/electronics/audio_sound',
level: 1,
status: 'active'
}, { transaction });
}
results.categories.push(audioCat);
let officeCat = await models.Categorie.findOne({
where: { tenant_id: tId, name: 'Office & Workspace' },
transaction
});
if (!officeCat) {
const code = await generateUniqueCode(models.Categorie, 'office_workspace', 'code', transaction);
officeCat = await models.Categorie.create({
tenant_id: tId,
name: 'Office & Workspace',
code,
path: '/office_workspace',
level: 0,
status: 'active'
}, { transaction });
}
results.categories.push(officeCat);
// 4. Product Families (Catalogs)
const familiesData = [
{ name: 'Computer Peripherals', code: 'computer_peripherals', category_id: compAccCat.id, description: 'Mice, keyboards, docks, and input devices' },
{ name: 'Consumer Audio', code: 'consumer_audio', category_id: audioCat.id, description: 'Headphones, earphones, and audio hardware' },
{ name: 'Workspace Ergonomics', code: 'workspace_ergonomics', category_id: officeCat.id, description: 'Laptop stands, mounts, and desk accessories' }
];
for (const f of familiesData) {
let family = await models.Catalog.findOne({
where: { tenant_id: tId, name: f.name },
transaction
});
if (!family) {
const code = await generateUniqueCode(models.Catalog, f.code, 'code', transaction);
family = await models.Catalog.create({
tenant_id: tId,
name: f.name,
code,
category_id: f.category_id,
description: f.description,
status: 'active'
}, { transaction });
}
results.families.push(family);
}
// 5. Channels
const ecomType = await models.ChannelType.findOne({ where: { code: 'ecommerce' }, transaction });
const mktType = await models.ChannelType.findOne({ where: { code: 'marketplace' }, transaction });
const erpType = await models.ChannelType.findOne({ where: { code: 'erp' }, transaction });
const channelsData = [
{ name: 'Shopify Online Store', code: 'shopify_online_store', type_id: ecomType?.id, description: 'Official online direct-to-consumer store' },
{ name: 'Amazon Marketplace', code: 'amazon_marketplace', type_id: mktType?.id, description: 'Amazon B2C marketplace channel' },
{ name: 'Maskan ERP & POS', code: 'maskan_erp_pos', type_id: erpType?.id, description: 'Central Inventory ERP & Store Point of Sale' }
];
for (const ch of channelsData) {
let channel = await models.Channel.findOne({
where: { tenant_id: tId, name: ch.name },
transaction
});
if (!channel) {
const code = await generateUniqueCode(models.Channel, ch.code, 'code', transaction);
channel = await models.Channel.create({
tenant_id: tId,
name: ch.name,
code,
type_id: ch.type_id,
description: ch.description,
status: 'active',
metadata: { allowPublishing: true }
}, { transaction });
}
results.channels.push(channel);
}
// 6. Products
const sampleProducts = [
{
name: 'Wireless Ergonomic Mouse',
code: 'MSK-WEM-001',
description: 'Precision ergonomic wireless mouse with multi-device Bluetooth and 2.4GHz connectivity.',
category_id: compAccCat.id,
brand_id: results.brands[0].id,
unit_id: defaultUnit.id,
family_id: results.families[0].id
},
{
name: 'RGB Mechanical Keyboard',
code: 'MSK-RMK-002',
description: 'Custom hot-swappable mechanical keyboard with per-key RGB backlighting and aluminum body.',
category_id: compAccCat.id,
brand_id: results.brands[0].id,
unit_id: defaultUnit.id,
family_id: results.families[0].id
},
{
name: 'Ultra-Slim USB-C Multiport Hub',
code: 'MSK-UCH-003',
description: '7-in-1 USB-C Hub with 4K HDMI, 100W Power Delivery, Gigabit Ethernet, and SD card slots.',
category_id: compAccCat.id,
brand_id: results.brands[1].id,
unit_id: defaultUnit.id,
family_id: results.families[0].id
},
{
name: 'Active Noise Cancelling Headphones',
code: 'MSK-ANC-004',
description: 'Over-ear wireless headphones with hybrid active noise cancellation and 40-hour battery life.',
category_id: audioCat.id,
brand_id: results.brands[2].id,
unit_id: defaultUnit.id,
family_id: results.families[1].id
},
{
name: 'Adjustable Aluminum Laptop Stand',
code: 'MSK-ALS-005',
description: 'Ergonomic multi-angle foldable aluminum laptop riser with heat dissipation pads.',
category_id: officeCat.id,
brand_id: results.brands[1].id,
unit_id: defaultUnit.id,
family_id: results.families[2].id
}
];
for (const p of sampleProducts) {
let product = await models.Product.findOne({
where: { tenant_id: tId, name: p.name },
transaction
});
if (!product) {
const code = await generateUniqueCode(models.Product, p.code, 'code', transaction);
const slug = code.toLowerCase().replace(/[^a-z0-9]+/g, '-');
product = await models.Product.create({
tenant_id: tId,
name: p.name,
code,
slug,
description: p.description,
short_description: p.description.slice(0, 100),
status: 'active',
is_active: true,
category_id: p.category_id,
brand_id: p.brand_id,
unit_id: p.unit_id,
family_id: p.family_id
}, { transaction });
}
results.products.push(product);
}
return results;
}
+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: {
+3 -3
View File
@@ -142,9 +142,9 @@ export const connectDatabase = async () => {
}
} catch (error) {
console.error('Unable to connect to the database:', error);
if (env === 'production') {
throw error;
}
// A web server without a usable schema is never healthy. Fail startup in
// every environment instead of exposing routes that can only return 500.
throw error;
}
};