Files
scrabble-game/backend/internal/feedback/service.go
T
Ilia Denisov b78ce42922
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
feat(feedback): capture the app version; show version + local Filed time
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.
2026-06-22 17:02:05 +02:00

260 lines
8.5 KiB
Go

package feedback
import (
"context"
"errors"
"net/netip"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/notify"
)
const (
// maxBodyRunes caps a feedback message (and an operator reply) length.
maxBodyRunes = 1024
// replyVisibleFor is how long an operator reply stays shown to the player after
// it is delivered (read).
replyVisibleFor = 7 * 24 * time.Hour
)
// Submit / reply validation errors. The transport layer maps them to stable result
// codes; the UI maps those to the messages shown on the feedback screen.
var (
ErrEmptyMessage = errors.New("feedback: empty message")
ErrMessageTooLong = errors.New("feedback: message too long")
ErrAttachmentTooLarge = errors.New("feedback: attachment too large")
ErrAttachmentType = errors.New("feedback: attachment type not allowed")
ErrGuestForbidden = errors.New("feedback: guests cannot submit feedback")
ErrBanned = errors.New("feedback: account is banned from feedback")
ErrPendingReview = errors.New("feedback: previous message still pending review")
)
// validChannels enumerates the submitting platforms a client may report; anything
// else is normalised to "web" (the channel is informational, never a gate).
var validChannels = map[string]bool{"telegram": true, "ios": true, "android": true, "web": true}
// Service is the feedback domain: the only writer of feedback_messages. It reads
// accounts for the guest/role gates and publishes the reply notification.
type Service struct {
store *Store
accounts *account.Store
pub notify.Publisher
now func() time.Time
}
// NewService constructs a Service. store owns feedback_messages; accounts supplies
// the guest flag and the feedback-ban role.
func NewService(store *Store, accounts *account.Store) *Service {
return &Service{
store: store,
accounts: accounts,
pub: notify.Nop{},
now: func() time.Time { return time.Now().UTC() },
}
}
// SetNotifier installs the live-event publisher used to push the "you have a reply"
// signal to the player. It must be called during startup wiring; the default is
// notify.Nop (no live events).
func (svc *Service) SetNotifier(p notify.Publisher) {
if p != nil {
svc.pub = p
}
}
// Submit stores a feedback message from accountID. It rejects guests, feedback-
// banned accounts and a sender who still has a message pending review, then
// 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, version, senderIP string) error {
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
return err
}
if acc.IsGuest {
return ErrGuestForbidden
}
banned, err := svc.accounts.HasRole(ctx, accountID, account.RoleFeedbackBanned)
if err != nil {
return err
}
if banned {
return ErrBanned
}
pending, err := svc.store.HasUnread(ctx, accountID)
if err != nil {
return err
}
if pending {
return ErrPendingReview
}
body = strings.TrimSpace(body)
if body == "" {
return ErrEmptyMessage
}
if utf8.RuneCountInString(body) > maxBodyRunes {
return ErrMessageTooLong
}
if len(attachment) > 0 {
if len(attachment) > maxAttachmentBytes {
return ErrAttachmentTooLarge
}
if !AllowedAttachment(attachmentName) {
return ErrAttachmentType
}
} else {
attachmentName = "" // a name without bytes carries no attachment
}
ch := normalizeChannel(channel)
// 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
}
// State is the player's feedback screen state. Reason is "" (can send), "pending"
// (a previous message is unreviewed) or "banned". Reply is the operator's answer to
// show, or nil. Fetching it delivers any pending replies (delivery counts as read),
// clearing the badge.
type State struct {
CanSend bool
BlockedReason string
Reply *Reply
}
// Reply is the operator's answer shown back to the player.
type Reply struct {
Body string
RepliedAt time.Time
}
// State computes the feedback screen state for accountID and marks any pending
// replies delivered.
func (svc *Service) State(ctx context.Context, accountID uuid.UUID) (State, error) {
banned, err := svc.accounts.HasRole(ctx, accountID, account.RoleFeedbackBanned)
if err != nil {
return State{}, err
}
pending, err := svc.store.HasUnread(ctx, accountID)
if err != nil {
return State{}, err
}
st := State{CanSend: !banned && !pending}
switch {
case banned:
st.BlockedReason = "banned"
case pending:
st.BlockedReason = "pending"
}
vr, ok, err := svc.store.LatestVisibleReply(ctx, accountID, svc.now().Add(-replyVisibleFor))
if err != nil {
return State{}, err
}
if ok {
st.Reply = &Reply{Body: vr.Body, RepliedAt: vr.RepliedAt}
}
// Opening the screen delivers every pending reply; mark them read to clear the
// badge (only the latest is shown, but all are now delivered).
if err := svc.store.MarkRepliesDelivered(ctx, accountID, svc.now()); err != nil {
return State{}, err
}
return st, nil
}
// ReplyUnread reports whether the account has an operator reply not yet delivered —
// the lobby/Info badge condition. It has no side effect (the lobby may poll it).
func (svc *Service) ReplyUnread(ctx context.Context, accountID uuid.UUID) (bool, error) {
return svc.store.HasUnreadReply(ctx, accountID)
}
// AdminList returns the filtered console feedback list, paginated.
func (svc *Service) AdminList(ctx context.Context, f AdminFilter, limit, offset int) ([]AdminRow, error) {
return svc.store.AdminList(ctx, f, limit, offset)
}
// AdminCount counts the filtered console feedback list.
func (svc *Service) AdminCount(ctx context.Context, f AdminFilter) (int, error) {
return svc.store.AdminCount(ctx, f)
}
// AdminGet loads one message for the console detail view, or ErrNotFound.
func (svc *Service) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error) {
return svc.store.AdminGet(ctx, id)
}
// CountUnread counts the active (unread, not archived) feedback queue for the
// dashboard.
func (svc *Service) CountUnread(ctx context.Context) (int, error) {
return svc.store.CountUnread(ctx)
}
// Attachment returns a message's file name and bytes, reporting false when absent.
func (svc *Service) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) {
return svc.store.Attachment(ctx, id)
}
// MarkRead marks a message dealt-with (the manual "read" action).
func (svc *Service) MarkRead(ctx context.Context, id uuid.UUID) error {
return svc.store.MarkRead(ctx, id, svc.now())
}
// Reply sets the operator reply on a message (marking it read), then pushes the
// "you have a reply" notification to the player.
func (svc *Service) Reply(ctx context.Context, id uuid.UUID, body string) error {
body = strings.TrimSpace(body)
if body == "" {
return ErrEmptyMessage
}
if utf8.RuneCountInString(body) > maxBodyRunes {
return ErrMessageTooLong
}
accountID, err := svc.store.Reply(ctx, id, body, svc.now())
if err != nil {
return err
}
svc.pub.Publish(notify.Notification(accountID, notify.NotifyAdminReply))
return nil
}
// Archive files a handled message away (marking it read).
func (svc *Service) Archive(ctx context.Context, id uuid.UUID) error {
return svc.store.Archive(ctx, id, svc.now())
}
// Delete physically removes a message and its attachment.
func (svc *Service) Delete(ctx context.Context, id uuid.UUID) error {
return svc.store.Delete(ctx, id)
}
// DeleteAllByAccount physically removes every message of an account.
func (svc *Service) DeleteAllByAccount(ctx context.Context, accountID uuid.UUID) error {
return svc.store.DeleteAllByAccount(ctx, accountID)
}
// parseIP returns a validated canonical IP string, or nil when raw is empty or not
// a valid address.
func parseIP(raw string) *string {
addr, err := netip.ParseAddr(strings.TrimSpace(raw))
if err != nil {
return nil
}
canon := addr.String()
return &canon
}
// normalizeChannel lower-cases and validates the client-reported channel, falling
// back to "web" for anything unrecognised.
func normalizeChannel(c string) string {
c = strings.ToLower(strings.TrimSpace(c))
if validChannels[c] {
return c
}
return "web"
}