From 55ed87fb117b83b54ac891904304034ed979cc88 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 13:25:27 +0200 Subject: [PATCH] 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. --- backend/internal/feedback/service.go | 10 ++++- backend/internal/feedback/store.go | 43 +++++++++++------- backend/internal/inttest/feedback_test.go | 45 +++++++++++++++++++ .../jet/backend/model/feedback_messages.go | 2 + .../jet/backend/table/feedback_messages.go | 10 ++++- .../postgres/migrations/00004_feedback.sql | 3 +- .../migrations/00005_feedback_languages.sql | 15 +++++++ .../server/handlers_admin_feedback.go | 11 +---- 8 files changed, 111 insertions(+), 28 deletions(-) create mode 100644 backend/internal/postgres/migrations/00005_feedback_languages.sql diff --git a/backend/internal/feedback/service.go b/backend/internal/feedback/service.go index 2f1eed1..d0b22be 100644 --- a/backend/internal/feedback/service.go +++ b/backend/internal/feedback/service.go @@ -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 } diff --git a/backend/internal/feedback/store.go b/backend/internal/feedback/store.go index 7d9c4a6..2e64e39 100644 --- a/backend/internal/feedback/store.go +++ b/backend/internal/feedback/store.go @@ -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) { diff --git a/backend/internal/inttest/feedback_test.go b/backend/internal/inttest/feedback_test.go index 98b3ee3..6aadcfa 100644 --- a/backend/internal/inttest/feedback_test.go +++ b/backend/internal/inttest/feedback_test.go @@ -145,6 +145,51 @@ func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) { } } +func TestFeedbackSnapshotsLanguages(t *testing.T) { + ctx := context.Background() + svc := newFeedbackService() + acc := provisionAccount(t) + if _, err := testDB.ExecContext(ctx, + `UPDATE backend.accounts SET preferred_language = 'en', service_language = 'ru' WHERE account_id = $1`, acc); err != nil { + t.Fatalf("set languages: %v", err) + } + // A Telegram (connector) message snapshots both the interface language and the bot. + if err := svc.Submit(ctx, acc, "from telegram", nil, "", "telegram", ""); err != nil { + t.Fatalf("submit: %v", err) + } + id := latestFeedbackID(t, svc, acc) + if m, err := svc.AdminGet(ctx, id); err != nil { + t.Fatalf("admin get: %v", err) + } else if m.Lang != "en" || m.ChannelLang != "ru" { + t.Fatalf("snapshot = lang %q / channel_lang %q, want en / ru", m.Lang, m.ChannelLang) + } + // Changing the account afterwards must not change the stored snapshot. + if _, err := testDB.ExecContext(ctx, + `UPDATE backend.accounts SET preferred_language = 'ru', service_language = 'en' WHERE account_id = $1`, acc); err != nil { + t.Fatalf("change languages: %v", err) + } + if m, err := svc.AdminGet(ctx, id); err != nil { + t.Fatal(err) + } else if m.Lang != "en" || m.ChannelLang != "ru" { + t.Fatalf("snapshot drifted after account change = lang %q / channel_lang %q", m.Lang, m.ChannelLang) + } + + // A non-connector channel records no bot language even when the account has one. + acc2 := provisionAccount(t) + if _, err := testDB.ExecContext(ctx, + `UPDATE backend.accounts SET preferred_language = 'en', service_language = 'ru' WHERE account_id = $1`, acc2); err != nil { + t.Fatalf("set languages 2: %v", err) + } + if err := svc.Submit(ctx, acc2, "from web", nil, "", "web", ""); err != nil { + t.Fatalf("submit web: %v", err) + } + if m, err := svc.AdminGet(ctx, latestFeedbackID(t, svc, acc2)); err != nil { + t.Fatal(err) + } else if m.Lang != "en" || m.ChannelLang != "" { + t.Fatalf("web snapshot = lang %q / channel_lang %q, want en / empty", m.Lang, m.ChannelLang) + } +} + func TestFeedbackBanRole(t *testing.T) { ctx := context.Background() svc := newFeedbackService() diff --git a/backend/internal/postgres/jet/backend/model/feedback_messages.go b/backend/internal/postgres/jet/backend/model/feedback_messages.go index a4b2607..ef0dff8 100644 --- a/backend/internal/postgres/jet/backend/model/feedback_messages.go +++ b/backend/internal/postgres/jet/backend/model/feedback_messages.go @@ -20,6 +20,8 @@ type FeedbackMessages struct { AttachmentName *string SenderIP *string Channel string + Lang *string + ChannelLang *string ReadAt *time.Time ArchivedAt *time.Time ReplyBody *string diff --git a/backend/internal/postgres/jet/backend/table/feedback_messages.go b/backend/internal/postgres/jet/backend/table/feedback_messages.go index ebf1c71..7b16c19 100644 --- a/backend/internal/postgres/jet/backend/table/feedback_messages.go +++ b/backend/internal/postgres/jet/backend/table/feedback_messages.go @@ -24,6 +24,8 @@ type feedbackMessagesTable struct { AttachmentName postgres.ColumnString SenderIP postgres.ColumnString Channel postgres.ColumnString + Lang postgres.ColumnString + ChannelLang postgres.ColumnString ReadAt postgres.ColumnTimestampz ArchivedAt postgres.ColumnTimestampz ReplyBody postgres.ColumnString @@ -78,14 +80,16 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM AttachmentNameColumn = postgres.StringColumn("attachment_name") SenderIPColumn = postgres.StringColumn("sender_ip") ChannelColumn = postgres.StringColumn("channel") + LangColumn = postgres.StringColumn("lang") + ChannelLangColumn = postgres.StringColumn("channel_lang") ReadAtColumn = postgres.TimestampzColumn("read_at") ArchivedAtColumn = postgres.TimestampzColumn("archived_at") ReplyBodyColumn = postgres.StringColumn("reply_body") RepliedAtColumn = postgres.TimestampzColumn("replied_at") ReplyReadAtColumn = postgres.TimestampzColumn("reply_read_at") CreatedAtColumn = postgres.TimestampzColumn("created_at") - allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn} - mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn} + allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, LangColumn, ChannelLangColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn} + mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, LangColumn, ChannelLangColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn} defaultColumns = postgres.ColumnList{CreatedAtColumn} ) @@ -100,6 +104,8 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM AttachmentName: AttachmentNameColumn, SenderIP: SenderIPColumn, Channel: ChannelColumn, + Lang: LangColumn, + ChannelLang: ChannelLangColumn, ReadAt: ReadAtColumn, ArchivedAt: ArchivedAtColumn, ReplyBody: ReplyBodyColumn, diff --git a/backend/internal/postgres/migrations/00004_feedback.sql b/backend/internal/postgres/migrations/00004_feedback.sql index 8785c16..c4fd632 100644 --- a/backend/internal/postgres/migrations/00004_feedback.sql +++ b/backend/internal/postgres/migrations/00004_feedback.sql @@ -15,7 +15,8 @@ SET search_path = backend, pg_catalog; -- attachment is the raw bytes (capped at 1,000,000 in Go); attachment_name carries the original -- file name (its extension drives the safe content-type at serve time). sender_ip is the -- gateway-forwarded client IP (validated, like chat); channel is the submitting platform --- (telegram/ios/android/web, validated in Go). +-- (telegram/ios/android/web, validated in Go). The sender's interface/bot language snapshots +-- (lang, channel_lang) are added additively in 00005. CREATE TABLE feedback_messages ( message_id uuid PRIMARY KEY, account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, diff --git a/backend/internal/postgres/migrations/00005_feedback_languages.sql b/backend/internal/postgres/migrations/00005_feedback_languages.sql new file mode 100644 index 0000000..ea3a92c --- /dev/null +++ b/backend/internal/postgres/migrations/00005_feedback_languages.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- Snapshot the sender's languages on each feedback message, taken at submit time so the +-- operator console shows the state as it was, not the account's current settings (the same +-- snapshot discipline as a suspension's reason). lang is the sender's interface language; +-- channel_lang is the connector bot language (en/ru) when the message arrived through an +-- external connector (Telegram), else NULL. Additive over 00004 so it applies forward-safe. +SET search_path = backend, pg_catalog; + +ALTER TABLE feedback_messages ADD COLUMN lang text; +ALTER TABLE feedback_messages ADD COLUMN channel_lang text; + +-- +goose Down +SET search_path = backend, pg_catalog; +ALTER TABLE feedback_messages DROP COLUMN IF EXISTS channel_lang; +ALTER TABLE feedback_messages DROP COLUMN IF EXISTS lang; diff --git a/backend/internal/server/handlers_admin_feedback.go b/backend/internal/server/handlers_admin_feedback.go index 973f96d..76e7e2f 100644 --- a/backend/internal/server/handlers_admin_feedback.go +++ b/backend/internal/server/handlers_admin_feedback.go @@ -79,7 +79,8 @@ func (s *Server) consoleFeedbackDetail(c *gin.Context) { } view := adminconsole.FeedbackDetailView{ ID: m.ID.String(), AccountID: m.AccountID.String(), SenderName: m.SenderName, - Source: m.Source, Channel: m.Channel, IP: m.SenderIP, Body: m.Body, + Source: m.Source, Channel: m.Channel, InterfaceLanguage: m.Lang, BotLanguage: m.ChannelLang, + IP: m.SenderIP, Body: m.Body, HasAttachment: m.HasAttachment, AttachmentName: m.AttachmentName, IsImage: feedback.IsImage(m.AttachmentName), Read: m.Read, Archived: m.Archived, Replied: m.Replied, ReplyBody: m.ReplyBody, RepliedAt: fmtTime(m.RepliedAt), CreatedAt: fmtTime(m.CreatedAt), @@ -87,14 +88,6 @@ func (s *Server) consoleFeedbackDetail(c *gin.Context) { if banned, err := s.accounts.HasRole(ctx, m.AccountID, account.RoleFeedbackBanned); err == nil { view.Banned = banned } - if acc, err := s.accounts.GetByID(ctx, m.AccountID); err == nil { - view.InterfaceLanguage = acc.PreferredLanguage - // The connector bot (the account's last service-language bot) is meaningful only - // for a message that arrived through an external connector — currently Telegram. - if m.Channel == "telegram" && acc.ServiceLanguage != "" { - view.BotLanguage = acc.ServiceLanguage - } - } s.renderConsole(c, "feedback_detail", "feedback", "Feedback", view) }