diff --git a/502.html b/502.html index 940ac59..479e97e 100644 --- a/502.html +++ b/502.html @@ -129,7 +129,7 @@

The application is currently launching. The page will automatically refresh once site is available.

-

Crafted Network

+

Fix It Local

Trust-first service directory with verification, transparent pricing, AI-style matching, and request tracking.

diff --git a/README.md b/README.md index 982e464..f95f064 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Crafted Network +# Fix It Local ## This project was generated by [Flatlogic Platform](https://flatlogic.com). diff --git a/backend/README.md b/backend/README.md index 4cbc541..7117b49 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,5 +1,5 @@ -#Crafted Network - template backend, +#Fix It Local - template backend, #### Run App on local machine: diff --git a/backend/package.json b/backend/package.json index a534629..d41e65c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/config.js b/backend/src/config.js index c8c45c4..dbb27e2 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -37,7 +37,7 @@ const config = { }, uploadDir: os.tmpdir(), email: { - from: 'Crafted Network ', + from: 'Fix It Local ', host: 'email-smtp.us-east-1.amazonaws.com', port: 587, auth: { diff --git a/backend/src/db/api/business_badges.js b/backend/src/db/api/business_badges.js index 2ff699b..70ed4bf 100644 --- a/backend/src/db/api/business_badges.js +++ b/backend/src/db/api/business_badges.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 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, })); } - - -}; - +}; \ No newline at end of file diff --git a/backend/src/db/api/business_categories.js b/backend/src/db/api/business_categories.js index d7b767e..43dba3f 100644 --- a/backend/src/db/api/business_categories.js +++ b/backend/src/db/api/business_categories.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 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, })); } - - -}; - +}; \ No newline at end of file diff --git a/backend/src/db/api/business_photos.js b/backend/src/db/api/business_photos.js index 7b7165a..88c194e 100644 --- a/backend/src/db/api/business_photos.js +++ b/backend/src/db/api/business_photos.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 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, })); } - - -}; - +}; \ No newline at end of file diff --git a/backend/src/db/api/businesses.js b/backend/src/db/api/businesses.js index 06e817b..bad14e3 100644 --- a/backend/src/db/api/businesses.js +++ b/backend/src/db/api/businesses.js @@ -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; } diff --git a/backend/src/db/api/lead_matches.js b/backend/src/db/api/lead_matches.js index 810dcd0..9b9cff0 100644 --- a/backend/src/db/api/lead_matches.js +++ b/backend/src/db/api/lead_matches.js @@ -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') { diff --git a/backend/src/db/api/leads.js b/backend/src/db/api/leads.js index fcda772..5cc1742 100644 --- a/backend/src/db/api/leads.js +++ b/backend/src/db/api/leads.js @@ -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 }); diff --git a/backend/src/db/api/messages.js b/backend/src/db/api/messages.js index 84bfc45..65758c8 100644 --- a/backend/src/db/api/messages.js +++ b/backend/src/db/api/messages.js @@ -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']], diff --git a/backend/src/db/api/reviews.js b/backend/src/db/api/reviews.js index 65c988a..905415d 100644 --- a/backend/src/db/api/reviews.js +++ b/backend/src/db/api/reviews.js @@ -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 { } -}; \ No newline at end of file +}; diff --git a/backend/src/db/api/service_prices.js b/backend/src/db/api/service_prices.js index bd2cab2..15f7fd2 100644 --- a/backend/src/db/api/service_prices.js +++ b/backend/src/db/api/service_prices.js @@ -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 { } -}; - +}; \ No newline at end of file diff --git a/backend/src/db/api/users.js b/backend/src/db/api/users.js index 2405ed0..595fc97 100644 --- a/backend/src/db/api/users.js +++ b/backend/src/db/api/users.js @@ -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 { -}; - +}; \ No newline at end of file diff --git a/backend/src/db/api/verification_evidences.js b/backend/src/db/api/verification_evidences.js index 4efd9d3..e9e316a 100644 --- a/backend/src/db/api/verification_evidences.js +++ b/backend/src/db/api/verification_evidences.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 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, })); } - - -}; - +}; \ No newline at end of file diff --git a/backend/src/db/api/verification_submissions.js b/backend/src/db/api/verification_submissions.js index 95f5cfc..c632782 100644 --- a/backend/src/db/api/verification_submissions.js +++ b/backend/src/db/api/verification_submissions.js @@ -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 { } -}; - +}; \ No newline at end of file diff --git a/backend/src/db/migrations/20260218021550-add-business-id-to-users.js b/backend/src/db/migrations/20260218021550-add-business-id-to-users.js new file mode 100644 index 0000000..1e708a3 --- /dev/null +++ b/backend/src/db/migrations/20260218021550-add-business-id-to-users.js @@ -0,0 +1,24 @@ + +module.exports = { + /** + * @param {import("sequelize").QueryInterface} queryInterface + * @param {import("sequelize").Sequelize} Sequelize + * @returns {Promise} + */ + 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'); + }, +}; diff --git a/backend/src/db/migrations/20260218022100-update-moderator-permissions.js b/backend/src/db/migrations/20260218022100-update-moderator-permissions.js new file mode 100644 index 0000000..d0844f1 --- /dev/null +++ b/backend/src/db/migrations/20260218022100-update-moderator-permissions.js @@ -0,0 +1,55 @@ +module.exports = { + /** + * @param {import("sequelize").QueryInterface} queryInterface + * @param {import("sequelize").Sequelize} Sequelize + * @returns {Promise} + */ + 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 + }, +}; \ No newline at end of file diff --git a/backend/src/db/migrations/20260218030000-create-listing-events.js b/backend/src/db/migrations/20260218030000-create-listing-events.js new file mode 100644 index 0000000..2f456a5 --- /dev/null +++ b/backend/src/db/migrations/20260218030000-create-listing-events.js @@ -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'); + }, +}; diff --git a/backend/src/db/migrations/20260218030001-create-plans-and-billing.js b/backend/src/db/migrations/20260218030001-create-plans-and-billing.js new file mode 100644 index 0000000..4888512 --- /dev/null +++ b/backend/src/db/migrations/20260218030001-create-plans-and-billing.js @@ -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'); + }, +}; diff --git a/backend/src/db/migrations/20260218040000-fix-client-role.js b/backend/src/db/migrations/20260218040000-fix-client-role.js new file mode 100644 index 0000000..3b5ab2a --- /dev/null +++ b/backend/src/db/migrations/20260218040000-fix-client-role.js @@ -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) {} +}; diff --git a/backend/src/db/migrations/20260218040500-add-create-business-permission-to-vbo.js b/backend/src/db/migrations/20260218040500-add-create-business-permission-to-vbo.js new file mode 100644 index 0000000..f50ff18 --- /dev/null +++ b/backend/src/db/migrations/20260218040500-add-create-business-permission-to-vbo.js @@ -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) {} +}; diff --git a/backend/src/db/models/businesses.js b/backend/src/db/models/businesses.js index f27dcc6..e994d93 100644 --- a/backend/src/db/models/businesses.js +++ b/backend/src/db/models/businesses.js @@ -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; -}; \ No newline at end of file +}; diff --git a/backend/src/db/models/listing_events.js b/backend/src/db/models/listing_events.js new file mode 100644 index 0000000..7e0618f --- /dev/null +++ b/backend/src/db/models/listing_events.js @@ -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; +}; \ No newline at end of file diff --git a/backend/src/db/models/plans.js b/backend/src/db/models/plans.js new file mode 100644 index 0000000..390461e --- /dev/null +++ b/backend/src/db/models/plans.js @@ -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; +}; diff --git a/backend/src/db/models/users.js b/backend/src/db/models/users.js index b01420a..1ce68c4 100644 --- a/backend/src/db/models/users.js +++ b/backend/src/db/models/users.js @@ -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; -} - +} \ No newline at end of file diff --git a/backend/src/index.js b/backend/src/index.js index 5e25b47..d4d480a 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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); diff --git a/backend/src/routes/businesses.js b/backend/src/routes/businesses.js index a6c5322..255bc44 100644 --- a/backend/src/routes/businesses.js +++ b/backend/src/routes/businesses.js @@ -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; \ No newline at end of file diff --git a/backend/src/routes/dashboard.js b/backend/src/routes/dashboard.js new file mode 100644 index 0000000..74bff3d --- /dev/null +++ b/backend/src/routes/dashboard.js @@ -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; diff --git a/backend/src/services/businesses.js b/backend/src/services/businesses.js index eae382b..9c8cd5b 100644 --- a/backend/src/services/businesses.js +++ b/backend/src/services/businesses.js @@ -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 { } -}; \ No newline at end of file +}; diff --git a/backend/src/services/dashboard.js b/backend/src/services/dashboard.js new file mode 100644 index 0000000..95eac82 --- /dev/null +++ b/backend/src/services/dashboard.js @@ -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) + }; + } +}; \ No newline at end of file diff --git a/backend/src/services/leads.js b/backend/src/services/leads.js index cdb8cec..ffb3a99 100644 --- a/backend/src/services/leads.js +++ b/backend/src/services/leads.js @@ -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; } } -}; \ No newline at end of file +}; diff --git a/backend/src/services/messages.js b/backend/src/services/messages.js index c2d5dd8..fb243a4 100644 --- a/backend/src/services/messages.js +++ b/backend/src/services/messages.js @@ -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 { } -}; - - +}; \ No newline at end of file diff --git a/backend/src/services/notifications/list.js b/backend/src/services/notifications/list.js index 9382c27..cb05dff 100644 --- a/backend/src/services/notifications/list.js +++ b/backend/src/services/notifications/list.js @@ -1,6 +1,6 @@ const errors = { app: { - title: 'Crafted Network', + title: 'Fix It Local', }, auth: { diff --git a/backend/src/services/reviews.js b/backend/src/services/reviews.js index 9fbb738..b6594ee 100644 --- a/backend/src/services/reviews.js +++ b/backend/src/services/reviews.js @@ -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; } } -}; \ No newline at end of file +}; diff --git a/backend/src/services/roles.js b/backend/src/services/roles.js index d2d8595..2097b73 100644 --- a/backend/src/services/roles.js +++ b/backend/src/services/roles.js @@ -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]; - - } - - -}; - - +}; \ No newline at end of file diff --git a/backend/src/services/service_prices.js b/backend/src/services/service_prices.js index 78cc6d6..8e9fc14 100644 --- a/backend/src/services/service_prices.js +++ b/backend/src/services/service_prices.js @@ -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 { } -}; - - +}; \ No newline at end of file diff --git a/backend/src/services/verification_submissions.js b/backend/src/services/verification_submissions.js index b6bf87a..85d16c2 100644 --- a/backend/src/services/verification_submissions.js +++ b/backend/src/services/verification_submissions.js @@ -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 { } -}; - - +}; \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md index 189e8db..269d0f7 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,4 +1,4 @@ -# Crafted Network +# Fix It Local ## This project was generated by Flatlogic Platform. ## Install diff --git a/frontend/src/colors.ts b/frontend/src/colors.ts index 71e116a..8d52301 100644 --- a/frontend/src/colors.ts +++ b/frontend/src/colors.ts @@ -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(' ') -} +} \ No newline at end of file diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index 63d5823..330a2ad 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -39,7 +39,7 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props >
- Crafted Network + Fix It Local
diff --git a/frontend/src/components/AsideMenuList.tsx b/frontend/src/components/AsideMenuList.tsx index 9e33ea1..c5cacf7 100644 --- a/frontend/src/components/AsideMenuList.tsx +++ b/frontend/src/components/AsideMenuList.tsx @@ -18,7 +18,11 @@ export default function AsideMenuList({ menu, isDropdownList = false, className return (
    {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 })}
) -} +} \ No newline at end of file diff --git a/frontend/src/components/ProgressBar.tsx b/frontend/src/components/ProgressBar.tsx new file mode 100644 index 0000000..83b32a8 --- /dev/null +++ b/frontend/src/components/ProgressBar.tsx @@ -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 = { + 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 ( +
+ {label && ( +
+ {label} + {percentage}% +
+ )} +
+
+
+
+ ); +}; + +export default ProgressBar; \ No newline at end of file diff --git a/frontend/src/config.ts b/frontend/src/config.ts index a9783c8..8f78486 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -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}` diff --git a/frontend/src/interfaces/index.ts b/frontend/src/interfaces/index.ts index 0c7dd74..c8cf658 100644 --- a/frontend/src/interfaces/index.ts +++ b/frontend/src/interfaces/index.ts @@ -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' diff --git a/frontend/src/layouts/Guest.tsx b/frontend/src/layouts/Guest.tsx index e1c58c3..2ab8a95 100644 --- a/frontend/src/layouts/Guest.tsx +++ b/frontend/src/layouts/Guest.tsx @@ -30,7 +30,7 @@ export default function LayoutGuest({ children }: Props) {
- Crafted Network + Fix It Local
Find Help @@ -107,7 +107,7 @@ export default function LayoutGuest({ children }: Props) {
- © 2026 Crafted Network™. Built with Trust & Transparency. + © 2026 Fix It Local™. Built with Trust & Transparency.
diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index ffeecab..87afed6 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -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 \ No newline at end of file diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 64b3262..b82b0e5 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -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" diff --git a/frontend/src/pages/billing.tsx b/frontend/src/pages/billing.tsx new file mode 100644 index 0000000..90e0621 --- /dev/null +++ b/frontend/src/pages/billing.tsx @@ -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 ( + +
+
+

{plan.name}

+ {isCurrent && ( + CURRENT PLAN + )} +
+
+ ${plan.price} + /mo +
+ +
+ onUpgrade(plan)} + /> +
+ ); +}; + +const BillingPage = () => { + const { currentUser } = useAppSelector((state) => state.auth); + const [plans, setPlans] = useState([]); + const [business, setBusiness] = useState(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 ( + +
+ +
+
+ ); + } + + return ( + <> + + {getPageTitle('Billing & Plans')} + + + + + {business && ( +
+
+
Current Plan
+
{business.plan?.name || 'Basic'}
+
+ Your plan renews on {moment(business.renewal_date).format('MMMM D, YYYY')} +
+
+
+ + +
+
+ )} + +
+ {plans.map((plan: any) => ( + + ))} +
+ + +
+
+ +
+
+

Need a custom plan?

+

Contact our sales team for enterprise solutions tailored to your specific needs.

+
+ +
+
+
+ + ); +}; + +BillingPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default BillingPage; diff --git a/frontend/src/pages/businesses/businesses-list.tsx b/frontend/src/pages/businesses/businesses-list.tsx index 9e290dc..27f09e9 100644 --- a/frontend/src/pages/businesses/businesses-list.tsx +++ b/frontend/src/pages/businesses/businesses-list.tsx @@ -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 ( + +
+

Redirecting to create your business profile...

+
+
+ ) + } + return ( <> @@ -167,4 +194,4 @@ BusinessesTablesPage.getLayout = function getLayout(page: ReactElement) { ) } -export default BusinessesTablesPage +export default BusinessesTablesPage \ No newline at end of file diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index 9655ed7..6776c30 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -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) => ( + +
+
+ +
+
+
{count}
+
{label}
+
+
+ +); + +const PipelineStat = ({ label, count, color }: any) => ( +
+
{count}
+
{label}
+
+); + +const BusinessDashboardView = ({ metrics, currentUser }: any) => { + if (metrics.no_business) { + return ( + +
+ +
+

No active business found

+

Create your first listing to start receiving leads and managing your beauty business with AI-powered tools.

+ +
+ ); + } + + const { action_queue, pipeline, recentMessages, performance, healthScore, businesses } = metrics; + const business = businesses[0]; + + return ( +
+ {/* Action Queue */} +
+ + + + +
+ +
+ {/* Lead Pipeline */} + + +
+ {pipeline.winRate30d.toFixed(1)}% Conversion +
+
+
+ + + + + +
+
+ + {/* Listing Health */} + + +
+ 80 ? 'green' : healthScore > 50 ? 'yellow' : 'red'} /> +
+
Improve visibility:
+ {action_queue.missingFields.slice(0, 3).map((field: string) => ( +
+ + Add {field.replace('_json', '').replace('_', ' ')} +
+ ))} + + Enhance Profile → + +
+
+
+
+ +
+ {/* Recent Messages */} + + +
+ {recentMessages.length > 0 ? recentMessages.map((msg: any) => ( +
+
+ {msg.sender_user?.firstName?.[0] || 'U'} +
+
+
+ {msg.sender_user?.firstName} {msg.sender_user?.lastName} + {moment(msg.createdAt).fromNow()} +
+

"{msg.body}"

+
+
+ +
+
+ )) : ( +
No recent messages yet
+ )} +
+ + + +
+ + {/* Performance & Billing */} +
+ + +
+
+
Views
+
{performance.views30d}
+
+ + 7d: {performance.views7d} +
+
+
+
Interactions
+
{performance.calls30d + performance.website30d}
+
+ + 7d: {performance.calls7d + performance.website7d} +
+
+
+
+
+ Conversion Rate + {performance.conversionRate.toFixed(1)}% +
+
+
+ + +
+
+
+
+
Tier Plan
+
{business.plan?.name || 'Professional'}
+
+
+ +
+
+
+
Renewal Date
+
{moment(business.renewal_date).format('MMMM Do, YYYY')}
+
+
+ +
+
+
+
+
+
+ ); +}; + 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(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({}); + + 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 ( + +
+
+ +
+
Curating your experience...
+
+
+ ); + } - React.useEffect(() => { - if (!currentUser || !widgetsRole?.role?.value) return; - getWidgets(widgetsRole?.role?.value || '').then(); - }, [widgetsRole?.role?.value]); - return ( <> - - {getPageTitle('Overview')} - + {getPageTitle('Beauty Studio Dashboard')} - {isBusinessOwner && hasPermission(currentUser, 'CREATE_BUSINESSES') && ( - + icon={icon.mdiStarFourPoints} + title={isBusinessOwner ? 'Beauty Studio Hub' : 'Network System Pulse'} + main + className="mb-8" + > + {isBusinessOwner && ( +
+ + +
)}
- - {hasPermission(currentUser, 'CREATE_ROLES') && } - {!!rolesWidgets.length && - hasPermission(currentUser, 'CREATE_ROLES') && ( -

- {`${widgetsRole?.role?.label || 'Users'}'s widgets`} -

- )} -
- {(isFetchingQuery || loading) && ( -
- {' '} - Loading widgets... -
- )} - - { rolesWidgets && - rolesWidgets.map((widget) => ( - - ))} -
- - {!!rolesWidgets.length &&
} - -
- {isBusinessOwner && myBusiness && ( - -
-
-
-
- My Listing -
-
- {myBusiness.name} -
-
Edit Listing
-
-
- -
-
+ {isBusinessOwner ? ( + + ) : ( +
+ {Object.keys(counts).map(entity => ( + + +
+
+
{entity.replace('_', ' ')}
+
{counts[entity]}
+
+
+ +
- - )} - - {hasPermission(currentUser, 'READ_LEADS') && -
-
-
-
- {isBusinessOwner ? 'Service Requests' : 'Leads'} -
-
- {leads} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_REVIEWS') && -
-
-
-
- Reviews -
-
- {reviews} -
-
-
- -
-
-
- } - - {!isBusinessOwner && hasPermission(currentUser, 'READ_USERS') && -
-
-
-
- Users -
-
- {users} -
-
-
- -
-
-
- } - - {!isBusinessOwner && hasPermission(currentUser, 'READ_ROLES') && -
-
-
-
- Roles -
-
- {roles} -
-
-
- -
-
-
- } - - {!isBusinessOwner && hasPermission(currentUser, 'READ_PERMISSIONS') && -
-
-
-
- Permissions -
-
- {permissions} -
-
-
- -
-
-
- } - - {!isBusinessOwner && hasPermission(currentUser, 'READ_REFRESH_TOKENS') && -
-
-
-
- Refresh tokens -
-
- {refresh_tokens} -
-
-
- -
-
-
- } - - {!isBusinessOwner && hasPermission(currentUser, 'READ_CATEGORIES') && -
-
-
-
- Categories -
-
- {categories} -
-
-
- -
-
-
- } - - {!isBusinessOwner && hasPermission(currentUser, 'READ_LOCATIONS') && -
-
-
-
- Locations -
-
- {locations} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_BUSINESSES') && -
-
-
-
- Businesses -
-
- {businesses} -
-
-
- -
-
-
- } -
+ + + ))} +
+ )} ) @@ -436,4 +324,4 @@ Dashboard.getLayout = function getLayout(page: ReactElement) { return {page} } -export default Dashboard \ No newline at end of file +export default Dashboard; \ No newline at end of file diff --git a/frontend/src/pages/forgot.tsx b/frontend/src/pages/forgot.tsx index 83b1f2c..41f239f 100644 --- a/frontend/src/pages/forgot.tsx +++ b/frontend/src/pages/forgot.tsx @@ -96,7 +96,7 @@ export default function Forgot() {
- Crafted Network + Fix It Local

Forgot Password?

@@ -138,7 +138,7 @@ export default function Forgot() {
- © 2026 Crafted Network™. All rights reserved.
+ © 2026 Fix It Local™. All rights reserved.
Privacy Policy
diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index d04935d..2c4f6a4 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -53,7 +53,7 @@ export default function LandingPage() { return (
- Crafted Network™ | 21st Century Service Directory + Fix It Local™ | 21st Century Service Directory diff --git a/frontend/src/pages/leads/leads-view.tsx b/frontend/src/pages/leads/leads-view.tsx index 10b90b4..43984ce 100644 --- a/frontend/src/pages/leads/leads-view.tsx +++ b/frontend/src/pages/leads/leads-view.tsx @@ -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 = () => { />
- -
-
-

User

-

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

-
-
-

Category

-

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

-
- -
-

Keyword

-

{leads?.keyword}

-
- -
-

Urgency

-

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

-
- -
-

Status

-

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

-
-
- - -