feat(feedback): in-app user feedback with admin review and account roles
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
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
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
// Package feedback owns user feedback: the flat list of messages a registered
|
||||
// player sends to the operators (each with an optional single attachment), the
|
||||
// anti-spam "one pending message at a time" gate, and the operator's inline reply
|
||||
// delivered back to the player's app. It is modelled on the admin chat-moderation
|
||||
// surface (internal/social/adminchat.go) and uses raw SQL throughout for the
|
||||
// attachment bytea, the COALESCE-based "set once" stamps, and the reply-visibility
|
||||
// window.
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when no feedback message matches the lookup.
|
||||
var ErrNotFound = errors.New("feedback: not found")
|
||||
|
||||
// Store is the Postgres-backed query surface for feedback_messages.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore constructs a Store wrapping db.
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// 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. created_at defaults to now() in the database.
|
||||
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel string, ip *string) (uuid.UUID, error) {
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err)
|
||||
}
|
||||
var att []byte // a nil []byte binds as bytea NULL
|
||||
if len(attachment) > 0 {
|
||||
att = attachment
|
||||
}
|
||||
var name *string
|
||||
if attachmentName != "" {
|
||||
name = &attachmentName
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO backend.feedback_messages
|
||||
(message_id, account_id, body, attachment, attachment_name, channel, sender_ip)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
id, accountID, body, att, name, channel, ip); err != nil {
|
||||
return uuid.Nil, fmt.Errorf("feedback: insert: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// HasUnread reports whether the account has any message the operator has not yet
|
||||
// dealt with (read_at IS NULL) — the anti-spam gate's condition.
|
||||
func (s *Store) HasUnread(ctx context.Context, accountID uuid.UUID) (bool, error) {
|
||||
var ok bool
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT EXISTS (SELECT 1 FROM backend.feedback_messages WHERE account_id = $1 AND read_at IS NULL)`,
|
||||
accountID).Scan(&ok); err != nil {
|
||||
return false, fmt.Errorf("feedback: has-unread %s: %w", accountID, err)
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// HasUnreadReply reports whether the account has an operator reply not yet
|
||||
// delivered to its app (reply_read_at IS NULL) — the badge's condition.
|
||||
func (s *Store) HasUnreadReply(ctx context.Context, accountID uuid.UUID) (bool, error) {
|
||||
var ok bool
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT EXISTS (SELECT 1 FROM backend.feedback_messages
|
||||
WHERE account_id = $1 AND reply_body IS NOT NULL AND reply_read_at IS NULL)`,
|
||||
accountID).Scan(&ok); err != nil {
|
||||
return false, fmt.Errorf("feedback: has-unread-reply %s: %w", accountID, err)
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// VisibleReply is the operator reply shown back to the player: the reply on their
|
||||
// most recent replied message still inside the visibility window.
|
||||
type VisibleReply struct {
|
||||
MessageID uuid.UUID
|
||||
Body string
|
||||
RepliedAt time.Time
|
||||
ReplyReadAt sql.NullTime
|
||||
}
|
||||
|
||||
// LatestVisibleReply returns the account's most recent message that carries a reply
|
||||
// still visible to the player — not yet delivered, or delivered after cutoff (one
|
||||
// week ago). Reports false when there is none.
|
||||
func (s *Store) LatestVisibleReply(ctx context.Context, accountID uuid.UUID, cutoff time.Time) (VisibleReply, bool, error) {
|
||||
var vr VisibleReply
|
||||
var repliedAt sql.NullTime
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT message_id, COALESCE(reply_body, ''), replied_at, reply_read_at
|
||||
FROM backend.feedback_messages
|
||||
WHERE account_id = $1 AND reply_body IS NOT NULL
|
||||
AND (reply_read_at IS NULL OR reply_read_at > $2)
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
accountID, cutoff).Scan(&vr.MessageID, &vr.Body, &repliedAt, &vr.ReplyReadAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return VisibleReply{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return VisibleReply{}, false, fmt.Errorf("feedback: latest visible reply %s: %w", accountID, err)
|
||||
}
|
||||
if repliedAt.Valid {
|
||||
vr.RepliedAt = repliedAt.Time
|
||||
}
|
||||
return vr, true, nil
|
||||
}
|
||||
|
||||
// MarkRepliesDelivered stamps reply_read_at on every not-yet-delivered reply of the
|
||||
// account (delivery counts as read), clearing the badge when the player opens the
|
||||
// feedback screen.
|
||||
func (s *Store) MarkRepliesDelivered(ctx context.Context, accountID uuid.UUID, at time.Time) error {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE backend.feedback_messages SET reply_read_at = $2
|
||||
WHERE account_id = $1 AND reply_body IS NOT NULL AND reply_read_at IS NULL`,
|
||||
accountID, at); err != nil {
|
||||
return fmt.Errorf("feedback: mark replies delivered %s: %w", accountID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkRead stamps read_at the first time, marking the message dealt-with without any
|
||||
// other action; a re-mark keeps the original time.
|
||||
func (s *Store) MarkRead(ctx context.Context, id uuid.UUID, at time.Time) error {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE backend.feedback_messages SET read_at = COALESCE(read_at, $2) WHERE message_id = $1`,
|
||||
id, at); err != nil {
|
||||
return fmt.Errorf("feedback: mark read %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reply sets (or replaces) the operator reply on a message, marks it read, and
|
||||
// resets its delivery so the player is notified again. Returns the message's
|
||||
// account_id for the live notification, or ErrNotFound.
|
||||
func (s *Store) Reply(ctx context.Context, id uuid.UUID, body string, at time.Time) (uuid.UUID, error) {
|
||||
var accountID uuid.UUID
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`UPDATE backend.feedback_messages
|
||||
SET reply_body = $2, replied_at = $3, reply_read_at = NULL, read_at = COALESCE(read_at, $3)
|
||||
WHERE message_id = $1
|
||||
RETURNING account_id`,
|
||||
id, body, at).Scan(&accountID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return uuid.Nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("feedback: reply %s: %w", id, err)
|
||||
}
|
||||
return accountID, nil
|
||||
}
|
||||
|
||||
// Archive files a handled message away and marks it read.
|
||||
func (s *Store) Archive(ctx context.Context, id uuid.UUID, at time.Time) error {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE backend.feedback_messages
|
||||
SET archived_at = COALESCE(archived_at, $2), read_at = COALESCE(read_at, $2)
|
||||
WHERE message_id = $1`,
|
||||
id, at); err != nil {
|
||||
return fmt.Errorf("feedback: archive %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete physically removes a message (with its attachment).
|
||||
func (s *Store) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM backend.feedback_messages WHERE message_id = $1`, id); err != nil {
|
||||
return fmt.Errorf("feedback: delete %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAllByAccount physically removes every message of an account.
|
||||
func (s *Store) DeleteAllByAccount(ctx context.Context, accountID uuid.UUID) error {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM backend.feedback_messages WHERE account_id = $1`, accountID); err != nil {
|
||||
return fmt.Errorf("feedback: delete all %s: %w", accountID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Attachment returns a message's stored file name and bytes, reporting false when
|
||||
// the message has no attachment or does not exist.
|
||||
func (s *Store) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) {
|
||||
var name string
|
||||
var data []byte
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(attachment_name, ''), attachment FROM backend.feedback_messages WHERE message_id = $1`,
|
||||
id).Scan(&name, &data)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, false, fmt.Errorf("feedback: attachment %s: %w", id, err)
|
||||
}
|
||||
if data == nil {
|
||||
return "", nil, false, nil
|
||||
}
|
||||
return name, data, true, nil
|
||||
}
|
||||
|
||||
// AdminMessage is one feedback message in the operator console detail view: the
|
||||
// message with its sender's resolved display name and source. The attachment bytes
|
||||
// are not loaded here (served separately); only their presence and name are.
|
||||
type AdminMessage struct {
|
||||
ID uuid.UUID
|
||||
AccountID uuid.UUID
|
||||
SenderName string
|
||||
Source string
|
||||
Body string
|
||||
Channel string
|
||||
SenderIP string
|
||||
HasAttachment bool
|
||||
AttachmentName string
|
||||
Read bool
|
||||
Archived bool
|
||||
Replied bool
|
||||
ReplyBody string
|
||||
RepliedAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// AdminRow is one feedback message in the console list (a lighter projection).
|
||||
type AdminRow struct {
|
||||
ID uuid.UUID
|
||||
AccountID uuid.UUID
|
||||
SenderName string
|
||||
Source string
|
||||
Channel string
|
||||
HasAttachment bool
|
||||
Read bool
|
||||
Replied bool
|
||||
Archived bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// AdminFilter narrows the console feedback list. Status is one of "unread"
|
||||
// (default), "read" or "archived". NameMask/ExtMask are glob masks
|
||||
// (account.LikePattern) on the sender's display name / any identity's external id;
|
||||
// AccountID, when set, restricts to one account (the per-user link from /users).
|
||||
type AdminFilter struct {
|
||||
Status string
|
||||
NameMask string
|
||||
ExtMask string
|
||||
AccountID uuid.UUID
|
||||
}
|
||||
|
||||
// feedbackSource projects a sender's source: guest, robot, or its oldest identity
|
||||
// kind ("—" when it has none). Mirrors social.adminMessageSource.
|
||||
const feedbackSource = `CASE
|
||||
WHEN a.is_guest THEN 'guest'
|
||||
WHEN EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'robot') THEN 'robot'
|
||||
ELSE COALESCE((SELECT i2.kind FROM backend.identities i2 WHERE i2.account_id = a.account_id ORDER BY i2.created_at ASC LIMIT 1), '—')
|
||||
END`
|
||||
|
||||
// adminWhere builds the shared WHERE clause and its positional args (from $1).
|
||||
func adminWhere(f AdminFilter) (string, []any) {
|
||||
var where string
|
||||
switch f.Status {
|
||||
case "archived":
|
||||
where = `m.archived_at IS NOT NULL`
|
||||
case "read":
|
||||
where = `m.read_at IS NOT NULL AND m.archived_at IS NULL`
|
||||
default: // unread
|
||||
where = `m.read_at IS NULL AND m.archived_at IS NULL`
|
||||
}
|
||||
var args []any
|
||||
if f.AccountID != uuid.Nil {
|
||||
args = append(args, f.AccountID)
|
||||
where += fmt.Sprintf(` AND m.account_id = $%d`, len(args))
|
||||
}
|
||||
if name := account.LikePattern(f.NameMask); name != "" {
|
||||
args = append(args, name)
|
||||
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
|
||||
}
|
||||
if ext := account.LikePattern(f.ExtMask); ext != "" {
|
||||
args = append(args, ext)
|
||||
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities ie WHERE ie.account_id = a.account_id AND ie.external_id ILIKE $%d ESCAPE '\')`, len(args))
|
||||
}
|
||||
return where, args
|
||||
}
|
||||
|
||||
// AdminList returns the filtered console feedback list, newest first, paginated.
|
||||
func (s *Store) AdminList(ctx context.Context, f AdminFilter, limit, offset int) ([]AdminRow, error) {
|
||||
where, args := adminWhere(f)
|
||||
q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.channel,
|
||||
(m.attachment IS NOT NULL), (m.read_at IS NOT NULL), (m.reply_body IS NOT NULL), (m.archived_at IS NOT NULL), m.created_at
|
||||
FROM backend.feedback_messages m
|
||||
JOIN backend.accounts a ON a.account_id = m.account_id
|
||||
WHERE ` + where +
|
||||
fmt.Sprintf(` ORDER BY m.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
|
||||
args = append(args, limit, offset)
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("feedback: admin list: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AdminRow
|
||||
for rows.Next() {
|
||||
var r AdminRow
|
||||
if err := rows.Scan(&r.ID, &r.AccountID, &r.SenderName, &r.Source, &r.Channel,
|
||||
&r.HasAttachment, &r.Read, &r.Replied, &r.Archived, &r.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("feedback: scan admin row: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AdminCount counts the filtered console feedback list, for the pager.
|
||||
func (s *Store) AdminCount(ctx context.Context, f AdminFilter) (int, error) {
|
||||
where, args := adminWhere(f)
|
||||
var n int
|
||||
q := `SELECT COUNT(*) FROM backend.feedback_messages m JOIN backend.accounts a ON a.account_id = m.account_id WHERE ` + where
|
||||
if err := s.db.QueryRowContext(ctx, q, args...).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("feedback: admin count: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// AdminGet loads one message for the console detail view, or ErrNotFound.
|
||||
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.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
|
||||
FROM backend.feedback_messages m
|
||||
JOIN backend.accounts a ON a.account_id = m.account_id
|
||||
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.SenderIP, &m.HasAttachment, &m.AttachmentName,
|
||||
&m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return AdminMessage{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return AdminMessage{}, fmt.Errorf("feedback: admin get %s: %w", id, err)
|
||||
}
|
||||
if repliedAt.Valid {
|
||||
m.RepliedAt = repliedAt.Time
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// CountUnread counts the active (unread, not archived) feedback queue, for the
|
||||
// console dashboard.
|
||||
func (s *Store) CountUnread(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM backend.feedback_messages WHERE read_at IS NULL AND archived_at IS NULL`,
|
||||
).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("feedback: count unread: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
Reference in New Issue
Block a user