419ea11b14
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
User-facing Feedback screen (Settings -> Info, registered accounts only): a message (<=1024 runes) plus one optional attachment, an anti-spam gate (one unreviewed message at a time), and the operator's inline reply with a Settings/Info badge. Server-rendered admin console section (/_gm/feedback): unread/read/archived queue with per-user search, detail with read/reply/ archive/delete/delete-all, safe attachment serving (nosniff, images inline via <img>, others download-only). Introduces account_roles, the first per-account role table; feedback_banned blocks only feedback submission, granted/revoked from /users and the delete-with-block action. - migration 00004_feedback (feedback_messages + account_roles) + jetgen - backend internal/feedback (store+service), internal/account/roles.go - wire: FlatBuffers feedback.submit/get/unread; gateway guest gate (Op.NonGuest, is_guest via session resolve) -> guest_forbidden before any backend call - reply push reuses NotificationEvent with a new admin_reply sub-kind - UI: /feedback route + screen, attachment picker, badge, channel detection, i18n - tests: feedback unit (Go+UI), gateway guest-gate, inttest lifecycle, e2e - docs: PLAN stage 19, ARCHITECTURE s15, FUNCTIONAL(+ru), TESTING, READMEs
257 lines
8.3 KiB
Go
257 lines
8.3 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, 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
|
|
}
|
|
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, normalizeChannel(channel), 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"
|
|
}
|