Files
Abhinav Raut 723c0e84ba fix notification and conversation read state
Opening a conversation used to mark it read even when the agent never scrolled to the new messages or the tab was in the background. Reply notifications in the bell also stayed unread after the agent read the reply.

Now the browser tells the server which message the agent saw. It only does this when the end of the thread is on screen and the tab has focus. The server moves the agent's last seen time forward to that message and never back.

A reply notification counts as read when the agent's last seen time is at or after the reply. "Mark as unread" marks the old reply notifications read first, so the bell stays the same.

Reply emails and pushes wait a little before sending. They are skipped if the agent already read the message or can no longer see the conversation.
2026-10-08 00:33:14 +05:30

174 lines
5.8 KiB
Go

package models
import (
"encoding/json"
"slices"
"time"
"github.com/volatiletech/null/v9"
)
// NotificationType represents the type of user notification.
type NotificationType string
// NotificationChannel represents the delivery channel of a notification.
type NotificationChannel string
const (
NotificationTypeMention NotificationType = "mention"
NotificationTypeAssignment NotificationType = "assignment"
NotificationTypeNewReply NotificationType = "new_reply"
NotificationTypeNewReplyParticipating NotificationType = "new_reply_participating"
NotificationTypeConversationReopened NotificationType = "conversation_reopened"
NotificationTypeAutomation NotificationType = "automation"
NotificationTypeSLAFirstResponseWarn NotificationType = "sla_first_response_warning"
NotificationTypeSLAFirstResponseBreach NotificationType = "sla_first_response_breach"
NotificationTypeSLANextResponseWarn NotificationType = "sla_next_response_warning"
NotificationTypeSLANextResponseBreach NotificationType = "sla_next_response_breach"
NotificationTypeSLAResolutionWarn NotificationType = "sla_resolution_warning"
NotificationTypeSLAResolutionBreach NotificationType = "sla_resolution_breach"
NotificationTypeSLAWarning NotificationType = "sla_warning"
NotificationTypeSLABreach NotificationType = "sla_breach"
NotificationChannelInApp NotificationChannel = "in_app"
NotificationChannelEmail NotificationChannel = "email"
NotificationChannelPush NotificationChannel = "push"
)
// AgentNotificationTypes are the notification types agents can set preferences for.
var AgentNotificationTypes = []NotificationType{
NotificationTypeAssignment,
NotificationTypeMention,
NotificationTypeConversationReopened,
NotificationTypeNewReply,
NotificationTypeNewReplyParticipating,
NotificationTypeSLAFirstResponseWarn,
NotificationTypeSLAFirstResponseBreach,
NotificationTypeSLANextResponseWarn,
NotificationTypeSLANextResponseBreach,
NotificationTypeSLAResolutionWarn,
NotificationTypeSLAResolutionBreach,
}
// NotificationChannels are the delivery channels a preference can target.
var NotificationChannels = []NotificationChannel{
NotificationChannelInApp,
NotificationChannelEmail,
NotificationChannelPush,
}
var ReplyTypes = []NotificationType{
NotificationTypeNewReply,
NotificationTypeNewReplyParticipating,
NotificationTypeConversationReopened,
}
// defaultDisabledTypes are off until an agent opts in. Every other type defaults to on.
var defaultDisabledTypes = []NotificationType{
NotificationTypeNewReply,
NotificationTypeNewReplyParticipating,
}
// NotificationPreference is a per-channel toggle for a notification type.
type NotificationPreference struct {
NotificationType NotificationType `db:"notification_type" json:"notification_type"`
Channel NotificationChannel `db:"channel" json:"channel"`
Enabled bool `db:"enabled" json:"enabled"`
}
type Recipient struct {
UserID int
Email *EmailNotification
}
type Notification struct {
Type NotificationType
Recipients []Recipient
Title string
Body null.String
ConversationID null.Int
MessageID null.Int
ActorID null.Int
Meta json.RawMessage
ConversationUUID string
MessageUUID string
ActorFirstName string
ActorLastName string
}
type EmailNotification struct {
Recipient string
Subject string
Content string
Delay time.Duration
}
type NotificationReference struct {
Type NotificationType
ConversationID null.Int
MessageID null.Int
NotificationID null.Int
}
type Email struct {
UserID int
NotificationID null.Int
Type NotificationType
ConversationID null.Int
MessageID null.Int
Recipient string
Subject string
Content string
}
type PushPayload struct {
Title string `json:"title"`
Body string `json:"body,omitempty"`
Tag string `json:"tag,omitempty"`
URL string `json:"url"`
}
type DeliveryResult struct {
RecipientID int
Channels []NotificationChannel
}
// DefaultEnabled reports the preference for a type when the agent has no stored row for it.
func DefaultEnabled(nType NotificationType) bool {
return !slices.Contains(defaultDisabledTypes, nType)
}
// UserNotification represents an in-app notification for a user.
type UserNotification struct {
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
UserID int `db:"user_id" json:"user_id"`
NotificationType NotificationType `db:"notification_type" json:"notification_type"`
Title string `db:"title" json:"title"`
Body null.String `db:"body" json:"body"`
IsRead bool `db:"is_read" json:"is_read"`
ConversationID null.Int `db:"conversation_id" json:"conversation_id"`
MessageID null.Int `db:"message_id" json:"message_id"`
ActorID null.Int `db:"actor_id" json:"actor_id"`
Meta json.RawMessage `db:"meta" json:"meta"`
// Joined fields from related tables
ActorFirstName null.String `db:"actor_first_name" json:"actor_first_name"`
ActorLastName null.String `db:"actor_last_name" json:"actor_last_name"`
ActorAvatarURL null.String `db:"actor_avatar_url" json:"actor_avatar_url"`
ConversationUUID null.String `db:"conversation_uuid" json:"conversation_uuid"`
MessageUUID null.String `db:"message_uuid" json:"message_uuid"`
}
// NotificationStats holds notification statistics for a user.
type NotificationStats struct {
UnreadCount int `db:"unread_count" json:"unread_count"`
TotalCount int `db:"total_count" json:"total_count"`
}
func IsReply(nType NotificationType) bool {
return slices.Contains(ReplyTypes, nType)
}