From 88a58c0f3be4f4a9c1925a3b12ee96dbcbcb8bcc Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Thu, 9 Apr 2026 00:04:00 +0530 Subject: [PATCH] Custom static directory support, self hosted installations can pass static dir path to override csat and other web-templates, js and css files. Allowing instances to be fully customizable. - New meta JSONB field in CSAT responses to capture random adhoc data as that might be needed for custom CSAT pages. - Add support prefilled rating for csat pages picked from query param --- cmd/csat.go | 98 +++++++++++++------ cmd/handlers.go | 4 +- cmd/init.go | 65 +++++++++++- cmd/main.go | 13 ++- config.sample.toml | 4 + .../features/admin/templates/TemplateForm.vue | 6 +- .../features/admin/templates/formSchema.js | 2 +- .../widget/src/components/ChatMessages.vue | 4 +- .../widget/src/components/MessageInput.vue | 3 +- frontend/apps/widget/src/store/chat.js | 1 + frontend/apps/widget/src/views/ChatView.vue | 3 +- i18n/da.json | 1 + i18n/de.json | 1 + i18n/en.json | 1 + i18n/es.json | 1 + i18n/fa.json | 1 + i18n/fr.json | 1 + i18n/it.json | 1 + i18n/ja.json | 1 + i18n/mr.json | 1 + internal/conversation/conversation.go | 16 ++- internal/conversation/message.go | 8 +- internal/csat/csat.go | 11 ++- internal/csat/models/models.go | 18 ++-- internal/csat/queries.sql | 2 + internal/migrations/v2.0.0.go | 49 ++++++++++ internal/template/render.go | 11 +++ schema.sql | 39 ++++++++ static/public/web-templates/csat.html | 8 ++ 29 files changed, 315 insertions(+), 59 deletions(-) diff --git a/cmd/csat.go b/cmd/csat.go index 739b4d3f..0b1b78cb 100644 --- a/cmd/csat.go +++ b/cmd/csat.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "strconv" "github.com/abhinavxd/libredesk/internal/envelope" @@ -12,8 +13,12 @@ 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. @@ -67,43 +72,20 @@ func handleShowCSAT(r *fastglue.Request) error { // 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 = r.RequestCtx.FormValue("rating") - feedback = string(r.RequestCtx.FormValue("feedback")) + app = r.Context.(*App) + uuid = r.RequestCtx.UserValue("uuid").(string) ) - ratingI, err := strconv.Atoi(string(rating)) - if err != nil { + 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("globals.messages.somethingWentWrong"), + "ErrorMessage": app.i18n.T(errKey), }, }) } - if ratingI < 0 || ratingI > 5 { - return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ - "Data": map[string]interface{}{ - "ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"), - }, - }) - } - - if uuid == "" { - return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ - "Data": map[string]interface{}{ - "ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"), - }, - }) - } - - // Trim feedback if it exceeds max length - if len(feedback) > maxCsatFeedbackLength { - feedback = feedback[:maxCsatFeedbackLength] - } - - if err := app.csat.UpdateResponse(uuid, ratingI, feedback); err != nil { + if err := app.csat.UpdateResponse(uuid, rating, feedback, metaJSON); err != nil { return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ "Data": map[string]interface{}{ "ErrorMessage": err.Error(), @@ -119,6 +101,62 @@ func handleUpdateCSATResponse(r *fastglue.Request) error { }) } +// 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" + } + + if len(feedback) > maxCsatFeedbackLength { + feedback = feedback[:maxCsatFeedbackLength] + } + + // 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, "" +} + // handleSubmitCSATResponse handles CSAT response submission from the widget API. func handleSubmitCSATResponse(r *fastglue.Request) error { var ( @@ -150,7 +188,7 @@ func handleSubmitCSATResponse(r *fastglue.Request) error { } // Update CSAT response - if err := app.csat.UpdateResponse(uuid, req.Rating, req.Feedback); err != nil { + if err := app.csat.UpdateResponse(uuid, req.Rating, req.Feedback, nil); err != nil { return sendErrorEnvelope(r, err) } diff --git a/cmd/handlers.go b/cmd/handlers.go index ca167282..36ade272 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -360,11 +360,10 @@ func serveWidgetIndexPage(r *fastglue.Request) error { return nil } -// serveStaticFiles serves static assets from the embedded filesystem. +// serveStaticFiles serves static assets from the filesystem. func serveStaticFiles(r *fastglue.Request) error { app := r.Context.(*App) - // Get the requested file path. filePath := string(r.RequestCtx.Path()) file, err := app.fs.Get(filePath) @@ -372,7 +371,6 @@ func serveStaticFiles(r *fastglue.Request) error { return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.T("validation.notFoundFile"), nil, envelope.NotFoundError) } - // Set the appropriate Content-Type based on the file extension. ext := filepath.Ext(filePath) contentType := mime.TypeByExtension(ext) if contentType == "" { diff --git a/cmd/init.go b/cmd/init.go index 4620b763..824813e4 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -137,6 +137,7 @@ func initFlags() { f.Bool("yes", false, "skip confirmation prompt") f.Bool("upgrade", false, "upgrade the database schema") f.Bool("set-system-user-password", false, "set password for the system user") + f.String("static-dir", "", "path to a directory with custom static files and templates to override the defaults") if err := f.Parse(os.Args[1:]); err != nil { log.Fatalf("loading flags: %v", err) @@ -160,8 +161,16 @@ func initConstants() *constants { } } -// initFS initializes the stuffbin FileSystem. -func initFS() stuffbin.FileSystem { +// initFS initializes the stuffbin FileSystem. If staticDir is set, files from +// that directory are merged into the FS, overriding embedded defaults. +func initFS(staticDir string) stuffbin.FileSystem { + // Paths in the custom static dir that map to stuffbin virtual paths. + staticFiles := []string{ + "./static:static/public/static", + "./web-templates:static/public/web-templates", + "./email-templates:static/email-templates", + } + // Get self executable path. path, err := os.Executable() if err != nil { @@ -190,9 +199,43 @@ func initFS() stuffbin.FileSystem { log.Fatalf("error initializing FS: %v", err) } } + + // Merge custom static files if a custom static dir is provided. + if staticDir != "" { + // Only include paths that exist in the custom dir. + var sf []string + for _, def := range staticFiles { + src := strings.Split(def, ":")[0] + if _, err := os.Stat(filepath.Join(staticDir, src)); err == nil { + sf = append(sf, def) + } + } + + if len(sf) > 0 { + files := joinFSPaths(staticDir, sf) + fLocal, err := stuffbin.NewLocalFS("/", files...) + if err != nil { + log.Fatalf("error loading custom static files from '%s': %v", staticDir, err) + } + if err := fs.Merge(fLocal); err != nil { + log.Fatalf("error merging custom static files from '%s': %v", staticDir, err) + } + } + } + return fs } +// joinFSPaths joins a root directory with stuffbin path specs (local:virtual). +func joinFSPaths(root string, paths []string) []string { + out := make([]string, 0, len(paths)) + for _, p := range paths { + f := strings.Split(p, ":") + out = append(out, filepath.Join(root, f[0])+":"+f[1]) + } + return out +} + // loadSettings loads settings from the DB into Koanf map. func loadSettings(m *setting.Manager) { j, err := m.GetAllJSON() @@ -363,6 +406,22 @@ func initWS(user *user.Manager) *ws.Hub { return ws.NewHub(user) } +// getCustomStaticDir returns the custom static directory path from CLI flag or config. +func getCustomStaticDir() string { + dir := ko.String("static-dir") + if dir == "" { + dir = ko.String("app.static_dir") + } + if dir == "" { + return "" + } + abs, err := filepath.Abs(dir) + if err != nil { + return dir + } + return abs +} + // initTemplates inits template manager. func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *i18n.I18n) *tmpl.Manager { var ( @@ -377,6 +436,7 @@ func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n * if err != nil { log.Fatalf("error parsing web templates: %v", err) } + m, err := tmpl.New(lo, db, webTpls, tpls, funcMap, i18n) if err != nil { log.Fatalf("error initializing template manager: %v", err) @@ -450,6 +510,7 @@ func reloadTemplates(app *App) error { app.lo.Error("error parsing web templates", "error", err) return err } + return app.tmpl.Reload(webTpls, tpls, funcMap) } diff --git a/cmd/main.go b/cmd/main.go index 9711ad1c..e93dfde7 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -137,7 +137,16 @@ func main() { // Load the config files into Koanf. initConfig(ko) - fs := initFS() + // Validate custom static directory if provided. + if dir := getCustomStaticDir(); dir != "" { + if _, err := os.Stat(dir); err != nil { + log.Fatalf("--static-dir path not accessible: %s: %v", dir, err) + } + colorlog.Green("Using custom static directory: %s", dir) + } + + // Init stuffbin fs with optional custom static dir overlay. + fs := initFS(getCustomStaticDir()) db := initDB() @@ -253,6 +262,7 @@ func main() { inbox: inbox, user: user, team: team, + csat: csat, status: status, priority: priority, tmpl: template, @@ -266,7 +276,6 @@ func main() { authz: initAuthz(i18n), view: initView(db, i18n), report: initReport(db, i18n), - csat: initCSAT(db, i18n), search: initSearch(db, i18n), role: initRole(db, i18n), tag: initTag(db, i18n), diff --git a/config.sample.toml b/config.sample.toml index 5e59f4ba..e487b060 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -8,6 +8,10 @@ env = "dev" check_updates = true # Encryption key. Generate using `openssl rand -hex 16` must be 32 characters long. encryption_key = "your-32-char-random-string-here!" +# Path to a directory with custom static files and templates to override the defaults. +# Place overrides in web-templates/, email-templates/, and static/ subdirectories. +# Only the files you provide will be replaced; the rest use built-in defaults. +# static_dir = "/path/to/custom/static" # HTTP server. [app.server] diff --git a/frontend/apps/main/src/features/admin/templates/TemplateForm.vue b/frontend/apps/main/src/features/admin/templates/TemplateForm.vue index a27807b4..c88a0c13 100644 --- a/frontend/apps/main/src/features/admin/templates/TemplateForm.vue +++ b/frontend/apps/main/src/features/admin/templates/TemplateForm.vue @@ -14,7 +14,7 @@ - + {{ $t('globals.terms.subject') }} @@ -117,6 +117,10 @@ const isOutgoingTemplate = computed(() => { return props.initialValues?.type === 'email_outgoing' }) +const hideSubject = computed(() => { + return isOutgoingTemplate.value || props.initialValues?.name === 'CSAT request' +}) + // Watch for changes in initialValues and update the form. watch( () => props.initialValues, diff --git a/frontend/apps/main/src/features/admin/templates/formSchema.js b/frontend/apps/main/src/features/admin/templates/formSchema.js index 922c0942..d68b5d56 100644 --- a/frontend/apps/main/src/features/admin/templates/formSchema.js +++ b/frontend/apps/main/src/features/admin/templates/formSchema.js @@ -13,7 +13,7 @@ export const createFormSchema = (t) => z is_default: z.boolean().optional().default(false), }) .superRefine((data, ctx) => { - if (data.type !== 'email_outgoing' && !data.subject) { + if (data.type !== 'email_outgoing' && data.name !== 'CSAT request' && !data.subject) { ctx.addIssue({ path: ['subject'], message: t('globals.messages.required'), diff --git a/frontend/apps/widget/src/components/ChatMessages.vue b/frontend/apps/widget/src/components/ChatMessages.vue index d5789073..2454099b 100644 --- a/frontend/apps/widget/src/components/ChatMessages.vue +++ b/frontend/apps/widget/src/components/ChatMessages.vue @@ -57,8 +57,10 @@ } ]" > - + + {{ message.content }} { stopTyping() // Convert text to HTML. - const messageText = convertTextToHtml(newMessage.value.trim()) + const messageText = newMessage.value.trim() // Clear input field immediately newMessage.value = '' diff --git a/frontend/apps/widget/src/store/chat.js b/frontend/apps/widget/src/store/chat.js index 048c6898..59d01980 100644 --- a/frontend/apps/widget/src/store/chat.js +++ b/frontend/apps/widget/src/store/chat.js @@ -76,6 +76,7 @@ export const useChatStore = defineStore('chat', () => { // Pending message is a temporary message that will be replaced with actual message later after sending. const pendingMessage = { content: messageText, + content_type: 'text', author: { type: authorType, id: authorId, diff --git a/frontend/apps/widget/src/views/ChatView.vue b/frontend/apps/widget/src/views/ChatView.vue index a6f0aebe..b346a034 100644 --- a/frontend/apps/widget/src/views/ChatView.vue +++ b/frontend/apps/widget/src/views/ChatView.vue @@ -34,7 +34,6 @@ import { useWidgetStore } from '../store/widget.js' import { useUserStore } from '../store/user.js' import { useChatStore } from '../store/chat.js' import { handleHTTPError } from '@shared-ui/utils/http.js' -import { convertTextToHtml } from '@shared-ui/utils/string.js' import api, { establishSession } from '@widget/api/index.js' import WidgetError from '@widget/components/WidgetError.vue' import ChatHeader from '@widget/components/ChatHeader.vue' @@ -105,7 +104,7 @@ const handlePreChatFormSubmit = async ({ formData, message }) => { try { const payload = { - message: convertTextToHtml(message) + message: message } if (Object.keys(formData).length > 0) { diff --git a/i18n/da.json b/i18n/da.json index 13760adb..fb7a5fbf 100644 --- a/i18n/da.json +++ b/i18n/da.json @@ -497,6 +497,7 @@ "conversationStatus.alreadyInUse": "Kan ikke slette status, da den er i brug. Fjern denne status fra alle samtaler inden sletning", "conversationStatus.cannotUpdateDefault": "Kan ikke opdatere standard samtalestatus", "csat.alreadySubmitted": "CSAT allerede indsendt", + "csat.pleaseFillRequired": "Angiv venligst en bedømmelse eller feedback.", "csat.pageTitle": "Bedøm interaktionen med os", "csat.rateYourInteraction": "Bedøm den seneste interaktion", "csat.thankYouMessage": "Vi sætter pris på, at brugere tager sig tid til at indsende feedback.", diff --git a/i18n/de.json b/i18n/de.json index e8c16236..a952b7df 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -497,6 +497,7 @@ "conversationStatus.alreadyInUse": "Status kann nicht gelöscht werden, da er gerade verwendet wird. Bitte entferne diesen Status von allen Konversationen bevor du ihn löschst", "conversationStatus.cannotUpdateDefault": "Standard Konversationsstatus kann nicht geändert werden", "csat.alreadySubmitted": "CSAT bereits übermittelt", + "csat.pleaseFillRequired": "Bitte geben Sie eine Bewertung oder ein Feedback ab.", "csat.pageTitle": "Bewerten Sie Ihre Interaktion mit uns", "csat.rateYourInteraction": "Kürzliche Interaktion bewerten", "csat.thankYouMessage": "Vielen Dank, dass Sie sich Zeit genommen haben Ihr Feedback zu übermitteln.", diff --git a/i18n/en.json b/i18n/en.json index 485b148f..f0231cbb 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -497,6 +497,7 @@ "conversationStatus.alreadyInUse": "Cannot delete status as it is in use, Please remove this status from all conversations before deleting", "conversationStatus.cannotUpdateDefault": "Cannot update default conversation status", "csat.alreadySubmitted": "CSAT already submitted", + "csat.pleaseFillRequired": "Please provide a rating or feedback.", "csat.pageTitle": "Rate your interaction with us", "csat.rateYourInteraction": "Rate your recent interaction", "csat.thankYouMessage": "We appreciate you taking the time to submit your feedback.", diff --git a/i18n/es.json b/i18n/es.json index 4a5d3b3d..0394bebe 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -497,6 +497,7 @@ "conversationStatus.alreadyInUse": "No se puede eliminar el estado ya que está en uso. Por favor elimina este estado de todas las conversaciones antes de eliminarlo", "conversationStatus.cannotUpdateDefault": "No se puede actualizar el estado de conversación predeterminado", "csat.alreadySubmitted": "CSAT ya enviado", + "csat.pleaseFillRequired": "Por favor, proporcione una calificacion o comentario.", "csat.pageTitle": "Califica tu interacción con nosotros", "csat.rateYourInteraction": "Califique su interaccion reciente", "csat.thankYouMessage": "Agradecemos que te hayas tomado el tiempo para enviar tus comentarios.", diff --git a/i18n/fa.json b/i18n/fa.json index 01a354d1..86c7c13d 100644 --- a/i18n/fa.json +++ b/i18n/fa.json @@ -497,6 +497,7 @@ "conversationStatus.alreadyInUse": "نمی‌توان وضعیت را حذف کرد زیرا در حال استفاده است، لطفاً قبل از حذف، این وضعیت را از تمام مکالمات حذف کنید", "conversationStatus.cannotUpdateDefault": "نمی‌توان وضعیت پیش‌فرض مکالمه را به‌روزرسانی کرد", "csat.alreadySubmitted": "نظرسنجی CSAT قبلاً ارسال شده است", + "csat.pleaseFillRequired": "لطفا یک امتیاز یا بازخورد ارائه دهید.", "csat.pageTitle": "تعامل خود با ما را امتیازدهی کنید", "csat.rateYourInteraction": "تعامل اخیر خود را ارزیابی کنید", "csat.thankYouMessage": "از اینکه وقت گذاشتید و بازخورد خود را ارسال کردید، قدردانی می‌کنیم.", diff --git a/i18n/fr.json b/i18n/fr.json index 1d70777d..75d3155e 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -497,6 +497,7 @@ "conversationStatus.alreadyInUse": "Impossible de supprimer le statut car il est en cours d'utilisation. Veuillez supprimer ce statut de toutes les conversations avant de le supprimer", "conversationStatus.cannotUpdateDefault": "Impossible de mettre à jour l'état de la conversation par défaut", "csat.alreadySubmitted": "CSAT déjà soumis", + "csat.pleaseFillRequired": "Veuillez fournir une note ou un commentaire.", "csat.pageTitle": "Noter votre interaction avec nous", "csat.rateYourInteraction": "Évaluez votre récente interaction", "csat.thankYouMessage": "Nous vous remercions de prendre le temps de nous faire part de vos commentaires.", diff --git a/i18n/it.json b/i18n/it.json index 92e6033e..68b9d719 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -497,6 +497,7 @@ "conversationStatus.alreadyInUse": "Impossibile eliminare lo stato come è in uso, Si prega di rimuovere questo stato da tutte le conversazioni prima di eliminare", "conversationStatus.cannotUpdateDefault": "Impossibile aggiornare lo stato predefinito della conversazione", "csat.alreadySubmitted": "Questionario già inviato", + "csat.pleaseFillRequired": "Fornisci una valutazione o un feedback.", "csat.pageTitle": "Valuta la tua interazione con noi", "csat.rateYourInteraction": "Valuta la tua interazione recente", "csat.thankYouMessage": "Apprezziamo il tempo necessario per inviare il tuo feedback.", diff --git a/i18n/ja.json b/i18n/ja.json index afee9e6a..1ab8cb28 100644 --- a/i18n/ja.json +++ b/i18n/ja.json @@ -497,6 +497,7 @@ "conversationStatus.alreadyInUse": "このステータスは使用中のため削除できません。削除する前に、すべての会話からこのステータスを削除してください", "conversationStatus.cannotUpdateDefault": "デフォルトの会話ステータスは更新できません", "csat.alreadySubmitted": "CSAT はすでに送信されました", + "csat.pleaseFillRequired": "評価またはフィードバックを入力してください。", "csat.pageTitle": "サポート対応の評価をお願いします", "csat.rateYourInteraction": "最近の対応を評価してください", "csat.thankYouMessage": "ご意見をお寄せいただき、ありがとうございます。", diff --git a/i18n/mr.json b/i18n/mr.json index 3b040e72..b765bc54 100644 --- a/i18n/mr.json +++ b/i18n/mr.json @@ -497,6 +497,7 @@ "conversationStatus.alreadyInUse": "वापरात असलेली स्थिती हटवू शकत नाही, कृपया ही स्थिती सर्व संभाषणांमधून काढा", "conversationStatus.cannotUpdateDefault": "डिफॉल्ट संभाषण स्थिती अद्ययावत करू शकत नाही", "csat.alreadySubmitted": "CSAT आधीच सबमिट केला आहे", + "csat.pleaseFillRequired": "कृपया रेटिंग किंवा अभिप्राय द्या.", "csat.pageTitle": "आमच्याशी असलेल्या तुमच्या संवादाला रेट करा", "csat.rateYourInteraction": "तुमच्या अलीकडील संवादाचे मूल्यांकन करा", "csat.thankYouMessage": "तुमचा अभिप्राय सबमिट केल्याबद्दल आम्ही कृतज्ञ आहोत.", diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index e85fb254..2f78c2fb 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -50,7 +50,6 @@ var ( conversationsAllowedFields = []string{"status_id", "priority_id", "assigned_team_id", "assigned_user_id", "inbox_id", "last_message_at", "last_interaction_at", "created_at", "waiting_since", "next_sla_deadline_at", "priority_id"} conversationStatusAllowedFields = []string{"id", "name"} usersAllowedFields = []string{"email"} - csatReplyMessage = "Please rate your experience with us: Rate now" ) const ( @@ -1342,7 +1341,20 @@ func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversatio return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil) } csatPublicURL := m.csatStore.MakePublicURL(appRootURL, csat.UUID) - message := fmt.Sprintf(csatReplyMessage, csatPublicURL) + + // Render CSAT email template. + data, err := m.BuildTemplateData(conversation.UUID, actorUserID) + if err != nil { + m.lo.Error("error building CSAT template data", "conversation_uuid", conversation.UUID, "error", err) + return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil) + } + data["CSATLink"] = csatPublicURL + message, err := m.template.RenderStoredTemplate(template.TmplCSATRequest, data) + if err != nil { + m.lo.Error("error rendering CSAT template", "conversation_uuid", conversation.UUID, "error", err) + return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil) + } + // Store `is_csat` meta to identify and filter CSAT public url from the message. meta := map[string]interface{}{ "is_csat": true, diff --git a/internal/conversation/message.go b/internal/conversation/message.go index 23ac86c8..294089db 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -547,8 +547,12 @@ func (m *Manager) InsertMessage(message *models.Message) error { message.ContentType = models.ContentTypeText } - // Convert HTML content to text for search. - message.TextContent = stringutil.HTML2Text(message.Content) + // Convert content to plain text for search. + if message.ContentType == models.ContentTypeText { + message.TextContent = message.Content + } else { + message.TextContent = stringutil.HTML2Text(message.Content) + } // Insert Message. if err := m.q.InsertMessage.Get(message, message.Type, message.Status, message.ConversationID, message.ConversationUUID, message.Content, message.TextContent, message.SenderID, message.SenderType, diff --git a/internal/csat/csat.go b/internal/csat/csat.go index d8b74697..b073ce84 100644 --- a/internal/csat/csat.go +++ b/internal/csat/csat.go @@ -4,6 +4,7 @@ package csat import ( "database/sql" "embed" + "encoding/json" "errors" "fmt" @@ -87,17 +88,21 @@ func (m *Manager) Get(uuid string) (models.CSATResponse, error) { } // UpdateResponse updates the CSAT response for the given csat. -func (m *Manager) UpdateResponse(uuid string, score int, feedback string) error { +func (m *Manager) UpdateResponse(uuid string, score int, feedback string, meta json.RawMessage) error { csat, err := m.Get(uuid) if err != nil { return err } - if csat.Rating > 0 || !csat.ResponseTimestamp.IsZero() { + if csat.ResponseTimestamp.Valid { return envelope.NewError(envelope.InputError, m.i18n.T("csat.alreadySubmitted"), nil) } - _, err = m.q.Update.Exec(uuid, score, feedback) + if len(meta) == 0 { + meta = json.RawMessage(`{}`) + } + + _, err = m.q.Update.Exec(uuid, score, feedback, meta) if err != nil { m.lo.Error("error updating CSAT", "error", err) return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil) diff --git a/internal/csat/models/models.go b/internal/csat/models/models.go index ff09ec1d..6aa536d0 100644 --- a/internal/csat/models/models.go +++ b/internal/csat/models/models.go @@ -2,6 +2,7 @@ package models import ( + "encoding/json" "time" "github.com/volatiletech/null/v9" @@ -9,12 +10,13 @@ import ( // CSATResponse represents a customer satisfaction survey response. type CSATResponse struct { - ID int `db:"id"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` - UUID string `db:"uuid"` - ConversationID int `db:"conversation_id"` - Rating int `db:"rating"` - Feedback null.String `db:"feedback"` - ResponseTimestamp null.Time `db:"response_timestamp"` + ID int `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + UUID string `db:"uuid" json:"uuid"` + ConversationID int `db:"conversation_id" json:"conversation_id"` + Rating int `db:"rating" json:"rating"` + Feedback null.String `db:"feedback" json:"feedback"` + Meta json.RawMessage `db:"meta" json:"meta"` + ResponseTimestamp null.Time `db:"response_timestamp" json:"response_timestamp"` } diff --git a/internal/csat/queries.sql b/internal/csat/queries.sql index 94e10104..202b23dc 100644 --- a/internal/csat/queries.sql +++ b/internal/csat/queries.sql @@ -13,6 +13,7 @@ SELECT id, conversation_id, rating, feedback, + meta, response_timestamp FROM csat_responses WHERE uuid = $1; @@ -21,5 +22,6 @@ WHERE uuid = $1; UPDATE csat_responses SET rating = $2, feedback = $3, + meta = COALESCE($4::jsonb, '{}'), response_timestamp = NOW() WHERE uuid = $1; diff --git a/internal/migrations/v2.0.0.go b/internal/migrations/v2.0.0.go index 79c97c88..d26423d7 100644 --- a/internal/migrations/v2.0.0.go +++ b/internal/migrations/v2.0.0.go @@ -188,5 +188,54 @@ func V2_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { return err } + // Add meta column to csat_responses. + _, err = db.Exec(`ALTER TABLE csat_responses ADD COLUMN IF NOT EXISTS meta JSONB DEFAULT '{}' NOT NULL;`) + if err != nil { + return err + } + + // Add built-in CSAT request email template. + _, err = db.Exec(` + INSERT INTO templates ("type", body, is_default, "name", subject, is_builtin) + VALUES ( + 'email_notification'::template_type, + ' +

Your recent conversation (#{{ .Conversation.ReferenceNumber }}) has been resolved. We would love to hear your feedback.

+

How would you rate your experience?

+ + + + + + + + +
+😢 +Poor + +😕 +Fair + +😊 +Good + +😃 +Great + +🤩 +Excellent +
+', + false, + 'CSAT request', + '', + true + ) ON CONFLICT DO NOTHING; + `) + if err != nil { + return err + } + return nil } diff --git a/internal/template/render.go b/internal/template/render.go index da9201ce..25b56af7 100644 --- a/internal/template/render.go +++ b/internal/template/render.go @@ -15,6 +15,7 @@ const ( TmplSLABreachWarning = "SLA breach warning" TmplSLABreached = "SLA breached" TmplMentioned = "Mentioned in conversation" + TmplCSATRequest = "CSAT request" // Built-in templates fetched from memory stored in `static` directory. TmplResetPassword = "reset-password" @@ -39,6 +40,16 @@ func (m *Manager) RenderString(data any, content string) string { return buf.String() } +// RenderStoredTemplate fetches a template by name and renders its body with the provided data +// without wrapping it in the base email template. +func (m *Manager) RenderStoredTemplate(name string, data any) (string, error) { + tmpl, err := m.getByName(name) + if err != nil { + return "", err + } + return m.RenderString(data, tmpl.Body), nil +} + // RenderEmailWithTemplate renders content inside the default outgoing email template. func (m *Manager) RenderEmailWithTemplate(data any, content string) (string, error) { m.mutex.RLock() diff --git a/schema.sql b/schema.sql index b58724e3..934ab627 100644 --- a/schema.sql +++ b/schema.sql @@ -483,6 +483,7 @@ CREATE TABLE csat_responses ( rating INT DEFAULT 0 NOT NULL, feedback TEXT NULL, + meta JSONB DEFAULT '{}' NOT NULL, response_timestamp TIMESTAMPTZ NULL, CONSTRAINT constraint_csat_responses_on_rating CHECK (rating >= 0 AND rating <= 5), CONSTRAINT constraint_csat_responses_on_feedback CHECK (length(feedback) <= 1000) @@ -875,3 +876,41 @@ Libredesk '{{ .MentionedBy.FullName }} mentioned you in conversation #{{ .Conversation.ReferenceNumber }}', true ); + +INSERT INTO templates +("type", body, is_default, "name", subject, is_builtin) +VALUES ( + 'email_notification'::template_type, + ' +

Your recent conversation (#{{ .Conversation.ReferenceNumber }}) has been resolved. We would love to hear your feedback.

+

How would you rate your experience?

+ + + + + + + + +
+😢 +Poor + +😕 +Fair + +😊 +Good + +😃 +Great + +🤩 +Excellent +
+', + false, + 'CSAT request', + '', + true +); diff --git a/static/public/web-templates/csat.html b/static/public/web-templates/csat.html index 67f6031a..94519eb7 100644 --- a/static/public/web-templates/csat.html +++ b/static/public/web-templates/csat.html @@ -89,6 +89,14 @@ }); }); + // Pre-select from ?rating= query param (e.g. from email rating links). + var params = new URLSearchParams(window.location.search); + var initial = params.get('rating'); + if (initial) { + var radio = document.getElementById('rating-' + initial); + if (radio) radio.checked = true; + } + // Stagger entrance animation document.querySelectorAll('.rating-option').forEach(function(el, i) { el.style.animationDelay = (i * 0.06) + 's';