feat(core): setup multi-tenant isolation, Cloudinary DAM, RBAC fast-pass, distinct JWT refresh secrets & error shielding
This commit is contained in:
+18
-11
@@ -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
@@ -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
|
||||
|
||||
Generated
+20
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export * from './common.helper.js';
|
||||
export * from './code.utils.js';
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user