From c8da54ec56c0fcd83ef28122a3c1965701049c2d Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sat, 27 Jun 2026 18:30:05 +0530 Subject: [PATCH] Centralize WhatsApp template fetching and improve CSAT template reconciliation --- cmd/inboxes.go | 24 ++++++++--- cmd/settings.go | 8 +++- cmd/whatsapp_template.go | 28 ++++++++----- .../conversation/WhatsAppComposer.vue | 23 ++--------- .../conversation/WhatsAppConversationForm.vue | 40 +++++-------------- .../conversation/useWhatsAppTemplatePicker.js | 31 +++++++++++--- internal/conversation/whatsapp.go | 2 +- internal/inbox/channel/whatsapp/whatsapp.go | 1 + internal/whatsapp_template/queries.sql | 12 +++++- internal/whatsapp_template/template.go | 26 +++++++----- 10 files changed, 111 insertions(+), 84 deletions(-) diff --git a/cmd/inboxes.go b/cmd/inboxes.go index 0405ffe8..2018323e 100644 --- a/cmd/inboxes.go +++ b/cmd/inboxes.go @@ -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 diff --git a/cmd/settings.go b/cmd/settings.go index ab90ccd3..0db222ca 100644 --- a/cmd/settings.go +++ b/cmd/settings.go @@ -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) } diff --git a/cmd/whatsapp_template.go b/cmd/whatsapp_template.go index 30c3d310..4c744a2a 100644 --- a/cmd/whatsapp_template.go +++ b/cmd/whatsapp_template.go @@ -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 +} diff --git a/frontend/apps/main/src/features/conversation/WhatsAppComposer.vue b/frontend/apps/main/src/features/conversation/WhatsAppComposer.vue index f2c7f350..b256aabd 100644 --- a/frontend/apps/main/src/features/conversation/WhatsAppComposer.vue +++ b/frontend/apps/main/src/features/conversation/WhatsAppComposer.vue @@ -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) diff --git a/frontend/apps/main/src/features/conversation/WhatsAppConversationForm.vue b/frontend/apps/main/src/features/conversation/WhatsAppConversationForm.vue index 7e9952c4..1161efbf 100644 --- a/frontend/apps/main/src/features/conversation/WhatsAppConversationForm.vue +++ b/frontend/apps/main/src/features/conversation/WhatsAppConversationForm.vue @@ -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 = '' diff --git a/frontend/apps/main/src/features/conversation/useWhatsAppTemplatePicker.js b/frontend/apps/main/src/features/conversation/useWhatsAppTemplatePicker.js index 47fb11eb..2089b0d5 100644 --- a/frontend/apps/main/src/features/conversation/useWhatsAppTemplatePicker.js +++ b/frontend/apps/main/src/features/conversation/useWhatsAppTemplatePicker.js @@ -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 } } diff --git a/internal/conversation/whatsapp.go b/internal/conversation/whatsapp.go index 173d6edd..a0ad7bb9 100644 --- a/internal/conversation/whatsapp.go +++ b/internal/conversation/whatsapp.go @@ -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) diff --git a/internal/inbox/channel/whatsapp/whatsapp.go b/internal/inbox/channel/whatsapp/whatsapp.go index c49f0c7a..4903e6c9 100644 --- a/internal/inbox/channel/whatsapp/whatsapp.go +++ b/internal/inbox/channel/whatsapp/whatsapp.go @@ -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. diff --git a/internal/whatsapp_template/queries.sql b/internal/whatsapp_template/queries.sql index b4dc4061..03d32b1e 100644 --- a/internal/whatsapp_template/queries.sql +++ b/internal/whatsapp_template/queries.sql @@ -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 diff --git a/internal/whatsapp_template/template.go b/internal/whatsapp_template/template.go index b41cf70c..6a6ce41a 100644 --- a/internal/whatsapp_template/template.go +++ b/internal/whatsapp_template/template.go @@ -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.