feat(feedback): capture the app version; show version + local Filed time
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s

Each feedback submission now carries the client app version (__APP_VERSION__),
snapshotted like the interface language: FlatBuffers FeedbackSubmitRequest gains
a version field → gateway transcode → backend, persisted in a new nullable
feedback_messages.app_version column (migration 00003, additive so an image
rollback stays DB-safe). The operator console detail shows the app version and
renders the Filed time in UTC plus the sender's time zone (fmtTimeIn).

Touches: fbs schema + regenerated Go/TS codegen, codec + transport (the client
attaches its build), gateway transcode + backendclient, feedback store/service,
admin view + template, docs (ARCHITECTURE §15, FUNCTIONAL + _ru). Verified:
feedback integration tests (migration + version round-trip), codec round-trip,
check/unit/build green.
This commit is contained in:
Ilia Denisov
2026-06-22 17:02:05 +02:00
parent 08c2c5f660
commit b78ce42922
20 changed files with 127 additions and 45 deletions
+4 -4
View File
@@ -72,7 +72,7 @@ func (svc *Service) SetNotifier(p notify.Publisher) {
// validates the body (non-empty, within the rune limit) and the optional
// attachment (size and extension allow-list). senderIP is the gateway-forwarded
// client IP (validated); channel is the submitting platform.
func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, senderIP string) error {
func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, version, senderIP string) error {
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
return err
@@ -112,9 +112,9 @@ func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string
attachmentName = "" // a name without bytes carries no attachment
}
ch := normalizeChannel(channel)
// Snapshot the sender's interface language at submit time (acc is already loaded
// for the guest check) so the operator later sees the state as it was.
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, parseIP(senderIP))
// Snapshot the sender's interface language and the client app version at submit time (acc
// is already loaded for the guest check) so the operator later sees the state as it was.
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, version, parseIP(senderIP))
return err
}
+15 -10
View File
@@ -34,10 +34,10 @@ 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. lang (the sender's interface language) is a snapshot
// 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 string, ip *string) (uuid.UUID, error) {
// channel are stored as given. lang (the sender's interface language) and version (the client
// app build) 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, version string, ip *string) (uuid.UUID, error) {
id, err := uuid.NewV7()
if err != nil {
return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err)
@@ -48,9 +48,9 @@ func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, at
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO backend.feedback_messages
(message_id, account_id, body, attachment, attachment_name, channel, lang, sender_ip)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), ip); err != nil {
(message_id, account_id, body, attachment, attachment_name, channel, lang, app_version, sender_ip)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), nullStr(version), ip); err != nil {
return uuid.Nil, fmt.Errorf("feedback: insert: %w", err)
}
return id, nil
@@ -228,7 +228,12 @@ type AdminMessage struct {
Body string
Channel string
// Lang is the sender's interface language, snapshotted at submit time.
Lang string
Lang string
// Version is the client app build the report was sent from, snapshotted at submit time.
Version string
// TimeZone is the sender account's IANA zone, for rendering CreatedAt in the sender's
// local time alongside UTC.
TimeZone string
SenderIP string
HasAttachment bool
AttachmentName string
@@ -343,7 +348,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.lang, ''), COALESCE(m.app_version, ''), a.time_zone,
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
@@ -352,7 +357,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.Lang, &m.Version, &m.TimeZone,
&m.SenderIP, &m.HasAttachment, &m.AttachmentName,
&m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {