Centralize WhatsApp template fetching and improve CSAT template reconciliation

This commit is contained in:
Abhinav Raut
2026-06-27 18:30:05 +05:30
parent a65b08bb6e
commit c8da54ec56
10 changed files with 111 additions and 84 deletions
+18 -6
View File
@@ -132,6 +132,20 @@ func validateWhatsAppCredentials(r *fastglue.Request, app *App, inb imodels.Inbo
return nil
}
func resyncWhatsAppCSATTemplates(app *App) {
inboxes, err := app.inbox.GetAll()
if err != nil {
app.lo.Error("error fetching inboxes for csat template resync", "error", err)
return
}
for _, inb := range inboxes {
if inb.Channel != whatsappChannel.ChannelWhatsApp {
continue
}
ensureWhatsAppCSATTemplate(app, inb.ID)
}
}
// ensureWhatsAppCSATTemplate reconciles the inbox's reserved CSAT template on Meta from its config: creates it when absent, edits it in place when the message or button changed, recreates under a new language when that changed. Approval arrives via webhook/sync.
func ensureWhatsAppCSATTemplate(app *App, inboxID int) {
if app.whatsappTemplate == nil {
@@ -210,7 +224,7 @@ func handleCreateInbox(r *fastglue.Request) error {
}
if createdInbox.Channel == whatsappChannel.ChannelWhatsApp {
go postSaveWhatsAppTasks(app, createdInbox.ID, createdInbox.CSATEnabled)
go postSaveWhatsAppTasks(app, createdInbox.ID)
}
// Clear passwords before returning.
@@ -279,7 +293,7 @@ func handleUpdateInbox(r *fastglue.Request) error {
if updatedInbox.Channel == whatsappChannel.ChannelWhatsApp {
app.inboxAuthErrors.Delete(id)
go postSaveWhatsAppTasks(app, id, updatedInbox.CSATEnabled)
go postSaveWhatsAppTasks(app, id)
}
// Clear passwords before returning.
@@ -340,11 +354,9 @@ func handleDeleteInbox(r *fastglue.Request) error {
return r.SendEnvelope(true)
}
func postSaveWhatsAppTasks(app *App, inboxID int, csatEnabled bool) {
func postSaveWhatsAppTasks(app *App, inboxID int) {
subscribeWhatsAppWebhook(app, inboxID)
if csatEnabled {
ensureWhatsAppCSATTemplate(app, inboxID)
}
ensureWhatsAppCSATTemplate(app, inboxID)
}
// validateInbox validates the inbox
+7 -1
View File
@@ -58,9 +58,10 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
// Trim whitespace and trailing slash from root URL.
req.RootURL = strings.TrimRight(strings.TrimSpace(req.RootURL), "/")
// Get current language before update.
// Get current language and root URL before update.
app.Lock()
oldLang := ko.String("app.lang")
oldRootURL := ko.String("app.root_url")
app.Unlock()
if err := app.setting.Update(req); err != nil {
@@ -86,6 +87,11 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
app.lo.Error("error reloading templates", "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)
}
if req.RootURL != oldRootURL {
go resyncWhatsAppCSATTemplates(app)
}
return r.SendEnvelope(true)
}
+18 -10
View File
@@ -85,8 +85,8 @@ func makeWhatsAppAuthErrorHook(app *App) func(acc whatsapp.Account) {
// handleListWhatsAppTemplates lists templates for a given inbox (?inbox_id=123).
func handleListWhatsAppTemplates(r *fastglue.Request) error {
app := r.Context.(*App)
if app.whatsappTemplate == nil {
return r.SendErrorEnvelope(fasthttp.StatusServiceUnavailable, "whatsapp not configured", nil, envelope.GeneralError)
if whatsAppTemplateUnavailable(r, app) {
return nil
}
inboxIDRaw := string(r.RequestCtx.QueryArgs().Peek("inbox_id"))
inboxID, err := strconv.Atoi(inboxIDRaw)
@@ -103,8 +103,8 @@ func handleListWhatsAppTemplates(r *fastglue.Request) error {
// handleGetWhatsAppTemplate returns a single template by id.
func handleGetWhatsAppTemplate(r *fastglue.Request) error {
app := r.Context.(*App)
if app.whatsappTemplate == nil {
return r.SendErrorEnvelope(fasthttp.StatusServiceUnavailable, "whatsapp not configured", nil, envelope.GeneralError)
if whatsAppTemplateUnavailable(r, app) {
return nil
}
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil {
@@ -120,8 +120,8 @@ func handleGetWhatsAppTemplate(r *fastglue.Request) error {
// handleCreateWhatsAppTemplate stores a new template and submits it to Meta.
func handleCreateWhatsAppTemplate(r *fastglue.Request) error {
app := r.Context.(*App)
if app.whatsappTemplate == nil {
return r.SendErrorEnvelope(fasthttp.StatusServiceUnavailable, "whatsapp not configured", nil, envelope.GeneralError)
if whatsAppTemplateUnavailable(r, app) {
return nil
}
var t wtmodels.Template
if err := r.Decode(&t, "json"); err != nil {
@@ -140,8 +140,8 @@ func handleCreateWhatsAppTemplate(r *fastglue.Request) error {
// handleDeleteWhatsAppTemplate removes a template locally and on Meta.
func handleDeleteWhatsAppTemplate(r *fastglue.Request) error {
app := r.Context.(*App)
if app.whatsappTemplate == nil {
return r.SendErrorEnvelope(fasthttp.StatusServiceUnavailable, "whatsapp not configured", nil, envelope.GeneralError)
if whatsAppTemplateUnavailable(r, app) {
return nil
}
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil {
@@ -156,8 +156,8 @@ func handleDeleteWhatsAppTemplate(r *fastglue.Request) error {
// handleSyncWhatsAppTemplates pulls templates from Meta and upserts locally.
func handleSyncWhatsAppTemplates(r *fastglue.Request) error {
app := r.Context.(*App)
if app.whatsappTemplate == nil {
return r.SendErrorEnvelope(fasthttp.StatusServiceUnavailable, "whatsapp not configured", nil, envelope.GeneralError)
if whatsAppTemplateUnavailable(r, app) {
return nil
}
inboxIDRaw := string(r.RequestCtx.QueryArgs().Peek("inbox_id"))
inboxID, err := strconv.Atoi(inboxIDRaw)
@@ -171,3 +171,11 @@ func handleSyncWhatsAppTemplates(r *fastglue.Request) error {
}
return r.SendEnvelope(map[string]int{"synced": count})
}
func whatsAppTemplateUnavailable(r *fastglue.Request, app *App) bool {
if app.whatsappTemplate != nil {
return false
}
r.SendErrorEnvelope(fasthttp.StatusServiceUnavailable, "whatsapp not configured", nil, envelope.GeneralError)
return true
}
@@ -125,7 +125,6 @@ const windowExpiresIn = computed(() => {
})
const {
templates,
selectedTemplate,
templateParams,
approvedTemplates,
@@ -133,12 +132,12 @@ const {
urlButtonParams,
allParamsFilled,
renderedPreview,
isFetchingTemplates,
pickTemplate,
reset
fetchTemplates,
} = useWhatsAppTemplatePicker()
const pickerOpen = ref(false)
const isFetchingTemplates = ref(false)
const isSending = ref(false)
watch(
@@ -151,23 +150,7 @@ watch(
const openTemplatePicker = async () => {
pickerOpen.value = true
reset()
const inboxID = conversationStore.current?.inbox_id
if (!inboxID) return
try {
isFetchingTemplates.value = true
const resp = await api.getWhatsAppTemplates(inboxID)
templates.value = resp.data.data || []
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isFetchingTemplates.value = false
}
await fetchTemplates(conversationStore.current?.inbox_id)
}
emitter.on(EMITTER_EVENTS.WHATSAPP_TEMPLATE_PICKER_OPEN, openTemplatePicker)
@@ -210,7 +210,6 @@ const countriesStore = useCountriesStore()
const { allCountries } = storeToRefs(countriesStore)
const {
templates,
selectedTemplate,
templateParams,
approvedTemplates,
@@ -218,8 +217,9 @@ const {
urlButtonParams,
allParamsFilled,
renderedPreview,
isFetchingTemplates,
pickTemplate,
reset
fetchTemplates
} = useWhatsAppTemplatePicker()
const inboxId = ref('')
@@ -236,29 +236,11 @@ const firstName = ref('')
const lastName = ref('')
const phoneCountryCode = ref('')
const phoneNumber = ref('')
const isFetchingTemplates = ref(false)
onMounted(() => countriesStore.fetchCountries())
onUnmounted(() => clearTimeout(timeoutId))
const fetchTemplates = async () => {
reset()
if (!inboxId.value) return
try {
isFetchingTemplates.value = true
const resp = await api.getWhatsAppTemplates(inboxId.value)
templates.value = resp.data.data || []
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isFetchingTemplates.value = false
}
}
watch(inboxId, fetchTemplates)
watch(inboxId, (id) => fetchTemplates(id))
const hasContact = computed(() => {
if (selectedContact.value) return true
@@ -322,16 +304,12 @@ const selectContact = (contact) => {
highlightedIndex.value = -1
}
watch(phoneNumber, (val) => {
if (selectedContact.value && val !== (selectedContact.value.phone_number || '')) {
selectedContact.value = null
firstName.value = ''
lastName.value = ''
}
})
watch(phoneCountryCode, (val) => {
if (selectedContact.value && val !== (selectedContact.value.phone_number_country_code || '')) {
watch([phoneNumber, phoneCountryCode], ([num, code]) => {
if (!selectedContact.value) return
if (
num !== (selectedContact.value.phone_number || '') ||
code !== (selectedContact.value.phone_number_country_code || '')
) {
selectedContact.value = null
firstName.value = ''
lastName.value = ''
@@ -1,6 +1,10 @@
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { PLACEHOLDER_PATTERN, extractPlaceholders, placeholderLabel } from './whatsappTemplate.js'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { EMITTER_EVENTS } from '@main/constants/emitterEvents.js'
import { useEmitter } from '@main/composables/useEmitter'
import api from '@main/api'
import { PLACEHOLDER_PATTERN, extractPlaceholders } from './whatsappTemplate.js'
// Media-header templates need a media ID the dashboard can't supply, AUTHENTICATION templates need OTP button params, and libredesk_csat_* names are reserved for surveys.
const SENDABLE_HEADER_TYPES = ['', 'NONE', 'TEXT']
@@ -8,6 +12,7 @@ const RESERVED_NAME_PREFIX = 'libredesk_csat_'
export function useWhatsAppTemplatePicker() {
const { t } = useI18n()
const emitter = useEmitter()
const templates = ref([])
const selectedTemplate = ref(null)
const templateParams = reactive({})
@@ -68,8 +73,23 @@ export function useWhatsAppTemplatePicker() {
return body
})
const clearParams = () => {
Object.keys(templateParams).forEach((k) => delete templateParams[k])
const isFetchingTemplates = ref(false)
const fetchTemplates = async (inboxID) => {
reset()
if (!inboxID) return
try {
isFetchingTemplates.value = true
const resp = await api.getWhatsAppTemplates(inboxID)
templates.value = resp.data.data || []
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isFetchingTemplates.value = false
}
}
const pickTemplate = (tmpl) => {
@@ -82,7 +102,7 @@ export function useWhatsAppTemplatePicker() {
const reset = () => {
selectedTemplate.value = null
templates.value = []
clearParams()
Object.keys(templateParams).forEach((k) => delete templateParams[k])
}
watch(selectedTemplate, () => {
@@ -101,8 +121,9 @@ export function useWhatsAppTemplatePicker() {
urlButtonParams,
allParamsFilled,
renderedPreview,
placeholderLabel,
isFetchingTemplates,
pickTemplate,
fetchTemplates,
reset
}
}
+1 -1
View File
@@ -196,7 +196,7 @@ func (m *Manager) sendWhatsAppCSAT(actorUserID int, conversation models.Conversa
t, err := m.whatsappTemplate.GetApproved(conversation.InboxID, wtmodels.CSATTemplateName(conversation.InboxID), m.csatTemplateLanguage(conversation.InboxID))
if err == nil {
meta["whatsapp_template_id"] = t.ID
meta["whatsapp_template_params"] = map[string]string{"button_url_0": csatURL}
meta["whatsapp_template_params"] = map[string]string{"button_url_0": csatUUID}
if _, err := m.QueueReply(nil, conversation.InboxID, actorUserID, conversation.ContactID, conversation.UUID, "", nil, nil, nil, meta); err != nil {
m.lo.Error("error sending whatsapp CSAT template", "conversation_uuid", conversation.UUID, "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
@@ -62,6 +62,7 @@ var supportedMediaMIMETypes = map[string]struct{}{
"image/webp": {},
"video/mp4": {},
"video/3gpp": {},
"video/3gp": {},
}
// Config is the per-inbox WhatsApp configuration from the inbox config JSONB, with tokens already decrypted.
+11 -1
View File
@@ -66,7 +66,17 @@ ON CONFLICT (inbox_id, name, language) DO UPDATE SET
footer_content = EXCLUDED.footer_content,
buttons = EXCLUDED.buttons,
rejection_reason = EXCLUDED.rejection_reason,
updated_at = NOW()
updated_at = CASE WHEN (
whatsapp_templates.meta_template_id IS DISTINCT FROM EXCLUDED.meta_template_id OR
whatsapp_templates.category IS DISTINCT FROM EXCLUDED.category OR
whatsapp_templates.status IS DISTINCT FROM EXCLUDED.status OR
whatsapp_templates.header_type IS DISTINCT FROM EXCLUDED.header_type OR
whatsapp_templates.header_content IS DISTINCT FROM EXCLUDED.header_content OR
whatsapp_templates.body_content IS DISTINCT FROM EXCLUDED.body_content OR
whatsapp_templates.footer_content IS DISTINCT FROM EXCLUDED.footer_content OR
whatsapp_templates.buttons IS DISTINCT FROM EXCLUDED.buttons OR
whatsapp_templates.rejection_reason IS DISTINCT FROM EXCLUDED.rejection_reason
) THEN NOW() ELSE whatsapp_templates.updated_at END
RETURNING *;
-- name: update-status-by-meta-name-language
+17 -9
View File
@@ -466,23 +466,31 @@ func buildEdit(t models.Template) (whatsapp.TemplateEdit, error) {
}, nil
}
// reservedContentChanged compares only the editable surface of a reserved template (body text and button label); the name, URL and language are fixed or handled by the caller.
func reservedContentChanged(existing, desired models.Template) bool {
if strings.TrimSpace(existing.BodyContent) != strings.TrimSpace(desired.BodyContent) {
return true
}
return firstButtonText(existing.Buttons) != firstButtonText(desired.Buttons)
return !buttonsSurfaceEqual(existing.Buttons, desired.Buttons)
}
func firstButtonText(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
func buttonsSurfaceEqual(a, b json.RawMessage) bool {
var ab, bb []whatsapp.TemplateButton
if err := json.Unmarshal(a, &ab); err != nil {
ab = nil
}
var btns []whatsapp.TemplateButton
if err := json.Unmarshal(raw, &btns); err != nil || len(btns) == 0 {
return ""
if err := json.Unmarshal(b, &bb); err != nil {
bb = nil
}
return strings.TrimSpace(btns[0].Text)
if len(ab) != len(bb) {
return false
}
for i := range ab {
if strings.TrimSpace(ab[i].Text) != strings.TrimSpace(bb[i].Text) ||
strings.TrimSpace(ab[i].URL) != strings.TrimSpace(bb[i].URL) {
return false
}
}
return true
}
// parseSampleValues decodes sample_values JSON, tolerating non-string values from the frontend.