From d3c2cc2527d7b511ec43d22cebd4ce4010c93ac7 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sun, 2 Feb 2025 21:03:09 +0530 Subject: [PATCH] feat: add CSAT sending functionality and improve error handling in templates feat: automation action to send CSAT. - minor refactors. --- cmd/conversation.go | 40 +-- cmd/csat.go | 66 +++-- cmd/handlers.go | 2 +- cmd/init.go | 9 +- cmd/main.go | 3 +- .../admin/automation/CreateOrEditRule.vue | 2 +- .../admin/conversation/macros/EditMacro.vue | 2 +- .../src/components/admin/inbox/EditInbox.vue | 2 +- .../src/components/admin/inbox/NewInbox.vue | 2 +- .../components/admin/team/roles/EditRole.vue | 2 +- .../components/admin/team/roles/NewRole.vue | 2 +- .../src/composables/useConversationFilters.js | 3 + internal/automation/automation.go | 10 +- internal/automation/models/models.go | 1 + internal/conversation/conversation.go | 127 +++++---- internal/conversation/message.go | 7 +- internal/conversation/queries.sql | 24 +- internal/csat/csat.go | 21 +- internal/oidc/oidc.go | 18 +- internal/setting/setting.go | 11 + internal/stringutil/stringutil.go | 4 +- internal/template/render.go | 4 + static/public/static/style.css | 246 ++++++++---------- static/public/web-templates/csat.html | 177 +++++++------ static/public/web-templates/error.html | 4 +- static/public/web-templates/index.html | 20 +- static/public/web-templates/info.html | 6 +- 27 files changed, 415 insertions(+), 400 deletions(-) diff --git a/cmd/conversation.go b/cmd/conversation.go index f52efd22..c4828609 100644 --- a/cmd/conversation.go +++ b/cmd/conversation.go @@ -2,7 +2,6 @@ package main import ( "encoding/json" - "fmt" "strconv" "time" @@ -361,7 +360,7 @@ func handleUpdateConversationUserAssignee(r *fastglue.Request) error { // Evaluate automation rules. app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationUserAssigned) - return r.SendEnvelope(true) + return r.SendEnvelope("User assigned successfully") } // handleUpdateTeamAssignee updates the team assigned to a conversation. @@ -496,9 +495,16 @@ func handleUpdateConversationStatus(r *fastglue.Request) error { // If status is `Resolved`, send CSAT survey if enabled on inbox. if status == cmodels.StatusResolved { - if err := sendCSATSurvey(app, conversation, user); err != nil { + // Check if CSAT is enabled on the inbox and send CSAT survey message. + inbox, err := app.inbox.GetDBRecord(conversation.InboxID) + if err != nil { return sendErrorEnvelope(r, err) } + if inbox.CSATEnabled { + if err := app.conversation.SendCSATReply(user.ID, *conversation); err != nil { + return sendErrorEnvelope(r, err) + } + } } return r.SendEnvelope("Status updated successfully") } @@ -596,34 +602,6 @@ func setSLADeadlines(app *App, conversation *cmodels.Conversation) error { return nil } -// Send CSAT survey email when enabled for inbox -func sendCSATSurvey(app *App, conversation *cmodels.Conversation, user umodels.User) error { - // Check if CSAT is enabled on the inbox. - inbox, err := app.inbox.GetDBRecord(conversation.InboxID) - if err != nil { - return err - } - if !inbox.CSATEnabled { - return nil - } - - // Create CSAT survey. - csatR, err := app.csat.Create(conversation.ID, conversation.AssignedUserID.Int) - if err != nil { - return err - } - - // Send CSAT survey to user as a reply. - consts := app.consts.Load().(*constants) - csatURL := fmt.Sprintf("%s/csat/%s", consts.AppBaseURL, csatR.UUID) - messageContent := fmt.Sprintf("Please rate your experience with us: Rate now", csatURL) - // Store is_csat meta to identify and filter CSAT replies from agent view - meta := map[string]interface{}{ - "is_csat": true, - } - return app.conversation.SendReply(nil, user.ID, conversation.UUID, messageContent, []string{}, []string{}, meta) -} - // handleRemoveUserAssignee removes the user assigned to a conversation. func handleRemoveUserAssignee(r *fastglue.Request) error { var ( diff --git a/cmd/csat.go b/cmd/csat.go index d63f38b9..a89234cc 100644 --- a/cmd/csat.go +++ b/cmd/csat.go @@ -3,8 +3,6 @@ package main import ( "strconv" - "github.com/abhinavxd/libredesk/internal/envelope" - "github.com/valyala/fasthttp" "github.com/zerodha/fastglue" ) @@ -15,38 +13,43 @@ func handleShowCSAT(r *fastglue.Request) error { uuid = r.RequestCtx.UserValue("uuid").(string) ) - if uuid == "" { - return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ - "error_message": "Page not found", - }) - } - csat, err := app.csat.Get(uuid) if err != nil { return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ - "error_message": "CSAT not found", + "Data": map[string]interface{}{ + "ErrorMessage": "Page not found", + }, }) } if csat.ResponseTimestamp.Valid { return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{ - "message": "You've already submitted your feedback", + "Data": map[string]interface{}{ + "Title": "Thank you!", + "Message": "We appreciate you taking the time to submit your feedback.", + }, }) } conversation, err := app.conversation.GetConversation(csat.ConversationID, "") if err != nil { return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ - "error_message": "Conversation not found", + "Data": map[string]interface{}{ + "ErrorMessage": "Page not found", + }, }) } return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{ - "csat": map[string]interface{}{ - "uuid": csat.UUID, - }, - "conversation": map[string]interface{}{ - "subject": conversation.Subject.String, + "Data": map[string]interface{}{ + "Title": "Rate your interaction with us", + "CSAT": map[string]interface{}{ + "UUID": csat.UUID, + }, + "Conversation": map[string]interface{}{ + "Subject": conversation.Subject.String, + "ReferenceNumber": conversation.ReferenceNumber, + }, }, }) } @@ -62,20 +65,41 @@ func handleUpdateCSATResponse(r *fastglue.Request) error { ratingI, err := strconv.Atoi(string(rating)) if err != nil { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `rating`", nil, envelope.InputError) + return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ + "Data": map[string]interface{}{ + "ErrorMessage": "Invalid `rating`", + }, + }) } if ratingI < 1 || ratingI > 5 { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "`rating` should be between 1 and 5", nil, envelope.InputError) + return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ + "Data": map[string]interface{}{ + "ErrorMessage": "Invalid `rating`", + }, + }) } if uuid == "" { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `uuid`", nil, envelope.InputError) + return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ + "Data": map[string]interface{}{ + "ErrorMessage": "Invalid `uuid`", + }, + }) } if err := app.csat.UpdateResponse(uuid, ratingI, feedback); err != nil { - return sendErrorEnvelope(r, err) + return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ + "Data": map[string]interface{}{ + "ErrorMessage": err.Error(), + }, + }) } - return r.SendEnvelope("CSAT response updated") + return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{ + "Data": map[string]interface{}{ + "Title": "Thank you!", + "Message": "We appreciate you taking the time to submit your feedback.", + }, + }) } diff --git a/cmd/handlers.go b/cmd/handlers.go index d8f661b2..779c04fd 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -195,7 +195,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { // Public pages. g.GET("/csat/{uuid}", handleShowCSAT) - g.POST("/csat/{uuid}", fastglue.ReqLenRangeParams(handleUpdateCSATResponse, map[string][2]int{"feedback": {1, 1000}})) + g.POST("/csat/{uuid}", handleUpdateCSATResponse) // Health check. g.GET("/health", handleHealthCheck) diff --git a/cmd/init.go b/cmd/init.go index 67f6ff17..2fc832e8 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -61,6 +61,7 @@ import ( // constants holds the app constants. type constants struct { AppBaseURL string + FaviconURL string LogoURL string SiteName string UploadProvider string @@ -111,6 +112,7 @@ func initFlags() { func initConstants() *constants { return &constants{ AppBaseURL: ko.String("app.root_url"), + FaviconURL: ko.String("app.favicon_url"), LogoURL: ko.String("app.logo_url"), SiteName: ko.String("app.site_name"), UploadProvider: ko.MustString("upload.provider"), @@ -206,10 +208,12 @@ func initConversations( userStore *user.Manager, teamStore *team.Manager, mediaStore *media.Manager, + settings *setting.Manager, + csat *csat.Manager, automationEngine *automation.Engine, template *tmpl.Manager, ) *conversation.Manager { - c, err := conversation.New(hub, i18n, notif, sla, status, priority, inboxStore, userStore, teamStore, mediaStore, automationEngine, template, conversation.Opts{ + c, err := conversation.New(hub, i18n, notif, sla, status, priority, inboxStore, userStore, teamStore, mediaStore, settings, csat, automationEngine, template, conversation.Opts{ DB: db, Lo: initLogger("conversation_manager"), OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"), @@ -326,6 +330,9 @@ func getTmplFuncs(consts *constants) template.FuncMap { "RootURL": func() string { return consts.AppBaseURL }, + "FaviconURL": func() string { + return consts.FaviconURL + }, "Date": func(layout string) string { if layout == "" { layout = time.ANSIC diff --git a/cmd/main.go b/cmd/main.go index 6c7d7651..714ece08 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -147,6 +147,7 @@ func main() { rdb = initRedis() constants = initConstants() i18n = initI18n(fs) + csat = initCSAT(db) oidc = initOIDC(db, settings) status = initStatus(db) priority = initPriority(db) @@ -160,7 +161,7 @@ func main() { notifier = initNotifier(user) automation = initAutomationEngine(db) sla = initSLA(db, team, settings, businessHours) - conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, automation, template) + conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, settings, csat, automation, template) autoassigner = initAutoAssigner(team, user, conversation) ) diff --git a/frontend/src/components/admin/automation/CreateOrEditRule.vue b/frontend/src/components/admin/automation/CreateOrEditRule.vue index 79bf32f2..670dc823 100644 --- a/frontend/src/components/admin/automation/CreateOrEditRule.vue +++ b/frontend/src/components/admin/automation/CreateOrEditRule.vue @@ -333,7 +333,7 @@ const handleSave = async (values) => { router.push('/admin/automations') } emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { - title: 'Saved', + title: 'Success', description: 'Rule saved successfully' }) } catch (error) { diff --git a/frontend/src/components/admin/conversation/macros/EditMacro.vue b/frontend/src/components/admin/conversation/macros/EditMacro.vue index ce718966..e536bbe0 100644 --- a/frontend/src/components/admin/conversation/macros/EditMacro.vue +++ b/frontend/src/components/admin/conversation/macros/EditMacro.vue @@ -35,7 +35,7 @@ const updateMacro = async (payload) => { formLoading.value = true await api.updateMacro(macro.value.id, payload) emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { - title: 'Saved', + title: 'Success', description: 'Macro updated successfully' }) } catch (error) { diff --git a/frontend/src/components/admin/inbox/EditInbox.vue b/frontend/src/components/admin/inbox/EditInbox.vue index e7ea88a5..a7b27995 100644 --- a/frontend/src/components/admin/inbox/EditInbox.vue +++ b/frontend/src/components/admin/inbox/EditInbox.vue @@ -54,7 +54,7 @@ const updateInbox = async (payload) => { isLoading.value = true await api.updateInbox(inbox.value.id, payload) emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { - title: 'Saved', + title: 'Success', description: 'Inbox updated succcessfully' }) } catch (error) { diff --git a/frontend/src/components/admin/inbox/NewInbox.vue b/frontend/src/components/admin/inbox/NewInbox.vue index db9695f7..9aa0c039 100644 --- a/frontend/src/components/admin/inbox/NewInbox.vue +++ b/frontend/src/components/admin/inbox/NewInbox.vue @@ -146,7 +146,7 @@ async function createInbox (payload) { isLoading.value = true await api.createInbox(payload) emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { - title: 'Saved', + title: 'Success', description: 'Inbox created successfully' }) router.push('/admin/inboxes') diff --git a/frontend/src/components/admin/team/roles/EditRole.vue b/frontend/src/components/admin/team/roles/EditRole.vue index 8fac7a6b..7f237367 100644 --- a/frontend/src/components/admin/team/roles/EditRole.vue +++ b/frontend/src/components/admin/team/roles/EditRole.vue @@ -55,7 +55,7 @@ const submitForm = async (values) => { formLoading.value = true await api.updateRole(props.id, values) emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { - title: 'Saved', + title: 'Success', description: 'Role updated successfully' }) } catch (error) { diff --git a/frontend/src/components/admin/team/roles/NewRole.vue b/frontend/src/components/admin/team/roles/NewRole.vue index 4937d73c..7bc1b10a 100644 --- a/frontend/src/components/admin/team/roles/NewRole.vue +++ b/frontend/src/components/admin/team/roles/NewRole.vue @@ -29,7 +29,7 @@ const submitForm = async (values) => { formLoading.value = true await api.createRole(values) emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { - title: 'Saved', + title: 'Success', description: "Role created successfully" }) router.push('/admin/teams/roles') diff --git a/frontend/src/composables/useConversationFilters.js b/frontend/src/composables/useConversationFilters.js index 275246f8..74e9023a 100644 --- a/frontend/src/composables/useConversationFilters.js +++ b/frontend/src/composables/useConversationFilters.js @@ -176,6 +176,9 @@ export function useConversationFilters () { label: 'Send reply', type: FIELD_TYPE.RICHTEXT }, + send_csat: { + label: 'Send CSAT', + }, set_sla: { label: 'Set SLA', type: FIELD_TYPE.SELECT, diff --git a/internal/automation/automation.go b/internal/automation/automation.go index 05ba222b..b753cdf1 100644 --- a/internal/automation/automation.go +++ b/internal/automation/automation.go @@ -336,7 +336,7 @@ func (e *Engine) handleUpdateConversation(conversationUUID, eventType string) { // handleTimeTrigger handles time trigger events. func (e *Engine) handleTimeTrigger() { - e.lo.Debug("handling time trigger") + e.lo.Debug("handling time triggers") thirtyDaysAgo := time.Now().Add(-30 * 24 * time.Hour) conversations, err := e.conversationStore.GetConversationsCreatedAfter(thirtyDaysAgo) if err != nil { @@ -349,7 +349,13 @@ func (e *Engine) handleTimeTrigger() { return } e.lo.Debug("fetched conversations for evaluating time triggers", "conversations_count", len(conversations), "rules_count", len(rules)) - for _, conversation := range conversations { + for _, c := range conversations { + // Fetch entire conversation. + conversation, err := e.conversationStore.GetConversation(0, c.UUID) + if err != nil { + e.lo.Error("error fetching conversation for time trigger", "uuid", c.UUID, "error", err) + continue + } e.evalConversationRules(rules, conversation) } } diff --git a/internal/automation/models/models.go b/internal/automation/models/models.go index 62bd1344..d3268c44 100644 --- a/internal/automation/models/models.go +++ b/internal/automation/models/models.go @@ -17,6 +17,7 @@ const ( ActionReply = "send_reply" ActionSetSLA = "set_sla" ActionSetTags = "set_tags" + ActionSendCSAT = "send_csat" OperatorAnd = "AND" OperatorOR = "OR" diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 86166f0e..353f5830 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -19,6 +19,7 @@ import ( "github.com/abhinavxd/libredesk/internal/conversation/models" pmodels "github.com/abhinavxd/libredesk/internal/conversation/priority/models" smodels "github.com/abhinavxd/libredesk/internal/conversation/status/models" + csatModels "github.com/abhinavxd/libredesk/internal/csat/models" "github.com/abhinavxd/libredesk/internal/dbutil" "github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/inbox" @@ -42,6 +43,7 @@ var ( ErrConversationNotFound = errors.New("conversation not found") ConversationsListAllowedFilterFields = []string{"status_id", "priority_id", "assigned_team_id", "assigned_user_id", "inbox_id"} ConversationStatusesFilterFields = []string{"id", "name"} + csatReplyMessage = "Please rate your experience with us: Rate now" ) const ( @@ -58,6 +60,8 @@ type Manager struct { statusStore statusStore priorityStore priorityStore slaStore slaStore + settingsStore settingsStore + csatStore csatStore notifier *notifier.Service lo *logf.Logger db *sqlx.DB @@ -109,6 +113,15 @@ type inboxStore interface { Get(int) (inbox.Inbox, error) } +type settingsStore interface { + GetAppRootURL() (string, error) +} + +type csatStore interface { + Create(conversationID int) (csatModels.CSATResponse, error) + MakePublicURL(appBaseURL, uuid string) string +} + // Opts holds the options for creating a new Manager. type Opts struct { DB *sqlx.DB @@ -122,13 +135,15 @@ func New( wsHub *ws.Hub, i18n *i18n.I18n, notifier *notifier.Service, - sla slaStore, - status statusStore, - priority priorityStore, + slaStore slaStore, + statusStore statusStore, + priorityStore priorityStore, inboxStore inboxStore, userStore userStore, teamStore teamStore, mediaStore mediaStore, + settingsStore settingsStore, + csatStore csatStore, automation *automation.Engine, template *template.Manager, opts Opts) (*Manager, error) { @@ -147,9 +162,11 @@ func New( userStore: userStore, teamStore: teamStore, mediaStore: mediaStore, - slaStore: sla, - statusStore: status, - priorityStore: priority, + settingsStore: settingsStore, + csatStore: csatStore, + slaStore: slaStore, + statusStore: statusStore, + priorityStore: priorityStore, automation: automation, template: template, db: opts.DB, @@ -759,71 +776,54 @@ func (m *Manager) ApplySLA(conversationUUID string, conversationID, assignedTeam // ApplyAction applies an action to a conversation, this can be called from multiple packages across the app to perform actions on conversations. // all actions are executed on behalf of the provided user if the user is not provided, system user is used. -func (m *Manager) ApplyAction(action amodels.RuleAction, conversation models.Conversation, user umodels.User) error { - if len(action.Value) == 0 { - m.lo.Warn("no value provided for action", "action", action.Type, "conversation_uuid", conversation.UUID) - return fmt.Errorf("no value provided for action %s", action.Type) +func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversation, user umodels.User) error { + // CSAT action does not require a value. + if len(action.Value) == 0 && action.Type != amodels.ActionSendCSAT { + return fmt.Errorf("empty value for action %s", action.Type) } - // If user is not provided, use system user. + // Fall back to system user if user is not provided. if user.ID == 0 { - systemUser, err := m.userStore.GetSystemUser() - if err != nil { - return fmt.Errorf("could not apply %s action. could not fetch system user: %w", action.Type, err) + var err error + if user, err = m.userStore.GetSystemUser(); err != nil { + return fmt.Errorf("get system user: %w", err) } - user = systemUser } + m.lo.Debug("executing action", + "type", action.Type, + "value", action.Value, + "conv_uuid", conv.UUID, + "user_id", user.ID, + ) + switch action.Type { case amodels.ActionAssignTeam: - m.lo.Debug("executing assign team action", "value", action.Value[0], "conversation_uuid", conversation.UUID) teamID, _ := strconv.Atoi(action.Value[0]) - if err := m.UpdateConversationTeamAssignee(conversation.UUID, teamID, user); err != nil { - return fmt.Errorf("could not apply %s action: %w", action.Type, err) - } + return m.UpdateConversationTeamAssignee(conv.UUID, teamID, user) case amodels.ActionAssignUser: - m.lo.Debug("executing assign user action", "value", action.Value[0], "conversation_uuid", conversation.UUID) agentID, _ := strconv.Atoi(action.Value[0]) - if err := m.UpdateConversationUserAssignee(conversation.UUID, agentID, user); err != nil { - return fmt.Errorf("could not apply %s action: %w", action.Type, err) - } + return m.UpdateConversationUserAssignee(conv.UUID, agentID, user) case amodels.ActionSetPriority: - m.lo.Debug("executing set priority action", "value", action.Value[0], "conversation_uuid", conversation.UUID) priorityID, _ := strconv.Atoi(action.Value[0]) - if err := m.UpdateConversationPriority(conversation.UUID, priorityID, "", user); err != nil { - return fmt.Errorf("could not apply %s action: %w", action.Type, err) - } + return m.UpdateConversationPriority(conv.UUID, priorityID, "", user) case amodels.ActionSetStatus: - m.lo.Debug("executing set status action", "value", action.Value[0], "conversation_uuid", conversation.UUID) statusID, _ := strconv.Atoi(action.Value[0]) - if err := m.UpdateConversationStatus(conversation.UUID, statusID, "", "", user); err != nil { - return fmt.Errorf("could not apply %s action: %w", action.Type, err) - } + return m.UpdateConversationStatus(conv.UUID, statusID, "", "", user) case amodels.ActionSendPrivateNote: - m.lo.Debug("executing send private note action", "value", action.Value[0], "conversation_uuid", conversation.UUID) - if err := m.SendPrivateNote([]mmodels.Media{}, user.ID, conversation.UUID, action.Value[0]); err != nil { - return fmt.Errorf("could not apply %s action: %w", action.Type, err) - } + return m.SendPrivateNote([]mmodels.Media{}, user.ID, conv.UUID, action.Value[0]) case amodels.ActionReply: - m.lo.Debug("executing reply action", "value", action.Value[0], "conversation_uuid", conversation.UUID) - if err := m.SendReply([]mmodels.Media{}, user.ID, conversation.UUID, action.Value[0], []string{}, []string{}, map[string]interface{}{}); err != nil { - return fmt.Errorf("could not apply %s action: %w", action.Type, err) - } + return m.SendReply([]mmodels.Media{}, user.ID, conv.UUID, action.Value[0], nil, nil, nil) case amodels.ActionSetSLA: - m.lo.Debug("executing apply SLA action", "value", action.Value[0], "conversation_uuid", conversation.UUID) - slaPolicyID, _ := strconv.Atoi(action.Value[0]) - if err := m.ApplySLA(conversation.UUID, conversation.ID, conversation.AssignedTeamID.Int, slaPolicyID, user); err != nil { - return fmt.Errorf("could not apply %s action: %w", action.Type, err) - } + slaID, _ := strconv.Atoi(action.Value[0]) + return m.ApplySLA(conv.UUID, conv.ID, conv.AssignedTeamID.Int, slaID, user) case amodels.ActionSetTags: - m.lo.Debug("executing set tags action", "value", action.Value, "conversation_uuid", conversation.UUID) - if err := m.UpsertConversationTags(conversation.UUID, action.Value); err != nil { - return fmt.Errorf("could not apply %s action: %w", action.Type, err) - } + return m.UpsertConversationTags(conv.UUID, action.Value) + case amodels.ActionSendCSAT: + return m.SendCSATReply(user.ID, conv) default: - return fmt.Errorf("unrecognized action type %s", action.Type) + return fmt.Errorf("unknown action: %s", action.Type) } - return nil } // RemoveConversationAssignee removes the assignee from the conversation. @@ -835,13 +835,30 @@ func (m *Manager) RemoveConversationAssignee(uuid, typ string) error { return nil } +// SendCSATReply sends a CSAT reply message to a conversation. +func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversation) error { + appRootURL, err := m.settingsStore.GetAppRootURL() + if err != nil { + return envelope.NewError(envelope.GeneralError, "Error fetching app root URL", nil) + } + csat, err := m.csatStore.Create(conversation.ID) + if err != nil { + return envelope.NewError(envelope.GeneralError, "Error creating CSAT", nil) + } + csatPublicURL := m.csatStore.MakePublicURL(appRootURL, csat.UUID) + message := fmt.Sprintf(csatReplyMessage, csatPublicURL) + // Store `is_csat` meta to identify and filter CSAT public url from the message. + meta := map[string]interface{}{ + "is_csat": true, + } + return m.SendReply([]mmodels.Media{}, actorUserID, conversation.UUID, message, nil, nil, meta) +} + // addConversationParticipant adds a user as participant to a conversation. func (c *Manager) addConversationParticipant(userID int, conversationUUID string) error { - if _, err := c.q.InsertConversationParticipant.Exec(userID, conversationUUID); err != nil { - if !dbutil.IsUniqueViolationError(err) { - c.lo.Error("error adding conversation participant", "user_id", userID, "conversation_uuid", conversationUUID, "error", err) - return fmt.Errorf("adding conversation participant: %w", err) - } + if _, err := c.q.InsertConversationParticipant.Exec(userID, conversationUUID); err != nil && !dbutil.IsUniqueViolationError(err) { + c.lo.Error("error adding conversation participant", "user_id", userID, "conversation_uuid", conversationUUID, "error", err) + return envelope.NewError(envelope.GeneralError, "Error adding conversation participant", nil) } return nil } diff --git a/internal/conversation/message.go b/internal/conversation/message.go index b5901604..96e3f954 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -331,7 +331,7 @@ func (m *Manager) InsertMessage(message *models.Message) error { message.Status = MessageStatusSent } - if message.Meta == "" { + if message.Meta == "" || message.Meta == "null" { message.Meta = "{}" } @@ -352,7 +352,7 @@ func (m *Manager) InsertMessage(message *models.Message) error { // Add this user as a participant. if err := m.addConversationParticipant(message.SenderID, message.ConversationUUID); err != nil { - return envelope.NewError(envelope.GeneralError, err.Error(), nil) + return err } // Update conversation last message details in conversation. @@ -472,7 +472,7 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error { } in.Message.SenderID = in.Contact.ID - // Conversations exists for this message? + // Conversations exists for this message? conversationID, err := m.findConversationID([]string{in.Message.SourceID.String}) if err != nil && err != ErrConversationNotFound { return err @@ -633,7 +633,6 @@ func (m *Manager) uploadMessageAttachments(message *models.Message) []error { m.lo.Error("error uploading thumbnail", "error", err) } } - } return uploadErr } diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql index 9cda0ed9..c85dcd5b 100644 --- a/internal/conversation/queries.sql +++ b/internal/conversation/queries.sql @@ -135,30 +135,8 @@ WHERE -- name: get-conversations-created-after SELECT c.id, - c.created_at, - c.updated_at, - c.closed_at, - c.resolved_at, - p.name as priority, - s.name as status, - c.uuid, - c.reference_number, - c.first_reply_at, - u.first_name as first_name, - u.last_name as last_name, - u.email as email, - u.avatar_url as avatar_url, - (SELECT COALESCE( - (SELECT json_agg(t.name) - FROM tags t - INNER JOIN conversation_tags ct ON ct.tag_id = t.id - WHERE ct.conversation_id = c.id), - '[]'::json - )) AS tags + c.uuid FROM conversations c -JOIN users u ON c.contact_id = u.id -LEFT JOIN conversation_statuses s ON c.status_id = s.id -LEFT JOIN conversation_priorities p ON c.priority_id = p.id WHERE c.created_at > $1; -- name: get-contact-conversations diff --git a/internal/csat/csat.go b/internal/csat/csat.go index 6150264d..65338d49 100644 --- a/internal/csat/csat.go +++ b/internal/csat/csat.go @@ -5,6 +5,7 @@ import ( "database/sql" "embed" "errors" + "fmt" "github.com/abhinavxd/libredesk/internal/csat/models" "github.com/abhinavxd/libredesk/internal/dbutil" @@ -15,10 +16,14 @@ import ( var ( //go:embed queries.sql - efs embed.FS + efs embed.FS ErrCSATAlreadyExists = errors.New("CSAT already exists") ) +const ( + csatURL = "%s/csat/%s" +) + // Manager manages CSAT. type Manager struct { q queries @@ -51,15 +56,14 @@ func New(opts Opts) (*Manager, error) { } // Create creates a new CSAT for the given conversation ID. -func (m *Manager) Create(conversationID, assignedAgentID int) (models.CSATResponse, error) { +func (m *Manager) Create(conversationID int) (models.CSATResponse, error) { var ( uuid string rsp models.CSATResponse ) - err := m.q.Insert.QueryRow(conversationID, assignedAgentID).Scan(&uuid) - if err != nil { + if err := m.q.Insert.QueryRow(conversationID).Scan(&uuid); err != nil { m.lo.Error("error creating CSAT", "error", err) - return rsp, envelope.NewError(envelope.GeneralError, "Error creating CSAT", nil) + return rsp, envelope.NewError(envelope.GeneralError, "Error creating CSAT survey", nil) } return m.Get(uuid) } @@ -92,7 +96,12 @@ func (m *Manager) UpdateResponse(uuid string, score int, feedback string) error _, err = m.q.Update.Exec(uuid, score, feedback) if err != nil { m.lo.Error("error updating CSAT", "error", err) - return envelope.NewError(envelope.GeneralError, "Error updating CSAT", nil) + return envelope.NewError(envelope.GeneralError, "Error saving CSAT response", nil) } return nil } + +// MakePublicURL returns the public URL for the given CSAT UUID. +func (m *Manager) MakePublicURL(appBaseURL, uuid string) string { + return fmt.Sprintf(csatURL, appBaseURL, uuid) +} diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go index 8aec9171..ec4d4dc0 100644 --- a/internal/oidc/oidc.go +++ b/internal/oidc/oidc.go @@ -10,7 +10,6 @@ import ( "github.com/abhinavxd/libredesk/internal/oidc/models" "github.com/abhinavxd/libredesk/internal/stringutil" "github.com/jmoiron/sqlx" - "github.com/jmoiron/sqlx/types" "github.com/zerodha/logf" ) @@ -44,7 +43,7 @@ type queries struct { } type settingsStore interface { - Get(key string) (types.JSONText, error) + GetAppRootURL() (string, error) } // New creates and returns a new instance of the oidc Manager. @@ -69,11 +68,12 @@ func (o *Manager) Get(id int, includeSecret bool) (models.OIDC, error) { } // Set logo and redirect URL. oidc.SetProviderLogo() - rootURL, err := o.getAppRootURL() + rootURL, err := o.setting.GetAppRootURL() if err != nil { return models.OIDC{}, err } oidc.RedirectURI = fmt.Sprintf(rootURL+redirectURL, oidc.ID) + // If secret is not to be included, replace it with dummy characters. if oidc.ClientSecret != "" && !includeSecret { oidc.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10) } @@ -89,7 +89,7 @@ func (o *Manager) GetAll() ([]models.OIDC, error) { } // Get root URL of the app. - rootURL, err := o.getAppRootURL() + rootURL, err := o.setting.GetAppRootURL() if err != nil { return nil, err } @@ -148,13 +148,3 @@ func (o *Manager) Delete(id int) error { } return nil } - -// getAppRootURL returns the root URL of the app. -func (o *Manager) getAppRootURL() (string, error) { - rootURL, err := o.setting.Get("app.root_url") - if err != nil { - o.lo.Error("error fetching root URL", "error", err) - return "", envelope.NewError(envelope.GeneralError, "Error fetching root URL", nil) - } - return strings.Trim(string(rootURL), "\""), nil -} diff --git a/internal/setting/setting.go b/internal/setting/setting.go index 65159da3..63fedb95 100644 --- a/internal/setting/setting.go +++ b/internal/setting/setting.go @@ -4,6 +4,7 @@ package setting import ( "embed" "encoding/json" + "strings" "github.com/abhinavxd/libredesk/internal/dbutil" "github.com/abhinavxd/libredesk/internal/envelope" @@ -112,3 +113,13 @@ func (m *Manager) Get(key string) (types.JSONText, error) { } return b, nil } + +// GetAppRootURL returns the root URL of the app. +func (m *Manager) GetAppRootURL() (string, error) { + rootURL, err := m.Get("app.root_url") + if err != nil { + m.lo.Error("error fetching root URL", "error", err) + return "", envelope.NewError(envelope.GeneralError, "Error fetching app root URL", nil) + } + return strings.Trim(string(rootURL), "\""), nil +} diff --git a/internal/stringutil/stringutil.go b/internal/stringutil/stringutil.go index ab3679f4..f8f94234 100644 --- a/internal/stringutil/stringutil.go +++ b/internal/stringutil/stringutil.go @@ -76,8 +76,8 @@ func RandomNumericString(n int) (string, error) { } // GetPathFromURL extracts the path from a URL. -func GetPathFromURL(rawURL string) (string, error) { - parsedURL, err := url.Parse(rawURL) +func GetPathFromURL(u string) (string, error) { + parsedURL, err := url.Parse(u) if err != nil { return "", err } diff --git a/internal/template/render.go b/internal/template/render.go index ccdb14f8..0aed5a05 100644 --- a/internal/template/render.go +++ b/internal/template/render.go @@ -154,5 +154,9 @@ func (m *Manager) RenderWebPage(ctx *fasthttp.RequestCtx, tmplFile string, data defer m.mutex.RUnlock() ctx.SetContentType("text/html; charset=utf-8") ctx.SetStatusCode(fasthttp.StatusOK) + // Add no-cache headers + ctx.Response.Header.Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") + ctx.Response.Header.Set("Pragma", "no-cache") + ctx.Response.Header.Set("Expires", "0") return m.webTpls.ExecuteTemplate(ctx, tmplFile, data) } diff --git a/static/public/static/style.css b/static/public/static/style.css index 7eb8654f..e1fac2e9 100644 --- a/static/public/static/style.css +++ b/static/public/static/style.css @@ -1,54 +1,79 @@ +@import url("https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600&display=swap"); + +:root { + --primary-color: #1a1a1d; + --secondary-color: #f4f4f5; + --text-color: #0a0a0b; + --background-color: #ffffff; + --border-color: #e5e5e6; + --shadow-color: rgba(26, 26, 29, 0.1); +} + * { box-sizing: border-box; } + html, body { padding: 0; margin: 0; min-width: 320px; } + body { background: #f9f9f9; - font-family: 'Inter', 'Open Sans', 'Helvetica Neue', sans-serif; + font-family: "Plus Jakarta Sans", "Inter", sans-serif; font-size: 16px; - line-height: 26px; - color: #111; + line-height: 1.6; + color: var(--text-color); } + a { - color: #18181b; - text-decoration-color: #abcbfb; + color: var(--secondary-color); + text-decoration: none; + transition: color 0.3s ease; } + a:hover { - color: #111; + color: var(--primary-color); } + label { cursor: pointer; - color: #444; + color: var(--text-color); + font-weight: 500; } + h1, h2, h3, h4 { - font-weight: 400; -} -.section { - margin-bottom: 45px; + font-weight: 600; + color: var(--primary-color); + margin-top: 0; } -input[type='text'], -input[type='email'], -input[type='password'], -select { - padding: 10px 15px; - border: 1px solid #888; - border-radius: 3px; - width: 100%; - box-shadow: 2px 2px 0 #f3f3f3; - border: 1px solid #ddd; - font-size: 1em; +.section { + margin-bottom: 2.5rem; } -input:focus { - border-color: #18181b; + +input[type="text"], +input[type="email"], +input[type="password"], +select { + padding: 0.75rem 1rem; + border: 1px solid var(--border-color); + border-radius: 0.375rem; + width: 100%; + font-size: 1rem; + transition: border-color 0.3s ease, box-shadow 0.3s ease; +} + +input:focus, +select:focus { + outline: none; + border-color: var(--secondary-color); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); } input:focus::placeholder { @@ -56,181 +81,130 @@ input:focus::placeholder { } input[disabled] { - opacity: 0.5; + opacity: 0.6; + cursor: not-allowed; } .center { text-align: center; } + .right { text-align: right; } + .error { - color: #ff5722; + color: #ef4444; + font-size: 0.875rem; } + .button { - background: #18181b; - padding: 15px 30px; - border-radius: 3px; + background: var(--primary-color); + padding: 0.75rem 1.5rem; + border-radius: 0.375rem; border: 0; cursor: pointer; text-decoration: none; - color: #ffff; + color: #ffffff; display: inline-block; min-width: 150px; - font-size: 1.1em; + font-size: 1rem; + font-weight: 500; text-align: center; + transition: background-color 0.3s ease, transform 0.1s ease; } + .button:hover { - background: #333; - color: #fff; -} + background: black; + color: #ffffff; + transform: translateY(-2px); + } + .button.button-outline { - background: #fff; - border: 1px solid #18181b; - color: #18181b; + background: #ffffff; + border: 1px solid var(--primary-color); + color: var(--primary-color); } + .button.button-outline:hover { - border-color: #333; - background-color: #333; - color: #fff; + background-color: var(--primary-color); + color: #ffffff; } .container { - margin: 60px auto 15px auto; - max-width: 550px; + margin: 3rem auto; + max-width: 600px; + padding: 0 1rem; } .wrap { - background: #fff; - padding: 40px; - box-shadow: 2px 2px 0 #f3f3f3; - border: 1px solid #eee; + background: #ffffff; + padding: 2.5rem; + border-radius: 0.5rem; + box-shadow: 0 4px 6px var(--shadow-color); } .header { - border-bottom: 1px solid #eee; - padding-bottom: 15px; - margin-bottom: 30px; + border-bottom: 1px solid var(--border-color); + padding-bottom: 1rem; + margin-bottom: 2rem; } + .header .logo img { width: auto; max-width: 150px; } -.unsub-all { - margin-top: 30px; - padding-top: 30px; - border-top: 1px solid #eee; +.row { + margin-bottom: 1.5rem; } -.row { - margin-bottom: 20px; -} -.lists { - list-style-type: none; - padding: 0; -} -.lists li { - margin: 0 0 5px 0; -} -.lists .description { - margin: 0 0 15px 0; - font-size: 0.875em; - line-height: 1.3rem; - color: #888; - margin-left: 25px; -} .form .nonce { display: none; } + .form .captcha { - margin-top: 30px; + margin-top: 2rem; } .archive { list-style-type: none; - margin: 25px 0 0 0; + margin: 1.5rem 0 0 0; padding: 0; } + .archive .date { display: block; - color: #666; - font-size: 0.875em; -} -.archive li { - margin-bottom: 15px; -} -.feed { - margin-right: 15px; -} - -.home-options { - margin-top: 30px; -} -.home-options a { - margin: 0 7px; -} - -.pagination { - margin-top: 30px; - text-align: center; -} -.pg-page { - display: inline-block; - padding: 0 10px; - text-decoration: none; -} -.pg-page.pg-selected { - text-decoration: underline; - font-weight: bold; -} - -.login .submit { - margin-top: 20px; -} -.login button { - width: 100%; - vertical-align: middle; - display: flex; - align-items: center; - justify-content: center; -} -.login button img { - max-width: 24px; - margin-right: 10px; -} - -#btn-back { - display: none; + color: #6b7280; + font-size: 0.875rem; } footer.container { - margin-top: 15px; + margin-top: 2rem; text-align: center; - color: #aaa; - font-size: 0.775em; - margin-top: 30px; - margin-bottom: 30px; + color: #9ca3af; + font-size: 0.875rem; } + footer a { - color: #aaa; + color: #9ca3af; text-decoration: none; } + footer a:hover { - color: #111; + color: var(--text-color); +} + +.green { + color: #10b981; } @media screen and (max-width: 650px) { .wrap { - margin: 0; - padding: 30px; - max-width: none; + padding: 1.5rem; + } + + .container { + margin: 1.5rem auto; } } - -.green { - color: green; -} - - diff --git a/static/public/web-templates/csat.html b/static/public/web-templates/csat.html index 0d83e982..3ac08530 100644 --- a/static/public/web-templates/csat.html +++ b/static/public/web-templates/csat.html @@ -2,87 +2,106 @@ {{ template "header" . }}
-

How was your experience?

-

Re: {{ .conversation.subject }}

+

Rate your experience

+

{{ .Data.Conversation.Subject }} - #{{ .Data.Conversation.ReferenceNumber + }}

-
+
- +
-
+ +
- +
+ + -{{ template "footer" . }} +{{ template "footer" }} {{ end }} \ No newline at end of file diff --git a/static/public/web-templates/error.html b/static/public/web-templates/error.html index c6232fe5..984776ee 100644 --- a/static/public/web-templates/error.html +++ b/static/public/web-templates/error.html @@ -2,8 +2,8 @@ {{ template "header" . }}
- {{ if .error_message }} -

{{ .error_message }}

+ {{ if .Data.ErrorMessage }} +

{{ .Data.ErrorMessage }}

{{ end }}
diff --git a/static/public/web-templates/index.html b/static/public/web-templates/index.html index 8a606a17..1c0b33d1 100644 --- a/static/public/web-templates/index.html +++ b/static/public/web-templates/index.html @@ -3,28 +3,25 @@ - {{ .Data.Title }} - {{ .SiteName }} - + {{ .Data.Title }} - {{ SiteName }} - - - + - {{ if ne .FaviconURL "" }} - + {{ if ne FaviconURL "" }} + {{ else }} - + {{ end }}
@@ -33,7 +30,6 @@ {{ define "footer" }}
- diff --git a/static/public/web-templates/info.html b/static/public/web-templates/info.html index 5c48095a..1a0ccf79 100644 --- a/static/public/web-templates/info.html +++ b/static/public/web-templates/info.html @@ -2,9 +2,11 @@ {{ template "header" . }}
- {{ if .message }} -

{{ .message }}

+

{{ .Data.Title }}

+ {{ if .Data.Message }} +

{{ .Data.Message }}

{{ end }} +
{{ template "footer" . }}