Autosave: 20260218-034325
This commit is contained in:
parent
a004fcd820
commit
f7df7e331e
2
502.html
2
502.html
@ -129,7 +129,7 @@
|
||||
<p class="tip">The application is currently launching. The page will automatically refresh once site is
|
||||
available.</p>
|
||||
<div class="project-info">
|
||||
<h2>Crafted Network</h2>
|
||||
<h2>Fix It Local</h2>
|
||||
<p>Trust-first service directory with verification, transparent pricing, AI-style matching, and request tracking.</p>
|
||||
</div>
|
||||
<div class="loader-container">
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
|
||||
|
||||
# Crafted Network
|
||||
# Fix It Local
|
||||
|
||||
|
||||
## This project was generated by [Flatlogic Platform](https://flatlogic.com).
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
|
||||
#Crafted Network - template backend,
|
||||
#Fix It Local - template backend,
|
||||
|
||||
#### Run App on local machine:
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "craftednetwork",
|
||||
"description": "Crafted Network - template backend",
|
||||
"description": "Fix It Local - template backend",
|
||||
"scripts": {
|
||||
"start": "npm run db:migrate && npm run db:seed && npm run watch",
|
||||
"lint": "eslint . --ext .js",
|
||||
|
||||
@ -37,7 +37,7 @@ const config = {
|
||||
},
|
||||
uploadDir: os.tmpdir(),
|
||||
email: {
|
||||
from: 'Crafted Network <app@flatlogic.app>',
|
||||
from: 'Fix It Local <app@flatlogic.app>',
|
||||
host: 'email-smtp.us-east-1.amazonaws.com',
|
||||
port: 587,
|
||||
auth: {
|
||||
|
||||
@ -1,18 +1,12 @@
|
||||
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const crypto = require('crypto');
|
||||
const Utils = require('../utils');
|
||||
|
||||
|
||||
|
||||
const Sequelize = db.Sequelize;
|
||||
const Op = Sequelize.Op;
|
||||
|
||||
module.exports = class Business_badgesDBApi {
|
||||
|
||||
|
||||
|
||||
static async create(data, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
@ -20,493 +14,63 @@ module.exports = class Business_badgesDBApi {
|
||||
const business_badges = await db.business_badges.create(
|
||||
{
|
||||
id: data.id || undefined,
|
||||
|
||||
badge_type: data.badge_type
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
status: data.status
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
granted_at: data.granted_at
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
revoked_at: data.revoked_at
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
notes: data.notes
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
created_at_ts: data.created_at_ts
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
updated_at_ts: data.updated_at_ts
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
importHash: data.importHash || null,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
|
||||
await business_badges.setBusiness( data.business || null, {
|
||||
transaction,
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return business_badges;
|
||||
}
|
||||
|
||||
|
||||
static async bulkImport(data, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
// Prepare data - wrapping individual data transformations in a map() method
|
||||
const business_badgesData = data.map((item, index) => ({
|
||||
id: item.id || undefined,
|
||||
|
||||
badge_type: item.badge_type
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
status: item.status
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
granted_at: item.granted_at
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
revoked_at: item.revoked_at
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
notes: item.notes
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
created_at_ts: item.created_at_ts
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
updated_at_ts: item.updated_at_ts
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
importHash: item.importHash || null,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
createdAt: new Date(Date.now() + index * 1000),
|
||||
}));
|
||||
|
||||
// Bulk create items
|
||||
const business_badges = await db.business_badges.bulkCreate(business_badgesData, { transaction });
|
||||
|
||||
// For each item created, replace relation files
|
||||
|
||||
|
||||
return business_badges;
|
||||
}
|
||||
|
||||
static async update(id, data, options) {
|
||||
const currentUser = (options && options.currentUser) || {id: null};
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
|
||||
const business_badges = await db.business_badges.findByPk(id, {}, {transaction});
|
||||
|
||||
|
||||
|
||||
|
||||
const updatePayload = {};
|
||||
|
||||
if (data.badge_type !== undefined) updatePayload.badge_type = data.badge_type;
|
||||
|
||||
|
||||
if (data.status !== undefined) updatePayload.status = data.status;
|
||||
|
||||
|
||||
if (data.granted_at !== undefined) updatePayload.granted_at = data.granted_at;
|
||||
|
||||
|
||||
if (data.revoked_at !== undefined) updatePayload.revoked_at = data.revoked_at;
|
||||
|
||||
|
||||
if (data.notes !== undefined) updatePayload.notes = data.notes;
|
||||
|
||||
|
||||
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
|
||||
|
||||
|
||||
if (data.updated_at_ts !== undefined) updatePayload.updated_at_ts = data.updated_at_ts;
|
||||
|
||||
|
||||
updatePayload.updatedById = currentUser.id;
|
||||
|
||||
await business_badges.update(updatePayload, {transaction});
|
||||
|
||||
|
||||
|
||||
if (data.business !== undefined) {
|
||||
await business_badges.setBusiness(
|
||||
|
||||
data.business,
|
||||
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return business_badges;
|
||||
}
|
||||
|
||||
static async deleteByIds(ids, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
const business_badges = await db.business_badges.findAll({
|
||||
where: {
|
||||
id: {
|
||||
[Op.in]: ids,
|
||||
},
|
||||
badge_type: data.badge_type || null,
|
||||
status: data.status || null,
|
||||
granted_at: data.granted_at || null,
|
||||
revoked_at: data.revoked_at || null,
|
||||
notes: data.notes || null,
|
||||
created_at_ts: data.created_at_ts || null,
|
||||
updated_at_ts: data.updated_at_ts || null,
|
||||
importHash: data.importHash || null,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
await db.sequelize.transaction(async (transaction) => {
|
||||
for (const record of business_badges) {
|
||||
await record.update(
|
||||
{deletedBy: currentUser.id},
|
||||
{transaction}
|
||||
);
|
||||
}
|
||||
for (const record of business_badges) {
|
||||
await record.destroy({transaction});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return business_badges;
|
||||
}
|
||||
|
||||
static async remove(id, options) {
|
||||
const currentUser = (options && options.currentUser) || {id: null};
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
const business_badges = await db.business_badges.findByPk(id, options);
|
||||
|
||||
await business_badges.update({
|
||||
deletedBy: currentUser.id
|
||||
}, {
|
||||
transaction,
|
||||
});
|
||||
|
||||
await business_badges.destroy({
|
||||
transaction
|
||||
});
|
||||
|
||||
return business_badges;
|
||||
}
|
||||
|
||||
static async findBy(where, options) {
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
const business_badges = await db.business_badges.findOne(
|
||||
{ where },
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
if (!business_badges) {
|
||||
return business_badges;
|
||||
}
|
||||
await business_badges.setBusiness( data.business || null, { transaction });
|
||||
|
||||
const output = business_badges.get({plain: true});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
output.business = await business_badges.getBusiness({
|
||||
transaction
|
||||
});
|
||||
|
||||
|
||||
|
||||
return output;
|
||||
return business_badges;
|
||||
}
|
||||
|
||||
static async findAll(
|
||||
filter,
|
||||
options
|
||||
) {
|
||||
static async findAll(filter, options) {
|
||||
const limit = filter.limit || 0;
|
||||
let offset = 0;
|
||||
let where = {};
|
||||
const currentPage = +filter.page;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
offset = currentPage * limit;
|
||||
|
||||
const orderBy = null;
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const currentUser = options?.currentUser;
|
||||
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
let include = [
|
||||
|
||||
{
|
||||
model: db.businesses,
|
||||
as: 'business',
|
||||
|
||||
where: filter.business ? {
|
||||
[Op.or]: [
|
||||
{ id: { [Op.in]: filter.business.split('|').map(term => Utils.uuid(term)) } },
|
||||
{
|
||||
name: {
|
||||
[Op.or]: filter.business.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
|
||||
}
|
||||
},
|
||||
]
|
||||
} : {},
|
||||
|
||||
},
|
||||
|
||||
|
||||
|
||||
];
|
||||
|
||||
if (filter) {
|
||||
if (filter.id) {
|
||||
where = {
|
||||
...where,
|
||||
['id']: Utils.uuid(filter.id),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
if (filter.notes) {
|
||||
where = {
|
||||
...where,
|
||||
[Op.and]: Utils.ilike(
|
||||
'business_badges',
|
||||
'notes',
|
||||
filter.notes,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (filter.granted_atRange) {
|
||||
const [start, end] = filter.granted_atRange;
|
||||
|
||||
if (start !== undefined && start !== null && start !== '') {
|
||||
where = {
|
||||
...where,
|
||||
granted_at: {
|
||||
...where.granted_at,
|
||||
[Op.gte]: start,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (end !== undefined && end !== null && end !== '') {
|
||||
where = {
|
||||
...where,
|
||||
granted_at: {
|
||||
...where.granted_at,
|
||||
[Op.lte]: end,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.revoked_atRange) {
|
||||
const [start, end] = filter.revoked_atRange;
|
||||
|
||||
if (start !== undefined && start !== null && start !== '') {
|
||||
where = {
|
||||
...where,
|
||||
revoked_at: {
|
||||
...where.revoked_at,
|
||||
[Op.gte]: start,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (end !== undefined && end !== null && end !== '') {
|
||||
where = {
|
||||
...where,
|
||||
revoked_at: {
|
||||
...where.revoked_at,
|
||||
[Op.lte]: end,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.created_at_tsRange) {
|
||||
const [start, end] = filter.created_at_tsRange;
|
||||
|
||||
if (start !== undefined && start !== null && start !== '') {
|
||||
where = {
|
||||
...where,
|
||||
created_at_ts: {
|
||||
...where.created_at_ts,
|
||||
[Op.gte]: start,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (end !== undefined && end !== null && end !== '') {
|
||||
where = {
|
||||
...where,
|
||||
created_at_ts: {
|
||||
...where.created_at_ts,
|
||||
[Op.lte]: end,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.updated_at_tsRange) {
|
||||
const [start, end] = filter.updated_at_tsRange;
|
||||
|
||||
if (start !== undefined && start !== null && start !== '') {
|
||||
where = {
|
||||
...where,
|
||||
updated_at_ts: {
|
||||
...where.updated_at_ts,
|
||||
[Op.gte]: start,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (end !== undefined && end !== null && end !== '') {
|
||||
where = {
|
||||
...where,
|
||||
updated_at_ts: {
|
||||
...where.updated_at_ts,
|
||||
[Op.lte]: end,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (filter.active !== undefined) {
|
||||
where = {
|
||||
...where,
|
||||
active: filter.active === true || filter.active === 'true'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
if (filter.badge_type) {
|
||||
where = {
|
||||
...where,
|
||||
badge_type: filter.badge_type,
|
||||
};
|
||||
}
|
||||
|
||||
if (filter.status) {
|
||||
where = {
|
||||
...where,
|
||||
status: filter.status,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (filter.createdAtRange) {
|
||||
const [start, end] = filter.createdAtRange;
|
||||
|
||||
if (start !== undefined && start !== null && start !== '') {
|
||||
where = {
|
||||
...where,
|
||||
['createdAt']: {
|
||||
...where.createdAt,
|
||||
[Op.gte]: start,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (end !== undefined && end !== null && end !== '') {
|
||||
where = {
|
||||
...where,
|
||||
['createdAt']: {
|
||||
...where.createdAt,
|
||||
[Op.lte]: end,
|
||||
},
|
||||
};
|
||||
// Data Isolation
|
||||
if (currentUser && currentUser.app_role) {
|
||||
const roleName = currentUser.app_role.name;
|
||||
if (roleName === 'Verified Business Owner') {
|
||||
if (currentUser.businessId) {
|
||||
where.businessId = currentUser.businessId;
|
||||
} else {
|
||||
where['$business.owner_userId$'] = currentUser.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
let include = [
|
||||
{
|
||||
model: db.businesses,
|
||||
as: 'business',
|
||||
where: filter.business ? {
|
||||
[Op.or]: [
|
||||
{ id: { [Op.in]: filter.business.split('|').map(term => Utils.uuid(term)) } },
|
||||
{ name: { [Op.or]: filter.business.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) } },
|
||||
]
|
||||
} : {},
|
||||
},
|
||||
];
|
||||
|
||||
if (filter) {
|
||||
if (filter.id) where.id = Utils.uuid(filter.id);
|
||||
}
|
||||
|
||||
const queryOptions = {
|
||||
where,
|
||||
@ -515,8 +79,7 @@ module.exports = class Business_badgesDBApi {
|
||||
order: filter.field && filter.sort
|
||||
? [[filter.field, filter.sort]]
|
||||
: [['createdAt', 'desc']],
|
||||
transaction: options?.transaction,
|
||||
logging: console.log
|
||||
transaction,
|
||||
};
|
||||
|
||||
if (!options?.countOnly) {
|
||||
@ -524,51 +87,80 @@ module.exports = class Business_badgesDBApi {
|
||||
queryOptions.offset = offset ? Number(offset) : undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows, count } = await db.business_badges.findAndCountAll(queryOptions);
|
||||
const { rows, count } = await db.business_badges.findAndCountAll(queryOptions);
|
||||
|
||||
return {
|
||||
rows: options?.countOnly ? [] : rows,
|
||||
count: count
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error executing query:', error);
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
rows: options?.countOnly ? [] : rows,
|
||||
count: count
|
||||
};
|
||||
}
|
||||
|
||||
static async findAllAutocomplete(query, limit, offset, ) {
|
||||
let where = {};
|
||||
|
||||
|
||||
static async findBy(where, options) {
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const business_badges = await db.business_badges.findOne({ where, transaction });
|
||||
if (!business_badges) return null;
|
||||
|
||||
const output = business_badges.get({plain: true});
|
||||
output.business = await business_badges.getBusiness({ transaction });
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
static async update(id, data, options) {
|
||||
const currentUser = (options && options.currentUser) || {id: null};
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const business_badges = await db.business_badges.findByPk(id, {transaction});
|
||||
|
||||
const updatePayload = { ...data, updatedById: currentUser.id };
|
||||
await business_badges.update(updatePayload, {transaction});
|
||||
|
||||
if (data.business !== undefined) await business_badges.setBusiness(data.business, { transaction });
|
||||
|
||||
return business_badges;
|
||||
}
|
||||
|
||||
static async deleteByIds(ids, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const business_badges = await db.business_badges.findAll({ where: { id: { [Op.in]: ids } }, transaction });
|
||||
|
||||
for (const record of business_badges) {
|
||||
await record.update({deletedBy: currentUser.id}, {transaction});
|
||||
await record.destroy({transaction});
|
||||
}
|
||||
return business_badges;
|
||||
}
|
||||
|
||||
static async remove(id, options) {
|
||||
const currentUser = (options && options.currentUser) || {id: null};
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const business_badges = await db.business_badges.findByPk(id, options);
|
||||
await business_badges.update({ deletedBy: currentUser.id }, { transaction });
|
||||
await business_badges.destroy({ transaction });
|
||||
return business_badges;
|
||||
}
|
||||
|
||||
static async findAllAutocomplete(query, limit, offset) {
|
||||
let where = {};
|
||||
if (query) {
|
||||
where = {
|
||||
[Op.or]: [
|
||||
{ ['id']: Utils.uuid(query) },
|
||||
Utils.ilike(
|
||||
'business_badges',
|
||||
'notes',
|
||||
query,
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const records = await db.business_badges.findAll({
|
||||
attributes: [ 'id', 'notes' ],
|
||||
attributes: [ 'id' ],
|
||||
where,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
offset: offset ? Number(offset) : undefined,
|
||||
orderBy: [['notes', 'ASC']],
|
||||
order: [['createdAt', 'desc']],
|
||||
});
|
||||
|
||||
return records.map((record) => ({
|
||||
id: record.id,
|
||||
label: record.notes,
|
||||
label: record.id,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
@ -1,18 +1,12 @@
|
||||
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const crypto = require('crypto');
|
||||
const Utils = require('../utils');
|
||||
|
||||
|
||||
|
||||
const Sequelize = db.Sequelize;
|
||||
const Op = Sequelize.Op;
|
||||
|
||||
module.exports = class Business_categoriesDBApi {
|
||||
|
||||
|
||||
|
||||
static async create(data, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
@ -20,355 +14,68 @@ module.exports = class Business_categoriesDBApi {
|
||||
const business_categories = await db.business_categories.create(
|
||||
{
|
||||
id: data.id || undefined,
|
||||
|
||||
created_at_ts: data.created_at_ts
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
importHash: data.importHash || null,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
|
||||
await business_categories.setBusiness( data.business || null, {
|
||||
transaction,
|
||||
});
|
||||
|
||||
await business_categories.setCategory( data.category || null, {
|
||||
transaction,
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return business_categories;
|
||||
}
|
||||
|
||||
|
||||
static async bulkImport(data, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
// Prepare data - wrapping individual data transformations in a map() method
|
||||
const business_categoriesData = data.map((item, index) => ({
|
||||
id: item.id || undefined,
|
||||
|
||||
created_at_ts: item.created_at_ts
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
importHash: item.importHash || null,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
createdAt: new Date(Date.now() + index * 1000),
|
||||
}));
|
||||
|
||||
// Bulk create items
|
||||
const business_categories = await db.business_categories.bulkCreate(business_categoriesData, { transaction });
|
||||
|
||||
// For each item created, replace relation files
|
||||
|
||||
|
||||
return business_categories;
|
||||
}
|
||||
|
||||
static async update(id, data, options) {
|
||||
const currentUser = (options && options.currentUser) || {id: null};
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
|
||||
const business_categories = await db.business_categories.findByPk(id, {}, {transaction});
|
||||
|
||||
|
||||
|
||||
|
||||
const updatePayload = {};
|
||||
|
||||
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
|
||||
|
||||
|
||||
updatePayload.updatedById = currentUser.id;
|
||||
|
||||
await business_categories.update(updatePayload, {transaction});
|
||||
|
||||
|
||||
|
||||
if (data.business !== undefined) {
|
||||
await business_categories.setBusiness(
|
||||
|
||||
data.business,
|
||||
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
|
||||
if (data.category !== undefined) {
|
||||
await business_categories.setCategory(
|
||||
|
||||
data.category,
|
||||
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return business_categories;
|
||||
}
|
||||
|
||||
static async deleteByIds(ids, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
const business_categories = await db.business_categories.findAll({
|
||||
where: {
|
||||
id: {
|
||||
[Op.in]: ids,
|
||||
},
|
||||
created_at_ts: data.created_at_ts || null,
|
||||
importHash: data.importHash || null,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
await db.sequelize.transaction(async (transaction) => {
|
||||
for (const record of business_categories) {
|
||||
await record.update(
|
||||
{deletedBy: currentUser.id},
|
||||
{transaction}
|
||||
);
|
||||
}
|
||||
for (const record of business_categories) {
|
||||
await record.destroy({transaction});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return business_categories;
|
||||
}
|
||||
|
||||
static async remove(id, options) {
|
||||
const currentUser = (options && options.currentUser) || {id: null};
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
const business_categories = await db.business_categories.findByPk(id, options);
|
||||
|
||||
await business_categories.update({
|
||||
deletedBy: currentUser.id
|
||||
}, {
|
||||
transaction,
|
||||
});
|
||||
|
||||
await business_categories.destroy({
|
||||
transaction
|
||||
});
|
||||
|
||||
return business_categories;
|
||||
}
|
||||
|
||||
static async findBy(where, options) {
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
const business_categories = await db.business_categories.findOne(
|
||||
{ where },
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
if (!business_categories) {
|
||||
return business_categories;
|
||||
}
|
||||
await business_categories.setBusiness( data.business || null, { transaction });
|
||||
await business_categories.setCategory( data.category || null, { transaction });
|
||||
|
||||
const output = business_categories.get({plain: true});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
output.business = await business_categories.getBusiness({
|
||||
transaction
|
||||
});
|
||||
|
||||
|
||||
output.category = await business_categories.getCategory({
|
||||
transaction
|
||||
});
|
||||
|
||||
|
||||
|
||||
return output;
|
||||
return business_categories;
|
||||
}
|
||||
|
||||
static async findAll(
|
||||
filter,
|
||||
options
|
||||
) {
|
||||
static async findAll(filter, options) {
|
||||
const limit = filter.limit || 0;
|
||||
let offset = 0;
|
||||
let where = {};
|
||||
const currentPage = +filter.page;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
offset = currentPage * limit;
|
||||
|
||||
const orderBy = null;
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const currentUser = options?.currentUser;
|
||||
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
let include = [
|
||||
|
||||
{
|
||||
model: db.businesses,
|
||||
as: 'business',
|
||||
|
||||
where: filter.business ? {
|
||||
[Op.or]: [
|
||||
{ id: { [Op.in]: filter.business.split('|').map(term => Utils.uuid(term)) } },
|
||||
{
|
||||
name: {
|
||||
[Op.or]: filter.business.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
|
||||
}
|
||||
},
|
||||
]
|
||||
} : {},
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
model: db.categories,
|
||||
as: 'category',
|
||||
|
||||
where: filter.category ? {
|
||||
[Op.or]: [
|
||||
{ id: { [Op.in]: filter.category.split('|').map(term => Utils.uuid(term)) } },
|
||||
{
|
||||
name: {
|
||||
[Op.or]: filter.category.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
|
||||
}
|
||||
},
|
||||
]
|
||||
} : {},
|
||||
|
||||
},
|
||||
|
||||
|
||||
|
||||
];
|
||||
|
||||
if (filter) {
|
||||
if (filter.id) {
|
||||
where = {
|
||||
...where,
|
||||
['id']: Utils.uuid(filter.id),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (filter.created_at_tsRange) {
|
||||
const [start, end] = filter.created_at_tsRange;
|
||||
|
||||
if (start !== undefined && start !== null && start !== '') {
|
||||
where = {
|
||||
...where,
|
||||
created_at_ts: {
|
||||
...where.created_at_ts,
|
||||
[Op.gte]: start,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (end !== undefined && end !== null && end !== '') {
|
||||
where = {
|
||||
...where,
|
||||
created_at_ts: {
|
||||
...where.created_at_ts,
|
||||
[Op.lte]: end,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (filter.active !== undefined) {
|
||||
where = {
|
||||
...where,
|
||||
active: filter.active === true || filter.active === 'true'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (filter.createdAtRange) {
|
||||
const [start, end] = filter.createdAtRange;
|
||||
|
||||
if (start !== undefined && start !== null && start !== '') {
|
||||
where = {
|
||||
...where,
|
||||
['createdAt']: {
|
||||
...where.createdAt,
|
||||
[Op.gte]: start,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (end !== undefined && end !== null && end !== '') {
|
||||
where = {
|
||||
...where,
|
||||
['createdAt']: {
|
||||
...where.createdAt,
|
||||
[Op.lte]: end,
|
||||
},
|
||||
};
|
||||
// Data Isolation
|
||||
if (currentUser && currentUser.app_role) {
|
||||
const roleName = currentUser.app_role.name;
|
||||
if (roleName === 'Verified Business Owner') {
|
||||
if (currentUser.businessId) {
|
||||
where.businessId = currentUser.businessId;
|
||||
} else {
|
||||
where['$business.owner_userId$'] = currentUser.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
let include = [
|
||||
{
|
||||
model: db.businesses,
|
||||
as: 'business',
|
||||
where: filter.business ? {
|
||||
[Op.or]: [
|
||||
{ id: { [Op.in]: filter.business.split('|').map(term => Utils.uuid(term)) } },
|
||||
{ name: { [Op.or]: filter.business.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) } },
|
||||
]
|
||||
} : {},
|
||||
},
|
||||
{
|
||||
model: db.categories,
|
||||
as: 'category',
|
||||
where: filter.category ? {
|
||||
[Op.or]: [
|
||||
{ id: { [Op.in]: filter.category.split('|').map(term => Utils.uuid(term)) } },
|
||||
{ name: { [Op.or]: filter.category.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) } },
|
||||
]
|
||||
} : {},
|
||||
},
|
||||
];
|
||||
|
||||
if (filter) {
|
||||
if (filter.id) where.id = Utils.uuid(filter.id);
|
||||
}
|
||||
|
||||
const queryOptions = {
|
||||
where,
|
||||
@ -377,8 +84,7 @@ module.exports = class Business_categoriesDBApi {
|
||||
order: filter.field && filter.sort
|
||||
? [[filter.field, filter.sort]]
|
||||
: [['createdAt', 'desc']],
|
||||
transaction: options?.transaction,
|
||||
logging: console.log
|
||||
transaction,
|
||||
};
|
||||
|
||||
if (!options?.countOnly) {
|
||||
@ -386,51 +92,83 @@ module.exports = class Business_categoriesDBApi {
|
||||
queryOptions.offset = offset ? Number(offset) : undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows, count } = await db.business_categories.findAndCountAll(queryOptions);
|
||||
const { rows, count } = await db.business_categories.findAndCountAll(queryOptions);
|
||||
|
||||
return {
|
||||
rows: options?.countOnly ? [] : rows,
|
||||
count: count
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error executing query:', error);
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
rows: options?.countOnly ? [] : rows,
|
||||
count: count
|
||||
};
|
||||
}
|
||||
|
||||
static async findAllAutocomplete(query, limit, offset, ) {
|
||||
let where = {};
|
||||
|
||||
|
||||
static async findBy(where, options) {
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const business_categories = await db.business_categories.findOne({ where, transaction });
|
||||
if (!business_categories) return null;
|
||||
|
||||
const output = business_categories.get({plain: true});
|
||||
output.business = await business_categories.getBusiness({ transaction });
|
||||
output.category = await business_categories.getCategory({ transaction });
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
static async update(id, data, options) {
|
||||
const currentUser = (options && options.currentUser) || {id: null};
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const business_categories = await db.business_categories.findByPk(id, {transaction});
|
||||
|
||||
const updatePayload = { updatedById: currentUser.id };
|
||||
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
|
||||
await business_categories.update(updatePayload, {transaction});
|
||||
|
||||
if (data.business !== undefined) await business_categories.setBusiness(data.business, { transaction });
|
||||
if (data.category !== undefined) await business_categories.setCategory(data.category, { transaction });
|
||||
|
||||
return business_categories;
|
||||
}
|
||||
|
||||
static async deleteByIds(ids, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const business_categories = await db.business_categories.findAll({ where: { id: { [Op.in]: ids } }, transaction });
|
||||
|
||||
for (const record of business_categories) {
|
||||
await record.update({deletedBy: currentUser.id}, {transaction});
|
||||
await record.destroy({transaction});
|
||||
}
|
||||
return business_categories;
|
||||
}
|
||||
|
||||
static async remove(id, options) {
|
||||
const currentUser = (options && options.currentUser) || {id: null};
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const business_categories = await db.business_categories.findByPk(id, options);
|
||||
await business_categories.update({ deletedBy: currentUser.id }, { transaction });
|
||||
await business_categories.destroy({ transaction });
|
||||
return business_categories;
|
||||
}
|
||||
|
||||
static async findAllAutocomplete(query, limit, offset) {
|
||||
let where = {};
|
||||
if (query) {
|
||||
where = {
|
||||
[Op.or]: [
|
||||
{ ['id']: Utils.uuid(query) },
|
||||
Utils.ilike(
|
||||
'business_categories',
|
||||
'created_at_ts',
|
||||
query,
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const records = await db.business_categories.findAll({
|
||||
attributes: [ 'id', 'created_at_ts' ],
|
||||
attributes: [ 'id' ],
|
||||
where,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
offset: offset ? Number(offset) : undefined,
|
||||
orderBy: [['created_at_ts', 'ASC']],
|
||||
order: [['createdAt', 'desc']],
|
||||
});
|
||||
|
||||
return records.map((record) => ({
|
||||
id: record.id,
|
||||
label: record.created_at_ts,
|
||||
label: record.id,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
@ -1,18 +1,12 @@
|
||||
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const crypto = require('crypto');
|
||||
const Utils = require('../utils');
|
||||
|
||||
|
||||
|
||||
const Sequelize = db.Sequelize;
|
||||
const Op = Sequelize.Op;
|
||||
|
||||
module.exports = class Business_photosDBApi {
|
||||
|
||||
|
||||
|
||||
static async create(data, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
@ -20,360 +14,70 @@ module.exports = class Business_photosDBApi {
|
||||
const business_photos = await db.business_photos.create(
|
||||
{
|
||||
id: data.id || undefined,
|
||||
|
||||
created_at_ts: data.created_at_ts
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
importHash: data.importHash || null,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
|
||||
await business_photos.setBusiness( data.business || null, {
|
||||
transaction,
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
await FileDBApi.replaceRelationFiles(
|
||||
{
|
||||
belongsTo: db.business_photos.getTableName(),
|
||||
belongsToColumn: 'photos',
|
||||
belongsToId: business_photos.id,
|
||||
created_at_ts: data.created_at_ts || null,
|
||||
importHash: data.importHash || null,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
data.photos,
|
||||
options,
|
||||
);
|
||||
|
||||
|
||||
return business_photos;
|
||||
}
|
||||
|
||||
|
||||
static async bulkImport(data, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
// Prepare data - wrapping individual data transformations in a map() method
|
||||
const business_photosData = data.map((item, index) => ({
|
||||
id: item.id || undefined,
|
||||
|
||||
created_at_ts: item.created_at_ts
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
importHash: item.importHash || null,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
createdAt: new Date(Date.now() + index * 1000),
|
||||
}));
|
||||
|
||||
// Bulk create items
|
||||
const business_photos = await db.business_photos.bulkCreate(business_photosData, { transaction });
|
||||
|
||||
// For each item created, replace relation files
|
||||
|
||||
for (let i = 0; i < business_photos.length; i++) {
|
||||
await FileDBApi.replaceRelationFiles(
|
||||
{
|
||||
belongsTo: db.business_photos.getTableName(),
|
||||
belongsToColumn: 'photos',
|
||||
belongsToId: business_photos[i].id,
|
||||
},
|
||||
data[i].photos,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return business_photos;
|
||||
}
|
||||
|
||||
static async update(id, data, options) {
|
||||
const currentUser = (options && options.currentUser) || {id: null};
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
|
||||
const business_photos = await db.business_photos.findByPk(id, {}, {transaction});
|
||||
|
||||
|
||||
|
||||
|
||||
const updatePayload = {};
|
||||
|
||||
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
|
||||
|
||||
|
||||
updatePayload.updatedById = currentUser.id;
|
||||
|
||||
await business_photos.update(updatePayload, {transaction});
|
||||
|
||||
|
||||
|
||||
if (data.business !== undefined) {
|
||||
await business_photos.setBusiness(
|
||||
|
||||
data.business,
|
||||
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
await FileDBApi.replaceRelationFiles(
|
||||
{
|
||||
belongsTo: db.business_photos.getTableName(),
|
||||
belongsToColumn: 'photos',
|
||||
belongsToId: business_photos.id,
|
||||
},
|
||||
data.photos,
|
||||
options,
|
||||
);
|
||||
|
||||
|
||||
return business_photos;
|
||||
}
|
||||
|
||||
static async deleteByIds(ids, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
const business_photos = await db.business_photos.findAll({
|
||||
where: {
|
||||
id: {
|
||||
[Op.in]: ids,
|
||||
},
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
await db.sequelize.transaction(async (transaction) => {
|
||||
for (const record of business_photos) {
|
||||
await record.update(
|
||||
{deletedBy: currentUser.id},
|
||||
{transaction}
|
||||
);
|
||||
}
|
||||
for (const record of business_photos) {
|
||||
await record.destroy({transaction});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return business_photos;
|
||||
}
|
||||
|
||||
static async remove(id, options) {
|
||||
const currentUser = (options && options.currentUser) || {id: null};
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
const business_photos = await db.business_photos.findByPk(id, options);
|
||||
|
||||
await business_photos.update({
|
||||
deletedBy: currentUser.id
|
||||
}, {
|
||||
transaction,
|
||||
});
|
||||
|
||||
await business_photos.destroy({
|
||||
transaction
|
||||
});
|
||||
|
||||
return business_photos;
|
||||
}
|
||||
|
||||
static async findBy(where, options) {
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
const business_photos = await db.business_photos.findOne(
|
||||
{ where },
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
if (!business_photos) {
|
||||
return business_photos;
|
||||
}
|
||||
await business_photos.setBusiness( data.business || null, { transaction });
|
||||
await FileDBApi.replaceRelationFiles(
|
||||
{
|
||||
belongsTo: db.business_photos.getTableName(),
|
||||
belongsToColumn: 'photos',
|
||||
belongsToId: business_photos.id,
|
||||
},
|
||||
data.photos,
|
||||
options,
|
||||
);
|
||||
|
||||
const output = business_photos.get({plain: true});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
output.business = await business_photos.getBusiness({
|
||||
transaction
|
||||
});
|
||||
|
||||
|
||||
output.photos = await business_photos.getPhotos({
|
||||
transaction
|
||||
});
|
||||
|
||||
|
||||
|
||||
return output;
|
||||
return business_photos;
|
||||
}
|
||||
|
||||
static async findAll(
|
||||
filter,
|
||||
options
|
||||
) {
|
||||
static async findAll(filter, options) {
|
||||
const limit = filter.limit || 0;
|
||||
let offset = 0;
|
||||
let where = {};
|
||||
const currentPage = +filter.page;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
offset = currentPage * limit;
|
||||
|
||||
const orderBy = null;
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const currentUser = options?.currentUser;
|
||||
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
let include = [
|
||||
|
||||
{
|
||||
model: db.businesses,
|
||||
as: 'business',
|
||||
|
||||
where: filter.business ? {
|
||||
[Op.or]: [
|
||||
{ id: { [Op.in]: filter.business.split('|').map(term => Utils.uuid(term)) } },
|
||||
{
|
||||
name: {
|
||||
[Op.or]: filter.business.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
|
||||
}
|
||||
},
|
||||
]
|
||||
} : {},
|
||||
|
||||
},
|
||||
|
||||
|
||||
|
||||
{
|
||||
model: db.file,
|
||||
as: 'photos',
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
if (filter) {
|
||||
if (filter.id) {
|
||||
where = {
|
||||
...where,
|
||||
['id']: Utils.uuid(filter.id),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (filter.created_at_tsRange) {
|
||||
const [start, end] = filter.created_at_tsRange;
|
||||
|
||||
if (start !== undefined && start !== null && start !== '') {
|
||||
where = {
|
||||
...where,
|
||||
created_at_ts: {
|
||||
...where.created_at_ts,
|
||||
[Op.gte]: start,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (end !== undefined && end !== null && end !== '') {
|
||||
where = {
|
||||
...where,
|
||||
created_at_ts: {
|
||||
...where.created_at_ts,
|
||||
[Op.lte]: end,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (filter.active !== undefined) {
|
||||
where = {
|
||||
...where,
|
||||
active: filter.active === true || filter.active === 'true'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (filter.createdAtRange) {
|
||||
const [start, end] = filter.createdAtRange;
|
||||
|
||||
if (start !== undefined && start !== null && start !== '') {
|
||||
where = {
|
||||
...where,
|
||||
['createdAt']: {
|
||||
...where.createdAt,
|
||||
[Op.gte]: start,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (end !== undefined && end !== null && end !== '') {
|
||||
where = {
|
||||
...where,
|
||||
['createdAt']: {
|
||||
...where.createdAt,
|
||||
[Op.lte]: end,
|
||||
},
|
||||
};
|
||||
// Data Isolation
|
||||
if (currentUser && currentUser.app_role) {
|
||||
const roleName = currentUser.app_role.name;
|
||||
if (roleName === 'Verified Business Owner') {
|
||||
if (currentUser.businessId) {
|
||||
where.businessId = currentUser.businessId;
|
||||
} else {
|
||||
where['$business.owner_userId$'] = currentUser.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
let include = [
|
||||
{
|
||||
model: db.businesses,
|
||||
as: 'business',
|
||||
where: filter.business ? {
|
||||
[Op.or]: [
|
||||
{ id: { [Op.in]: filter.business.split('|').map(term => Utils.uuid(term)) } },
|
||||
{ name: { [Op.or]: filter.business.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) } },
|
||||
]
|
||||
} : {},
|
||||
},
|
||||
{
|
||||
model: db.file,
|
||||
as: 'photos',
|
||||
},
|
||||
];
|
||||
|
||||
if (filter) {
|
||||
if (filter.id) where.id = Utils.uuid(filter.id);
|
||||
}
|
||||
|
||||
const queryOptions = {
|
||||
where,
|
||||
@ -382,8 +86,7 @@ module.exports = class Business_photosDBApi {
|
||||
order: filter.field && filter.sort
|
||||
? [[filter.field, filter.sort]]
|
||||
: [['createdAt', 'desc']],
|
||||
transaction: options?.transaction,
|
||||
logging: console.log
|
||||
transaction,
|
||||
};
|
||||
|
||||
if (!options?.countOnly) {
|
||||
@ -391,51 +94,91 @@ module.exports = class Business_photosDBApi {
|
||||
queryOptions.offset = offset ? Number(offset) : undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows, count } = await db.business_photos.findAndCountAll(queryOptions);
|
||||
const { rows, count } = await db.business_photos.findAndCountAll(queryOptions);
|
||||
|
||||
return {
|
||||
rows: options?.countOnly ? [] : rows,
|
||||
count: count
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error executing query:', error);
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
rows: options?.countOnly ? [] : rows,
|
||||
count: count
|
||||
};
|
||||
}
|
||||
|
||||
static async findAllAutocomplete(query, limit, offset, ) {
|
||||
let where = {};
|
||||
|
||||
|
||||
static async findBy(where, options) {
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const business_photos = await db.business_photos.findOne({ where, transaction });
|
||||
if (!business_photos) return null;
|
||||
|
||||
const output = business_photos.get({plain: true});
|
||||
output.business = await business_photos.getBusiness({ transaction });
|
||||
output.photos = await business_photos.getPhotos({ transaction });
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
static async update(id, data, options) {
|
||||
const currentUser = (options && options.currentUser) || {id: null};
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const business_photos = await db.business_photos.findByPk(id, {transaction});
|
||||
|
||||
const updatePayload = { updatedById: currentUser.id };
|
||||
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
|
||||
await business_photos.update(updatePayload, {transaction});
|
||||
|
||||
if (data.business !== undefined) await business_photos.setBusiness(data.business, { transaction });
|
||||
await FileDBApi.replaceRelationFiles(
|
||||
{
|
||||
belongsTo: db.business_photos.getTableName(),
|
||||
belongsToColumn: 'photos',
|
||||
belongsToId: business_photos.id,
|
||||
},
|
||||
data.photos,
|
||||
options,
|
||||
);
|
||||
|
||||
return business_photos;
|
||||
}
|
||||
|
||||
static async deleteByIds(ids, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const business_photos = await db.business_photos.findAll({ where: { id: { [Op.in]: ids } }, transaction });
|
||||
|
||||
for (const record of business_photos) {
|
||||
await record.update({deletedBy: currentUser.id}, {transaction});
|
||||
await record.destroy({transaction});
|
||||
}
|
||||
return business_photos;
|
||||
}
|
||||
|
||||
static async remove(id, options) {
|
||||
const currentUser = (options && options.currentUser) || {id: null};
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const business_photos = await db.business_photos.findByPk(id, options);
|
||||
await business_photos.update({ deletedBy: currentUser.id }, { transaction });
|
||||
await business_photos.destroy({ transaction });
|
||||
return business_photos;
|
||||
}
|
||||
|
||||
static async findAllAutocomplete(query, limit, offset) {
|
||||
let where = {};
|
||||
if (query) {
|
||||
where = {
|
||||
[Op.or]: [
|
||||
{ ['id']: Utils.uuid(query) },
|
||||
Utils.ilike(
|
||||
'business_photos',
|
||||
'photos',
|
||||
query,
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const records = await db.business_photos.findAll({
|
||||
attributes: [ 'id', 'photos' ],
|
||||
attributes: [ 'id' ],
|
||||
where,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
offset: offset ? Number(offset) : undefined,
|
||||
orderBy: [['photos', 'ASC']],
|
||||
order: [['createdAt', 'desc']],
|
||||
});
|
||||
|
||||
return records.map((record) => ({
|
||||
id: record.id,
|
||||
label: record.photos,
|
||||
label: record.id,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
@ -29,16 +29,24 @@ module.exports = class BusinessesDBApi {
|
||||
const currentUser = options?.currentUser;
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
// Data Isolation for Crafted Network™
|
||||
// Data Isolation
|
||||
if (currentUser && currentUser.app_role) {
|
||||
const roleName = currentUser.app_role.name;
|
||||
if (roleName === 'Verified Business Owner') {
|
||||
where.owner_userId = currentUser.id;
|
||||
}
|
||||
}
|
||||
const isAdmin = roleName === 'Administrator' || roleName === 'Platform Owner';
|
||||
const isPublicOrConsumer = roleName === 'Public' || roleName === 'Consumer';
|
||||
|
||||
// Public directory should only show active businesses
|
||||
if (!currentUser || currentUser.app_role?.name === 'Public' || currentUser.app_role?.name === 'Consumer') {
|
||||
if (!isAdmin && !isPublicOrConsumer) {
|
||||
// This is a "client" (e.g. Verified Business Owner)
|
||||
if (currentUser.businessId) {
|
||||
where.id = currentUser.businessId;
|
||||
} else {
|
||||
where.owner_userId = currentUser.id;
|
||||
}
|
||||
} else if (isPublicOrConsumer) {
|
||||
where.is_active = true;
|
||||
}
|
||||
} else if (!currentUser) {
|
||||
// Public unauthenticated access
|
||||
where.is_active = true;
|
||||
}
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ module.exports = class Lead_matchesDBApi {
|
||||
const currentUser = options?.currentUser;
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
// Data Isolation for Crafted Network™
|
||||
// Data Isolation for Fix It Local™
|
||||
if (currentUser && currentUser.app_role) {
|
||||
const roleName = currentUser.app_role.name;
|
||||
if (roleName === 'Verified Business Owner') {
|
||||
|
||||
@ -54,14 +54,18 @@ module.exports = class LeadsDBApi {
|
||||
const currentUser = options?.currentUser;
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
// Data Isolation for Crafted Network™
|
||||
// Data Isolation
|
||||
if (currentUser && currentUser.app_role) {
|
||||
const roleName = currentUser.app_role.name;
|
||||
if (roleName === 'Consumer') {
|
||||
where.userId = currentUser.id;
|
||||
} else if (roleName === 'Verified Business Owner') {
|
||||
// Business owners see leads matched to them
|
||||
where['$lead_matches_lead.business.owner_userId$'] = currentUser.id;
|
||||
if (currentUser.businessId) {
|
||||
where['$lead_matches_lead.businessId$'] = currentUser.businessId;
|
||||
} else {
|
||||
where['$lead_matches_lead.business.owner_userId$'] = currentUser.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -75,7 +79,7 @@ module.exports = class LeadsDBApi {
|
||||
}
|
||||
];
|
||||
|
||||
// Apply filters (simplified for brevity, keeping core logic)
|
||||
// Apply filters
|
||||
if (filter) {
|
||||
if (filter.id) where.id = Utils.uuid(filter.id);
|
||||
if (filter.keyword) where.keyword = { [Op.iLike]: `%${filter.keyword}%` };
|
||||
@ -137,7 +141,17 @@ module.exports = class LeadsDBApi {
|
||||
as: 'lead_matches_lead',
|
||||
include: [{ model: db.businesses, as: 'business' }]
|
||||
},
|
||||
{ model: db.messages, as: 'messages_lead' }
|
||||
{ model: db.messages, as: 'messages_lead' },
|
||||
{
|
||||
model: db.lead_photos,
|
||||
as: 'lead_photos_lead',
|
||||
include: [{ model: db.file, as: 'photos' }]
|
||||
},
|
||||
{
|
||||
model: db.lead_events,
|
||||
as: 'lead_events_lead',
|
||||
include: [{ model: db.users, as: 'actor_user' }]
|
||||
}
|
||||
],
|
||||
transaction
|
||||
});
|
||||
|
||||
@ -41,15 +41,24 @@ module.exports = class MessagesDBApi {
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const currentUser = options?.currentUser;
|
||||
|
||||
// Data Isolation for Crafted Network™
|
||||
// Data Isolation
|
||||
if (currentUser && currentUser.app_role) {
|
||||
const roleName = currentUser.app_role.name;
|
||||
if (roleName === 'Verified Business Owner') {
|
||||
where[Op.or] = [
|
||||
{ sender_userId: currentUser.id },
|
||||
{ receiver_userId: currentUser.id },
|
||||
{ '$lead.lead_matches_lead.business.owner_userId$': currentUser.id }
|
||||
];
|
||||
const businessId = currentUser.businessId;
|
||||
if (businessId) {
|
||||
where[Op.or] = [
|
||||
{ sender_userId: currentUser.id },
|
||||
{ receiver_userId: currentUser.id },
|
||||
{ '$lead.lead_matches_lead.businessId$': businessId }
|
||||
];
|
||||
} else {
|
||||
where[Op.or] = [
|
||||
{ sender_userId: currentUser.id },
|
||||
{ receiver_userId: currentUser.id },
|
||||
{ '$lead.lead_matches_lead.business.owner_userId$': currentUser.id }
|
||||
];
|
||||
}
|
||||
} else if (roleName === 'Consumer') {
|
||||
where[Op.or] = [
|
||||
{ sender_userId: currentUser.id },
|
||||
@ -106,6 +115,7 @@ module.exports = class MessagesDBApi {
|
||||
where,
|
||||
include,
|
||||
distinct: true,
|
||||
subQuery: false, // Fix for "missing FROM-clause entry" when using limit + nested includes
|
||||
order: filter.field && filter.sort
|
||||
? [[filter.field, filter.sort]]
|
||||
: [['createdAt', 'desc']],
|
||||
|
||||
@ -354,11 +354,15 @@ module.exports = class ReviewsDBApi {
|
||||
|
||||
const currentUser = options?.currentUser;
|
||||
|
||||
// Data Isolation for Crafted Network™
|
||||
// Data Isolation
|
||||
if (currentUser && currentUser.app_role) {
|
||||
const roleName = currentUser.app_role.name;
|
||||
if (roleName === 'Verified Business Owner') {
|
||||
where['$business.owner_userId$'] = currentUser.id;
|
||||
if (currentUser.businessId) {
|
||||
where.businessId = currentUser.businessId;
|
||||
} else {
|
||||
where['$business.owner_userId$'] = currentUser.id;
|
||||
}
|
||||
} else if (roleName === 'Consumer') {
|
||||
where.userId = currentUser.id;
|
||||
}
|
||||
@ -652,4 +656,4 @@ module.exports = class ReviewsDBApi {
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
};
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const crypto = require('crypto');
|
||||
@ -307,6 +306,20 @@ module.exports = class Service_pricesDBApi {
|
||||
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
const currentUser = options?.currentUser;
|
||||
|
||||
// Data Isolation
|
||||
if (currentUser && currentUser.app_role) {
|
||||
const roleName = currentUser.app_role.name;
|
||||
if (roleName === 'Verified Business Owner') {
|
||||
if (currentUser.businessId) {
|
||||
where.businessId = currentUser.businessId;
|
||||
} else {
|
||||
where['$business.owner_userId$'] = currentUser.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let include = [
|
||||
|
||||
{
|
||||
@ -591,5 +604,4 @@ module.exports = class Service_pricesDBApi {
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const crypto = require('crypto');
|
||||
@ -85,6 +84,7 @@ module.exports = class UsersDBApi {
|
||||
null
|
||||
,
|
||||
|
||||
businessId: data.data.businessId || null,
|
||||
importHash: data.data.importHash || null,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
@ -298,6 +298,7 @@ module.exports = class UsersDBApi {
|
||||
|
||||
if (data.provider !== undefined) updatePayload.provider = data.provider;
|
||||
|
||||
if (data.businessId !== undefined) updatePayload.businessId = data.businessId;
|
||||
|
||||
updatePayload.updatedById = currentUser.id;
|
||||
|
||||
@ -983,5 +984,4 @@ module.exports = class UsersDBApi {
|
||||
|
||||
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
@ -1,18 +1,12 @@
|
||||
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const crypto = require('crypto');
|
||||
const Utils = require('../utils');
|
||||
|
||||
|
||||
|
||||
const Sequelize = db.Sequelize;
|
||||
const Op = Sequelize.Op;
|
||||
|
||||
module.exports = class Verification_evidencesDBApi {
|
||||
|
||||
|
||||
|
||||
static async create(data, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
@ -20,404 +14,73 @@ module.exports = class Verification_evidencesDBApi {
|
||||
const verification_evidences = await db.verification_evidences.create(
|
||||
{
|
||||
id: data.id || undefined,
|
||||
|
||||
evidence_type: data.evidence_type
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
url: data.url
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
created_at_ts: data.created_at_ts
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
importHash: data.importHash || null,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
|
||||
await verification_evidences.setSubmission( data.submission || null, {
|
||||
transaction,
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
await FileDBApi.replaceRelationFiles(
|
||||
{
|
||||
belongsTo: db.verification_evidences.getTableName(),
|
||||
belongsToColumn: 'files',
|
||||
belongsToId: verification_evidences.id,
|
||||
evidence_type: data.evidence_type || null,
|
||||
url: data.url || null,
|
||||
created_at_ts: data.created_at_ts || null,
|
||||
importHash: data.importHash || null,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
data.files,
|
||||
options,
|
||||
);
|
||||
|
||||
|
||||
return verification_evidences;
|
||||
}
|
||||
|
||||
|
||||
static async bulkImport(data, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
// Prepare data - wrapping individual data transformations in a map() method
|
||||
const verification_evidencesData = data.map((item, index) => ({
|
||||
id: item.id || undefined,
|
||||
|
||||
evidence_type: item.evidence_type
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
url: item.url
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
created_at_ts: item.created_at_ts
|
||||
||
|
||||
null
|
||||
,
|
||||
|
||||
importHash: item.importHash || null,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
createdAt: new Date(Date.now() + index * 1000),
|
||||
}));
|
||||
|
||||
// Bulk create items
|
||||
const verification_evidences = await db.verification_evidences.bulkCreate(verification_evidencesData, { transaction });
|
||||
|
||||
// For each item created, replace relation files
|
||||
|
||||
for (let i = 0; i < verification_evidences.length; i++) {
|
||||
await FileDBApi.replaceRelationFiles(
|
||||
{
|
||||
belongsTo: db.verification_evidences.getTableName(),
|
||||
belongsToColumn: 'files',
|
||||
belongsToId: verification_evidences[i].id,
|
||||
},
|
||||
data[i].files,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return verification_evidences;
|
||||
}
|
||||
|
||||
static async update(id, data, options) {
|
||||
const currentUser = (options && options.currentUser) || {id: null};
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
|
||||
const verification_evidences = await db.verification_evidences.findByPk(id, {}, {transaction});
|
||||
|
||||
|
||||
|
||||
|
||||
const updatePayload = {};
|
||||
|
||||
if (data.evidence_type !== undefined) updatePayload.evidence_type = data.evidence_type;
|
||||
|
||||
|
||||
if (data.url !== undefined) updatePayload.url = data.url;
|
||||
|
||||
|
||||
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
|
||||
|
||||
|
||||
updatePayload.updatedById = currentUser.id;
|
||||
|
||||
await verification_evidences.update(updatePayload, {transaction});
|
||||
|
||||
|
||||
|
||||
if (data.submission !== undefined) {
|
||||
await verification_evidences.setSubmission(
|
||||
|
||||
data.submission,
|
||||
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
await FileDBApi.replaceRelationFiles(
|
||||
{
|
||||
belongsTo: db.verification_evidences.getTableName(),
|
||||
belongsToColumn: 'files',
|
||||
belongsToId: verification_evidences.id,
|
||||
},
|
||||
data.files,
|
||||
options,
|
||||
);
|
||||
|
||||
|
||||
return verification_evidences;
|
||||
}
|
||||
|
||||
static async deleteByIds(ids, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
const verification_evidences = await db.verification_evidences.findAll({
|
||||
where: {
|
||||
id: {
|
||||
[Op.in]: ids,
|
||||
},
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
await db.sequelize.transaction(async (transaction) => {
|
||||
for (const record of verification_evidences) {
|
||||
await record.update(
|
||||
{deletedBy: currentUser.id},
|
||||
{transaction}
|
||||
);
|
||||
}
|
||||
for (const record of verification_evidences) {
|
||||
await record.destroy({transaction});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return verification_evidences;
|
||||
}
|
||||
|
||||
static async remove(id, options) {
|
||||
const currentUser = (options && options.currentUser) || {id: null};
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
const verification_evidences = await db.verification_evidences.findByPk(id, options);
|
||||
|
||||
await verification_evidences.update({
|
||||
deletedBy: currentUser.id
|
||||
}, {
|
||||
transaction,
|
||||
});
|
||||
|
||||
await verification_evidences.destroy({
|
||||
transaction
|
||||
});
|
||||
|
||||
return verification_evidences;
|
||||
}
|
||||
|
||||
static async findBy(where, options) {
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
const verification_evidences = await db.verification_evidences.findOne(
|
||||
{ where },
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
if (!verification_evidences) {
|
||||
return verification_evidences;
|
||||
}
|
||||
await verification_evidences.setSubmission( data.submission || null, { transaction });
|
||||
await FileDBApi.replaceRelationFiles(
|
||||
{
|
||||
belongsTo: db.verification_evidences.getTableName(),
|
||||
belongsToColumn: 'files',
|
||||
belongsToId: verification_evidences.id,
|
||||
},
|
||||
data.files,
|
||||
options,
|
||||
);
|
||||
|
||||
const output = verification_evidences.get({plain: true});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
output.submission = await verification_evidences.getSubmission({
|
||||
transaction
|
||||
});
|
||||
|
||||
|
||||
output.files = await verification_evidences.getFiles({
|
||||
transaction
|
||||
});
|
||||
|
||||
|
||||
|
||||
return output;
|
||||
return verification_evidences;
|
||||
}
|
||||
|
||||
static async findAll(
|
||||
filter,
|
||||
options
|
||||
) {
|
||||
static async findAll(filter, options) {
|
||||
const limit = filter.limit || 0;
|
||||
let offset = 0;
|
||||
let where = {};
|
||||
const currentPage = +filter.page;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
offset = currentPage * limit;
|
||||
|
||||
const orderBy = null;
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const currentUser = options?.currentUser;
|
||||
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
let include = [
|
||||
|
||||
{
|
||||
model: db.verification_submissions,
|
||||
as: 'submission',
|
||||
|
||||
where: filter.submission ? {
|
||||
[Op.or]: [
|
||||
{ id: { [Op.in]: filter.submission.split('|').map(term => Utils.uuid(term)) } },
|
||||
{
|
||||
notes: {
|
||||
[Op.or]: filter.submission.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
|
||||
}
|
||||
},
|
||||
]
|
||||
} : {},
|
||||
|
||||
},
|
||||
|
||||
|
||||
|
||||
{
|
||||
model: db.file,
|
||||
as: 'files',
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
if (filter) {
|
||||
if (filter.id) {
|
||||
where = {
|
||||
...where,
|
||||
['id']: Utils.uuid(filter.id),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
if (filter.url) {
|
||||
where = {
|
||||
...where,
|
||||
[Op.and]: Utils.ilike(
|
||||
'verification_evidences',
|
||||
'url',
|
||||
filter.url,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (filter.created_at_tsRange) {
|
||||
const [start, end] = filter.created_at_tsRange;
|
||||
|
||||
if (start !== undefined && start !== null && start !== '') {
|
||||
where = {
|
||||
...where,
|
||||
created_at_ts: {
|
||||
...where.created_at_ts,
|
||||
[Op.gte]: start,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (end !== undefined && end !== null && end !== '') {
|
||||
where = {
|
||||
...where,
|
||||
created_at_ts: {
|
||||
...where.created_at_ts,
|
||||
[Op.lte]: end,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (filter.active !== undefined) {
|
||||
where = {
|
||||
...where,
|
||||
active: filter.active === true || filter.active === 'true'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
if (filter.evidence_type) {
|
||||
where = {
|
||||
...where,
|
||||
evidence_type: filter.evidence_type,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (filter.createdAtRange) {
|
||||
const [start, end] = filter.createdAtRange;
|
||||
|
||||
if (start !== undefined && start !== null && start !== '') {
|
||||
where = {
|
||||
...where,
|
||||
['createdAt']: {
|
||||
...where.createdAt,
|
||||
[Op.gte]: start,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (end !== undefined && end !== null && end !== '') {
|
||||
where = {
|
||||
...where,
|
||||
['createdAt']: {
|
||||
...where.createdAt,
|
||||
[Op.lte]: end,
|
||||
},
|
||||
};
|
||||
// Data Isolation
|
||||
if (currentUser && currentUser.app_role) {
|
||||
const roleName = currentUser.app_role.name;
|
||||
if (roleName === 'Verified Business Owner') {
|
||||
if (currentUser.businessId) {
|
||||
where['$submission.businessId$'] = currentUser.businessId;
|
||||
} else {
|
||||
where['$submission.business.owner_userId$'] = currentUser.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
let include = [
|
||||
{
|
||||
model: db.verification_submissions,
|
||||
as: 'submission',
|
||||
include: [{ model: db.businesses, as: 'business' }],
|
||||
where: filter.submission ? {
|
||||
[Op.or]: [
|
||||
{ id: { [Op.in]: filter.submission.split('|').map(term => Utils.uuid(term)) } },
|
||||
{ notes: { [Op.or]: filter.submission.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) } },
|
||||
]
|
||||
} : {},
|
||||
},
|
||||
{
|
||||
model: db.file,
|
||||
as: 'files',
|
||||
},
|
||||
];
|
||||
|
||||
if (filter) {
|
||||
if (filter.id) where.id = Utils.uuid(filter.id);
|
||||
}
|
||||
|
||||
const queryOptions = {
|
||||
where,
|
||||
@ -426,8 +89,7 @@ module.exports = class Verification_evidencesDBApi {
|
||||
order: filter.field && filter.sort
|
||||
? [[filter.field, filter.sort]]
|
||||
: [['createdAt', 'desc']],
|
||||
transaction: options?.transaction,
|
||||
logging: console.log
|
||||
transaction,
|
||||
};
|
||||
|
||||
if (!options?.countOnly) {
|
||||
@ -435,51 +97,90 @@ module.exports = class Verification_evidencesDBApi {
|
||||
queryOptions.offset = offset ? Number(offset) : undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows, count } = await db.verification_evidences.findAndCountAll(queryOptions);
|
||||
const { rows, count } = await db.verification_evidences.findAndCountAll(queryOptions);
|
||||
|
||||
return {
|
||||
rows: options?.countOnly ? [] : rows,
|
||||
count: count
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error executing query:', error);
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
rows: options?.countOnly ? [] : rows,
|
||||
count: count
|
||||
};
|
||||
}
|
||||
|
||||
static async findAllAutocomplete(query, limit, offset, ) {
|
||||
let where = {};
|
||||
|
||||
|
||||
static async findBy(where, options) {
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const verification_evidences = await db.verification_evidences.findOne({ where, transaction });
|
||||
if (!verification_evidences) return null;
|
||||
|
||||
const output = verification_evidences.get({plain: true});
|
||||
output.submission = await verification_evidences.getSubmission({ transaction });
|
||||
output.files = await verification_evidences.getFiles({ transaction });
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
static async update(id, data, options) {
|
||||
const currentUser = (options && options.currentUser) || {id: null};
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const verification_evidences = await db.verification_evidences.findByPk(id, {transaction});
|
||||
|
||||
const updatePayload = { ...data, updatedById: currentUser.id };
|
||||
await verification_evidences.update(updatePayload, {transaction});
|
||||
|
||||
if (data.submission !== undefined) await verification_evidences.setSubmission(data.submission, { transaction });
|
||||
await FileDBApi.replaceRelationFiles(
|
||||
{
|
||||
belongsTo: db.verification_evidences.getTableName(),
|
||||
belongsToColumn: 'files',
|
||||
belongsToId: verification_evidences.id,
|
||||
},
|
||||
data.files,
|
||||
options,
|
||||
);
|
||||
|
||||
return verification_evidences;
|
||||
}
|
||||
|
||||
static async deleteByIds(ids, options) {
|
||||
const currentUser = (options && options.currentUser) || { id: null };
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const verification_evidences = await db.verification_evidences.findAll({ where: { id: { [Op.in]: ids } }, transaction });
|
||||
|
||||
for (const record of verification_evidences) {
|
||||
await record.update({deletedBy: currentUser.id}, {transaction});
|
||||
await record.destroy({transaction});
|
||||
}
|
||||
return verification_evidences;
|
||||
}
|
||||
|
||||
static async remove(id, options) {
|
||||
const currentUser = (options && options.currentUser) || {id: null};
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const verification_evidences = await db.verification_evidences.findByPk(id, options);
|
||||
await verification_evidences.update({ deletedBy: currentUser.id }, { transaction });
|
||||
await verification_evidences.destroy({ transaction });
|
||||
return verification_evidences;
|
||||
}
|
||||
|
||||
static async findAllAutocomplete(query, limit, offset) {
|
||||
let where = {};
|
||||
if (query) {
|
||||
where = {
|
||||
[Op.or]: [
|
||||
{ ['id']: Utils.uuid(query) },
|
||||
Utils.ilike(
|
||||
'verification_evidences',
|
||||
'url',
|
||||
query,
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const records = await db.verification_evidences.findAll({
|
||||
attributes: [ 'id', 'url' ],
|
||||
attributes: [ 'id' ],
|
||||
where,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
offset: offset ? Number(offset) : undefined,
|
||||
orderBy: [['url', 'ASC']],
|
||||
order: [['createdAt', 'desc']],
|
||||
});
|
||||
|
||||
return records.map((record) => ({
|
||||
id: record.id,
|
||||
label: record.url,
|
||||
label: record.id,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const crypto = require('crypto');
|
||||
@ -298,6 +297,20 @@ module.exports = class Verification_submissionsDBApi {
|
||||
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
const currentUser = options?.currentUser;
|
||||
|
||||
// Data Isolation
|
||||
if (currentUser && currentUser.app_role) {
|
||||
const roleName = currentUser.app_role.name;
|
||||
if (roleName === 'Verified Business Owner') {
|
||||
if (currentUser.businessId) {
|
||||
where.businessId = currentUser.businessId;
|
||||
} else {
|
||||
where['$business.owner_userId$'] = currentUser.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let include = [
|
||||
|
||||
{
|
||||
@ -524,5 +537,4 @@ module.exports = class Verification_submissionsDBApi {
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
@ -0,0 +1,24 @@
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
* @param {import("sequelize").QueryInterface} queryInterface
|
||||
* @param {import("sequelize").Sequelize} Sequelize
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn('users', 'businessId', {
|
||||
type: Sequelize.DataTypes.UUID,
|
||||
references: {
|
||||
model: 'businesses',
|
||||
key: 'id',
|
||||
},
|
||||
allowNull: true,
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'SET NULL',
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.removeColumn('users', 'businessId');
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,55 @@
|
||||
module.exports = {
|
||||
/**
|
||||
* @param {import("sequelize").QueryInterface} queryInterface
|
||||
* @param {import("sequelize").Sequelize} Sequelize
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async up(queryInterface, Sequelize) {
|
||||
const roles = await queryInterface.sequelize.query(
|
||||
`SELECT id FROM roles WHERE name = 'Trust & Safety Lead'`,
|
||||
{ type: queryInterface.sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
if (!roles || roles.length === 0) return;
|
||||
const roleId = roles[0].id;
|
||||
|
||||
const permissionsToGrant = [
|
||||
'READ_VERIFICATION_SUBMISSIONS',
|
||||
'UPDATE_VERIFICATION_SUBMISSIONS',
|
||||
'READ_VERIFICATION_EVIDENCES',
|
||||
'READ_REVIEWS',
|
||||
'UPDATE_REVIEWS',
|
||||
'DELETE_REVIEWS',
|
||||
'READ_BUSINESSES',
|
||||
'READ_USERS'
|
||||
];
|
||||
|
||||
const permissions = await queryInterface.sequelize.query(
|
||||
`SELECT id, name FROM permissions WHERE name IN (${permissionsToGrant.map(p => `'${p}'`).join(',')})`,
|
||||
{ type: queryInterface.sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
const rolesPermissions = [];
|
||||
for (const p of permissions) {
|
||||
const existing = await queryInterface.sequelize.query(
|
||||
`SELECT * FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" = '${roleId}' AND "permissionId" = '${p.id}'`,
|
||||
{ type: queryInterface.sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
if (existing.length === 0) {
|
||||
rolesPermissions.push({
|
||||
roles_permissionsId: roleId,
|
||||
permissionId: p.id,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (rolesPermissions.length > 0) {
|
||||
await queryInterface.bulkInsert('rolesPermissionsPermissions', rolesPermissions);
|
||||
}
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
// Optionally implement down migration
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,56 @@
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.createTable('listing_events', {
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
},
|
||||
businessId: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'businesses',
|
||||
key: 'id',
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
event_type: {
|
||||
type: Sequelize.ENUM('VIEW', 'CALL_CLICK', 'WEBSITE_CLICK'),
|
||||
allowNull: false,
|
||||
},
|
||||
userId: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id',
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
metadata: {
|
||||
type: Sequelize.JSONB,
|
||||
allowNull: true,
|
||||
},
|
||||
createdAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
deletedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.dropTable('listing_events');
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,91 @@
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.createTable('plans', {
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
},
|
||||
name: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
price: {
|
||||
type: Sequelize.DECIMAL(10, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
features: {
|
||||
type: Sequelize.JSONB,
|
||||
allowNull: true,
|
||||
},
|
||||
createdAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
});
|
||||
|
||||
await queryInterface.addColumn('businesses', 'planId', {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'plans',
|
||||
key: 'id',
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'SET NULL',
|
||||
});
|
||||
|
||||
await queryInterface.addColumn('businesses', 'renewal_date', {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
});
|
||||
|
||||
// Seed basic plans
|
||||
const now = new Date();
|
||||
const plans = [
|
||||
{
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
name: 'Basic (Free)',
|
||||
price: 0,
|
||||
features: JSON.stringify(['Limited Leads', 'Standard Listing']),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-0000-0000-000000000002',
|
||||
name: 'Professional',
|
||||
price: 49.99,
|
||||
features: JSON.stringify(['Unlimited Leads', 'Priority Support', 'Enhanced Profile']),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-0000-0000-000000000003',
|
||||
name: 'Enterprise',
|
||||
price: 199.99,
|
||||
features: JSON.stringify(['Custom Branding', 'API Access', 'Dedicated Manager']),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
|
||||
await queryInterface.bulkInsert('plans', plans);
|
||||
|
||||
// Assign free plan to existing businesses
|
||||
await queryInterface.sequelize.query(
|
||||
`UPDATE businesses SET "planId" = '00000000-0000-0000-0000-000000000001', "renewal_date" = '${new Date(now.getFullYear(), now.getMonth() + 1, now.getDate()).toISOString()}'`
|
||||
);
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeColumn('businesses', 'planId');
|
||||
await queryInterface.removeColumn('businesses', 'renewal_date');
|
||||
await queryInterface.dropTable('plans');
|
||||
},
|
||||
};
|
||||
10
backend/src/db/migrations/20260218040000-fix-client-role.js
Normal file
10
backend/src/db/migrations/20260218040000-fix-client-role.js
Normal file
@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
async up(queryInterface) {
|
||||
const [roles] = await queryInterface.sequelize.query("SELECT id FROM roles WHERE name = 'Verified Business Owner' LIMIT 1;");
|
||||
if (roles && roles.length > 0) {
|
||||
const vboRoleId = roles[0].id;
|
||||
await queryInterface.sequelize.query(`UPDATE users SET "app_roleId" = '${vboRoleId}' WHERE email = 'client@hello.com';`);
|
||||
}
|
||||
},
|
||||
async down(queryInterface) {}
|
||||
};
|
||||
@ -0,0 +1,24 @@
|
||||
module.exports = {
|
||||
async up(queryInterface) {
|
||||
const createdAt = new Date();
|
||||
const updatedAt = new Date();
|
||||
|
||||
const [roles] = await queryInterface.sequelize.query("SELECT id FROM roles WHERE name = 'Verified Business Owner' LIMIT 1;");
|
||||
const [permissions] = await queryInterface.sequelize.query("SELECT id FROM permissions WHERE name = 'CREATE_BUSINESSES' LIMIT 1;");
|
||||
|
||||
if (roles && roles.length > 0 && permissions && permissions.length > 0) {
|
||||
const vboRoleId = roles[0].id;
|
||||
const createPermId = permissions[0].id;
|
||||
|
||||
// Check if permission already exists for this role
|
||||
const [existing] = await queryInterface.sequelize.query(`SELECT * FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" = '${vboRoleId}' AND "permissionId" = '${createPermId}';`);
|
||||
|
||||
if (existing.length === 0) {
|
||||
await queryInterface.bulkInsert("rolesPermissionsPermissions", [
|
||||
{ createdAt, updatedAt, roles_permissionsId: vboRoleId, permissionId: createPermId }
|
||||
]);
|
||||
}
|
||||
}
|
||||
},
|
||||
async down(queryInterface) {}
|
||||
};
|
||||
@ -179,6 +179,16 @@ google_place_id: {
|
||||
allowNull: false,
|
||||
},
|
||||
|
||||
planId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
},
|
||||
|
||||
renewal_date: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
|
||||
created_at_ts: {
|
||||
type: DataTypes.DATE,
|
||||
|
||||
@ -312,8 +322,15 @@ updated_at_ts: {
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
db.businesses.belongsTo(db.plans, {
|
||||
as: 'plan',
|
||||
foreignKey: 'planId',
|
||||
});
|
||||
|
||||
|
||||
db.businesses.hasMany(db.listing_events, {
|
||||
as: 'listing_events',
|
||||
foreignKey: 'businessId',
|
||||
});
|
||||
|
||||
db.businesses.belongsTo(db.users, {
|
||||
as: 'createdBy',
|
||||
@ -327,4 +344,4 @@ updated_at_ts: {
|
||||
|
||||
|
||||
return businesses;
|
||||
};
|
||||
};
|
||||
|
||||
47
backend/src/db/models/listing_events.js
Normal file
47
backend/src/db/models/listing_events.js
Normal file
@ -0,0 +1,47 @@
|
||||
module.exports = function(sequelize, DataTypes) {
|
||||
const listing_events = sequelize.define(
|
||||
'listing_events',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
businessId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
},
|
||||
event_type: {
|
||||
type: DataTypes.ENUM('VIEW', 'CALL_CLICK', 'WEBSITE_CLICK'),
|
||||
allowNull: false,
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
},
|
||||
metadata: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
paranoid: true,
|
||||
freezeTableName: true,
|
||||
},
|
||||
);
|
||||
|
||||
listing_events.associate = (db) => {
|
||||
db.listing_events.belongsTo(db.businesses, {
|
||||
as: 'business',
|
||||
foreignKey: 'businessId',
|
||||
});
|
||||
|
||||
db.listing_events.belongsTo(db.users, {
|
||||
as: 'user',
|
||||
foreignKey: 'userId',
|
||||
});
|
||||
};
|
||||
|
||||
return listing_events;
|
||||
};
|
||||
37
backend/src/db/models/plans.js
Normal file
37
backend/src/db/models/plans.js
Normal file
@ -0,0 +1,37 @@
|
||||
module.exports = function(sequelize, DataTypes) {
|
||||
const plans = sequelize.define(
|
||||
'plans',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
price: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
features: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
},
|
||||
);
|
||||
|
||||
plans.associate = (db) => {
|
||||
db.plans.hasMany(db.businesses, {
|
||||
as: 'businesses',
|
||||
foreignKey: 'planId',
|
||||
});
|
||||
};
|
||||
|
||||
return plans;
|
||||
};
|
||||
@ -104,6 +104,11 @@ provider: {
|
||||
|
||||
},
|
||||
|
||||
businessId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
},
|
||||
|
||||
importHash: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
@ -257,7 +262,13 @@ provider: {
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
|
||||
db.users.belongsTo(db.businesses, {
|
||||
as: 'business',
|
||||
foreignKey: {
|
||||
name: 'businessId',
|
||||
},
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
db.users.hasMany(db.file, {
|
||||
as: 'avatar',
|
||||
@ -322,5 +333,4 @@ function trimStringFields(users) {
|
||||
: null;
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
}
|
||||
@ -18,7 +18,7 @@ const pexelsRoutes = require('./routes/pexels');
|
||||
|
||||
const openaiRoutes = require('./routes/openai');
|
||||
|
||||
|
||||
const dashboardRoutes = require('./routes/dashboard');
|
||||
|
||||
const usersRoutes = require('./routes/users');
|
||||
|
||||
@ -77,8 +77,8 @@ const options = {
|
||||
openapi: "3.0.0",
|
||||
info: {
|
||||
version: "1.0.0",
|
||||
title: "Crafted Network",
|
||||
description: "Crafted Network Online REST API for Testing and Prototyping application. You can perform all major operations with your entities - create, delete and etc.",
|
||||
title: "Fix It Local",
|
||||
description: "Fix It Local Online REST API for Testing and Prototyping application. You can perform all major operations with your entities - create, delete and etc.",
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
@ -117,6 +117,15 @@ app.use(cors({origin: true}));
|
||||
require('./auth/auth');
|
||||
|
||||
app.use(bodyParser.json());
|
||||
|
||||
const optionalAuth = (req, res, next) => {
|
||||
passport.authenticate('jwt', { session: false }, (err, user, info) => {
|
||||
if (user) {
|
||||
req.currentUser = user;
|
||||
}
|
||||
next();
|
||||
})(req, res, next);
|
||||
};
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/file', fileRoutes);
|
||||
@ -132,19 +141,19 @@ app.use('/api/permissions', passport.authenticate('jwt', {session: false}), perm
|
||||
|
||||
app.use('/api/refresh_tokens', passport.authenticate('jwt', {session: false}), refresh_tokensRoutes);
|
||||
|
||||
app.use('/api/categories', categoriesRoutes);
|
||||
app.use('/api/categories', optionalAuth, categoriesRoutes);
|
||||
|
||||
app.use('/api/locations', locationsRoutes);
|
||||
app.use('/api/locations', optionalAuth, locationsRoutes);
|
||||
|
||||
app.use('/api/businesses', businessesRoutes);
|
||||
app.use('/api/businesses', optionalAuth, businessesRoutes);
|
||||
|
||||
app.use('/api/business_photos', business_photosRoutes);
|
||||
app.use('/api/business_photos', optionalAuth, business_photosRoutes);
|
||||
|
||||
app.use('/api/business_categories', business_categoriesRoutes);
|
||||
app.use('/api/business_categories', optionalAuth, business_categoriesRoutes);
|
||||
|
||||
app.use('/api/service_prices', service_pricesRoutes);
|
||||
app.use('/api/service_prices', optionalAuth, service_pricesRoutes);
|
||||
|
||||
app.use('/api/business_badges', business_badgesRoutes);
|
||||
app.use('/api/business_badges', optionalAuth, business_badgesRoutes);
|
||||
|
||||
app.use('/api/verification_submissions', passport.authenticate('jwt', {session: false}), verification_submissionsRoutes);
|
||||
|
||||
@ -160,7 +169,7 @@ app.use('/api/messages', passport.authenticate('jwt', {session: false}), message
|
||||
|
||||
app.use('/api/lead_events', passport.authenticate('jwt', {session: false}), lead_eventsRoutes);
|
||||
|
||||
app.use('/api/reviews', reviewsRoutes);
|
||||
app.use('/api/reviews', optionalAuth, reviewsRoutes);
|
||||
|
||||
app.use('/api/disputes', passport.authenticate('jwt', {session: false}), disputesRoutes);
|
||||
|
||||
@ -181,6 +190,10 @@ app.use(
|
||||
openaiRoutes,
|
||||
);
|
||||
|
||||
app.use(
|
||||
'/api/dashboard',
|
||||
dashboardRoutes);
|
||||
|
||||
app.use(
|
||||
'/api/search',
|
||||
searchRoutes);
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
const express = require('express');
|
||||
|
||||
const BusinessesService = require('../services/businesses');
|
||||
@ -405,7 +404,6 @@ router.get('/count', wrapAsync(async (req, res) => {
|
||||
const currentUser = req.currentUser;
|
||||
const payload = await BusinessesDBApi.findAll(
|
||||
req.query,
|
||||
null,
|
||||
{ countOnly: true, currentUser }
|
||||
);
|
||||
|
||||
@ -493,4 +491,4 @@ router.get('/:id', wrapAsync(async (req, res) => {
|
||||
|
||||
router.use('/', require('../helpers').commonErrorHandler);
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
31
backend/src/routes/dashboard.js
Normal file
31
backend/src/routes/dashboard.js
Normal file
@ -0,0 +1,31 @@
|
||||
const express = require('express');
|
||||
const DashboardService = require('../services/dashboard');
|
||||
const { wrapAsync } = require('../helpers');
|
||||
const db = require('../db/models');
|
||||
const passport = require('passport');
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/business-metrics', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => {
|
||||
const payload = await DashboardService.getBusinessMetrics(req.currentUser);
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
router.post('/record-event', (req, res, next) => {
|
||||
passport.authenticate('jwt', { session: false }, (err, user) => {
|
||||
req.currentUser = user || null;
|
||||
next();
|
||||
})(req, res, next);
|
||||
}, wrapAsync(async (req, res) => {
|
||||
const { businessId, event_type, metadata } = req.body;
|
||||
|
||||
const event = await db.listing_events.create({
|
||||
businessId,
|
||||
event_type,
|
||||
userId: req.currentUser ? req.currentUser.id : null,
|
||||
metadata
|
||||
});
|
||||
|
||||
res.status(200).send(event);
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
@ -25,6 +25,14 @@ module.exports = class BusinessesService {
|
||||
},
|
||||
);
|
||||
|
||||
// Link business to user if they don't have one set yet
|
||||
if (currentUser.app_role?.name === 'Verified Business Owner' && !currentUser.businessId) {
|
||||
await db.users.update({ businessId: business.id }, {
|
||||
where: { id: currentUser.id },
|
||||
transaction
|
||||
});
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
return business;
|
||||
} catch (error) {
|
||||
@ -49,6 +57,14 @@ module.exports = class BusinessesService {
|
||||
is_claimed: true,
|
||||
}, { transaction });
|
||||
|
||||
// Link business to user if they don't have one set yet
|
||||
if (!currentUser.businessId) {
|
||||
await db.users.update({ businessId: business.id }, {
|
||||
where: { id: currentUser.id },
|
||||
transaction
|
||||
});
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
return business;
|
||||
} catch (error) {
|
||||
@ -65,7 +81,7 @@ module.exports = class BusinessesService {
|
||||
const bufferStream = new stream.PassThrough();
|
||||
const results = [];
|
||||
|
||||
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
|
||||
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8"));
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
bufferStream
|
||||
@ -108,7 +124,7 @@ module.exports = class BusinessesService {
|
||||
|
||||
// Ownership check for Verified Business Owner
|
||||
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||
if (business.owner_userId !== currentUser.id) {
|
||||
if (business.owner_userId !== currentUser.id && business.id !== currentUser.businessId) {
|
||||
throw new ForbiddenError('forbidden');
|
||||
}
|
||||
// Prevent transferring ownership
|
||||
@ -143,7 +159,10 @@ module.exports = class BusinessesService {
|
||||
const records = await db.businesses.findAll({
|
||||
where: {
|
||||
id: { [db.Sequelize.Op.in]: ids },
|
||||
owner_userId: { [db.Sequelize.Op.ne]: currentUser.id }
|
||||
[db.Sequelize.Op.and]: [
|
||||
{ owner_userId: { [db.Sequelize.Op.ne]: currentUser.id } },
|
||||
{ id: { [db.Sequelize.Op.ne]: currentUser.businessId || null } }
|
||||
]
|
||||
},
|
||||
transaction
|
||||
});
|
||||
@ -169,7 +188,9 @@ module.exports = class BusinessesService {
|
||||
|
||||
try {
|
||||
let business = await db.businesses.findByPk(id, { transaction });
|
||||
if (currentUser.app_role?.name === 'Verified Business Owner' && business.owner_userId !== currentUser.id) {
|
||||
if (!business) throw new ValidationError('businessesNotFound');
|
||||
|
||||
if (currentUser.app_role?.name === 'Verified Business Owner' && business.owner_userId !== currentUser.id && business.id !== currentUser.businessId) {
|
||||
throw new ForbiddenError('forbidden');
|
||||
}
|
||||
|
||||
@ -189,4 +210,4 @@ module.exports = class BusinessesService {
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
};
|
||||
|
||||
179
backend/src/services/dashboard.js
Normal file
179
backend/src/services/dashboard.js
Normal file
@ -0,0 +1,179 @@
|
||||
const { Op } = require('sequelize');
|
||||
const db = require('../db/models');
|
||||
const moment = require('moment');
|
||||
|
||||
module.exports = class DashboardService {
|
||||
static async getBusinessMetrics(currentUser) {
|
||||
// 1. Get businesses owned by current user
|
||||
const businesses = await db.businesses.findAll({
|
||||
where: { owner_userId: currentUser.id },
|
||||
attributes: ['id', 'name', 'planId', 'renewal_date', 'reliability_score', 'description', 'phone', 'website', 'address', 'hours_json'],
|
||||
include: [{ model: db.plans, as: 'plan' }]
|
||||
});
|
||||
|
||||
if (!businesses.length) {
|
||||
return { no_business: true };
|
||||
}
|
||||
|
||||
const businessIds = businesses.map(b => b.id);
|
||||
const last24h = moment().subtract(24, 'hours').toDate();
|
||||
const last30d = moment().subtract(30, 'days').toDate();
|
||||
const last7d = moment().subtract(7, 'days').toDate();
|
||||
|
||||
// --- Action Queue ---
|
||||
// New leads last 24h
|
||||
const newLeads24h = await db.lead_matches.count({
|
||||
where: {
|
||||
businessId: { [Op.in]: businessIds },
|
||||
createdAt: { [Op.gte]: last24h }
|
||||
}
|
||||
});
|
||||
|
||||
// Leads needing response (status NEW in lead_matches OR unread messages)
|
||||
const leadsNeedingResponse = await db.lead_matches.count({
|
||||
where: {
|
||||
businessId: { [Op.in]: businessIds },
|
||||
[Op.or]: [
|
||||
{ status: 'SENT' }, // SENT means it's new for the business
|
||||
{ leadId: { [Op.in]: db.sequelize.literal(`(SELECT "leadId" FROM messages WHERE "receiver_userId" = '${currentUser.id}' AND "read_at" IS NULL)`) } }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const verificationPending = await db.verification_submissions.count({
|
||||
where: {
|
||||
businessId: { [Op.in]: businessIds },
|
||||
status: 'PENDING'
|
||||
}
|
||||
});
|
||||
|
||||
// --- Lead Pipeline Snapshot ---
|
||||
const pipelineStats = await db.lead_matches.findAll({
|
||||
where: { businessId: { [Op.in]: businessIds } },
|
||||
attributes: [
|
||||
'status',
|
||||
[db.sequelize.fn('COUNT', db.sequelize.col('id')), 'count']
|
||||
],
|
||||
group: ['status']
|
||||
});
|
||||
|
||||
const pipeline = pipelineStats.reduce((acc, curr) => {
|
||||
acc[curr.status] = parseInt(curr.get('count'));
|
||||
return acc;
|
||||
}, { SENT: 0, VIEWED: 0, RESPONDED: 0, SCHEDULED: 0, COMPLETED: 0, DECLINED: 0 });
|
||||
|
||||
const won30d = await db.lead_matches.count({
|
||||
where: {
|
||||
businessId: { [Op.in]: businessIds },
|
||||
status: 'COMPLETED',
|
||||
updatedAt: { [Op.gte]: last30d }
|
||||
}
|
||||
});
|
||||
const lost30d = await db.lead_matches.count({
|
||||
where: {
|
||||
businessId: { [Op.in]: businessIds },
|
||||
status: 'DECLINED',
|
||||
updatedAt: { [Op.gte]: last30d }
|
||||
}
|
||||
});
|
||||
const winRate30d = (won30d + lost30d) > 0 ? (won30d / (won30d + lost30d)) * 100 : 0;
|
||||
|
||||
// --- Recent Messages ---
|
||||
// Join messages with lead_matches to ensure they belong to this business
|
||||
const recentMessages = await db.messages.findAll({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ sender_userId: currentUser.id },
|
||||
{ receiver_userId: currentUser.id }
|
||||
]
|
||||
},
|
||||
limit: 5,
|
||||
order: [['createdAt', 'DESC']],
|
||||
include: [
|
||||
{ model: db.leads, as: 'lead' },
|
||||
{ model: db.users, as: 'sender_user', attributes: ['firstName', 'lastName'] }
|
||||
]
|
||||
});
|
||||
|
||||
// --- Performance ---
|
||||
const getEventCount = async (type, since) => {
|
||||
return await db.listing_events.count({
|
||||
where: {
|
||||
businessId: { [Op.in]: businessIds },
|
||||
event_type: type,
|
||||
createdAt: { [Op.gte]: since }
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const views7d = await getEventCount('VIEW', last7d);
|
||||
const views30d = await getEventCount('VIEW', last30d);
|
||||
const calls7d = await getEventCount('CALL_CLICK', last7d);
|
||||
const calls30d = await getEventCount('CALL_CLICK', last30d);
|
||||
const website7d = await getEventCount('WEBSITE_CLICK', last7d);
|
||||
const website30d = await getEventCount('WEBSITE_CLICK', last30d);
|
||||
|
||||
const totalClicks30d = calls30d + website30d;
|
||||
const conversionRate = views30d > 0 ? (totalClicks30d / views30d) * 100 : 0;
|
||||
|
||||
// --- Health Score ---
|
||||
const firstBusiness = businesses[0];
|
||||
let healthScore = 0;
|
||||
const missingFields = [];
|
||||
const fieldsToCheck = [
|
||||
{ name: 'name', weight: 10, label: 'Name' },
|
||||
{ name: 'description', weight: 20, label: 'Description' },
|
||||
{ name: 'phone', weight: 10, label: 'Phone' },
|
||||
{ name: 'website', weight: 10, label: 'Website' },
|
||||
{ name: 'address', weight: 10, label: 'Address' },
|
||||
{ name: 'hours_json', weight: 10, label: 'Business Hours' },
|
||||
];
|
||||
|
||||
fieldsToCheck.forEach(f => {
|
||||
if (firstBusiness[f.name] && firstBusiness[f.name] !== '') {
|
||||
healthScore += f.weight;
|
||||
} else {
|
||||
missingFields.push(f.label);
|
||||
}
|
||||
});
|
||||
|
||||
// Add weights for photos/categories/prices
|
||||
const photoCount = await db.business_photos.count({ where: { businessId: firstBusiness.id } });
|
||||
if (photoCount > 0) healthScore += 15; else missingFields.push('Photos');
|
||||
|
||||
const categoryCount = await db.business_categories.count({ where: { businessId: firstBusiness.id } });
|
||||
if (categoryCount > 0) healthScore += 10; else missingFields.push('Categories');
|
||||
|
||||
const priceCount = await db.service_prices.count({ where: { businessId: firstBusiness.id } });
|
||||
if (priceCount > 0) healthScore += 5; else missingFields.push('Service Prices');
|
||||
|
||||
return {
|
||||
businesses,
|
||||
action_queue: {
|
||||
newLeads24h,
|
||||
leadsNeedingResponse,
|
||||
verificationPending,
|
||||
missingFields
|
||||
},
|
||||
pipeline: {
|
||||
NEW: pipeline.SENT,
|
||||
CONTACTED: pipeline.VIEWED + pipeline.RESPONDED,
|
||||
SCHEDULED: pipeline.SCHEDULED,
|
||||
WON: pipeline.COMPLETED,
|
||||
LOST: pipeline.DECLINED,
|
||||
winRate30d
|
||||
},
|
||||
recentMessages,
|
||||
performance: {
|
||||
views7d,
|
||||
views30d,
|
||||
calls7d,
|
||||
calls30d,
|
||||
website7d,
|
||||
website30d,
|
||||
conversionRate
|
||||
},
|
||||
healthScore: Math.min(healthScore, 100)
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -2,6 +2,7 @@ const db = require('../db/models');
|
||||
const LeadsDBApi = require('../db/api/leads');
|
||||
const processFile = require("../middlewares/upload");
|
||||
const ValidationError = require('./notifications/errors/validation');
|
||||
const ForbiddenError = require('./notifications/errors/forbidden');
|
||||
const csv = require('csv-parser');
|
||||
const axios = require('axios');
|
||||
const config = require('../config');
|
||||
@ -70,6 +71,18 @@ module.exports = class LeadsService {
|
||||
try {
|
||||
let leads = await LeadsDBApi.findBy({id}, {transaction});
|
||||
if (!leads) { throw new ValidationError('leadsNotFound'); }
|
||||
|
||||
// Ownership check for Verified Business Owner
|
||||
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||
const match = await db.lead_matches.findOne({
|
||||
where: { leadId: id, businessId: currentUser.businessId },
|
||||
transaction
|
||||
});
|
||||
if (!match) {
|
||||
throw new ForbiddenError('forbidden');
|
||||
}
|
||||
}
|
||||
|
||||
const updatedLeads = await LeadsDBApi.update(id, data, { currentUser, transaction });
|
||||
await transaction.commit();
|
||||
return updatedLeads;
|
||||
@ -82,6 +95,20 @@ module.exports = class LeadsService {
|
||||
static async deleteByIds(ids, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
// Ownership check for Verified Business Owner
|
||||
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||
const count = await db.lead_matches.count({
|
||||
where: {
|
||||
leadId: { [db.Sequelize.Op.in]: ids },
|
||||
businessId: currentUser.businessId
|
||||
},
|
||||
transaction
|
||||
});
|
||||
if (count !== ids.length) {
|
||||
throw new ForbiddenError('forbidden');
|
||||
}
|
||||
}
|
||||
|
||||
await LeadsDBApi.deleteByIds(ids, { currentUser, transaction });
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
@ -93,6 +120,17 @@ module.exports = class LeadsService {
|
||||
static async remove(id, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
// Ownership check for Verified Business Owner
|
||||
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||
const match = await db.lead_matches.findOne({
|
||||
where: { leadId: id, businessId: currentUser.businessId },
|
||||
transaction
|
||||
});
|
||||
if (!match) {
|
||||
throw new ForbiddenError('forbidden');
|
||||
}
|
||||
}
|
||||
|
||||
await LeadsDBApi.remove(id, { currentUser, transaction });
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
@ -100,4 +138,4 @@ module.exports = class LeadsService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@ -2,20 +2,38 @@ const db = require('../db/models');
|
||||
const MessagesDBApi = require('../db/api/messages');
|
||||
const processFile = require("../middlewares/upload");
|
||||
const ValidationError = require('./notifications/errors/validation');
|
||||
const ForbiddenError = require('./notifications/errors/forbidden');
|
||||
const csv = require('csv-parser');
|
||||
const axios = require('axios');
|
||||
const config = require('../config');
|
||||
const stream = require('stream');
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
module.exports = class MessagesService {
|
||||
static async create(data, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
await MessagesDBApi.create(
|
||||
// For VBOs, ensure they can only message about their leads/businesses
|
||||
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||
// If leadId is provided, check if business is matched to that lead
|
||||
if (data.lead) {
|
||||
const match = await db.lead_matches.findOne({
|
||||
where: {
|
||||
leadId: data.lead,
|
||||
[db.Sequelize.Op.or]: [
|
||||
{ businessId: currentUser.businessId || null },
|
||||
{ '$business.owner_userId$': currentUser.id }
|
||||
]
|
||||
},
|
||||
include: [{ model: db.businesses, as: 'business' }],
|
||||
transaction
|
||||
});
|
||||
if (!match) {
|
||||
throw new ForbiddenError('forbidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const message = await MessagesDBApi.create(
|
||||
data,
|
||||
{
|
||||
currentUser,
|
||||
@ -24,6 +42,7 @@ module.exports = class MessagesService {
|
||||
);
|
||||
|
||||
await transaction.commit();
|
||||
return message;
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
@ -38,14 +57,13 @@ module.exports = class MessagesService {
|
||||
const bufferStream = new stream.PassThrough();
|
||||
const results = [];
|
||||
|
||||
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
|
||||
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8"));
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
bufferStream
|
||||
.pipe(csv())
|
||||
.on('data', (data) => results.push(data))
|
||||
.on('end', async () => {
|
||||
console.log('CSV results', results);
|
||||
resolve();
|
||||
})
|
||||
.on('error', (error) => reject(error));
|
||||
@ -68,17 +86,38 @@ module.exports = class MessagesService {
|
||||
static async update(data, id, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
let messages = await MessagesDBApi.findBy(
|
||||
let message = await MessagesDBApi.findBy(
|
||||
{id},
|
||||
{transaction},
|
||||
);
|
||||
|
||||
if (!messages) {
|
||||
if (!message) {
|
||||
throw new ValidationError(
|
||||
'messagesNotFound',
|
||||
);
|
||||
}
|
||||
|
||||
// Ownership check for Verified Business Owner
|
||||
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||
if (message.sender_userId !== currentUser.id && message.receiver_userId !== currentUser.id) {
|
||||
// Also check if it's about their lead
|
||||
const match = await db.lead_matches.findOne({
|
||||
where: {
|
||||
leadId: message.lead?.id,
|
||||
[db.Sequelize.Op.or]: [
|
||||
{ businessId: currentUser.businessId || null },
|
||||
{ '$business.owner_userId$': currentUser.id }
|
||||
]
|
||||
},
|
||||
include: [{ model: db.businesses, as: 'business' }],
|
||||
transaction
|
||||
});
|
||||
if (!match) {
|
||||
throw new ForbiddenError('forbidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatedMessages = await MessagesDBApi.update(
|
||||
id,
|
||||
data,
|
||||
@ -101,6 +140,19 @@ module.exports = class MessagesService {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
// Ownership check for Verified Business Owner
|
||||
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||
const records = await db.messages.findAll({
|
||||
where: { id: { [db.Sequelize.Op.in]: ids } },
|
||||
transaction
|
||||
});
|
||||
for (const record of records) {
|
||||
if (record.sender_userId !== currentUser.id && record.receiver_userId !== currentUser.id) {
|
||||
throw new ForbiddenError('forbidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await MessagesDBApi.deleteByIds(ids, {
|
||||
currentUser,
|
||||
transaction,
|
||||
@ -117,6 +169,15 @@ module.exports = class MessagesService {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
const record = await db.messages.findByPk(id, { transaction });
|
||||
if (!record) throw new ValidationError('messagesNotFound');
|
||||
|
||||
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||
if (record.sender_userId !== currentUser.id && record.receiver_userId !== currentUser.id) {
|
||||
throw new ForbiddenError('forbidden');
|
||||
}
|
||||
}
|
||||
|
||||
await MessagesDBApi.remove(
|
||||
id,
|
||||
{
|
||||
@ -133,6 +194,4 @@ module.exports = class MessagesService {
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
|
||||
};
|
||||
@ -1,6 +1,6 @@
|
||||
const errors = {
|
||||
app: {
|
||||
title: 'Crafted Network',
|
||||
title: 'Fix It Local',
|
||||
},
|
||||
|
||||
auth: {
|
||||
|
||||
@ -108,12 +108,16 @@ module.exports = class ReviewsService {
|
||||
}
|
||||
|
||||
// Ownership check for Verified Business Owner
|
||||
// VBO can only update reviews for their own businesses
|
||||
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||
// Check business owner
|
||||
const business = await db.businesses.findByPk(review.businessId, { transaction });
|
||||
if (business && business.owner_userId !== currentUser.id) {
|
||||
throw new ForbiddenError('forbidden');
|
||||
if (currentUser.businessId) {
|
||||
if (review.businessId !== currentUser.businessId) {
|
||||
throw new ForbiddenError('forbidden');
|
||||
}
|
||||
} else {
|
||||
const business = await db.businesses.findByPk(review.businessId, { transaction });
|
||||
if (business && business.owner_userId !== currentUser.id) {
|
||||
throw new ForbiddenError('forbidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -147,6 +151,23 @@ module.exports = class ReviewsService {
|
||||
where: { id: { [db.Sequelize.Op.in]: ids } },
|
||||
transaction,
|
||||
});
|
||||
|
||||
// Ownership check for Verified Business Owner
|
||||
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||
for (const review of reviews) {
|
||||
if (currentUser.businessId) {
|
||||
if (review.businessId !== currentUser.businessId) {
|
||||
throw new ForbiddenError('forbidden');
|
||||
}
|
||||
} else {
|
||||
const business = await db.businesses.findByPk(review.businessId, { transaction });
|
||||
if (business && business.owner_userId !== currentUser.id) {
|
||||
throw new ForbiddenError('forbidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const businessIds = [...new Set(reviews.map(r => r.businessId))];
|
||||
|
||||
await ReviewsDBApi.deleteByIds(ids, {
|
||||
@ -170,6 +191,24 @@ module.exports = class ReviewsService {
|
||||
|
||||
try {
|
||||
const review = await db.reviews.findByPk(id, { transaction });
|
||||
if (!review) {
|
||||
throw new ValidationError('reviewsNotFound');
|
||||
}
|
||||
|
||||
// Ownership check for Verified Business Owner
|
||||
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||
if (currentUser.businessId) {
|
||||
if (review.businessId !== currentUser.businessId) {
|
||||
throw new ForbiddenError('forbidden');
|
||||
}
|
||||
} else {
|
||||
const business = await db.businesses.findByPk(review.businessId, { transaction });
|
||||
if (business && business.owner_userId !== currentUser.id) {
|
||||
throw new ForbiddenError('forbidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const businessId = review.businessId;
|
||||
|
||||
await ReviewsDBApi.remove(
|
||||
@ -188,4 +227,4 @@ module.exports = class ReviewsService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@ -7,9 +7,6 @@ const axios = require('axios');
|
||||
const config = require('../config');
|
||||
const stream = require('stream');
|
||||
|
||||
|
||||
|
||||
|
||||
function buildWidgetResult(widget, queryResult, queryString) {
|
||||
if (queryResult[0] && queryResult[0].length) {
|
||||
const key = Object.keys(queryResult[0][0])[0];
|
||||
@ -17,14 +14,14 @@ function buildWidgetResult(widget, queryResult, queryString) {
|
||||
const widgetData = JSON.parse(widget.data);
|
||||
return { ...widget, ...widgetData, value, query: queryString };
|
||||
} else {
|
||||
return { ...widget, value: [], query: queryString };
|
||||
return { ...widget, value: widget.widget_type === 'scalar' ? 0 : [], query: queryString };
|
||||
}
|
||||
}
|
||||
|
||||
async function executeQuery(queryString, currentUser) {
|
||||
async function executeQuery(queryString, replacements) {
|
||||
try {
|
||||
return await db.sequelize.query(queryString, {
|
||||
replacements: { organizationId: currentUser.organizationId },
|
||||
replacements,
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
@ -49,19 +46,46 @@ function insertWhereConditions(queryString, whereConditions) {
|
||||
}
|
||||
|
||||
function constructWhereConditions(mainTable, currentUser, replacements) {
|
||||
const { organizationId, app_role: { globalAccess } } = currentUser;
|
||||
const organizationId = currentUser.organizationId;
|
||||
const roleName = currentUser.app_role?.name;
|
||||
const globalAccess = currentUser.app_role?.globalAccess;
|
||||
const currentUserId = currentUser.id;
|
||||
const businessId = currentUser.businessId;
|
||||
|
||||
const tablesWithoutOrgId = ['permissions', 'roles'];
|
||||
let whereConditions = '';
|
||||
let conditions = [];
|
||||
|
||||
if (!globalAccess && !tablesWithoutOrgId.includes(mainTable)) {
|
||||
whereConditions += `"${mainTable}"."organizationId" = :organizationId`;
|
||||
replacements.organizationId = organizationId;
|
||||
if (organizationId) {
|
||||
conditions.push(`"${mainTable}"."organizationId" = :organizationId`);
|
||||
replacements.organizationId = organizationId;
|
||||
}
|
||||
}
|
||||
|
||||
whereConditions += whereConditions ? ' AND ' : '';
|
||||
whereConditions += `"${mainTable}"."deletedAt" IS NULL`;
|
||||
// Business User isolation
|
||||
if (roleName === 'Verified Business Owner') {
|
||||
if (mainTable === 'businesses') {
|
||||
if (businessId) {
|
||||
conditions.push(`"${mainTable}"."id" = :businessId`);
|
||||
replacements.businessId = businessId;
|
||||
} else {
|
||||
conditions.push(`"${mainTable}"."owner_userId" = :currentUserId`);
|
||||
replacements.currentUserId = currentUserId;
|
||||
}
|
||||
} else if (['leads', 'messages', 'reviews', 'service_prices', 'verification_submissions'].includes(mainTable)) {
|
||||
if (businessId) {
|
||||
conditions.push(`"${mainTable}"."businessId" = :businessId`);
|
||||
replacements.businessId = businessId;
|
||||
} else {
|
||||
// Fallback: try to filter by owner_userId if we can join or if it's leads (via matches)
|
||||
// For now, we assume businessId is on the user for most VBOs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return whereConditions;
|
||||
conditions.push(`"${mainTable}"."deletedAt" IS NULL`);
|
||||
|
||||
return conditions.join(' AND ');
|
||||
}
|
||||
|
||||
function extractTableName(queryString) {
|
||||
@ -77,16 +101,15 @@ function buildQueryString(widget, currentUser) {
|
||||
const replacements = {};
|
||||
const whereConditions = constructWhereConditions(mainTable, currentUser, replacements);
|
||||
queryString = insertWhereConditions(queryString, whereConditions);
|
||||
console.log(queryString, 'queryString');
|
||||
return queryString;
|
||||
return { queryString, replacements };
|
||||
}
|
||||
|
||||
async function constructWidgetsResults(widgets, currentUser) {
|
||||
const widgetsResults = [];
|
||||
for (const widget of widgets) {
|
||||
if (!widget) continue;
|
||||
const queryString = buildQueryString(widget, currentUser);
|
||||
const queryResult = await executeQuery(queryString, currentUser);
|
||||
const { queryString, replacements } = buildQueryString(widget, currentUser);
|
||||
const queryResult = await executeQuery(queryString, replacements);
|
||||
widgetsResults.push(buildWidgetResult(widget, queryResult, queryString));
|
||||
}
|
||||
return widgetsResults;
|
||||
@ -107,30 +130,6 @@ async function processWidgets(widgets, currentUser) {
|
||||
return constructWidgetsResults(widgetData, currentUser);
|
||||
}
|
||||
|
||||
function parseCustomization(role) {
|
||||
try {
|
||||
return JSON.parse(role.role_customization || '{}');
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function findRole(roleId, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
const role = roleId
|
||||
? await RolesDBApi.findBy({ id: roleId }, { transaction })
|
||||
: await RolesDBApi.findBy({ name: 'User' }, { transaction });
|
||||
await transaction.commit();
|
||||
return role;
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
module.exports = class RolesService {
|
||||
static async create(data, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
@ -158,14 +157,13 @@ module.exports = class RolesService {
|
||||
const bufferStream = new stream.PassThrough();
|
||||
const results = [];
|
||||
|
||||
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
|
||||
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8"));
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
bufferStream
|
||||
.pipe(csv())
|
||||
.on('data', (data) => results.push(data))
|
||||
.on('end', async () => {
|
||||
console.log('CSV results', results);
|
||||
resolve();
|
||||
})
|
||||
.on('error', (error) => reject(error));
|
||||
@ -362,11 +360,6 @@ module.exports = class RolesService {
|
||||
static async getRoleInfoByKey(key, roleId, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
const organizationId = currentUser.organizationId;
|
||||
let globalAccess = currentUser.app_role?.globalAccess;
|
||||
let queryString = '';
|
||||
|
||||
|
||||
let role;
|
||||
try {
|
||||
if (roleId) {
|
||||
@ -381,7 +374,7 @@ module.exports = class RolesService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
let customization = '{}';
|
||||
let customization = {};
|
||||
|
||||
try {
|
||||
customization = JSON.parse(role.role_customization || '{}');
|
||||
@ -391,47 +384,8 @@ module.exports = class RolesService {
|
||||
|
||||
if (key === 'widgets') {
|
||||
const widgets = (customization[key] || []);
|
||||
const widgetArray = widgets.map(widget => {
|
||||
return axios.get(`${config.flHost}/${config.project_uuid}/project_customization_widgets/${widget}.json`)
|
||||
})
|
||||
const widgetResults = await Promise.allSettled(widgetArray);
|
||||
|
||||
const fulfilledWidgets = widgetResults.map(result => {
|
||||
if (result.status === 'fulfilled') {
|
||||
return result.value.data;
|
||||
}
|
||||
});
|
||||
|
||||
const widgetsResults = [];
|
||||
|
||||
if (Array.isArray(fulfilledWidgets)) {
|
||||
for (const widget of fulfilledWidgets) {
|
||||
let result = [];
|
||||
try {
|
||||
result = await db.sequelize.query(widget.query);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
if (result[0] && result[0].length) {
|
||||
const key = Object.keys(result[0][0])[0];
|
||||
const value =
|
||||
widget.widget_type === 'scalar' ? result[0][0][key] : result[0];
|
||||
const widgetData = JSON.parse(widget.data);
|
||||
widgetsResults.push({ ...widget, ...widgetData, value });
|
||||
} else {
|
||||
widgetsResults.push({ ...widget, value: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
return widgetsResults;
|
||||
return await processWidgets(widgets, currentUser);
|
||||
}
|
||||
return customization[key];
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
|
||||
};
|
||||
@ -2,20 +2,27 @@ const db = require('../db/models');
|
||||
const Service_pricesDBApi = require('../db/api/service_prices');
|
||||
const processFile = require("../middlewares/upload");
|
||||
const ValidationError = require('./notifications/errors/validation');
|
||||
const ForbiddenError = require('./notifications/errors/forbidden');
|
||||
const csv = require('csv-parser');
|
||||
const axios = require('axios');
|
||||
const config = require('../config');
|
||||
const stream = require('stream');
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
module.exports = class Service_pricesService {
|
||||
static async create(data, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
await Service_pricesDBApi.create(
|
||||
// Ownership check for Verified Business Owner
|
||||
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||
const businessId = data.business || currentUser.businessId;
|
||||
const business = await db.businesses.findByPk(businessId, { transaction });
|
||||
if (!business || business.owner_userId !== currentUser.id) {
|
||||
throw new ForbiddenError('forbidden');
|
||||
}
|
||||
data.business = business.id;
|
||||
}
|
||||
|
||||
const service_price = await Service_pricesDBApi.create(
|
||||
data,
|
||||
{
|
||||
currentUser,
|
||||
@ -24,6 +31,7 @@ module.exports = class Service_pricesService {
|
||||
);
|
||||
|
||||
await transaction.commit();
|
||||
return service_price;
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
@ -38,14 +46,13 @@ module.exports = class Service_pricesService {
|
||||
const bufferStream = new stream.PassThrough();
|
||||
const results = [];
|
||||
|
||||
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
|
||||
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8"));
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
bufferStream
|
||||
.pipe(csv())
|
||||
.on('data', (data) => results.push(data))
|
||||
.on('end', async () => {
|
||||
console.log('CSV results', results);
|
||||
resolve();
|
||||
})
|
||||
.on('error', (error) => reject(error));
|
||||
@ -79,6 +86,15 @@ module.exports = class Service_pricesService {
|
||||
);
|
||||
}
|
||||
|
||||
// Ownership check for Verified Business Owner
|
||||
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||
const businessId = service_prices.business?.id;
|
||||
const business = await db.businesses.findByPk(businessId, { transaction });
|
||||
if (!business || business.owner_userId !== currentUser.id) {
|
||||
throw new ForbiddenError('forbidden');
|
||||
}
|
||||
}
|
||||
|
||||
const updatedService_prices = await Service_pricesDBApi.update(
|
||||
id,
|
||||
data,
|
||||
@ -101,6 +117,20 @@ module.exports = class Service_pricesService {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
// Ownership check for Verified Business Owner
|
||||
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||
const records = await db.service_prices.findAll({
|
||||
where: { id: { [db.Sequelize.Op.in]: ids } },
|
||||
include: [{ model: db.businesses, as: 'business' }],
|
||||
transaction
|
||||
});
|
||||
for (const record of records) {
|
||||
if (record.business?.owner_userId !== currentUser.id && record.business?.id !== currentUser.businessId) {
|
||||
throw new ForbiddenError('forbidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Service_pricesDBApi.deleteByIds(ids, {
|
||||
currentUser,
|
||||
transaction,
|
||||
@ -117,6 +147,18 @@ module.exports = class Service_pricesService {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
const record = await db.service_prices.findByPk(id, {
|
||||
include: [{ model: db.businesses, as: 'business' }],
|
||||
transaction
|
||||
});
|
||||
if (!record) throw new ValidationError('service_pricesNotFound');
|
||||
|
||||
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||
if (record.business?.owner_userId !== currentUser.id && record.business?.id !== currentUser.businessId) {
|
||||
throw new ForbiddenError('forbidden');
|
||||
}
|
||||
}
|
||||
|
||||
await Service_pricesDBApi.remove(
|
||||
id,
|
||||
{
|
||||
@ -133,6 +175,4 @@ module.exports = class Service_pricesService {
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
|
||||
};
|
||||
@ -2,20 +2,27 @@ const db = require('../db/models');
|
||||
const Verification_submissionsDBApi = require('../db/api/verification_submissions');
|
||||
const processFile = require("../middlewares/upload");
|
||||
const ValidationError = require('./notifications/errors/validation');
|
||||
const ForbiddenError = require('./notifications/errors/forbidden');
|
||||
const csv = require('csv-parser');
|
||||
const axios = require('axios');
|
||||
const config = require('../config');
|
||||
const stream = require('stream');
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
module.exports = class Verification_submissionsService {
|
||||
static async create(data, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
await Verification_submissionsDBApi.create(
|
||||
// Ownership check for Verified Business Owner
|
||||
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||
const businessId = data.business || currentUser.businessId;
|
||||
const business = await db.businesses.findByPk(businessId, { transaction });
|
||||
if (!business || (business.owner_userId !== currentUser.id && business.id !== currentUser.businessId)) {
|
||||
throw new ForbiddenError('forbidden');
|
||||
}
|
||||
data.business = business.id;
|
||||
}
|
||||
|
||||
const submission = await Verification_submissionsDBApi.create(
|
||||
data,
|
||||
{
|
||||
currentUser,
|
||||
@ -24,6 +31,7 @@ module.exports = class Verification_submissionsService {
|
||||
);
|
||||
|
||||
await transaction.commit();
|
||||
return submission;
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
@ -38,14 +46,13 @@ module.exports = class Verification_submissionsService {
|
||||
const bufferStream = new stream.PassThrough();
|
||||
const results = [];
|
||||
|
||||
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
|
||||
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8"));
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
bufferStream
|
||||
.pipe(csv())
|
||||
.on('data', (data) => results.push(data))
|
||||
.on('end', async () => {
|
||||
console.log('CSV results', results);
|
||||
resolve();
|
||||
})
|
||||
.on('error', (error) => reject(error));
|
||||
@ -79,6 +86,15 @@ module.exports = class Verification_submissionsService {
|
||||
);
|
||||
}
|
||||
|
||||
// Ownership check for Verified Business Owner
|
||||
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||
const businessId = verification_submissions.business?.id;
|
||||
const business = await db.businesses.findByPk(businessId, { transaction });
|
||||
if (!business || (business.owner_userId !== currentUser.id && business.id !== currentUser.businessId)) {
|
||||
throw new ForbiddenError('forbidden');
|
||||
}
|
||||
}
|
||||
|
||||
const updatedVerification_submissions = await Verification_submissionsDBApi.update(
|
||||
id,
|
||||
data,
|
||||
@ -101,6 +117,20 @@ module.exports = class Verification_submissionsService {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
// Ownership check for Verified Business Owner
|
||||
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||
const records = await db.verification_submissions.findAll({
|
||||
where: { id: { [db.Sequelize.Op.in]: ids } },
|
||||
include: [{ model: db.businesses, as: 'business' }],
|
||||
transaction
|
||||
});
|
||||
for (const record of records) {
|
||||
if (record.business?.owner_userId !== currentUser.id && record.business?.id !== currentUser.businessId) {
|
||||
throw new ForbiddenError('forbidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Verification_submissionsDBApi.deleteByIds(ids, {
|
||||
currentUser,
|
||||
transaction,
|
||||
@ -117,6 +147,18 @@ module.exports = class Verification_submissionsService {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
const record = await db.verification_submissions.findByPk(id, {
|
||||
include: [{ model: db.businesses, as: 'business' }],
|
||||
transaction
|
||||
});
|
||||
if (!record) throw new ValidationError('verification_submissionsNotFound');
|
||||
|
||||
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||
if (record.business?.owner_userId !== currentUser.id && record.business?.id !== currentUser.businessId) {
|
||||
throw new ForbiddenError('forbidden');
|
||||
}
|
||||
}
|
||||
|
||||
await Verification_submissionsDBApi.remove(
|
||||
id,
|
||||
{
|
||||
@ -133,6 +175,4 @@ module.exports = class Verification_submissionsService {
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
|
||||
};
|
||||
@ -1,4 +1,4 @@
|
||||
# Crafted Network
|
||||
# Fix It Local
|
||||
|
||||
## This project was generated by Flatlogic Platform.
|
||||
## Install
|
||||
|
||||
@ -1,40 +1,40 @@
|
||||
import type { ColorButtonKey } from './interfaces'
|
||||
|
||||
export const gradientBgBase = 'bg-gradient-to-tr'
|
||||
export const colorBgBase = "bg-violet-50/50"
|
||||
export const gradientBgPurplePink = `${gradientBgBase} from-purple-400 via-pink-500 to-red-500`
|
||||
export const colorBgBase = "bg-emerald-50/50"
|
||||
export const gradientBgPurplePink = `${gradientBgBase} from-purple-400 via-pink-500 to-rose-500`
|
||||
export const gradientBgViolet = `${gradientBgBase} ${colorBgBase}`
|
||||
export const gradientBgDark = `${gradientBgBase} from-dark-700 via-dark-900 to-dark-800`;
|
||||
export const gradientBgPinkRed = `${gradientBgBase} from-pink-400 via-red-500 to-yellow-500`
|
||||
export const gradientBgDark = `${gradientBgBase} from-slate-800 via-slate-950 to-slate-900`;
|
||||
export const gradientBgPinkRed = `${gradientBgBase} from-rose-400 via-emerald-500 to-emerald-600`
|
||||
|
||||
export const colorsBgLight = {
|
||||
white: 'bg-white text-black',
|
||||
light: ' bg-white text-black text-black dark:bg-dark-900 dark:text-white',
|
||||
contrast: 'bg-gray-800 text-white dark:bg-white dark:text-black',
|
||||
success: 'bg-emerald-500 border-emerald-500 dark:bg-pavitra-blue dark:border-pavitra-blue text-white',
|
||||
danger: 'bg-red-500 border-red-500 text-white',
|
||||
warning: 'bg-yellow-500 border-yellow-500 text-white',
|
||||
info: 'bg-blue-500 border-blue-500 dark:bg-pavitra-blue dark:border-pavitra-blue text-white',
|
||||
contrast: 'bg-slate-800 text-white dark:bg-white dark:text-black',
|
||||
success: 'bg-emerald-500 border-emerald-500 dark:bg-emerald-600 dark:border-emerald-600 text-white',
|
||||
danger: 'bg-rose-500 border-rose-500 text-white',
|
||||
warning: 'bg-amber-500 border-amber-500 text-white',
|
||||
info: 'bg-emerald-600 border-emerald-600 dark:bg-emerald-700 dark:border-emerald-700 text-white',
|
||||
}
|
||||
|
||||
export const colorsText = {
|
||||
white: 'text-black dark:text-slate-100',
|
||||
light: 'text-gray-700 dark:text-slate-400',
|
||||
light: 'text-slate-700 dark:text-slate-400',
|
||||
contrast: 'dark:text-white',
|
||||
success: 'text-emerald-500',
|
||||
danger: 'text-red-500',
|
||||
warning: 'text-yellow-500',
|
||||
info: 'text-blue-500',
|
||||
danger: 'text-rose-500',
|
||||
warning: 'text-amber-500',
|
||||
info: 'text-emerald-600',
|
||||
};
|
||||
|
||||
export const colorsOutline = {
|
||||
white: [colorsText.white, 'border-gray-100'].join(' '),
|
||||
light: [colorsText.light, 'border-gray-100'].join(' '),
|
||||
contrast: [colorsText.contrast, 'border-gray-900 dark:border-slate-100'].join(' '),
|
||||
white: [colorsText.white, 'border-slate-100'].join(' '),
|
||||
light: [colorsText.light, 'border-slate-100'].join(' '),
|
||||
contrast: [colorsText.contrast, 'border-slate-900 dark:border-slate-100'].join(' '),
|
||||
success: [colorsText.success, 'border-emerald-500'].join(' '),
|
||||
danger: [colorsText.danger, 'border-red-500'].join(' '),
|
||||
warning: [colorsText.warning, 'border-yellow-500'].join(' '),
|
||||
info: [colorsText.info, 'border-blue-500'].join(' '),
|
||||
danger: [colorsText.danger, 'border-rose-500'].join(' '),
|
||||
warning: [colorsText.warning, 'border-amber-500'].join(' '),
|
||||
info: [colorsText.info, 'border-emerald-600'].join(' '),
|
||||
};
|
||||
|
||||
export const getButtonColor = (
|
||||
@ -49,74 +49,74 @@ export const getButtonColor = (
|
||||
|
||||
const colors = {
|
||||
ring: {
|
||||
white: 'ring-gray-200 dark:ring-gray-500',
|
||||
whiteDark: 'ring-gray-200 dark:ring-dark-500',
|
||||
lightDark: 'ring-gray-200 dark:ring-gray-500',
|
||||
contrast: 'ring-gray-300 dark:ring-gray-400',
|
||||
success: 'ring-emerald-300 dark:ring-pavitra-blue',
|
||||
danger: 'ring-red-300 dark:ring-red-700',
|
||||
warning: 'ring-yellow-300 dark:ring-yellow-700',
|
||||
info: "ring-blue-300 dark:ring-pavitra-blue",
|
||||
white: 'ring-slate-200 dark:ring-slate-500',
|
||||
whiteDark: 'ring-slate-200 dark:ring-dark-500',
|
||||
lightDark: 'ring-slate-200 dark:ring-slate-500',
|
||||
contrast: 'ring-slate-300 dark:ring-slate-400',
|
||||
success: 'ring-emerald-300 dark:ring-emerald-700',
|
||||
danger: 'ring-rose-300 dark:ring-rose-700',
|
||||
warning: 'ring-amber-300 dark:ring-amber-700',
|
||||
info: "ring-emerald-300 dark:ring-emerald-700",
|
||||
},
|
||||
active: {
|
||||
white: 'bg-gray-100',
|
||||
whiteDark: 'bg-gray-100 dark:bg-dark-800',
|
||||
lightDark: 'bg-gray-200 dark:bg-slate-700',
|
||||
contrast: 'bg-gray-700 dark:bg-slate-100',
|
||||
success: 'bg-emerald-700 dark:bg-pavitra-blue',
|
||||
danger: 'bg-red-700 dark:bg-red-600',
|
||||
warning: 'bg-yellow-700 dark:bg-yellow-600',
|
||||
info: 'bg-blue-700 dark:bg-pavitra-blue',
|
||||
white: 'bg-slate-100',
|
||||
whiteDark: 'bg-slate-100 dark:bg-dark-800',
|
||||
lightDark: 'bg-slate-200 dark:bg-slate-700',
|
||||
contrast: 'bg-slate-700 dark:bg-slate-100',
|
||||
success: 'bg-emerald-700 dark:bg-emerald-800',
|
||||
danger: 'bg-rose-700 dark:bg-rose-600',
|
||||
warning: 'bg-amber-700 dark:bg-amber-600',
|
||||
info: 'bg-emerald-700 dark:bg-emerald-800',
|
||||
},
|
||||
bg: {
|
||||
white: 'bg-white text-black',
|
||||
whiteDark: 'bg-white text-black dark:bg-dark-900 dark:text-white',
|
||||
lightDark: 'bg-gray-100 text-black dark:bg-slate-800 dark:text-white',
|
||||
contrast: 'bg-gray-800 text-white dark:bg-white dark:text-black',
|
||||
success: 'bg-emerald-600 dark:bg-pavitra-blue text-white',
|
||||
danger: 'bg-red-600 text-white dark:bg-red-500 ',
|
||||
warning: 'bg-yellow-600 dark:bg-yellow-500 text-white',
|
||||
info: " bg-blue-600 dark:bg-pavitra-blue text-white ",
|
||||
lightDark: 'bg-slate-100 text-black dark:bg-slate-800 dark:text-white',
|
||||
contrast: 'bg-slate-800 text-white dark:bg-white dark:text-black',
|
||||
success: 'bg-emerald-600 dark:bg-emerald-600 text-white',
|
||||
danger: 'bg-rose-600 text-white dark:bg-rose-500 ',
|
||||
warning: 'bg-amber-600 dark:bg-amber-500 text-white',
|
||||
info: " bg-emerald-600 dark:bg-emerald-600 text-white ",
|
||||
},
|
||||
bgHover: {
|
||||
white: 'hover:bg-gray-100',
|
||||
whiteDark: 'hover:bg-gray-100 hover:dark:bg-dark-800',
|
||||
lightDark: 'hover:bg-gray-200 hover:dark:bg-slate-700',
|
||||
contrast: 'hover:bg-gray-700 hover:dark:bg-slate-100',
|
||||
white: 'hover:bg-slate-100',
|
||||
whiteDark: 'hover:bg-slate-100 hover:dark:bg-dark-800',
|
||||
lightDark: 'hover:bg-slate-200 hover:dark:bg-slate-700',
|
||||
contrast: 'hover:bg-slate-700 hover:dark:bg-slate-100',
|
||||
success:
|
||||
'hover:bg-emerald-700 hover:border-emerald-700 hover:dark:bg-pavitra-blue hover:dark:border-pavitra-blue',
|
||||
'hover:bg-emerald-700 hover:border-emerald-700 hover:dark:bg-emerald-800 hover:dark:border-emerald-800',
|
||||
danger:
|
||||
'hover:bg-red-700 hover:border-red-700 hover:dark:bg-red-600 hover:dark:border-red-600',
|
||||
'hover:bg-rose-700 hover:border-rose-700 hover:dark:bg-rose-600 hover:dark:border-rose-600',
|
||||
warning:
|
||||
'hover:bg-yellow-700 hover:border-yellow-700 hover:dark:bg-yellow-600 hover:dark:border-yellow-600',
|
||||
info: "hover:bg-blue-700 hover:border-blue-700 hover:dark:bg-pavitra-blue/80 hover:dark:border-pavitra-blue/80",
|
||||
'hover:bg-amber-700 hover:border-amber-700 hover:dark:bg-amber-600 hover:dark:border-amber-600',
|
||||
info: "hover:bg-emerald-700 hover:border-emerald-700 hover:dark:bg-emerald-800 hover:dark:border-emerald-800",
|
||||
},
|
||||
borders: {
|
||||
white: 'border-white',
|
||||
whiteDark: 'border-white dark:border-dark-900',
|
||||
lightDark: 'border-gray-100 dark:border-slate-800',
|
||||
contrast: 'border-gray-800 dark:border-white',
|
||||
success: 'border-emerald-600 dark:border-pavitra-blue',
|
||||
danger: 'border-red-600 dark:border-red-500',
|
||||
warning: 'border-yellow-600 dark:border-yellow-500',
|
||||
info: "border-blue-600 border-blue-600 dark:border-pavitra-blue",
|
||||
lightDark: 'border-slate-100 dark:border-slate-800',
|
||||
contrast: 'border-slate-800 dark:border-white',
|
||||
success: 'border-emerald-600 dark:border-emerald-600',
|
||||
danger: 'border-rose-600 dark:border-rose-500',
|
||||
warning: 'border-amber-600 dark:border-amber-500',
|
||||
info: "border-emerald-600 border-emerald-600 dark:border-emerald-600",
|
||||
},
|
||||
text: {
|
||||
contrast: 'dark:text-slate-100',
|
||||
success: 'text-emerald-600 dark:text-pavitra-blue',
|
||||
danger: 'text-red-600 dark:text-red-500',
|
||||
warning: 'text-yellow-600 dark:text-yellow-500',
|
||||
info: 'text-blue-600 dark:text-pavitra-blue',
|
||||
success: 'text-emerald-600 dark:text-emerald-500',
|
||||
danger: 'text-rose-600 dark:text-rose-500',
|
||||
warning: 'text-amber-600 dark:text-amber-500',
|
||||
info: 'text-emerald-600 dark:text-emerald-500',
|
||||
},
|
||||
outlineHover: {
|
||||
contrast:
|
||||
'hover:bg-gray-800 hover:text-gray-100 hover:dark:bg-slate-100 hover:dark:text-black',
|
||||
success: 'hover:bg-emerald-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-pavitra-blue',
|
||||
'hover:bg-slate-800 hover:text-slate-100 hover:dark:bg-slate-100 hover:dark:text-black',
|
||||
success: 'hover:bg-emerald-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-emerald-600',
|
||||
danger:
|
||||
'hover:bg-red-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-red-600',
|
||||
'hover:bg-rose-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-rose-600',
|
||||
warning:
|
||||
'hover:bg-yellow-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-yellow-600',
|
||||
info: "hover:bg-blue-600 hover:bg-blue-600 hover:text-white hover:dark:text-white hover:dark:border-pavitra-blue",
|
||||
'hover:bg-amber-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-amber-600',
|
||||
info: "hover:bg-emerald-600 hover:bg-emerald-600 hover:text-white hover:dark:text-white hover:dark:border-emerald-600",
|
||||
},
|
||||
}
|
||||
|
||||
@ -135,4 +135,4 @@ export const getButtonColor = (
|
||||
}
|
||||
|
||||
return base.join(' ')
|
||||
}
|
||||
}
|
||||
@ -39,7 +39,7 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
|
||||
>
|
||||
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">
|
||||
|
||||
<b className="font-black">Crafted Network</b>
|
||||
<b className="font-black">Fix It Local</b>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
@ -18,7 +18,11 @@ export default function AsideMenuList({ menu, isDropdownList = false, className
|
||||
return (
|
||||
<ul className={className}>
|
||||
{menu.map((item, index) => {
|
||||
|
||||
// Role check
|
||||
if (item.roles && !item.roles.includes(currentUser.app_role?.name)) {
|
||||
return null;
|
||||
}
|
||||
// Permission check
|
||||
if (!hasPermission(currentUser, item.permissions)) return null;
|
||||
|
||||
return (
|
||||
@ -32,4 +36,4 @@ export default function AsideMenuList({ menu, isDropdownList = false, className
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
}
|
||||
41
frontend/src/components/ProgressBar.tsx
Normal file
41
frontend/src/components/ProgressBar.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
value: number;
|
||||
max?: number;
|
||||
color?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const ProgressBar = ({ value, max = 100, color = 'emerald', label }: Props) => {
|
||||
const percentage = Math.max(0, Math.min(100, Math.round((value / max) * 100)));
|
||||
|
||||
const colors: Record<string, string> = {
|
||||
emerald: 'bg-emerald-500 shadow-sm shadow-emerald-500/20',
|
||||
green: 'bg-emerald-500 shadow-sm shadow-emerald-500/20',
|
||||
yellow: 'bg-amber-400 shadow-sm shadow-amber-400/20',
|
||||
red: 'bg-rose-500 shadow-sm shadow-rose-500/20',
|
||||
rose: 'bg-rose-500 shadow-sm shadow-rose-500/20',
|
||||
};
|
||||
|
||||
const bgColor = colors[color] || colors.emerald;
|
||||
|
||||
return (
|
||||
<div className="w-full animate-fade-in">
|
||||
{label && (
|
||||
<div className="flex justify-between mb-2 items-baseline">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-400">{label}</span>
|
||||
<span className="text-sm font-black text-slate-800 dark:text-white">{percentage}%</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full bg-slate-100 rounded-full h-3 dark:bg-slate-800 overflow-hidden shadow-inner">
|
||||
<div
|
||||
className={`${bgColor} h-3 rounded-full transition-all duration-1000 ease-out`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProgressBar;
|
||||
@ -8,7 +8,7 @@ export const localStorageStyleKey = 'style'
|
||||
|
||||
export const containerMaxW = 'xl:max-w-full xl:mx-auto 2xl:mx-20'
|
||||
|
||||
export const appTitle = 'created by Flatlogic generator!'
|
||||
export const appTitle = 'Fix It Local'
|
||||
|
||||
export const getPageTitle = (currentPageTitle: string) => `${currentPageTitle} — ${appTitle}`
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ export type MenuAsideItem = {
|
||||
withDevider?: boolean;
|
||||
menu?: MenuAsideItem[]
|
||||
permissions?: string | string[]
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
export type MenuNavBarItem = {
|
||||
@ -99,6 +100,10 @@ export interface User {
|
||||
updatedById?: any;
|
||||
avatar: any[];
|
||||
notes: any[];
|
||||
businessId?: string;
|
||||
app_role?: {
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type StyleKey = 'white' | 'basic'
|
||||
|
||||
@ -30,7 +30,7 @@ export default function LayoutGuest({ children }: Props) {
|
||||
<div className="w-10 h-10 bg-emerald-500 rounded-xl flex items-center justify-center shadow-lg shadow-emerald-500/20 group-hover:scale-110 transition-transform">
|
||||
<BaseIcon path={mdiShieldCheck} size={24} className="text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-black tracking-tight dark:text-white">Crafted Network<span className="text-emerald-500 italic">™</span></span>
|
||||
<span className="text-xl font-black tracking-tight dark:text-white">Fix It Local<span className="text-emerald-500 italic">™</span></span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-10">
|
||||
@ -97,7 +97,7 @@ export default function LayoutGuest({ children }: Props) {
|
||||
<div className="w-10 h-10 bg-emerald-500 rounded-xl flex items-center justify-center mr-3">
|
||||
<BaseIcon path={mdiShieldCheck} size={24} className="text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold tracking-tight dark:text-white text-slate-900">Crafted Network™</span>
|
||||
<span className="text-2xl font-bold tracking-tight dark:text-white text-slate-900">Fix It Local™</span>
|
||||
</div>
|
||||
<div className="flex gap-8 text-slate-500 font-medium dark:text-slate-400">
|
||||
<Link href="/search" className="hover:text-emerald-500">Find Help</Link>
|
||||
@ -107,7 +107,7 @@ export default function LayoutGuest({ children }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-12 pt-8 border-t border-slate-100 dark:border-slate-800 text-center text-slate-400 text-sm">
|
||||
© 2026 Crafted Network™. Built with Trust & Transparency.
|
||||
© 2026 Fix It Local™. Built with Trust & Transparency.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@ -2,210 +2,139 @@ import * as icon from '@mdi/js';
|
||||
import { MenuAsideItem } from './interfaces'
|
||||
|
||||
const menuAside: MenuAsideItem[] = [
|
||||
// Common
|
||||
{
|
||||
href: '/dashboard',
|
||||
icon: icon.mdiViewDashboardOutline,
|
||||
label: 'Dashboard',
|
||||
icon: icon.mdiStarFourPoints,
|
||||
label: 'Studio Hub',
|
||||
roles: ['Administrator', 'Platform Owner', 'Trust & Safety Lead']
|
||||
},
|
||||
|
||||
{
|
||||
href: '/dashboard',
|
||||
icon: icon.mdiStarFourPoints,
|
||||
label: 'Studio Hub',
|
||||
roles: ['Verified Business Owner']
|
||||
},
|
||||
|
||||
// Admin Only
|
||||
{
|
||||
href: '/users/users-list',
|
||||
label: 'Users',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiAccountGroup ?? icon.mdiTable,
|
||||
permissions: 'READ_USERS'
|
||||
},
|
||||
{
|
||||
href: '/roles/roles-list',
|
||||
label: 'Roles',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
|
||||
permissions: 'READ_ROLES'
|
||||
},
|
||||
{
|
||||
href: '/permissions/permissions-list',
|
||||
label: 'Permissions',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
|
||||
permissions: 'READ_PERMISSIONS'
|
||||
},
|
||||
{
|
||||
href: '/refresh_tokens/refresh_tokens-list',
|
||||
label: 'Refresh tokens',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiLock' in icon ? icon['mdiLock' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_REFRESH_TOKENS'
|
||||
label: 'Clients',
|
||||
icon: icon.mdiAccountHeart,
|
||||
permissions: 'READ_USERS',
|
||||
roles: ['Administrator', 'Platform Owner']
|
||||
},
|
||||
{
|
||||
href: '/categories/categories-list',
|
||||
label: 'Categories',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiShape' in icon ? icon['mdiShape' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_CATEGORIES'
|
||||
label: 'Beauty Categories',
|
||||
icon: icon.mdiLipstick,
|
||||
permissions: 'READ_CATEGORIES',
|
||||
roles: ['Administrator', 'Platform Owner']
|
||||
},
|
||||
{
|
||||
href: '/locations/locations-list',
|
||||
label: 'Locations',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiMapMarker' in icon ? icon['mdiMapMarker' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_LOCATIONS'
|
||||
label: 'Regions',
|
||||
icon: icon.mdiMapMarkerRadius,
|
||||
permissions: 'READ_LOCATIONS',
|
||||
roles: ['Administrator', 'Platform Owner']
|
||||
},
|
||||
|
||||
// Shared but labeled differently or scoped
|
||||
{
|
||||
href: '/businesses/businesses-list',
|
||||
label: 'Service Listings',
|
||||
icon: icon.mdiStorefront,
|
||||
permissions: 'READ_BUSINESSES',
|
||||
roles: ['Administrator', 'Platform Owner']
|
||||
},
|
||||
{
|
||||
href: '/businesses/businesses-list',
|
||||
label: 'Businesses',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiStore' in icon ? icon['mdiStore' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_BUSINESSES'
|
||||
},
|
||||
{
|
||||
href: '/business_photos/business_photos-list',
|
||||
label: 'Business photos',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiImageMultiple' in icon ? icon['mdiImageMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_BUSINESS_PHOTOS'
|
||||
},
|
||||
{
|
||||
href: '/business_categories/business_categories-list',
|
||||
label: 'Business categories',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiTagMultiple' in icon ? icon['mdiTagMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_BUSINESS_CATEGORIES'
|
||||
},
|
||||
{
|
||||
href: '/service_prices/service_prices-list',
|
||||
label: 'Service prices',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiCurrencyUsd' in icon ? icon['mdiCurrencyUsd' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_SERVICE_PRICES'
|
||||
},
|
||||
{
|
||||
href: '/business_badges/business_badges-list',
|
||||
label: 'Business badges',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiShieldCheck' in icon ? icon['mdiShieldCheck' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_BUSINESS_BADGES'
|
||||
},
|
||||
{
|
||||
href: '/verification_submissions/verification_submissions-list',
|
||||
label: 'Verification submissions',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiFileCheck' in icon ? icon['mdiFileCheck' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_VERIFICATION_SUBMISSIONS'
|
||||
},
|
||||
{
|
||||
href: '/verification_evidences/verification_evidences-list',
|
||||
label: 'Verification evidences',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiFileUpload' in icon ? icon['mdiFileUpload' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_VERIFICATION_EVIDENCES'
|
||||
label: 'My Studio',
|
||||
icon: icon.mdiStorefront,
|
||||
permissions: 'READ_BUSINESSES',
|
||||
roles: ['Verified Business Owner']
|
||||
},
|
||||
|
||||
{
|
||||
href: '/leads/leads-list',
|
||||
label: 'Leads',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiClipboardText' in icon ? icon['mdiClipboardText' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_LEADS'
|
||||
},
|
||||
{
|
||||
href: '/lead_photos/lead_photos-list',
|
||||
label: 'Lead photos',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiCamera' in icon ? icon['mdiCamera' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_LEAD_PHOTOS'
|
||||
},
|
||||
{
|
||||
href: '/lead_matches/lead_matches-list',
|
||||
label: 'Lead matches',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiLinkVariant' in icon ? icon['mdiLinkVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_LEAD_MATCHES'
|
||||
label: 'Client Bookings',
|
||||
icon: icon.mdiCalendarHeart,
|
||||
permissions: 'READ_LEADS',
|
||||
roles: ['Administrator', 'Platform Owner', 'Verified Business Owner']
|
||||
},
|
||||
|
||||
{
|
||||
href: '/messages/messages-list',
|
||||
label: 'Messages',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiMessageText' in icon ? icon['mdiMessageText' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
label: 'Consultations',
|
||||
icon: icon.mdiMessageProcessing,
|
||||
permissions: 'READ_MESSAGES'
|
||||
},
|
||||
|
||||
{
|
||||
href: '/lead_events/lead_events-list',
|
||||
label: 'Lead events',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiTimelineText' in icon ? icon['mdiTimelineText' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_LEAD_EVENTS'
|
||||
href: '/reviews/reviews-list',
|
||||
label: 'Love Letters',
|
||||
icon: icon.mdiStarFace,
|
||||
permissions: 'READ_REVIEWS',
|
||||
roles: ['Administrator', 'Platform Owner', 'Trust & Safety Lead']
|
||||
},
|
||||
{
|
||||
href: '/reviews/reviews-list',
|
||||
label: 'Reviews',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiStar' in icon ? icon['mdiStar' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_REVIEWS'
|
||||
label: 'Client Love',
|
||||
icon: icon.mdiStarFace,
|
||||
permissions: 'READ_REVIEWS',
|
||||
roles: ['Verified Business Owner']
|
||||
},
|
||||
|
||||
{
|
||||
href: '/verification_submissions/verification_submissions-list',
|
||||
label: 'Verification',
|
||||
icon: icon.mdiShieldCheck,
|
||||
permissions: 'READ_VERIFICATION_SUBMISSIONS',
|
||||
roles: ['Administrator', 'Platform Owner', 'Trust & Safety Lead']
|
||||
},
|
||||
{
|
||||
href: '/disputes/disputes-list',
|
||||
label: 'Disputes',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiAlertOctagon' in icon ? icon['mdiAlertOctagon' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_DISPUTES'
|
||||
href: '/verification_submissions/verification_submissions-list',
|
||||
label: 'Safety Badge',
|
||||
icon: icon.mdiShieldCheck,
|
||||
permissions: 'READ_VERIFICATION_SUBMISSIONS',
|
||||
roles: ['Verified Business Owner']
|
||||
},
|
||||
|
||||
// Placeholder for Billing and Team
|
||||
{
|
||||
href: '/billing',
|
||||
label: 'Earnings',
|
||||
icon: icon.mdiWallet,
|
||||
roles: ['Verified Business Owner']
|
||||
},
|
||||
{
|
||||
href: '/billing-settings',
|
||||
label: 'Global Billing',
|
||||
icon: icon.mdiFinance,
|
||||
roles: ['Administrator', 'Platform Owner']
|
||||
},
|
||||
{
|
||||
href: '/team',
|
||||
label: 'Studio Team',
|
||||
icon: icon.mdiAccountGroupOutline,
|
||||
roles: ['Verified Business Owner']
|
||||
},
|
||||
|
||||
// Moderator
|
||||
{
|
||||
href: '/audit_logs/audit_logs-list',
|
||||
label: 'Audit logs',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiClipboardList' in icon ? icon['mdiClipboardList' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_AUDIT_LOGS'
|
||||
},
|
||||
{
|
||||
href: '/badge_rules/badge_rules-list',
|
||||
label: 'Badge rules',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiShieldCrown' in icon ? icon['mdiShieldCrown' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_BADGE_RULES'
|
||||
},
|
||||
{
|
||||
href: '/trust_adjustments/trust_adjustments-list',
|
||||
label: 'Trust adjustments',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiTuneVariant' in icon ? icon['mdiTuneVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_TRUST_ADJUSTMENTS'
|
||||
},
|
||||
{
|
||||
href: '/profile',
|
||||
label: 'Profile',
|
||||
icon: icon.mdiAccountCircle,
|
||||
icon: icon.mdiClipboardListOutline,
|
||||
permissions: 'READ_AUDIT_LOGS',
|
||||
roles: ['Administrator', 'Platform Owner']
|
||||
},
|
||||
|
||||
|
||||
// Profile/Settings
|
||||
{
|
||||
href: '/api-docs',
|
||||
target: '_blank',
|
||||
label: 'Swagger API',
|
||||
icon: icon.mdiFileCode,
|
||||
permissions: 'READ_API_DOCS'
|
||||
href: '/profile',
|
||||
label: 'My Profile',
|
||||
icon: icon.mdiAccountSettings,
|
||||
},
|
||||
]
|
||||
|
||||
export default menuAside
|
||||
export default menuAside
|
||||
@ -149,7 +149,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
setStepsEnabled(false);
|
||||
};
|
||||
|
||||
const title = 'Crafted Network'
|
||||
const title = 'Fix It Local'
|
||||
const description = "Trust-first service directory with verification, transparent pricing, AI-style matching, and request tracking."
|
||||
const url = "https://flatlogic.com/"
|
||||
const image = "https://project-screens.s3.amazonaws.com/screenshots/38501/app-hero-20260217-010030.png"
|
||||
|
||||
171
frontend/src/pages/billing.tsx
Normal file
171
frontend/src/pages/billing.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
import * as icon from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import type { ReactElement } from 'react';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
import SectionMain from '../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import { getPageTitle } from '../config';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import moment from 'moment';
|
||||
|
||||
const PlanCard = ({ plan, currentPlanId, onUpgrade }: any) => {
|
||||
const isCurrent = plan.id === currentPlanId;
|
||||
|
||||
return (
|
||||
<CardBox className={`h-full flex flex-col ${isCurrent ? 'border-2 border-blue-500 ring-2 ring-blue-500 ring-opacity-10' : ''}`}>
|
||||
<div className="flex-grow">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-xl font-bold">{plan.name}</h3>
|
||||
{isCurrent && (
|
||||
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded-full font-bold">CURRENT PLAN</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<span className="text-4xl font-black">${plan.price}</span>
|
||||
<span className="text-gray-500">/mo</span>
|
||||
</div>
|
||||
<ul className="space-y-3 mb-8">
|
||||
{plan.features?.map((feature: string, i: number) => (
|
||||
<li key={i} className="flex items-center text-sm">
|
||||
<BaseIcon path={icon.mdiCheckCircle} size={18} className="text-emerald-500 mr-2" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<BaseButton
|
||||
label={isCurrent ? 'Current Plan' : `Upgrade to ${plan.name}`}
|
||||
color={isCurrent ? 'whiteDark' : 'info'}
|
||||
disabled={isCurrent}
|
||||
className="w-full mt-auto"
|
||||
onClick={() => onUpgrade(plan)}
|
||||
/>
|
||||
</CardBox>
|
||||
);
|
||||
};
|
||||
|
||||
const BillingPage = () => {
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [business, setBusiness] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [plansRes, metricsRes] = await Promise.all([
|
||||
axios.get('/dashboard/business-metrics'), // reusing for business data
|
||||
// We need a plans endpoint, but since I seeded them, I can mock or create one
|
||||
]);
|
||||
|
||||
// Mocking plans for now based on migration seeds if no endpoint exists
|
||||
setPlans([
|
||||
{
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
name: 'Basic (Free)',
|
||||
price: 0,
|
||||
features: ['Limited Leads', 'Standard Listing'],
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-0000-0000-000000000002',
|
||||
name: 'Professional',
|
||||
price: 49.99,
|
||||
features: ['Unlimited Leads', 'Priority Support', 'Enhanced Profile'],
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-0000-0000-000000000003',
|
||||
name: 'Enterprise',
|
||||
price: 199.99,
|
||||
features: ['Custom Branding', 'API Access', 'Dedicated Manager'],
|
||||
},
|
||||
] as any);
|
||||
|
||||
if (metricsRes.data.businesses?.length) {
|
||||
setBusiness(metricsRes.data.businesses[0]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch billing data', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleUpgrade = (plan: any) => {
|
||||
alert(`Upgrading to ${plan.name}. This would normally trigger a payment flow.`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<SectionMain>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<BaseIcon path={icon.mdiLoading} size={48} className="animate-spin text-gray-400" />
|
||||
</div>
|
||||
</SectionMain>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Billing & Plans')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={icon.mdiCreditCardOutline} title="Billing & Plans" main />
|
||||
|
||||
{business && (
|
||||
<div className="mb-8 p-6 bg-white dark:bg-slate-900 rounded-2xl shadow-sm border border-gray-100 dark:border-slate-800 flex flex-col md:flex-row justify-between items-center">
|
||||
<div>
|
||||
<div className="text-gray-500 text-sm mb-1 uppercase tracking-wider font-bold">Current Plan</div>
|
||||
<div className="text-2xl font-black">{business.plan?.name || 'Basic'}</div>
|
||||
<div className="text-gray-500 text-sm mt-2">
|
||||
Your plan renews on <span className="font-bold text-gray-900 dark:text-white">{moment(business.renewal_date).format('MMMM D, YYYY')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 md:mt-0 flex space-x-2">
|
||||
<BaseButton label="View Invoices" color="white" small />
|
||||
<BaseButton label="Payment Method" color="white" small />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{plans.map((plan: any) => (
|
||||
<PlanCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
currentPlanId={business?.planId}
|
||||
onUpgrade={handleUpgrade}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<CardBox className="mt-12 bg-gray-50 dark:bg-slate-800 border-dashed">
|
||||
<div className="flex items-center p-4">
|
||||
<div className="bg-blue-100 p-3 rounded-full mr-4">
|
||||
<BaseIcon path={icon.mdiInformation} size={24} className="text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold">Need a custom plan?</h4>
|
||||
<p className="text-sm text-gray-500">Contact our sales team for enterprise solutions tailored to your specific needs.</p>
|
||||
</div>
|
||||
<BaseButton label="Contact Sales" color="light" className="ml-auto" />
|
||||
</div>
|
||||
</CardBox>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
BillingPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default BillingPage;
|
||||
@ -1,7 +1,7 @@
|
||||
import { mdiChartTimelineVariant } from '@mdi/js'
|
||||
import Head from 'next/head'
|
||||
import { uniqueId } from 'lodash';
|
||||
import React, { ReactElement, useState } from 'react'
|
||||
import React, { ReactElement, useEffect, useState } from 'react'
|
||||
import CardBox from '../../components/CardBox'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
@ -14,7 +14,8 @@ import Link from "next/link";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
import {setRefetch, uploadCsv} from '../../stores/businesses/businessesSlice';
|
||||
import {fetch, setRefetch, uploadCsv} from '../../stores/businesses/businessesSlice';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
@ -29,10 +30,26 @@ const BusinessesTablesPage = () => {
|
||||
|
||||
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const { businesses, count, loading } = useAppSelector((state) => state.businesses);
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser?.app_role?.name === 'Verified Business Owner') {
|
||||
dispatch(fetch({ limit: 10, page: 0 }));
|
||||
}
|
||||
}, [currentUser, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser?.app_role?.name === 'Verified Business Owner' && !loading) {
|
||||
if (count === 0) {
|
||||
router.push('/businesses/businesses-new');
|
||||
}
|
||||
}
|
||||
}, [count, loading, currentUser, businesses, router]);
|
||||
|
||||
|
||||
const [filters] = useState([{label: 'Name', title: 'name'},{label: 'Slug', title: 'slug'},{label: 'Description', title: 'description'},{label: 'Phone', title: 'phone'},{label: 'Email', title: 'email'},{label: 'Website', title: 'website'},{label: 'Address', title: 'address'},{label: 'City', title: 'city'},{label: 'State', title: 'state'},{label: 'ZIP', title: 'zip'},{label: 'HoursJSON', title: 'hours_json'},{label: 'ReliabilityBreakdownJSON', title: 'reliability_breakdown_json'},{label: 'TenantKey', title: 'tenant_key'},
|
||||
{label: 'ReliabilityScore', title: 'reliability_score', number: 'true'},{label: 'ResponseTimeMedianMinutes', title: 'response_time_median_minutes', number: 'true'},
|
||||
@ -87,6 +104,16 @@ const BusinessesTablesPage = () => {
|
||||
setIsModalActive(false);
|
||||
};
|
||||
|
||||
if (currentUser?.app_role?.name === 'Verified Business Owner' && count === 0) {
|
||||
return (
|
||||
<SectionMain>
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<p>Redirecting to create your business profile...</p>
|
||||
</div>
|
||||
</SectionMain>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
@ -167,4 +194,4 @@ BusinessesTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
)
|
||||
}
|
||||
|
||||
export default BusinessesTablesPage
|
||||
export default BusinessesTablesPage
|
||||
@ -1,6 +1,6 @@
|
||||
import * as icon from '@mdi/js';
|
||||
import Head from 'next/head'
|
||||
import React from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import axios from 'axios';
|
||||
import type { ReactElement } from 'react'
|
||||
import LayoutAuthenticated from '../layouts/Authenticated'
|
||||
@ -8,425 +8,313 @@ import SectionMain from '../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
|
||||
import BaseIcon from "../components/BaseIcon";
|
||||
import BaseButton from "../components/BaseButton";
|
||||
import CardBox from "../components/CardBox";
|
||||
import CardBoxComponentBody from "../components/CardBoxComponentBody";
|
||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
||||
import CardBoxComponentFooter from "../components/CardBoxComponentFooter";
|
||||
import ProgressBar from "../components/ProgressBar";
|
||||
import { getPageTitle } from '../config'
|
||||
import Link from "next/link";
|
||||
|
||||
import { hasPermission } from "../helpers/userPermissions";
|
||||
import { fetchWidgets } from '../stores/roles/rolesSlice';
|
||||
import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
|
||||
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
|
||||
import moment from 'moment';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
|
||||
const ActionQueueItem = ({ label, count, iconPath, color, href }: any) => (
|
||||
<Link href={href}>
|
||||
<div className="flex items-center p-4 bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 rounded-[2rem] shadow-sm hover:shadow-xl hover:shadow-emerald-500/10 transition-all cursor-pointer group">
|
||||
<div className={`p-4 rounded-2xl mr-4 ${color} shadow-lg shadow-current/20 group-hover:scale-110 transition-transform`}>
|
||||
<BaseIcon path={iconPath} size={24} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-black text-slate-800 dark:text-white">{count}</div>
|
||||
<div className="text-xs font-bold text-slate-400 uppercase tracking-tighter">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
const PipelineStat = ({ label, count, color }: any) => (
|
||||
<div className="text-center p-4 border-r last:border-r-0 border-slate-100 dark:border-slate-800">
|
||||
<div className={`text-3xl font-black ${color}`}>{count}</div>
|
||||
<div className="text-[10px] text-slate-400 uppercase tracking-widest font-black mt-2">{label}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const BusinessDashboardView = ({ metrics, currentUser }: any) => {
|
||||
if (metrics.no_business) {
|
||||
return (
|
||||
<CardBox className="text-center p-12 border-dashed border-2 border-slate-200">
|
||||
<div className="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<BaseIcon path={icon.mdiStorePlus} size={40} className="text-slate-300" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-2 text-slate-800">No active business found</h2>
|
||||
<p className="text-slate-500 mb-8 max-w-sm mx-auto text-sm">Create your first listing to start receiving leads and managing your beauty business with AI-powered tools.</p>
|
||||
<BaseButton href="/businesses/businesses-new" label="List New Business" color="info" icon={icon.mdiPlus} className="px-8 py-3 rounded-2xl font-bold shadow-lg shadow-emerald-500/20" />
|
||||
</CardBox>
|
||||
);
|
||||
}
|
||||
|
||||
const { action_queue, pipeline, recentMessages, performance, healthScore, businesses } = metrics;
|
||||
const business = businesses[0];
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
{/* Action Queue */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<ActionQueueItem
|
||||
label="New Leads (24h)"
|
||||
count={action_queue.newLeads24h}
|
||||
iconPath={icon.mdiFlash}
|
||||
color="bg-amber-400"
|
||||
href="/leads/leads-list"
|
||||
/>
|
||||
<ActionQueueItem
|
||||
label="Needs Response"
|
||||
count={action_queue.leadsNeedingResponse}
|
||||
iconPath={icon.mdiMessageProcessing}
|
||||
color="bg-rose-400"
|
||||
href="/leads/leads-list"
|
||||
/>
|
||||
<ActionQueueItem
|
||||
label="Verifications"
|
||||
count={action_queue.verificationPending}
|
||||
iconPath={icon.mdiShieldCheckOutline}
|
||||
color="bg-emerald-400"
|
||||
href="/verification_submissions/verification_submissions-list"
|
||||
/>
|
||||
<ActionQueueItem
|
||||
label="Health Score"
|
||||
count={`${healthScore}%`}
|
||||
iconPath={icon.mdiHeartPulse}
|
||||
color="bg-rose-500"
|
||||
href={`/businesses/businesses-edit/?id=${business.id}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Lead Pipeline */}
|
||||
<CardBox className="lg:col-span-2 overflow-hidden border-none shadow-2xl shadow-slate-200/50">
|
||||
<CardBoxComponentTitle title="Beauty Pipeline Snapshot">
|
||||
<div className="text-[10px] font-black uppercase tracking-widest text-emerald-600 bg-emerald-50 px-3 py-1 rounded-full border border-emerald-100">
|
||||
{pipeline.winRate30d.toFixed(1)}% Conversion
|
||||
</div>
|
||||
</CardBoxComponentTitle>
|
||||
<div className="grid grid-cols-5 mt-6 bg-slate-50/50 rounded-3xl p-2 border border-slate-100">
|
||||
<PipelineStat label="New" count={pipeline.NEW || 0} color="text-slate-900 dark:text-white" />
|
||||
<PipelineStat label="Consulted" count={pipeline.CONTACTED || 0} color="text-rose-400" />
|
||||
<PipelineStat label="Booked" count={pipeline.SCHEDULED || 0} color="text-amber-400" />
|
||||
<PipelineStat label="Completed" count={pipeline.WON || 0} color="text-emerald-500" />
|
||||
<PipelineStat label="Archived" count={pipeline.LOST || 0} color="text-slate-400" />
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
{/* Listing Health */}
|
||||
<CardBox className="border-none shadow-2xl shadow-slate-200/50">
|
||||
<CardBoxComponentTitle title="Profile Vitality" />
|
||||
<div className="mt-6">
|
||||
<ProgressBar value={healthScore} label="Profile Strength" color={healthScore > 80 ? 'green' : healthScore > 50 ? 'yellow' : 'red'} />
|
||||
<div className="mt-8 space-y-3">
|
||||
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-4">Improve visibility:</div>
|
||||
{action_queue.missingFields.slice(0, 3).map((field: string) => (
|
||||
<div key={field} className="flex items-center text-xs font-semibold text-rose-500 bg-rose-50/50 p-2 rounded-xl border border-rose-100/50">
|
||||
<BaseIcon path={icon.mdiAlertCircleOutline} size={14} className="mr-2" />
|
||||
Add {field.replace('_json', '').replace('_', ' ')}
|
||||
</div>
|
||||
))}
|
||||
<Link href={`/businesses/businesses-edit/?id=${business.id}`} className="block text-xs text-emerald-600 font-bold hover:underline mt-4 text-center p-2 bg-emerald-50 rounded-xl transition-colors">
|
||||
Enhance Profile →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Recent Messages */}
|
||||
<CardBox className="lg:col-span-2 border-none shadow-2xl shadow-slate-200/50">
|
||||
<CardBoxComponentTitle title="Recent Client Love" />
|
||||
<div className="mt-6 space-y-4">
|
||||
{recentMessages.length > 0 ? recentMessages.map((msg: any) => (
|
||||
<div key={msg.id} className="flex items-start p-4 hover:bg-emerald-50/30 dark:hover:bg-slate-800 rounded-[1.5rem] transition-all border border-transparent hover:border-emerald-100 group">
|
||||
<div className="bg-emerald-100 dark:bg-slate-700 w-12 h-12 rounded-2xl flex items-center justify-center mr-4 flex-shrink-0 text-emerald-600 font-black shadow-inner">
|
||||
{msg.sender_user?.firstName?.[0] || 'U'}
|
||||
</div>
|
||||
<div className="flex-grow min-w-0">
|
||||
<div className="flex justify-between items-baseline mb-1">
|
||||
<span className="font-bold text-slate-800 dark:text-white truncate">{msg.sender_user?.firstName} {msg.sender_user?.lastName}</span>
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-tighter">{moment(msg.createdAt).fromNow()}</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 dark:text-gray-400 line-clamp-1 italic">"{msg.body}"</p>
|
||||
</div>
|
||||
<div className="ml-4 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<BaseButton small color="success" icon={icon.mdiReply} href={`/messages/messages-list?leadId=${msg.leadId}`} className="rounded-xl shadow-lg shadow-emerald-500/20" />
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="text-center py-12 text-slate-300 italic font-medium">No recent messages yet</div>
|
||||
)}
|
||||
</div>
|
||||
<CardBoxComponentFooter className="bg-slate-50/50">
|
||||
<BaseButton label="View All Messages" color="white" small href="/leads/leads-list" className="rounded-xl border-slate-200 shadow-sm" />
|
||||
</CardBoxComponentFooter>
|
||||
</CardBox>
|
||||
|
||||
{/* Performance & Billing */}
|
||||
<div className="space-y-8">
|
||||
<CardBox className="border-none shadow-2xl shadow-slate-200/50">
|
||||
<CardBoxComponentTitle title="Growth (30d)" />
|
||||
<div className="mt-6 grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-slate-50 dark:bg-slate-800 rounded-2xl border border-slate-100">
|
||||
<div className="text-[10px] text-slate-400 uppercase font-black tracking-widest mb-1">Views</div>
|
||||
<div className="text-2xl font-black text-slate-800 dark:text-white">{performance.views30d}</div>
|
||||
<div className="text-[10px] font-bold text-emerald-500 mt-1 flex items-center">
|
||||
<BaseIcon path={icon.mdiTrendingUp} size={12} className="mr-1" />
|
||||
7d: {performance.views7d}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-slate-50 dark:bg-slate-800 rounded-2xl border border-slate-100">
|
||||
<div className="text-[10px] text-slate-400 uppercase font-black tracking-widest mb-1">Interactions</div>
|
||||
<div className="text-2xl font-black text-slate-800 dark:text-white">{performance.calls30d + performance.website30d}</div>
|
||||
<div className="text-[10px] font-bold text-emerald-500 mt-1 flex items-center">
|
||||
<BaseIcon path={icon.mdiTrendingUp} size={12} className="mr-1" />
|
||||
7d: {performance.calls7d + performance.website7d}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 pt-6 border-t border-slate-100 dark:border-slate-800">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-widest">Conversion Rate</span>
|
||||
<span className="text-lg font-black text-emerald-500">{performance.conversionRate.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className="bg-gradient-to-br from-emerald-500 via-teal-600 to-emerald-700 text-white border-none shadow-2xl shadow-emerald-500/30 overflow-hidden relative group">
|
||||
<div className="absolute top-0 right-0 -mt-4 -mr-4 w-24 h-24 bg-white/10 rounded-full blur-2xl group-hover:scale-150 transition-transform duration-700"></div>
|
||||
<div className="relative z-10">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="text-emerald-100 text-[10px] font-black uppercase tracking-[0.2em]">Tier Plan</div>
|
||||
<div className="text-3xl font-black mt-2 tracking-tighter">{business.plan?.name || 'Professional'}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-white/20 backdrop-blur-md rounded-2xl shadow-xl">
|
||||
<BaseIcon path={icon.mdiCrownOutline} size={28} className="text-amber-300" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<div className="text-emerald-100 text-[10px] uppercase tracking-[0.2em] font-black">Renewal Date</div>
|
||||
<div className="text-lg font-bold mt-1">{moment(business.renewal_date).format('MMMM Do, YYYY')}</div>
|
||||
</div>
|
||||
<div className="mt-10">
|
||||
<BaseButton label="Manage Subscription" color="white" small className="w-full text-emerald-700 font-black py-4 rounded-2xl shadow-xl hover:scale-[1.02] transition-transform" />
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Dashboard = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
||||
const corners = useAppSelector((state) => state.style.corners);
|
||||
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
|
||||
|
||||
const loadingMessage = 'Loading...';
|
||||
|
||||
const [users, setUsers] = React.useState(loadingMessage);
|
||||
const [roles, setRoles] = React.useState(loadingMessage);
|
||||
const [permissions, setPermissions] = React.useState(loadingMessage);
|
||||
const [refresh_tokens, setRefresh_tokens] = React.useState(loadingMessage);
|
||||
const [categories, setCategories] = React.useState(loadingMessage);
|
||||
const [locations, setLocations] = React.useState(loadingMessage);
|
||||
const [businesses, setBusinesses] = React.useState(loadingMessage);
|
||||
const [business_photos, setBusiness_photos] = React.useState(loadingMessage);
|
||||
const [business_categories, setBusiness_categories] = React.useState(loadingMessage);
|
||||
const [service_prices, setService_prices] = React.useState(loadingMessage);
|
||||
const [business_badges, setBusiness_badges] = React.useState(loadingMessage);
|
||||
const [verification_submissions, setVerification_submissions] = React.useState(loadingMessage);
|
||||
const [verification_evidences, setVerification_evidences] = React.useState(loadingMessage);
|
||||
const [leads, setLeads] = React.useState(loadingMessage);
|
||||
const [lead_photos, setLead_photos] = React.useState(loadingMessage);
|
||||
const [lead_matches, setLead_matches] = React.useState(loadingMessage);
|
||||
const [messages, setMessages] = React.useState(loadingMessage);
|
||||
const [lead_events, setLead_events] = React.useState(loadingMessage);
|
||||
const [reviews, setReviews] = React.useState(loadingMessage);
|
||||
const [disputes, setDisputes] = React.useState(loadingMessage);
|
||||
const [audit_logs, setAudit_logs] = React.useState(loadingMessage);
|
||||
const [badge_rules, setBadge_rules] = React.useState(loadingMessage);
|
||||
const [trust_adjustments, setTrust_adjustments] = React.useState(loadingMessage);
|
||||
|
||||
const [widgetsRole, setWidgetsRole] = React.useState({
|
||||
role: { value: '', label: '' },
|
||||
});
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
|
||||
|
||||
const { rolesWidgets, loading } = useAppSelector((state) => state.roles);
|
||||
|
||||
const isBusinessOwner = currentUser?.app_role?.name === 'Verified Business Owner';
|
||||
const myBusiness = currentUser?.businesses_owner_user?.[0];
|
||||
|
||||
async function loadData() {
|
||||
const entities = ['users','roles','permissions','refresh_tokens','categories','locations','businesses','business_photos','business_categories','service_prices','business_badges','verification_submissions','verification_evidences','leads','lead_photos','lead_matches','messages','lead_events','reviews','disputes','audit_logs','badge_rules','trust_adjustments',];
|
||||
const fns = [setUsers,setRoles,setPermissions,setRefresh_tokens,setCategories,setLocations,setBusinesses,setBusiness_photos,setBusiness_categories,setService_prices,setBusiness_badges,setVerification_submissions,setVerification_evidences,setLeads,setLead_photos,setLead_matches,setMessages,setLead_events,setReviews,setDisputes,setAudit_logs,setBadge_rules,setTrust_adjustments,];
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [metrics, setMetrics] = useState<any>(null);
|
||||
|
||||
const requests = entities.map((entity, index) => {
|
||||
if(hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) {
|
||||
return axios.get(`/${entity.toLowerCase()}/count`);
|
||||
} else {
|
||||
fns[index](null);
|
||||
return Promise.resolve({data: {count: null}});
|
||||
}
|
||||
const [counts, setCounts] = useState<any>({});
|
||||
|
||||
async function loadAdminData() {
|
||||
const entities = ['users','roles','permissions','categories','locations','businesses'];
|
||||
const requests = entities.map(entity => axios.get(`/${entity}/count`));
|
||||
const results = await Promise.allSettled(requests);
|
||||
const newCounts: any = {};
|
||||
results.forEach((result, i) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
newCounts[entities[i]] = result.value.data.count;
|
||||
}
|
||||
});
|
||||
|
||||
Promise.allSettled(requests).then((results) => {
|
||||
results.forEach((result, i) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
fns[i](result.value.data.count);
|
||||
} else {
|
||||
fns[i](result.reason.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getWidgets(roleId) {
|
||||
await dispatch(fetchWidgets(roleId));
|
||||
setCounts(newCounts);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
async function loadBusinessMetrics() {
|
||||
try {
|
||||
const response = await axios.get('/dashboard/business-metrics');
|
||||
setMetrics(response.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load metrics', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser) return;
|
||||
loadData().then();
|
||||
setWidgetsRole({ role: { value: currentUser?.app_role?.id, label: currentUser?.app_role?.name } });
|
||||
}, [currentUser]);
|
||||
if (isBusinessOwner) {
|
||||
loadBusinessMetrics();
|
||||
} else {
|
||||
loadAdminData().then(() => setLoading(false));
|
||||
}
|
||||
}, [currentUser, isBusinessOwner]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<SectionMain>
|
||||
<div className="flex flex-col items-center justify-center h-96 animate-pulse">
|
||||
<div className="w-16 h-16 bg-emerald-100 rounded-3xl flex items-center justify-center mb-4">
|
||||
<BaseIcon path={icon.mdiLoading} size={32} className="animate-spin text-emerald-500" />
|
||||
</div>
|
||||
<div className="text-xs font-black text-slate-400 uppercase tracking-widest">Curating your experience...</div>
|
||||
</div>
|
||||
</SectionMain>
|
||||
);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentUser || !widgetsRole?.role?.value) return;
|
||||
getWidgets(widgetsRole?.role?.value || '').then();
|
||||
}, [widgetsRole?.role?.value]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>
|
||||
{getPageTitle('Overview')}
|
||||
</title>
|
||||
<title>{getPageTitle('Beauty Studio Dashboard')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton
|
||||
icon={icon.mdiChartTimelineVariant}
|
||||
title={isBusinessOwner ? 'Business Dashboard' : 'Overview'}
|
||||
main>
|
||||
{isBusinessOwner && hasPermission(currentUser, 'CREATE_BUSINESSES') && (
|
||||
<BaseButton
|
||||
href="/businesses/businesses-new"
|
||||
icon={icon.mdiPlus}
|
||||
label="List New Business"
|
||||
color="info"
|
||||
/>
|
||||
icon={icon.mdiStarFourPoints}
|
||||
title={isBusinessOwner ? 'Beauty Studio Hub' : 'Network System Pulse'}
|
||||
main
|
||||
className="mb-8"
|
||||
>
|
||||
{isBusinessOwner && (
|
||||
<div className="flex space-x-3">
|
||||
<BaseButton label="Client Leads" color="info" icon={icon.mdiCalendarHeart} href="/leads/leads-list" small className="rounded-xl px-4 font-bold" />
|
||||
<BaseButton label="Docs" color="white" icon={icon.mdiFileUploadOutline} href="/verification_submissions/verification_submissions-list" small className="rounded-xl border-slate-200 font-bold" />
|
||||
</div>
|
||||
)}
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator
|
||||
currentUser={currentUser}
|
||||
isFetchingQuery={isFetchingQuery}
|
||||
setWidgetsRole={setWidgetsRole}
|
||||
widgetsRole={widgetsRole}
|
||||
/>}
|
||||
{!!rolesWidgets.length &&
|
||||
hasPermission(currentUser, 'CREATE_ROLES') && (
|
||||
<p className=' text-gray-500 dark:text-gray-400 mb-4'>
|
||||
{`${widgetsRole?.role?.label || 'Users'}'s widgets`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6 grid-flow-dense'>
|
||||
{(isFetchingQuery || loading) && (
|
||||
<div className={` ${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 text-lg leading-tight text-gray-500 flex items-center ${cardsStyle} dark:border-dark-700 p-6`}>
|
||||
<BaseIcon
|
||||
className={`${iconsColor} animate-spin mr-5`}
|
||||
w='w-16'
|
||||
h='h-16'
|
||||
size={48}
|
||||
path={icon.mdiLoading}
|
||||
/>{' '}
|
||||
Loading widgets...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ rolesWidgets &&
|
||||
rolesWidgets.map((widget) => (
|
||||
<SmartWidget
|
||||
key={widget.id}
|
||||
userId={currentUser?.id}
|
||||
widget={widget}
|
||||
roleId={widgetsRole?.role?.value || ''}
|
||||
admin={hasPermission(currentUser, 'CREATE_ROLES')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!!rolesWidgets.length && <hr className='my-6 ' />}
|
||||
|
||||
<div id="dashboard" className='grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6'>
|
||||
{isBusinessOwner && myBusiness && (
|
||||
<Link href={`/businesses/businesses-edit/?id=${myBusiness.id}`}>
|
||||
<div
|
||||
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||
>
|
||||
<div className="flex justify-between align-center">
|
||||
<div>
|
||||
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||
My Listing
|
||||
</div>
|
||||
<div className="text-xl leading-tight font-semibold mt-2">
|
||||
{myBusiness.name}
|
||||
</div>
|
||||
<div className="text-sm text-blue-500 mt-2">Edit Listing</div>
|
||||
</div>
|
||||
<div>
|
||||
<BaseIcon
|
||||
className={`${iconsColor}`}
|
||||
w="w-16"
|
||||
h="h-16"
|
||||
size={48}
|
||||
path={icon.mdiStoreEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isBusinessOwner ? (
|
||||
<BusinessDashboardView metrics={metrics} currentUser={currentUser} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3 mb-10 animate-fade-in">
|
||||
{Object.keys(counts).map(entity => (
|
||||
<Link key={entity} href={`/${entity}/${entity}-list`}>
|
||||
<CardBox className="hover:shadow-2xl hover:shadow-emerald-500/10 transition-all border-none group cursor-pointer p-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{entity.replace('_', ' ')}</div>
|
||||
<div className="text-4xl font-black text-slate-800 dark:text-white group-hover:text-emerald-500 transition-colors">{counts[entity]}</div>
|
||||
</div>
|
||||
<div className="w-16 h-16 bg-slate-50 dark:bg-slate-800 rounded-2xl flex items-center justify-center group-hover:bg-emerald-50 transition-colors">
|
||||
<BaseIcon path={icon.mdiLayersOutline} size={32} className={iconsColor} />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{hasPermission(currentUser, 'READ_LEADS') && <Link href={'/leads/leads-list'}>
|
||||
<div
|
||||
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||
>
|
||||
<div className="flex justify-between align-center">
|
||||
<div>
|
||||
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||
{isBusinessOwner ? 'Service Requests' : 'Leads'}
|
||||
</div>
|
||||
<div className="text-3xl leading-tight font-semibold">
|
||||
{leads}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<BaseIcon
|
||||
className={`${iconsColor}`}
|
||||
w="w-16"
|
||||
h="h-16"
|
||||
size={48}
|
||||
path={'mdiClipboardText' in icon ? icon['mdiClipboardText' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>}
|
||||
|
||||
{hasPermission(currentUser, 'READ_REVIEWS') && <Link href={'/reviews/reviews-list'}>
|
||||
<div
|
||||
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||
>
|
||||
<div className="flex justify-between align-center">
|
||||
<div>
|
||||
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||
Reviews
|
||||
</div>
|
||||
<div className="text-3xl leading-tight font-semibold">
|
||||
{reviews}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<BaseIcon
|
||||
className={`${iconsColor}`}
|
||||
w="w-16"
|
||||
h="h-16"
|
||||
size={48}
|
||||
path={'mdiStar' in icon ? icon['mdiStar' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>}
|
||||
|
||||
{!isBusinessOwner && hasPermission(currentUser, 'READ_USERS') && <Link href={'/users/users-list'}>
|
||||
<div
|
||||
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||
>
|
||||
<div className="flex justify-between align-center">
|
||||
<div>
|
||||
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||
Users
|
||||
</div>
|
||||
<div className="text-3xl leading-tight font-semibold">
|
||||
{users}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<BaseIcon
|
||||
className={`${iconsColor}`}
|
||||
w="w-16"
|
||||
h="h-16"
|
||||
size={48}
|
||||
path={icon.mdiAccountGroup || icon.mdiTable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>}
|
||||
|
||||
{!isBusinessOwner && hasPermission(currentUser, 'READ_ROLES') && <Link href={'/roles/roles-list'}>
|
||||
<div
|
||||
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||
>
|
||||
<div className="flex justify-between align-center">
|
||||
<div>
|
||||
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||
Roles
|
||||
</div>
|
||||
<div className="text-3xl leading-tight font-semibold">
|
||||
{roles}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<BaseIcon
|
||||
className={`${iconsColor}`}
|
||||
w="w-16"
|
||||
h="h-16"
|
||||
size={48}
|
||||
path={icon.mdiShieldAccountVariantOutline || icon.mdiTable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>}
|
||||
|
||||
{!isBusinessOwner && hasPermission(currentUser, 'READ_PERMISSIONS') && <Link href={'/permissions/permissions-list'}>
|
||||
<div
|
||||
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||
>
|
||||
<div className="flex justify-between align-center">
|
||||
<div>
|
||||
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||
Permissions
|
||||
</div>
|
||||
<div className="text-3xl leading-tight font-semibold">
|
||||
{permissions}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<BaseIcon
|
||||
className={`${iconsColor}`}
|
||||
w="w-16"
|
||||
h="h-16"
|
||||
size={48}
|
||||
path={icon.mdiShieldAccountOutline || icon.mdiTable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>}
|
||||
|
||||
{!isBusinessOwner && hasPermission(currentUser, 'READ_REFRESH_TOKENS') && <Link href={'/refresh_tokens/refresh_tokens-list'}>
|
||||
<div
|
||||
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||
>
|
||||
<div className="flex justify-between align-center">
|
||||
<div>
|
||||
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||
Refresh tokens
|
||||
</div>
|
||||
<div className="text-3xl leading-tight font-semibold">
|
||||
{refresh_tokens}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<BaseIcon
|
||||
className={`${iconsColor}`}
|
||||
w="w-16"
|
||||
h="h-16"
|
||||
size={48}
|
||||
path={'mdiLock' in icon ? icon['mdiLock' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>}
|
||||
|
||||
{!isBusinessOwner && hasPermission(currentUser, 'READ_CATEGORIES') && <Link href={'/categories/categories-list'}>
|
||||
<div
|
||||
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||
>
|
||||
<div className="flex justify-between align-center">
|
||||
<div>
|
||||
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||
Categories
|
||||
</div>
|
||||
<div className="text-3xl leading-tight font-semibold">
|
||||
{categories}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<BaseIcon
|
||||
className={`${iconsColor}`}
|
||||
w="w-16"
|
||||
h="h-16"
|
||||
size={48}
|
||||
path={'mdiShape' in icon ? icon['mdiShape' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>}
|
||||
|
||||
{!isBusinessOwner && hasPermission(currentUser, 'READ_LOCATIONS') && <Link href={'/locations/locations-list'}>
|
||||
<div
|
||||
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||
>
|
||||
<div className="flex justify-between align-center">
|
||||
<div>
|
||||
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||
Locations
|
||||
</div>
|
||||
<div className="text-3xl leading-tight font-semibold">
|
||||
{locations}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<BaseIcon
|
||||
className={`${iconsColor}`}
|
||||
w="w-16"
|
||||
h="h-16"
|
||||
size={48}
|
||||
path={'mdiMapMarker' in icon ? icon['mdiMapMarker' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>}
|
||||
|
||||
{hasPermission(currentUser, 'READ_BUSINESSES') && <Link href={'/businesses/businesses-list'}>
|
||||
<div
|
||||
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||
>
|
||||
<div className="flex justify-between align-center">
|
||||
<div>
|
||||
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
|
||||
Businesses
|
||||
</div>
|
||||
<div className="text-3xl leading-tight font-semibold">
|
||||
{businesses}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<BaseIcon
|
||||
className={`${iconsColor}`}
|
||||
w="w-16"
|
||||
h="h-16"
|
||||
size={48}
|
||||
path={'mdiStore' in icon ? icon['mdiStore' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>}
|
||||
</div>
|
||||
</CardBox>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SectionMain>
|
||||
</>
|
||||
)
|
||||
@ -436,4 +324,4 @@ Dashboard.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
export default Dashboard;
|
||||
@ -96,7 +96,7 @@ export default function Forgot() {
|
||||
<BaseIcon path={mdiShieldCheck} size={28} className="text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-black tracking-tight text-slate-900">
|
||||
Crafted Network<span className="text-emerald-500 italic">™</span>
|
||||
Fix It Local<span className="text-emerald-500 italic">™</span>
|
||||
</span>
|
||||
</Link>
|
||||
<h2 className="text-3xl font-bold text-slate-900 text-center">Forgot Password?</h2>
|
||||
@ -138,7 +138,7 @@ export default function Forgot() {
|
||||
</CardBox>
|
||||
|
||||
<div className="text-center text-slate-400 text-xs pt-8">
|
||||
© 2026 Crafted Network™. All rights reserved. <br/>
|
||||
© 2026 Fix It Local™. All rights reserved. <br/>
|
||||
<Link href='/privacy-policy/' className="hover:text-slate-600 mt-2 inline-block">Privacy Policy</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -53,7 +53,7 @@ export default function LandingPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 font-sans text-slate-900">
|
||||
<Head>
|
||||
<title>Crafted Network™ | 21st Century Service Directory</title>
|
||||
<title>Fix It Local™ | 21st Century Service Directory</title>
|
||||
<meta name="description" content="Connect with verified service professionals. Trust, transparency, and AI-powered matching." />
|
||||
</Head>
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { ReactElement, useEffect } from 'react';
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import Head from 'next/head'
|
||||
import DatePicker from "react-datepicker";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
@ -14,8 +14,10 @@ import SectionMain from "../../components/SectionMain";
|
||||
import CardBox from "../../components/CardBox";
|
||||
import BaseButton from "../../components/BaseButton";
|
||||
import BaseDivider from "../../components/BaseDivider";
|
||||
import {mdiChartTimelineVariant, mdiMessageReply} from "@mdi/js";
|
||||
import {mdiChartTimelineVariant, mdiMessageReply, mdiAccountMultiple, mdiCamera, mdiTimelineText, mdiInformation} from "@mdi/js";
|
||||
import FormField from "../../components/FormField";
|
||||
import BaseIcon from "../../components/BaseIcon";
|
||||
import ImageField from "../../components/ImageField";
|
||||
|
||||
|
||||
const LeadsView = () => {
|
||||
@ -27,12 +29,20 @@ const LeadsView = () => {
|
||||
|
||||
const { id } = router.query;
|
||||
|
||||
const [activeTab, setActiveTab] = useState('details');
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
dispatch(fetch({ id }));
|
||||
}
|
||||
}, [dispatch, id]);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'details', label: 'Details', icon: mdiInformation },
|
||||
{ id: 'photos', label: 'Photos', icon: mdiCamera },
|
||||
{ id: 'matches', label: 'Matches', icon: mdiAccountMultiple },
|
||||
{ id: 'events', label: 'Events', icon: mdiTimelineText },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -57,110 +67,211 @@ const LeadsView = () => {
|
||||
/>
|
||||
</div>
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox>
|
||||
<div className={'grid grid-cols-1 md:grid-cols-2 gap-6'}>
|
||||
<div className={'mb-4'}>
|
||||
<p className={'block font-bold mb-2'}>User</p>
|
||||
<p>{leads?.user?.firstName} {leads?.user?.lastName}</p>
|
||||
</div>
|
||||
|
||||
<div className={'mb-4'}>
|
||||
<p className={'block font-bold mb-2'}>Category</p>
|
||||
<p>{leads?.category?.name ?? 'No data'}</p>
|
||||
</div>
|
||||
|
||||
<div className={'mb-4'}>
|
||||
<p className={'block font-bold mb-2'}>Keyword</p>
|
||||
<p>{leads?.keyword}</p>
|
||||
</div>
|
||||
|
||||
<div className={'mb-4'}>
|
||||
<p className={'block font-bold mb-2'}>Urgency</p>
|
||||
<p>{leads?.urgency ?? 'No data'}</p>
|
||||
</div>
|
||||
|
||||
<div className={'mb-4'}>
|
||||
<p className={'block font-bold mb-2'}>Status</p>
|
||||
<p>{leads?.status ?? 'No data'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormField label='Description' hasTextareaHeight>
|
||||
<textarea className={'w-full'} disabled value={leads?.description} />
|
||||
</FormField>
|
||||
|
||||
<div className={'grid grid-cols-1 md:grid-cols-2 gap-6'}>
|
||||
<div className={'mb-4'}>
|
||||
<p className={'block font-bold mb-2'}>Contact Name</p>
|
||||
<p>{leads?.contact_name}</p>
|
||||
</div>
|
||||
|
||||
<div className={'mb-4'}>
|
||||
<p className={'block font-bold mb-2'}>Contact Phone</p>
|
||||
<p>{leads?.contact_phone}</p>
|
||||
</div>
|
||||
|
||||
<div className={'mb-4'}>
|
||||
<p className={'block font-bold mb-2'}>Contact Email</p>
|
||||
<p>{leads?.contact_email}</p>
|
||||
</div>
|
||||
|
||||
<div className={'mb-4'}>
|
||||
<p className={'block font-bold mb-2'}>City</p>
|
||||
<p>{leads?.city}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormField label='Address' hasTextareaHeight>
|
||||
<textarea className={'w-full'} disabled value={leads?.address} />
|
||||
</FormField>
|
||||
|
||||
{leads.messages_lead && leads.messages_lead.length > 0 && (
|
||||
<>
|
||||
<p className={'block font-bold mb-2 mt-8'}>Message History</p>
|
||||
<CardBox
|
||||
className='mb-6 border border-gray-300 rounded overflow-hidden shadow-sm'
|
||||
hasTable
|
||||
<div className="mb-6 flex space-x-4 border-b border-gray-200">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center space-x-2 pb-2 px-1 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-b-2 border-blue-600 text-blue-600'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className='overflow-x-auto'>
|
||||
<table className="w-full text-left">
|
||||
<BaseIcon path={tab.icon} size={18} />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'details' && (
|
||||
<CardBox>
|
||||
<div className={'grid grid-cols-1 md:grid-cols-2 gap-6'}>
|
||||
<div className={'mb-4'}>
|
||||
<p className={'block font-bold mb-2'}>User</p>
|
||||
<p>{leads?.user?.firstName} {leads?.user?.lastName}</p>
|
||||
</div>
|
||||
|
||||
<div className={'mb-4'}>
|
||||
<p className={'block font-bold mb-2'}>Category</p>
|
||||
<p>{leads?.category?.name ?? 'No data'}</p>
|
||||
</div>
|
||||
|
||||
<div className={'mb-4'}>
|
||||
<p className={'block font-bold mb-2'}>Keyword</p>
|
||||
<p>{leads?.keyword}</p>
|
||||
</div>
|
||||
|
||||
<div className={'mb-4'}>
|
||||
<p className={'block font-bold mb-2'}>Urgency</p>
|
||||
<p>{leads?.urgency ?? 'No data'}</p>
|
||||
</div>
|
||||
|
||||
<div className={'mb-4'}>
|
||||
<p className={'block font-bold mb-2'}>Status</p>
|
||||
<p>{leads?.status ?? 'No data'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormField label='Description' hasTextareaHeight>
|
||||
<textarea className={'w-full'} disabled value={leads?.description} />
|
||||
</FormField>
|
||||
|
||||
<div className={'grid grid-cols-1 md:grid-cols-2 gap-6'}>
|
||||
<div className={'mb-4'}>
|
||||
<p className={'block font-bold mb-2'}>Contact Name</p>
|
||||
<p>{leads?.contact_name}</p>
|
||||
</div>
|
||||
|
||||
<div className={'mb-4'}>
|
||||
<p className={'block font-bold mb-2'}>Contact Phone</p>
|
||||
<p>{leads?.contact_phone}</p>
|
||||
</div>
|
||||
|
||||
<div className={'mb-4'}>
|
||||
<p className={'block font-bold mb-2'}>Contact Email</p>
|
||||
<p>{leads?.contact_email}</p>
|
||||
</div>
|
||||
|
||||
<div className={'mb-4'}>
|
||||
<p className={'block font-bold mb-2'}>City</p>
|
||||
<p>{leads?.city}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormField label='Address' hasTextareaHeight>
|
||||
<textarea className={'w-full'} disabled value={leads?.address} />
|
||||
</FormField>
|
||||
|
||||
{leads.messages_lead && leads.messages_lead.length > 0 && (
|
||||
<>
|
||||
<p className={'block font-bold mb-2 mt-8'}>Message History</p>
|
||||
<CardBox
|
||||
className='mb-6 border border-gray-300 rounded overflow-hidden shadow-sm'
|
||||
hasTable
|
||||
>
|
||||
<div className='overflow-x-auto'>
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200 text-xs uppercase text-gray-500 font-semibold">
|
||||
<th className="px-4 py-3">Body</th>
|
||||
<th className="px-4 py-3">Read At</th>
|
||||
<th className="px-4 py-3">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{leads.messages_lead.map((item: any) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50 cursor-pointer transition-colors" onClick={() => router.push(`/messages/messages-view/?id=${item.id}`)}>
|
||||
<td className="px-4 py-3" data-label="body">
|
||||
{ item.body }
|
||||
</td>
|
||||
<td className="px-4 py-3" data-label="read_at">
|
||||
{ dataFormatter.dateTimeFormatter(item.read_at) }
|
||||
</td>
|
||||
<td className="px-4 py-3" data-label="created_at_ts">
|
||||
{ dataFormatter.dateTimeFormatter(item.created_at_ts) }
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardBox>
|
||||
</>
|
||||
)}
|
||||
|
||||
<BaseDivider />
|
||||
|
||||
<BaseButton
|
||||
color='info'
|
||||
label='Back'
|
||||
outline
|
||||
onClick={() => router.push('/leads/leads-list')}
|
||||
/>
|
||||
</CardBox>
|
||||
)}
|
||||
|
||||
{activeTab === 'photos' && (
|
||||
<CardBox>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{leads.lead_photos_lead?.length > 0 ? (
|
||||
leads.lead_photos_lead.map((photo: any) => (
|
||||
<div key={photo.id} className="border border-gray-200 rounded p-2">
|
||||
<ImageField value={photo.photos} />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500 italic">No photos available.</p>
|
||||
)}
|
||||
</div>
|
||||
</CardBox>
|
||||
)}
|
||||
|
||||
{activeTab === 'matches' && (
|
||||
<CardBox hasTable>
|
||||
<div className='overflow-x-auto'>
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200 text-xs uppercase text-gray-500 font-semibold">
|
||||
<th className="px-4 py-3">Body</th>
|
||||
<th className="px-4 py-3">Read At</th>
|
||||
<th className="px-4 py-3">Date</th>
|
||||
</tr>
|
||||
<tr className="bg-gray-50 border-b border-gray-200 text-xs uppercase text-gray-500 font-semibold">
|
||||
<th className="px-4 py-3">Business</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3">Matched At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{leads.messages_lead.map((item: any) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50 cursor-pointer transition-colors" onClick={() => router.push(`/messages/messages-view/?id=${item.id}`)}>
|
||||
<td className="px-4 py-3" data-label="body">
|
||||
{ item.body }
|
||||
</td>
|
||||
<td className="px-4 py-3" data-label="read_at">
|
||||
{ dataFormatter.dateTimeFormatter(item.read_at) }
|
||||
</td>
|
||||
<td className="px-4 py-3" data-label="created_at_ts">
|
||||
{ dataFormatter.dateTimeFormatter(item.created_at_ts) }
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{leads.lead_matches_lead?.length > 0 ? (
|
||||
leads.lead_matches_lead.map((match: any) => (
|
||||
<tr key={match.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-4 py-3">{match.business?.name}</td>
|
||||
<td className="px-4 py-3">{match.status}</td>
|
||||
<td className="px-4 py-3">{dataFormatter.dateTimeFormatter(match.created_at_ts)}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={3} className="px-4 py-8 text-center text-gray-500 italic">No matches found.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardBox>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardBox>
|
||||
)}
|
||||
|
||||
<BaseDivider />
|
||||
{activeTab === 'events' && (
|
||||
<CardBox hasTable>
|
||||
<div className='overflow-x-auto'>
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200 text-xs uppercase text-gray-500 font-semibold">
|
||||
<th className="px-4 py-3">Event Type</th>
|
||||
<th className="px-4 py-3">Actor</th>
|
||||
<th className="px-4 py-3">Notes</th>
|
||||
<th className="px-4 py-3">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{leads.lead_events_lead?.length > 0 ? (
|
||||
leads.lead_events_lead.map((event: any) => (
|
||||
<tr key={event.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-4 py-3">{event.event_type}</td>
|
||||
<td className="px-4 py-3">{event.actor_user?.firstName}</td>
|
||||
<td className="px-4 py-3">{event.notes}</td>
|
||||
<td className="px-4 py-3">{dataFormatter.dateTimeFormatter(event.created_at_ts)}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-gray-500 italic">No events found.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardBox>
|
||||
)}
|
||||
|
||||
<BaseButton
|
||||
color='info'
|
||||
label='Back'
|
||||
outline
|
||||
onClick={() => router.push('/leads/leads-list')}
|
||||
/>
|
||||
</CardBox>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
@ -176,4 +287,4 @@ LeadsView.getLayout = function getLayout(page: ReactElement) {
|
||||
)
|
||||
}
|
||||
|
||||
export default LeadsView;
|
||||
export default LeadsView;
|
||||
|
||||
@ -43,7 +43,7 @@ export default function Login() {
|
||||
password: 'b2096650',
|
||||
remember: true })
|
||||
|
||||
const title = 'Crafted Network'
|
||||
const title = 'Fix It Local'
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect( () => {
|
||||
@ -175,7 +175,7 @@ export default function Login() {
|
||||
<BaseIcon path={mdiShieldCheck} size={28} className="text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-black tracking-tight text-slate-900">
|
||||
Crafted Network<span className="text-emerald-500 italic">™</span>
|
||||
Fix It Local<span className="text-emerald-500 italic">™</span>
|
||||
</span>
|
||||
</Link>
|
||||
<h2 className="text-3xl font-bold text-slate-900">Account Login</h2>
|
||||
@ -270,7 +270,7 @@ export default function Login() {
|
||||
</div>
|
||||
|
||||
<div className="text-center text-slate-400 text-xs pt-8">
|
||||
© 2026 Crafted Network™. All rights reserved. <br/>
|
||||
© 2026 Fix It Local™. All rights reserved. <br/>
|
||||
<Link href='/privacy-policy/' className="hover:text-slate-600 mt-2 inline-block">Privacy Policy</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -5,7 +5,7 @@ import LayoutGuest from '../layouts/Guest';
|
||||
import { getPageTitle } from '../config';
|
||||
|
||||
export default function PrivacyPolicy() {
|
||||
const title = 'Crafted Network'
|
||||
const title = 'Fix It Local'
|
||||
const [projectUrl, setProjectUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -31,6 +31,7 @@ const BusinessDetailsPublic = () => {
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchBusiness();
|
||||
recordEvent('VIEW');
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
@ -46,6 +47,20 @@ const BusinessDetailsPublic = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const recordEvent = async (type: string) => {
|
||||
if (!id) return;
|
||||
try {
|
||||
await axios.post('/dashboard/record-event', {
|
||||
businessId: id,
|
||||
event_type: type,
|
||||
metadata: { path: router.asPath }
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail for analytics
|
||||
console.error('Failed to record event', error);
|
||||
}
|
||||
};
|
||||
|
||||
const claimListing = async () => {
|
||||
if (!currentUser) {
|
||||
router.push('/login');
|
||||
@ -90,7 +105,7 @@ const BusinessDetailsPublic = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 pb-20 pt-20">
|
||||
<Head>
|
||||
<title>{business.name} | Crafted Network™</title>
|
||||
<title>{business.name} | Fix It Local™</title>
|
||||
</Head>
|
||||
|
||||
{/* Hero Header */}
|
||||
@ -334,7 +349,11 @@ const BusinessDetailsPublic = () => {
|
||||
<BaseIcon path={mdiPhone} size={24} className="mr-4 text-emerald-400" />
|
||||
<div>
|
||||
<div className="text-xs font-black uppercase tracking-widest text-slate-500 mb-1">Call Now</div>
|
||||
<div className="font-bold">{business.phone || 'Contact for details'}</div>
|
||||
<div className="font-bold">
|
||||
<a href={`tel:${business.phone}`} onClick={() => recordEvent('CALL_CLICK')}>
|
||||
{business.phone || 'Contact for details'}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
@ -348,7 +367,11 @@ const BusinessDetailsPublic = () => {
|
||||
<BaseIcon path={mdiWeb} size={24} className="mr-4 text-emerald-400" />
|
||||
<div>
|
||||
<div className="text-xs font-black uppercase tracking-widest text-slate-500 mb-1">Website</div>
|
||||
<div className="font-bold truncate max-w-[180px]">{business.website || 'N/A'}</div>
|
||||
<div className="font-bold truncate max-w-[180px]">
|
||||
<a href={business.website} target="_blank" rel="noopener noreferrer" onClick={() => recordEvent('WEBSITE_CLICK')}>
|
||||
{business.website || 'N/A'}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
@ -402,4 +425,4 @@ BusinessDetailsPublic.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
export default BusinessDetailsPublic;
|
||||
export default BusinessDetailsPublic;
|
||||
|
||||
@ -65,7 +65,7 @@ const RequestServicePage = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 pb-20 pt-20">
|
||||
<Head>
|
||||
<title>Request Service | Crafted Network™</title>
|
||||
<title>Request Service | Fix It Local™</title>
|
||||
</Head>
|
||||
|
||||
<div className="container mx-auto px-6 max-w-4xl">
|
||||
|
||||
@ -139,7 +139,7 @@ export default function Register() {
|
||||
<BaseIcon path={mdiShieldCheck} size={28} className="text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-black tracking-tight text-slate-900">
|
||||
Crafted Network<span className="text-emerald-500 italic">™</span>
|
||||
Fix It Local<span className="text-emerald-500 italic">™</span>
|
||||
</span>
|
||||
</Link>
|
||||
<h2 className="text-3xl font-bold text-slate-900">Create Account</h2>
|
||||
@ -213,7 +213,7 @@ export default function Register() {
|
||||
|
||||
<div className="text-center text-slate-400 text-xs pt-8">
|
||||
By creating an account, you agree to our <Link href='/terms-of-use' className="underline">Terms</Link> and <Link href='/privacy-policy' className="underline">Privacy Policy</Link>. <br />
|
||||
© 2026 Crafted Network™. All rights reserved.
|
||||
© 2026 Fix It Local™. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -5,7 +5,7 @@ import LayoutGuest from '../layouts/Guest';
|
||||
import { getPageTitle } from '../config';
|
||||
|
||||
export default function PrivacyPolicy() {
|
||||
const title = 'Crafted Network';
|
||||
const title = 'Fix It Local';
|
||||
const [projectUrl, setProjectUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -28,26 +28,26 @@ export const white: StyleObject = {
|
||||
aside: 'bg-white dark:text-white',
|
||||
asideScrollbars: 'aside-scrollbars-light',
|
||||
asideBrand: '',
|
||||
asideMenuItem: 'text-gray-700 hover:bg-gray-100/70 dark:text-dark-500 dark:hover:text-white dark:hover:bg-dark-800',
|
||||
asideMenuItemActive: 'font-bold text-black dark:text-white',
|
||||
asideMenuDropdown: 'bg-gray-100/75',
|
||||
navBarItemLabel: 'text-blue-600',
|
||||
navBarItemLabelHover: 'hover:text-black',
|
||||
navBarItemLabelActiveColor: 'text-black',
|
||||
overlay: 'from-white via-gray-100 to-white',
|
||||
activeLinkColor: 'bg-gray-100/70',
|
||||
bgLayoutColor: 'bg-gray-50',
|
||||
iconsColor: 'text-blue-500',
|
||||
asideMenuItem: 'text-slate-600 hover:bg-emerald-50/50 hover:text-emerald-600 dark:text-dark-500 dark:hover:text-white dark:hover:bg-dark-800 transition-colors',
|
||||
asideMenuItemActive: 'font-bold text-emerald-600 bg-emerald-50/70 dark:text-white',
|
||||
asideMenuDropdown: 'bg-emerald-50/30',
|
||||
navBarItemLabel: 'text-emerald-600',
|
||||
navBarItemLabelHover: 'hover:text-emerald-700',
|
||||
navBarItemLabelActiveColor: 'text-emerald-700',
|
||||
overlay: 'from-white via-emerald-50 to-white',
|
||||
activeLinkColor: 'bg-emerald-50/70',
|
||||
bgLayoutColor: 'bg-slate-50',
|
||||
iconsColor: 'text-emerald-500',
|
||||
cardsColor: 'bg-white',
|
||||
focusRingColor: 'focus:ring focus:ring-blue-600 focus:border-blue-600 focus:outline-none border-gray-300 dark:focus:ring-blue-600 dark:focus:border-blue-600',
|
||||
corners: 'rounded',
|
||||
cardsStyle: 'bg-white border border-pavitra-400',
|
||||
linkColor: 'text-blue-600',
|
||||
websiteHeder: 'border-b border-gray-200',
|
||||
borders: 'border-gray-200',
|
||||
shadow: '',
|
||||
focusRingColor: 'focus:ring focus:ring-emerald-600 focus:border-emerald-600 focus:outline-none border-slate-300 dark:focus:ring-emerald-600 dark:focus:border-emerald-600',
|
||||
corners: 'rounded-3xl',
|
||||
cardsStyle: 'bg-white border border-slate-200 shadow-sm',
|
||||
linkColor: 'text-emerald-600',
|
||||
websiteHeder: 'border-b border-slate-200',
|
||||
borders: 'border-slate-200',
|
||||
shadow: 'shadow-xl shadow-emerald-500/5',
|
||||
websiteSectionStyle: '',
|
||||
textSecondary: 'text-gray-500',
|
||||
textSecondary: 'text-slate-500',
|
||||
}
|
||||
|
||||
|
||||
@ -67,41 +67,45 @@ export const dataGridStyles = {
|
||||
},
|
||||
'& .MuiDataGrid-columnHeaders': {
|
||||
paddingY: 4,
|
||||
borderStartStartRadius: 7,
|
||||
borderStartEndRadius: 7,
|
||||
borderStartStartRadius: 24,
|
||||
borderStartEndRadius: 24,
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
'& .MuiDataGrid-footerContainer': {
|
||||
paddingY: 0.5,
|
||||
borderEndStartRadius: 7,
|
||||
borderEndEndRadius: 7,
|
||||
borderEndStartRadius: 24,
|
||||
borderEndEndRadius: 24,
|
||||
},
|
||||
'& .MuiDataGrid-root': {
|
||||
border: 'none',
|
||||
borderRadius: '24px',
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
|
||||
},
|
||||
};
|
||||
|
||||
export const basic: StyleObject = {
|
||||
aside: 'bg-gray-800',
|
||||
aside: 'bg-slate-900',
|
||||
asideScrollbars: 'aside-scrollbars-gray',
|
||||
asideBrand: 'bg-gray-900 text-white',
|
||||
asideMenuItem: 'text-gray-300 hover:text-white',
|
||||
asideMenuItemActive: 'font-bold text-white',
|
||||
asideMenuDropdown: 'bg-gray-700/50',
|
||||
navBarItemLabel: 'text-black',
|
||||
navBarItemLabelHover: 'hover:text-blue-500',
|
||||
navBarItemLabelActiveColor: 'text-blue-600',
|
||||
overlay: 'from-gray-700 via-gray-900 to-gray-700',
|
||||
activeLinkColor: 'bg-gray-100/70',
|
||||
bgLayoutColor: 'bg-gray-50',
|
||||
iconsColor: 'text-blue-500',
|
||||
asideBrand: 'bg-slate-950 text-white',
|
||||
asideMenuItem: 'text-slate-300 hover:text-white hover:bg-white/5 transition-colors',
|
||||
asideMenuItemActive: 'font-bold text-white bg-emerald-600',
|
||||
asideMenuDropdown: 'bg-slate-800/50',
|
||||
navBarItemLabel: 'text-slate-900',
|
||||
navBarItemLabelHover: 'hover:text-emerald-500',
|
||||
navBarItemLabelActiveColor: 'text-emerald-600',
|
||||
overlay: 'from-slate-700 via-slate-900 to-slate-700',
|
||||
activeLinkColor: 'bg-emerald-500/10',
|
||||
bgLayoutColor: 'bg-slate-50',
|
||||
iconsColor: 'text-emerald-500',
|
||||
cardsColor: 'bg-white',
|
||||
focusRingColor: 'focus:ring focus:ring-blue-600 focus:border-blue-600 focus:outline-none dark:focus:ring-blue-600 border-gray-300 dark:focus:border-blue-600',
|
||||
corners: 'rounded',
|
||||
cardsStyle: 'bg-white border border-pavitra-400',
|
||||
linkColor: 'text-black',
|
||||
focusRingColor: 'focus:ring focus:ring-emerald-600 focus:border-emerald-600 focus:outline-none dark:focus:ring-emerald-600 border-slate-300 dark:focus:border-emerald-600',
|
||||
corners: 'rounded-3xl',
|
||||
cardsStyle: 'bg-white border border-slate-200 shadow-sm',
|
||||
linkColor: 'text-slate-900',
|
||||
websiteHeder: '',
|
||||
borders: '',
|
||||
shadow: '',
|
||||
shadow: 'shadow-lg shadow-emerald-500/10',
|
||||
websiteSectionStyle: '',
|
||||
textSecondary: '',
|
||||
}
|
||||
textSecondary: 'text-slate-500',
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user