feat(tenant-engine): enhance tenant provisioning with subscription quotas, baseline roles, email onboarding & fix double-hashing bug
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: `
|
||||
<div style="font-family: Arial, sans-serif; padding: 20px; color: #333;">
|
||||
<h2 style="color: #4f46e5;">Welcome to Maskan PIM!</h2>
|
||||
<p>Your enterprise product information workspace for <strong>${tenant.tenant_name}</strong> has been successfully provisioned.</p>
|
||||
<h3>Workspace Details</h3>
|
||||
<ul>
|
||||
<li><strong>Tenant Code:</strong> ${tenant.tenant_code}</li>
|
||||
<li><strong>Plan:</strong> ${tenant.plan_name} (${tenant.max_products.toLocaleString()} product limit)</li>
|
||||
<li><strong>Admin Email:</strong> ${adminUser.email}</li>
|
||||
</ul>
|
||||
<p>You can now log in at your workspace URL to manage your catalog items and team members.</p>
|
||||
<br/>
|
||||
<p>Best regards,<br/><strong>Maskan PIM Platform Team</strong></p>
|
||||
</div>
|
||||
`
|
||||
}).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 ? {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user