Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
355dddc429 | ||
|
|
6b6252db61 | ||
|
|
ad416d631a | ||
|
|
e9748a74a2 | ||
|
|
53a0238a23 | ||
|
|
00cf7aed76 |
+3
-2
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
Executable
+110
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user