From f6e2fc19569adbbfeca8693cdf4c41a82e219bc8 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 3 Jun 2025 04:03:16 +0530 Subject: [PATCH] feat: allow sending attachments in new conversations - replace existing combobox selects with common component selectcombobox.vue --- cmd/conversation.go | 93 +++++--- cmd/macro.go | 11 +- cmd/messages.go | 2 +- frontend/src/api/index.js | 6 +- .../src/components/button/CloseButton.vue | 24 ++ .../components/combobox/SelectCombobox.vue | 58 +++++ .../src/components/filter/FilterBuilder.vue | 92 +++----- .../features/admin/automation/ActionBox.vue | 59 +---- .../src/features/admin/automation/RuleBox.vue | 64 +----- .../src/features/admin/automation/RuleTab.vue | 15 ++ .../features/admin/macros/ActionBuilder.vue | 216 +++++++++--------- .../src/features/admin/macros/MacroForm.vue | 164 ++++++------- .../features/admin/macros/dataTableColumns.js | 2 +- .../admin/macros/dataTableDropdown.vue | 2 +- .../src/features/admin/macros/formSchema.js | 2 +- .../conversation/CreateConversation.vue | 115 ++-------- .../conversation/MacroActionsPreview.vue | 2 +- .../src/features/conversation/ReplyBox.vue | 12 +- .../features/conversation/ReplyBoxContent.vue | 25 +- .../features/conversation/ReplyBoxMenuBar.vue | 6 +- .../message/attachment/AttachmentsPreview.vue | 2 +- .../sidebar/ConversationSideBar.vue | 124 ++-------- .../conversation/sidebar/CustomAttributes.vue | 6 +- frontend/src/stores/conversation.js | 12 +- frontend/src/stores/macro.js | 19 +- i18n/en.json | 27 +-- internal/macro/macro.go | 9 +- internal/macro/models/models.go | 5 +- internal/macro/queries.sql | 12 +- internal/migrations/v0.6.0.go | 7 +- schema.sql | 4 +- 31 files changed, 512 insertions(+), 685 deletions(-) create mode 100644 frontend/src/components/button/CloseButton.vue create mode 100644 frontend/src/components/combobox/SelectCombobox.vue diff --git a/cmd/conversation.go b/cmd/conversation.go index d8c7d4a..cdb900d 100644 --- a/cmd/conversation.go +++ b/cmd/conversation.go @@ -3,7 +3,6 @@ package main import ( "encoding/json" "strconv" - "strings" "time" amodels "github.com/abhinavxd/libredesk/internal/auth/models" @@ -11,6 +10,7 @@ import ( "github.com/abhinavxd/libredesk/internal/automation/models" cmodels "github.com/abhinavxd/libredesk/internal/conversation/models" "github.com/abhinavxd/libredesk/internal/envelope" + medModels "github.com/abhinavxd/libredesk/internal/media/models" "github.com/abhinavxd/libredesk/internal/stringutil" umodels "github.com/abhinavxd/libredesk/internal/user/models" "github.com/valyala/fasthttp" @@ -18,6 +18,18 @@ import ( "github.com/zerodha/fastglue" ) +type createConversationRequest struct { + InboxID int `json:"inbox_id" form:"inbox_id"` + AssignedAgentID int `json:"agent_id" form:"agent_id"` + AssignedTeamID int `json:"team_id" form:"team_id"` + Email string `json:"contact_email" form:"contact_email"` + FirstName string `json:"first_name" form:"first_name"` + LastName string `json:"last_name" form:"last_name"` + Subject string `json:"subject" form:"subject"` + Content string `json:"content" form:"content"` + Attachments []int `json:"attachments" form:"attachments"` +} + // handleGetAllConversations retrieves all conversations. func handleGetAllConversations(r *fastglue.Request) error { var ( @@ -632,36 +644,32 @@ func filterCurrentConv(convs []cmodels.Conversation, uuid string) []cmodels.Conv // handleCreateConversation creates a new conversation and sends a message to it. func handleCreateConversation(r *fastglue.Request) error { var ( - app = r.Context.(*App) - auser = r.RequestCtx.UserValue("user").(amodels.User) - inboxID = r.RequestCtx.PostArgs().GetUintOrZero("inbox_id") - assignedAgentID = r.RequestCtx.PostArgs().GetUintOrZero("agent_id") - assignedTeamID = r.RequestCtx.PostArgs().GetUintOrZero("team_id") - email = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("contact_email"))) - firstName = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("first_name"))) - lastName = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("last_name"))) - subject = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("subject"))) - content = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("content"))) - to = []string{email} + app = r.Context.(*App) + auser = r.RequestCtx.UserValue("user").(amodels.User) + req = createConversationRequest{} ) + if err := r.Decode(&req, "json"); err != nil { + app.lo.Error("error decoding create conversation request", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) + } + + to := []string{req.Email} + // Validate required fields - if inboxID <= 0 { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`inbox_id`"), nil, envelope.InputError) + if req.InboxID <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil, envelope.InputError) } - if subject == "" { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`subject`"), nil, envelope.InputError) + if req.Content == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil, envelope.InputError) } - if content == "" { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`content`"), nil, envelope.InputError) + if req.Email == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil, envelope.InputError) } - if email == "" { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`contact_email`"), nil, envelope.InputError) + if req.FirstName == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil, envelope.InputError) } - if firstName == "" { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`first_name`"), nil, envelope.InputError) - } - if !stringutil.ValidEmail(email) { + if !stringutil.ValidEmail(req.Email) { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil, envelope.InputError) } @@ -671,7 +679,7 @@ func handleCreateConversation(r *fastglue.Request) error { } // Check if inbox exists and is enabled. - inbox, err := app.inbox.GetDBRecord(inboxID) + inbox, err := app.inbox.GetDBRecord(req.InboxID) if err != nil { return sendErrorEnvelope(r, err) } @@ -681,11 +689,11 @@ func handleCreateConversation(r *fastglue.Request) error { // Find or create contact. contact := umodels.User{ - Email: null.StringFrom(email), - SourceChannelID: null.StringFrom(email), - FirstName: firstName, - LastName: lastName, - InboxID: inboxID, + Email: null.StringFrom(req.Email), + SourceChannelID: null.StringFrom(req.Email), + FirstName: req.FirstName, + LastName: req.LastName, + InboxID: req.InboxID, } if err := app.user.CreateContact(&contact); err != nil { return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil)) @@ -695,10 +703,10 @@ func handleCreateConversation(r *fastglue.Request) error { conversationID, conversationUUID, err := app.conversation.CreateConversation( contact.ID, contact.ContactChannelID, - inboxID, + req.InboxID, "", /** last_message **/ time.Now(), /** last_message_at **/ - subject, + req.Subject, true, /** append reference number to subject **/ ) if err != nil { @@ -706,8 +714,19 @@ func handleCreateConversation(r *fastglue.Request) error { return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.conversation}"), nil)) } + // Prepare attachments. + var media = make([]medModels.Media, 0, len(req.Attachments)) + for _, id := range req.Attachments { + m, err := app.media.Get(id, "") + if err != nil { + app.lo.Error("error fetching media", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.media}"), nil, envelope.GeneralError) + } + media = append(media, m) + } + // Send reply to the created conversation. - if err := app.conversation.SendReply(nil /**media**/, inboxID, auser.ID /**sender_id**/, conversationUUID, content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil { + if err := app.conversation.SendReply(media, req.InboxID, auser.ID /**sender_id**/, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil { // Delete the conversation if reply fails. if err := app.conversation.DeleteConversation(conversationUUID); err != nil { app.lo.Error("error deleting conversation", "error", err) @@ -716,11 +735,11 @@ func handleCreateConversation(r *fastglue.Request) error { } // Assign the conversation to the agent or team. - if assignedAgentID > 0 { - app.conversation.UpdateConversationUserAssignee(conversationUUID, assignedAgentID, user) + if req.AssignedAgentID > 0 { + app.conversation.UpdateConversationUserAssignee(conversationUUID, req.AssignedAgentID, user) } - if assignedTeamID > 0 { - app.conversation.UpdateConversationTeamAssignee(conversationUUID, assignedTeamID, user) + if req.AssignedTeamID > 0 { + app.conversation.UpdateConversationTeamAssignee(conversationUUID, req.AssignedTeamID, user) } // Send the created conversation back to the client. diff --git a/cmd/macro.go b/cmd/macro.go index cb1e414..22589c8 100644 --- a/cmd/macro.go +++ b/cmd/macro.go @@ -81,8 +81,7 @@ func handleCreateMacro(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.Actions) - if err != nil { + if err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions); err != nil { return sendErrorEnvelope(r, err) } @@ -110,7 +109,7 @@ func handleUpdateMacro(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - if err = app.macro.Update(id, macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.Actions); err != nil { + if err = app.macro.Update(id, macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions); err != nil { return sendErrorEnvelope(r, err) } @@ -275,13 +274,17 @@ func validateMacro(app *App, macro models.Macro) error { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil) } + if len(macro.VisibleWhen) == 0 { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`visible_when`"), nil) + } + var act []autoModels.RuleAction if err := json.Unmarshal(macro.Actions, &act); err != nil { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.macroAction}"), nil) } for _, a := range act { if len(a.Value) == 0 { - return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.emptyActionValue", "name", a.Type), nil) + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", a.Type), nil) } } return nil diff --git a/cmd/messages.go b/cmd/messages.go index 0c6406f..712c97d 100644 --- a/cmd/messages.go +++ b/cmd/messages.go @@ -132,7 +132,6 @@ func handleSendMessage(r *fastglue.Request) error { app = r.Context.(*App) auser = r.RequestCtx.UserValue("user").(amodels.User) cuuid = r.RequestCtx.UserValue("cuuid").(string) - media = []medModels.Media{} req = messageReq{} ) @@ -153,6 +152,7 @@ func handleSendMessage(r *fastglue.Request) error { } // Prepare attachments. + var media = make([]medModels.Media, 0, len(req.Attachments)) for _, id := range req.Attachments { m, err := app.media.Get(id, "") if err != nil { diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 2280760..f13161a 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -231,7 +231,11 @@ const updateConversationCustomAttribute = (uuid, data) => http.put(`/api/v1/conv 'Content-Type': 'application/json' } }) -const createConversation = (data) => http.post('/api/v1/conversations', data) +const createConversation = (data) => http.post('/api/v1/conversations', data, { + headers: { + 'Content-Type': 'application/json' + } +}) const updateConversationStatus = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/status`, data) const updateConversationPriority = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/priority`, data) const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`) diff --git a/frontend/src/components/button/CloseButton.vue b/frontend/src/components/button/CloseButton.vue new file mode 100644 index 0000000..c48b63b --- /dev/null +++ b/frontend/src/components/button/CloseButton.vue @@ -0,0 +1,24 @@ + + + diff --git a/frontend/src/components/combobox/SelectCombobox.vue b/frontend/src/components/combobox/SelectCombobox.vue new file mode 100644 index 0000000..c524afd --- /dev/null +++ b/frontend/src/components/combobox/SelectCombobox.vue @@ -0,0 +1,58 @@ + + + diff --git a/frontend/src/components/filter/FilterBuilder.vue b/frontend/src/components/filter/FilterBuilder.vue index 06575e2..6e95665 100644 --- a/frontend/src/components/filter/FilterBuilder.vue +++ b/frontend/src/components/filter/FilterBuilder.vue @@ -44,79 +44,47 @@
- - +
@@ -146,12 +114,12 @@ import { SelectTrigger, SelectValue } from '@/components/ui/select' -import { Plus, X } from 'lucide-vue-next' +import { Plus } from 'lucide-vue-next' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' import { useI18n } from 'vue-i18n' -import ComboBox from '@/components/ui/combobox/ComboBox.vue' +import CloseButton from '@/components/button/CloseButton.vue' +import SelectComboBox from '@/components/combobox/SelectCombobox.vue' const props = defineProps({ fields: { diff --git a/frontend/src/features/admin/automation/ActionBox.vue b/frontend/src/features/admin/automation/ActionBox.vue index 2e660d3..053c3d7 100644 --- a/frontend/src/features/admin/automation/ActionBox.vue +++ b/frontend/src/features/admin/automation/ActionBox.vue @@ -48,63 +48,17 @@ class="w-48" v-if="action.type && conversationActions[action.type]?.type === 'select'" > - - - - - + :type="action.type === 'assign_team' ? 'team' : 'user'" + />
-
- -
+
import { toRefs } from 'vue' import { Button } from '@/components/ui/button' -import { X } from 'lucide-vue-next' +import CloseButton from '@/components/button/CloseButton.vue' import { useTagStore } from '@/stores/tag' import { Select, @@ -143,13 +97,12 @@ import { SelectTrigger, SelectValue } from '@/components/ui/select' -import ComboBox from '@/components/ui/combobox/ComboBox.vue' -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { SelectTag } from '@/components/ui/select' import { useConversationFilters } from '@/composables/useConversationFilters' import { getTextFromHTML } from '@/utils/strings.js' import { useI18n } from 'vue-i18n' import Editor from '@/features/conversation/ConversationTextEditor.vue' +import SelectComboBox from '@/components/combobox/SelectCombobox.vue' const props = defineProps({ actions: { diff --git a/frontend/src/features/admin/automation/RuleBox.vue b/frontend/src/features/admin/automation/RuleBox.vue index 9f5a936..8060772 100644 --- a/frontend/src/features/admin/automation/RuleBox.vue +++ b/frontend/src/features/admin/automation/RuleBox.vue @@ -102,59 +102,12 @@
- - - - - + :type="rule.field === 'assigned_user' ? 'user' : 'team'" + />
@@ -209,9 +162,7 @@
-
- -
+
@@ -242,6 +193,7 @@ import { toRefs, computed, watch } from 'vue' import { Checkbox } from '@/components/ui/checkbox' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { Button } from '@/components/ui/button' +import CloseButton from '@/components/button/CloseButton.vue' import { Select, SelectContent, @@ -258,13 +210,11 @@ import { TagsInputItemDelete, TagsInputItemText } from '@/components/ui/tags-input' -import { X } from 'lucide-vue-next' import { Label } from '@/components/ui/label' import { Input } from '@/components/ui/input' -import ComboBox from '@/components/ui/combobox/ComboBox.vue' -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { useI18n } from 'vue-i18n' import { useConversationFilters } from '@/composables/useConversationFilters' +import SelectComboBox from '@/components/combobox/SelectCombobox.vue' const props = defineProps({ ruleGroup: { diff --git a/frontend/src/features/admin/automation/RuleTab.vue b/frontend/src/features/admin/automation/RuleTab.vue index ea8114f..1c178a7 100644 --- a/frontend/src/features/admin/automation/RuleTab.vue +++ b/frontend/src/features/admin/automation/RuleTab.vue @@ -23,6 +23,21 @@
+
+
+

+ {{ + $t('globals.messages.noResults', { + name: $t('globals.terms.rule', 2).toLowerCase() + }) + }} +

+
+
+
diff --git a/frontend/src/features/admin/macros/ActionBuilder.vue b/frontend/src/features/admin/macros/ActionBuilder.vue index ecaf348..c079583 100644 --- a/frontend/src/features/admin/macros/ActionBuilder.vue +++ b/frontend/src/features/admin/macros/ActionBuilder.vue @@ -1,125 +1,132 @@ diff --git a/frontend/src/features/conversation/ReplyBoxMenuBar.vue b/frontend/src/features/conversation/ReplyBoxMenuBar.vue index f59bdb1..893fb98 100644 --- a/frontend/src/features/conversation/ReplyBoxMenuBar.vue +++ b/frontend/src/features/conversation/ReplyBoxMenuBar.vue @@ -13,13 +13,13 @@
- + /> --> diff --git a/frontend/src/features/conversation/sidebar/ConversationSideBar.vue b/frontend/src/features/conversation/sidebar/ConversationSideBar.vue index 034f708..6baea76 100644 --- a/frontend/src/features/conversation/sidebar/ConversationSideBar.vue +++ b/frontend/src/features/conversation/sidebar/ConversationSideBar.vue @@ -10,107 +10,31 @@ - - - - - + type="user" + /> - - - - - + type="team" + /> - - - - - + type="priority" + /> String(conversationStore.current?.assigned_user_id)) -const assignedTeamID = computed(() => String(conversationStore.current?.assigned_team_id)) -const priorityID = computed(() => String(conversationStore.current?.priority_id)) const priorityOptions = computed(() => conversationStore.priorityOptions) const fetchTags = async () => { @@ -288,7 +207,6 @@ const selectAgent = (agent) => { handleRemoveAssignee('user') return } - if (conversationStore.current.assigned_user_id == agent.value) return conversationStore.current.assigned_user_id = agent.value handleAssignedUserChange(agent.value) } @@ -298,31 +216,15 @@ const selectTeam = (team) => { handleRemoveAssignee('team') return } - if (conversationStore.current.assigned_team_id == team.value) return - conversationStore.current.assigned_team_id = team.value handleAssignedTeamChange(team.value) } const selectPriority = (priority) => { - if (conversationStore.current.priority_id == priority.value) return conversationStore.current.priority = priority.label conversationStore.current.priority_id = priority.value handlePriorityChange(priority.label) } -const getPriorityIcon = (value) => { - switch (value) { - case '1': - return SignalLow - case '2': - return SignalMedium - case '3': - return SignalHigh - default: - return CircleAlert - } -} - const updateContactCustomAttributes = async (attributes) => { let previousAttributes = conversationStore.current.contact.custom_attributes try { diff --git a/frontend/src/features/conversation/sidebar/CustomAttributes.vue b/frontend/src/features/conversation/sidebar/CustomAttributes.vue index d90df52..79864d3 100644 --- a/frontend/src/features/conversation/sidebar/CustomAttributes.vue +++ b/frontend/src/features/conversation/sidebar/CustomAttributes.vue @@ -196,7 +196,7 @@ const getValidationSchema = (attribute) => { z .number({ invalid_type_error: t('globals.messages.invalid', { - name: t('form.field.value').toLowerCase() + name: t('globals.terms.value').toLowerCase() }) }) .nullable() @@ -209,7 +209,7 @@ const getValidationSchema = (attribute) => { .refine( (val) => !isNaN(Date.parse(val)), t('globals.messages.invalid', { - name: t('form.field.value').toLowerCase() + name: t('globals.terms.value').toLowerCase() }) ) .nullable() @@ -227,7 +227,7 @@ const getValidationSchema = (attribute) => { .string() .refine((val) => attribute.values.includes(val), { message: t('globals.messages.invalid', { - name: t('form.field.value').toLowerCase() + name: t('globals.terms.value').toLowerCase() }) }) .nullable() diff --git a/frontend/src/stores/conversation.js b/frontend/src/stores/conversation.js index 97147d1..2bfa46d 100644 --- a/frontend/src/stores/conversation.js +++ b/frontend/src/stores/conversation.js @@ -1,5 +1,5 @@ import { defineStore } from 'pinia' -import { computed, reactive, ref, nextTick, watchEffect } from 'vue' +import { computed, reactive, ref, watchEffect } from 'vue' import { CONVERSATION_LIST_TYPE, CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation' import { handleHTTPError } from '@/utils/http' import { computeRecipientsFromMessage } from '@/utils/email-recipients' @@ -100,7 +100,6 @@ export const useConversationStore = defineStore('conversation', () => { const conversation = reactive({ data: null, participants: {}, - mediaFiles: [], loading: false, errorMessage: '' }) @@ -118,10 +117,6 @@ export const useConversationStore = defineStore('conversation', () => { const incrementMessageVersion = () => setTimeout(() => messages.version++, 0) - function resetMediaFiles () { - conversation.mediaFiles = [] - } - function setListStatus (status, fetch = true) { conversations.status = status if (fetch) { @@ -631,7 +626,6 @@ export const useConversationStore = defineStore('conversation', () => { Object.assign(conversation, { data: null, participants: {}, - mediaFiles: [], macro: {}, loading: false, errorMessage: '' @@ -645,9 +639,8 @@ export const useConversationStore = defineStore('conversation', () => { } - /** Macros **/ + /** Macros for new conversation or open conversation **/ async function setMacro (macro, context) { - console.debug('Setting macro for context:', context, macro) macros.value[context] = macro } @@ -706,7 +699,6 @@ export const useConversationStore = defineStore('conversation', () => { getMacro, setMacro, resetMacro, - resetMediaFiles, removeAssignee, getListSortField, getListStatus, diff --git a/frontend/src/stores/macro.js b/frontend/src/stores/macro.js index f19d625..a896232 100644 --- a/frontend/src/stores/macro.js +++ b/frontend/src/stores/macro.js @@ -7,11 +7,11 @@ import { useUserStore } from './user' import api from '@/api' import { permissions as perms } from '@/constants/permissions.js' - export const useMacroStore = defineStore('macroStore', () => { const macroList = ref([]) const emitter = useEmitter() const userStore = useUserStore() + const currentView = ref('') // actionPermissions is a map of action names to their corresponding permissions that a user must have to perform the action. const actionPermissions = { @@ -34,6 +34,14 @@ export const useMacroStore = defineStore('macroStore', () => { userTeams.includes(macro.team_id) || String(macro.user_id) === String(userStore.userID) ) + + // Filter by visible_when if currentView is set. + if (currentView.value) { + filtered = filtered.filter(macro => + !macro.visible_when?.length || macro.visible_when.includes(currentView.value) + ) + } + // Filter macros based on permissions. filtered.forEach(macro => { macro.actions = macro.actions.filter(action => { @@ -42,14 +50,17 @@ export const useMacroStore = defineStore('macroStore', () => { return userStore.can(permission) }) }) + // Skip macros that do not have any actions left AND the macro field `message_content` is empty. filtered = filtered.filter(macro => !(macro.actions.length === 0 && macro.message_content === "")) + return filtered.map(macro => ({ ...macro, label: macro.name, value: String(macro.id), })) }) + const loadMacros = async () => { if (macroList.value.length) return try { @@ -62,9 +73,15 @@ export const useMacroStore = defineStore('macroStore', () => { }) } } + + const setCurrentView = (view) => { + currentView.value = view + } + return { macroList, macroOptions, loadMacros, + setCurrentView } }) \ No newline at end of file diff --git a/i18n/en.json b/i18n/en.json index f7d4b33..2faaa89 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -93,9 +93,14 @@ "globals.terms.security": "Security | Security", "globals.terms.myInbox": "My Inbox | My Inboxes", "globals.terms.teamInbox": "Team Inbox | Team Inboxes", - "globals.terms.optional": "Optional", + "globals.terms.optional": "Optional | Optionals", + "globals.terms.visibility": "Visibility | Visibilities", + "globals.terms.privateNote": "Private note | Private notes", + "globals.terms.automationRule": "Automation Rule | Automation Rules", + "globals.messages.replying": "Replying", "globals.messages.golangDurationHoursMinutes": "Duration in hours or minutes. Example: 1h, 30m, 1h30m", "globals.messages.badRequest": "Bad request", + "globals.messages.visibleWhen": "Visible when", "globals.messages.adjustFilters": "Try adjusting filters", "globals.messages.errorUpdating": "Error updating {name}", "globals.messages.errorCreating": "Error creating {name}", @@ -133,21 +138,21 @@ "globals.messages.create": "Create {name}", "globals.messages.new": "New {name}", "globals.messages.add": "Add {name}", + "globals.messages.adding": "Adding {name}", + "globals.messages.starting": "Starting {name}", "globals.messages.all": "All {name}", "globals.messages.denied": "{name} denied", "globals.messages.noResults": "No {name} found", "globals.messages.enter": "Enter {name}", "globals.messages.yes": "Yes", - "globals.messages.no": "No", + "globals.messages.no": "No {name}", + "globals.messages.type": "{name} type", "globals.messages.typeOf": "Type of {name}", "globals.messages.invalidEmailAddress": "Invalid email address", "globals.messages.pleaseSelectAtLeastOne": "Please select at least one {name}", "globals.messages.strongPassword": "Password must be between {min} and {max} characters long, should contain at least one uppercase letter, one lowercase letter, one number, and one special character.", - "globals.messages.couldNotReload": "Could not reload {name}. Please restart the app", - "globals.messages.invalid": "Invalid {name}", - "globals.messages.disabled": "{name} is disabled", - "globals.messages.fieldRequired": "{name} required", - "globals.messages.required": "Required", + "globals.messages.couldNotReload": "Could not reload {name}", + "globals.messages.required": "{name} Required", "globals.messages.invalidPortNumber": "Invalid port number", "globals.messages.mustBeNumber": "Must be a number", "globals.messages.fileTypeisNotAnImage": "File type is not an image", @@ -171,6 +176,7 @@ "globals.messages.snooze": "Snooze", "globals.messages.resolve": "Resolve", "globals.messages.applyMacro": "Apply macro", + "globals.messages.deletionConfirmation": "This action cannot be undone. This will permanently delete this {name}.", "globals.messages.atleastOneRecipient": "At least one recipient is required", "globals.messages.startTypingToSearch": "Start typing to search...", "globals.messages.goHourMinuteDuration": "Invalid duration format. Should be a number followed by h (hours), m (minutes).", @@ -318,7 +324,6 @@ "form.field.setValue": "Set value", "form.field.selectEvents": "Select events", "form.field.selectOperator": "Select operator", - "form.field.value": "Value", "form.error.min": "Must be at least {min} characters", "form.error.max": "Must be at most {max} characters", "form.error.minmax": "Must be between {min} and {max} characters", @@ -386,13 +391,10 @@ "admin.conversationTags.deleteConfirmation": "This action cannot be undone. This will permanently delete this tag, and remove it from all conversations.", "admin.macro.messageContent": "Response to be sent when macro is used (optional)", "admin.macro.actions": "Actions (optional)", - "admin.macro.visibility": "Visibility", - "admin.macro.visibility.all": "All users", "admin.macro.messageOrActionRequired": "Either message content or actions are required", "admin.macro.actionTypeRequired": "Action type is required", "admin.macro.actionValueRequired": "Action value is required", - "admin.macro.teamOrUserRequired": "team is required when visibility is `team` & a user is required when visibility is `user`", - "admin.macro.deleteConfirmation": "This action cannot be undone. This will permanently delete this macro.", + "admin.macro.teamOrUserRequired": "Team or user is required", "admin.conversationStatus.name.description": "Set status name. Click save when you're done.", "admin.conversationStatus.deleteConfirmation": "This action cannot be undone. This will permanently delete this status.", "admin.inbox.name.description": "Name for your inbox.", @@ -594,7 +596,6 @@ "ai.enterOpenAIAPIKey": "Enter OpenAI API Key", "ai.apiKey.description": "{provider} API Key is not set or invalid. Please enter a valid API key to use AI features.", "replyBox.reply": "Reply", - "replyBox.privateNote": "Private note", "replyBox.emailAddresess": "Email addresses separated by comma", "replyBox.editor.placeholder": "Shift + Enter to add a new line. Cmd + Enter to send. Cmd + K to open command bar.", "replyBox.invalidEmailsIn": "Invalid email(s) in", diff --git a/internal/macro/macro.go b/internal/macro/macro.go index 035944f..6ad925e 100644 --- a/internal/macro/macro.go +++ b/internal/macro/macro.go @@ -11,6 +11,7 @@ import ( "github.com/abhinavxd/libredesk/internal/macro/models" "github.com/jmoiron/sqlx" "github.com/knadh/go-i18n" + "github.com/lib/pq" "github.com/zerodha/logf" ) @@ -67,8 +68,8 @@ func (m *Manager) Get(id int) (models.Macro, error) { } // Create adds a new macro. -func (m *Manager) Create(name, messageContent string, userID, teamID *int, visibility string, actions json.RawMessage) error { - _, err := m.q.Create.Exec(name, messageContent, userID, teamID, visibility, actions) +func (m *Manager) Create(name, messageContent string, userID, teamID *int, visibility string, visibleWhen []string, actions json.RawMessage) error { + _, err := m.q.Create.Exec(name, messageContent, userID, teamID, visibility, pq.StringArray(visibleWhen), actions) if err != nil { m.lo.Error("error creating macro", "error", err) return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.macro}"), nil) @@ -77,8 +78,8 @@ func (m *Manager) Create(name, messageContent string, userID, teamID *int, visib } // Update modifies an existing macro. -func (m *Manager) Update(id int, name, messageContent string, userID, teamID *int, visibility string, actions json.RawMessage) error { - result, err := m.q.Update.Exec(id, name, messageContent, userID, teamID, visibility, actions) +func (m *Manager) Update(id int, name, messageContent string, userID, teamID *int, visibility string, visibleWhen []string, actions json.RawMessage) error { + result, err := m.q.Update.Exec(id, name, messageContent, userID, teamID, visibility, pq.StringArray(visibleWhen), actions) if err != nil { m.lo.Error("error updating macro", "error", err) return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.macro}"), nil) diff --git a/internal/macro/models/models.go b/internal/macro/models/models.go index 1ed30aa..3cfa840 100644 --- a/internal/macro/models/models.go +++ b/internal/macro/models/models.go @@ -3,6 +3,8 @@ package models import ( "encoding/json" "time" + + "github.com/lib/pq" ) type Macro struct { @@ -11,7 +13,8 @@ type Macro struct { UpdatedAt time.Time `db:"updated_at" json:"updated_at"` Name string `db:"name" json:"name"` MessageContent string `db:"message_content" json:"message_content"` - Visibility string `db:"visibility" json:"visibility"` + VisibleWhen pq.StringArray `db:"visible_when" json:"visible_when"` + Visibility string `db:"visibility" json:"visibility"` UserID *int `db:"user_id" json:"user_id,string"` TeamID *int `db:"team_id" json:"team_id,string"` UsageCount int `db:"usage_count" json:"usage_count"` diff --git a/internal/macro/queries.sql b/internal/macro/queries.sql index 9f297fe..289b779 100644 --- a/internal/macro/queries.sql +++ b/internal/macro/queries.sql @@ -9,6 +9,7 @@ SELECT user_id, team_id, actions, + visible_when, usage_count FROM macros @@ -26,6 +27,7 @@ SELECT user_id, team_id, actions, + visible_when, usage_count FROM macros @@ -34,9 +36,9 @@ ORDER BY -- name: create INSERT INTO - macros (name, message_content, user_id, team_id, visibility, actions) + macros (name, message_content, user_id, team_id, visibility, visible_when, actions) VALUES - ($1, $2, $3, $4, $5, $6); + ($1, $2, $3, $4, $5, $6, $7); -- name: update UPDATE @@ -47,7 +49,8 @@ SET user_id = $4, team_id = $5, visibility = $6, - actions = $7, + visible_when = $7, + actions = $8, updated_at = NOW() WHERE id = $1; @@ -62,6 +65,7 @@ WHERE UPDATE macros SET - usage_count = usage_count + 1 + usage_count = usage_count + 1, + updated_at = NOW() WHERE id = $1; \ No newline at end of file diff --git a/internal/migrations/v0.6.0.go b/internal/migrations/v0.6.0.go index f1dc014..806b3c8 100644 --- a/internal/migrations/v0.6.0.go +++ b/internal/migrations/v0.6.0.go @@ -294,7 +294,7 @@ func V0_6_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { IF NOT EXISTS ( SELECT 1 FROM pg_type WHERE typname = 'macro_visible_when' ) THEN - CREATE TYPE macro_visible_when AS ENUM ('replying', 'starting_conversation', 'adding_note'); + CREATE TYPE macro_visible_when AS ENUM ('replying', 'starting_conversation', 'adding_private_note'); END IF; END $$; @@ -303,14 +303,15 @@ func V0_6_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { return err } - // Add macro_visible_when column to macros table if it doesn't exist + // Add visible_when column to macros table if it doesn't exist _, err = db.Exec(` ALTER TABLE macros - ADD COLUMN IF NOT EXISTS macro_visible_when macro_visible_when[] NOT NULL DEFAULT ARRAY['replying']; + ADD COLUMN IF NOT EXISTS visible_when macro_visible_when[] NOT NULL DEFAULT ARRAY[]::macro_visible_when[]; `) if err != nil { return err } + return nil } diff --git a/schema.sql b/schema.sql index f269a86..ffb5be2 100644 --- a/schema.sql +++ b/schema.sql @@ -19,7 +19,7 @@ DROP TYPE IF EXISTS "sla_event_status" CASCADE; CREATE TYPE "sla_event_status" A DROP TYPE IF EXISTS "sla_metric" CASCADE; CREATE TYPE "sla_metric" AS ENUM ('first_response', 'resolution', 'next_response'); DROP TYPE IF EXISTS "sla_notification_type" CASCADE; CREATE TYPE "sla_notification_type" AS ENUM ('warning', 'breach'); DROP TYPE IF EXISTS "activity_log_type" CASCADE; CREATE TYPE "activity_log_type" AS ENUM ('agent_login', 'agent_logout', 'agent_away', 'agent_away_reassigned', 'agent_online'); -DROP TYPE IF EXISTS "macro_visible_when" CASCADE; CREATE TYPE "visible_when" AS ENUM ('replying', 'starting_conversation', 'adding_private_note'); +DROP TYPE IF EXISTS "macro_visible_when" CASCADE; CREATE TYPE "macro_visible_when" AS ENUM ('replying', 'starting_conversation', 'adding_private_note'); -- Sequence to generate reference number for conversations. DROP SEQUENCE IF EXISTS conversation_reference_number_sequence; CREATE SEQUENCE conversation_reference_number_sequence START 100; @@ -292,7 +292,7 @@ CREATE TABLE macros ( name TEXT NOT NULL, actions JSONB DEFAULT '{}'::jsonb NOT NULL, visibility macro_visibility NOT NULL, - visible_when macro_visible_when[] NOT NULL DEFAULT ARRAY['replying'], + visible_when macro_visible_when[] NOT NULL DEFAULT ARRAY[]::macro_visible_when[], message_content TEXT NOT NULL, -- Cascade deletes when user is deleted. user_id BIGINT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,