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
This commit is contained in:
Abhinav Raut
2026-04-09 00:04:00 +05:30
parent e7eac6eb94
commit 88a58c0f3b
29 changed files with 315 additions and 59 deletions
+68 -30
View File
@@ -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)
}
+1 -3
View File
@@ -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 == "" {
+63 -2
View File
@@ -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)
}
+11 -2
View File
@@ -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),
+4
View File
@@ -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]
@@ -14,7 +14,7 @@
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="subject" v-if="!isOutgoingTemplate">
<FormField v-slot="{ componentField }" name="subject" v-if="!hideSubject">
<FormItem>
<FormLabel>{{ $t('globals.terms.subject') }}</FormLabel>
<FormControl>
@@ -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,
@@ -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'),
@@ -57,8 +57,10 @@
}
]"
>
<!-- Message content rendered using vue-letter -->
<!-- Message content -->
<span v-if="message.content_type === 'text'" class="mb-1 whitespace-pre-wrap">{{ message.content }}</span>
<Letter
v-else
:html="message.content"
:allowedSchemas="['cid', 'https', 'http', 'mailto']"
class="mb-1 native-html"
@@ -59,7 +59,6 @@ import { useChatStore } from '../store/chat.js'
import { useUserStore } from '@widget/store/user.js'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { sendWidgetTyping } from '../websocket.js'
import { convertTextToHtml } from '@shared-ui/utils/string.js'
import { useTypingIndicator } from '@shared-ui/composables/useTypingIndicator.js'
import MessageInputActions from './MessageInputActions.vue'
import api, { establishSession } from '@widget/api/index.js'
@@ -138,7 +137,7 @@ const sendMessage = async () => {
stopTyping()
// Convert text to HTML.
const messageText = convertTextToHtml(newMessage.value.trim())
const messageText = newMessage.value.trim()
// Clear input field immediately
newMessage.value = ''
+1
View File
@@ -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,
+1 -2
View File
@@ -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) {
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -497,6 +497,7 @@
"conversationStatus.alreadyInUse": "نمی‌توان وضعیت را حذف کرد زیرا در حال استفاده است، لطفاً قبل از حذف، این وضعیت را از تمام مکالمات حذف کنید",
"conversationStatus.cannotUpdateDefault": "نمی‌توان وضعیت پیش‌فرض مکالمه را به‌روزرسانی کرد",
"csat.alreadySubmitted": "نظرسنجی CSAT قبلاً ارسال شده است",
"csat.pleaseFillRequired": "لطفا یک امتیاز یا بازخورد ارائه دهید.",
"csat.pageTitle": "تعامل خود با ما را امتیازدهی کنید",
"csat.rateYourInteraction": "تعامل اخیر خود را ارزیابی کنید",
"csat.thankYouMessage": "از اینکه وقت گذاشتید و بازخورد خود را ارسال کردید، قدردانی می‌کنیم.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -497,6 +497,7 @@
"conversationStatus.alreadyInUse": "このステータスは使用中のため削除できません。削除する前に、すべての会話からこのステータスを削除してください",
"conversationStatus.cannotUpdateDefault": "デフォルトの会話ステータスは更新できません",
"csat.alreadySubmitted": "CSAT はすでに送信されました",
"csat.pleaseFillRequired": "評価またはフィードバックを入力してください。",
"csat.pageTitle": "サポート対応の評価をお願いします",
"csat.rateYourInteraction": "最近の対応を評価してください",
"csat.thankYouMessage": "ご意見をお寄せいただき、ありがとうございます。",
+1
View File
@@ -497,6 +497,7 @@
"conversationStatus.alreadyInUse": "वापरात असलेली स्थिती हटवू शकत नाही, कृपया ही स्थिती सर्व संभाषणांमधून काढा",
"conversationStatus.cannotUpdateDefault": "डिफॉल्ट संभाषण स्थिती अद्ययावत करू शकत नाही",
"csat.alreadySubmitted": "CSAT आधीच सबमिट केला आहे",
"csat.pleaseFillRequired": "कृपया रेटिंग किंवा अभिप्राय द्या.",
"csat.pageTitle": "आमच्याशी असलेल्या तुमच्या संवादाला रेट करा",
"csat.rateYourInteraction": "तुमच्या अलीकडील संवादाचे मूल्यांकन करा",
"csat.thankYouMessage": "तुमचा अभिप्राय सबमिट केल्याबद्दल आम्ही कृतज्ञ आहोत.",
+14 -2
View File
@@ -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: <a href=\"%s\">Rate now</a>"
)
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,
+6 -2
View File
@@ -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,
+8 -3
View File
@@ -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)
+10 -8
View File
@@ -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"`
}
+2
View File
@@ -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;
+49
View File
@@ -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,
'
<p>Your recent conversation (#{{ .Conversation.ReferenceNumber }}) has been resolved. We would love to hear your feedback.</p>
<p style="text-align: center; font-weight: bold;">How would you rate your experience?</p>
<table style="width: 100%%; max-width: 400px; margin: 0 auto;" border="0" cellpadding="0" cellspacing="0">
<tr>
<td align="center" valign="top" style="width: 20%%; padding: 8px 0;">
<a href="{{ .CSATLink }}?rating=1" style="text-decoration: none; color: #555; font-size: 28px; display: block;">&#128546;</a>
<span style="font-size: 11px; color: #888;">Poor</span>
</td>
<td align="center" valign="top" style="width: 20%%; padding: 8px 0;">
<a href="{{ .CSATLink }}?rating=2" style="text-decoration: none; color: #555; font-size: 28px; display: block;">&#128533;</a>
<span style="font-size: 11px; color: #888;">Fair</span>
</td>
<td align="center" valign="top" style="width: 20%%; padding: 8px 0;">
<a href="{{ .CSATLink }}?rating=3" style="text-decoration: none; color: #555; font-size: 28px; display: block;">&#128522;</a>
<span style="font-size: 11px; color: #888;">Good</span>
</td>
<td align="center" valign="top" style="width: 20%%; padding: 8px 0;">
<a href="{{ .CSATLink }}?rating=4" style="text-decoration: none; color: #555; font-size: 28px; display: block;">&#128515;</a>
<span style="font-size: 11px; color: #888;">Great</span>
</td>
<td align="center" valign="top" style="width: 20%%; padding: 8px 0;">
<a href="{{ .CSATLink }}?rating=5" style="text-decoration: none; color: #555; font-size: 28px; display: block;">&#129321;</a>
<span style="font-size: 11px; color: #888;">Excellent</span>
</td>
</tr>
</table>
',
false,
'CSAT request',
'',
true
) ON CONFLICT DO NOTHING;
`)
if err != nil {
return err
}
return nil
}
+11
View File
@@ -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()
+39
View File
@@ -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,
'
<p>Your recent conversation (#{{ .Conversation.ReferenceNumber }}) has been resolved. We would love to hear your feedback.</p>
<p style="text-align: center; font-weight: bold;">How would you rate your experience?</p>
<table style="width: 100%; max-width: 400px; margin: 0 auto;" border="0" cellpadding="0" cellspacing="0">
<tr>
<td align="center" valign="top" style="width: 20%; padding: 8px 0;">
<a href="{{ .CSATLink }}?rating=1" style="text-decoration: none; color: #555; font-size: 28px; display: block;">&#128546;</a>
<span style="font-size: 11px; color: #888;">Poor</span>
</td>
<td align="center" valign="top" style="width: 20%; padding: 8px 0;">
<a href="{{ .CSATLink }}?rating=2" style="text-decoration: none; color: #555; font-size: 28px; display: block;">&#128533;</a>
<span style="font-size: 11px; color: #888;">Fair</span>
</td>
<td align="center" valign="top" style="width: 20%; padding: 8px 0;">
<a href="{{ .CSATLink }}?rating=3" style="text-decoration: none; color: #555; font-size: 28px; display: block;">&#128522;</a>
<span style="font-size: 11px; color: #888;">Good</span>
</td>
<td align="center" valign="top" style="width: 20%; padding: 8px 0;">
<a href="{{ .CSATLink }}?rating=4" style="text-decoration: none; color: #555; font-size: 28px; display: block;">&#128515;</a>
<span style="font-size: 11px; color: #888;">Great</span>
</td>
<td align="center" valign="top" style="width: 20%; padding: 8px 0;">
<a href="{{ .CSATLink }}?rating=5" style="text-decoration: none; color: #555; font-size: 28px; display: block;">&#129321;</a>
<span style="font-size: 11px; color: #888;">Excellent</span>
</td>
</tr>
</table>
',
false,
'CSAT request',
'',
true
);
+8
View File
@@ -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';