55ed87fb11
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.
265 lines
8.7 KiB
Go
265 lines
8.7 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
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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"
|
|
}
|