mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-10-27 14:57:02 +00:00
Centralize WhatsApp template fetching and improve CSAT template reconciliation
This commit is contained in:
+18
-6
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user