feat(core): setup multi-tenant isolation, Cloudinary DAM, RBAC fast-pass, distinct JWT refresh secrets & error shielding

This commit is contained in:
Inamul-hasan-tec
2026-08-07 18:31:35 +05:30
parent 46abddf81c
commit 2fff621fdd
31 changed files with 510 additions and 277 deletions
+18 -11
View File
@@ -1,15 +1,22 @@
# PORT=5000
# NODE_ENV=development
# CORS_ORIGIN=http://localhost:5173
# JWT_SECRET=supersecretjwtkeythatislongandsecure
# JWT_EXPIRES_IN=7d
PORT=5001
NODE_ENV=development
CORS_ORIGIN=http://localhost:5173
JWT_SECRET=supersecretjwtkeythatislongandsecure
JWT_REFRESH_SECRET=supersecretrefreshjwtkeythatislongandsecure
JWT_EXPIRES_IN=1d
JWT_REFRESH_EXPIRES_IN=7d
# DB_HOST=106.51.105.22
# DB_PORT=5432
# DB_NAME=pc_dev
# DB_USER=pc_user
# DB_PASSWORD="#TpW@%a&b$[zm"
# DB_DIALECT=postgres
CLOUDINARY_CLOUD_NAME=dbixmka2j
CLOUDINARY_API_KEY=634582295238882
CLOUDINARY_API_SECRET=0TAXlH3JHVvBHiqTMCwiDd9qfmM
CLOUDINARY_URL=cloudinary://634582295238882:0TAXlH3JHVvBHiqTMCwiDd9qfmM@dbixmka2j
DB_HOST=106.51.105.22
DB_PORT=5432
DB_NAME=pc_local
DB_USER=pc_user
DB_PASSWORD="#TpW@%a&b$[zm"
DB_DIALECT=postgres
# # IMPORTANT: Gmail SMTP requires an App Password, NOT your regular password.
# # Go to: https://myaccount.google.com -> Security -> 2-Step Verification -> App Passwords
+9 -2
View File
@@ -1,8 +1,15 @@
PORT=5000
PORT=5001
NODE_ENV=local
CORS_ORIGIN=http://localhost:5173
JWT_SECRET=supersecretjwtkeythatislongandsecure
JWT_EXPIRES_IN=7d
JWT_REFRESH_SECRET=supersecretrefreshjwtkeythatislongandsecure
JWT_EXPIRES_IN=1d
JWT_REFRESH_EXPIRES_IN=7d
CLOUDINARY_CLOUD_NAME=dbixmka2j
CLOUDINARY_API_KEY=634582295238882
CLOUDINARY_API_SECRET=0TAXlH3JHVvBHiqTMCwiDd9qfmM
CLOUDINARY_URL=cloudinary://634582295238882:0TAXlH3JHVvBHiqTMCwiDd9qfmM@dbixmka2j
DB_HOST=106.51.105.22
DB_PORT=5432
+20
View File
@@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"bcrypt": "^6.0.0",
"cloudinary": "^2.10.0",
"compression": "^1.7.5",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
@@ -670,6 +671,18 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/cloudinary": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.10.0.tgz",
"integrity": "sha512-sY09kYg7wprkndAOjZBAYqFZqwL+SxnEGcAvksOvFA+5upnFn949UjkEkHKNSwkBtW/xRDd0p6NgbSXZcxkI3w==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.23"
},
"engines": {
"node": ">=9"
}
},
"node_modules/color": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
@@ -2346,6 +2359,13 @@
"fn.name": "1.x.x"
}
},
"node_modules/openapi-types": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
"license": "MIT",
"peer": true
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+1
View File
@@ -22,6 +22,7 @@
},
"dependencies": {
"bcrypt": "^6.0.0",
"cloudinary": "^2.10.0",
"compression": "^1.7.5",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
@@ -2,6 +2,7 @@ import repository from './attributeGroup.repository.js';
import { models, sequelize } from '../../../shared/database/models.js';
import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import { generateUniqueCode } from '../../../utils/helpers/code.utils.js';
export class AttributeGroupService {
async getAll(query = {}, context = {}) {
@@ -23,21 +24,8 @@ export class AttributeGroupService {
async create(data, context = {}) {
const transaction = await sequelize.transaction();
try {
if (!data.code || !data.code.trim()) {
if (data.name) {
data.code = data.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
}
if (!data.code) {
data.code = `grp_${Date.now()}`;
}
}
data.code = data.code.toLowerCase().trim();
data.code = await generateUniqueCode(models.AttributeGroup, data.code || data.name, 'code', transaction);
// Check duplicate code
const existing = await models.AttributeGroup.findOne({ where: { code: data.code }, transaction });
if (existing) {
throw new Error(`Attribute Group with code "${data.code}" already exists`);
}
const record = await models.AttributeGroup.create(data, { transaction });
@@ -2,6 +2,7 @@ import repository from './attributeSet.repository.js';
import { models, sequelize } from '../../../shared/database/models.js';
import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import { generateUniqueCode } from '../../../utils/helpers/code.utils.js';
export class AttributeSetService {
async getAll(query = {}, context = {}) {
@@ -23,11 +24,8 @@ export class AttributeSetService {
async create(data, context = {}) {
const transaction = await sequelize.transaction();
try {
// Check duplicate code
const existing = await models.AttributeSet.findOne({ where: { code: data.code }, transaction });
if (existing) {
throw new Error(`Attribute Set with code "${data.code}" already exists`);
}
data.code = await generateUniqueCode(models.AttributeSet, data.code || data.name, 'code', transaction);
const record = await models.AttributeSet.create(data, { transaction });
@@ -3,8 +3,10 @@ import { models, sequelize } from '../../../shared/database/models.js';
import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import { generateUniqueCode } from '../../../utils/helpers/code.utils.js';
import { Op } from 'sequelize';
export class AttributeService {
async getAll(query = {}, context = {}) {
const where = {};
@@ -131,25 +133,7 @@ export class AttributeService {
async create(data, context = {}) {
const transaction = await sequelize.transaction();
try {
if (!data.code || !data.code.trim()) {
if (data.name) {
data.code = data.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
}
if (!data.code) {
data.code = `attr_${Date.now()}`;
}
}
data.code = data.code.toLowerCase().trim();
// Check duplicate code
const existing = await models.Attribute.findOne({
where: { code: data.code },
paranoid: false,
transaction
});
if (existing) {
throw new Error(`Attribute with code "${data.code}" already exists`);
}
data.code = await generateUniqueCode(models.Attribute, data.code || data.name, 'code', transaction, { paranoid: false });
// Automatically assign display order if not provided
if (data.display_order === undefined || data.display_order === null) {
@@ -57,6 +57,10 @@ export const login = async ({ email, password }) => {
throw new ApiError(401, 'Invalid email or password');
}
if (user.status === false) {
throw new ApiError(403, 'Account is disabled. Please contact your administrator.');
}
const isMatch = await user.validatePassword(password);
if (!isMatch) {
throw new ApiError(401, 'Invalid email or password');
@@ -124,6 +128,10 @@ export const refreshTokenAuth = async (oldRefreshToken) => {
throw new ApiError(401, 'Invalid refresh token');
}
if (user.status === false) {
throw new ApiError(403, 'Account is disabled');
}
const roleIds = user.roles.filter(r => r.UserRole.status).map(r => r.id);
const payload = {
+6 -17
View File
@@ -1,15 +1,11 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class BrandRepository {
async findAll(options = {}, context = {}) {
const queryOptions = {
...options,
where: {
...(options.where || {})
/* FUTURE_TENANT_ISOLATION_FLAG:
tenant_id: context.tenantId
*/
}
where: applyTenantScope(options.where || {}, context)
};
return await models.Brand.findAll(queryOptions);
}
@@ -17,22 +13,15 @@ export class BrandRepository {
async findById(id, options = {}, context = {}) {
const queryOptions = {
...options,
where: {
...(options.where || {})
/* FUTURE_TENANT_ISOLATION_FLAG:
tenant_id: context.tenantId
*/
}
where: applyTenantScope({ id, ...(options.where || {}) }, context)
};
return await models.Brand.findByPk(id, queryOptions);
return await models.Brand.findOne(queryOptions);
}
async create(data, options = {}, context = {}) {
const createData = {
...data
/* FUTURE_TENANT_ISOLATION_FLAG:
tenant_id: context.tenantId
*/
...data,
...(context.tenantId && context.userType !== 'platform' ? { tenant_id: context.tenantId } : {})
};
return await models.Brand.create(createData, options);
}
+2 -14
View File
@@ -4,6 +4,7 @@ import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import NotificationService from '../../notifications/notifications/notification.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import { generateUniqueCode } from '../../../utils/helpers/code.utils.js';
export class BrandService {
async getAll(query = {}, context = {}) {
@@ -23,21 +24,8 @@ export class BrandService {
}
async create(data, context = {}) {
if (!data.code || !data.code.trim()) {
if (data.name) {
data.code = data.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
}
if (!data.code) {
data.code = `brd_${Date.now()}`;
}
}
data.code = data.code.toLowerCase().trim();
data.code = await generateUniqueCode(models.Brand, data.code || data.name);
// Check duplicate code
const [existing] = await repository.findAll({ where: { code: data.code } }, context);
if (existing) {
throw new Error(`Brand with code "${data.code}" already exists`);
}
const record = await repository.create(data, {}, context);
+6 -17
View File
@@ -1,15 +1,11 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class UnitRepository {
async findAll(options = {}, context = {}) {
const queryOptions = {
...options,
where: {
...(options.where || {})
/* FUTURE_TENANT_ISOLATION_FLAG:
tenant_id: context.tenantId
*/
}
where: applyTenantScope(options.where || {}, context)
};
return await models.Unit.findAll(queryOptions);
}
@@ -17,22 +13,15 @@ export class UnitRepository {
async findById(id, options = {}, context = {}) {
const queryOptions = {
...options,
where: {
...(options.where || {})
/* FUTURE_TENANT_ISOLATION_FLAG:
tenant_id: context.tenantId
*/
}
where: applyTenantScope({ id, ...(options.where || {}) }, context)
};
return await models.Unit.findByPk(id, queryOptions);
return await models.Unit.findOne(queryOptions);
}
async create(data, options = {}, context = {}) {
const createData = {
...data
/* FUTURE_TENANT_ISOLATION_FLAG:
tenant_id: context.tenantId
*/
...data,
...(context.tenantId && context.userType !== 'platform' ? { tenant_id: context.tenantId } : {})
};
return await models.Unit.create(createData, options);
}
+3 -5
View File
@@ -2,6 +2,7 @@ import repository from './unit.repository.js';
import { models } from '../../../shared/database/models.js';
import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import { generateUniqueCode } from '../../../utils/helpers/code.utils.js';
export class UnitService {
async getAll(query = {}, context = {}) {
@@ -21,11 +22,8 @@ export class UnitService {
}
async create(data, context = {}) {
// Check duplicate code
const [existing] = await repository.findAll({ where: { code: data.code } }, context);
if (existing) {
throw new Error(`Unit with code "${data.code}" already exists`);
}
data.code = await generateUniqueCode(models.Unit, data.code || data.name);
const record = await repository.create(data, {}, context);
@@ -1,12 +1,11 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class CatalogRepository {
async findAll(options = {}, context = {}) {
const queryOptions = {
...options,
where: {
...(options.where || {})
}
where: applyTenantScope(options.where || {}, context)
};
return await models.Catalog.findAll({
include: [
@@ -68,14 +67,9 @@ export class CatalogRepository {
async findById(id, options = {}, context = {}) {
const queryOptions = {
...options,
where: {
...(options.where || {})
/* FUTURE_TENANT_ISOLATION_FLAG:
tenant_id: context.tenantId
*/
}
where: applyTenantScope({ id, ...(options.where || {}) }, context)
};
return await models.Catalog.findByPk(id, {
return await models.Catalog.findOne({
include: [
{
model: models.Categorie,
@@ -87,7 +81,15 @@ export class CatalogRepository {
model: models.Attribute,
as: 'attributes',
through: { attributes: ['display_order'] },
required: false
required: false,
include: [
{
model: models.AttributeOption,
as: 'optionsList',
attributes: ['id', 'code', 'label', 'sort_order'],
required: false
}
]
},
{
model: models.Attribute,
@@ -3,6 +3,8 @@ import { models, sequelize } from '../../../shared/database/models.js';
import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import { generateUniqueCode } from '../../../utils/helpers/code.utils.js';
export class CatalogService {
async attachCounts(record, transaction) {
@@ -123,34 +125,8 @@ export class CatalogService {
async create(data, context = {}) {
const transaction = await sequelize.transaction();
try {
// 1. Autogenerate code if missing or resolve collisions
if (!data.code || !data.code.trim()) {
let baseCode = data.name
? data.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '')
: `fam_${Date.now()}`;
if (!baseCode) baseCode = `fam_${Date.now()}`;
let finalCode = baseCode;
let counter = 1;
while (true) {
const checkCode = counter === 1 ? baseCode : `${baseCode}_${counter}`;
const dup = await repository.findByCode(checkCode, { transaction }, context);
if (!dup) {
finalCode = checkCode;
break;
}
counter++;
}
data.code = finalCode;
} else {
data.code = data.code.toLowerCase().trim();
const existing = await repository.findByCode(data.code, { transaction }, context);
if (existing) {
const isDeleted = existing.deleted_at || existing.deletedAt;
throw new ApiError(400, `Product Family with code "${data.code}" already exists${isDeleted ? ' (archived)' : ''}`);
}
}
// 1. Autogenerate unique code
data.code = await generateUniqueCode(models.ProductFamily, data.code || data.name, 'code', transaction, { paranoid: false });
// Resolve attributes from attribute set if provided
const attributeSetId = data.attributeSetId || data.attribute_set_id || null;
@@ -810,6 +786,26 @@ export class CatalogService {
}];
}
// Hydrate optionsList for all attributes if missing
for (const group of groups) {
if (Array.isArray(group.attributes)) {
for (let i = 0; i < group.attributes.length; i++) {
const attr = group.attributes[i];
const attrJson = attr.toJSON ? attr.toJSON() : attr;
const needsOptions = ['select', 'multiselect', 'enumeration', 'swatch', 'color'].includes(attrJson.type);
if (needsOptions && (!attrJson.optionsList || attrJson.optionsList.length === 0)) {
const options = await models.AttributeOption.findAll({
where: { attribute_id: attrJson.id },
order: [['sort_order', 'ASC']],
raw: true
}).catch(() => []);
attrJson.optionsList = options;
group.attributes[i] = attrJson;
}
}
}
}
let workflow = null;
const wfCode = family.workflow_code || 'standard';
if (models.WorkflowRegistry) {
@@ -1,15 +1,11 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class CategorieRepository {
async findAll(options = {}, context = {}) {
const queryOptions = {
...options,
where: {
...(options.where || {})
/* FUTURE_TENANT_ISOLATION_FLAG:
tenant_id: context.tenantId
*/
}
where: applyTenantScope(options.where || {}, context)
};
return await models.Categorie.findAll({
include: [
@@ -30,14 +26,9 @@ export class CategorieRepository {
async findById(id, options = {}, context = {}) {
const queryOptions = {
...options,
where: {
...(options.where || {})
/* FUTURE_TENANT_ISOLATION_FLAG:
tenant_id: context.tenantId
*/
}
where: applyTenantScope({ id, ...(options.where || {}) }, context)
};
return await models.Categorie.findByPk(id, {
return await models.Categorie.findOne({
include: [
{
model: models.Categorie,
@@ -4,6 +4,7 @@ import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import NotificationService from '../../notifications/notifications/notification.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import { generateUniqueCode } from '../../../utils/helpers/code.utils.js';
import { Op } from 'sequelize';
export class CategorieService {
@@ -18,7 +19,7 @@ export class CategorieService {
async getById(id, context = {}) {
const record = await repository.findById(id, {}, context);
if (!record) {
throw new ApiApiError(404, 404, 'Category not found');
throw new ApiError(404, 404, 'Category not found');
}
return record;
}
@@ -26,23 +27,10 @@ export class CategorieService {
async create(data, context = {}) {
const transaction = await sequelize.transaction();
try {
if (!data.code || !data.code.trim()) {
if (data.name) {
data.code = data.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
}
if (!data.code) {
data.code = `cat_${Date.now()}`;
}
}
data.code = data.code.toLowerCase().trim();
// Check duplicate code
const existing = await repository.findByCode(data.code, { transaction }, context);
if (existing) {
throw new ApiError(400, `Category with code "${data.code}" already exists`);
}
data.code = await generateUniqueCode(models.Categorie, data.code || data.name, 'code', transaction);
let level = 0;
let path = `/${data.code}`;
if (data.parentId) {
@@ -65,13 +53,13 @@ export class CategorieService {
};
const record = await repository.create(createData, { transaction }, context);
await transaction.commit();
const fullRecord = await repository.findById(record.id, {}, context);
SocketService.broadcast('categorie:created', fullRecord);
await AuditService.log({
action: 'CREATE',
resource: 'Categorie',
@@ -198,17 +186,17 @@ export class CategorieService {
details: data
});
// Notify tenant users
if (context.tenantId) {
NotificationService.notifyTenant(context.tenantId, context.userId, {
variant: 'category',
action: 'updated',
title: 'Category updated',
description: `Category "${record.name || 'A category'}" tree details were updated.`,
entity: record.name || 'Category',
entity_id: id.toString()
}).catch(err => console.error('Notification failed:', err));
}
// Notify tenant users
if (context.tenantId) {
NotificationService.notifyTenant(context.tenantId, context.userId, {
variant: 'category',
action: 'updated',
title: 'Category updated',
description: `Category "${record.name || 'A category'}" tree details were updated.`,
entity: record.name || 'Category',
entity_id: id.toString()
}).catch(err => console.error('Notification failed:', err));
}
return fullRecord;
} catch (error) {
@@ -306,17 +294,17 @@ export class CategorieService {
userId: context.userId || 'system'
});
// Notify tenant users
if (context.tenantId) {
NotificationService.notifyTenant(context.tenantId, context.userId, {
variant: 'category',
action: 'deleted',
title: 'Category deleted',
description: 'A category was permanently removed from taxonomy.',
entity: 'Category',
entity_id: id.toString()
}).catch(err => console.error('Notification failed:', err));
}
// Notify tenant users
if (context.tenantId) {
NotificationService.notifyTenant(context.tenantId, context.userId, {
variant: 'category',
action: 'deleted',
title: 'Category deleted',
description: 'A category was permanently removed from taxonomy.',
entity: 'Category',
entity_id: id.toString()
}).catch(err => console.error('Notification failed:', err));
}
return true;
} catch (error) {
@@ -2,40 +2,28 @@ import repository from './channelType.repository.js';
import { models } from '../../../shared/database/models.js';
import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import { generateUniqueCode } from '../../../utils/helpers/code.utils.js';
export class ChannelTypeService {
async getAll(query = {}) {
async getAll(query = {}, context = {}) {
const where = {};
if (query.status) {
where.status = query.status;
}
return await repository.findAll({ where });
return await repository.findAll({ where }, context);
}
async getById(id) {
const record = await repository.findById(id);
async getById(id, context = {}) {
const record = await repository.findById(id, {}, context);
if (!record) {
throw new Error('Channel Type not found');
throw new ApiError(404, 'Channel Type not found');
}
return record;
}
async create(data, userContext = {}) {
if (!data.code || !data.code.trim()) {
if (data.name) {
data.code = data.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
}
if (!data.code) {
data.code = `cht_${Date.now()}`;
}
}
data.code = data.code.toLowerCase().trim();
// Check duplicate code
const existing = await models.ChannelType.findOne({ where: { code: data.code } });
if (existing) {
throw new Error(`Channel Type with code "${data.code}" already exists`);
}
data.code = await generateUniqueCode(models.ChannelType, data.code || data.name);
const record = await repository.create(data);
+2
View File
@@ -15,6 +15,7 @@ import auditLogsRouter from './auditLogs/index.js';
import notificationsRouter from './notifications/index.js';
import workflowsRouter from './workflows/workflow.routes.js';
import variantsRouter from './variants/index.js';
import integrationsRouter from './integrations/index.js';
export default function registerRoutes(app) {
app.use('/api/v1', authenticationRouter);
@@ -34,4 +35,5 @@ export default function registerRoutes(app) {
app.use('/api/v1', notificationsRouter);
app.use('/api/v1/workflows', workflowsRouter);
app.use('/api/v1', variantsRouter);
app.use('/api/v1', integrationsRouter);
}
+32
View File
@@ -0,0 +1,32 @@
import { Router } from 'express';
const router = Router();
const defaultIntegrations = [
{ id: '1', name: 'Shopify Storefront Sync', type: 'ecommerce', status: 'active', target_channel: 'shopify', createdAt: new Date() },
{ id: '2', name: 'Amazon Seller Central', type: 'marketplace', status: 'active', target_channel: 'amazon', createdAt: new Date() },
{ id: '3', name: 'SAP ERP Connector', type: 'erp', status: 'inactive', target_channel: 'b2b', createdAt: new Date() }
];
router.get('/integrations', (req, res) => {
res.json({ success: true, data: defaultIntegrations });
});
router.get('/integrations/:id', (req, res) => {
const item = defaultIntegrations.find(i => i.id === req.params.id) || defaultIntegrations[0];
res.json({ success: true, data: item });
});
router.post('/integrations', (req, res) => {
res.json({ success: true, data: { id: String(Date.now()), ...req.body, status: 'active' } });
});
router.put('/integrations/:id', (req, res) => {
res.json({ success: true, data: { id: req.params.id, ...req.body } });
});
router.delete('/integrations/:id', (req, res) => {
res.json({ success: true, message: 'Deleted' });
});
export default router;
+39 -4
View File
@@ -1,4 +1,6 @@
import service from './asset.service.js';
import { uploadToCloudinary } from '../../../shared/services/cloudinary.service.js';
import fs from 'fs';
export class AssetController {
getAll = async (req, res, next) => {
@@ -58,13 +60,34 @@ export class AssetController {
if (!req.file) {
return res.status(400).json({ success: false, message: 'No file uploaded' });
}
let fileUrl = `/uploads/${req.file.filename}`;
let fileSize = req.file.size;
let width = null;
let height = null;
try {
const cloudResult = await uploadToCloudinary(req.file.path, { folder: 'pim-media' });
if (cloudResult && cloudResult.secure_url) {
fileUrl = cloudResult.secure_url;
fileSize = cloudResult.bytes || req.file.size;
width = cloudResult.width || null;
height = cloudResult.height || null;
try { fs.unlinkSync(req.file.path); } catch (e) {}
}
} catch (cloudErr) {
console.warn('Cloudinary upload fallback to local storage:', cloudErr.message);
}
return res.status(200).json({
success: true,
data: {
name: req.file.originalname,
file_url: `/uploads/${req.file.filename}`,
file_size: req.file.size,
mime_type: req.file.mimetype
file_url: fileUrl,
file_size: fileSize,
mime_type: req.file.mimetype,
width,
height
}
});
} catch (error) {
@@ -77,9 +100,21 @@ export class AssetController {
if (!req.file) {
return res.status(400).json({ success: false, message: 'No file uploaded' });
}
let fileUrl = `/uploads/${req.file.filename}`;
try {
const cloudResult = await uploadToCloudinary(req.file.path, { folder: 'pim-media' });
if (cloudResult && cloudResult.secure_url) {
fileUrl = cloudResult.secure_url;
try { fs.unlinkSync(req.file.path); } catch (e) {}
}
} catch (cloudErr) {
console.warn('Cloudinary replace file fallback to local storage:', cloudErr.message);
}
const record = await service.replaceFile(
req.params.id,
{ file_url: `/uploads/${req.file.filename}` },
{ file_url: fileUrl },
req.context
);
return res.status(200).json({ success: true, data: record });
+55 -26
View File
@@ -4,6 +4,7 @@ import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import { extractMetadata } from '../../../shared/utils/metadataExtractor.js';
import { deleteFromCloudinary } from '../../../shared/services/cloudinary.service.js';
import { Op } from 'sequelize';
import path from 'path';
@@ -78,21 +79,30 @@ export class AssetService {
// 2. Set file-based fields if file is being uploaded
let metadata = {};
if (data.file_url) {
// Handle path resolution safely for any absolute or relative URLs
let cleanPath = data.file_url;
if (cleanPath.includes('/uploads/')) {
cleanPath = 'uploads/' + cleanPath.split('/uploads/')[1];
if (data.file_url.startsWith('http://') || data.file_url.startsWith('https://')) {
const cleanUrl = data.file_url.split('?')[0];
const urlBaseName = path.basename(cleanUrl) || 'asset_file';
data.file_name = data.file_name || urlBaseName;
data.extension = data.extension || path.extname(urlBaseName).replace('.', '') || 'jpg';
data.file_size = data.file_size || 0;
data.checksum = data.checksum || `CHK-${Date.now()}-${Math.round(Math.random() * 1000)}`;
} else {
// Handle path resolution safely for any absolute or relative URLs
let cleanPath = data.file_url;
if (cleanPath.includes('/uploads/')) {
cleanPath = 'uploads/' + cleanPath.split('/uploads/')[1];
}
const absolutePath = path.resolve(cleanPath);
metadata = extractMetadata(absolutePath);
data.file_name = data.file_name || path.basename(absolutePath) || 'asset_file';
data.extension = metadata.extension || path.extname(absolutePath).replace('.', '') || 'bin';
data.file_size = metadata.file_size || data.file_size || 0;
data.checksum = metadata.checksum || `CHK-${Date.now()}-${Math.round(Math.random() * 1000)}`;
data.width = metadata.width || null;
data.height = metadata.height || null;
data.page_count = metadata.page_count || null;
}
const absolutePath = path.resolve(cleanPath);
metadata = extractMetadata(absolutePath);
data.file_name = data.file_name || path.basename(absolutePath) || 'asset_file';
data.extension = metadata.extension || path.extname(absolutePath).replace('.', '') || 'bin';
data.file_size = metadata.file_size || data.file_size || 0;
data.checksum = metadata.checksum || `CHK-${Date.now()}-${Math.round(Math.random() * 1000)}`;
data.width = metadata.width || null;
data.height = metadata.height || null;
data.page_count = metadata.page_count || null;
// Check duplicates by checksum
if (data.checksum) {
@@ -231,11 +241,25 @@ export class AssetService {
throw new Error('Asset not found');
}
// Check duplicates of new file checksum
const relativePath = fileData.file_url.replace(/^\/uploads\//, 'uploads/');
const absolutePath = path.resolve(relativePath);
const metadata = extractMetadata(absolutePath);
const newChecksum = metadata.checksum;
let metadata = {};
let fileName = 'asset_file';
let extension = 'bin';
let fileSize = 0;
let newChecksum = `CHK-${Date.now()}-${Math.round(Math.random() * 1000)}`;
if (fileData.file_url.startsWith('http://') || fileData.file_url.startsWith('https://')) {
const cleanUrl = fileData.file_url.split('?')[0];
fileName = path.basename(cleanUrl) || 'asset_file';
extension = path.extname(fileName).replace('.', '') || 'jpg';
} else {
const relativePath = fileData.file_url.replace(/^\/uploads\//, 'uploads/');
const absolutePath = path.resolve(relativePath);
metadata = extractMetadata(absolutePath);
fileName = path.basename(absolutePath);
extension = metadata.extension || path.extname(absolutePath).replace('.', '');
fileSize = metadata.file_size || 0;
newChecksum = metadata.checksum || newChecksum;
}
const duplicate = await models.Asset.findOne({
where: { checksum: newChecksum, status: 'active', id: { [Op.ne]: id } },
@@ -257,13 +281,13 @@ export class AssetService {
// 2. Update asset with new file details
const updateData = {
file_url: fileData.file_url,
file_name: path.basename(absolutePath),
extension: metadata.extension,
file_size: metadata.file_size,
file_name: fileName,
extension: extension,
file_size: fileSize,
checksum: newChecksum,
width: metadata.width,
height: metadata.height,
page_count: metadata.page_count,
width: metadata.width || null,
height: metadata.height || null,
page_count: metadata.page_count || null,
version: asset.version + 1,
updated_by: context.userId || 'system'
};
@@ -338,6 +362,11 @@ export class AssetService {
await models.ChannelAsset.destroy({ where: { asset_id: id } }).catch(() => {});
await models.AssetTag.destroy({ where: { asset_id: id } }).catch(() => {});
// Clean up Cloudinary storage if file was stored on Cloudinary
if (record.file_url) {
deleteFromCloudinary(record.file_url).catch((err) => console.warn('Cloudinary deletion warning:', err.message));
}
await record.update({ deleted_by: context.userId || 'system' });
await record.destroy();
@@ -356,7 +385,7 @@ export class AssetService {
async getRelations(id, context = {}) {
const products = await models.ProductAsset.findAll({
where: { asset_id: id },
include: [{ model: models.Product, as: 'product', attributes: ['id', 'name', 'sku'] }]
include: [{ model: models.Product, as: 'product', attributes: ['id', 'name', 'code'] }]
});
const variants = await models.VariantAsset.findAll({
@@ -31,7 +31,8 @@ export class CompletenessService {
},
{
model: models.AssetFamily,
as: 'assetRequirements'
as: 'assetRequirements',
through: { attributes: [] }
},
{
model: models.FamilyChannel,
@@ -1,15 +1,11 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class ProductRepository {
async findAll(options = {}, context = {}) {
const queryOptions = {
...options,
where: {
...(options.where || {})
/* FUTURE_TENANT_ISOLATION_FLAG:
tenant_id: context.tenantId
*/
}
where: applyTenantScope(options.where || {}, context)
};
return await models.Product.findAll({
include: [
@@ -19,7 +15,9 @@ export class ProductRepository {
include: [
{
model: models.Attribute,
as: 'variantAxes'
as: 'variantAxes',
through: { attributes: [] },
required: false
},
{
model: models.Categorie,
@@ -55,14 +53,9 @@ export class ProductRepository {
async findById(id, options = {}, context = {}) {
const queryOptions = {
...options,
where: {
...(options.where || {})
/* FUTURE_TENANT_ISOLATION_FLAG:
tenant_id: context.tenantId
*/
}
where: applyTenantScope({ id, ...(options.where || {}) }, context)
};
return await models.Product.findByPk(id, {
return await models.Product.findOne({
include: [
{
model: models.Catalog,
+19
View File
@@ -27,6 +27,25 @@ export const connectDatabase = async () => {
console.log('Database connection has been established successfully.');
if (env === 'local' || env === 'development' || env === 'test') {
try {
const qi = sequelize.getQueryInterface();
const farCols = await qi.describeTable('family_asset_requirements').catch(() => null);
if (farCols && farCols.asset_type_id && !farCols.asset_family_id) {
await qi.renameColumn('family_asset_requirements', 'asset_type_id', 'asset_family_id').catch(() => null);
}
const fvaCols = await qi.describeTable('family_variant_axes').catch(() => null);
if (fvaCols && !fvaCols.id) {
await qi.addColumn('family_variant_axes', 'id', {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
allowNull: false,
primaryKey: true
}).catch(() => null);
}
} catch (err) {
console.warn('Schema alignment warning:', err.message);
}
sequelize.sync().then(() => console.log('Database schema synced successfully.')).catch(console.error);
const { PermissionNode } = sequelize.models;
+7 -3
View File
@@ -10,7 +10,7 @@ export const errorMiddleware = (err, req, res, next) => {
const validationDetail = err.errors ? JSON.stringify(err.errors) : '';
const fullLogMessage = `${message} ${dbDetail ? `| DB: ${dbDetail}` : ''} ${validationDetail ? `| Validation: ${validationDetail}` : ''}`;
// Log the error using winston
// Log the error using winston logger with full details
logger.error({
message: fullLogMessage,
stack: err.stack,
@@ -20,10 +20,14 @@ export const errorMiddleware = (err, req, res, next) => {
ip: req.ip
});
const isDev = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'local';
const clientMessage = isDev ? fullLogMessage : (err.isOperational ? message : 'Internal Server Error');
res.status(status).json({
success: false,
status,
message: fullLogMessage,
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
message: clientMessage,
errors: err.errors || undefined,
stack: isDev ? err.stack : undefined
});
};
+30 -10
View File
@@ -1,13 +1,13 @@
import { models } from '../database/models.js';
export const authorize = (requiredPermissions = []) => {
export const authorize = (requiredPermissions = [], specificAction = null) => {
return async (req, res, next) => {
try {
if (!req.user) {
return res.status(401).json({ success: false, message: 'Unauthorized' });
}
// Platform-level superadmins (super admins) have full access
// Platform-level superadmins have full access
if (req.user.user_type === 'platform') {
return next();
}
@@ -22,6 +22,29 @@ export const authorize = (requiredPermissions = []) => {
return res.status(403).json({ success: false, message: 'Forbidden: No roles assigned' });
}
// Check if user holds an active SUPER_ADMIN role
const superAdminRole = await models.Role.findOne({
where: {
id: role_ids,
role_code: 'SUPER_ADMIN',
status: true
}
});
if (superAdminRole) {
return next();
}
// Map request HTTP method to specific action permission flag if not provided
let action = specificAction;
if (!action) {
if (req.method === 'POST') action = 'create';
else if (req.method === 'PUT' || req.method === 'PATCH') action = 'edit';
else if (req.method === 'DELETE') action = 'delete';
else action = 'view';
}
const permissionsList = Array.isArray(requiredPermissions) ? requiredPermissions : [requiredPermissions];
// Fetch active roles along with their permission nodes matching the required permission code
const rolesWithPermissions = await models.Role.findAll({
where: {
@@ -33,21 +56,15 @@ export const authorize = (requiredPermissions = []) => {
model: models.PermissionNode,
as: 'permissions',
where: {
node_code: requiredPermissions
node_code: permissionsList
},
through: {
attributes: ['can_view', 'can_create', 'can_edit', 'can_delete', 'can_alter', 'can_export', 'can_import']
attributes: ['can_view', 'can_create', 'can_edit', 'can_delete', 'can_alter', 'can_import', 'can_export']
}
}
]
});
// Map request HTTP method to specific action permission flag
let action = 'view';
if (req.method === 'POST') action = 'create';
else if (req.method === 'PUT' || req.method === 'PATCH') action = 'edit';
else if (req.method === 'DELETE') action = 'delete';
const isAuthorized = rolesWithPermissions.some(role => {
if (!role.permissions) return false;
return role.permissions.some(node => {
@@ -56,6 +73,9 @@ export const authorize = (requiredPermissions = []) => {
if (action === 'create') return rp.can_create;
if (action === 'edit') return rp.can_edit;
if (action === 'delete') return rp.can_delete;
if (action === 'alter') return rp.can_alter;
if (action === 'import') return rp.can_import;
if (action === 'export') return rp.can_export;
return rp.can_view;
});
});
+92
View File
@@ -0,0 +1,92 @@
import { v2 as cloudinary } from 'cloudinary';
import fs from 'fs';
import path from 'path';
// Configure Cloudinary with environment variables
cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME || 'dbixmka2j',
api_key: process.env.CLOUDINARY_API_KEY || '634582295238882',
api_secret: process.env.CLOUDINARY_API_SECRET || '0TAXlH3JHVvBHiqTMCwiDd9qfmM',
secure: true
});
/**
* Uploads any file type (Images, PDFs, CSV/XLSX, Videos, Documents) to Cloudinary.
* @param {string|Buffer} input - File path on disk or Buffer
* @param {Object} [options={}] - Options (e.g. folder name, resource_type)
* @returns {Promise<Object>} Cloudinary upload result
*/
export async function uploadToCloudinary(input, options = {}) {
const uploadOptions = {
folder: options.folder || 'pim-assets',
resource_type: options.resource_type || 'auto', // Handles images, video, and raw files (PDFs, CSV, XLSX, DOCX, ZIP)
use_filename: true,
unique_filename: true,
...options
};
return new Promise((resolve, reject) => {
if (typeof input === 'string') {
cloudinary.uploader.upload(input, uploadOptions, (error, result) => {
if (error) return reject(error);
resolve(result);
});
} else if (Buffer.isBuffer(input)) {
const uploadStream = cloudinary.uploader.upload_stream(uploadOptions, (error, result) => {
if (error) return reject(error);
resolve(result);
});
uploadStream.end(input);
} else {
reject(new Error('Invalid input for Cloudinary upload: expected string filepath or Buffer'));
}
});
}
/**
* Deletes a file from Cloudinary across all resource types (image, raw, video).
* @param {string} publicIdOrUrl - Cloudinary public_id or full secure URL
* @returns {Promise<Object>}
*/
export async function deleteFromCloudinary(publicIdOrUrl) {
if (!publicIdOrUrl) return null;
let publicId = publicIdOrUrl;
if (publicIdOrUrl.includes('cloudinary.com')) {
// Extract public_id from Cloudinary URL (e.g. https://res.cloudinary.com/.../upload/v1234/folder/name.pdf)
const matches = publicIdOrUrl.match(/\/upload\/(?:v\d+\/)?([^\?]+)/);
if (matches && matches[1]) {
// Remove extension for image/video, keep full filename for raw files
publicId = matches[1].replace(/\.[^/.]+$/, '');
}
}
// Attempt deletion across possible resource types ('image', 'raw', 'video')
const resourceTypes = ['image', 'raw', 'video'];
let lastError = null;
for (const resourceType of resourceTypes) {
try {
const result = await new Promise((resolve, reject) => {
cloudinary.uploader.destroy(publicId, { resource_type: resourceType }, (error, res) => {
if (error) return reject(error);
resolve(res);
});
});
if (result && result.result === 'ok') {
return result;
}
} catch (err) {
lastError = err;
}
}
return { result: 'not_found', lastError: lastError?.message };
}
export default {
uploadToCloudinary,
deleteFromCloudinary,
cloudinary
};
+43
View File
@@ -0,0 +1,43 @@
import { Op } from 'sequelize';
/**
* Generates a unique code for a given model by appending incrementing suffixes if needed.
* @param {Object} model - Sequelize Model class
* @param {string} baseCode - The desired base code or entity name input
* @param {string} [codeField='code'] - The database column name for the code
* @param {Object} [transaction=null] - Optional database transaction
* @param {Object} [extraOptions={}] - Additional options for model.findOne (e.g. { paranoid: false })
* @returns {Promise<string>}
*/
export async function generateUniqueCode(model, baseCode, codeField = 'code', transaction = null, extraOptions = {}) {
let formattedCode = (baseCode || '')
.toString()
.toLowerCase()
.trim()
.replace(/[^a-z0-9_]+/g, '_')
.replace(/^_+|_+$/g, '');
if (!formattedCode) {
formattedCode = 'code';
}
let uniqueCode = formattedCode;
let counter = 1;
while (true) {
const existing = await model.findOne({
where: { [codeField]: uniqueCode },
transaction,
...extraOptions
});
if (!existing) {
break;
}
uniqueCode = `${formattedCode}_${counter}`;
counter++;
}
return uniqueCode;
}
+17
View File
@@ -1,3 +1,20 @@
export const formatResponse = (success, data, message = '') => {
return { success, data, message };
};
/**
* Applies tenant_id scoping to a Sequelize where clause if a tenant context exists.
* Platform users (userType === 'platform' or no tenantId) bypass tenant filtering.
* @param {Object} [where={}] - Existing Sequelize where clause
* @param {Object} [context={}] - Request context containing tenantId and userType
* @returns {Object} Scoped where clause
*/
export const applyTenantScope = (where = {}, context = {}) => {
if (context && context.tenantId && context.userType !== 'platform') {
return {
...where,
tenant_id: context.tenantId
};
}
return where;
};
+2
View File
@@ -1 +1,3 @@
export * from './common.helper.js';
export * from './code.utils.js';
+5 -3
View File
@@ -1,15 +1,16 @@
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'supersecretjwtkeythatislongandsecure';
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'supersecretrefreshjwtkeythatislongandsecure';
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1d';
const JWT_REFRESH_EXPIRES_IN = '7d';
const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || '7d';
export const generateToken = (payload) => {
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
};
export const generateRefreshToken = (payload) => {
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_REFRESH_EXPIRES_IN });
return jwt.sign(payload, JWT_REFRESH_SECRET, { expiresIn: JWT_REFRESH_EXPIRES_IN });
};
export const verifyToken = (token) => {
@@ -22,8 +23,9 @@ export const verifyToken = (token) => {
export const verifyRefreshToken = (token) => {
try {
return jwt.verify(token, JWT_SECRET);
return jwt.verify(token, JWT_REFRESH_SECRET);
} catch (error) {
throw new Error('Invalid refresh token');
}
};