feat(feedback): snapshot the sender's language and connector bot at submit
Store the sender's interface language (lang) and, for a message that arrived through an external connector (Telegram), the bot language (channel_lang) on the feedback row at submit time, so the operator console shows the state as it was rather than the account's current settings (same snapshot discipline as a suspension reason). Added additively in migration 00005. The console detail reads these columns instead of loading the account live.
This commit is contained in:
@@ -111,7 +111,15 @@ func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string
|
||||
} else {
|
||||
attachmentName = "" // a name without bytes carries no attachment
|
||||
}
|
||||
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, normalizeChannel(channel), parseIP(senderIP))
|
||||
ch := normalizeChannel(channel)
|
||||
// Snapshot the languages at submit time (acc is already loaded for the guest check):
|
||||
// the sender's interface language, and the connector bot language when the message
|
||||
// came through an external connector (currently Telegram).
|
||||
var channelLang string
|
||||
if ch == "telegram" {
|
||||
channelLang = acc.ServiceLanguage
|
||||
}
|
||||
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, channelLang, parseIP(senderIP))
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -34,8 +34,11 @@ func NewStore(db *sql.DB) *Store {
|
||||
|
||||
// Insert stores one feedback message from accountID and returns its id. attachment
|
||||
// is the raw file bytes (nil for none); attachmentName, ip and a non-default
|
||||
// channel are stored as given. created_at defaults to now() in the database.
|
||||
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel string, ip *string) (uuid.UUID, error) {
|
||||
// channel are stored as given. lang (the sender's interface language) and channelLang
|
||||
// (the connector bot language, empty for a non-connector channel) are snapshots taken
|
||||
// now, so the operator later sees the state at submit time. created_at defaults to
|
||||
// now() in the database.
|
||||
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang, channelLang string, ip *string) (uuid.UUID, error) {
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err)
|
||||
@@ -44,20 +47,24 @@ func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, at
|
||||
if len(attachment) > 0 {
|
||||
att = attachment
|
||||
}
|
||||
var name *string
|
||||
if attachmentName != "" {
|
||||
name = &attachmentName
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO backend.feedback_messages
|
||||
(message_id, account_id, body, attachment, attachment_name, channel, sender_ip)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
id, accountID, body, att, name, channel, ip); err != nil {
|
||||
(message_id, account_id, body, attachment, attachment_name, channel, lang, channel_lang, sender_ip)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), nullStr(channelLang), ip); err != nil {
|
||||
return uuid.Nil, fmt.Errorf("feedback: insert: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// nullStr maps an empty string to a NULL text bind, else the value.
|
||||
func nullStr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// HasUnread reports whether the account has any message the operator has not yet
|
||||
// dealt with (read_at IS NULL) — the anti-spam gate's condition.
|
||||
func (s *Store) HasUnread(ctx context.Context, accountID uuid.UUID) (bool, error) {
|
||||
@@ -215,12 +222,16 @@ func (s *Store) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, b
|
||||
// message with its sender's resolved display name and source. The attachment bytes
|
||||
// are not loaded here (served separately); only their presence and name are.
|
||||
type AdminMessage struct {
|
||||
ID uuid.UUID
|
||||
AccountID uuid.UUID
|
||||
SenderName string
|
||||
Source string
|
||||
Body string
|
||||
Channel string
|
||||
ID uuid.UUID
|
||||
AccountID uuid.UUID
|
||||
SenderName string
|
||||
Source string
|
||||
Body string
|
||||
Channel string
|
||||
// Lang is the sender's interface language and ChannelLang the connector bot language
|
||||
// (en/ru, empty for a non-connector channel) — both snapshotted at submit time.
|
||||
Lang string
|
||||
ChannelLang string
|
||||
SenderIP string
|
||||
HasAttachment bool
|
||||
AttachmentName string
|
||||
@@ -335,6 +346,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error
|
||||
var m AdminMessage
|
||||
var repliedAt sql.NullTime
|
||||
q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel,
|
||||
COALESCE(m.lang, ''), COALESCE(m.channel_lang, ''),
|
||||
COALESCE(m.sender_ip, ''), (m.attachment IS NOT NULL), COALESCE(m.attachment_name, ''),
|
||||
(m.read_at IS NOT NULL), (m.archived_at IS NOT NULL), (m.reply_body IS NOT NULL),
|
||||
COALESCE(m.reply_body, ''), m.replied_at, m.created_at
|
||||
@@ -343,6 +355,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error
|
||||
WHERE m.message_id = $1`
|
||||
err := s.db.QueryRowContext(ctx, q, id).Scan(
|
||||
&m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel,
|
||||
&m.Lang, &m.ChannelLang,
|
||||
&m.SenderIP, &m.HasAttachment, &m.AttachmentName,
|
||||
&m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
|
||||
Reference in New Issue
Block a user