rework notify action with subject, message and picked recipients

The notify action only created a fixed in-app notification and recipients
were typed as a raw team:<id> / user:<id> DSL where typos silently notified
nobody. Now:

- recipients are picked by name (assignee, assigned team, any team or agent)
- admin writes the subject and message; both show in the bell notification
  and go out as an email (message plus conversation link, wrapped in the
  default outgoing email template, so nothing is hardcoded in English)
- the action serializes as typed fields {subject, message, recipients}
  instead of a positional value array

Also: previous_* condition fields are hidden for time triggers and populated
on message events (previous = current) so those rules can actually match,
action row widths now follow the RuleBox pattern (fixed type select, value
widgets fill the row), and three new i18n keys merged into existing globals.
This commit is contained in:
Abhinav Raut
2026-08-06 02:38:38 +05:30
parent 89b40f9dac
commit ae0b6ee9f2
10 changed files with 174 additions and 91 deletions
@@ -339,7 +339,7 @@ export function useConversationFilters () {
type: FIELD_TYPE.RECIPIENTS
},
snooze: {
label: t('actions.snooze'),
label: t('globals.terms.snooze'),
type: FIELD_TYPE.TEXT
},
trigger_webhook: {
@@ -7,9 +7,9 @@
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex gap-5">
<div class="w-48">
<div class="flex items-start justify-between gap-5">
<div class="flex gap-5 flex-1 min-w-0">
<div class="w-56 shrink-0">
<!-- Type -->
<Select
v-model="action.type"
@@ -35,7 +35,7 @@
<!-- Value -->
<div
v-if="action.type && conversationActions[action.type]?.type === 'tag'"
class="w-full"
class="flex-1 min-w-0"
>
<SelectTag
v-model="action.value"
@@ -46,32 +46,29 @@
<div
v-if="action.type && conversationActions[action.type]?.type === 'recipients'"
class="w-full"
class="flex-1 min-w-0 space-y-3"
>
<TagsInput
:modelValue="action.value || []"
@update:modelValue="(value) => handleNotifyChange(value, index)"
:addOnBlur="true"
:addOnTab="true"
:addOnPaste="true"
>
<TagsInputItem
v-for="item in action.value || []"
:key="item"
:value="item"
>
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput :placeholder="t('placeholders.notifyRecipient')" />
</TagsInput>
<p class="text-xs text-muted-foreground mt-1">
{{ $t('admin.automation.notifyRecipientHint') }}
</p>
<SelectTag
:modelValue="action.recipients || []"
@update:modelValue="(value) => handleNotifyRecipientsChange(value, index)"
:items="notifyRecipientOptions"
:placeholder="t('placeholders.selectRecipients')"
/>
<Input
type="text"
:placeholder="t('globals.terms.subject')"
:modelValue="action.subject || ''"
@update:modelValue="(value) => handleNotifySubjectChange(value, index)"
/>
<Textarea
:placeholder="t('globals.terms.message')"
:modelValue="action.message || ''"
@update:modelValue="(value) => handleNotifyMessageChange(value, index)"
/>
</div>
<div
class="w-48"
class="flex-1 min-w-0"
v-if="action.type && conversationActions[action.type]?.type === 'select'"
>
<SelectComboBox
@@ -84,10 +81,10 @@
</div>
<div
class="flex gap-3"
class="flex gap-3 flex-1 min-w-0"
v-if="action.type && conversationActions[action.type]?.type === 'webhook'"
>
<div class="w-48">
<div class="flex-1 min-w-0">
<SelectComboBox
v-model="action.value[0]"
:items="webhookStore.options"
@@ -95,7 +92,7 @@
@select="handleWebhookChange($event, index)"
/>
</div>
<div class="w-48">
<div class="flex-1 min-w-0">
<Input
type="text"
:placeholder="t('placeholders.webhookEventName')"
@@ -109,7 +106,7 @@
</div>
<div
class="w-48"
class="flex-1 min-w-0"
v-if="action.type && conversationActions[action.type]?.type === 'text'"
>
<Input
@@ -147,12 +144,15 @@
</template>
<script setup>
import { toRefs } from 'vue'
import { toRefs, computed } from 'vue'
import { Button } from '@shared-ui/components/ui/button'
import { Input } from '@shared-ui/components/ui/input'
import { Textarea } from '@shared-ui/components/ui/textarea'
import CloseButton from '@main/components/button/CloseButton.vue'
import { useTagStore } from '@main/stores/tag'
import { useWebhookStore } from '@main/stores/webhook'
import { useUsersStore } from '@main/stores/users'
import { useTeamStore } from '@main/stores/team'
import {
Select,
SelectContent,
@@ -162,13 +162,6 @@ import {
SelectValue
} from '@shared-ui/components/ui/select'
import { SelectTag } from '@shared-ui/components/ui/select'
import {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
} from '@shared-ui/components/ui/tags-input'
import { useConversationFilters } from '@main/composables/useConversationFilters'
import { getTextFromHTML } from '@shared-ui/utils/string'
import { useI18n } from 'vue-i18n'
@@ -187,12 +180,37 @@ const { t } = useI18n()
const emit = defineEmits(['update-actions', 'add-action', 'remove-action'])
const tagsStore = useTagStore()
const webhookStore = useWebhookStore()
const uStore = useUsersStore()
const tStore = useTeamStore()
const { conversationActions } = useConversationFilters()
webhookStore.fetchWebhooks()
const handleNotifyChange = (value, index) => {
actions.value[index].value = value || []
const notifyRecipientOptions = computed(() => [
{ label: t('admin.automation.notifyAssignee'), value: 'assignee' },
{ label: t('admin.automation.notifyAssignedTeam'), value: 'assigned_team' },
...tStore.options.map((o) => ({
label: `${t('globals.terms.team')}: ${o.label}`,
value: `team:${o.value}`
})),
...uStore.options.map((o) => ({
label: `${t('globals.terms.agent')}: ${o.label}`,
value: `user:${o.value}`
}))
])
const handleNotifyRecipientsChange = (value, index) => {
actions.value[index].recipients = value || []
emitUpdate(index)
}
const handleNotifySubjectChange = (value, index) => {
actions.value[index].subject = value
emitUpdate(index)
}
const handleNotifyMessageChange = (value, index) => {
actions.value[index].message = value
emitUpdate(index)
}
@@ -222,8 +240,12 @@ const placeholderForText = (type) => {
}
const handleFieldChange = (value, index) => {
actions.value[index].value = []
actions.value[index].type = value
const action = actions.value[index]
action.value = []
action.type = value
delete action.subject
delete action.message
delete action.recipients
emitUpdate(index)
}
@@ -250,9 +250,14 @@ const { t } = useI18n()
// Computed property to get the correct filters based on type
const currentFilters = computed(() => {
return props.type === 'new_conversation'
? newConversationFilters.value
: conversationFilters.value
if (props.type === 'new_conversation') return newConversationFilters.value
if (props.type === 'conversation_update') return conversationFilters.value
// previous_* values only exist on conversation update events.
const filters = { ...conversationFilters.value }
for (const key of Object.keys(filters)) {
if (key.startsWith('previous_')) delete filters[key]
}
return filters
})
// Watch for type change and reset the rules as the fields will change
@@ -404,6 +404,14 @@ const getRulesValidationError = () => {
action.value = ['0']
}
// Notify uses its own fields instead of value.
if (action.type === 'notify') {
if (!action.subject?.trim() || !action.message?.trim() || !action.recipients?.length) {
return t('admin.automation.validation.setActionValue')
}
continue
}
// Empty array, no value selected.
if (action.value.length === 0) {
return t('admin.automation.validation.setActionValue')
+4 -5
View File
@@ -12,7 +12,7 @@
"actions.addingPrivateNotes": "Adding private notes",
"actions.addPrivateNote": "Add private note",
"actions.applyMacro": "Apply macro",
"actions.notify": "Notify",
"actions.notify": "Notify agents",
"actions.openMacros": "Open macros",
"actions.assignAgent": "Assign agent",
"actions.assignTeam": "Assign team",
@@ -26,7 +26,6 @@
"actions.setStatus": "Set status",
"actions.setTags": "Set tags",
"actions.setValue": "Set value",
"actions.snooze": "Snooze",
"actions.startingConversation": "Starting conversation",
"actions.triggerWebhook": "Trigger webhook",
"activityLog.agentAway": "{actorEmail} ({actorId}) changed {targetEmail} ({targetId}) status to away",
@@ -72,7 +71,8 @@
"admin.automation.matchTheseRules": "Match these rules",
"admin.automation.newConversation.description": "Rules that run when a new conversation is created by a contact. Conversations initiated by agents do not trigger these rules. Drag and drop to reorder.",
"admin.automation.noRulesFound": "No rules found",
"admin.automation.notifyRecipientHint": "Enter recipients as assignee, assigned_team, team:<id>, or user:<id>. Press Enter after each.",
"admin.automation.notifyAssignedTeam": "Assigned team",
"admin.automation.notifyAssignee": "Assignee",
"admin.automation.webhookEventNameHint": "Event name sent in the webhook payload.",
"admin.automation.or": "OR",
"admin.automation.performTheseActions": "Perform these actions",
@@ -1130,7 +1130,6 @@
"navigation.keyboardShortcuts": "Keyboard shortcuts",
"navigation.logout": "Logout",
"navigation.reassignReplies": "Reassign replies",
"notification.automationNotify": "Automation notification for #{referenceNumber}",
"notification.conversationAssigned": "Conversation assigned to you #{referenceNumber}",
"notification.mentionedInConversation": "{author} mentioned you in #{referenceNumber}",
"notification.slaAlert": "SLA {type}: {metric} for #{referenceNumber}",
@@ -1146,7 +1145,6 @@
"placeholders.introductionMessage": "We're here to help!",
"placeholders.linkText": "Link Text",
"placeholders.noticeBannerText": "Our response times are slower than usual. We're working hard to get to your message.",
"placeholders.notifyRecipient": "assignee, team:5, user:42",
"placeholders.selectAction": "Select action",
"placeholders.selectAgent": "Select agent",
"placeholders.selectCsvFile": "Select CSV file",
@@ -1158,6 +1156,7 @@
"placeholders.selectPriority": "Select priority",
"placeholders.selectProtocol": "Select protocol",
"placeholders.selectProvider": "Select provider",
"placeholders.selectRecipients": "Select recipients",
"placeholders.selectTags": "Select tags",
"placeholders.selectTeam": "Select team",
"placeholders.selectTeams": "Select teams",
+1 -1
View File
@@ -330,7 +330,7 @@ func (e *Engine) EvaluateConversationUpdateRulesByID(conversationID int, convers
e.lo.Error("error fetching conversation", "conversation_id", conversationID, "error", err)
return
}
e.EvaluateConversationUpdateRules(conversation, eventType, nil)
e.EvaluateConversationUpdateRules(conversation, eventType, models.PreviousValues(conversation))
}
// handleNewConversation handles new conversation events.
+3 -3
View File
@@ -1480,7 +1480,7 @@ func TestStartsWithOperator(t *testing.T) {
func TestNotifyAction_PassedThrough(t *testing.T) {
mockStore := new(mockConversationStore)
expected := models.RuleAction{Type: models.ActionNotify, Value: []string{"assignee", "team:5"}}
expected := models.RuleAction{Type: models.ActionNotify, Subject: "subject", Message: "message", Recipients: []string{"assignee", "team:5"}}
mockStore.On("ApplyAction", expected, mock.Anything, mock.Anything).Return(nil).Once()
engine := createTestEngine(mockStore)
@@ -1526,8 +1526,8 @@ func TestMultipleNotifyActions(t *testing.T) {
},
}},
[]models.RuleAction{
{Type: models.ActionNotify, Value: []string{"assignee"}},
{Type: models.ActionNotify, Value: []string{"team:5"}},
{Type: models.ActionNotify, Subject: "subject", Message: "message", Recipients: []string{"assignee"}},
{Type: models.ActionNotify, Subject: "subject", Message: "message", Recipients: []string{"team:5"}},
},
models.OperatorOR,
),
+30
View File
@@ -2,9 +2,11 @@ package models
import (
"encoding/json"
"strconv"
"time"
authzModels "github.com/abhinavxd/libredesk/internal/authz/models"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/lib/pq"
)
@@ -135,4 +137,32 @@ type RuleAction struct {
Type string `json:"type" db:"type"`
Value []string `json:"value" db:"value"`
DisplayValue []string `json:"display_value" db:"-"`
// Set only for the notify action.
Subject string `json:"subject,omitempty"`
Message string `json:"message,omitempty"`
Recipients []string `json:"recipients,omitempty"`
}
// PreviousValues returns conv's field values keyed for previous_* filters; pass the pre-change conversation.
func PreviousValues(conv cmodels.Conversation) map[string]string {
values := map[string]string{
ConversationPreviousStatus: "",
ConversationPreviousPriority: "",
ConversationPreviousAssignedUser: "",
ConversationPreviousAssignedTeam: "",
}
if conv.StatusID.Valid {
values[ConversationPreviousStatus] = strconv.Itoa(conv.StatusID.Int)
}
if conv.PriorityID.Valid {
values[ConversationPreviousPriority] = strconv.Itoa(conv.PriorityID.Int)
}
if conv.AssignedUserID.Valid {
values[ConversationPreviousAssignedUser] = strconv.Itoa(conv.AssignedUserID.Int)
}
if conv.AssignedTeamID.Valid {
values[ConversationPreviousAssignedTeam] = strconv.Itoa(conv.AssignedTeamID.Int)
}
return values
}
+54 -35
View File
@@ -59,6 +59,12 @@ var (
const (
conversationsListMaxPageSize = 500
automationNotifyEmailContent = `<p style="white-space: pre-line;">{{ .Message }}</p>
<p>
<a href="{{ RootURL }}/inboxes/all/conversation/{{ .Conversation.UUID }}">#{{ .Conversation.ReferenceNumber }}</a>
</p>`
)
var conversationFilterRenderers = dbutil.FieldRenderers{
@@ -746,7 +752,7 @@ func (c *Manager) UpdateConversationUserAssignee(uuid string, assigneeID int, ac
if err := c.updateAssignee(uuid, assigneeID, models.AssigneeTypeUser); err != nil {
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
}
return c.afterUserAssignedHooks(uuid, assigneeID, actor, previousValuesFor(previousConversation))
return c.afterUserAssignedHooks(uuid, assigneeID, actor, amodels.PreviousValues(previousConversation))
}
// ClaimUnassignedConversation atomically assigns a conversation only if still unassigned and still in expectedTeamID, else returns ErrConversationAlreadyAssigned.
@@ -834,7 +840,7 @@ func (c *Manager) UpdateConversationTeamAssignee(uuid string, teamID int, actor
return err
}
previousAssignedTeamID := conversation.AssignedTeamID.Int
previousValues := previousValuesFor(conversation)
previousValues := amodels.PreviousValues(conversation)
if err := c.updateAssignee(uuid, teamID, models.AssigneeTypeTeam); err != nil {
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
@@ -923,7 +929,7 @@ func (c *Manager) UpdateConversationPriority(uuid string, priorityID int, priori
conversation, err := c.GetConversation(0, uuid, "")
if err == nil {
c.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationPriorityChange, previousValuesFor(previousConversation))
c.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationPriorityChange, amodels.PreviousValues(previousConversation))
}
// Record activity.
@@ -1040,7 +1046,7 @@ func (c *Manager) UpdateConversationStatus(uuid string, statusID int, status, sn
c.BroadcastConversationUpdate(uuid, agentData)
if conversation.ID != 0 {
c.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationStatusChange, previousValuesFor(conversationBeforeChange))
c.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationStatusChange, amodels.PreviousValues(conversationBeforeChange))
}
// Broadcast conversation update to widget clients.
@@ -1397,8 +1403,8 @@ func (m *Manager) ApplySLA(conversation models.Conversation, policyID int, actor
// ApplyAction applies an action to a conversation, this can be called from multiple packages across the app to perform actions on conversations.
// all actions are executed on behalf of the provided user if the user is not provided, system user is used.
func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversation, user umodels.User) error {
// CSAT action does not require a value.
if len(action.Value) == 0 && action.Type != amodels.ActionSendCSAT {
// CSAT has no value; notify carries its data in dedicated fields.
if len(action.Value) == 0 && action.Type != amodels.ActionSendCSAT && action.Type != amodels.ActionNotify {
return fmt.Errorf("empty value for action %s", action.Type)
}
@@ -1504,14 +1510,19 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio
})
return nil
case amodels.ActionNotify:
return m.notifyAutomation(action.Value, conv)
subject := strings.TrimSpace(action.Subject)
message := strings.TrimSpace(action.Message)
if subject == "" || message == "" || len(action.Recipients) == 0 {
return fmt.Errorf("notify action requires a subject, a message and at least one recipient")
}
return m.notifyAutomation(subject, message, action.Recipients, conv)
default:
return fmt.Errorf("unknown action: %s", action.Type)
}
return nil
}
func (m *Manager) notifyAutomation(entries []string, conv models.Conversation) error {
func (m *Manager) notifyAutomation(subject, message string, entries []string, conv models.Conversation) error {
userIDs := m.resolveNotifyRecipients(entries, conv)
if len(userIDs) == 0 {
m.lo.Debug("notify action: no recipients resolved", "conversation_uuid", conv.UUID)
@@ -1521,14 +1532,45 @@ func (m *Manager) notifyAutomation(entries []string, conv models.Conversation) e
m.lo.Warn("notify action: recipient cap reached, truncating", "original", len(userIDs), "cap", amodels.MaxNotifyRecipients, "conversation_uuid", conv.UUID)
userIDs = userIDs[:amodels.MaxNotifyRecipients]
}
m.dispatcher.Send(notifier.Notification{
notification := notifier.Notification{
Type: nmodels.NotificationTypeMention,
RecipientIDs: userIDs,
Title: m.i18n.Ts("notification.automationNotify", "referenceNumber", conv.ReferenceNumber),
Body: conv.Subject,
Title: subject,
Body: null.StringFrom(message),
ConversationID: null.IntFrom(conv.ID),
ConversationUUID: conv.UUID,
})
}
content, err := m.template.RenderEmailWithTemplate(
map[string]any{
"Conversation": map[string]any{
"ReferenceNumber": conv.ReferenceNumber,
"UUID": conv.UUID,
},
"Message": message,
},
automationNotifyEmailContent)
if err != nil {
m.lo.Error("error rendering automation notify email", "conversation_uuid", conv.UUID, "error", err)
} else {
emails := make([]string, len(userIDs))
for i, id := range userIDs {
agent, err := m.userStore.GetAgent(id, "")
if err != nil {
m.lo.Error("notify: error fetching agent for email", "user_id", id, "error", err)
continue
}
emails[i] = agent.Email.String
}
notification.Email = &notifier.EmailNotification{
Recipients: emails,
Subject: notification.Title,
Content: content,
}
}
m.dispatcher.Send(notification)
return nil
}
@@ -2144,29 +2186,6 @@ func parseNotifyRecipient(entry string) (string, int) {
return kind, id
}
// previousValuesFor returns the pre-change field values consumed by previous_* automation filters.
func previousValuesFor(conv models.Conversation) map[string]string {
values := map[string]string{
amodels.ConversationPreviousStatus: "",
amodels.ConversationPreviousPriority: "",
amodels.ConversationPreviousAssignedUser: "",
amodels.ConversationPreviousAssignedTeam: "",
}
if conv.StatusID.Valid {
values[amodels.ConversationPreviousStatus] = strconv.Itoa(conv.StatusID.Int)
}
if conv.PriorityID.Valid {
values[amodels.ConversationPreviousPriority] = strconv.Itoa(conv.PriorityID.Int)
}
if conv.AssignedUserID.Valid {
values[amodels.ConversationPreviousAssignedUser] = strconv.Itoa(conv.AssignedUserID.Int)
}
if conv.AssignedTeamID.Valid {
values[amodels.ConversationPreviousAssignedTeam] = strconv.Itoa(conv.AssignedTeamID.Int)
}
return values
}
func nullTimeOrNil(t null.Time) any {
if !t.Valid || t.Time.IsZero() {
return nil
+1 -1
View File
@@ -1393,7 +1393,7 @@ func (m *Manager) ProcessIncomingMessageHooks(conversationUUID string, isNewConv
m.lo.Error("error fetching conversation for incoming message hooks", "conversation_uuid", conversationUUID, "error", err)
} else {
// Trigger automations on incoming message event.
m.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationMessageIncoming, nil)
m.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationMessageIncoming, amodels.PreviousValues(conversation))
// If assigned to an AI assistant, let it respond to this inbound customer message.
if m.aiAgent != nil && conversation.AssignedUserID.Valid {