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

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:
Ilia Denisov
2026-06-15 12:23:10 +02:00
parent fc848157d6
commit 419ea11b14
75 changed files with 3638 additions and 55 deletions
+1
View File
@@ -130,6 +130,7 @@ internal/server/ # gin engine, route groups, X-User-ID middleware, probes
internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay
internal/game/ # game domain: lifecycle, journal+cache, hint, word-check, GCG, sweeper
internal/social/ # friend graph, per-user blocks, per-game chat + nudge, content filter
internal/feedback/ # user feedback: messages + attachment (bytea), anti-spam gate, admin review/reply
internal/lobby/ # auto-match (DB-backed open games + robot substitution) + friend-game invitations
internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver
internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm
+4
View File
@@ -22,6 +22,7 @@ import (
"scrabble/backend/internal/config"
"scrabble/backend/internal/connector"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/feedback"
"scrabble/backend/internal/game"
"scrabble/backend/internal/link"
"scrabble/backend/internal/lobby"
@@ -167,6 +168,8 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
socialSvc := social.NewService(social.NewStore(db), accounts, games)
socialSvc.SetNotifier(hub)
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
feedbackSvc := feedback.NewService(feedback.NewStore(db), accounts)
feedbackSvc.SetNotifier(hub)
// Robot opponent: provision its durable account pool (a hard startup
// dependency, like the dictionaries) and start its move driver. The matchmaker
@@ -202,6 +205,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
Sessions: sessions,
Accounts: accounts,
Games: games,
Feedback: feedbackSvc,
Social: socialSvc,
Matchmaker: matchmaker,
Invitations: invitations,
+92
View File
@@ -0,0 +1,92 @@
package account
import (
"context"
"fmt"
"github.com/go-jet/jet/v2/postgres"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// Per-account roles. A role is a named capability or restriction attached to an
// account, the reusable replacement for per-feature boolean flags. The set is
// expected to grow; roles are validated against KnownRoles in Go so adding one
// needs no migration. Granted/revoked from the admin console (/users and the
// feedback section).
const (
// RoleFeedbackBanned forbids the account from submitting feedback (only that;
// it is not a full account suspension). See internal/feedback.
RoleFeedbackBanned = "feedback_banned"
)
// KnownRoles is the set of roles the console may grant or revoke; an operator
// cannot assign an unrecognised role.
var KnownRoles = []string{RoleFeedbackBanned}
// IsKnownRole reports whether role is a recognised account role.
func IsKnownRole(role string) bool {
for _, r := range KnownRoles {
if r == role {
return true
}
}
return false
}
// GrantRole gives the account the role, idempotently (a repeat grant is a no-op).
func (s *Store) GrantRole(ctx context.Context, accountID uuid.UUID, role string) error {
stmt := table.AccountRoles.
INSERT(table.AccountRoles.AccountID, table.AccountRoles.Role).
VALUES(accountID, role).
ON_CONFLICT(table.AccountRoles.AccountID, table.AccountRoles.Role).DO_NOTHING()
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("account: grant role %q to %s: %w", role, accountID, err)
}
return nil
}
// RevokeRole removes the role from the account, idempotently.
func (s *Store) RevokeRole(ctx context.Context, accountID uuid.UUID, role string) error {
stmt := table.AccountRoles.
DELETE().
WHERE(table.AccountRoles.AccountID.EQ(postgres.UUID(accountID)).
AND(table.AccountRoles.Role.EQ(postgres.String(role))))
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("account: revoke role %q from %s: %w", role, accountID, err)
}
return nil
}
// HasRole reports whether the account holds the role.
func (s *Store) HasRole(ctx context.Context, accountID uuid.UUID, role string) (bool, error) {
var ok bool
err := s.db.QueryRowContext(ctx,
`SELECT EXISTS (SELECT 1 FROM backend.account_roles WHERE account_id = $1 AND role = $2)`,
accountID, role).Scan(&ok)
if err != nil {
return false, fmt.Errorf("account: has-role %q %s: %w", role, accountID, err)
}
return ok, nil
}
// ListRoles returns the account's roles, oldest grant first.
func (s *Store) ListRoles(ctx context.Context, accountID uuid.UUID) ([]string, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT role FROM backend.account_roles WHERE account_id = $1 ORDER BY granted_at ASC`,
accountID)
if err != nil {
return nil, fmt.Errorf("account: list roles %s: %w", accountID, err)
}
defer rows.Close()
var out []string
for rows.Next() {
var role string
if err := rows.Scan(&role); err != nil {
return nil, fmt.Errorf("account: scan role: %w", err)
}
out = append(out, role)
}
return out, rows.Err()
}
@@ -116,3 +116,8 @@ code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
.lg-min { color: var(--ok); }
.lg-avg { color: var(--accent); }
.lg-max { color: var(--danger); }
/* Feedback: user-controlled message bodies are wrapped and escaped (never HTML);
an image attachment is previewed inline, bounded so it cannot dominate the page. */
.msgbody { white-space: pre-wrap; word-break: break-word; background: var(--bg); padding: 0.6rem 0.8rem; border-radius: 6px; margin: 0.6rem 0; }
.attach { max-width: 100%; max-height: 480px; height: auto; border: 1px solid var(--line); border-radius: 6px; }
@@ -25,6 +25,7 @@ func TestRendererRendersEveryPage(t *testing.T) {
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya", FlaggedHighRate: true}}, Pager: NewPager(1, 50, 1)}, "high-rate"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"},
{"throttled", ThrottledView{
Episodes: []ThrottleEpisodeRow{{Class: "user", Key: "a1", UserID: "a1", Rejected: 1234, FirstSeen: "2026-06-10 12:00", LastSeen: "2026-06-10 12:05"}},
Flagged: []FlaggedAccountRow{{ID: "a1", DisplayName: "Kaya", FlaggedAt: "2026-06-10 12:05"}},
@@ -35,6 +36,8 @@ func TestRendererRendersEveryPage(t *testing.T) {
{"game_detail", GameDetailView{ID: "g1", Variant: "scrabble_en", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
{"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"},
{"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1"}}, Pager: NewPager(1, 50, 1)}, "good luck"},
{"feedback", FeedbackView{Items: []FeedbackRow{{ID: "f1", AccountID: "a1", SenderName: "Kaya", Source: "telegram", Channel: "web", HasAttachment: true, Replied: true}}, Status: "unread", Pager: NewPager(1, 50, 1)}, "replied"},
{"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "ios", Body: "please fix the board", HasAttachment: true, AttachmentName: "shot.png", IsImage: true, Banned: true}, "please fix the board"},
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"},
{"dictionary", DictionaryView{ActiveVersion: "v1.0.0", Variants: []VariantVersions{{Variant: "scrabble_en", Versions: []string{"v1.0.0"}}}, Changes: []DictChangeRow{{Variant: "scrabble_en", Word: "qi", Action: "add"}}}, "Update dictionaries"},
{"dictionary_preview", DictionaryPreviewView{Version: "v1.1.0", Token: "0123456789abcdef0123456789abcdef", ActiveVersion: "v1.0.0", Variants: []VariantDiffRow{{Variant: "scrabble_en", AddedCount: 2, RemovedCount: 1, AddedSample: []string{"qi", "za"}, RemovedSample: []string{"xqz"}, RemovedTruncated: true}}}, "v1.1.0"},
@@ -16,6 +16,7 @@
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
<a href="/_gm/feedback"{{if eq .ActiveNav "feedback"}} class="active"{{end}}>Feedback</a>
<a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a>
<a href="/_gm/throttled"{{if eq .ActiveNav "throttled"}} class="active"{{end}}>Throttled</a>
<a href="/_gm/reasons"{{if eq .ActiveNav "reasons"}} class="active"{{end}}>Reasons</a>
@@ -7,6 +7,7 @@
<a class="card" href="/_gm/games"><h2>Games</h2><p class="bignum">{{.Games}}</p></a>
<a class="card" href="/_gm/games?status=active"><h2>Active games</h2><p class="bignum">{{.ActiveGames}}</p></a>
<a class="card" href="/_gm/complaints?status=open"><h2>Open complaints</h2><p class="bignum">{{.OpenComplaints}}</p></a>
<a class="card" href="/_gm/feedback?status=unread"><h2>Unread feedback</h2><p class="bignum">{{.OpenFeedback}}</p></a>
<a class="card" href="/_gm/dictionary"><h2>Pending dict changes</h2><p class="bignum">{{.PendingChanges}}</p></a>
</div>
<section class="panel">
@@ -0,0 +1,38 @@
{{define "content" -}}
<h1>Feedback</h1>
{{with .Data}}
<nav class="subnav">
<a href="/_gm/feedback?status=unread"{{if eq .Status "unread"}} class="active"{{end}}>unread</a> ·
<a href="/_gm/feedback?status=read"{{if eq .Status "read"}} class="active"{{end}}>read</a> ·
<a href="/_gm/feedback?status=archived"{{if eq .Status "archived"}} class="active"{{end}}>archived</a>
</nav>
<form class="form" method="get" action="/_gm/feedback">
<input type="hidden" name="status" value="{{.Status}}">
{{if .UserID}}<input type="hidden" name="user" value="{{.UserID}}">{{end}}
<input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)">
<input name="ext" value="{{.ExtMask}}" placeholder="external id mask (* ?)">
<button type="submit">Filter</button>
</form>
<table class="list">
<thead><tr><th>Sender</th><th>Source</th><th>Channel</th><th>Attach</th><th>Reply</th><th>State</th><th>Filed</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td><a href="/_gm/feedback/{{.ID}}">{{.SenderName}}</a></td>
<td>{{.Source}}</td>
<td>{{.Channel}}</td>
<td>{{if .HasAttachment}}yes{{else}}<span class="note">—</span>{{end}}</td>
<td>{{if .Replied}}<span class="ok">replied</span>{{else}}<span class="note">—</span>{{end}}</td>
<td>{{if .Archived}}archived{{else if .Read}}read{{else}}<span class="warn">unread</span>{{end}}</td>
<td>{{.CreatedAt}}</td>
</tr>
{{else}}<tr><td colspan="7"><span class="note">no feedback</span></td></tr>{{end}}
</tbody>
</table>
<nav class="pager">
{{if .Pager.HasPrev}}<a href="/_gm/feedback?{{.FilterQuery}}&amp;page={{.Pager.PrevPage}}">&laquo; prev</a>{{end}}
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
{{if .Pager.HasNext}}<a href="/_gm/feedback?{{.FilterQuery}}&amp;page={{.Pager.NextPage}}">next &raquo;</a>{{end}}
</nav>
{{end}}
{{- end}}
@@ -0,0 +1,46 @@
{{define "content" -}}
{{with .Data}}
<h1>Feedback</h1>
<nav class="subnav"><a href="/_gm/feedback">&laquo; feedback</a> · <a href="/_gm/users/{{.AccountID}}">user</a></nav>
<section class="panel"><h2>Message</h2>
<ul class="kv">
<li><b>From</b> <a href="/_gm/users/{{.AccountID}}">{{.SenderName}}</a> ({{.Source}})</li>
<li><b>Channel</b> {{.Channel}}</li>
<li><b>IP</b> {{if .IP}}<code>{{.IP}}</code>{{else}}<span class="note">none</span>{{end}}</li>
<li><b>Filed</b> {{.CreatedAt}}</li>
<li><b>State</b> {{if .Archived}}archived{{else if .Read}}read{{else}}<span class="warn">unread</span>{{end}}</li>
{{if .Banned}}<li><b>Feedback</b> <span class="warn">sender is banned from feedback</span></li>{{end}}
</ul>
<div class="msgbody">{{.Body}}</div>
{{if .HasAttachment}}
<h3>Attachment</h3>
{{if .IsImage}}<p><img class="attach" src="/_gm/feedback/{{.ID}}/attachment" alt="attachment"></p>{{end}}
<p><a href="/_gm/feedback/{{.ID}}/attachment" download>download {{.AttachmentName}}</a></p>
{{end}}
</section>
{{if .Replied}}
<section class="panel"><h2>Current reply</h2>
<div class="msgbody">{{.ReplyBody}}</div>
<p class="note">sent {{.RepliedAt}}</p>
</section>
{{end}}
<section class="panel"><h2>{{if .Replied}}Re-reply{{else}}Reply{{end}}</h2>
<form class="form col" method="post" action="/_gm/feedback/{{.ID}}/reply">
<label>Reply <textarea name="reply" required></textarea></label>
<div><button type="submit">Send reply</button></div>
</form>
</section>
<section class="panel"><h2>Actions</h2>
<form class="form" method="post" action="/_gm/feedback/{{.ID}}/read"><button type="submit">Mark read</button></form>
<form class="form" method="post" action="/_gm/feedback/{{.ID}}/archive"><button type="submit">Archive</button></form>
<form class="form col" method="post" action="/_gm/feedback/{{.ID}}/delete">
<label><input type="checkbox" name="block" value="1"> ban the player from feedback</label>
<div><button type="submit">Delete</button></div>
</form>
<form class="form col" method="post" action="/_gm/feedback/{{.ID}}/delete-all">
<label><input type="checkbox" name="block" value="1"> ban the player from feedback</label>
<div><button type="submit">Delete all from this player</button></div>
</form>
</section>
{{end}}
{{- end}}
@@ -1,7 +1,7 @@
{{define "content" -}}
{{with .Data}}
<h1>{{.DisplayName}}</h1>
<nav class="subnav"><a href="/_gm/users">&laquo; users</a> · <a href="/_gm/messages?user={{.ID}}">messages</a></nav>
<nav class="subnav"><a href="/_gm/users">&laquo; users</a> · <a href="/_gm/messages?user={{.ID}}">messages</a> · <a href="/_gm/feedback?user={{.ID}}">feedback</a></nav>
<div class="cards">
<section class="panel"><h2>Account</h2>
<ul class="kv">
@@ -67,6 +67,23 @@
<div><button type="submit">{{if .Suspension.Blocked}}Re-block{{else}}Block{{end}}</button></div>
</form>
</section>
<section class="panel"><h2>Roles</h2>
{{$id := .ID}}
{{if .Roles}}
<table class="list">
<thead><tr><th>Role</th><th></th></tr></thead>
<tbody>
{{range .Roles}}
<tr><td><code>{{.}}</code></td><td><form class="form" method="post" action="/_gm/users/{{$id}}/revoke-role"><input type="hidden" name="role" value="{{.}}"><button type="submit">Revoke</button></form></td></tr>
{{end}}
</tbody>
</table>
{{else}}<p class="note">no roles</p>{{end}}
<form class="form col" method="post" action="/_gm/users/{{$id}}/grant-role">
<label>Grant role <select name="role">{{range .KnownRoles}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
<div><button type="submit">Grant</button></div>
</form>
</section>
{{if .MoveChart}}
<section class="panel"><h2>Move timing</h2>
<p class="note">Think time per move number across all games — <span class="lg lg-min">min</span> · <span class="lg lg-avg">mean</span> · <span class="lg lg-max">max</span>.</p>
+60
View File
@@ -42,6 +42,7 @@ type DashboardView struct {
Games int
ActiveGames int
OpenComplaints int
OpenFeedback int
PendingChanges int
// ActiveVersion is the dictionary version new games pin (the persisted active
// version), distinct from the per-variant resident versions.
@@ -142,6 +143,10 @@ type UserDetailView struct {
// form; Reasons is the operator-editable reason picklist offered in the block form.
Suspension SuspensionView
Reasons []ReasonOption
// Roles is the account's current roles (each revocable); KnownRoles is the set the
// grant form offers. The first role is the feedback ban (see internal/account).
Roles []string
KnownRoles []string
}
// SuspensionView is an account's current manual-block state shown on the user card: whether it
@@ -362,3 +367,58 @@ type MessageView struct {
Body string
Back string
}
// FeedbackView is the paginated user-feedback queue. Status is the active
// unread/read/archived filter; NameMask/ExtMask are the sender glob filters;
// UserID pins the list to one account (the per-user link from /users);
// FilterQuery is the active filters URL-encoded for the pager links (already
// escaped, hence template.URL — see UsersView.FilterQuery).
type FeedbackView struct {
Items []FeedbackRow
Status string
NameMask string
ExtMask string
UserID string
Pager Pager
FilterQuery template.URL
}
// FeedbackRow is one feedback message in the queue: its sender (linked to the user
// card), source, channel, whether it has an attachment / a reply, its state and
// time.
type FeedbackRow struct {
ID string
AccountID string
SenderName string
Source string
Channel string
HasAttachment bool
Read bool
Replied bool
Archived bool
CreatedAt string
}
// FeedbackDetailView is one feedback message with its body, attachment, state and
// the reply / archive / delete forms. Body, AttachmentName, SenderName and IP are
// user-controlled and rendered as plain auto-escaped text. IsImage gates the inline
// <img> preview; Banned shows whether the sender already holds the feedback ban.
type FeedbackDetailView struct {
ID string
AccountID string
SenderName string
Source string
Channel string
IP string
Body string
HasAttachment bool
AttachmentName string
IsImage bool
Read bool
Archived bool
Replied bool
ReplyBody string
RepliedAt string
CreatedAt string
Banned bool
}
+54
View File
@@ -0,0 +1,54 @@
package feedback
import (
"path/filepath"
"strings"
)
// maxAttachmentBytes caps a single attachment's raw size. Chosen to fit, with the
// message text and the FlatBuffers framing, under the gateway's 1 MiB edge body
// cap, so the whole submit request passes without weakening that cap.
const maxAttachmentBytes = 1_000_000
// allowedExt is the attachment extension allow-list. It is mirrored on the UI as a
// pre-upload gate; the server re-checks here as the trust boundary (metadata only,
// the file content is never parsed). Images render inline in the console; the rest
// are download-only.
var allowedExt = map[string]bool{
"png": true, "jpg": true, "jpeg": true, "webp": true, "gif": true, // images
"pdf": true, "txt": true, "log": true, "doc": true, "docx": true,
"rtf": true, "zip": true, "gz": true, "7z": true,
}
// imageType maps an image extension to the content-type the console serves it with
// (loaded only via <img>, which never executes, so a renamed non-image is inert).
var imageType = map[string]string{
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
"webp": "image/webp", "gif": "image/gif",
}
// ext returns name's lower-cased extension without the leading dot.
func ext(name string) string {
return strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
}
// AllowedAttachment reports whether name's extension is on the allow-list.
func AllowedAttachment(name string) bool {
return allowedExt[ext(name)]
}
// IsImage reports whether name is an inline-previewable image by its extension.
func IsImage(name string) bool {
_, ok := imageType[ext(name)]
return ok
}
// ContentType returns the safe content-type the console serves the attachment
// with: the matching image type for an image, else application/octet-stream so a
// non-image is downloaded rather than rendered.
func ContentType(name string) string {
if t, ok := imageType[ext(name)]; ok {
return t
}
return "application/octet-stream"
}
@@ -0,0 +1,81 @@
package feedback
import "testing"
func TestAllowedAttachment(t *testing.T) {
tests := []struct {
name string
file string
want bool
}{
{"png image", "shot.png", true},
{"jpeg upper-case ext", "Photo.JPG", true},
{"pdf doc", "report.pdf", true},
{"archive 7z", "logs.7z", true},
{"doc with dotted name", "my.notes.docx", true},
{"disallowed exe", "evil.exe", false},
{"disallowed svg (xss vector)", "x.svg", false},
{"disallowed html", "x.html", false},
{"no extension", "README", false},
{"empty name", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := AllowedAttachment(tt.file); got != tt.want {
t.Errorf("AllowedAttachment(%q) = %v, want %v", tt.file, got, tt.want)
}
})
}
}
func TestIsImageAndContentType(t *testing.T) {
tests := []struct {
file string
isImage bool
ctype string
}{
{"a.png", true, "image/png"},
{"a.jpg", true, "image/jpeg"},
{"a.jpeg", true, "image/jpeg"},
{"a.webp", true, "image/webp"},
{"a.gif", true, "image/gif"},
{"a.pdf", false, "application/octet-stream"},
{"a.zip", false, "application/octet-stream"},
{"a.svg", false, "application/octet-stream"}, // even if it slipped past, never image/svg+xml
{"noext", false, "application/octet-stream"},
}
for _, tt := range tests {
t.Run(tt.file, func(t *testing.T) {
if got := IsImage(tt.file); got != tt.isImage {
t.Errorf("IsImage(%q) = %v, want %v", tt.file, got, tt.isImage)
}
if got := ContentType(tt.file); got != tt.ctype {
t.Errorf("ContentType(%q) = %q, want %q", tt.file, got, tt.ctype)
}
})
}
}
func TestNormalizeChannel(t *testing.T) {
tests := []struct {
in string
want string
}{
{"telegram", "telegram"},
{"ios", "ios"},
{"android", "android"},
{"web", "web"},
{" iOS ", "ios"}, // trimmed + lower-cased
{"TELEGRAM", "telegram"},
{"", "web"}, // unknown -> web
{"windows", "web"}, // unknown -> web
{"'; DROP", "web"}, // junk -> web
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
if got := normalizeChannel(tt.in); got != tt.want {
t.Errorf("normalizeChannel(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
+256
View File
@@ -0,0 +1,256 @@
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"
}
+370
View File
@@ -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
}
+227
View File
@@ -0,0 +1,227 @@
//go:build integration
package inttest
import (
"context"
"errors"
"strings"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/feedback"
)
func newFeedbackService() *feedback.Service {
return feedback.NewService(feedback.NewStore(testDB), account.NewStore(testDB))
}
// latestFeedbackID returns the id of the account's single message (across states).
func latestFeedbackID(t *testing.T, svc *feedback.Service, acc uuid.UUID) uuid.UUID {
t.Helper()
ctx := context.Background()
for _, status := range []string{"unread", "read", "archived"} {
rows, err := svc.AdminList(ctx, feedback.AdminFilter{AccountID: acc, Status: status}, 50, 0)
if err != nil {
t.Fatalf("admin list %s: %v", status, err)
}
if len(rows) > 0 {
return rows[0].ID
}
}
t.Fatal("no feedback message for account")
return uuid.Nil
}
func TestFeedbackGuestRejected(t *testing.T) {
svc := newFeedbackService()
guest := provisionGuest(t)
if err := svc.Submit(context.Background(), guest, "hi", nil, "", "web", "1.2.3.4"); !errors.Is(err, feedback.ErrGuestForbidden) {
t.Fatalf("guest submit err = %v, want ErrGuestForbidden", err)
}
}
func TestFeedbackSubmitGateAndReplyLifecycle(t *testing.T) {
ctx := context.Background()
svc := newFeedbackService()
acc := provisionAccount(t)
if err := svc.Submit(ctx, acc, " please fix the board ", []byte("PNGDATA"), "shot.png", "ios", "9.9.9.9"); err != nil {
t.Fatalf("submit: %v", err)
}
// Anti-spam gate: a second message is refused while the first is unreviewed.
if err := svc.Submit(ctx, acc, "again", nil, "", "web", ""); !errors.Is(err, feedback.ErrPendingReview) {
t.Fatalf("second submit err = %v, want ErrPendingReview", err)
}
if st, err := svc.State(ctx, acc); err != nil {
t.Fatal(err)
} else if st.CanSend || st.BlockedReason != "pending" || st.Reply != nil {
t.Fatalf("state = %+v, want pending / no reply", st)
}
id := latestFeedbackID(t, svc, acc)
m, err := svc.AdminGet(ctx, id)
if err != nil {
t.Fatalf("admin get: %v", err)
}
if m.Body != "please fix the board" { // trimmed
t.Fatalf("body = %q, want trimmed", m.Body)
}
if !m.HasAttachment || m.AttachmentName != "shot.png" || m.Channel != "ios" || m.SenderIP != "9.9.9.9" {
t.Fatalf("admin message = %+v", m)
}
if name, data, ok, err := svc.Attachment(ctx, id); err != nil || !ok || name != "shot.png" || string(data) != "PNGDATA" {
t.Fatalf("attachment = (%q, %q, %v, %v)", name, data, ok, err)
}
// Reply: marks the message read (so the player can send again) and is undelivered.
if err := svc.Reply(ctx, id, "We are on it."); err != nil {
t.Fatalf("reply: %v", err)
}
if unread, err := svc.ReplyUnread(ctx, acc); err != nil || !unread {
t.Fatalf("reply unread = %v (err %v), want true", unread, err)
}
st, err := svc.State(ctx, acc)
if err != nil {
t.Fatal(err)
}
if !st.CanSend {
t.Fatal("want canSend true after the message was read via reply")
}
if st.Reply == nil || st.Reply.Body != "We are on it." {
t.Fatalf("reply not shown: %+v", st.Reply)
}
// Fetching the state delivered the reply: the badge clears.
if unread, err := svc.ReplyUnread(ctx, acc); err != nil || unread {
t.Fatalf("reply unread = %v (err %v), want false after State", unread, err)
}
// The reply is hidden one week after it was delivered.
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.feedback_messages SET reply_read_at = now() - interval '8 days' WHERE message_id = $1`, id); err != nil {
t.Fatalf("age reply: %v", err)
}
if st, err := svc.State(ctx, acc); err != nil {
t.Fatal(err)
} else if st.Reply != nil {
t.Fatalf("reply should be hidden after a week, got %+v", st.Reply)
}
}
func TestFeedbackBanRole(t *testing.T) {
ctx := context.Background()
svc := newFeedbackService()
accounts := account.NewStore(testDB)
acc := provisionAccount(t)
if err := accounts.GrantRole(ctx, acc, account.RoleFeedbackBanned); err != nil {
t.Fatalf("grant role: %v", err)
}
if err := svc.Submit(ctx, acc, "hi", nil, "", "web", ""); !errors.Is(err, feedback.ErrBanned) {
t.Fatalf("banned submit err = %v, want ErrBanned", err)
}
if st, err := svc.State(ctx, acc); err != nil {
t.Fatal(err)
} else if st.CanSend || st.BlockedReason != "banned" {
t.Fatalf("state = %+v, want banned", st)
}
// Revoke restores submission (the /users unblock path).
if err := accounts.RevokeRole(ctx, acc, account.RoleFeedbackBanned); err != nil {
t.Fatalf("revoke role: %v", err)
}
if err := svc.Submit(ctx, acc, "hi again", nil, "", "web", ""); err != nil {
t.Fatalf("submit after unban: %v", err)
}
}
func TestFeedbackValidation(t *testing.T) {
ctx := context.Background()
svc := newFeedbackService()
tests := []struct {
name string
body string
attachment []byte
attachmentName string
want error
}{
{"empty body", " ", nil, "", feedback.ErrEmptyMessage},
{"too long", strings.Repeat("a", 1025), nil, "", feedback.ErrMessageTooLong},
{"bad type", "ok", []byte("x"), "evil.exe", feedback.ErrAttachmentType},
{"too large", "ok", make([]byte, 1_000_001), "a.png", feedback.ErrAttachmentTooLarge},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
acc := provisionAccount(t) // fresh account so the pending gate never fires first
if err := svc.Submit(ctx, acc, tt.body, tt.attachment, tt.attachmentName, "web", ""); !errors.Is(err, tt.want) {
t.Fatalf("submit err = %v, want %v", err, tt.want)
}
})
}
}
func TestFeedbackAdminLifecycle(t *testing.T) {
ctx := context.Background()
svc := newFeedbackService()
acc := provisionAccount(t)
if err := svc.Submit(ctx, acc, "first report", nil, "", "web", ""); err != nil {
t.Fatalf("submit: %v", err)
}
id := latestFeedbackID(t, svc, acc)
inCount := func(status string) int {
t.Helper()
n, err := svc.AdminCount(ctx, feedback.AdminFilter{AccountID: acc, Status: status})
if err != nil {
t.Fatalf("count %s: %v", status, err)
}
return n
}
if inCount("unread") != 1 || inCount("read") != 0 || inCount("archived") != 0 {
t.Fatalf("after submit: unread=%d read=%d archived=%d", inCount("unread"), inCount("read"), inCount("archived"))
}
// Marking read moves it from the unread queue to the read list.
if err := svc.MarkRead(ctx, id); err != nil {
t.Fatalf("mark read: %v", err)
}
if inCount("unread") != 0 || inCount("read") != 1 {
t.Fatalf("after read: unread=%d read=%d", inCount("unread"), inCount("read"))
}
// Archiving moves it to the archive.
if err := svc.Archive(ctx, id); err != nil {
t.Fatalf("archive: %v", err)
}
if inCount("read") != 0 || inCount("archived") != 1 {
t.Fatalf("after archive: read=%d archived=%d", inCount("read"), inCount("archived"))
}
// Deleting removes it entirely.
if err := svc.Delete(ctx, id); err != nil {
t.Fatalf("delete: %v", err)
}
if inCount("archived") != 0 {
t.Fatalf("after delete: archived=%d, want 0", inCount("archived"))
}
}
func TestFeedbackDeleteAllByAccount(t *testing.T) {
ctx := context.Background()
svc := newFeedbackService()
acc := provisionAccount(t)
if err := svc.Submit(ctx, acc, "one", nil, "", "web", ""); err != nil {
t.Fatalf("submit: %v", err)
}
if err := svc.DeleteAllByAccount(ctx, acc); err != nil {
t.Fatalf("delete all: %v", err)
}
// The pending gate is clear again (no messages left), so the account can submit.
if has, err := svc.ReplyUnread(ctx, acc); err != nil || has {
t.Fatalf("reply unread after delete-all = %v (err %v)", has, err)
}
if err := svc.Submit(ctx, acc, "fresh", nil, "", "web", ""); err != nil {
t.Fatalf("submit after delete-all: %v", err)
}
}
+4
View File
@@ -53,6 +53,10 @@ const (
// becomes an out-of-app push.
NotifyInvitationUpdate = "invitation_update"
NotifyGameStarted = "game_started"
// NotifyAdminReply tells the player an operator has answered their feedback, so
// the client raises the Settings/Info badge and re-fetches the reply. It carries
// no payload (the reply is fetched on the feedback screen). In-app only.
NotifyAdminReply = "admin_reply"
)
// Intent is one live event destined for a single user. Payload is the
@@ -0,0 +1,19 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type AccountRoles struct {
AccountID uuid.UUID `sql:"primary_key"`
Role string `sql:"primary_key"`
GrantedAt time.Time
}
@@ -0,0 +1,29 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type FeedbackMessages struct {
MessageID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID
Body string
Attachment *[]byte
AttachmentName *string
SenderIP *string
Channel string
ReadAt *time.Time
ArchivedAt *time.Time
ReplyBody *string
RepliedAt *time.Time
ReplyReadAt *time.Time
CreatedAt time.Time
}
@@ -0,0 +1,84 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var AccountRoles = newAccountRolesTable("backend", "account_roles", "")
type accountRolesTable struct {
postgres.Table
// Columns
AccountID postgres.ColumnString
Role postgres.ColumnString
GrantedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type AccountRolesTable struct {
accountRolesTable
EXCLUDED accountRolesTable
}
// AS creates new AccountRolesTable with assigned alias
func (a AccountRolesTable) AS(alias string) *AccountRolesTable {
return newAccountRolesTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new AccountRolesTable with assigned schema name
func (a AccountRolesTable) FromSchema(schemaName string) *AccountRolesTable {
return newAccountRolesTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new AccountRolesTable with assigned table prefix
func (a AccountRolesTable) WithPrefix(prefix string) *AccountRolesTable {
return newAccountRolesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new AccountRolesTable with assigned table suffix
func (a AccountRolesTable) WithSuffix(suffix string) *AccountRolesTable {
return newAccountRolesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newAccountRolesTable(schemaName, tableName, alias string) *AccountRolesTable {
return &AccountRolesTable{
accountRolesTable: newAccountRolesTableImpl(schemaName, tableName, alias),
EXCLUDED: newAccountRolesTableImpl("", "excluded", ""),
}
}
func newAccountRolesTableImpl(schemaName, tableName, alias string) accountRolesTable {
var (
AccountIDColumn = postgres.StringColumn("account_id")
RoleColumn = postgres.StringColumn("role")
GrantedAtColumn = postgres.TimestampzColumn("granted_at")
allColumns = postgres.ColumnList{AccountIDColumn, RoleColumn, GrantedAtColumn}
mutableColumns = postgres.ColumnList{GrantedAtColumn}
defaultColumns = postgres.ColumnList{GrantedAtColumn}
)
return accountRolesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
AccountID: AccountIDColumn,
Role: RoleColumn,
GrantedAt: GrantedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,114 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var FeedbackMessages = newFeedbackMessagesTable("backend", "feedback_messages", "")
type feedbackMessagesTable struct {
postgres.Table
// Columns
MessageID postgres.ColumnString
AccountID postgres.ColumnString
Body postgres.ColumnString
Attachment postgres.ColumnBytea
AttachmentName postgres.ColumnString
SenderIP postgres.ColumnString
Channel postgres.ColumnString
ReadAt postgres.ColumnTimestampz
ArchivedAt postgres.ColumnTimestampz
ReplyBody postgres.ColumnString
RepliedAt postgres.ColumnTimestampz
ReplyReadAt postgres.ColumnTimestampz
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type FeedbackMessagesTable struct {
feedbackMessagesTable
EXCLUDED feedbackMessagesTable
}
// AS creates new FeedbackMessagesTable with assigned alias
func (a FeedbackMessagesTable) AS(alias string) *FeedbackMessagesTable {
return newFeedbackMessagesTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new FeedbackMessagesTable with assigned schema name
func (a FeedbackMessagesTable) FromSchema(schemaName string) *FeedbackMessagesTable {
return newFeedbackMessagesTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new FeedbackMessagesTable with assigned table prefix
func (a FeedbackMessagesTable) WithPrefix(prefix string) *FeedbackMessagesTable {
return newFeedbackMessagesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new FeedbackMessagesTable with assigned table suffix
func (a FeedbackMessagesTable) WithSuffix(suffix string) *FeedbackMessagesTable {
return newFeedbackMessagesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newFeedbackMessagesTable(schemaName, tableName, alias string) *FeedbackMessagesTable {
return &FeedbackMessagesTable{
feedbackMessagesTable: newFeedbackMessagesTableImpl(schemaName, tableName, alias),
EXCLUDED: newFeedbackMessagesTableImpl("", "excluded", ""),
}
}
func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackMessagesTable {
var (
MessageIDColumn = postgres.StringColumn("message_id")
AccountIDColumn = postgres.StringColumn("account_id")
BodyColumn = postgres.StringColumn("body")
AttachmentColumn = postgres.ByteaColumn("attachment")
AttachmentNameColumn = postgres.StringColumn("attachment_name")
SenderIPColumn = postgres.StringColumn("sender_ip")
ChannelColumn = postgres.StringColumn("channel")
ReadAtColumn = postgres.TimestampzColumn("read_at")
ArchivedAtColumn = postgres.TimestampzColumn("archived_at")
ReplyBodyColumn = postgres.StringColumn("reply_body")
RepliedAtColumn = postgres.TimestampzColumn("replied_at")
ReplyReadAtColumn = postgres.TimestampzColumn("reply_read_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{CreatedAtColumn}
)
return feedbackMessagesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
MessageID: MessageIDColumn,
AccountID: AccountIDColumn,
Body: BodyColumn,
Attachment: AttachmentColumn,
AttachmentName: AttachmentNameColumn,
SenderIP: SenderIPColumn,
Channel: ChannelColumn,
ReadAt: ReadAtColumn,
ArchivedAt: ArchivedAtColumn,
ReplyBody: ReplyBodyColumn,
RepliedAt: RepliedAtColumn,
ReplyReadAt: ReplyReadAtColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -10,6 +10,7 @@ package table
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
// this method only once at the beginning of the program.
func UseSchema(schema string) {
AccountRoles = AccountRoles.FromSchema(schema)
AccountStats = AccountStats.FromSchema(schema)
AccountSuspensions = AccountSuspensions.FromSchema(schema)
Accounts = Accounts.FromSchema(schema)
@@ -18,6 +19,7 @@ func UseSchema(schema string) {
Complaints = Complaints.FromSchema(schema)
DictionaryState = DictionaryState.FromSchema(schema)
EmailConfirmations = EmailConfirmations.FromSchema(schema)
FeedbackMessages = FeedbackMessages.FromSchema(schema)
FriendCodes = FriendCodes.FromSchema(schema)
Friendships = Friendships.FromSchema(schema)
GameDrafts = GameDrafts.FromSchema(schema)
@@ -0,0 +1,54 @@
-- +goose Up
-- User feedback ("Обратная связь"): a flat list of messages a registered player sends to the
-- operators from Settings -> Info, each with an optional single attachment, plus the operator's
-- inline reply shown back to the player. Distinct from the in-game chat_messages (peer-to-peer)
-- and from the out-of-app Telegram messages. Also introduces account_roles, the project's first
-- per-account role table, replacing per-feature boolean flags. See docs/ARCHITECTURE.md.
SET search_path = backend, pg_catalog;
-- One feedback message. read_at marks "the operator has dealt with this" (set by every console
-- action: read / reply / archive; never by merely opening the detail). The anti-spam gate forbids
-- a new submission while the player has any read_at IS NULL message. archived_at files a handled
-- message away. The reply lives on the same row: reply_body/replied_at are stamped when the
-- operator answers; reply_read_at is stamped when the player's app first fetches the reply for
-- display ("delivered = read"), and the reply is hidden from the player one week after that.
-- attachment is the raw bytes (capped at 1,000,000 in Go); attachment_name carries the original
-- file name (its extension drives the safe content-type at serve time). sender_ip is the
-- gateway-forwarded client IP (validated, like chat); channel is the submitting platform
-- (telegram/ios/android/web, validated in Go).
CREATE TABLE feedback_messages (
message_id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
body text NOT NULL,
attachment bytea,
attachment_name text,
sender_ip text,
channel text NOT NULL,
read_at timestamptz,
archived_at timestamptz,
reply_body text,
replied_at timestamptz,
reply_read_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
-- The per-player probes (anti-spam "has unread", "latest message", "latest reply") and the
-- console's user-scoped search all look an account up newest-first.
CREATE INDEX feedback_messages_account_idx ON feedback_messages (account_id, created_at DESC);
-- The console queue lists messages newest-first across all accounts.
CREATE INDEX feedback_messages_created_idx ON feedback_messages (created_at DESC);
-- Named per-account roles. A reusable replacement for per-feature boolean flags: the first role,
-- 'feedback_banned', forbids only feedback submission (not the whole account, unlike a
-- suspension). Roles are validated in Go (no CHECK here) so new ones need no migration. Granted
-- from the feedback console (the "block" checkbox on delete) and granted/revoked from /users.
CREATE TABLE account_roles (
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
role text NOT NULL,
granted_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, role)
);
-- +goose Down
SET search_path = backend, pg_catalog;
DROP TABLE IF EXISTS account_roles;
DROP TABLE IF EXISTS feedback_messages;
+4 -2
View File
@@ -27,9 +27,11 @@ type okResponse struct {
OK bool `json:"ok"`
}
// resolveResponse maps a session token to its account.
// resolveResponse maps a session token to its account. IsGuest lets the gateway
// gate guest-forbidden operations without an extra round-trip.
type resolveResponse struct {
UserID string `json:"user_id"`
UserID string `json:"user_id"`
IsGuest bool `json:"is_guest"`
}
// profileResponse is the authenticated account's own profile. AwayStart and AwayEnd
+20
View File
@@ -12,6 +12,7 @@ import (
"scrabble/backend/internal/account"
"scrabble/backend/internal/accountmerge"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/feedback"
"scrabble/backend/internal/game"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/session"
@@ -77,6 +78,11 @@ func (s *Server) registerRoutes() {
u.PUT("/games/:id/draft", s.handleSaveDraft)
u.POST("/games/:id/hide", s.handleHideGame)
}
if s.feedback != nil {
u.POST("/feedback", s.handleFeedbackSubmit)
u.GET("/feedback", s.handleFeedbackState)
u.GET("/feedback/unread", s.handleFeedbackUnread)
}
if s.matchmaker != nil {
u.POST("/lobby/enqueue", s.handleEnqueue)
}
@@ -232,6 +238,20 @@ func statusForError(err error) (int, string) {
return http.StatusConflict, "request_declined"
case errors.Is(err, social.ErrFriendCodeInvalid):
return http.StatusUnprocessableEntity, "friend_code_invalid"
case errors.Is(err, feedback.ErrGuestForbidden):
return http.StatusForbidden, "feedback_guest_forbidden"
case errors.Is(err, feedback.ErrBanned):
return http.StatusForbidden, "feedback_banned"
case errors.Is(err, feedback.ErrPendingReview):
return http.StatusConflict, "feedback_pending"
case errors.Is(err, feedback.ErrEmptyMessage):
return http.StatusUnprocessableEntity, "feedback_empty"
case errors.Is(err, feedback.ErrMessageTooLong):
return http.StatusUnprocessableEntity, "feedback_too_long"
case errors.Is(err, feedback.ErrAttachmentTooLarge):
return http.StatusUnprocessableEntity, "feedback_attachment_too_large"
case errors.Is(err, feedback.ErrAttachmentType):
return http.StatusUnprocessableEntity, "feedback_attachment_type"
default:
return http.StatusInternalServerError, "internal"
}
@@ -58,6 +58,8 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.POST("/users/:id/grant-hints", s.consoleGrantHints)
gm.POST("/users/:id/block", s.consoleBlockUser)
gm.POST("/users/:id/unblock", s.consoleUnblockUser)
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
gm.GET("/reasons", s.consoleReasons)
gm.POST("/reasons", s.consoleCreateReason)
gm.POST("/reasons/:id/update", s.consoleUpdateReason)
@@ -68,6 +70,16 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.GET("/complaints", s.consoleComplaints)
gm.GET("/complaints/:id", s.consoleComplaintDetail)
gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
if s.feedback != nil {
gm.GET("/feedback", s.consoleFeedback)
gm.GET("/feedback/:id", s.consoleFeedbackDetail)
gm.GET("/feedback/:id/attachment", s.consoleFeedbackAttachment)
gm.POST("/feedback/:id/read", s.consoleFeedbackRead)
gm.POST("/feedback/:id/reply", s.consoleFeedbackReply)
gm.POST("/feedback/:id/archive", s.consoleFeedbackArchive)
gm.POST("/feedback/:id/delete", s.consoleFeedbackDelete)
gm.POST("/feedback/:id/delete-all", s.consoleFeedbackDeleteAll)
}
gm.GET("/messages", s.consoleMessages)
gm.GET("/messages.csv", s.consoleMessagesCSV)
gm.GET("/dictionary", s.consoleDictionary)
@@ -87,6 +99,9 @@ func (s *Server) consoleDashboard(c *gin.Context) {
view.Games, _ = s.games.CountGames(ctx, "")
view.ActiveGames, _ = s.games.CountGames(ctx, game.StatusActive)
view.OpenComplaints, _ = s.games.CountComplaints(ctx, game.StatusComplaintOpen)
if s.feedback != nil {
view.OpenFeedback, _ = s.feedback.CountUnread(ctx)
}
if changes, err := s.games.DictionaryChanges(ctx); err == nil {
view.PendingChanges = len(changes)
}
@@ -316,6 +331,10 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
view.Reasons = append(view.Reasons, adminconsole.ReasonOption{ID: r.ID.String(), TextEn: r.TextEn, TextRu: r.TextRu})
}
}
if roles, err := s.accounts.ListRoles(ctx, id); err == nil {
view.Roles = roles
}
view.KnownRoles = account.KnownRoles
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
}
@@ -0,0 +1,293 @@
package server
import (
"context"
"html/template"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/adminconsole"
"scrabble/backend/internal/feedback"
)
// consoleFeedback renders the paginated feedback queue, filtered by state
// (unread/read/archived), optionally pinned to one sender (?user=) and narrowed by
// sender glob masks (?name / ?ext).
func (s *Server) consoleFeedback(c *gin.Context) {
ctx := c.Request.Context()
page := consolePage(c)
status := normalizeFeedbackStatus(c.Query("status"))
user, _ := uuid.Parse(strings.TrimSpace(c.Query("user")))
filter := feedback.AdminFilter{
Status: status,
NameMask: c.Query("name"),
ExtMask: c.Query("ext"),
AccountID: user,
}
total, _ := s.feedback.AdminCount(ctx, filter)
rows, err := s.feedback.AdminList(ctx, filter, adminPageSize, (page-1)*adminPageSize)
if err != nil {
s.consoleError(c, err)
return
}
q := url.Values{}
q.Set("status", status)
if filter.AccountID != uuid.Nil {
q.Set("user", filter.AccountID.String())
}
if strings.TrimSpace(filter.NameMask) != "" {
q.Set("name", filter.NameMask)
}
if strings.TrimSpace(filter.ExtMask) != "" {
q.Set("ext", filter.ExtMask)
}
view := adminconsole.FeedbackView{
Status: status, Pager: adminconsole.NewPager(page, adminPageSize, total),
NameMask: filter.NameMask, ExtMask: filter.ExtMask,
FilterQuery: template.URL(q.Encode()),
}
if filter.AccountID != uuid.Nil {
view.UserID = filter.AccountID.String()
}
for _, r := range rows {
view.Items = append(view.Items, adminconsole.FeedbackRow{
ID: r.ID.String(), AccountID: r.AccountID.String(), SenderName: r.SenderName,
Source: r.Source, Channel: r.Channel, HasAttachment: r.HasAttachment,
Read: r.Read, Replied: r.Replied, Archived: r.Archived, CreatedAt: fmtTime(r.CreatedAt),
})
}
s.renderConsole(c, "feedback", "feedback", "Feedback", view)
}
// consoleFeedbackDetail renders one feedback message with its actions. Opening it
// does NOT mark it read (only the explicit actions do).
func (s *Server) consoleFeedbackDetail(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/feedback")
if !ok {
return
}
m, err := s.feedback.AdminGet(ctx, id)
if err != nil {
s.consoleError(c, err)
return
}
view := adminconsole.FeedbackDetailView{
ID: m.ID.String(), AccountID: m.AccountID.String(), SenderName: m.SenderName,
Source: m.Source, Channel: m.Channel, IP: m.SenderIP, Body: m.Body,
HasAttachment: m.HasAttachment, AttachmentName: m.AttachmentName, IsImage: feedback.IsImage(m.AttachmentName),
Read: m.Read, Archived: m.Archived, Replied: m.Replied, ReplyBody: m.ReplyBody,
RepliedAt: fmtTime(m.RepliedAt), CreatedAt: fmtTime(m.CreatedAt),
}
if banned, err := s.accounts.HasRole(ctx, m.AccountID, account.RoleFeedbackBanned); err == nil {
view.Banned = banned
}
s.renderConsole(c, "feedback_detail", "feedback", "Feedback", view)
}
// consoleFeedbackAttachment serves a message's attachment. It is always sent with
// X-Content-Type-Options: nosniff; images are served with their type for an inline
// <img> preview (which never executes), everything else as an octet-stream download
// (so a non-image — including a renamed one — is downloaded, never rendered).
func (s *Server) consoleFeedbackAttachment(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/feedback")
if !ok {
return
}
name, data, ok, err := s.feedback.Attachment(c.Request.Context(), id)
if err != nil {
s.consoleError(c, err)
return
}
if !ok {
s.renderConsoleMessage(c, "No attachment", "this message has no attachment", "/_gm/feedback/"+id.String())
return
}
ctype := "application/octet-stream"
disposition := `attachment; filename="` + sanitizeFilename(name) + `"`
if feedback.IsImage(name) {
ctype = feedback.ContentType(name)
disposition = "inline"
}
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Content-Disposition", disposition)
c.Data(http.StatusOK, ctype, data)
}
// consoleFeedbackRead marks a message dealt-with without any other action.
func (s *Server) consoleFeedbackRead(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/feedback")
if !ok {
return
}
back := "/_gm/feedback/" + id.String()
if err := s.feedback.MarkRead(c.Request.Context(), id); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Marked read", "the message is marked read", back)
}
// consoleFeedbackReply sends the operator's reply to the player (marking the
// message read and notifying the player's app).
func (s *Server) consoleFeedbackReply(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/feedback")
if !ok {
return
}
back := "/_gm/feedback/" + id.String()
body := trimForm(c, "reply")
if body == "" {
s.renderConsoleMessage(c, "Empty reply", "enter a reply before sending", back)
return
}
if err := s.feedback.Reply(c.Request.Context(), id, body); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Replied", "the reply was sent to the player", back)
}
// consoleFeedbackArchive files a handled message away (marking it read).
func (s *Server) consoleFeedbackArchive(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/feedback")
if !ok {
return
}
if err := s.feedback.Archive(c.Request.Context(), id); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Archived", "the message was archived", "/_gm/feedback")
}
// consoleFeedbackDelete physically deletes one message, optionally banning the
// sender from feedback (the "block" checkbox).
func (s *Server) consoleFeedbackDelete(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/feedback")
if !ok {
return
}
m, err := s.feedback.AdminGet(ctx, id)
if err != nil {
s.consoleError(c, err)
return
}
if err := s.feedback.Delete(ctx, id); err != nil {
s.consoleError(c, err)
return
}
extra, err := s.maybeBan(ctx, m.AccountID, trimForm(c, "block") != "")
if err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Deleted", "the message was deleted"+extra, "/_gm/feedback")
}
// consoleFeedbackDeleteAll physically deletes every message from the sender of the
// given message, optionally banning the sender from feedback.
func (s *Server) consoleFeedbackDeleteAll(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/feedback")
if !ok {
return
}
m, err := s.feedback.AdminGet(ctx, id)
if err != nil {
s.consoleError(c, err)
return
}
if err := s.feedback.DeleteAllByAccount(ctx, m.AccountID); err != nil {
s.consoleError(c, err)
return
}
extra, err := s.maybeBan(ctx, m.AccountID, trimForm(c, "block") != "")
if err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Deleted", "all messages from the player were deleted"+extra, "/_gm/feedback")
}
// maybeBan grants the feedback-ban role to accountID when ban is set, returning a
// suffix for the result message.
func (s *Server) maybeBan(ctx context.Context, accountID uuid.UUID, ban bool) (string, error) {
if !ban {
return "", nil
}
if err := s.accounts.GrantRole(ctx, accountID, account.RoleFeedbackBanned); err != nil {
return "", err
}
return " and the player was banned from feedback", nil
}
// consoleGrantRole grants a known role to an account (the /users role panel).
func (s *Server) consoleGrantRole(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
role := trimForm(c, "role")
if !account.IsKnownRole(role) {
s.renderConsoleMessage(c, "Invalid role", "the selected role is not recognised", back)
return
}
if err := s.accounts.GrantRole(c.Request.Context(), id, role); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Role granted", "granted "+role, back)
}
// consoleRevokeRole removes a role from an account (the /users role panel).
func (s *Server) consoleRevokeRole(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
role := trimForm(c, "role")
if role == "" {
s.renderConsoleMessage(c, "Invalid role", "no role given", back)
return
}
if err := s.accounts.RevokeRole(c.Request.Context(), id, role); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Role revoked", "revoked "+role, back)
}
// normalizeFeedbackStatus keeps only a recognised feedback status filter, else
// "unread" (the default queue).
func normalizeFeedbackStatus(s string) string {
switch s {
case "read", "archived":
return s
default:
return "unread"
}
}
// sanitizeFilename strips characters unsafe in a Content-Disposition filename
// (control chars, quotes and path separators) to prevent header injection.
func sanitizeFilename(name string) string {
name = strings.Map(func(r rune) rune {
if r < 0x20 || r == '"' || r == '\\' || r == '/' {
return -1
}
return r
}, name)
if name == "" {
return "attachment"
}
return name
}
+8 -1
View File
@@ -176,7 +176,14 @@ func (s *Server) handleResolveSession(c *gin.Context) {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, resolveResponse{UserID: sess.AccountID.String()})
// is_guest is best-effort: a transient account read must not fail an otherwise
// valid resolve (the auth hot path), so a read error falls back to false; the
// per-operation backend gate remains the authoritative guest check.
resp := resolveResponse{UserID: sess.AccountID.String()}
if acc, err := s.accounts.GetByID(c.Request.Context(), sess.AccountID); err == nil {
resp.IsGuest = acc.IsGuest
}
c.JSON(http.StatusOK, resp)
}
// handleRevokeSession revokes the session for a token (idempotent).
@@ -0,0 +1,105 @@
package server
import (
"encoding/base64"
"net/http"
"github.com/gin-gonic/gin"
)
// feedbackSubmitRequest is the player's feedback submission. Attachment is the
// base64-encoded file bytes (empty for none); AttachmentName carries the original
// file name (its extension is the allow-list key); Channel is the submitting
// platform (telegram/ios/android/web).
type feedbackSubmitRequest struct {
Body string `json:"body"`
Attachment string `json:"attachment"`
AttachmentName string `json:"attachment_name"`
Channel string `json:"channel"`
}
// feedbackReplyDTO is the operator's reply shown back to the player.
type feedbackReplyDTO struct {
Body string `json:"body"`
RepliedAtUnix int64 `json:"replied_at_unix"`
}
// feedbackStateResponse is the player's feedback screen state. BlockedReason is
// "" (can send), "pending" or "banned"; Reply is omitted when there is none.
type feedbackStateResponse struct {
CanSend bool `json:"can_send"`
BlockedReason string `json:"blocked_reason"`
Reply *feedbackReplyDTO `json:"reply,omitempty"`
}
// feedbackUnreadResponse reports whether the player has an undelivered reply, for
// the lobby/Info badge.
type feedbackUnreadResponse struct {
ReplyUnread bool `json:"reply_unread"`
}
// handleFeedbackSubmit stores a feedback message from the authenticated player.
// The sender IP comes from the gateway-forwarded X-Forwarded-For header. Guests
// and feedback-banned accounts are refused (also gated at the gateway).
func (s *Server) handleFeedbackSubmit(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req feedbackSubmitRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
var attachment []byte
if req.Attachment != "" {
data, err := base64.StdEncoding.DecodeString(req.Attachment)
if err != nil {
abortBadRequest(c, "invalid attachment encoding")
return
}
attachment = data
}
if err := s.feedback.Submit(c.Request.Context(), uid, req.Body, attachment, req.AttachmentName, req.Channel, clientIP(c)); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// handleFeedbackState returns the player's feedback screen state and marks any
// pending operator replies delivered (clearing the badge).
func (s *Server) handleFeedbackState(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
st, err := s.feedback.State(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
resp := feedbackStateResponse{CanSend: st.CanSend, BlockedReason: st.BlockedReason}
if st.Reply != nil {
resp.Reply = &feedbackReplyDTO{Body: st.Reply.Body, RepliedAtUnix: st.Reply.RepliedAt.Unix()}
}
c.JSON(http.StatusOK, resp)
}
// handleFeedbackUnread reports whether the player has an undelivered operator
// reply, for the lobby/Info badge. It has no side effect.
func (s *Server) handleFeedbackUnread(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
unread, err := s.feedback.ReplyUnread(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, feedbackUnreadResponse{ReplyUnread: unread})
}
+6
View File
@@ -21,6 +21,7 @@ import (
"scrabble/backend/internal/adminconsole"
"scrabble/backend/internal/connector"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/feedback"
"scrabble/backend/internal/game"
"scrabble/backend/internal/link"
"scrabble/backend/internal/lobby"
@@ -54,6 +55,9 @@ type Deps struct {
Sessions *session.Service
Accounts *account.Store
Games *game.Service
// Feedback is the user-feedback domain service (the /api/v1/user/feedback
// endpoints and the console section). A nil Feedback disables them.
Feedback *feedback.Service
// Social, Matchmaker, Invitations and Emails are the domain services
// the REST handlers route to.
Social *social.Service
@@ -91,6 +95,7 @@ type Server struct {
sessions *session.Service
accounts *account.Store
games *game.Service
feedback *feedback.Service
social *social.Service
matchmaker *lobby.Matchmaker
invitations *lobby.InvitationService
@@ -132,6 +137,7 @@ func New(addr string, deps Deps) *Server {
sessions: deps.Sessions,
accounts: deps.Accounts,
games: deps.Games,
feedback: deps.Feedback,
social: deps.Social,
matchmaker: deps.Matchmaker,
invitations: deps.Invitations,