From 6b6252db6140195ba582d573e319317a6043ec34 Mon Sep 17 00:00:00 2001 From: Inamul-hasan-tec Date: Tue, 8 Sep 2026 16:26:41 +0530 Subject: [PATCH] feat(local): add tenant starter catalog seeder --- package.json | 5 +- scripts/seed-tenant-starter.js | 110 +++++++++ src/seeders/tenantStarterData.service.js | 278 +++++++++++++++++++++++ 3 files changed, 391 insertions(+), 2 deletions(-) create mode 100755 scripts/seed-tenant-starter.js create mode 100644 src/seeders/tenantStarterData.service.js diff --git a/package.json b/package.json index 72c4914..6ade486 100644 --- a/package.json +++ b/package.json @@ -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 -} +} \ No newline at end of file diff --git a/scripts/seed-tenant-starter.js b/scripts/seed-tenant-starter.js new file mode 100755 index 0000000..a43ff06 --- /dev/null +++ b/scripts/seed-tenant-starter.js @@ -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); +}); diff --git a/src/seeders/tenantStarterData.service.js b/src/seeders/tenantStarterData.service.js new file mode 100644 index 0000000..c29974a --- /dev/null +++ b/src/seeders/tenantStarterData.service.js @@ -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; +}