From ebc2e004c3117c5549f434866fc2315c2bcb9439 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Fri, 2 May 2025 08:44:54 +0000 Subject: [PATCH] v1.1 --- .gitignore | 5 + app-shell/src/_schema.json | 7 +- backend/src/db/api/conversations.js | 355 +++++++++++++ backend/src/db/api/messages.js | 340 ++++++++++++ backend/src/db/migrations/1746173616791.js | 72 +++ backend/src/db/migrations/1746173773482.js | 49 ++ backend/src/db/migrations/1746173790909.js | 72 +++ backend/src/db/migrations/1746173858091.js | 47 ++ backend/src/db/migrations/1746173878197.js | 49 ++ backend/src/db/migrations/1746173895643.js | 49 ++ backend/src/db/models/conversations.js | 65 +++ backend/src/db/models/messages.js | 69 +++ .../db/seeders/20200430130760-user-roles.js | 60 ++- .../db/seeders/20231127130745-sample-data.js | 208 +++++++- backend/src/db/seeders/20250502081336.js | 87 +++ backend/src/db/seeders/20250502081630.js | 87 +++ backend/src/index.js | 16 + backend/src/routes/conversations.js | 444 ++++++++++++++++ backend/src/routes/messages.js | 450 ++++++++++++++++ backend/src/services/conversations.js | 117 ++++ backend/src/services/messages.js | 114 ++++ backend/src/services/search.js | 4 + frontend/json/runtimeError.json | 1 + .../Conversations/CardConversations.tsx | 139 +++++ .../Conversations/ListConversations.tsx | 116 ++++ .../Conversations/TableConversations.tsx | 500 ++++++++++++++++++ .../configureConversationsCols.tsx | 125 +++++ .../src/components/Messages/CardMessages.tsx | 155 ++++++ .../src/components/Messages/ListMessages.tsx | 118 +++++ .../src/components/Messages/TableMessages.tsx | 484 +++++++++++++++++ .../Messages/configureMessagesCols.tsx | 133 +++++ frontend/src/menuAside.ts | 16 + .../pages/conversations/[conversationsId].tsx | 183 +++++++ .../conversations/conversations-edit.tsx | 181 +++++++ .../conversations/conversations-list.tsx | 178 +++++++ .../pages/conversations/conversations-new.tsx | 132 +++++ .../conversations/conversations-table.tsx | 175 ++++++ .../conversations/conversations-view.tsx | 182 +++++++ frontend/src/pages/dashboard.tsx | 84 ++- frontend/src/pages/messages/[messagesId].tsx | 170 ++++++ frontend/src/pages/messages/messages-edit.tsx | 168 ++++++ frontend/src/pages/messages/messages-list.tsx | 170 ++++++ frontend/src/pages/messages/messages-new.tsx | 132 +++++ .../src/pages/messages/messages-table.tsx | 173 ++++++ frontend/src/pages/messages/messages-view.tsx | 118 +++++ frontend/src/pages/users/users-view.tsx | 49 ++ .../conversations/conversationsSlice.ts | 250 +++++++++ frontend/src/stores/messages/messagesSlice.ts | 252 +++++++++ .../src/stores/messages/messagesSlice.ts.temp | 268 ++++++++++ frontend/src/stores/store.ts | 4 + 50 files changed, 7411 insertions(+), 11 deletions(-) create mode 100644 backend/src/db/api/conversations.js create mode 100644 backend/src/db/api/messages.js create mode 100644 backend/src/db/migrations/1746173616791.js create mode 100644 backend/src/db/migrations/1746173773482.js create mode 100644 backend/src/db/migrations/1746173790909.js create mode 100644 backend/src/db/migrations/1746173858091.js create mode 100644 backend/src/db/migrations/1746173878197.js create mode 100644 backend/src/db/migrations/1746173895643.js create mode 100644 backend/src/db/models/conversations.js create mode 100644 backend/src/db/models/messages.js create mode 100644 backend/src/db/seeders/20250502081336.js create mode 100644 backend/src/db/seeders/20250502081630.js create mode 100644 backend/src/routes/conversations.js create mode 100644 backend/src/routes/messages.js create mode 100644 backend/src/services/conversations.js create mode 100644 backend/src/services/messages.js create mode 100644 frontend/json/runtimeError.json create mode 100644 frontend/src/components/Conversations/CardConversations.tsx create mode 100644 frontend/src/components/Conversations/ListConversations.tsx create mode 100644 frontend/src/components/Conversations/TableConversations.tsx create mode 100644 frontend/src/components/Conversations/configureConversationsCols.tsx create mode 100644 frontend/src/components/Messages/CardMessages.tsx create mode 100644 frontend/src/components/Messages/ListMessages.tsx create mode 100644 frontend/src/components/Messages/TableMessages.tsx create mode 100644 frontend/src/components/Messages/configureMessagesCols.tsx create mode 100644 frontend/src/pages/conversations/[conversationsId].tsx create mode 100644 frontend/src/pages/conversations/conversations-edit.tsx create mode 100644 frontend/src/pages/conversations/conversations-list.tsx create mode 100644 frontend/src/pages/conversations/conversations-new.tsx create mode 100644 frontend/src/pages/conversations/conversations-table.tsx create mode 100644 frontend/src/pages/conversations/conversations-view.tsx create mode 100644 frontend/src/pages/messages/[messagesId].tsx create mode 100644 frontend/src/pages/messages/messages-edit.tsx create mode 100644 frontend/src/pages/messages/messages-list.tsx create mode 100644 frontend/src/pages/messages/messages-new.tsx create mode 100644 frontend/src/pages/messages/messages-table.tsx create mode 100644 frontend/src/pages/messages/messages-view.tsx create mode 100644 frontend/src/stores/conversations/conversationsSlice.ts create mode 100644 frontend/src/stores/messages/messagesSlice.ts create mode 100644 frontend/src/stores/messages/messagesSlice.ts.temp diff --git a/.gitignore b/.gitignore index e427ff3..d0eb167 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ node_modules/ */node_modules/ */build/ + +**/node_modules/ +**/build/ +.DS_Store +.env \ No newline at end of file diff --git a/app-shell/src/_schema.json b/app-shell/src/_schema.json index 35e2991..15c761e 100644 --- a/app-shell/src/_schema.json +++ b/app-shell/src/_schema.json @@ -1,5 +1,4 @@ - - { - "Initial version": "{\"iv\":\"Dn/UUznLNSj+kA/t\",\"encryptedData\":\"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\"}" -} + "Initial version": "{\"iv\":\"Dn/UUznLNSj+kA/t\",\"encryptedData\":\"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\"}", + "v1.1": "{\"iv\":\"z9N5LBZFeA+wx7x6\",\"encryptedData\":\"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\"}" +} \ No newline at end of file diff --git a/backend/src/db/api/conversations.js b/backend/src/db/api/conversations.js new file mode 100644 index 0000000..394297f --- /dev/null +++ b/backend/src/db/api/conversations.js @@ -0,0 +1,355 @@ +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 ConversationsDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const conversations = await db.conversations.create( + { + id: data.id || undefined, + + title: data.title || null, + createdat: data.createdat || null, + updatedat: data.updatedat || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await conversations.setUser(data.user || null, { + transaction, + }); + + return conversations; + } + + 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 conversationsData = data.map((item, index) => ({ + id: item.id || undefined, + + title: item.title || null, + createdat: item.createdat || null, + updatedat: item.updatedat || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const conversations = await db.conversations.bulkCreate(conversationsData, { + transaction, + }); + + // For each item created, replace relation files + + return conversations; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const conversations = await db.conversations.findByPk( + id, + {}, + { transaction }, + ); + + const updatePayload = {}; + + if (data.title !== undefined) updatePayload.title = data.title; + + if (data.createdat !== undefined) updatePayload.createdat = data.createdat; + + if (data.updatedat !== undefined) updatePayload.updatedat = data.updatedat; + + updatePayload.updatedById = currentUser.id; + + await conversations.update(updatePayload, { transaction }); + + if (data.user !== undefined) { + await conversations.setUser( + data.user, + + { transaction }, + ); + } + + return conversations; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const conversations = await db.conversations.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of conversations) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of conversations) { + await record.destroy({ transaction }); + } + }); + + return conversations; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const conversations = await db.conversations.findByPk(id, options); + + await conversations.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await conversations.destroy({ + transaction, + }); + + return conversations; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const conversations = await db.conversations.findOne( + { where }, + { transaction }, + ); + + if (!conversations) { + return conversations; + } + + const output = conversations.get({ plain: true }); + + output.user = await conversations.getUser({ + transaction, + }); + + return output; + } + + 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; + + let include = [ + { + model: db.users, + as: 'user', + + where: filter.user + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.user + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + firstName: { + [Op.or]: filter.user + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.title) { + where = { + ...where, + [Op.and]: Utils.ilike('conversations', 'title', filter.title), + }; + } + + 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, + }, + }; + } + } + + if (filter.updatedatRange) { + const [start, end] = filter.updatedatRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + updatedat: { + ...where.updatedat, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + updatedat: { + ...where.updatedat, + [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, + }, + }; + } + } + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.conversations.findAndCountAll( + queryOptions, + ); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete(query, limit, offset) { + let where = {}; + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('conversations', 'title', query), + ], + }; + } + + const records = await db.conversations.findAll({ + attributes: ['id', 'title'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['title', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.title, + })); + } +}; diff --git a/backend/src/db/api/messages.js b/backend/src/db/api/messages.js new file mode 100644 index 0000000..a660ad0 --- /dev/null +++ b/backend/src/db/api/messages.js @@ -0,0 +1,340 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class MessagesDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const messages = await db.messages.create( + { + id: data.id || undefined, + + content: data.content || null, + sender: data.sender || null, + agentname: data.agentname || null, + createdat: data.createdat || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await messages.setConversation(data.conversation || null, { + transaction, + }); + + return messages; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const messagesData = data.map((item, index) => ({ + id: item.id || undefined, + + content: item.content || null, + sender: item.sender || null, + agentname: item.agentname || null, + createdat: item.createdat || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const messages = await db.messages.bulkCreate(messagesData, { + transaction, + }); + + // For each item created, replace relation files + + return messages; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const messages = await db.messages.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.content !== undefined) updatePayload.content = data.content; + + if (data.sender !== undefined) updatePayload.sender = data.sender; + + if (data.agentname !== undefined) updatePayload.agentname = data.agentname; + + if (data.createdat !== undefined) updatePayload.createdat = data.createdat; + + updatePayload.updatedById = currentUser.id; + + await messages.update(updatePayload, { transaction }); + + if (data.conversation !== undefined) { + await messages.setConversation( + data.conversation, + + { transaction }, + ); + } + + return messages; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const messages = await db.messages.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of messages) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of messages) { + await record.destroy({ transaction }); + } + }); + + return messages; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const messages = await db.messages.findByPk(id, options); + + await messages.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await messages.destroy({ + transaction, + }); + + return messages; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const messages = await db.messages.findOne({ where }, { transaction }); + + if (!messages) { + return messages; + } + + const output = messages.get({ plain: true }); + + output.conversation = await messages.getConversation({ + transaction, + }); + + return output; + } + + 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; + + let include = [ + { + model: db.conversations, + as: 'conversation', + + where: filter.conversation + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.conversation + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + title: { + [Op.or]: filter.conversation + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.content) { + where = { + ...where, + [Op.and]: Utils.ilike('messages', 'content', filter.content), + }; + } + + if (filter.sender) { + where = { + ...where, + [Op.and]: Utils.ilike('messages', 'sender', filter.sender), + }; + } + + if (filter.agentname) { + where = { + ...where, + [Op.and]: Utils.ilike('messages', 'agentname', filter.agentname), + }; + } + + 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, + }, + }; + } + } + + 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, + }, + }; + } + } + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.messages.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete(query, limit, offset) { + let where = {}; + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('messages', 'sender', query), + ], + }; + } + + const records = await db.messages.findAll({ + attributes: ['id', 'sender'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['sender', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.sender, + })); + } +}; diff --git a/backend/src/db/migrations/1746173616791.js b/backend/src/db/migrations/1746173616791.js new file mode 100644 index 0000000..5c4ab61 --- /dev/null +++ b/backend/src/db/migrations/1746173616791.js @@ -0,0 +1,72 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.createTable( + 'conversations', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.dropTable('conversations', { transaction }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/1746173773482.js b/backend/src/db/migrations/1746173773482.js new file mode 100644 index 0000000..40e20c6 --- /dev/null +++ b/backend/src/db/migrations/1746173773482.js @@ -0,0 +1,49 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.addColumn( + 'conversations', + 'updatedat', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.removeColumn('conversations', 'updatedat', { + transaction, + }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/1746173790909.js b/backend/src/db/migrations/1746173790909.js new file mode 100644 index 0000000..85fbc72 --- /dev/null +++ b/backend/src/db/migrations/1746173790909.js @@ -0,0 +1,72 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.createTable( + 'messages', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.dropTable('messages', { transaction }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/1746173858091.js b/backend/src/db/migrations/1746173858091.js new file mode 100644 index 0000000..a323707 --- /dev/null +++ b/backend/src/db/migrations/1746173858091.js @@ -0,0 +1,47 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.addColumn( + 'messages', + 'sender', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.removeColumn('messages', 'sender', { transaction }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/1746173878197.js b/backend/src/db/migrations/1746173878197.js new file mode 100644 index 0000000..fef8002 --- /dev/null +++ b/backend/src/db/migrations/1746173878197.js @@ -0,0 +1,49 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.addColumn( + 'messages', + 'agentname', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.removeColumn('messages', 'agentname', { + transaction, + }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/1746173895643.js b/backend/src/db/migrations/1746173895643.js new file mode 100644 index 0000000..ac84eff --- /dev/null +++ b/backend/src/db/migrations/1746173895643.js @@ -0,0 +1,49 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.addColumn( + 'messages', + 'createdat', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.removeColumn('messages', 'createdat', { + transaction, + }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/models/conversations.js b/backend/src/db/models/conversations.js new file mode 100644 index 0000000..a674b01 --- /dev/null +++ b/backend/src/db/models/conversations.js @@ -0,0 +1,65 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const conversations = sequelize.define( + 'conversations', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + title: { + type: DataTypes.TEXT, + }, + + createdat: { + type: DataTypes.DATE, + }, + + updatedat: { + type: DataTypes.DATE, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + conversations.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.conversations.belongsTo(db.users, { + as: 'user', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + db.conversations.belongsTo(db.users, { + as: 'createdBy', + }); + + db.conversations.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return conversations; +}; diff --git a/backend/src/db/models/messages.js b/backend/src/db/models/messages.js new file mode 100644 index 0000000..ae06c62 --- /dev/null +++ b/backend/src/db/models/messages.js @@ -0,0 +1,69 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const messages = sequelize.define( + 'messages', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + content: { + type: DataTypes.TEXT, + }, + + sender: { + type: DataTypes.TEXT, + }, + + agentname: { + type: DataTypes.TEXT, + }, + + createdat: { + type: DataTypes.DATE, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + messages.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.messages.belongsTo(db.conversations, { + as: 'conversation', + foreignKey: { + name: 'conversationId', + }, + constraints: false, + }); + + db.messages.belongsTo(db.users, { + as: 'createdBy', + }); + + db.messages.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return messages; +}; diff --git a/backend/src/db/seeders/20200430130760-user-roles.js b/backend/src/db/seeders/20200430130760-user-roles.js index 1e7803b..bd41020 100644 --- a/backend/src/db/seeders/20200430130760-user-roles.js +++ b/backend/src/db/seeders/20200430130760-user-roles.js @@ -86,7 +86,15 @@ module.exports = { ]; } - const entities = ['users', 'agents', 'roles', 'permissions', ,]; + const entities = [ + 'users', + 'agents', + 'roles', + 'permissions', + 'conversations', + 'messages', + , + ]; await queryInterface.bulkInsert( 'permissions', entities.flatMap(createPermissions), @@ -408,6 +416,56 @@ primary key ("roles_permissionsId", "permissionId") permissionId: getId('DELETE_PERMISSIONS'), }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_CONVERSATIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_CONVERSATIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_CONVERSATIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_CONVERSATIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_MESSAGES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_MESSAGES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_MESSAGES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_MESSAGES'), + }, + { createdAt, updatedAt, diff --git a/backend/src/db/seeders/20231127130745-sample-data.js b/backend/src/db/seeders/20231127130745-sample-data.js index fb0f48e..9191104 100644 --- a/backend/src/db/seeders/20231127130745-sample-data.js +++ b/backend/src/db/seeders/20231127130745-sample-data.js @@ -3,6 +3,10 @@ const Users = db.users; const Agents = db.agents; +const Conversations = db.conversations; + +const Messages = db.messages; + const AgentsData = [ { name: 'M&A Structuring Specialist AI', @@ -11,7 +15,7 @@ const AgentsData = [ purpose: 'Assist in structuring M&A deals', - status: 'inactive', + status: 'development', }, { @@ -21,7 +25,7 @@ const AgentsData = [ purpose: 'Analyze capital market trends', - status: 'development', + status: 'active', }, { @@ -31,7 +35,7 @@ const AgentsData = [ purpose: 'Ensure compliance with regulations', - status: 'active', + status: 'inactive', }, { @@ -41,22 +45,218 @@ const AgentsData = [ purpose: 'Provide insights into customer behavior', - status: 'inactive', + status: 'development', + }, +]; + +const ConversationsData = [ + { + title: 'Jean Piaget', + + // type code here for "relation_one" field + + createdat: new Date(Date.now()), + + updatedat: new Date(Date.now()), + }, + + { + title: 'Frederick Gowland Hopkins', + + // type code here for "relation_one" field + + createdat: new Date(Date.now()), + + updatedat: new Date(Date.now()), + }, + + { + title: 'Louis Pasteur', + + // type code here for "relation_one" field + + createdat: new Date(Date.now()), + + updatedat: new Date(Date.now()), + }, + + { + title: 'Albert Einstein', + + // type code here for "relation_one" field + + createdat: new Date(Date.now()), + + updatedat: new Date(Date.now()), + }, +]; + +const MessagesData = [ + { + // type code here for "relation_one" field + + content: 'Albert Einstein', + + sender: 'Antoine Laurent Lavoisier', + + agentname: 'Galileo Galilei', + + createdat: new Date(Date.now()), + }, + + { + // type code here for "relation_one" field + + content: 'Linus Pauling', + + sender: 'Richard Feynman', + + agentname: 'Johannes Kepler', + + createdat: new Date(Date.now()), + }, + + { + // type code here for "relation_one" field + + content: 'Jonas Salk', + + sender: 'Murray Gell-Mann', + + agentname: 'Frederick Gowland Hopkins', + + createdat: new Date(Date.now()), + }, + + { + // type code here for "relation_one" field + + content: 'Hans Bethe', + + sender: 'J. Robert Oppenheimer', + + agentname: 'Ludwig Boltzmann', + + createdat: new Date(Date.now()), }, ]; // Similar logic for "relation_many" +async function associateConversationWithUser() { + const relatedUser0 = await Users.findOne({ + offset: Math.floor(Math.random() * (await Users.count())), + }); + const Conversation0 = await Conversations.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Conversation0?.setUser) { + await Conversation0.setUser(relatedUser0); + } + + const relatedUser1 = await Users.findOne({ + offset: Math.floor(Math.random() * (await Users.count())), + }); + const Conversation1 = await Conversations.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Conversation1?.setUser) { + await Conversation1.setUser(relatedUser1); + } + + const relatedUser2 = await Users.findOne({ + offset: Math.floor(Math.random() * (await Users.count())), + }); + const Conversation2 = await Conversations.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Conversation2?.setUser) { + await Conversation2.setUser(relatedUser2); + } + + const relatedUser3 = await Users.findOne({ + offset: Math.floor(Math.random() * (await Users.count())), + }); + const Conversation3 = await Conversations.findOne({ + order: [['id', 'ASC']], + offset: 3, + }); + if (Conversation3?.setUser) { + await Conversation3.setUser(relatedUser3); + } +} + +async function associateMessageWithConversation() { + const relatedConversation0 = await Conversations.findOne({ + offset: Math.floor(Math.random() * (await Conversations.count())), + }); + const Message0 = await Messages.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Message0?.setConversation) { + await Message0.setConversation(relatedConversation0); + } + + const relatedConversation1 = await Conversations.findOne({ + offset: Math.floor(Math.random() * (await Conversations.count())), + }); + const Message1 = await Messages.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Message1?.setConversation) { + await Message1.setConversation(relatedConversation1); + } + + const relatedConversation2 = await Conversations.findOne({ + offset: Math.floor(Math.random() * (await Conversations.count())), + }); + const Message2 = await Messages.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Message2?.setConversation) { + await Message2.setConversation(relatedConversation2); + } + + const relatedConversation3 = await Conversations.findOne({ + offset: Math.floor(Math.random() * (await Conversations.count())), + }); + const Message3 = await Messages.findOne({ + order: [['id', 'ASC']], + offset: 3, + }); + if (Message3?.setConversation) { + await Message3.setConversation(relatedConversation3); + } +} + module.exports = { up: async (queryInterface, Sequelize) => { await Agents.bulkCreate(AgentsData); + await Conversations.bulkCreate(ConversationsData); + + await Messages.bulkCreate(MessagesData); + await Promise.all([ // Similar logic for "relation_many" + + await associateConversationWithUser(), + + await associateMessageWithConversation(), ]); }, down: async (queryInterface, Sequelize) => { await queryInterface.bulkDelete('agents', null, {}); + + await queryInterface.bulkDelete('conversations', null, {}); + + await queryInterface.bulkDelete('messages', null, {}); }, }; diff --git a/backend/src/db/seeders/20250502081336.js b/backend/src/db/seeders/20250502081336.js new file mode 100644 index 0000000..2133d5d --- /dev/null +++ b/backend/src/db/seeders/20250502081336.js @@ -0,0 +1,87 @@ +const { v4: uuid } = require('uuid'); +const db = require('../models'); +const Sequelize = require('sequelize'); +const config = require('../../config'); + +module.exports = { + /** + * @param{import("sequelize").QueryInterface} queryInterface + * @return {Promise} + */ + async up(queryInterface) { + const createdAt = new Date(); + const updatedAt = new Date(); + + /** @type {Map} */ + const idMap = new Map(); + + /** + * @param {string} key + * @return {string} + */ + function getId(key) { + if (idMap.has(key)) { + return idMap.get(key); + } + const id = uuid(); + idMap.set(key, id); + return id; + } + + /** + * @param {string} name + */ + function createPermissions(name) { + return [ + { + id: getId(`CREATE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `CREATE_${name.toUpperCase()}`, + }, + { + id: getId(`READ_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `READ_${name.toUpperCase()}`, + }, + { + id: getId(`UPDATE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `UPDATE_${name.toUpperCase()}`, + }, + { + id: getId(`DELETE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `DELETE_${name.toUpperCase()}`, + }, + ]; + } + + const entities = ['conversations']; + + const createdPermissions = entities.flatMap(createPermissions); + + // Add permissions to database + await queryInterface.bulkInsert('permissions', createdPermissions); + // Get permissions ids + const permissionsIds = createdPermissions.map((p) => p.id); + // Get admin role + const adminRole = await db.roles.findOne({ + where: { name: config.roles.admin }, + }); + + if (adminRole) { + // Add permissions to admin role if it exists + await adminRole.addPermissions(permissionsIds); + } + }, + down: async (queryInterface, Sequelize) => { + await queryInterface.bulkDelete( + 'permissions', + entities.flatMap(createPermissions), + ); + }, +}; diff --git a/backend/src/db/seeders/20250502081630.js b/backend/src/db/seeders/20250502081630.js new file mode 100644 index 0000000..be900db --- /dev/null +++ b/backend/src/db/seeders/20250502081630.js @@ -0,0 +1,87 @@ +const { v4: uuid } = require('uuid'); +const db = require('../models'); +const Sequelize = require('sequelize'); +const config = require('../../config'); + +module.exports = { + /** + * @param{import("sequelize").QueryInterface} queryInterface + * @return {Promise} + */ + async up(queryInterface) { + const createdAt = new Date(); + const updatedAt = new Date(); + + /** @type {Map} */ + const idMap = new Map(); + + /** + * @param {string} key + * @return {string} + */ + function getId(key) { + if (idMap.has(key)) { + return idMap.get(key); + } + const id = uuid(); + idMap.set(key, id); + return id; + } + + /** + * @param {string} name + */ + function createPermissions(name) { + return [ + { + id: getId(`CREATE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `CREATE_${name.toUpperCase()}`, + }, + { + id: getId(`READ_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `READ_${name.toUpperCase()}`, + }, + { + id: getId(`UPDATE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `UPDATE_${name.toUpperCase()}`, + }, + { + id: getId(`DELETE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `DELETE_${name.toUpperCase()}`, + }, + ]; + } + + const entities = ['messages']; + + const createdPermissions = entities.flatMap(createPermissions); + + // Add permissions to database + await queryInterface.bulkInsert('permissions', createdPermissions); + // Get permissions ids + const permissionsIds = createdPermissions.map((p) => p.id); + // Get admin role + const adminRole = await db.roles.findOne({ + where: { name: config.roles.admin }, + }); + + if (adminRole) { + // Add permissions to admin role if it exists + await adminRole.addPermissions(permissionsIds); + } + }, + down: async (queryInterface, Sequelize) => { + await queryInterface.bulkDelete( + 'permissions', + entities.flatMap(createPermissions), + ); + }, +}; diff --git a/backend/src/index.js b/backend/src/index.js index 6b16ef0..ca189f5 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -25,6 +25,10 @@ const rolesRoutes = require('./routes/roles'); const permissionsRoutes = require('./routes/permissions'); +const conversationsRoutes = require('./routes/conversations'); + +const messagesRoutes = require('./routes/messages'); + const getBaseUrl = (url) => { if (!url) return ''; return url.endsWith('/api') ? url.slice(0, -4) : url; @@ -114,6 +118,18 @@ app.use( permissionsRoutes, ); +app.use( + '/api/conversations', + passport.authenticate('jwt', { session: false }), + conversationsRoutes, +); + +app.use( + '/api/messages', + passport.authenticate('jwt', { session: false }), + messagesRoutes, +); + app.use( '/api/openai', passport.authenticate('jwt', { session: false }), diff --git a/backend/src/routes/conversations.js b/backend/src/routes/conversations.js new file mode 100644 index 0000000..a8bb0a7 --- /dev/null +++ b/backend/src/routes/conversations.js @@ -0,0 +1,444 @@ +const express = require('express'); + +const ConversationsService = require('../services/conversations'); +const ConversationsDBApi = require('../db/api/conversations'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('conversations')); + +/** + * @swagger + * components: + * schemas: + * Conversations: + * type: object + * properties: + + * title: + * type: string + * default: title + + */ + +/** + * @swagger + * tags: + * name: Conversations + * description: The Conversations managing API + */ + +/** + * @swagger + * /api/conversations: + * post: + * security: + * - bearerAuth: [] + * tags: [Conversations] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Conversations" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Conversations" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await ConversationsService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Conversations] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Conversations" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Conversations" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await ConversationsService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/conversations/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Conversations] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Conversations" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Conversations" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await ConversationsService.update( + req.body.data, + req.body.id, + req.currentUser, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/conversations/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Conversations] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Conversations" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await ConversationsService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/conversations/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Conversations] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Conversations" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await ConversationsService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/conversations: + * get: + * security: + * - bearerAuth: [] + * tags: [Conversations] + * summary: Get all conversations + * description: Get all conversations + * responses: + * 200: + * description: Conversations list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Conversations" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const currentUser = req.currentUser; + const payload = await ConversationsDBApi.findAll(req.query, { + currentUser, + }); + if (filetype && filetype === 'csv') { + const fields = ['id', 'title', 'createdat', 'updatedat']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/conversations/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Conversations] + * summary: Count all conversations + * description: Count all conversations + * responses: + * 200: + * description: Conversations count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Conversations" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const payload = await ConversationsDBApi.findAll(req.query, null, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/conversations/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Conversations] + * summary: Find all conversations that match search criteria + * description: Find all conversations that match search criteria + * responses: + * 200: + * description: Conversations list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Conversations" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const payload = await ConversationsDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/conversations/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Conversations] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Conversations" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await ConversationsDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/messages.js b/backend/src/routes/messages.js new file mode 100644 index 0000000..adab13b --- /dev/null +++ b/backend/src/routes/messages.js @@ -0,0 +1,450 @@ +const express = require('express'); + +const MessagesService = require('../services/messages'); +const MessagesDBApi = require('../db/api/messages'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('messages')); + +/** + * @swagger + * components: + * schemas: + * Messages: + * type: object + * properties: + + * content: + * type: string + * default: content + * sender: + * type: string + * default: sender + * agentname: + * type: string + * default: agentname + + */ + +/** + * @swagger + * tags: + * name: Messages + * description: The Messages managing API + */ + +/** + * @swagger + * /api/messages: + * post: + * security: + * - bearerAuth: [] + * tags: [Messages] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Messages" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Messages" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + // Save user message + const userMessage = await MessagesService.create( + req.body.data, + req.currentUser, + ); + // Create placeholder agent response + const placeholderResponse = { + conversation: req.body.data.conversation, + content: 'This is a placeholder response from the AI Agent Hub.', + sender: 'agent', + agentName: null, + }; + const agentMessage = await MessagesService.create( + placeholderResponse, + req.currentUser, + ); + // Return both messages + res.status(200).send({ userMessage, agentMessage }); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Messages] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Messages" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Messages" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await MessagesService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/messages/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Messages] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Messages" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Messages" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await MessagesService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/messages/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Messages] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Messages" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await MessagesService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/messages/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Messages] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Messages" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await MessagesService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/messages: + * get: + * security: + * - bearerAuth: [] + * tags: [Messages] + * summary: Get all messages + * description: Get all messages + * responses: + * 200: + * description: Messages list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Messages" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const currentUser = req.currentUser; + const payload = await MessagesDBApi.findAll(req.query, { currentUser }); + if (filetype && filetype === 'csv') { + const fields = ['id', 'content', 'sender', 'agentname', 'createdat']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/messages/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Messages] + * summary: Count all messages + * description: Count all messages + * responses: + * 200: + * description: Messages count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Messages" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const payload = await MessagesDBApi.findAll(req.query, null, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/messages/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Messages] + * summary: Find all messages that match search criteria + * description: Find all messages that match search criteria + * responses: + * 200: + * description: Messages list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Messages" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const payload = await MessagesDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/messages/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Messages] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Messages" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await MessagesDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/services/conversations.js b/backend/src/services/conversations.js new file mode 100644 index 0000000..5a67c76 --- /dev/null +++ b/backend/src/services/conversations.js @@ -0,0 +1,117 @@ +const db = require('../db/models'); +const ConversationsDBApi = require('../db/api/conversations'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +module.exports = class ConversationsService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await ConversationsDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + 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)); + }); + + await ConversationsDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let conversations = await ConversationsDBApi.findBy( + { id }, + { transaction }, + ); + + if (!conversations) { + throw new ValidationError('conversationsNotFound'); + } + + const updatedConversations = await ConversationsDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedConversations; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await ConversationsDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await ConversationsDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/messages.js b/backend/src/services/messages.js new file mode 100644 index 0000000..9571999 --- /dev/null +++ b/backend/src/services/messages.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const MessagesDBApi = require('../db/api/messages'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +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 { + const record = await MessagesDBApi.create(data, { + currentUser, + transaction, + }); + await transaction.commit(); + return record; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + 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)); + }); + + await MessagesDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let messages = await MessagesDBApi.findBy({ id }, { transaction }); + + if (!messages) { + throw new ValidationError('messagesNotFound'); + } + + const updatedMessages = await MessagesDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedMessages; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await MessagesDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await MessagesDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/search.js b/backend/src/services/search.js index 0db6137..b70a22f 100644 --- a/backend/src/services/search.js +++ b/backend/src/services/search.js @@ -44,6 +44,10 @@ module.exports = class SearchService { users: ['firstName', 'lastName', 'phoneNumber', 'email'], agents: ['name', 'expertise', 'purpose'], + + conversations: ['title'], + + messages: ['content', 'sender', 'agentname'], }; const columnsInt = {}; diff --git a/frontend/json/runtimeError.json b/frontend/json/runtimeError.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/frontend/json/runtimeError.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/frontend/src/components/Conversations/CardConversations.tsx b/frontend/src/components/Conversations/CardConversations.tsx new file mode 100644 index 0000000..657fd7f --- /dev/null +++ b/frontend/src/components/Conversations/CardConversations.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + conversations: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardConversations = ({ + conversations, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission( + currentUser, + 'UPDATE_CONVERSATIONS', + ); + + return ( +
+ {loading && } +
    + {!loading && + conversations.map((item, index) => ( +
  • +
    + + {item.title} + + +
    + +
    +
    +
    +
    +
    Title
    +
    +
    {item.title}
    +
    +
    + +
    +
    User
    +
    +
    + {dataFormatter.usersOneListFormatter(item.user)} +
    +
    +
    + +
    +
    + Createdat +
    +
    +
    + {dataFormatter.dateTimeFormatter(item.createdat)} +
    +
    +
    + +
    +
    + Updatedat +
    +
    +
    + {dataFormatter.dateTimeFormatter(item.updatedat)} +
    +
    +
    +
    +
  • + ))} + {!loading && conversations.length === 0 && ( +
    +

    No data to display

    +
    + )} +
+
+ +
+
+ ); +}; + +export default CardConversations; diff --git a/frontend/src/components/Conversations/ListConversations.tsx b/frontend/src/components/Conversations/ListConversations.tsx new file mode 100644 index 0000000..4d395fe --- /dev/null +++ b/frontend/src/components/Conversations/ListConversations.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + conversations: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListConversations = ({ + conversations, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission( + currentUser, + 'UPDATE_CONVERSATIONS', + ); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
+ {loading && } + {!loading && + conversations.map((item) => ( + +
+ dark:divide-dark-700 overflow-x-auto' + } + > +
+

Title

+

{item.title}

+
+ +
+

User

+

+ {dataFormatter.usersOneListFormatter(item.user)} +

+
+ +
+

Createdat

+

+ {dataFormatter.dateTimeFormatter(item.createdat)} +

+
+ +
+

Updatedat

+

+ {dataFormatter.dateTimeFormatter(item.updatedat)} +

+
+ + +
+
+ ))} + {!loading && conversations.length === 0 && ( +
+

No data to display

+
+ )} +
+
+ +
+ + ); +}; + +export default ListConversations; diff --git a/frontend/src/components/Conversations/TableConversations.tsx b/frontend/src/components/Conversations/TableConversations.tsx new file mode 100644 index 0000000..f9bf8f7 --- /dev/null +++ b/frontend/src/components/Conversations/TableConversations.tsx @@ -0,0 +1,500 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/conversations/conversationsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureConversationsCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +import ListConversations from './ListConversations'; + +const perPage = 10; + +const TableSampleConversations = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + conversations, + loading, + count, + notify: conversationsNotify, + refetch, + } = useAppSelector((state) => state.conversations); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (conversationsNotify.showNotification) { + notify( + conversationsNotify.typeNotification, + conversationsNotify.textNotification, + ); + } + }, [conversationsNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `conversations`, currentUser).then( + (newCols) => setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
+ `datagrid--row`} + rows={conversations ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
+ ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
+ <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
+
+
+ Filter +
+ + {filters.map((selectOption) => ( + + ))} + +
+ {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
+
Value
+ + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
+ ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
+
+
+ From +
+ +
+
+
+ To +
+ +
+
+ ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
+
+
+ From +
+ +
+
+
+ To +
+ +
+
+ ) : ( +
+
+ Contains +
+ +
+ )} +
+
+ Action +
+ { + deleteFilter(filterItem.id); + }} + /> +
+
+ ); + })} +
+ + +
+ +
+
+
+ ) : null} + +

Are you sure you want to delete this item?

+
+ + {conversations && Array.isArray(conversations) && !showGrid && ( + + )} + + {showGrid && dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleConversations; diff --git a/frontend/src/components/Conversations/configureConversationsCols.tsx b/frontend/src/components/Conversations/configureConversationsCols.tsx new file mode 100644 index 0000000..99499a9 --- /dev/null +++ b/frontend/src/components/Conversations/configureConversationsCols.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_CONVERSATIONS'); + + return [ + { + field: 'title', + headerName: 'Title', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'user', + headerName: 'User', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('users'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'createdat', + headerName: 'Createdat', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'dateTime', + valueGetter: (params: GridValueGetterParams) => + new Date(params.row.createdat), + }, + + { + field: 'updatedat', + headerName: 'Updatedat', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'dateTime', + valueGetter: (params: GridValueGetterParams) => + new Date(params.row.updatedat), + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ + , + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/Messages/CardMessages.tsx b/frontend/src/components/Messages/CardMessages.tsx new file mode 100644 index 0000000..d10417f --- /dev/null +++ b/frontend/src/components/Messages/CardMessages.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + messages: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardMessages = ({ + messages, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_MESSAGES'); + + return ( +
+ {loading && } +
    + {!loading && + messages.map((item, index) => ( +
  • +
    + + {item.sender} + + +
    + +
    +
    +
    +
    +
    + Conversation +
    +
    +
    + {dataFormatter.conversationsOneListFormatter( + item.conversation, + )} +
    +
    +
    + +
    +
    + Content +
    +
    +
    + {item.content} +
    +
    +
    + +
    +
    + Sender +
    +
    +
    + {item.sender} +
    +
    +
    + +
    +
    + Agentname +
    +
    +
    + {item.agentname} +
    +
    +
    + +
    +
    + Createdat +
    +
    +
    + {dataFormatter.dateTimeFormatter(item.createdat)} +
    +
    +
    +
    +
  • + ))} + {!loading && messages.length === 0 && ( +
    +

    No data to display

    +
    + )} +
+
+ +
+
+ ); +}; + +export default CardMessages; diff --git a/frontend/src/components/Messages/ListMessages.tsx b/frontend/src/components/Messages/ListMessages.tsx new file mode 100644 index 0000000..e3fb075 --- /dev/null +++ b/frontend/src/components/Messages/ListMessages.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + messages: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListMessages = ({ + messages, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_MESSAGES'); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
+ {loading && } + {!loading && + messages.map((item) => ( + +
+ dark:divide-dark-700 overflow-x-auto' + } + > +
+

Conversation

+

+ {dataFormatter.conversationsOneListFormatter( + item.conversation, + )} +

+
+ +
+

Content

+

{item.content}

+
+ +
+

Sender

+

{item.sender}

+
+ +
+

Agentname

+

{item.agentname}

+
+ +
+

Createdat

+

+ {dataFormatter.dateTimeFormatter(item.createdat)} +

+
+ + +
+
+ ))} + {!loading && messages.length === 0 && ( +
+

No data to display

+
+ )} +
+
+ +
+ + ); +}; + +export default ListMessages; diff --git a/frontend/src/components/Messages/TableMessages.tsx b/frontend/src/components/Messages/TableMessages.tsx new file mode 100644 index 0000000..5a88970 --- /dev/null +++ b/frontend/src/components/Messages/TableMessages.tsx @@ -0,0 +1,484 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/messages/messagesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureMessagesCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +const perPage = 10; + +const TableSampleMessages = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + messages, + loading, + count, + notify: messagesNotify, + refetch, + } = useAppSelector((state) => state.messages); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (messagesNotify.showNotification) { + notify(messagesNotify.typeNotification, messagesNotify.textNotification); + } + }, [messagesNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `messages`, currentUser).then( + (newCols) => setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
+ `datagrid--row`} + rows={messages ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
+ ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
+ <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
+
+
+ Filter +
+ + {filters.map((selectOption) => ( + + ))} + +
+ {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
+
Value
+ + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
+ ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
+
+
+ From +
+ +
+
+
+ To +
+ +
+
+ ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
+
+
+ From +
+ +
+
+
+ To +
+ +
+
+ ) : ( +
+
+ Contains +
+ +
+ )} +
+
+ Action +
+ { + deleteFilter(filterItem.id); + }} + /> +
+
+ ); + })} +
+ + +
+ +
+
+
+ ) : null} + +

Are you sure you want to delete this item?

+
+ + {dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleMessages; diff --git a/frontend/src/components/Messages/configureMessagesCols.tsx b/frontend/src/components/Messages/configureMessagesCols.tsx new file mode 100644 index 0000000..fb571d6 --- /dev/null +++ b/frontend/src/components/Messages/configureMessagesCols.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_MESSAGES'); + + return [ + { + field: 'conversation', + headerName: 'Conversation', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('conversations'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'content', + headerName: 'Content', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'sender', + headerName: 'Sender', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'agentname', + headerName: 'Agentname', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'createdat', + headerName: 'Createdat', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'dateTime', + valueGetter: (params: GridValueGetterParams) => + new Date(params.row.createdat), + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ + , + ]; + }, + }, + ]; +}; diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 1fa85f7..7a700e4 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -43,6 +43,22 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiShieldAccountOutline ?? icon.mdiTable, permissions: 'READ_PERMISSIONS', }, + { + href: '/conversations/conversations-list', + label: 'Conversations', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_CONVERSATIONS', + }, + { + href: '/messages/messages-list', + label: 'Messages', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_MESSAGES', + }, { href: '/profile', label: 'Profile', diff --git a/frontend/src/pages/conversations/[conversationsId].tsx b/frontend/src/pages/conversations/[conversationsId].tsx new file mode 100644 index 0000000..75ba77e --- /dev/null +++ b/frontend/src/pages/conversations/[conversationsId].tsx @@ -0,0 +1,183 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/conversations/conversationsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditConversations = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + title: '', + + user: null, + + createdat: new Date(), + + updatedat: new Date(), + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { conversations } = useAppSelector((state) => state.conversations); + + const { conversationsId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: conversationsId })); + }, [conversationsId]); + + useEffect(() => { + if (typeof conversations === 'object') { + setInitialValues(conversations); + } + }, [conversations]); + + useEffect(() => { + if (typeof conversations === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = conversations[el]), + ); + + setInitialValues(newInitialVal); + } + }, [conversations]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: conversationsId, data })); + await router.push('/conversations/conversations-list'); + }; + + return ( + <> + + {getPageTitle('Edit conversations')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + setInitialValues({ ...initialValues, createdat: date }) + } + /> + + + + + setInitialValues({ ...initialValues, updatedat: date }) + } + /> + + + + + + + + router.push('/conversations/conversations-list') + } + /> + + +
+
+
+ + ); +}; + +EditConversations.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditConversations; diff --git a/frontend/src/pages/conversations/conversations-edit.tsx b/frontend/src/pages/conversations/conversations-edit.tsx new file mode 100644 index 0000000..740f644 --- /dev/null +++ b/frontend/src/pages/conversations/conversations-edit.tsx @@ -0,0 +1,181 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/conversations/conversationsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditConversationsPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + title: '', + + user: null, + + createdat: new Date(), + + updatedat: new Date(), + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { conversations } = useAppSelector((state) => state.conversations); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof conversations === 'object') { + setInitialValues(conversations); + } + }, [conversations]); + + useEffect(() => { + if (typeof conversations === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = conversations[el]), + ); + setInitialValues(newInitialVal); + } + }, [conversations]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/conversations/conversations-list'); + }; + + return ( + <> + + {getPageTitle('Edit conversations')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + setInitialValues({ ...initialValues, createdat: date }) + } + /> + + + + + setInitialValues({ ...initialValues, updatedat: date }) + } + /> + + + + + + + + router.push('/conversations/conversations-list') + } + /> + + +
+
+
+ + ); +}; + +EditConversationsPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditConversationsPage; diff --git a/frontend/src/pages/conversations/conversations-list.tsx b/frontend/src/pages/conversations/conversations-list.tsx new file mode 100644 index 0000000..76dd1d3 --- /dev/null +++ b/frontend/src/pages/conversations/conversations-list.tsx @@ -0,0 +1,178 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableConversations from '../../components/Conversations/TableConversations'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +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/conversations/conversationsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const ConversationsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Title', title: 'title' }, + + { label: 'Createdat', title: 'createdat', date: 'true' }, + { label: 'Updatedat', title: 'updatedat', date: 'true' }, + + { label: 'User', title: 'user' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_CONVERSATIONS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getConversationsCSV = async () => { + const response = await axios({ + url: '/conversations?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'conversationsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Conversations')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
+
+
+ +
+ + Switch to Table + +
+
+ + + + +
+ + + + + ); +}; + +ConversationsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default ConversationsTablesPage; diff --git a/frontend/src/pages/conversations/conversations-new.tsx b/frontend/src/pages/conversations/conversations-new.tsx new file mode 100644 index 0000000..4d047b2 --- /dev/null +++ b/frontend/src/pages/conversations/conversations-new.tsx @@ -0,0 +1,132 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SwitchField } from '../../components/SwitchField'; + +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { RichTextField } from '../../components/RichTextField'; + +import { create } from '../../stores/conversations/conversationsSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + title: '', + + user: '', + + createdat: '', + + updatedat: '', +}; + +const ConversationsNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/conversations/conversations-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + + + + + + + + + + router.push('/conversations/conversations-list') + } + /> + + +
+
+
+ + ); +}; + +ConversationsNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default ConversationsNew; diff --git a/frontend/src/pages/conversations/conversations-table.tsx b/frontend/src/pages/conversations/conversations-table.tsx new file mode 100644 index 0000000..091f3be --- /dev/null +++ b/frontend/src/pages/conversations/conversations-table.tsx @@ -0,0 +1,175 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableConversations from '../../components/Conversations/TableConversations'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +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/conversations/conversationsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const ConversationsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Title', title: 'title' }, + + { label: 'Createdat', title: 'createdat', date: 'true' }, + { label: 'Updatedat', title: 'updatedat', date: 'true' }, + + { label: 'User', title: 'user' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_CONVERSATIONS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getConversationsCSV = async () => { + const response = await axios({ + url: '/conversations?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'conversationsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Conversations')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
+
+ + + Back to list + +
+
+ + + +
+ + + + + ); +}; + +ConversationsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default ConversationsTablesPage; diff --git a/frontend/src/pages/conversations/conversations-view.tsx b/frontend/src/pages/conversations/conversations-view.tsx new file mode 100644 index 0000000..5d58e98 --- /dev/null +++ b/frontend/src/pages/conversations/conversations-view.tsx @@ -0,0 +1,182 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/conversations/conversationsSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +const ConversationsView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { conversations } = useAppSelector((state) => state.conversations); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View conversations')} + + + + + + +
+

Title

+

{conversations?.title}

+
+ +
+

User

+ +

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

+
+ + + {conversations.createdat ? ( + + ) : ( +

No Createdat

+ )} +
+ + + {conversations.updatedat ? ( + + ) : ( +

No Updatedat

+ )} +
+ + <> +

Messages Conversation

+ +
+ + + + + + + + + + + + + + {conversations.messages_conversation && + Array.isArray(conversations.messages_conversation) && + conversations.messages_conversation.map((item: any) => ( + + router.push( + `/messages/messages-view/?id=${item.id}`, + ) + } + > + + + + + + + + + ))} + +
ContentSenderAgentnameCreatedat
{item.content}{item.sender}{item.agentname} + {dataFormatter.dateTimeFormatter(item.createdat)} +
+
+ {!conversations?.messages_conversation?.length && ( +
No data
+ )} +
+ + + + + router.push('/conversations/conversations-list')} + /> +
+
+ + ); +}; + +ConversationsView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default ConversationsView; diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index a4cd852..008df0b 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -26,6 +26,8 @@ const Dashboard = () => { const [agents, setAgents] = React.useState('Loading...'); const [roles, setRoles] = React.useState('Loading...'); const [permissions, setPermissions] = React.useState('Loading...'); + const [conversations, setConversations] = React.useState('Loading...'); + const [messages, setMessages] = React.useState('Loading...'); const [widgetsRole, setWidgetsRole] = React.useState({ role: { value: '', label: '' }, @@ -36,8 +38,22 @@ const Dashboard = () => { const { rolesWidgets, loading } = useAppSelector((state) => state.roles); async function loadData() { - const entities = ['users', 'agents', 'roles', 'permissions']; - const fns = [setUsers, setAgents, setRoles, setPermissions]; + const entities = [ + 'users', + 'agents', + 'roles', + 'permissions', + 'conversations', + 'messages', + ]; + const fns = [ + setUsers, + setAgents, + setRoles, + setPermissions, + setConversations, + setMessages, + ]; const requests = entities.map((entity, index) => { if (hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) { @@ -278,6 +294,70 @@ const Dashboard = () => { )} + + {hasPermission(currentUser, 'READ_CONVERSATIONS') && ( + +
+
+
+
+ Conversations +
+
+ {conversations} +
+
+
+ +
+
+
+ + )} + + {hasPermission(currentUser, 'READ_MESSAGES') && ( + +
+
+
+
+ Messages +
+
+ {messages} +
+
+
+ +
+
+
+ + )} diff --git a/frontend/src/pages/messages/[messagesId].tsx b/frontend/src/pages/messages/[messagesId].tsx new file mode 100644 index 0000000..92e3ef0 --- /dev/null +++ b/frontend/src/pages/messages/[messagesId].tsx @@ -0,0 +1,170 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/messages/messagesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditMessages = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + conversation: null, + + content: '', + + sender: '', + + agentname: '', + + createdat: new Date(), + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { messages } = useAppSelector((state) => state.messages); + + const { messagesId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: messagesId })); + }, [messagesId]); + + useEffect(() => { + if (typeof messages === 'object') { + setInitialValues(messages); + } + }, [messages]); + + useEffect(() => { + if (typeof messages === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach((el) => (newInitialVal[el] = messages[el])); + + setInitialValues(newInitialVal); + } + }, [messages]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: messagesId, data })); + await router.push('/messages/messages-list'); + }; + + return ( + <> + + {getPageTitle('Edit messages')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + + + + + + + setInitialValues({ ...initialValues, createdat: date }) + } + /> + + + + + + + router.push('/messages/messages-list')} + /> + + +
+
+
+ + ); +}; + +EditMessages.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditMessages; diff --git a/frontend/src/pages/messages/messages-edit.tsx b/frontend/src/pages/messages/messages-edit.tsx new file mode 100644 index 0000000..a95b888 --- /dev/null +++ b/frontend/src/pages/messages/messages-edit.tsx @@ -0,0 +1,168 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/messages/messagesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditMessagesPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + conversation: null, + + content: '', + + sender: '', + + agentname: '', + + createdat: new Date(), + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { messages } = useAppSelector((state) => state.messages); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof messages === 'object') { + setInitialValues(messages); + } + }, [messages]); + + useEffect(() => { + if (typeof messages === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach((el) => (newInitialVal[el] = messages[el])); + setInitialValues(newInitialVal); + } + }, [messages]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/messages/messages-list'); + }; + + return ( + <> + + {getPageTitle('Edit messages')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + + + + + + + setInitialValues({ ...initialValues, createdat: date }) + } + /> + + + + + + + router.push('/messages/messages-list')} + /> + + +
+
+
+ + ); +}; + +EditMessagesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditMessagesPage; diff --git a/frontend/src/pages/messages/messages-list.tsx b/frontend/src/pages/messages/messages-list.tsx new file mode 100644 index 0000000..1ef906c --- /dev/null +++ b/frontend/src/pages/messages/messages-list.tsx @@ -0,0 +1,170 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableMessages from '../../components/Messages/TableMessages'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +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/messages/messagesSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const MessagesTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Content', title: 'content' }, + { label: 'Sender', title: 'sender' }, + { label: 'Agentname', title: 'agentname' }, + + { label: 'Createdat', title: 'createdat', date: 'true' }, + + { label: 'Conversation', title: 'conversation' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_MESSAGES'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getMessagesCSV = async () => { + const response = await axios({ + url: '/messages?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'messagesCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Messages')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
+
+
+
+ + + + +
+ + + + + ); +}; + +MessagesTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default MessagesTablesPage; diff --git a/frontend/src/pages/messages/messages-new.tsx b/frontend/src/pages/messages/messages-new.tsx new file mode 100644 index 0000000..76abba7 --- /dev/null +++ b/frontend/src/pages/messages/messages-new.tsx @@ -0,0 +1,132 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SwitchField } from '../../components/SwitchField'; + +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { RichTextField } from '../../components/RichTextField'; + +import { create } from '../../stores/messages/messagesSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + conversation: '', + + content: '', + + sender: '', + + agentname: '', + + createdat: '', +}; + +const MessagesNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/messages/messages-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + router.push('/messages/messages-list')} + /> + + +
+
+
+ + ); +}; + +MessagesNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default MessagesNew; diff --git a/frontend/src/pages/messages/messages-table.tsx b/frontend/src/pages/messages/messages-table.tsx new file mode 100644 index 0000000..8de31fa --- /dev/null +++ b/frontend/src/pages/messages/messages-table.tsx @@ -0,0 +1,173 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableMessages from '../../components/Messages/TableMessages'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +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/messages/messagesSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const MessagesTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Content', title: 'content' }, + { label: 'Sender', title: 'sender' }, + { label: 'Agentname', title: 'agentname' }, + + { label: 'Createdat', title: 'createdat', date: 'true' }, + + { label: 'Conversation', title: 'conversation' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_MESSAGES'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getMessagesCSV = async () => { + const response = await axios({ + url: '/messages?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'messagesCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Messages')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
+
+ + + Back to table + +
+
+ + + +
+ + + + + ); +}; + +MessagesTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default MessagesTablesPage; diff --git a/frontend/src/pages/messages/messages-view.tsx b/frontend/src/pages/messages/messages-view.tsx new file mode 100644 index 0000000..6301eb5 --- /dev/null +++ b/frontend/src/pages/messages/messages-view.tsx @@ -0,0 +1,118 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/messages/messagesSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +const MessagesView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { messages } = useAppSelector((state) => state.messages); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View messages')} + + + + + + +
+

Conversation

+ +

{messages?.conversation?.title ?? 'No data'}

+
+ +
+

Content

+

{messages?.content}

+
+ +
+

Sender

+

{messages?.sender}

+
+ +
+

Agentname

+

{messages?.agentname}

+
+ + + {messages.createdat ? ( + + ) : ( +

No Createdat

+ )} +
+ + + + router.push('/messages/messages-list')} + /> +
+
+ + ); +}; + +MessagesView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default MessagesView; diff --git a/frontend/src/pages/users/users-view.tsx b/frontend/src/pages/users/users-view.tsx index ce0851d..83ca701 100644 --- a/frontend/src/pages/users/users-view.tsx +++ b/frontend/src/pages/users/users-view.tsx @@ -138,6 +138,55 @@ const UsersView = () => { + <> +

Conversations User

+ +
+ + + + + + + + + + + + {users.conversations_user && + Array.isArray(users.conversations_user) && + users.conversations_user.map((item: any) => ( + + router.push( + `/conversations/conversations-view/?id=${item.id}`, + ) + } + > + + + + + + + ))} + +
TitleCreatedatUpdatedat
{item.title} + {dataFormatter.dateTimeFormatter(item.createdat)} + + {dataFormatter.dateTimeFormatter(item.updatedat)} +
+
+ {!users?.conversations_user?.length && ( +
No data
+ )} +
+ + { + const { id, query } = data; + const result = await axios.get( + `conversations${query || (id ? `/${id}` : '')}`, + ); + return id + ? result.data + : { rows: result.data.rows, count: result.data.count }; + }, +); + +export const deleteItemsByIds = createAsyncThunk( + 'conversations/deleteByIds', + async (data: any, { rejectWithValue }) => { + try { + await axios.post('conversations/deleteByIds', { data }); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const deleteItem = createAsyncThunk( + 'conversations/deleteConversations', + async (id: string, { rejectWithValue }) => { + try { + await axios.delete(`conversations/${id}`); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const create = createAsyncThunk( + 'conversations/createConversations', + async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post('conversations', { data }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const uploadCsv = createAsyncThunk( + 'conversations/uploadCsv', + async (file: File, { rejectWithValue }) => { + try { + const data = new FormData(); + data.append('file', file); + data.append('filename', file.name); + + const result = await axios.post('conversations/bulk-import', data, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const update = createAsyncThunk( + 'conversations/updateConversations', + async (payload: any, { rejectWithValue }) => { + try { + const result = await axios.put(`conversations/${payload.id}`, { + id: payload.id, + data: payload.data, + }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const conversationsSlice = createSlice({ + name: 'conversations', + initialState, + reducers: { + setRefetch: (state, action: PayloadAction) => { + state.refetch = action.payload; + }, + }, + extraReducers: (builder) => { + builder.addCase(fetch.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(fetch.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(fetch.fulfilled, (state, action) => { + if (action.payload.rows && action.payload.count >= 0) { + state.conversations = action.payload.rows; + state.count = action.payload.count; + } else { + state.conversations = action.payload; + } + state.loading = false; + }); + + builder.addCase(deleteItemsByIds.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItemsByIds.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Conversations has been deleted'); + }); + + builder.addCase(deleteItemsByIds.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(deleteItem.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItem.fulfilled, (state) => { + state.loading = false; + fulfilledNotify( + state, + `${'Conversations'.slice(0, -1)} has been deleted`, + ); + }); + + builder.addCase(deleteItem.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(create.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.fulfilled, (state) => { + state.loading = false; + fulfilledNotify( + state, + `${'Conversations'.slice(0, -1)} has been created`, + ); + }); + + builder.addCase(update.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(update.fulfilled, (state) => { + state.loading = false; + fulfilledNotify( + state, + `${'Conversations'.slice(0, -1)} has been updated`, + ); + }); + builder.addCase(update.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(uploadCsv.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(uploadCsv.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Conversations has been uploaded'); + }); + builder.addCase(uploadCsv.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + }, +}); + +// Action creators are generated for each case reducer function +export const { setRefetch } = conversationsSlice.actions; + +export default conversationsSlice.reducer; diff --git a/frontend/src/stores/messages/messagesSlice.ts b/frontend/src/stores/messages/messagesSlice.ts new file mode 100644 index 0000000..a62108a --- /dev/null +++ b/frontend/src/stores/messages/messagesSlice.ts @@ -0,0 +1,252 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from 'axios'; +import { + fulfilledNotify, + rejectNotify, + resetNotify, +} from '../../helpers/notifyStateHandler'; + +interface MainState { + messages: any; + loading: boolean; + count: number; + refetch: boolean; + rolesWidgets: any[]; + notify: { + showNotification: boolean; + textNotification: string; + typeNotification: string; + }; +} + +const initialState: MainState = { + messages: [], + loading: false, + count: 0, + refetch: false, + rolesWidgets: [], + notify: { + showNotification: false, + textNotification: '', + typeNotification: 'warn', + }, +}; + +export const fetch = createAsyncThunk('messages/fetch', async (data: any) => { + const { id, query } = data; + const result = await axios.get(`messages${query || (id ? `/${id}` : '')}`); + return id + ? result.data + : { rows: result.data.rows, count: result.data.count }; +}); + +export const deleteItemsByIds = createAsyncThunk( + 'messages/deleteByIds', + async (data: any, { rejectWithValue }) => { + try { + await axios.post('messages/deleteByIds', { data }); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const deleteItem = createAsyncThunk( + 'messages/deleteMessages', + async (id: string, { rejectWithValue }) => { + try { + await axios.delete(`messages/${id}`); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const create = createAsyncThunk( + 'messages/createMessages', + async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post('messages', { data }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +// Thunk for chat messaging including stub agent response +export const createChatMessage = createAsyncThunk( + 'messages/createChatMessage', + async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post('messages', { data }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + return rejectWithValue(error.response.data); + } + } +); + +export const uploadCsv = createAsyncThunk( + 'messages/uploadCsv', + async (file: File, { rejectWithValue }) => { + try { + const data = new FormData(); + data.append('file', file); + data.append('filename', file.name); + + const result = await axios.post('messages/bulk-import', data, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const update = createAsyncThunk( + 'messages/updateMessages', + async (payload: any, { rejectWithValue }) => { + try { + const result = await axios.put(`messages/${payload.id}`, { + id: payload.id, + data: payload.data, + }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const messagesSlice = createSlice({ + name: 'messages', + initialState, + reducers: { + setRefetch: (state, action: PayloadAction) => { + state.refetch = action.payload; + }, + }, + extraReducers: (builder) => { + builder.addCase(fetch.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(fetch.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(fetch.fulfilled, (state, action) => { + if (action.payload.rows && action.payload.count >= 0) { + state.messages = action.payload.rows; + state.count = action.payload.count; + } else { + state.messages = action.payload; + } + state.loading = false; + }); + + builder.addCase(deleteItemsByIds.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItemsByIds.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Messages has been deleted'); + }); + + builder.addCase(deleteItemsByIds.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(deleteItem.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItem.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Messages'.slice(0, -1)} has been deleted`); + }); + + builder.addCase(deleteItem.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(create.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Messages'.slice(0, -1)} has been created`); + }); + + builder.addCase(update.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(update.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Messages'.slice(0, -1)} has been updated`); + }); + builder.addCase(update.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(uploadCsv.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(uploadCsv.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Messages has been uploaded'); + }); + builder.addCase(uploadCsv.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + }, +}); + +// Action creators are generated for each case reducer function +export const { setRefetch } = messagesSlice.actions; + +export default messagesSlice.reducer; diff --git a/frontend/src/stores/messages/messagesSlice.ts.temp b/frontend/src/stores/messages/messagesSlice.ts.temp new file mode 100644 index 0000000..ed3c916 --- /dev/null +++ b/frontend/src/stores/messages/messagesSlice.ts.temp @@ -0,0 +1,268 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from 'axios'; +import { + fulfilledNotify, + rejectNotify, + resetNotify, +} from '../../helpers/notifyStateHandler'; + +interface MainState { + messages: any; + loading: boolean; + count: number; + refetch: boolean; + rolesWidgets: any[]; + notify: { + showNotification: boolean; + textNotification: string; + typeNotification: string; + }; +} + +const initialState: MainState = { + messages: [], + loading: false, + count: 0, + refetch: false, + rolesWidgets: [], + notify: { + showNotification: false, + textNotification: '', + typeNotification: 'warn', + }, +}; + +export const fetch = createAsyncThunk('messages/fetch', async (data: any) => { + const { id, query } = data; + const result = await axios.get(`messages${query || (id ? `/${id}` : '')}`); + return id + ? result.data + : { rows: result.data.rows, count: result.data.count }; +}); + +export const deleteItemsByIds = createAsyncThunk( + 'messages/deleteByIds', + async (data: any, { rejectWithValue }) => { + try { + await axios.post('messages/deleteByIds', { data }); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const deleteItem = createAsyncThunk( + 'messages/deleteMessages', + async (id: string, { rejectWithValue }) => { + try { + await axios.delete(`messages/${id}`); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const create = createAsyncThunk( + 'messages/createMessages', + async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post('messages', { data }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); +// Chat-specific thunk: sends user message and receives stub agent response +export const createChatMessage = createAsyncThunk( + 'messages/createChatMessage', + async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post('messages', { data }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + return rejectWithValue(error.response.data); + } + } +); + + +// Thunk for chat messaging including stub agent response +export const createChatMessage = createAsyncThunk( + 'messages/createChatMessage', + async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post('messages', { data }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + return rejectWithValue(error.response.data); + } + } +); + +export const uploadCsv = createAsyncThunk( + 'messages/uploadCsv', + async (file: File, { rejectWithValue }) => { + try { + const data = new FormData(); + data.append('file', file); + data.append('filename', file.name); + + const result = await axios.post('messages/bulk-import', data, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const update = createAsyncThunk( + 'messages/updateMessages', + async (payload: any, { rejectWithValue }) => { + try { + const result = await axios.put(`messages/${payload.id}`, { + id: payload.id, + data: payload.data, + }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const messagesSlice = createSlice({ + name: 'messages', + initialState, + reducers: { + setRefetch: (state, action: PayloadAction) => { + state.refetch = action.payload; + }, + }, + extraReducers: (builder) => { + builder.addCase(fetch.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(fetch.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(fetch.fulfilled, (state, action) => { + if (action.payload.rows && action.payload.count >= 0) { + state.messages = action.payload.rows; + state.count = action.payload.count; + } else { + state.messages = action.payload; + } + state.loading = false; + }); + + builder.addCase(deleteItemsByIds.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItemsByIds.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Messages has been deleted'); + }); + + builder.addCase(deleteItemsByIds.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(deleteItem.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItem.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Messages'.slice(0, -1)} has been deleted`); + }); + + builder.addCase(deleteItem.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(create.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Messages'.slice(0, -1)} has been created`); + }); + + builder.addCase(update.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(update.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Messages'.slice(0, -1)} has been updated`); + }); + builder.addCase(update.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(uploadCsv.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(uploadCsv.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Messages has been uploaded'); + }); + builder.addCase(uploadCsv.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + }, +}); + +// Action creators are generated for each case reducer function +export const { setRefetch } = messagesSlice.actions; + +export default messagesSlice.reducer; diff --git a/frontend/src/stores/store.ts b/frontend/src/stores/store.ts index c3bc371..7ef8a77 100644 --- a/frontend/src/stores/store.ts +++ b/frontend/src/stores/store.ts @@ -8,6 +8,8 @@ import usersSlice from './users/usersSlice'; import agentsSlice from './agents/agentsSlice'; import rolesSlice from './roles/rolesSlice'; import permissionsSlice from './permissions/permissionsSlice'; +import conversationsSlice from './conversations/conversationsSlice'; +import messagesSlice from './messages/messagesSlice'; export const store = configureStore({ reducer: { @@ -20,6 +22,8 @@ export const store = configureStore({ agents: agentsSlice, roles: rolesSlice, permissions: permissionsSlice, + conversations: conversationsSlice, + messages: messagesSlice, }, });