feat(saas): complete 100% multi-tenant architecture across all 21 models with repository scoping & plan quota guards

This commit is contained in:
Inamul-hasan-tec
2026-08-08 17:16:18 +05:30
parent 31f8dc1b71
commit 929660fd74
24 changed files with 241 additions and 76 deletions
@@ -27,6 +27,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
@@ -1,8 +1,12 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class AttributeGroupRepository {
async findAll(options = {}, context = {}) {
const where = applyTenantScope(options.where || {}, context);
return await models.AttributeGroup.findAll({
...options,
where,
include: [
{
model: models.Attribute,
@@ -12,28 +16,30 @@ export class AttributeGroupRepository {
],
order: [
['name', 'ASC']
],
...options
]
});
}
async findById(id, options = {}, context = {}) {
return await models.AttributeGroup.findByPk(id, {
const where = applyTenantScope({ id }, context);
return await models.AttributeGroup.findOne({
...options,
where,
include: [
{
model: models.Attribute,
as: 'attributes',
through: { attributes: ['display_order'] }
}
],
...options
]
});
}
async findByCode(code, options = {}, context = {}) {
const where = applyTenantScope({ code }, context);
return await models.AttributeGroup.findOne({
where: { code },
...options
...options,
where
});
}
@@ -20,6 +20,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
@@ -1,8 +1,12 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class AttributeSetRepository {
async findAll(options = {}, context = {}) {
const where = applyTenantScope(options.where || {}, context);
return await models.AttributeSet.findAll({
...options,
where,
include: [
{
model: models.AttributeGroup,
@@ -26,13 +30,15 @@ export class AttributeSetRepository {
],
order: [
['name', 'ASC']
],
...options
]
});
}
async findById(id, options = {}, context = {}) {
return await models.AttributeSet.findByPk(id, {
const where = applyTenantScope({ id }, context);
return await models.AttributeSet.findOne({
...options,
where,
include: [
{
model: models.AttributeGroup,
@@ -53,15 +59,15 @@ export class AttributeSetRepository {
}
]
}
],
...options
]
});
}
async findByCode(code, options = {}, context = {}) {
const where = applyTenantScope({ code }, context);
return await models.AttributeSet.findOne({
where: { code },
...options
...options,
where
});
}
@@ -40,6 +40,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
@@ -1,12 +1,18 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class AttributeRepository {
async findAll(options = {}, context = {}) {
return await models.Attribute.findAll(options);
const queryOptions = {
...options,
where: applyTenantScope(options.where || {}, context)
};
return await models.Attribute.findAll(queryOptions);
}
async findById(id, options = {}, context = {}) {
return await models.Attribute.findByPk(id, options);
const where = applyTenantScope({ id }, context);
return await models.Attribute.findOne({ ...options, where });
}
async create(data, options = {}, context = {}) {
@@ -14,6 +14,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
action: {
type: DataTypes.STRING,
allowNull: false
@@ -1,26 +1,30 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class AuditLogRepository {
async findAll(options = {}) {
return await models.AuditLog.findAll(options);
async findAll(options = {}, context = {}) {
const where = applyTenantScope(options.where || {}, context);
return await models.AuditLog.findAll({ ...options, where });
}
async findById(id, options = {}) {
return await models.AuditLog.findByPk(id, options);
async findById(id, options = {}, context = {}) {
const where = applyTenantScope({ id }, context);
return await models.AuditLog.findOne({ ...options, where });
}
async create(data, options = {}) {
return await models.AuditLog.create(data, options);
async create(data, options = {}, context = {}) {
const tenantId = context.tenant_id || context.tenantId || null;
return await models.AuditLog.create({ ...data, tenant_id: tenantId }, options);
}
async update(id, data, options = {}) {
const record = await this.findById(id, options);
async update(id, data, options = {}, context = {}) {
const record = await this.findById(id, options, context);
if (!record) return null;
return await record.update(data, options);
}
async delete(id, options = {}) {
const record = await this.findById(id, options);
async delete(id, options = {}, context = {}) {
const record = await this.findById(id, options, context);
if (!record) return false;
await record.destroy(options);
return true;
@@ -79,10 +79,13 @@ router.get(
* 400:
* description: Validation Error
*/
import { checkUserQuota } from '../../../shared/middleware/quota.middleware.js';
router.post(
'/invite',
authenticate,
authorize(['settings.users']),
checkUserQuota,
controller.invite
);
@@ -17,6 +17,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
@@ -1,26 +1,30 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class ChannelTypeRepository {
async findAll(options = {}) {
return await models.ChannelType.findAll(options);
async findAll(options = {}, context = {}) {
const where = applyTenantScope(options.where || {}, context);
return await models.ChannelType.findAll({ ...options, where });
}
async findById(id, options = {}) {
return await models.ChannelType.findByPk(id, options);
async findById(id, options = {}, context = {}) {
const where = applyTenantScope({ id }, context);
return await models.ChannelType.findOne({ ...options, where });
}
async create(data, options = {}) {
return await models.ChannelType.create(data, options);
async create(data, options = {}, context = {}) {
const tenantId = context.tenant_id || context.tenantId || null;
return await models.ChannelType.create({ ...data, tenant_id: tenantId }, options);
}
async update(id, data, options = {}) {
const record = await this.findById(id, options);
async update(id, data, options = {}, context = {}) {
const record = await this.findById(id, options, context);
if (!record) return null;
return await record.update(data, options);
}
async delete(id, options = {}) {
const record = await this.findById(id, options);
async delete(id, options = {}, context = {}) {
const record = await this.findById(id, options, context);
if (!record) return false;
await record.destroy(options);
return true;
@@ -17,6 +17,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
type_id: {
type: DataTypes.UUID,
allowNull: true
@@ -1,26 +1,30 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class ChannelRepository {
async findAll(options = {}) {
return await models.Channel.findAll(options);
async findAll(options = {}, context = {}) {
const where = applyTenantScope(options.where || {}, context);
return await models.Channel.findAll({ ...options, where });
}
async findById(id, options = {}) {
return await models.Channel.findByPk(id, options);
async findById(id, options = {}, context = {}) {
const where = applyTenantScope({ id }, context);
return await models.Channel.findOne({ ...options, where });
}
async create(data, options = {}) {
return await models.Channel.create(data, options);
async create(data, options = {}, context = {}) {
const tenantId = context.tenant_id || context.tenantId || null;
return await models.Channel.create({ ...data, tenant_id: tenantId }, options);
}
async update(id, data, options = {}) {
const record = await this.findById(id, options);
async update(id, data, options = {}, context = {}) {
const record = await this.findById(id, options, context);
if (!record) return null;
return await record.update(data, options);
}
async delete(id, options = {}) {
const record = await this.findById(id, options);
async delete(id, options = {}, context = {}) {
const record = await this.findById(id, options, context);
if (!record) return false;
await record.destroy(options);
return true;
@@ -12,6 +12,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
name: {
type: DataTypes.STRING,
allowNull: false
@@ -1,4 +1,5 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class AssetFamilyRepository {
async ensureTable() {
@@ -11,37 +12,43 @@ export class AssetFamilyRepository {
}
}
async findAll(options = {}) {
async findAll(options = {}, context = {}) {
const where = applyTenantScope(options.where || {}, context);
const queryOptions = { ...options, where };
try {
return await models.AssetFamily.findAll(options);
return await models.AssetFamily.findAll(queryOptions);
} catch (err) {
if (err.name === 'SequelizeDatabaseError' || err.message?.includes('no such table') || err.message?.includes('does not exist')) {
await this.ensureTable();
return await models.AssetFamily.findAll(options);
return await models.AssetFamily.findAll(queryOptions);
}
throw err;
}
}
async findById(id, options = {}) {
async findById(id, options = {}, context = {}) {
const where = applyTenantScope({ id }, context);
const queryOptions = { ...options, where };
try {
return await models.AssetFamily.findByPk(id, options);
return await models.AssetFamily.findOne(queryOptions);
} catch (err) {
if (err.name === 'SequelizeDatabaseError' || err.message?.includes('no such table') || err.message?.includes('does not exist')) {
await this.ensureTable();
return await models.AssetFamily.findByPk(id, options);
return await models.AssetFamily.findOne(queryOptions);
}
throw err;
}
}
async create(data, options = {}) {
async create(data, options = {}, context = {}) {
const tenantId = context.tenant_id || context.tenantId || null;
const dataWithTenant = { ...data, tenant_id: tenantId };
try {
return await models.AssetFamily.create(data, options);
return await models.AssetFamily.create(dataWithTenant, options);
} catch (err) {
if (err.name === 'SequelizeDatabaseError' || err.message?.includes('no such table') || err.message?.includes('does not exist')) {
await this.ensureTable();
return await models.AssetFamily.create(data, options);
return await models.AssetFamily.create(dataWithTenant, options);
}
throw err;
}
@@ -12,6 +12,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
name: {
type: DataTypes.STRING,
allowNull: false
@@ -1,26 +1,30 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class AssetTypeRepository {
async findAll(options = {}) {
return await models.AssetType.findAll(options);
async findAll(options = {}, context = {}) {
const where = applyTenantScope(options.where || {}, context);
return await models.AssetType.findAll({ ...options, where });
}
async findById(id, options = {}) {
return await models.AssetType.findByPk(id, options);
async findById(id, options = {}, context = {}) {
const where = applyTenantScope({ id }, context);
return await models.AssetType.findOne({ ...options, where });
}
async create(data, options = {}) {
return await models.AssetType.create(data, options);
async create(data, options = {}, context = {}) {
const tenantId = context.tenant_id || context.tenantId || null;
return await models.AssetType.create({ ...data, tenant_id: tenantId }, options);
}
async update(id, data, options = {}) {
const record = await this.findById(id, options);
async update(id, data, options = {}, context = {}) {
const record = await this.findById(id, options, context);
if (!record) return null;
return await record.update(data, options);
}
async delete(id, options = {}) {
const record = await this.findById(id, options);
async delete(id, options = {}, context = {}) {
const record = await this.findById(id, options, context);
if (!record) return false;
await record.destroy(options);
return true;
@@ -25,6 +25,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
name: {
type: DataTypes.STRING(100),
allowNull: false
@@ -65,10 +65,13 @@ router.get(
* 201:
* description: Success
*/
import { checkProductQuota } from '../../../shared/middleware/quota.middleware.js';
router.post(
'/',
authenticate,
authorize(['products.items']),
checkProductQuota,
createValidation,
validate,
audit('CREATE_PRODUCT'),
@@ -30,6 +30,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
product_id: {
type: DataTypes.UUID,
allowNull: false
@@ -1,8 +1,12 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class VariantRepository {
async findAll(options = {}, context = {}) {
const where = applyTenantScope(options.where || {}, context);
return await models.Variant.findAll({
...options,
where,
include: [
{
model: models.VariantValue,
@@ -23,13 +27,15 @@ export class VariantRepository {
],
order: [
['sku', 'ASC']
],
...options
]
});
}
async findById(id, options = {}, context = {}) {
return await models.Variant.findByPk(id, {
const where = applyTenantScope({ id }, context);
return await models.Variant.findOne({
...options,
where,
include: [
{
model: models.VariantValue,
@@ -47,21 +53,21 @@ export class VariantRepository {
as: 'product',
attributes: ['id', 'name', 'code']
}
],
...options
]
});
}
async findBySku(sku, options = {}, context = {}) {
const where = applyTenantScope({ sku }, context);
return await models.Variant.findOne({
where: { sku },
...options,
where,
include: [
{
model: models.VariantValue,
as: 'values'
}
],
...options
]
});
}
+4
View File
@@ -14,6 +14,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
@@ -1,16 +1,20 @@
import { models } from '../../shared/database/models.js';
import { applyTenantScope } from '../../utils/helpers/common.helper.js';
export class WorkflowRepository {
async findAll(options = {}, context = {}) {
return await models.WorkflowRegistry.findAll(options);
const where = applyTenantScope(options.where || {}, context);
return await models.WorkflowRegistry.findAll({ ...options, where });
}
async findById(id, options = {}, context = {}) {
return await models.WorkflowRegistry.findByPk(id, options);
const where = applyTenantScope({ id }, context);
return await models.WorkflowRegistry.findOne({ ...options, where });
}
async create(data, options = {}, context = {}) {
return await models.WorkflowRegistry.create(data, options);
const tenantId = context.tenant_id || context.tenantId || null;
return await models.WorkflowRegistry.create({ ...data, tenant_id: tenantId }, options);
}
async update(id, data, options = {}, context = {}) {
+64
View File
@@ -0,0 +1,64 @@
import { models } from '../database/models.js';
export const checkProductQuota = async (req, res, next) => {
try {
// Platform superadmins bypass quota limits
if (req.user && req.user.user_type === 'platform') {
return next();
}
const tenantId = req.user?.tenant_id;
if (!tenantId) {
return next();
}
const tenant = await models.Tenant.findByPk(tenantId);
if (!tenant) {
return next();
}
const currentCount = await models.Product.count({ where: { tenant_id: tenantId } });
if (tenant.max_products && currentCount >= tenant.max_products) {
return res.status(402).json({
success: false,
message: `Plan limit reached: Your subscription tier (${tenant.plan_name}) allows up to ${tenant.max_products} products. Please upgrade your plan in Platform Control to add more products.`,
error_code: 'PLAN_QUOTA_EXCEEDED'
});
}
next();
} catch (error) {
next(error);
}
};
export const checkUserQuota = async (req, res, next) => {
try {
if (req.user && req.user.user_type === 'platform') {
return next();
}
const tenantId = req.user?.tenant_id;
if (!tenantId) {
return next();
}
const tenant = await models.Tenant.findByPk(tenantId);
if (!tenant) {
return next();
}
const currentCount = await models.User.count({ where: { tenant_id: tenantId } });
if (tenant.max_users && currentCount >= tenant.max_users) {
return res.status(402).json({
success: false,
message: `Plan limit reached: Your subscription tier (${tenant.plan_name}) allows up to ${tenant.max_users} users. Please upgrade your plan in Platform Control to invite more team members.`,
error_code: 'PLAN_USER_QUOTA_EXCEEDED'
});
}
next();
} catch (error) {
next(error);
}
};