feat: add CSAT sending functionality and improve error handling in templates

feat: automation action to send CSAT.
- minor refactors.
This commit is contained in:
Abhinav Raut
2025-02-02 21:03:09 +05:30
parent d7ac76824f
commit d3c2cc2527
27 changed files with 415 additions and 400 deletions
+9 -31
View File
@@ -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: <a href=\"%s\">Rate now</a>", 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 (
+45 -21
View File
@@ -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.",
},
})
}
+1 -1
View File
@@ -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)
+8 -1
View File
@@ -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
+2 -1
View File
@@ -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)
)
@@ -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) {
@@ -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) {
@@ -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) {
@@ -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')
@@ -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) {
@@ -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')
@@ -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,
+8 -2
View File
@@ -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)
}
}
+1
View File
@@ -17,6 +17,7 @@ const (
ActionReply = "send_reply"
ActionSetSLA = "set_sla"
ActionSetTags = "set_tags"
ActionSendCSAT = "send_csat"
OperatorAnd = "AND"
OperatorOR = "OR"
+72 -55
View File
@@ -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: <a href=\"%s\">Rate now</a>"
)
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
}
+3 -4
View File
@@ -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
}
+1 -23
View File
@@ -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
+15 -6
View File
@@ -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)
}
+4 -14
View File
@@ -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
}
+11
View File
@@ -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
}
+2 -2
View File
@@ -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
}
+4
View File
@@ -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)
}
+110 -136
View File
@@ -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;
}
+96 -81
View File
@@ -2,87 +2,106 @@
{{ template "header" . }}
<div class="csat-container">
<div class="csat-header">
<h1>How was your experience?</h1>
<p class="conversation-subject">Re: {{ .conversation.subject }}</p>
<h1>Rate your experience</h1>
<p class="conversation-subject"><i>{{ .Data.Conversation.Subject }}</i> - #{{ .Data.Conversation.ReferenceNumber
}}</p>
</div>
<form action="{{ .RootURL }}/csat/{{ .csat.uuid }}" method="POST" class="csat-form">
<form action="/csat/{{ .Data.CSAT.UUID }}" method="POST" class="csat-form" novalidate>
<div class="rating-container">
<label class="rating-label">How would you rate your interaction?</label>
<label class="rating-label">We would greatly appreciate if you could rate your recent interaction with us to
help us improve the quality of our services.</label>
<div class="rating-options">
<input type="radio" id="rating-1" name="rating" value="1" required>
<label for="rating-1" class="rating-option">
<label for="rating-1" class="rating-option" tabindex="0">
<div class="emoji-wrapper">
<span class="emoji">😢</span>
</div>
<span class="rating-text">Very Dissatisfied</span>
<span class="rating-text">Poor</span>
</label>
<input type="radio" id="rating-2" name="rating" value="2">
<label for="rating-2" class="rating-option">
<label for="rating-2" class="rating-option" tabindex="0">
<div class="emoji-wrapper">
<span class="emoji">😕</span>
</div>
<span class="rating-text">Dissatisfied</span>
<span class="rating-text">Fair</span>
</label>
<input type="radio" id="rating-3" name="rating" value="3">
<label for="rating-3" class="rating-option">
<label for="rating-3" class="rating-option" tabindex="0">
<div class="emoji-wrapper">
<span class="emoji">😊</span>
</div>
<span class="rating-text">Neutral</span>
<span class="rating-text">Good</span>
</label>
<input type="radio" id="rating-4" name="rating" value="4">
<label for="rating-4" class="rating-option">
<label for="rating-4" class="rating-option" tabindex="0">
<div class="emoji-wrapper">
<span class="emoji">😃</span>
</div>
<span class="rating-text">Satisfied</span>
<span class="rating-text">Great</span>
</label>
<input type="radio" id="rating-5" name="rating" value="5">
<label for="rating-5" class="rating-option">
<label for="rating-5" class="rating-option" tabindex="0">
<div class="emoji-wrapper">
<span class="emoji">🤩</span>
</div>
<span class="rating-text">Very Satisfied</span>
<span class="rating-text">Excellent</span>
</label>
</div>
<!-- Validation message for rating -->
<div class="validation-message" id="ratingValidationMessage"
style="display: none; color: #dc2626; text-align: center; margin-top: 10px; font-size: 0.9em;">
Please select a rating before submitting.
</div>
</div>
<div class="feedback-container">
<label for="feedback" class="feedback-label">What made you feel this way? (optional)</label>
<textarea id="feedback" name="feedback" placeholder="Help us understand your experience better..." rows="4"
maxlength="1000" onkeyup="updateCharCount(this)"></textarea>
<label for="feedback" class="feedback-label">Additional feedback (optional)</label>
<textarea id="feedback" name="feedback" placeholder="Share your thoughts..." rows="6" maxlength="1000"
onkeyup="updateCharCount(this)"></textarea>
<div class="char-counter">
<span id="charCount">0</span>/1000 characters
<span id="charCount">0</span>/1000
</div>
<script>
function updateCharCount (textarea) {
const counter = document.getElementById('charCount');
const count = textarea.value.length;
counter.textContent = count;
// Add visual feedback as user approaches limit
const charCounter = counter.parentElement;
if (count > 900) {
charCounter.classList.add('near-limit');
} else {
charCounter.classList.remove('near-limit');
}
}
</script>
</div>
<button type="submit" class="button submit-button">
Submit Feedback
</button>
<button type="submit" class="button submit-button">Submit</button>
</form>
</div>
<script>
function updateCharCount (textarea) {
const counter = document.getElementById('charCount');
const count = textarea.value.length;
counter.textContent = count;
const charCounter = counter.parentElement;
if (count > 900) {
charCounter.classList.add('near-limit');
} else {
charCounter.classList.remove('near-limit');
}
}
// Make sure a rating is selected before submitting the form
document.querySelector('.csat-form').addEventListener('submit', function (e) {
const rating = document.querySelector('input[name="rating"]:checked');
const feedback = document.querySelector('#feedback');
if (!rating) {
e.preventDefault();
validationMsg.style.display = 'block';
// Hide the message after 10 seconds
setTimeout(() => {
validationMsg.style.display = 'none';
}, 10000);
}
});
</script>
<style>
.csat-container {
background: #fff;
@@ -150,6 +169,11 @@
transform: translateY(-3px);
}
.rating-option:focus {
outline: 2px solid #0055d4;
border-radius: 8px;
}
.rating-options input[type="radio"]:checked+.rating-option {
transform: translateY(-3px);
}
@@ -243,58 +267,49 @@
font-weight: 500;
}
.submit-button {
width: 100%;
margin-top: 20px;
padding: 15px 30px;
font-size: 1.1em;
font-weight: 500;
transition: all 0.3s ease;
}
@media screen and (max-width: 650px) {
.char-counter {
font-size: 0.85em;
.csat-container {
margin: 0;
padding: 30px;
border-radius: 0;
}
.submit-button {
.rating-options {
flex-direction: column;
}
.rating-option {
flex-direction: row;
justify-content: flex-start;
gap: 15px;
width: 100%;
margin-top: 20px;
padding: 15px 30px;
font-size: 1.1em;
font-weight: 500;
transition: all 0.3s ease;
padding: 15px;
}
.submit-button:hover {
background-color: #004bb8;
transform: translateY(-1px);
.emoji-wrapper {
margin-bottom: 0;
width: 50px;
height: 50px;
}
@media screen and (max-width: 650px) {
.csat-container {
margin: 0;
padding: 30px;
border-radius: 0;
}
.rating-options {
flex-direction: column;
}
.rating-option {
flex-direction: row;
justify-content: flex-start;
gap: 15px;
width: 100%;
padding: 15px;
}
.emoji-wrapper {
margin-bottom: 0;
width: 50px;
height: 50px;
}
.emoji {
font-size: 1.8em;
}
.rating-text {
text-align: left;
}
.emoji {
font-size: 1.8em;
}
.rating-text {
text-align: left;
}
}
</style>
{{ template "footer" . }}
{{ template "footer" }}
{{ end }}
+2 -2
View File
@@ -2,8 +2,8 @@
{{ template "header" . }}
<section class="center">
{{ if .error_message }}
<p class="error">{{ .error_message }}</p>
{{ if .Data.ErrorMessage }}
<p class="error">{{ .Data.ErrorMessage }}</p>
{{ end }}
</section>
+8 -12
View File
@@ -3,28 +3,25 @@
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>{{ .Data.Title }} - {{ .SiteName }}</title>
<meta name="description" content="{{ .Data.Description }}" />
<title>{{ .Data.Title }} - {{ SiteName }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
<link href="/static/public/static/style.css?v={{ .AssetVersion }}" rel="stylesheet" type="text/css" />
<link href="/public/custom.css?v={{ .AssetVersion }}" rel="stylesheet" type="text/css">
<script src="/public/custom.js?v={{ .AssetVersion }}" async defer></script>
<link href="/static/public/static/style.css" rel="stylesheet" type="text/css" />
{{ if ne .FaviconURL "" }}
<link rel="icon" href="{{ .FaviconURL }}" type="image/x-icon" />
{{ if ne FaviconURL "" }}
<link rel="icon" href="{{ FaviconURL }}" type="image/x-icon" />
{{ else }}
<link rel="icon" href="/public/static/favicon.png?v={{ .AssetVersion }}" type="image/png" />
<link rel="icon" href="/public/static/favicon.png" type="image/png" />
{{ end }}
</head>
<body>
<div class="container wrap">
<header class="header">
<div class="logo">
{{ if ne .LogoURL "" }}
<img src="{{ LogoURL }}" alt="{{ .Data.Title }}" /></a>
{{ if ne LogoURL "" }}
<img src="{{ LogoURL }}" alt="{{ SiteName }}" />
{{ else }}
<img src="/public/static/logo.svg?v={{ .AssetVersion }}" alt="{{ .Data.Title }}" />
<img src="/public/static/logo.svg" alt="{{ SiteName }}" />
{{ end }}
</a>
</div>
@@ -33,7 +30,6 @@
{{ define "footer" }}
</div>
<footer class="container">
Powered by <a target="_blank" rel="noreferrer" href="https://libredesk.io/">LibreDesk</a>
</footer>
+4 -2
View File
@@ -2,9 +2,11 @@
{{ template "header" . }}
<section class="center">
{{ if .message }}
<p class="green">{{ .message }}</p>
<h1>{{ .Data.Title }}</h1>
{{ if .Data.Message }}
<p class="green">{{ .Data.Message }}</p>
{{ end }}
</section>
{{ template "footer" . }}