diff --git a/src/features/authentication/users/user.model.js b/src/features/authentication/users/user.model.js index d61539f..f342fd9 100644 --- a/src/features/authentication/users/user.model.js +++ b/src/features/authentication/users/user.model.js @@ -93,13 +93,13 @@ export default (sequelize) => { updatedAt: 'updated_at', hooks: { beforeCreate: async (user) => { - if (user.password_hash) { + if (user.password_hash && !user.password_hash.startsWith('$2b$') && !user.password_hash.startsWith('$2a$')) { const salt = await bcrypt.genSalt(10); user.password_hash = await bcrypt.hash(user.password_hash, salt); } }, beforeUpdate: async (user) => { - if (user.changed('password_hash')) { + if (user.changed('password_hash') && !user.password_hash.startsWith('$2b$') && !user.password_hash.startsWith('$2a$')) { const salt = await bcrypt.genSalt(10); user.password_hash = await bcrypt.hash(user.password_hash, salt); } diff --git a/src/features/organization/org/platform.service.js b/src/features/organization/org/platform.service.js index 617f7a5..b77ea3a 100644 --- a/src/features/organization/org/platform.service.js +++ b/src/features/organization/org/platform.service.js @@ -2,8 +2,15 @@ import { models, sequelize } from '../../../shared/database/models.js'; import { ApiError } from '../../../utils/helpers/ApiError.utils.js'; import { generateUniqueCode } from '../../../utils/helpers/code.utils.js'; import { generateToken } from '../../../utils/helpers/jwt.utils.js'; +import { sendEmail } from '../../../shared/utils/email.js'; import bcrypt from 'bcrypt'; +const PLAN_LIMITS = { + STARTER: { max_users: 10, max_products: 5000, storage_limit_mb: 5000 }, + PROFESSIONAL: { max_users: 50, max_products: 50000, storage_limit_mb: 50000 }, + ENTERPRISE: { max_users: 500, max_products: 500000, storage_limit_mb: 500000 } +}; + export class PlatformService { async getTenants(query = {}) { const where = {}; @@ -39,12 +46,13 @@ export class PlatformService { } async provisionTenant(data, context = {}) { - const { tenant_name, domain, contact_email, admin_name, admin_email, admin_password } = data; + const { tenant_name, domain, contact_email, plan_name = 'STARTER', admin_name, admin_email, admin_password } = data; if (!tenant_name || !contact_email) { throw new ApiError(400, 'Tenant name and contact email are required'); } + const selectedPlan = PLAN_LIMITS[plan_name.toUpperCase()] || PLAN_LIMITS.STARTER; const transaction = await sequelize.transaction(); try { @@ -56,16 +64,20 @@ export class PlatformService { const tenantCode = await generateUniqueCode(models.Tenant, tenant_name, 'tenant_code', transaction); - // 2. Create Tenant Record + // 2. Create Tenant Record with Quota Limits const tenant = await models.Tenant.create({ tenant_name, tenant_code: tenantCode, domain: domain ? domain.toLowerCase().trim() : null, contact_email, + plan_name: plan_name.toUpperCase(), + max_users: selectedPlan.max_users, + max_products: selectedPlan.max_products, + storage_limit_mb: selectedPlan.storage_limit_mb, status: true }, { transaction }); - // 3. Create Tenant Admin Role for the new Tenant + // 3. Create Baseline Tenant Roles for the new Tenant const tenantAdminRole = await models.Role.create({ tenant_id: tenant.id, role_code: 'TENANT_ADMIN', @@ -76,6 +88,26 @@ export class PlatformService { status: true }, { transaction }); + const catalogManagerRole = await models.Role.create({ + tenant_id: tenant.id, + role_code: 'CATALOG_MANAGER', + role_name: 'Catalog Manager', + description: 'Manage products, families, categories, attributes, and digital assets', + role_type: 'tenant', + is_system_role: false, + status: true + }, { transaction }); + + const catalogViewerRole = await models.Role.create({ + tenant_id: tenant.id, + role_code: 'CATALOG_VIEWER', + role_name: 'Catalog Viewer', + description: 'Read-only view access across tenant catalog items and assets', + role_type: 'tenant', + is_system_role: false, + status: true + }, { transaction }); + // Assign all non-platform feature nodes to Tenant Admin role const featureNodes = await models.PermissionNode.findAll({ where: { node_type: 'feature' }, @@ -84,6 +116,7 @@ export class PlatformService { for (const node of featureNodes) { if (!node.node_code.startsWith('platform.')) { + // TENANT_ADMIN gets full access await models.RolePermission.create({ role_id: tenantAdminRole.id, node_id: node.id, @@ -95,6 +128,34 @@ export class PlatformService { can_export: true, can_import: true }, { transaction }); + + // CATALOG_MANAGER gets catalog access + if (node.node_code.startsWith('products') || node.node_code.startsWith('masters')) { + await models.RolePermission.create({ + role_id: catalogManagerRole.id, + node_id: node.id, + can_view: true, + can_create: true, + can_edit: true, + can_delete: false, + can_alter: false, + can_export: true, + can_import: true + }, { transaction }); + } + + // CATALOG_VIEWER gets view-only + await models.RolePermission.create({ + role_id: catalogViewerRole.id, + node_id: node.id, + can_view: true, + can_create: false, + can_edit: false, + can_delete: false, + can_alter: false, + can_export: false, + can_import: false + }, { transaction }); } } @@ -122,6 +183,29 @@ export class PlatformService { await transaction.commit(); + // 5. Send Onboarding Welcome Email asynchronously + if (adminUser) { + sendEmail({ + to: adminUser.email, + subject: `Welcome to Maskan PIM — ${tenant.tenant_name} Workspace Provisioned`, + html: ` +
+

Welcome to Maskan PIM!

+

Your enterprise product information workspace for ${tenant.tenant_name} has been successfully provisioned.

+

Workspace Details

+ +

You can now log in at your workspace URL to manage your catalog items and team members.

+
+

Best regards,
Maskan PIM Platform Team

+
+ ` + }).catch(err => console.error('Failed to send tenant welcome email:', err.message)); + } + return { tenant: { id: tenant.id, @@ -129,6 +213,10 @@ export class PlatformService { tenant_code: tenant.tenant_code, domain: tenant.domain, contact_email: tenant.contact_email, + plan_name: tenant.plan_name, + max_users: tenant.max_users, + max_products: tenant.max_products, + storage_limit_mb: tenant.storage_limit_mb, status: tenant.status }, admin: adminUser ? { diff --git a/src/features/organization/org/tenant.model.js b/src/features/organization/org/tenant.model.js index f5d8d39..436d62f 100644 --- a/src/features/organization/org/tenant.model.js +++ b/src/features/organization/org/tenant.model.js @@ -37,6 +37,25 @@ export default (sequelize) => { status: { type: DataTypes.BOOLEAN, defaultValue: true + }, + plan_name: { + type: DataTypes.STRING(50), + defaultValue: 'STARTER' + }, + max_users: { + type: DataTypes.INTEGER, + defaultValue: 10 + }, + max_products: { + type: DataTypes.INTEGER, + defaultValue: 5000 + }, + storage_limit_mb: { + type: DataTypes.INTEGER, + defaultValue: 5000 + }, + subscription_expires_at: { + type: DataTypes.DATE } }, { sequelize,