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, browserTZ, 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, the client app version and the client's // detected UTC offset 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, browserTZ, 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) } // CountSince counts feedback created after since, for the operator alert worker. func (svc *Service) CountSince(ctx context.Context, since time.Time) (int, error) { return svc.store.CountSince(ctx, since) } // 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" }