diff --git a/backend/src/db/api/businesses.js b/backend/src/db/api/businesses.js index cac5747..06e817b 100644 --- a/backend/src/db/api/businesses.js +++ b/backend/src/db/api/businesses.js @@ -32,7 +32,7 @@ module.exports = class BusinessesDBApi { // Data Isolation for Crafted Network™ if (currentUser && currentUser.app_role) { const roleName = currentUser.app_role.name; - if (roleName === 'VerifiedBusinessOwner') { + if (roleName === 'Verified Business Owner') { where.owner_userId = currentUser.id; } } @@ -63,6 +63,31 @@ module.exports = class BusinessesDBApi { return { rows: options?.countOnly ? [] : rows, count: count }; } + static async findAllAutocomplete(query, limit, offset) { + let where = {}; + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + { ['name']: { [Op.iLike]: `%${query}%` } }, + ], + }; + } + + const records = await db.businesses.findAll({ + attributes: ['id', 'name'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + order: [['name', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.name, + })); + } + static async findBy(where, options) { const transaction = (options && options.transaction) || undefined; const business = await db.businesses.findOne({ diff --git a/backend/src/db/api/lead_matches.js b/backend/src/db/api/lead_matches.js index 5dd194e..810dcd0 100644 --- a/backend/src/db/api/lead_matches.js +++ b/backend/src/db/api/lead_matches.js @@ -20,7 +20,7 @@ module.exports = class Lead_matchesDBApi { // Data Isolation for Crafted Network™ if (currentUser && currentUser.app_role) { const roleName = currentUser.app_role.name; - if (roleName === 'VerifiedBusinessOwner') { + if (roleName === 'Verified Business Owner') { // Business owners only see matches for THEIR businesses where['$business.owner_userId$'] = currentUser.id; } else if (roleName === 'Consumer') { diff --git a/backend/src/db/api/leads.js b/backend/src/db/api/leads.js index 062235c..fcda772 100644 --- a/backend/src/db/api/leads.js +++ b/backend/src/db/api/leads.js @@ -59,10 +59,8 @@ module.exports = class LeadsDBApi { const roleName = currentUser.app_role.name; if (roleName === 'Consumer') { where.userId = currentUser.id; - } else if (roleName === 'VerifiedBusinessOwner') { + } else if (roleName === 'Verified Business Owner') { // Business owners see leads matched to them - // This is complex for standard findAll, usually handled in lead_matches - // But if they access /leads, we should probably filter where['$lead_matches_lead.business.owner_userId$'] = currentUser.id; } } @@ -102,7 +100,31 @@ module.exports = class LeadsDBApi { }; } - // ... other methods kept as standard or updated as needed + static async findAllAutocomplete(query, limit, offset) { + let where = {}; + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + { ['keyword']: { [Op.iLike]: `%${query}%` } }, + ], + }; + } + + const records = await db.leads.findAll({ + attributes: ['id', 'keyword'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + order: [['keyword', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.keyword, + })); + } + static async findBy(where, options) { const transaction = (options && options.transaction) || undefined; const leads = await db.leads.findOne({ diff --git a/backend/src/db/api/messages.js b/backend/src/db/api/messages.js index 3285736..84bfc45 100644 --- a/backend/src/db/api/messages.js +++ b/backend/src/db/api/messages.js @@ -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 MessagesDBApi { - - - static async create(data, options) { const currentUser = (options && options.currentUser) || { id: null }; const transaction = (options && options.transaction) || undefined; @@ -20,453 +14,93 @@ module.exports = class MessagesDBApi { const messages = await db.messages.create( { id: data.id || undefined, - - body: data.body - || - null - , - - read_at: data.read_at - || - null - , - - created_at_ts: data.created_at_ts - || - null - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - await messages.setLead( data.lead || null, { - transaction, - }); - - await messages.setSender_user( data.sender_user || null, { - transaction, - }); - - await messages.setReceiver_user( data.receiver_user || null, { - transaction, - }); - - - - - - - return messages; - } - - - 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 messagesData = data.map((item, index) => ({ - id: item.id || undefined, - - body: item.body - || - null - , - - read_at: item.read_at - || - 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 messages = await db.messages.bulkCreate(messagesData, { transaction }); - - // For each item created, replace relation files - - - return messages; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - - const messages = await db.messages.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.body !== undefined) updatePayload.body = data.body; - - - if (data.read_at !== undefined) updatePayload.read_at = data.read_at; - - - if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts; - - - updatePayload.updatedById = currentUser.id; - - await messages.update(updatePayload, {transaction}); - - - - if (data.lead !== undefined) { - await messages.setLead( - - data.lead, - - { transaction } - ); - } - - if (data.sender_user !== undefined) { - await messages.setSender_user( - - data.sender_user, - - { transaction } - ); - } - - if (data.receiver_user !== undefined) { - await messages.setReceiver_user( - - data.receiver_user, - - { transaction } - ); - } - - - - - - - - return messages; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const messages = await db.messages.findAll({ - where: { - id: { - [Op.in]: ids, - }, + body: data.body || null, + read_at: data.read_at || null, + 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 messages) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of messages) { - await record.destroy({transaction}); - } - }); - - - return messages; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const messages = await db.messages.findByPk(id, options); - - await messages.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await messages.destroy({ - transaction - }); - - return messages; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const messages = await db.messages.findOne( - { where }, { transaction }, ); - if (!messages) { - return messages; - } + await messages.setLead( data.lead || null, { transaction }); + await messages.setSender_user( data.sender_user || currentUser.id, { transaction }); + await messages.setReceiver_user( data.receiver_user || null, { transaction }); - const output = messages.get({plain: true}); - - - - - - - - - - - - - - - - - - - - - - - - - - - output.lead = await messages.getLead({ - transaction - }); - - - output.sender_user = await messages.getSender_user({ - transaction - }); - - - output.receiver_user = await messages.getReceiver_user({ - transaction - }); - - - - return output; + return messages; } - 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.leads, - as: 'lead', - - where: filter.lead ? { - [Op.or]: [ - { id: { [Op.in]: filter.lead.split('|').map(term => Utils.uuid(term)) } }, - { - keyword: { - [Op.or]: filter.lead.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - { - model: db.users, - as: 'sender_user', - - where: filter.sender_user ? { - [Op.or]: [ - { id: { [Op.in]: filter.sender_user.split('|').map(term => Utils.uuid(term)) } }, - { - firstName: { - [Op.or]: filter.sender_user.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - { - model: db.users, - as: 'receiver_user', - - where: filter.receiver_user ? { - [Op.or]: [ - { id: { [Op.in]: filter.receiver_user.split('|').map(term => Utils.uuid(term)) } }, - { - firstName: { - [Op.or]: filter.receiver_user.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - if (filter.body) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'messages', - 'body', - filter.body, - ), - }; - } - - - - - - - if (filter.read_atRange) { - const [start, end] = filter.read_atRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - read_at: { - ...where.read_at, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - read_at: { - ...where.read_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.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 for Crafted Network™ + 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 } + ]; + } else if (roleName === 'Consumer') { + where[Op.or] = [ + { sender_userId: currentUser.id }, + { receiver_userId: currentUser.id }, + { '$lead.userId$': currentUser.id } + ]; } } - - + let include = [ + { + model: db.leads, + as: 'lead', + include: [{ + model: db.lead_matches, + as: 'lead_matches_lead', + include: [{ model: db.businesses, as: 'business' }] + }], + where: filter.lead ? { + [Op.or]: [ + { id: { [Op.in]: filter.lead.split('|').map(term => Utils.uuid(term)) } }, + { keyword: { [Op.or]: filter.lead.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) } }, + ] + } : {}, + }, + { + model: db.users, + as: 'sender_user', + where: filter.sender_user ? { + [Op.or]: [ + { id: { [Op.in]: filter.sender_user.split('|').map(term => Utils.uuid(term)) } }, + { firstName: { [Op.or]: filter.sender_user.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) } }, + ] + } : {}, + }, + { + model: db.users, + as: 'receiver_user', + where: filter.receiver_user ? { + [Op.or]: [ + { id: { [Op.in]: filter.receiver_user.split('|').map(term => Utils.uuid(term)) } }, + { firstName: { [Op.or]: filter.receiver_user.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) } }, + ] + } : {}, + }, + ]; + + if (filter) { + if (filter.id) where.id = Utils.uuid(filter.id); + if (filter.body) where.body = { [Op.iLike]: `%${filter.body}%` }; + } const queryOptions = { where, @@ -475,8 +109,7 @@ module.exports = class MessagesDBApi { order: filter.field && filter.sort ? [[filter.field, filter.sort]] : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log + transaction, }; if (!options?.countOnly) { @@ -484,43 +117,81 @@ module.exports = class MessagesDBApi { queryOptions.offset = offset ? Number(offset) : undefined; } - try { - const { rows, count } = await db.messages.findAndCountAll(queryOptions); + const { rows, count } = await db.messages.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 messages = await db.messages.findOne({ where, transaction }); + if (!messages) return null; + const output = messages.get({plain: true}); + output.lead = await messages.getLead({ transaction }); + output.sender_user = await messages.getSender_user({ transaction }); + output.receiver_user = await messages.getReceiver_user({ transaction }); + + return output; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || {id: null}; + const transaction = (options && options.transaction) || undefined; + const messages = await db.messages.findByPk(id, {transaction}); + if (!messages) return null; + + const updatePayload = { ...data, updatedById: currentUser.id }; + await messages.update(updatePayload, {transaction}); + + if (data.lead !== undefined) await messages.setLead(data.lead, { transaction }); + if (data.sender_user !== undefined) await messages.setSender_user(data.sender_user, { transaction }); + if (data.receiver_user !== undefined) await messages.setReceiver_user(data.receiver_user, { transaction }); + + return messages; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const messages = await db.messages.findAll({ where: { id: { [Op.in]: ids } }, transaction }); + + for (const record of messages) { + await record.update({deletedBy: currentUser.id}, {transaction}); + await record.destroy({transaction}); + } + return messages; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || {id: null}; + const transaction = (options && options.transaction) || undefined; + const messages = await db.messages.findByPk(id, options); + await messages.update({ deletedBy: currentUser.id }, { transaction }); + await messages.destroy({ transaction }); + return messages; + } + + static async findAllAutocomplete(query, limit, offset) { + let where = {}; if (query) { where = { [Op.or]: [ { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'messages', - 'body', - query, - ), + { ['body']: { [Op.iLike]: `%${query}%` } }, ], }; } const records = await db.messages.findAll({ - attributes: [ 'id', 'body' ], + attributes: ['id', 'body'], where, limit: limit ? Number(limit) : undefined, offset: offset ? Number(offset) : undefined, - orderBy: [['body', 'ASC']], + order: [['body', 'ASC']], }); return records.map((record) => ({ @@ -528,7 +199,4 @@ module.exports = class MessagesDBApi { label: record.body, })); } - - -}; - +}; \ No newline at end of file diff --git a/backend/src/db/api/reviews.js b/backend/src/db/api/reviews.js index c31aac3..65c988a 100644 --- a/backend/src/db/api/reviews.js +++ b/backend/src/db/api/reviews.js @@ -1,4 +1,3 @@ - const db = require('../models'); const FileDBApi = require('./file'); const crypto = require('crypto'); @@ -46,6 +45,9 @@ module.exports = class ReviewsDBApi { || null , + + response: data.response || null, + response_at_ts: data.response ? new Date() : null, created_at_ts: data.created_at_ts || @@ -170,6 +172,11 @@ module.exports = class ReviewsDBApi { if (data.moderation_notes !== undefined) updatePayload.moderation_notes = data.moderation_notes; + + if (data.response !== undefined) { + updatePayload.response = data.response; + updatePayload.response_at_ts = new Date(); + } if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts; @@ -345,6 +352,18 @@ module.exports = class ReviewsDBApi { const transaction = (options && options.transaction) || undefined; + const currentUser = options?.currentUser; + + // Data Isolation for Crafted Network™ + if (currentUser && currentUser.app_role) { + const roleName = currentUser.app_role.name; + if (roleName === 'Verified Business Owner') { + where['$business.owner_userId$'] = currentUser.id; + } else if (roleName === 'Consumer') { + where.userId = currentUser.id; + } + } + let include = [ { @@ -633,5 +652,4 @@ module.exports = class ReviewsDBApi { } -}; - +}; \ No newline at end of file diff --git a/backend/src/db/migrations/20260218012153-add-response-to-reviews.js b/backend/src/db/migrations/20260218012153-add-response-to-reviews.js new file mode 100644 index 0000000..231a511 --- /dev/null +++ b/backend/src/db/migrations/20260218012153-add-response-to-reviews.js @@ -0,0 +1,20 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up (queryInterface, Sequelize) { + await queryInterface.addColumn('reviews', 'response', { + type: Sequelize.TEXT, + allowNull: true, + }); + await queryInterface.addColumn('reviews', 'response_at_ts', { + type: Sequelize.DATE, + allowNull: true, + }); + }, + + async down (queryInterface, Sequelize) { + await queryInterface.removeColumn('reviews', 'response'); + await queryInterface.removeColumn('reviews', 'response_at_ts'); + } +}; \ No newline at end of file diff --git a/backend/src/db/models/reviews.js b/backend/src/db/models/reviews.js index e96a6fe..f923e83 100644 --- a/backend/src/db/models/reviews.js +++ b/backend/src/db/models/reviews.js @@ -67,6 +67,14 @@ moderation_notes: { }, + response: { + type: DataTypes.TEXT, + }, + + response_at_ts: { + type: DataTypes.DATE, + }, + created_at_ts: { type: DataTypes.DATE, @@ -167,6 +175,4 @@ updated_at_ts: { return reviews; -}; - - +}; \ No newline at end of file diff --git a/backend/src/db/seeders/20260218012500-update-vbo-permissions.js b/backend/src/db/seeders/20260218012500-update-vbo-permissions.js new file mode 100644 index 0000000..23daf84 --- /dev/null +++ b/backend/src/db/seeders/20260218012500-update-vbo-permissions.js @@ -0,0 +1,88 @@ +const { v4: uuid } = require("uuid"); + +module.exports = { + /** + * @param{import("sequelize").QueryInterface} queryInterface + * @return {Promise} + */ + async up(queryInterface) { + const createdAt = new Date(); + const updatedAt = new Date(); + + /** @type {Map} */ + const idMap = new Map(); + + /** + * @param {string} key + * @return {string} + */ + function getId(key) { + if (idMap.has(key)) { + return idMap.get(key); + } + const id = uuid(); + idMap.set(key, id); + return id; + } + + // Since we are updating, we should try to fetch existing IDs if possible, + // but in a seeder for this kind of platform it's often better to just recreate or use fixed IDs if they were fixed. + // However, the previous seeder used random UUIDs. + // To update permissions, I'll need to fetch the roles. + + const roles = await queryInterface.sequelize.query( + `SELECT id, name FROM "roles";`, + { type: queryInterface.sequelize.QueryTypes.SELECT } + ); + + const permissions = await queryInterface.sequelize.query( + `SELECT id, name FROM "permissions";`, + { type: queryInterface.sequelize.QueryTypes.SELECT } + ); + + const getRoleId = (name) => roles.find(r => r.name === name)?.id; + const getPermId = (name) => permissions.find(p => p.name === name)?.id; + + const vboRoleId = getRoleId("Verified Business Owner"); + + if (vboRoleId) { + const newPerms = [ + "CREATE_BUSINESSES", + "UPDATE_REVIEWS", + "CREATE_BUSINESS_PHOTOS", + "CREATE_BUSINESS_CATEGORIES", + "CREATE_SERVICE_PRICES", + "CREATE_LEAD_MATCHES", // Maybe? + "CREATE_MESSAGES", + ]; + + const rolePermsToInsert = []; + for (const p of newPerms) { + const permId = getPermId(p); + if (permId) { + // Check if it already exists + const existing = await queryInterface.sequelize.query( + `SELECT * FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" = '${vboRoleId}' AND "permissionId" = '${permId}';`, + { type: queryInterface.sequelize.QueryTypes.SELECT } + ); + if (existing.length === 0) { + rolePermsToInsert.push({ + createdAt, + updatedAt, + roles_permissionsId: vboRoleId, + permissionId: permId + }); + } + } + } + + if (rolePermsToInsert.length > 0) { + await queryInterface.bulkInsert("rolesPermissionsPermissions", rolePermsToInsert); + } + } + }, + + async down(queryInterface) { + // No easy way to undo this without more logic + } +}; \ No newline at end of file diff --git a/backend/src/routes/search.js b/backend/src/routes/search.js index 164b376..8c49dfa 100644 --- a/backend/src/routes/search.js +++ b/backend/src/routes/search.js @@ -22,6 +22,8 @@ router.use(checkCrudPermissions('search')); * properties: * searchQuery: * type: string + * location: + * type: string * required: * - searchQuery * responses: @@ -34,14 +36,14 @@ router.use(checkCrudPermissions('search')); */ router.post('/', async (req, res) => { - const { searchQuery } = req.body; + const { searchQuery, location } = req.body; if (!searchQuery) { return res.status(400).json({ error: 'Please enter a search query' }); } try { - const foundMatches = await SearchService.search(searchQuery, req.currentUser ); + const foundMatches = await SearchService.search(searchQuery, req.currentUser, location ); res.json(foundMatches); } catch (error) { console.error('Internal Server Error', error); diff --git a/backend/src/services/businesses.js b/backend/src/services/businesses.js index 33aa5a6..eae382b 100644 --- a/backend/src/services/businesses.js +++ b/backend/src/services/businesses.js @@ -2,6 +2,7 @@ const db = require('../db/models'); const BusinessesDBApi = require('../db/api/businesses'); 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'); @@ -11,7 +12,12 @@ module.exports = class BusinessesService { static async create(data, currentUser) { const transaction = await db.sequelize.transaction(); try { - await BusinessesDBApi.create( + // For VBOs, force the owner to be the current user + if (currentUser.app_role?.name === 'Verified Business Owner') { + data.owner_user = currentUser.id; + } + + const business = await BusinessesDBApi.create( data, { currentUser, @@ -20,6 +26,7 @@ module.exports = class BusinessesService { ); await transaction.commit(); + return business; } catch (error) { await transaction.rollback(); throw error; @@ -88,17 +95,27 @@ module.exports = class BusinessesService { static async update(data, id, currentUser) { const transaction = await db.sequelize.transaction(); try { - let businesses = await BusinessesDBApi.findBy( + let business = await BusinessesDBApi.findBy( {id}, {transaction}, ); - if (!businesses) { + if (!business) { throw new ValidationError( 'businessesNotFound', ); } + // Ownership check for Verified Business Owner + if (currentUser.app_role?.name === 'Verified Business Owner') { + if (business.owner_userId !== currentUser.id) { + throw new ForbiddenError('forbidden'); + } + // Prevent transferring ownership + delete data.owner_user; + delete data.owner_userId; + } + const updatedBusinesses = await BusinessesDBApi.update( id, data, @@ -121,6 +138,20 @@ module.exports = class BusinessesService { 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.businesses.findAll({ + where: { + id: { [db.Sequelize.Op.in]: ids }, + owner_userId: { [db.Sequelize.Op.ne]: currentUser.id } + }, + transaction + }); + if (records.length > 0) { + throw new ForbiddenError('forbidden'); + } + } + await BusinessesDBApi.deleteByIds(ids, { currentUser, transaction, @@ -137,6 +168,11 @@ module.exports = class BusinessesService { const transaction = await db.sequelize.transaction(); try { + let business = await db.businesses.findByPk(id, { transaction }); + if (currentUser.app_role?.name === 'Verified Business Owner' && business.owner_userId !== currentUser.id) { + throw new ForbiddenError('forbidden'); + } + await BusinessesDBApi.remove( id, { diff --git a/backend/src/services/googlePlaces.js b/backend/src/services/googlePlaces.js index e530717..f5dfce5 100644 --- a/backend/src/services/googlePlaces.js +++ b/backend/src/services/googlePlaces.js @@ -44,7 +44,7 @@ class GooglePlacesService { const response = await axios.get(`${this.baseUrl}/details/json`, { params: { place_id: placeId, - fields: 'name,formatted_address,formatted_phone_number,website,opening_hours,geometry,rating,types,photos', + fields: 'name,formatted_address,address_components,formatted_phone_number,website,opening_hours,geometry,rating,types,photos,editorial_summary', key: this.apiKey, }, }); @@ -66,38 +66,76 @@ class GooglePlacesService { }); if (business) { + // Even if it exists, we might want to update the description or hours if missing + if (!business.description || !business.hours_json) { + const details = await this.getPlaceDetails(googlePlace.place_id); + if (details) { + let updated = false; + if (!business.description && details.editorial_summary) { + business.description = details.editorial_summary.overview; + updated = true; + } + if (!business.hours_json && details.opening_hours) { + business.hours_json = JSON.stringify(details.opening_hours); + updated = true; + } + if (updated) { + await business.save({ transaction }); + } + } + } await transaction.commit(); return business; } + // If it's a new business, let's fetch full details to get the description + const details = await this.getPlaceDetails(googlePlace.place_id); + const placeData = details || googlePlace; + + // Try to parse city/state/zip from address if available + let city = null; + let state = null; + let zip = null; + + const formattedAddress = placeData.formatted_address || googlePlace.formatted_address || googlePlace.vicinity; + if (formattedAddress) { + const parts = formattedAddress.split(','); + if (parts.length >= 3) { + // Typically "City, State Zip, Country" or "City, State, Country" + city = parts[parts.length - 3].trim(); + const stateZip = parts[parts.length - 2].trim().split(' '); + if (stateZip.length >= 1) state = stateZip[0]; + if (stateZip.length >= 2) zip = stateZip[1]; + } + } + // Prepare business data const businessData = { id: uuidv4(), - name: googlePlace.name, - slug: googlePlace.name.toLowerCase().replace(/[^a-z0-9]+/g, '-'), - address: googlePlace.formatted_address || googlePlace.vicinity, - lat: googlePlace.geometry?.location?.lat, - lng: googlePlace.geometry?.location?.lng, + name: placeData.name, + slug: placeData.name.toLowerCase().replace(/[^a-z0-9]+/g, '-'), + address: formattedAddress, + city, + state, + zip, + lat: placeData.geometry?.location?.lat, + lng: placeData.geometry?.location?.lng, google_place_id: googlePlace.place_id, - rating: googlePlace.rating || 0, + rating: placeData.rating || 0, is_active: true, is_claimed: false, - hours_json: googlePlace.opening_hours ? JSON.stringify(googlePlace.opening_hours) : null, + hours_json: placeData.opening_hours ? JSON.stringify(placeData.opening_hours) : null, + description: placeData.editorial_summary?.overview || null, + phone: placeData.formatted_phone_number || null, + website: placeData.website || null, }; - // If we have more details (from getPlaceDetails) - if (googlePlace.formatted_phone_number) { - businessData.phone = googlePlace.formatted_phone_number; - } - if (googlePlace.website) { - businessData.website = googlePlace.website; - } - business = await db.businesses.create(businessData, { transaction }); // Handle categories/types - if (googlePlace.types) { - for (const type of googlePlace.types) { + const types = placeData.types || googlePlace.types; + if (types) { + for (const type of types) { // Find or create category let category = await db.categories.findOne({ where: { name: { [db.Sequelize.Op.iLike]: type.replace(/_/g, ' ') } }, @@ -105,9 +143,12 @@ class GooglePlacesService { }); if (!category) { - // Only create if it's a "beauty" related type to keep it relevant - const beautyTypes = ['beauty_salon', 'hair_care', 'spa', 'health', 'cosmetics']; - if (beautyTypes.includes(type)) { + // Include beauty types AND common service types found on landing page + const allowedTypes = [ + 'beauty_salon', 'hair_care', 'spa', 'health', 'cosmetics', 'beauty_product', 'hair_salon', 'massage', 'nail_salon', 'skin_care', + 'plumber', 'electrician', 'hvac_contractor', 'painter', 'home_improvement_contractor', 'general_contractor', 'cleaning_service', 'locksmith', 'roofing_contractor' + ]; + if (allowedTypes.includes(type)) { category = await db.categories.create({ id: uuidv4(), name: type.replace(/_/g, ' ').charAt(0).toUpperCase() + type.replace(/_/g, ' ').slice(1), @@ -128,8 +169,9 @@ class GooglePlacesService { } // Handle photos - if (googlePlace.photos && googlePlace.photos.length > 0) { - const photo = googlePlace.photos[0]; + const photos = placeData.photos || googlePlace.photos; + if (photos && photos.length > 0) { + const photo = photos[0]; const photoReference = photo.photo_reference; const photoUrl = `${this.baseUrl}/photo?maxwidth=800&photoreference=${photoReference}&key=${this.apiKey}`; @@ -182,4 +224,4 @@ class GooglePlacesService { } } -module.exports = new GooglePlacesService(); +module.exports = new GooglePlacesService(); \ No newline at end of file diff --git a/backend/src/services/lead_matches.js b/backend/src/services/lead_matches.js index 9fc8832..9488167 100644 --- a/backend/src/services/lead_matches.js +++ b/backend/src/services/lead_matches.js @@ -2,15 +2,12 @@ const db = require('../db/models'); const Lead_matchesDBApi = require('../db/api/lead_matches'); 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 Lead_matchesService { static async create(data, currentUser) { const transaction = await db.sequelize.transaction(); @@ -68,17 +65,25 @@ module.exports = class Lead_matchesService { static async update(data, id, currentUser) { const transaction = await db.sequelize.transaction(); try { - let lead_matches = await Lead_matchesDBApi.findBy( + let lead_match = await Lead_matchesDBApi.findBy( {id}, {transaction}, ); - if (!lead_matches) { + if (!lead_match) { throw new ValidationError( 'lead_matchesNotFound', ); } + // Ownership check for Verified Business Owner + if (currentUser.app_role?.name === 'Verified Business Owner') { + const business = await db.businesses.findByPk(lead_match.businessId, { transaction }); + if (business && business.owner_userId !== currentUser.id) { + throw new ForbiddenError('forbidden'); + } + } + const updatedLead_matches = await Lead_matchesDBApi.update( id, data, @@ -133,6 +138,4 @@ module.exports = class Lead_matchesService { } -}; - - +}; \ No newline at end of file diff --git a/backend/src/services/reviews.js b/backend/src/services/reviews.js index 5b3e212..9fbb738 100644 --- a/backend/src/services/reviews.js +++ b/backend/src/services/reviews.js @@ -2,6 +2,7 @@ const db = require('../db/models'); const ReviewsDBApi = require('../db/api/reviews'); 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'); @@ -95,17 +96,27 @@ module.exports = class ReviewsService { static async update(data, id, currentUser) { const transaction = await db.sequelize.transaction(); try { - let reviews = await ReviewsDBApi.findBy( + let review = await ReviewsDBApi.findBy( {id}, {transaction}, ); - if (!reviews) { + if (!review) { throw new ValidationError( 'reviewsNotFound', ); } + // 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'); + } + } + const updatedReviews = await ReviewsDBApi.update( id, data, @@ -115,7 +126,7 @@ module.exports = class ReviewsService { }, ); - const businessId = (updatedReviews.business && updatedReviews.business.id) || updatedReviews.businessId || reviews.businessId; + const businessId = (updatedReviews.business && updatedReviews.business.id) || updatedReviews.businessId || review.businessId; await this.updateBusinessRating(businessId, transaction); await transaction.commit(); diff --git a/backend/src/services/search.js b/backend/src/services/search.js index f0c4d6e..7830218 100644 --- a/backend/src/services/search.js +++ b/backend/src/services/search.js @@ -51,12 +51,14 @@ async function checkPermissions(permission, currentUser) { } module.exports = class SearchService { - static async search(searchQuery, currentUser ) { + static async search(searchQuery, currentUser, location ) { try { if (!searchQuery) { throw new ValidationError('iam.errors.searchQueryRequired'); } - const tableColumns = { + + // Columns that can be searched using iLike + const searchableColumns = { "users": [ "firstName", "lastName", @@ -66,7 +68,6 @@ module.exports = class SearchService { "categories": [ "name", "slug", - "icon", "description", ], "locations": [ @@ -88,6 +89,23 @@ module.exports = class SearchService { "zip", ], }; + + // All columns to be returned in the result + const returnColumns = { + "users": [...searchableColumns.users], + "categories": [...searchableColumns.categories, "icon"], + "locations": [...searchableColumns.locations], + "businesses": [ + ...searchableColumns.businesses, + "is_claimed", + "rating", + "lat", + "lng", + "reliability_score", + "response_time_median_minutes" + ], + }; + const columnsInt = { "businesses": [ "lat", @@ -99,26 +117,49 @@ module.exports = class SearchService { let allFoundRecords = []; - for (const tableName in tableColumns) { - if (tableColumns.hasOwnProperty(tableName)) { - const attributesToSearch = tableColumns[tableName]; + for (const tableName in searchableColumns) { + if (searchableColumns.hasOwnProperty(tableName)) { + const attributesToSearch = searchableColumns[tableName]; const attributesIntToSearch = columnsInt[tableName] || []; + + const searchConditions = [ + ...attributesToSearch.map(attribute => ({ + [attribute]: { + [Op.iLike] : `%${searchQuery}%`, + }, + })), + ...attributesIntToSearch.map(attribute => ( + Sequelize.where( + Sequelize.cast(Sequelize.col(`${tableName}.${attribute}`), 'varchar'), + { [Op.iLike]: `%${searchQuery}%` } + ) + )), + ]; + const whereCondition = { - [Op.or]: [ - ...attributesToSearch.map(attribute => ({ - [attribute]: { - [Op.iLike] : `%${searchQuery}%`, - }, - })), - ...attributesIntToSearch.map(attribute => ( - Sequelize.where( - Sequelize.cast(Sequelize.col(`${tableName}.${attribute}`), 'varchar'), - { [Op.iLike]: `%${searchQuery}%` } - ) - )), - ], + [Op.or]: searchConditions, }; + // If location is provided, bias local results by location for businesses and locations + if (location && (tableName === 'businesses' || tableName === 'locations')) { + const locationConditions = [ + { zip: { [Op.iLike]: `%${location}%` } }, + { city: { [Op.iLike]: `%${location}%` } }, + { state: { [Op.iLike]: `%${location}%` } }, + ]; + + // locations table doesn't have address column + if (tableName === 'businesses') { + locationConditions.push({ address: { [Op.iLike]: `%${location}%` } }); + } + + whereCondition[Op.and] = [ + { + [Op.or]: locationConditions + } + ]; + } + const hasPerm = await checkPermissions(`READ_${tableName.toUpperCase()}`, currentUser); if (!hasPerm) { continue; @@ -138,7 +179,7 @@ module.exports = class SearchService { const foundRecords = await db[tableName].findAll({ where: whereCondition, - attributes: [...tableColumns[tableName], 'id', ...attributesIntToSearch], + attributes: [...returnColumns[tableName], 'id'], include }); @@ -146,7 +187,7 @@ module.exports = class SearchService { const matchAttribute = []; for (const attribute of attributesToSearch) { - if (record[attribute]?.toLowerCase()?.includes(searchQuery.toLowerCase())) { + if (record[attribute] && typeof record[attribute] === 'string' && record[attribute].toLowerCase().includes(searchQuery.toLowerCase())) { matchAttribute.push(attribute); } } @@ -208,8 +249,10 @@ module.exports = class SearchService { try { // If no location in search query, try to append a default location to help Google let refinedQuery = searchQuery; - if (!searchQuery.match(/\d{5}/) && !searchQuery.match(/in\s+[A-Za-z]+/i)) { - refinedQuery = `${searchQuery} 22193`; // Default to Woodbridge, VA area + if (location) { + refinedQuery = `${searchQuery} ${location}`; + } else if (!searchQuery.match(/\d{5}/) && !searchQuery.match(/in\s+[A-Za-z]+/i)) { + refinedQuery = `${searchQuery} USA`; // Search nationwide if no location specified } const googleResults = await GooglePlacesService.searchPlaces(refinedQuery); @@ -230,7 +273,7 @@ module.exports = class SearchService { }); // Add to search results if not already there - if (!allFoundRecords.find(r => r.id === fullBusiness.id)) { + if (fullBusiness && !allFoundRecords.find(r => r.id === fullBusiness.id)) { allFoundRecords.push({ ...fullBusiness.get(), matchAttribute: ['name'], diff --git a/frontend/src/components/Reviews/configureReviewsCols.tsx b/frontend/src/components/Reviews/configureReviewsCols.tsx index a1cf2f8..ee1c312 100644 --- a/frontend/src/components/Reviews/configureReviewsCols.tsx +++ b/frontend/src/components/Reviews/configureReviewsCols.tsx @@ -1,16 +1,9 @@ import React from 'react'; -import BaseIcon from '../BaseIcon'; -import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; import axios from 'axios'; import { - GridActionsCellItem, GridRowParams, GridValueGetterParams, } from '@mui/x-data-grid'; -import ImageField from '../ImageField'; -import {saveFile} from "../../helpers/fileSaver"; -import dataFormatter from '../../helpers/dataFormatter' -import DataGridMultiSelect from "../DataGridMultiSelect"; import ListActionsPopover from '../ListActionsPopover'; import {hasPermission} from "../../helpers/userPermissions"; @@ -38,9 +31,9 @@ export const loadColumns = async ( } const hasUpdatePermission = hasPermission(user, 'UPDATE_REVIEWS') + const isBusinessOwner = user?.app_role?.name === 'Verified Business Owner'; - return [ - + const columns: any[] = [ { field: 'business', headerName: 'Business', @@ -49,10 +42,7 @@ export const loadColumns = async ( filterable: false, headerClassName: 'datagrid--header', cellClassName: 'datagrid--cell', - - - editable: hasUpdatePermission, - + editable: hasUpdatePermission && !isBusinessOwner, sortable: false, type: 'singleSelect', getOptionValue: (value: any) => value?.id, @@ -60,9 +50,7 @@ export const loadColumns = async ( valueOptions: await callOptionsApi('businesses'), valueGetter: (params: GridValueGetterParams) => params?.value?.id ?? params?.value, - }, - { field: 'user', headerName: 'User', @@ -71,10 +59,7 @@ export const loadColumns = async ( filterable: false, headerClassName: 'datagrid--header', cellClassName: 'datagrid--cell', - - - editable: hasUpdatePermission, - + editable: hasUpdatePermission && !isBusinessOwner, sortable: false, type: 'singleSelect', getOptionValue: (value: any) => value?.id, @@ -82,144 +67,89 @@ export const loadColumns = async ( valueOptions: await callOptionsApi('users'), valueGetter: (params: GridValueGetterParams) => params?.value?.id ?? params?.value, - }, - - { - field: 'lead', - headerName: 'Lead', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - - editable: hasUpdatePermission, - - sortable: false, - type: 'singleSelect', - getOptionValue: (value: any) => value?.id, - getOptionLabel: (value: any) => value?.label, - valueOptions: await callOptionsApi('leads'), - valueGetter: (params: GridValueGetterParams) => - params?.value?.id ?? params?.value, - - }, - { field: 'rating', headerName: 'Rating', - flex: 1, - minWidth: 120, + flex: 0.5, + minWidth: 80, filterable: false, headerClassName: 'datagrid--header', cellClassName: 'datagrid--cell', - - - editable: hasUpdatePermission, - + editable: hasUpdatePermission && !isBusinessOwner, type: 'number', - }, - { field: 'text', - headerName: 'Text', - flex: 1, - minWidth: 120, + headerName: 'Review', + flex: 2, + minWidth: 200, filterable: false, headerClassName: 'datagrid--header', cellClassName: 'datagrid--cell', - - - editable: hasUpdatePermission, - - + editable: hasUpdatePermission && !isBusinessOwner, }, - { - field: 'is_verified_job', - headerName: 'IsVerifiedJob', - flex: 1, - minWidth: 120, + field: 'response', + headerName: 'Your Response', + flex: 2, + minWidth: 200, filterable: false, headerClassName: 'datagrid--header', cellClassName: 'datagrid--cell', - - editable: hasUpdatePermission, - - type: 'boolean', - }, - { field: 'status', headerName: 'Status', flex: 1, - minWidth: 120, + minWidth: 100, filterable: false, headerClassName: 'datagrid--header', cellClassName: 'datagrid--cell', - - - editable: hasUpdatePermission, - - - }, - + editable: hasUpdatePermission && !isBusinessOwner, + } + ]; + + if (!isBusinessOwner) { + columns.push( + { + field: 'is_verified_job', + headerName: 'Verified', + flex: 0.5, + minWidth: 80, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + editable: hasUpdatePermission, + type: 'boolean', + }, + { + field: 'moderation_notes', + headerName: 'Moderation Notes', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + editable: hasUpdatePermission, + } + ); + } + + columns.push( { - field: 'moderation_notes', - headerName: 'ModerationNotes', + field: 'createdAt', + headerName: 'Date', flex: 1, - minWidth: 120, + minWidth: 150, filterable: false, headerClassName: 'datagrid--header', cellClassName: 'datagrid--cell', - - - editable: hasUpdatePermission, - - - }, - - { - field: 'created_at_ts', - headerName: 'CreatedAt', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - - editable: hasUpdatePermission, - type: 'dateTime', valueGetter: (params: GridValueGetterParams) => - new Date(params.row.created_at_ts), - + new Date(params.row.createdAt), }, - - { - field: 'updated_at_ts', - headerName: 'UpdatedAt', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - - editable: hasUpdatePermission, - - type: 'dateTime', - valueGetter: (params: GridValueGetterParams) => - new Date(params.row.updated_at_ts), - - }, - { field: 'actions', type: 'actions', @@ -227,7 +157,6 @@ export const loadColumns = async ( headerClassName: 'datagrid--header', cellClassName: 'datagrid--cell', getActions: (params: GridRowParams) => { - return [
, ] }, - }, - ]; -}; + } + ); + + return columns; +}; \ No newline at end of file diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 26c3572..200e8b6 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -87,6 +87,32 @@ export default function LayoutAuthenticated({ const layoutAsidePadding = 'xl:pl-60' + // Filter and customize menu for Verified Business Owner + const filteredMenu = menuAside.filter(item => { + if (currentUser?.app_role?.name === 'Verified Business Owner') { + const allowedPaths = [ + '/dashboard', + '/businesses/businesses-list', + '/leads/leads-list', + '/reviews/reviews-list', + '/messages/messages-list', + '/profile' + ]; + return allowedPaths.includes(item.href); + } + return true; + }).map(item => { + if (currentUser?.app_role?.name === 'Verified Business Owner') { + if (item.href === '/leads/leads-list') { + return { ...item, label: 'Service Requests' }; + } + if (item.href === '/businesses/businesses-list') { + return { ...item, label: 'My Listing' }; + } + } + return item; + }); + return (
setIsAsideLgActive(false)} /> {children} @@ -125,4 +151,4 @@ export default function LayoutAuthenticated({
) -} \ No newline at end of file +} diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index 8ad606b..9655ed7 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -7,6 +7,7 @@ import LayoutAuthenticated from '../layouts/Authenticated' import SectionMain from '../components/SectionMain' import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton' import BaseIcon from "../components/BaseIcon"; +import BaseButton from "../components/BaseButton"; import { getPageTitle } from '../config' import Link from "next/link"; @@ -16,6 +17,7 @@ import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator'; import { SmartWidget } from '../components/SmartWidget/SmartWidget'; import { useAppDispatch, useAppSelector } from '../stores/hooks'; + const Dashboard = () => { const dispatch = useAppDispatch(); const iconsColor = useAppSelector((state) => state.style.iconsColor); @@ -24,7 +26,6 @@ const Dashboard = () => { const loadingMessage = 'Loading...'; - const [users, setUsers] = React.useState(loadingMessage); const [roles, setRoles] = React.useState(loadingMessage); const [permissions, setPermissions] = React.useState(loadingMessage); @@ -49,7 +50,6 @@ const Dashboard = () => { const [badge_rules, setBadge_rules] = React.useState(loadingMessage); const [trust_adjustments, setTrust_adjustments] = React.useState(loadingMessage); - const [widgetsRole, setWidgetsRole] = React.useState({ role: { value: '', label: '' }, }); @@ -57,21 +57,21 @@ const Dashboard = () => { 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 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}}); } - }); Promise.allSettled(requests).then((results) => { @@ -88,6 +88,7 @@ const Dashboard = () => { async function getWidgets(roleId) { await dispatch(fetchWidgets(roleId)); } + React.useEffect(() => { if (!currentUser) return; loadData().then(); @@ -109,9 +110,16 @@ const Dashboard = () => { - {''} + {isBusinessOwner && hasPermission(currentUser, 'CREATE_BUSINESSES') && ( + + )} {hasPermission(currentUser, 'CREATE_ROLES') && { {!!rolesWidgets.length &&
}
+ {isBusinessOwner && myBusiness && ( + +
+
+
+
+ My Listing +
+
+ {myBusiness.name} +
+
Edit Listing
+
+
+ +
+
+
+ + )} - - {hasPermission(currentUser, 'READ_USERS') && + {hasPermission(currentUser, 'READ_LEADS') && +
+
+
+
+ {isBusinessOwner ? 'Service Requests' : 'Leads'} +
+
+ {leads} +
+
+
+ +
+
+
+ } + + {hasPermission(currentUser, 'READ_REVIEWS') && +
+
+
+
+ Reviews +
+
+ {reviews} +
+
+
+ +
+
+
+ } + + {!isBusinessOwner && hasPermission(currentUser, 'READ_USERS') &&
@@ -177,8 +264,6 @@ const Dashboard = () => { w="w-16" h="h-16" size={48} - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore path={icon.mdiAccountGroup || icon.mdiTable} />
@@ -186,7 +271,7 @@ const Dashboard = () => {
} - {hasPermission(currentUser, 'READ_ROLES') && + {!isBusinessOwner && hasPermission(currentUser, 'READ_ROLES') &&
@@ -205,8 +290,6 @@ const Dashboard = () => { w="w-16" h="h-16" size={48} - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore path={icon.mdiShieldAccountVariantOutline || icon.mdiTable} />
@@ -214,7 +297,7 @@ const Dashboard = () => { } - {hasPermission(currentUser, 'READ_PERMISSIONS') && + {!isBusinessOwner && hasPermission(currentUser, 'READ_PERMISSIONS') &&
@@ -233,8 +316,6 @@ const Dashboard = () => { w="w-16" h="h-16" size={48} - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore path={icon.mdiShieldAccountOutline || icon.mdiTable} />
@@ -242,7 +323,7 @@ const Dashboard = () => { } - {hasPermission(currentUser, 'READ_REFRESH_TOKENS') && + {!isBusinessOwner && hasPermission(currentUser, 'READ_REFRESH_TOKENS') &&
@@ -261,8 +342,6 @@ const Dashboard = () => { w="w-16" h="h-16" size={48} - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore path={'mdiLock' in icon ? icon['mdiLock' as keyof typeof icon] : icon.mdiTable || icon.mdiTable} />
@@ -270,7 +349,7 @@ const Dashboard = () => { } - {hasPermission(currentUser, 'READ_CATEGORIES') && + {!isBusinessOwner && hasPermission(currentUser, 'READ_CATEGORIES') &&
@@ -289,8 +368,6 @@ const Dashboard = () => { w="w-16" h="h-16" size={48} - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore path={'mdiShape' in icon ? icon['mdiShape' as keyof typeof icon] : icon.mdiTable || icon.mdiTable} />
@@ -298,7 +375,7 @@ const Dashboard = () => { } - {hasPermission(currentUser, 'READ_LOCATIONS') && + {!isBusinessOwner && hasPermission(currentUser, 'READ_LOCATIONS') &&
@@ -317,8 +394,6 @@ const Dashboard = () => { w="w-16" h="h-16" size={48} - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore path={'mdiMapMarker' in icon ? icon['mdiMapMarker' as keyof typeof icon] : icon.mdiTable || icon.mdiTable} />
@@ -345,464 +420,12 @@ const Dashboard = () => { w="w-16" h="h-16" size={48} - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore path={'mdiStore' in icon ? icon['mdiStore' as keyof typeof icon] : icon.mdiTable || icon.mdiTable} /> } - - {hasPermission(currentUser, 'READ_BUSINESS_PHOTOS') && -
-
-
-
- Business photos -
-
- {business_photos} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_BUSINESS_CATEGORIES') && -
-
-
-
- Business categories -
-
- {business_categories} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_SERVICE_PRICES') && -
-
-
-
- Service prices -
-
- {service_prices} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_BUSINESS_BADGES') && -
-
-
-
- Business badges -
-
- {business_badges} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_VERIFICATION_SUBMISSIONS') && -
-
-
-
- Verification submissions -
-
- {verification_submissions} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_VERIFICATION_EVIDENCES') && -
-
-
-
- Verification evidences -
-
- {verification_evidences} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_LEADS') && -
-
-
-
- Leads -
-
- {leads} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_LEAD_PHOTOS') && -
-
-
-
- Lead photos -
-
- {lead_photos} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_LEAD_MATCHES') && -
-
-
-
- Lead matches -
-
- {lead_matches} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_MESSAGES') && -
-
-
-
- Messages -
-
- {messages} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_LEAD_EVENTS') && -
-
-
-
- Lead events -
-
- {lead_events} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_REVIEWS') && -
-
-
-
- Reviews -
-
- {reviews} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_DISPUTES') && -
-
-
-
- Disputes -
-
- {disputes} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_AUDIT_LOGS') && -
-
-
-
- Audit logs -
-
- {audit_logs} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_BADGE_RULES') && -
-
-
-
- Badge rules -
-
- {badge_rules} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_TRUST_ADJUSTMENTS') && -
-
-
-
- Trust adjustments -
-
- {trust_adjustments} -
-
-
- -
-
-
- } - -
@@ -813,4 +436,4 @@ Dashboard.getLayout = function getLayout(page: ReactElement) { return {page} } -export default Dashboard +export default Dashboard \ No newline at end of file diff --git a/frontend/src/pages/forgot.tsx b/frontend/src/pages/forgot.tsx index 39b0193..83b1f2c 100644 --- a/frontend/src/pages/forgot.tsx +++ b/frontend/src/pages/forgot.tsx @@ -1,79 +1,151 @@ -import React from 'react'; + +import React, { useEffect, useState } from 'react'; import type { ReactElement } from 'react'; import { ToastContainer, toast } from 'react-toastify'; import Head from 'next/head'; import BaseButton from '../components/BaseButton'; import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; +import BaseIcon from "../components/BaseIcon"; +import { mdiShieldCheck } from '@mdi/js'; import LayoutGuest from '../layouts/Guest'; import { Field, Form, Formik } from 'formik'; import FormField from '../components/FormField'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; import { useRouter } from 'next/router'; import { getPageTitle } from '../config'; +import Link from 'next/link'; import axios from "axios"; +import { getPexelsImage, getPexelsVideo } from '../helpers/pexels' export default function Forgot() { const [loading, setLoading] = React.useState(false) const router = useRouter(); - const notify = (type, msg) => toast( msg, {type}); + const notify = (type, msg) => toast(msg, { type }); + + const [illustrationImage, setIllustrationImage] = useState({ + src: undefined, + photographer: undefined, + photographer_url: undefined, + }) + const [illustrationVideo, setIllustrationVideo] = useState({ video_files: [] }) + const [contentType, setContentType] = useState('image'); + + useEffect(() => { + async function fetchData() { + const image = await getPexelsImage() + const video = await getPexelsVideo() + setIllustrationImage(image); + setIllustrationVideo(video); + } + fetchData(); + }, []); const handleSubmit = async (value) => { setLoading(true) try { - const { data: response } = await axios.post('/auth/send-password-reset-email', value); + await axios.post('/auth/send-password-reset-email', value); setLoading(false) - notify('success', 'Please check your email for verification link'); + notify('success', 'Please check your email for reset link'); setTimeout(async () => { await router.push('/login') }, 3000) } catch (error) { setLoading(false) console.log('error: ', error) - notify('error', 'Something was wrong. Try again') + notify('error', 'Something went wrong. Try again') } }; + const imageBlock = (image) => ( +
+
+
+

Secure Your Account.

+

+ We'll help you get back into your account safely and securely. +

+
+
+ + Photo by {image?.photographer} on Pexels + +
+
+ ) + return ( - <> +
- {getPageTitle('Login')} + {getPageTitle('Forgot Password')} - - - handleSubmit(values)} - > -
- - - +
+ {imageBlock(illustrationImage)} + +
+
+ {/* Branding */} +
+ +
+ +
+ + Crafted Network + + +

Forgot Password?

+

Enter your email and we'll send you a link to reset your password.

+
- + + handleSubmit(values)} + > + + + + - - - - - - - - +
+ +
+ +
+ + Back to Login + +
+ + + + +
+ © 2026 Crafted Network™. All rights reserved.
+ Privacy Policy +
+
+
+
- +
); } diff --git a/frontend/src/pages/leads/leads-edit.tsx b/frontend/src/pages/leads/leads-edit.tsx index f54afc7..db8bf69 100644 --- a/frontend/src/pages/leads/leads-edit.tsx +++ b/frontend/src/pages/leads/leads-edit.tsx @@ -1,9 +1,8 @@ -import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js' +import { mdiChartTimelineVariant } from '@mdi/js' import Head from 'next/head' import React, { ReactElement, useEffect, useState } from 'react' import DatePicker from "react-datepicker"; import "react-datepicker/dist/react-datepicker.css"; -import dayjs from "dayjs"; import CardBox from '../../components/CardBox' import LayoutAuthenticated from '../../layouts/Authenticated' @@ -16,562 +15,41 @@ import FormField from '../../components/FormField' import BaseDivider from '../../components/BaseDivider' import BaseButtons from '../../components/BaseButtons' import BaseButton from '../../components/BaseButton' -import FormCheckRadio from '../../components/FormCheckRadio' -import FormCheckRadioGroup from '../../components/FormCheckRadioGroup' -import FormFilePicker from '../../components/FormFilePicker' -import FormImagePicker from '../../components/FormImagePicker' import { SelectField } from "../../components/SelectField"; -import { SelectFieldMany } from "../../components/SelectFieldMany"; -import { SwitchField } from '../../components/SwitchField' -import {RichTextField} from "../../components/RichTextField"; import { update, fetch } from '../../stores/leads/leadsSlice' import { useAppDispatch, useAppSelector } from '../../stores/hooks' import { useRouter } from 'next/router' -import {saveFile} from "../../helpers/fileSaver"; -import dataFormatter from '../../helpers/dataFormatter'; -import ImageField from "../../components/ImageField"; const EditLeadsPage = () => { const router = useRouter() const dispatch = useAppDispatch() + + const { currentUser } = useAppSelector((state) => state.auth); + const isBusinessOwner = currentUser?.app_role?.name === 'Verified Business Owner'; + const initVals = { - - - - - - - - - - - - - - - - - - - - - - - - user: null, - - - - - - - - - - - - - - - - - - - - - - - - - - - category: null, - - - - - - 'keyword': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + keyword: '', description: '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - urgency: '', - - - - - - - - - - - - - - - - - - - - - - - - - - - status: '', - - - - - - - - - - - - 'contact_name': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - 'contact_phone': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - 'contact_email': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + contact_name: '', + contact_phone: '', + contact_email: '', address: '', - - - - - - - - - - - - - - - - - - - - - - - - - - 'city': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - 'state': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - 'zip': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - 'lat': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - 'lng': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + city: '', + state: '', + zip: '', + lat: '', + lng: '', inferred_tags_json: '', - - - - - - - - - - - - - - - - - - - - - - - - - - 'tenant_key': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + tenant_key: '', created_at_ts: new Date(), - - - - - - - - - - - - - - - - - - - - - - - - - - - updated_at_ts: new Date(), - - - - - - - - - - - - - - - - - } const [initialValues, setInitialValues] = useState(initVals) @@ -581,21 +59,21 @@ const EditLeadsPage = () => { const { id } = router.query useEffect(() => { - dispatch(fetch({ id: id })) - }, [id]) - - useEffect(() => { - if (typeof leads === 'object') { - setInitialValues(leads) + if (id) { + dispatch(fetch({ id: id })) } - }, [leads]) + }, [id, dispatch]) useEffect(() => { - if (typeof leads === 'object') { - const newInitialVal = {...initVals}; - Object.keys(initVals).forEach(el => newInitialVal[el] = (leads)[el]) - setInitialValues(newInitialVal); - } + if (typeof leads === 'object' && leads !== null) { + const newInitialVal = {...initVals}; + Object.keys(initVals).forEach(el => { + if (leads[el] !== undefined) { + newInitialVal[el] = leads[el] + } + }) + setInitialValues(newInitialVal); + } }, [leads]) const handleSubmit = async (data) => { @@ -619,28 +97,8 @@ const EditLeadsPage = () => { onSubmit={(values) => handleSubmit(values)} >
- - - - - - - - - - - - - - - - - - - - - - + {!isBusinessOwner && ( + <> { component={SelectField} options={initialValues.user} itemRef={'users'} - - showField={'firstName'} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - { component={SelectField} options={initialValues.category} itemRef={'categories'} - - - - - - - - - - showField={'name'} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - > - - - - - - - - - - - - @@ -810,159 +129,37 @@ const EditLeadsPage = () => { placeholder="Keyword" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + )} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + {!isBusinessOwner && ( + <> @@ -971,35 +168,7 @@ const EditLeadsPage = () => { placeholder="ContactName" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1008,35 +177,7 @@ const EditLeadsPage = () => { placeholder="ContactPhone" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1045,67 +186,11 @@ const EditLeadsPage = () => { placeholder="ContactEmail" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1114,35 +199,7 @@ const EditLeadsPage = () => { placeholder="City" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1151,35 +208,7 @@ const EditLeadsPage = () => { placeholder="State" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1188,190 +217,7 @@ const EditLeadsPage = () => { placeholder="ZIP" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1379,42 +225,12 @@ const EditLeadsPage = () => { dateFormat="yyyy-MM-dd hh:mm" showTimeSelect selected={initialValues.created_at_ts ? - new Date( - dayjs(initialValues.created_at_ts).format('YYYY-MM-DD hh:mm'), - ) : null + new Date(initialValues.created_at_ts) : null } onChange={(date) => setInitialValues({...initialValues, 'created_at_ts': date})} /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1422,31 +238,13 @@ const EditLeadsPage = () => { dateFormat="yyyy-MM-dd hh:mm" showTimeSelect selected={initialValues.updated_at_ts ? - new Date( - dayjs(initialValues.updated_at_ts).format('YYYY-MM-DD hh:mm'), - ) : null + new Date(initialValues.updated_at_ts) : null } onChange={(date) => setInitialValues({...initialValues, 'updated_at_ts': date})} /> - - - - - - - - - - - - - - - - - - + + )} @@ -1465,13 +263,11 @@ const EditLeadsPage = () => { EditLeadsPage.getLayout = function getLayout(page: ReactElement) { return ( {page} ) } -export default EditLeadsPage +export default EditLeadsPage \ No newline at end of file diff --git a/frontend/src/pages/leads/leads-view.tsx b/frontend/src/pages/leads/leads-view.tsx index 569b7a6..10b90b4 100644 --- a/frontend/src/pages/leads/leads-view.tsx +++ b/frontend/src/pages/leads/leads-view.tsx @@ -6,9 +6,7 @@ import dayjs from "dayjs"; import {useAppDispatch, useAppSelector} from "../../stores/hooks"; import {useRouter} from "next/router"; import { fetch } from '../../stores/leads/leadsSlice' -import {saveFile} from "../../helpers/fileSaver"; import dataFormatter from '../../helpers/dataFormatter'; -import ImageField from "../../components/ImageField"; import LayoutAuthenticated from "../../layouts/Authenticated"; import {getPageTitle} from "../../config"; import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton"; @@ -16,8 +14,7 @@ import SectionMain from "../../components/SectionMain"; import CardBox from "../../components/CardBox"; import BaseButton from "../../components/BaseButton"; import BaseDivider from "../../components/BaseDivider"; -import {mdiChartTimelineVariant} from "@mdi/js"; -import {SwitchField} from "../../components/SwitchField"; +import {mdiChartTimelineVariant, mdiMessageReply} from "@mdi/js"; import FormField from "../../components/FormField"; @@ -25,1315 +22,142 @@ const LeadsView = () => { const router = useRouter() const dispatch = useAppDispatch() const { leads } = useAppSelector((state) => state.leads) - + const { currentUser } = useAppSelector((state) => state.auth); + const isBusinessOwner = currentUser?.app_role?.name === 'Verified Business Owner'; const { id } = router.query; - function removeLastCharacter(str) { - console.log(str,`str`) - return str.slice(0, -1); - } - useEffect(() => { - dispatch(fetch({ id })); + if (id) { + dispatch(fetch({ id })); + } }, [dispatch, id]); return ( <> - {getPageTitle('View leads')} + {getPageTitle('View Service Request')} - - + +
+ {isBusinessOwner && ( + + )} + +
- +
+
+

User

+

{leads?.user?.firstName} {leads?.user?.lastName}

+
- - - - - - - - - - - - - - - - - - - - -
-

User

- - -

{leads?.user?.firstName ?? 'No data'}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

Category

- - - - - - - - - - +
+

Category

{leads?.category?.name ?? 'No data'}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
+ +
+

Keyword

+

{leads?.keyword}

+
+ +
+

Urgency

+

{leads?.urgency ?? 'No data'}

+
+ +
+

Status

+

{leads?.status ?? 'No data'}

+
- - - - - - - - - - -
-

Keyword

-

{leads?.keyword}

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -