Files
libredesk/cmd/csat.go
Abhinav Raut c3a7534734 Merge remote-tracking branch 'origin/feat-whatsapp' into feat-telegram
# Conflicts:
#	README.md
#	cmd/init.go
#	cmd/upgrade.go
#	frontend/apps/main/src/api/index.js
#	frontend/apps/main/src/constants/emitterEvents.js
#	frontend/apps/main/src/features/admin/inbox/LivechatInboxForm.vue
#	frontend/apps/main/src/features/admin/inbox/WhatsAppInboxForm.vue
#	frontend/apps/main/src/features/conversation/ReplyBox.vue
#	frontend/apps/main/src/features/conversation/ReplyBoxContent.vue
#	frontend/apps/main/src/features/conversation/ReplyBoxMenuBar.vue
#	frontend/apps/main/src/features/conversation/list/ConversationListItem.vue
#	frontend/apps/main/src/features/conversation/message/MessageBubble.vue
#	frontend/apps/main/src/features/conversation/sidebar/ConversationInfo.vue
#	frontend/apps/main/src/views/admin/whatsapp/NewWhatsAppTemplate.vue
#	i18n/en-US.json
#	internal/conversation/conversation.go
#	internal/conversation/message.go
#	internal/conversation/queries.sql
#	internal/conversation/whatsapp.go
#	internal/csat/csat.go
#	internal/csat/queries.sql
2026-09-27 11:25:32 +05:30

237 lines
6.5 KiB
Go

package main
import (
"encoding/json"
"strconv"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
type csatResponse struct {
Rating int `json:"rating"`
Feedback string `json:"feedback"`
}
const (
maxCsatFeedbackLength = 1000
maxCsatMetaKeys = 100
maxCsatMetaKeyLength = 100
maxCsatMetaValLength = 1000
)
// handleShowCSAT renders the CSAT page for a given csat.
func handleShowCSAT(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
)
csat, err := app.csat.Get(uuid)
if err != nil {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{
"ErrorMessage": app.i18n.T("globals.messages.pageNotFound"),
},
})
}
if csat.ResponseTimestamp.Valid && !csat.FeedbackPending() {
return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
"Data": map[string]interface{}{
"Title": app.i18n.T("globals.messages.thankYou"),
"Message": app.i18n.T("csat.thankYouMessage"),
},
})
}
conversation, err := app.conversation.GetConversation(csat.ConversationID, "", "")
if err != nil {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{
"ErrorMessage": app.i18n.T("globals.messages.pageNotFound"),
},
})
}
return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{
"Data": map[string]interface{}{
"Title": app.i18n.T("csat.pageTitle"),
"CSAT": map[string]interface{}{
"UUID": csat.UUID,
"Rating": csat.Rating,
"FeedbackPending": csat.FeedbackPending(),
},
"Conversation": map[string]interface{}{
"Subject": conversation.Subject.String,
"ReferenceNumber": conversation.ReferenceNumber,
},
},
})
}
// handleUpdateCSATResponse updates the CSAT response for a given csat.
func handleUpdateCSATResponse(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
)
rating, feedback, metaJSON, errKey := validateCSATForm(r)
if errKey != "" {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{
"ErrorMessage": app.i18n.T(errKey),
},
})
}
if err := app.csat.UpdateResponse(uuid, rating, feedback, metaJSON); err != nil {
// Surface only known, user-safe messages; never leak raw driver errors
// (e.g. a malformed UUID triggers a Postgres syntax error string).
msg := app.i18n.T("globals.messages.somethingWentWrong")
if e, ok := err.(envelope.Error); ok {
msg = e.Error()
}
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{
"ErrorMessage": msg,
},
})
}
return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
"Data": map[string]interface{}{
"Title": app.i18n.T("globals.messages.thankYou"),
"Message": app.i18n.T("csat.thankYouMessage"),
},
})
}
// handleShowCSATWidget renders a minimal CSAT widget page (just stars) for iframe embedding.
func handleShowCSATWidget(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
)
csat, err := app.csat.Get(uuid)
if err != nil {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{
"ErrorMessage": app.i18n.T("globals.messages.pageNotFound"),
},
})
}
return app.tmpl.RenderWebPage(r.RequestCtx, "csat-widget", map[string]interface{}{
"Data": map[string]interface{}{
"CSAT": map[string]interface{}{
"UUID": csat.UUID,
"Responded": csat.ResponseTimestamp.Valid,
},
},
})
}
// handleSubmitCSATResponse handles CSAT response submission from the widget API.
func handleSubmitCSATResponse(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
req = csatResponse{}
)
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid JSON", nil, envelope.InputError)
}
if req.Rating < 0 || req.Rating > 5 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Rating must be between 0 and 5 (0 means no rating)", nil, envelope.InputError)
}
// At least one of rating or feedback must be provided
if req.Rating == 0 && req.Feedback == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Either rating or feedback must be provided", nil, envelope.InputError)
}
if uuid == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid UUID", nil, envelope.InputError)
}
req.Feedback = truncateFeedback(req.Feedback)
// Update CSAT response
if err := app.csat.UpdateResponse(uuid, req.Rating, req.Feedback, nil); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// validateCSATForm parses and validates the CSAT form submission.
// Returns rating (0 if not provided), trimmed feedback, meta JSON, and error message key if invalid.
func validateCSATForm(r *fastglue.Request) (int, string, json.RawMessage, string) {
var (
feedback = string(r.RequestCtx.FormValue("feedback"))
rating int
)
// Rating is optional (0 = not provided). If provided, must be 1-5.
if rs := string(r.RequestCtx.FormValue("rating")); rs != "" {
v, err := strconv.Atoi(rs)
if err != nil || v < 1 || v > 5 {
return 0, "", nil, "globals.messages.somethingWentWrong"
}
rating = v
}
// At least one of rating or feedback must be provided.
if rating == 0 && feedback == "" {
return 0, "", nil, "csat.pleaseFillRequired"
}
feedback = truncateFeedback(feedback)
// Collect extra form fields into meta, skipping the known fields.
meta := make(map[string]string)
r.RequestCtx.PostArgs().VisitAll(func(key, value []byte) {
k := string(key)
if k == "rating" || k == "feedback" {
return
}
if len(meta) >= maxCsatMetaKeys {
return
}
if len(k) > maxCsatMetaKeyLength {
k = k[:maxCsatMetaKeyLength]
}
v := string(value)
if len(v) > maxCsatMetaValLength {
v = v[:maxCsatMetaValLength]
}
meta[k] = v
})
metaJSON, err := json.Marshal(meta)
if err != nil {
app := r.Context.(*App)
app.lo.Error("error marshalling CSAT meta", "error", err)
metaJSON = []byte(`{}`)
}
return rating, feedback, metaJSON, ""
}
func truncateFeedback(s string) string {
if len(s) <= maxCsatFeedbackLength {
return s
}
r := []rune(s)
if len(r) <= maxCsatFeedbackLength {
return s
}
return string(r[:maxCsatFeedbackLength])
}