mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-10 14:15:42 +00:00
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:
+68
-30
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -497,6 +497,7 @@
|
||||
"conversationStatus.alreadyInUse": "نمیتوان وضعیت را حذف کرد زیرا در حال استفاده است، لطفاً قبل از حذف، این وضعیت را از تمام مکالمات حذف کنید",
|
||||
"conversationStatus.cannotUpdateDefault": "نمیتوان وضعیت پیشفرض مکالمه را بهروزرسانی کرد",
|
||||
"csat.alreadySubmitted": "نظرسنجی CSAT قبلاً ارسال شده است",
|
||||
"csat.pleaseFillRequired": "لطفا یک امتیاز یا بازخورد ارائه دهید.",
|
||||
"csat.pageTitle": "تعامل خود با ما را امتیازدهی کنید",
|
||||
"csat.rateYourInteraction": "تعامل اخیر خود را ارزیابی کنید",
|
||||
"csat.thankYouMessage": "از اینکه وقت گذاشتید و بازخورد خود را ارسال کردید، قدردانی میکنیم.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -497,6 +497,7 @@
|
||||
"conversationStatus.alreadyInUse": "このステータスは使用中のため削除できません。削除する前に、すべての会話からこのステータスを削除してください",
|
||||
"conversationStatus.cannotUpdateDefault": "デフォルトの会話ステータスは更新できません",
|
||||
"csat.alreadySubmitted": "CSAT はすでに送信されました",
|
||||
"csat.pleaseFillRequired": "評価またはフィードバックを入力してください。",
|
||||
"csat.pageTitle": "サポート対応の評価をお願いします",
|
||||
"csat.rateYourInteraction": "最近の対応を評価してください",
|
||||
"csat.thankYouMessage": "ご意見をお寄せいただき、ありがとうございます。",
|
||||
|
||||
@@ -497,6 +497,7 @@
|
||||
"conversationStatus.alreadyInUse": "वापरात असलेली स्थिती हटवू शकत नाही, कृपया ही स्थिती सर्व संभाषणांमधून काढा",
|
||||
"conversationStatus.cannotUpdateDefault": "डिफॉल्ट संभाषण स्थिती अद्ययावत करू शकत नाही",
|
||||
"csat.alreadySubmitted": "CSAT आधीच सबमिट केला आहे",
|
||||
"csat.pleaseFillRequired": "कृपया रेटिंग किंवा अभिप्राय द्या.",
|
||||
"csat.pageTitle": "आमच्याशी असलेल्या तुमच्या संवादाला रेट करा",
|
||||
"csat.rateYourInteraction": "तुमच्या अलीकडील संवादाचे मूल्यांकन करा",
|
||||
"csat.thankYouMessage": "तुमचा अभिप्राय सबमिट केल्याबद्दल आम्ही कृतज्ञ आहोत.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;">😢</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;">😕</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;">😊</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;">😃</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;">🤩</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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;">😢</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;">😕</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;">😊</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;">😃</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;">🤩</a>
|
||||
<span style="font-size: 11px; color: #888;">Excellent</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
',
|
||||
false,
|
||||
'CSAT request',
|
||||
'',
|
||||
true
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user