feat(feedback): обратная связь от пользователей с разбором в админке и ролями #64

Merged
developer merged 7 commits from feature/feedback into development 2026-06-15 11:30:09 +00:00
76 changed files with 3940 additions and 55 deletions
+45
View File
@@ -52,6 +52,7 @@ independent (see ARCHITECTURE §9.1).
| 16 | Deploy infra & test contour (Dockerfiles, gateway static UI, compose, observability) | **done** |
| 17 | Test-contour verification & defect fixes | **done** |
| 18 | Prod contour deploy (SSH export/import, manual after merge) | todo |
| 19 | User feedback (in-app submit + attachment, admin review/reply, account roles) | **done** |
Scaffolding is incremental: `go.work` lists only existing modules; each stage
adds the modules it needs.
@@ -419,6 +420,37 @@ parameterised for this; the test contour leaves it `:80` behind the host caddy).
Open details (re-interview): export/import vs a registry trade-off; prod domain/cert source (ACME vs a
provided cert) at the contour caddy; prod VPN; rollback.
### Stage 19 — User feedback *(done)*
A user→operator feedback channel, sequenced after the numbered stages but shipped **before** the Stage 18
prod deploy. Scope: a **Feedback** screen under Settings → Info where a registered player sends a message
(≤1024 runes) with an optional single attachment; the operator reviews and replies in the server-rendered
**admin console**; the reply is delivered back in-app with a Settings/Info badge. Introduces the project's
**first per-account role** (`account_roles`), the reusable replacement for per-feature flags: the
`feedback_banned` role blocks **only** feedback submission (not the whole account, unlike a suspension),
granted from the feedback section's delete-with-block and granted/revoked from `/users`.
- **Data:** migration `00004_feedback``feedback_messages` (body, attachment `bytea`, sender_ip, channel,
read/archived/reply/reply-read timestamps) + `account_roles` (account_id, role). New backend domain
`internal/feedback` (store + service), modelled on `internal/social` + the admin chat-moderation surface.
- **Anti-spam gate:** a player with an unreviewed message cannot send another ("Ожидаем рассмотрения вашего
последнего обращения"); enforced server-side. This *is* the rate limit — no separate one.
- **Reply lifecycle:** the reply lives on the message row; shown on the feedback screen ("Ответ на ваше
последнее сообщение") for the player's latest replied message; it becomes read the moment the screen
fetches it (delivery = read) and is hidden one week after. The badge (lobby ⚙️, combined with friend
requests; Settings → Info "1") rides the existing `NotificationEvent` with a new `admin_reply` sub-kind —
no wire-schema change for the push.
- **Wire:** new FlatBuffers `feedback.submit` / `feedback.get` / `feedback.unread`; `feedback.submit` is
**guest-gated at the gateway** via a new `Op.NonGuest` flag (the session resolve now carries `is_guest`),
so a guest's submission never reaches the backend; the backend rejects guests too.
- **Security:** body + attachment limits enforced server-side (the UI limits are UX only); attachments
served with `X-Content-Type-Options: nosniff`, images inline-previewed via `<img>` (which never
executes), everything else download-only; the UI gates the attachment by extension (allowed types not
listed) and the backend mirrors the allow-list + size as the trust boundary; the console renders user
content as auto-escaped text (`html/template`).
Open details settled at planning: attachment ≤1,000,000 B (keeps the 1 MiB edge cap); allowed types
(images + pdf/txt/log/doc/docx/rtf/zip/gz/7z); channel from the client (telegram/ios/android/web); guests
cannot submit; three-way admin filter.
## Refinements logged during implementation
- **Stage 0**: solver `replace` deferred to Stage 2 (nothing imports it yet;
@@ -1476,6 +1508,19 @@ provided cert) at the contour caddy; prod VPN; rollback.
`proactiveNudgeGap` unit test, the retimed `TestRobotProactiveNudge`, `TestVariantLanguage`,
emit (`your_turn`/`game_over` language) and a `TestNudgeRoutedByGameLanguage` integration test.
### Stage 19 — user feedback
- Dropped the planned `feedback_messages.attachment_type` column: the safe content-type is derived from the
file-name extension at both validate and serve time (one source of truth), so a stored column was
redundant.
- The admin filter is **three-way** (Непрочитанные / Прочитанные / Архив) rather than the two first
sketched, mirroring complaints' `open·resolved·all`, so a read-but-unarchived message is never
unreachable (owner-approved at planning).
- The guest gate was extended to the **gateway** at the owner's request: a new `Op.NonGuest` flag plus
`is_guest` threaded through the session resolve + gateway cache reject a guest's `feedback.submit` with
the `guest_forbidden` result code before any backend call (the backend keeps its own guest check as the
backstop). `handleResolveSession` now loads the account best-effort to report `is_guest` (a read error
falls back to false so the auth hot path stays resilient).
## Deferred TODOs (cross-stage)
- ~~**TODO-1 — publish & version the solver.**~~ **Done in Stage 14.** `scrabble-solver` is
+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: "telegram", InterfaceLanguage: "en", BotLanguage: "ru", Body: "please fix the board", HasAttachment: true, AttachmentName: "shot.png", IsImage: true, Banned: true}, "bot: ru"},
{"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}}{{if .BotLanguage}} (bot: {{.BotLanguage}}){{end}}</li>
<li><b>Interface language</b> {{.InterfaceLanguage}}</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>
{{if not .Read}}<form class="form" method="post" action="/_gm/feedback/{{.ID}}/read"><button type="submit">Mark read</button></form>{{end}}
{{if not .Archived}}<form class="form" method="post" action="/_gm/feedback/{{.ID}}/archive"><button type="submit">Archive</button></form>{{end}}
<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>
<button type="submit" formaction="/_gm/feedback/{{.ID}}/delete-all">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>
+65
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,63 @@ 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
// InterfaceLanguage is the sender's interface language (account preference);
// BotLanguage is the connector bot they last used (en/ru), set only for a
// message that arrived through an external connector (Telegram).
InterfaceLanguage string
BotLanguage 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)
}
})
}
}
+264
View File
@@ -0,0 +1,264 @@
package feedback
import (
"context"
"errors"
"net/netip"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/notify"
)
const (
// maxBodyRunes caps a feedback message (and an operator reply) length.
maxBodyRunes = 1024
// replyVisibleFor is how long an operator reply stays shown to the player after
// it is delivered (read).
replyVisibleFor = 7 * 24 * time.Hour
)
// Submit / reply validation errors. The transport layer maps them to stable result
// codes; the UI maps those to the messages shown on the feedback screen.
var (
ErrEmptyMessage = errors.New("feedback: empty message")
ErrMessageTooLong = errors.New("feedback: message too long")
ErrAttachmentTooLarge = errors.New("feedback: attachment too large")
ErrAttachmentType = errors.New("feedback: attachment type not allowed")
ErrGuestForbidden = errors.New("feedback: guests cannot submit feedback")
ErrBanned = errors.New("feedback: account is banned from feedback")
ErrPendingReview = errors.New("feedback: previous message still pending review")
)
// validChannels enumerates the submitting platforms a client may report; anything
// else is normalised to "web" (the channel is informational, never a gate).
var validChannels = map[string]bool{"telegram": true, "ios": true, "android": true, "web": true}
// Service is the feedback domain: the only writer of feedback_messages. It reads
// accounts for the guest/role gates and publishes the reply notification.
type Service struct {
store *Store
accounts *account.Store
pub notify.Publisher
now func() time.Time
}
// NewService constructs a Service. store owns feedback_messages; accounts supplies
// the guest flag and the feedback-ban role.
func NewService(store *Store, accounts *account.Store) *Service {
return &Service{
store: store,
accounts: accounts,
pub: notify.Nop{},
now: func() time.Time { return time.Now().UTC() },
}
}
// SetNotifier installs the live-event publisher used to push the "you have a reply"
// signal to the player. It must be called during startup wiring; the default is
// notify.Nop (no live events).
func (svc *Service) SetNotifier(p notify.Publisher) {
if p != nil {
svc.pub = p
}
}
// Submit stores a feedback message from accountID. It rejects guests, feedback-
// banned accounts and a sender who still has a message pending review, then
// validates the body (non-empty, within the rune limit) and the optional
// attachment (size and extension allow-list). senderIP is the gateway-forwarded
// client IP (validated); channel is the submitting platform.
func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, senderIP string) error {
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
return err
}
if acc.IsGuest {
return ErrGuestForbidden
}
banned, err := svc.accounts.HasRole(ctx, accountID, account.RoleFeedbackBanned)
if err != nil {
return err
}
if banned {
return ErrBanned
}
pending, err := svc.store.HasUnread(ctx, accountID)
if err != nil {
return err
}
if pending {
return ErrPendingReview
}
body = strings.TrimSpace(body)
if body == "" {
return ErrEmptyMessage
}
if utf8.RuneCountInString(body) > maxBodyRunes {
return ErrMessageTooLong
}
if len(attachment) > 0 {
if len(attachment) > maxAttachmentBytes {
return ErrAttachmentTooLarge
}
if !AllowedAttachment(attachmentName) {
return ErrAttachmentType
}
} else {
attachmentName = "" // a name without bytes carries no attachment
}
ch := normalizeChannel(channel)
// Snapshot the languages at submit time (acc is already loaded for the guest check):
// the sender's interface language, and the connector bot language when the message
// came through an external connector (currently Telegram).
var channelLang string
if ch == "telegram" {
channelLang = acc.ServiceLanguage
}
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, channelLang, parseIP(senderIP))
return err
}
// State is the player's feedback screen state. Reason is "" (can send), "pending"
// (a previous message is unreviewed) or "banned". Reply is the operator's answer to
// show, or nil. Fetching it delivers any pending replies (delivery counts as read),
// clearing the badge.
type State struct {
CanSend bool
BlockedReason string
Reply *Reply
}
// Reply is the operator's answer shown back to the player.
type Reply struct {
Body string
RepliedAt time.Time
}
// State computes the feedback screen state for accountID and marks any pending
// replies delivered.
func (svc *Service) State(ctx context.Context, accountID uuid.UUID) (State, error) {
banned, err := svc.accounts.HasRole(ctx, accountID, account.RoleFeedbackBanned)
if err != nil {
return State{}, err
}
pending, err := svc.store.HasUnread(ctx, accountID)
if err != nil {
return State{}, err
}
st := State{CanSend: !banned && !pending}
switch {
case banned:
st.BlockedReason = "banned"
case pending:
st.BlockedReason = "pending"
}
vr, ok, err := svc.store.LatestVisibleReply(ctx, accountID, svc.now().Add(-replyVisibleFor))
if err != nil {
return State{}, err
}
if ok {
st.Reply = &Reply{Body: vr.Body, RepliedAt: vr.RepliedAt}
}
// Opening the screen delivers every pending reply; mark them read to clear the
// badge (only the latest is shown, but all are now delivered).
if err := svc.store.MarkRepliesDelivered(ctx, accountID, svc.now()); err != nil {
return State{}, err
}
return st, nil
}
// ReplyUnread reports whether the account has an operator reply not yet delivered —
// the lobby/Info badge condition. It has no side effect (the lobby may poll it).
func (svc *Service) ReplyUnread(ctx context.Context, accountID uuid.UUID) (bool, error) {
return svc.store.HasUnreadReply(ctx, accountID)
}
// AdminList returns the filtered console feedback list, paginated.
func (svc *Service) AdminList(ctx context.Context, f AdminFilter, limit, offset int) ([]AdminRow, error) {
return svc.store.AdminList(ctx, f, limit, offset)
}
// AdminCount counts the filtered console feedback list.
func (svc *Service) AdminCount(ctx context.Context, f AdminFilter) (int, error) {
return svc.store.AdminCount(ctx, f)
}
// AdminGet loads one message for the console detail view, or ErrNotFound.
func (svc *Service) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error) {
return svc.store.AdminGet(ctx, id)
}
// CountUnread counts the active (unread, not archived) feedback queue for the
// dashboard.
func (svc *Service) CountUnread(ctx context.Context) (int, error) {
return svc.store.CountUnread(ctx)
}
// Attachment returns a message's file name and bytes, reporting false when absent.
func (svc *Service) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) {
return svc.store.Attachment(ctx, id)
}
// MarkRead marks a message dealt-with (the manual "read" action).
func (svc *Service) MarkRead(ctx context.Context, id uuid.UUID) error {
return svc.store.MarkRead(ctx, id, svc.now())
}
// Reply sets the operator reply on a message (marking it read), then pushes the
// "you have a reply" notification to the player.
func (svc *Service) Reply(ctx context.Context, id uuid.UUID, body string) error {
body = strings.TrimSpace(body)
if body == "" {
return ErrEmptyMessage
}
if utf8.RuneCountInString(body) > maxBodyRunes {
return ErrMessageTooLong
}
accountID, err := svc.store.Reply(ctx, id, body, svc.now())
if err != nil {
return err
}
svc.pub.Publish(notify.Notification(accountID, notify.NotifyAdminReply))
return nil
}
// Archive files a handled message away (marking it read).
func (svc *Service) Archive(ctx context.Context, id uuid.UUID) error {
return svc.store.Archive(ctx, id, svc.now())
}
// Delete physically removes a message and its attachment.
func (svc *Service) Delete(ctx context.Context, id uuid.UUID) error {
return svc.store.Delete(ctx, id)
}
// DeleteAllByAccount physically removes every message of an account.
func (svc *Service) DeleteAllByAccount(ctx context.Context, accountID uuid.UUID) error {
return svc.store.DeleteAllByAccount(ctx, accountID)
}
// parseIP returns a validated canonical IP string, or nil when raw is empty or not
// a valid address.
func parseIP(raw string) *string {
addr, err := netip.ParseAddr(strings.TrimSpace(raw))
if err != nil {
return nil
}
canon := addr.String()
return &canon
}
// normalizeChannel lower-cases and validates the client-reported channel, falling
// back to "web" for anything unrecognised.
func normalizeChannel(c string) string {
c = strings.ToLower(strings.TrimSpace(c))
if validChannels[c] {
return c
}
return "web"
}
+383
View File
@@ -0,0 +1,383 @@
// 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. lang (the sender's interface language) and channelLang
// (the connector bot language, empty for a non-connector channel) are snapshots taken
// now, so the operator later sees the state at submit time. created_at defaults to
// now() in the database.
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang, channelLang 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
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO backend.feedback_messages
(message_id, account_id, body, attachment, attachment_name, channel, lang, channel_lang, sender_ip)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), nullStr(channelLang), ip); err != nil {
return uuid.Nil, fmt.Errorf("feedback: insert: %w", err)
}
return id, nil
}
// nullStr maps an empty string to a NULL text bind, else the value.
func nullStr(s string) *string {
if s == "" {
return nil
}
return &s
}
// 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 {
Body string
RepliedAt time.Time
}
// LatestVisibleReply returns the reply on the account's **most recent** message, if
// that message carries one still visible to the player — not yet delivered, or
// delivered after cutoff (one week ago). Binding it to the single latest message
// means that the moment the player sends a newer message the previous reply stops
// showing (the new message has no reply yet). 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 COALESCE(reply_body, ''), replied_at FROM (
SELECT reply_body, replied_at, reply_read_at FROM backend.feedback_messages
WHERE account_id = $1 ORDER BY created_at DESC LIMIT 1
) latest
WHERE reply_body IS NOT NULL AND (reply_read_at IS NULL OR reply_read_at > $2)`,
accountID, cutoff).Scan(&vr.Body, &repliedAt)
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
// Lang is the sender's interface language and ChannelLang the connector bot language
// (en/ru, empty for a non-connector channel) — both snapshotted at submit time.
Lang string
ChannelLang 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.lang, ''), COALESCE(m.channel_lang, ''),
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.Lang, &m.ChannelLang,
&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
}
+307
View File
@@ -0,0 +1,307 @@
//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 TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) {
ctx := context.Background()
svc := newFeedbackService()
acc := provisionAccount(t)
// msg1, replied → the player can send again and currently sees the reply.
if err := svc.Submit(ctx, acc, "first", nil, "", "web", ""); err != nil {
t.Fatalf("submit msg1: %v", err)
}
if err := svc.Reply(ctx, latestFeedbackID(t, svc, acc), "the answer"); err != nil {
t.Fatalf("reply: %v", err)
}
if st, err := svc.State(ctx, acc); err != nil {
t.Fatal(err)
} else if st.Reply == nil || st.Reply.Body != "the answer" {
t.Fatalf("reply not shown before the new message: %+v", st.Reply)
}
// Sending a new message immediately drops the previous reply (it now belongs to an
// older message), even though it is well within the one-week window.
if err := svc.Submit(ctx, acc, "second", nil, "", "web", ""); err != nil {
t.Fatalf("submit msg2: %v", err)
}
st, err := svc.State(ctx, acc)
if err != nil {
t.Fatal(err)
}
if st.Reply != nil {
t.Fatalf("reply should be hidden after a new message, got %+v", st.Reply)
}
if st.CanSend || st.BlockedReason != "pending" {
t.Fatalf("state after new message = %+v, want pending", st)
}
}
func TestFeedbackSnapshotsLanguages(t *testing.T) {
ctx := context.Background()
svc := newFeedbackService()
acc := provisionAccount(t)
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.accounts SET preferred_language = 'en', service_language = 'ru' WHERE account_id = $1`, acc); err != nil {
t.Fatalf("set languages: %v", err)
}
// A Telegram (connector) message snapshots both the interface language and the bot.
if err := svc.Submit(ctx, acc, "from telegram", nil, "", "telegram", ""); err != nil {
t.Fatalf("submit: %v", err)
}
id := latestFeedbackID(t, svc, acc)
if m, err := svc.AdminGet(ctx, id); err != nil {
t.Fatalf("admin get: %v", err)
} else if m.Lang != "en" || m.ChannelLang != "ru" {
t.Fatalf("snapshot = lang %q / channel_lang %q, want en / ru", m.Lang, m.ChannelLang)
}
// Changing the account afterwards must not change the stored snapshot.
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.accounts SET preferred_language = 'ru', service_language = 'en' WHERE account_id = $1`, acc); err != nil {
t.Fatalf("change languages: %v", err)
}
if m, err := svc.AdminGet(ctx, id); err != nil {
t.Fatal(err)
} else if m.Lang != "en" || m.ChannelLang != "ru" {
t.Fatalf("snapshot drifted after account change = lang %q / channel_lang %q", m.Lang, m.ChannelLang)
}
// A non-connector channel records no bot language even when the account has one.
acc2 := provisionAccount(t)
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.accounts SET preferred_language = 'en', service_language = 'ru' WHERE account_id = $1`, acc2); err != nil {
t.Fatalf("set languages 2: %v", err)
}
if err := svc.Submit(ctx, acc2, "from web", nil, "", "web", ""); err != nil {
t.Fatalf("submit web: %v", err)
}
if m, err := svc.AdminGet(ctx, latestFeedbackID(t, svc, acc2)); err != nil {
t.Fatal(err)
} else if m.Lang != "en" || m.ChannelLang != "" {
t.Fatalf("web snapshot = lang %q / channel_lang %q, want en / empty", m.Lang, m.ChannelLang)
}
}
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,31 @@
//
// 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
Lang *string
ChannelLang *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,120 @@
//
// 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
Lang postgres.ColumnString
ChannelLang 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")
LangColumn = postgres.StringColumn("lang")
ChannelLangColumn = postgres.StringColumn("channel_lang")
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, LangColumn, ChannelLangColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, LangColumn, ChannelLangColumn, 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,
Lang: LangColumn,
ChannelLang: ChannelLangColumn,
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,55 @@
-- +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). The sender's interface/bot language snapshots
-- (lang, channel_lang) are added additively in 00005.
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;
@@ -0,0 +1,15 @@
-- +goose Up
-- Snapshot the sender's languages on each feedback message, taken at submit time so the
-- operator console shows the state as it was, not the account's current settings (the same
-- snapshot discipline as a suspension's reason). lang is the sender's interface language;
-- channel_lang is the connector bot language (en/ru) when the message arrived through an
-- external connector (Telegram), else NULL. Additive over 00004 so it applies forward-safe.
SET search_path = backend, pg_catalog;
ALTER TABLE feedback_messages ADD COLUMN lang text;
ALTER TABLE feedback_messages ADD COLUMN channel_lang text;
-- +goose Down
SET search_path = backend, pg_catalog;
ALTER TABLE feedback_messages DROP COLUMN IF EXISTS channel_lang;
ALTER TABLE feedback_messages DROP COLUMN IF EXISTS lang;
+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,294 @@
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, InterfaceLanguage: m.Lang, BotLanguage: m.ChannelLang,
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,
+39
View File
@@ -703,6 +703,7 @@ promotions) is future work and would deliver short markdown messages (text + lin
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
| Manual account block (suspension) | backend: a per-request gate refuses a blocked account on every `/api/v1/user/*` route except the block-status probe with **403 `account_blocked`**; the operator blocks/unblocks from the admin console (§11) |
| User feedback gate | backend rejects a guest or a `feedback_banned` account from submitting; the **gateway** also rejects a guest's `feedback.submit` (the `Op.NonGuest` flag + `is_guest` from session resolve) with **`guest_forbidden`** before any backend call; attachments are served `nosniff` with a download disposition for non-images (§15) |
| Admin authentication | a single Basic-Auth gate on `/_gm/*`, forwarded **verbatim** to the backend's server-rendered admin console (and, in the deployed contour, routing `/_gm/grafana/*` to Grafana). In the deploy the **caddy** owns this gate (§13); a local non-caddy run uses the gateway's own `GATEWAY_ADMIN_*` proxy, which the per-IP admin limiter class guards ahead of its Basic-Auth — the caddy-fronted path has no limiter (stock caddy), an accepted gap. The backend trusts the proxy (no admin principal) and guards its state-changing POSTs with a **same-origin** check — the console's CSRF defence. No operator identity is tracked |
| backend ↔ gateway ↔ connector trust | the network (only gateway may reach backend; the connector serves unauthenticated gRPC on the internal segment) |
@@ -817,3 +818,41 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
`BACKEND_DICT_DIR`.
- After any push, the run is watched to green before a stage is declared done
(`python3 ~/.claude/bin/gitea-ci-watch.py`).
## 15. User feedback & account roles
Players reach the operators through a **Feedback** screen (Settings → Info, registered accounts
only). A message (≤1024 runes) plus an optional single attachment is stored in
`feedback_messages`; the sender's IP (gateway-forwarded, as for chat) and the submitting
**channel** (telegram/ios/android/web, client-reported and validated) are recorded. The domain
is `internal/feedback` (store + service), modelled on the admin chat-moderation surface.
**Anti-spam.** A player with an unreviewed message (`read_at IS NULL`) cannot submit another; the
gate is server-side. Because the operator must act before the next message, this is itself the
rate limit — there is no separate per-user feedback limiter.
**Operator review** happens in the server-rendered console (`/_gm/feedback`): an
unread / read / archived queue with per-user search (the `/users` glob masks), a detail card
(user content rendered as auto-escaped `html/template` text), and the read / reply / archive /
delete / delete-all actions — each marks the message read; merely opening the detail does not.
The attachment is served from `/_gm/feedback/:id/attachment` with `X-Content-Type-Options:
nosniff`: images inline (loaded only via `<img>`, which never executes — a renamed non-image is
inert), everything else as an `application/octet-stream` download. The UI gates the attachment by
file extension (the allow-list is not shown to the user) and the backend mirrors that allow-list
plus the ≤1,000,000-byte size cap as the trust boundary; file *content* is not inspected. The
1,000,000-byte cap keeps the whole `feedback.submit` request under the gateway's 1 MiB edge body
cap (§12) without weakening it.
**Reply delivery.** The operator's reply lives on the message row and is shown back on the
feedback screen ("Ответ на ваше последнее сообщение") for the player's most recent replied
message. It becomes "read by the player" the instant the screen fetches it (delivery = read) and
is hidden one week after. A Settings → Info badge — folded into the lobby ⚙️ badge together with
the friend-request count — signals an undelivered reply; it rides the existing `NotificationEvent`
with a new `admin_reply` sub-kind (no new push schema) plus an authoritative poll on lobby load.
**Account roles.** `account_roles` (account_id, role) is the project's first per-account role
table — the reusable replacement for per-feature boolean flags. The first role, `feedback_banned`,
blocks **only** feedback submission (unlike a suspension, the whole-account block of §12). It is
granted from the feedback section (the delete-with-block checkbox) and granted/revoked from the
`/users` console card. Roles are validated against a known set in Go, so adding one needs no
migration.
+22
View File
@@ -172,6 +172,18 @@ block toggles. The profile form is edited inline (no separate edit mode). Linkin
an email or Telegram and merging accounts are covered under "Accounts, linking &
merge".
### Feedback
A registered player reaches the operators from Settings → Info: a **Feedback** screen with a
message (up to 1024 characters) and an optional single attachment (one file, up to ~1 MB — images,
PDF, text/log, office documents, RTF or archives; an unsupported file is refused on the form
without naming the allowed types). After sending, the form clears and confirms "Ваше сообщение
отправлено", and sending is blocked until the operator has dealt with that message — on re-entry
the screen reads "Ожидаем рассмотрения вашего последнего обращения". The operator's reply appears
below the form as "Ответ на ваше последнее сообщение" (any link in it opens in a new tab); it is
marked read once the screen shows it and disappears a week later. A badge on the Settings tab (and on Info inside it) flags an
unanswered reply. Guests cannot send feedback (the entry is hidden). A player the operator has
barred from feedback (a role, not a full account block) sees the send control disabled.
### History & statistics
Finished games are archived in a dictionary-independent form and exportable to
GCG; the export is offered **only once a game is finished** (exporting a live game
@@ -225,3 +237,13 @@ From the user card the operator can also **top up a player's hint wallet**: an a
(1100 hints per action) that raises the balance shown on the card. Grants are **raise-only**
the console can never lower a wallet (a player only loses hints by spending them in a game), so an
over-grant cannot be reversed there.
The console works a **feedback** queue too (`/_gm/feedback`): the messages players sent, filtered
**unread / read / archived** with per-user search, each shown with its sender, source, channel
(with the connector bot language — en/ru — for a Telegram message), the sender's interface
language, IP and any attachment. The operator can mark a message read, **reply** to the player (delivered
in-app), archive it, delete it, or delete every message from that player — and, alongside a delete,
**bar the player from feedback** (a `feedback_banned` role, distinct from a full account block: it
stops only feedback submission). Roles are listed and granted/revoked on the user card. Opening a
message does not mark it read — only the explicit actions do; message bodies and attachments are
shown defensively (text escaped, attachments downloaded rather than rendered).
+23
View File
@@ -177,6 +177,18 @@ UTC), суточного окна отсутствия (away; сетка по 10
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние».
### Обратная связь
Зарегистрированный игрок обращается к операторам из Settings → Info: экран **«Обратная связь»** с
сообщением (до 1024 символов) и необязательным вложением (один файл, до ~1 МБ — изображения, PDF,
текст/лог, офисные документы, RTF или архивы; неподходящий файл отклоняется на форме без перечисления
допустимых типов). После отправки форма очищается и подтверждает «Ваше сообщение отправлено», а
повторная отправка блокируется, пока оператор не разобрал обращение — при повторном входе на экране
«Ожидаем рассмотрения вашего последнего обращения». Ответ оператора показывается под формой как
«Ответ на ваше последнее сообщение» (ссылки в нём открываются в новой вкладке); он помечается
прочитанным, как только экран его показал, и исчезает через неделю. Бейдж на вкладке Settings (и на Info внутри неё) сигналит о непрочитанном
ответе. Гость отправлять обратную связь не может (пункт скрыт). Игрок, которому оператор запретил
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
### История и статистика
Завершённые партии архивируются в независимом от словаря виде и экспортируются
в GCG; экспорт доступен **только после завершения партии** (экспорт идущей партии
@@ -231,3 +243,14 @@ high-rate флага. С карточки пользователя операт
начисление (1–100 подсказок за раз), которое **только увеличивает** баланс на карточке. Начисления
**только в плюс** — понизить кошелёк из консоли нельзя (игрок теряет подсказки только тратя их в
партии), поэтому ошибочно начисленное через консоль там не отнять.
Консоль ведёт и очередь **обратной связи** (`/_gm/feedback`): присланные игроками сообщения с фильтром
**непрочитанные / прочитанные / архив** и поиском по пользователю, каждое — с отправителем, источником,
каналом (и языком бота-коннектора — en/ru — для сообщения из Telegram), языком интерфейса отправителя,
IP и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка
в приложение), отправить в архив, удалить или удалить все сообщения этого игрока — и вместе с удалением
**запретить игроку обратную связь** (роль `feedback_banned`, отличная от полной блокировки аккаунта:
останавливает только отправку обратной связи). Роли перечислены и выдаются/снимаются на карточке
пользователя. Открытие сообщения не помечает его прочитанным — это делают только явные действия; тело
сообщения и вложения показываются защищённо (текст экранируется, вложения отдаются на скачивание, а не
рендерятся).
+9
View File
@@ -136,6 +136,15 @@ tests or touching CI.
postgres_exporter** Grafana dashboard on the contour. Two passes are recorded — the
early [`REPORT-R2.md`](../loadtest/REPORT-R2.md) and the final, tuned
[`REPORT-R7.md`](../loadtest/REPORT-R7.md). See [`../loadtest/README.md`](../loadtest/README.md).
- **User feedback** — `internal/feedback` unit tests cover the attachment allow-list /
content-type and the channel normaliser; the UI covers `detectChannel`, the attachment gate and
the feedback wire round-trip (`channel` / `feedback` / `codec` tests) plus a Playwright e2e
(submit → confirm + resend blocked; an operator reply raises the badge and shows on the screen).
The gateway `connectsrv` test asserts the **guest gate** (a guest's `feedback.submit`
`guest_forbidden` with no backend call; a non-gated authenticated op still passes). Postgres-backed
`inttest` drives the **feedback lifecycle** end to end: the guest / ban / pending gates, the reply
read + one-week visibility window, the account-role grant/revoke, and the admin queue filters with
delete / delete-all. The admin-console render test renders the feedback list + detail pages.
## Principles
+11 -1
View File
@@ -33,7 +33,9 @@ Login uses `Screen`.
hub tabs: ⚙️ Settings / 👤 Profile / 🤝 Friends / ️ About (Friends hidden for guests);
comms hub tabs: 💬 Chat / 🔎 Dictionary (Dictionary only while the game is active). The
routes `/settings|/profile|/friends|/about` and `/game/:id/{chat,check}` survive as hub
entry points (so a Telegram friend-code deep-link still lands on the Friends tab).
entry points (so a Telegram friend-code deep-link still lands on the Friends tab). The
**Feedback** screen is its own deeper route `/feedback` (back → `/about`, the Info tab), so
it slides in/out like a sub-screen rather than switching a tab in place.
- **Tab bar** (`TabBar.svelte`): square, borderless, evenly distributed buttons — a large
emoji icon over a tiny truncated label (the icon is `aria-hidden`, so the label names the
button). A press highlights a rounded **square** behind the icon; a hub's **selected** tab
@@ -252,6 +254,14 @@ IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use
row is turn-driven: on your turn the message field shows until your one allowed message is
sent, then a short caption replaces it until the next turn; on the opponent's turn the 🛎️
nudge takes the field's place.
- **Feedback** (`screens/Feedback.svelte`, reached from a button at the bottom of the ️ Info
tab, registered accounts only): a multi-line message field over a row that pairs a **ghost**
attach button (a hidden `<input type=file>`, with a chosen-file name + a remove control) with
the filled accent **Send** button. A rejected file shows one generic notice (the allowed types
are not advertised). The send controls disable while a previous message is under review or the
account is barred; the operator's reply renders below as a titled block. An undelivered reply
raises a badge on the lobby ⚙️ tab (combined with the friend-request count) and a "1" on the
Info tab.
## Caveat
+6 -4
View File
@@ -207,14 +207,16 @@ func (c *Client) EmailLogin(ctx context.Context, email, code string) (SessionRes
return out, err
}
// ResolveSession maps a token to its account id (gateway session-cache miss).
func (c *Client) ResolveSession(ctx context.Context, token string) (string, error) {
// ResolveSession maps a token to its account id and guest flag (gateway
// session-cache miss). The guest flag lets the edge gate guest-forbidden ops.
func (c *Client) ResolveSession(ctx context.Context, token string) (string, bool, error) {
var out struct {
UserID string `json:"user_id"`
UserID string `json:"user_id"`
IsGuest bool `json:"is_guest"`
}
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/resolve", "", "",
map[string]string{"token": token}, &out)
return out.UserID, err
return out.UserID, out.IsGuest, err
}
// Profile returns the authenticated account's profile.
@@ -0,0 +1,56 @@
package backendclient
import (
"context"
"encoding/base64"
"net/http"
)
// FeedbackReplyResp is the operator reply shown back to the player.
type FeedbackReplyResp struct {
Body string `json:"body"`
RepliedAtUnix int64 `json:"replied_at_unix"`
}
// FeedbackStateResp is the player's feedback screen state. Reply is nil when there
// is none to show.
type FeedbackStateResp struct {
CanSend bool `json:"can_send"`
BlockedReason string `json:"blocked_reason"`
Reply *FeedbackReplyResp `json:"reply,omitempty"`
}
// FeedbackUnreadResp reports whether the player has an undelivered operator reply.
type FeedbackUnreadResp struct {
ReplyUnread bool `json:"reply_unread"`
}
// FeedbackSubmit posts a feedback message. The attachment bytes are base64-encoded
// into the JSON body for the internal hop; clientIP rides X-Forwarded-For.
func (c *Client) FeedbackSubmit(ctx context.Context, userID, body string, attachment []byte, attachmentName, channel, clientIP string) error {
payload := map[string]string{
"body": body,
"attachment": "",
"attachment_name": attachmentName,
"channel": channel,
}
if len(attachment) > 0 {
payload["attachment"] = base64.StdEncoding.EncodeToString(attachment)
}
return c.do(ctx, http.MethodPost, "/api/v1/user/feedback", userID, clientIP, payload, nil)
}
// FeedbackGet returns the player's feedback screen state, marking any pending reply
// delivered (clearing the badge).
func (c *Client) FeedbackGet(ctx context.Context, userID string) (FeedbackStateResp, error) {
var out FeedbackStateResp
err := c.do(ctx, http.MethodGet, "/api/v1/user/feedback", userID, "", nil, &out)
return out, err
}
// FeedbackUnread reports whether the player has an undelivered operator reply.
func (c *Client) FeedbackUnread(ctx context.Context, userID string) (FeedbackUnreadResp, error) {
var out FeedbackUnreadResp
err := c.do(ctx, http.MethodGet, "/api/v1/user/feedback/unread", userID, "", nil, &out)
return out, err
}
+19 -9
View File
@@ -207,11 +207,20 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP}
if op.Auth {
uid, err := s.resolve(ctx, req.Header())
uid, isGuest, err := s.resolve(ctx, req.Header())
if err != nil {
result = "unauthenticated"
return nil, err
}
// A guest may not perform a non-guest operation: reject it here as a domain
// outcome, before any backend call.
if op.NonGuest && isGuest {
result = "domain"
return connect.NewResponse(&edgev1.ExecuteResponse{
RequestId: req.Msg.GetRequestId(),
ResultCode: "guest_forbidden",
}), nil
}
// A valid session proving an authenticated request is an "action" for the
// active_users gauge, counted before the rate-limit/domain outcome.
s.metrics.recordActive(uid)
@@ -254,7 +263,7 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
// Subscribe streams the authenticated user's live events with a keep-alive
// heartbeat until the client disconnects.
func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error {
uid, err := s.resolve(ctx, req.Header())
uid, _, err := s.resolve(ctx, req.Header())
if err != nil {
return err
}
@@ -332,14 +341,15 @@ func (s *Server) limitAdmin(next http.Handler) http.Handler {
})
}
// resolve extracts and resolves the Authorization bearer token to an account id,
// returning a Connect Unauthenticated error when it is missing or unknown.
func (s *Server) resolve(ctx context.Context, h http.Header) (string, error) {
// resolve extracts and resolves the Authorization bearer token to an account id
// and its guest flag, returning a Connect Unauthenticated error when it is missing
// or unknown.
func (s *Server) resolve(ctx context.Context, h http.Header) (string, bool, error) {
token := bearerToken(h.Get("Authorization"))
if token == "" {
return "", connect.NewError(connect.CodeUnauthenticated, errMissingToken)
return "", false, connect.NewError(connect.CodeUnauthenticated, errMissingToken)
}
uid, err := s.sessions.Resolve(ctx, token)
uid, isGuest, err := s.sessions.Resolve(ctx, token)
if err != nil {
// An unknown or expired token (a backend 4xx) is the client's problem and
// stays silent; anything else — a resolve timeout, a refused connection, a
@@ -350,9 +360,9 @@ func (s *Server) resolve(ctx context.Context, h http.Header) (string, error) {
if !errors.As(err, &apiErr) || apiErr.Status >= http.StatusInternalServerError {
s.log.Warn("session resolve failed", zap.Error(err))
}
return "", connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
return "", false, connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
}
return uid, nil
return uid, isGuest, nil
}
// bearerToken extracts the token from an "Authorization: Bearer <token>" header,
@@ -69,6 +69,45 @@ func TestExecuteGuestAuthOK(t *testing.T) {
}
}
// TestExecuteGuestGate verifies the NonGuest op flag: a guest is rejected with the
// guest_forbidden result code before any backend call, while a guest may still run
// an authenticated op that is not guest-gated.
func TestExecuteGuestGate(t *testing.T) {
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/internal/sessions/resolve":
_, _ = w.Write([]byte(`{"user_id":"g-1","is_guest":true}`))
case "/api/v1/user/feedback/unread":
_, _ = w.Write([]byte(`{"reply_unread":false}`))
case "/api/v1/user/feedback":
t.Errorf("guest feedback.submit must not reach the backend")
default:
t.Errorf("unexpected backend path %s", r.URL.Path)
}
})
defer cleanup()
submit := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgFeedbackSubmit, RequestId: "rq-1"})
submit.Header().Set("Authorization", "Bearer tok")
resp, err := client.Execute(context.Background(), submit)
if err != nil {
t.Fatalf("execute submit: %v", err)
}
if resp.Msg.GetResultCode() != "guest_forbidden" {
t.Fatalf("submit result = %q, want guest_forbidden", resp.Msg.GetResultCode())
}
unread := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgFeedbackUnread})
unread.Header().Set("Authorization", "Bearer tok")
resp, err = client.Execute(context.Background(), unread)
if err != nil {
t.Fatalf("execute unread: %v", err)
}
if resp.Msg.GetResultCode() != "ok" {
t.Fatalf("unread result = %q, want ok", resp.Msg.GetResultCode())
}
}
func TestExecuteAuthedRequiresSession(t *testing.T) {
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("backend must not be called without a session")
+22 -20
View File
@@ -11,10 +11,10 @@ import (
"time"
)
// Resolver resolves a token to an account id at the backend (the cache miss
// path). backendclient.Client satisfies it.
// Resolver resolves a token to an account id and its guest flag at the backend
// (the cache miss path). backendclient.Client satisfies it.
type Resolver interface {
ResolveSession(ctx context.Context, token string) (string, error)
ResolveSession(ctx context.Context, token string) (string, bool, error)
}
// Cache resolves session tokens to account ids, caching hits for ttl.
@@ -30,6 +30,7 @@ type Cache struct {
type entry struct {
userID string
isGuest bool
expires time.Time
}
@@ -47,19 +48,19 @@ func NewCache(backend Resolver, ttl time.Duration, max int) *Cache {
}
}
// Resolve returns the account id for token, consulting the cache first and the
// backend on a miss (caching the result). An empty token is rejected by the
// backend like any unknown token.
func (c *Cache) Resolve(ctx context.Context, token string) (string, error) {
if uid, ok := c.lookup(token); ok {
return uid, nil
// Resolve returns the account id and guest flag for token, consulting the cache
// first and the backend on a miss (caching the result). An empty token is rejected
// by the backend like any unknown token.
func (c *Cache) Resolve(ctx context.Context, token string) (string, bool, error) {
if uid, guest, ok := c.lookup(token); ok {
return uid, guest, nil
}
uid, err := c.backend.ResolveSession(ctx, token)
uid, guest, err := c.backend.ResolveSession(ctx, token)
if err != nil {
return "", err
return "", false, err
}
c.store(token, uid)
return uid, nil
c.store(token, uid, guest)
return uid, guest, nil
}
// Invalidate drops a token from the cache (e.g. after a revoke).
@@ -69,25 +70,26 @@ func (c *Cache) Invalidate(token string) {
delete(c.entries, token)
}
// lookup returns a live cached account id for token.
func (c *Cache) lookup(token string) (string, bool) {
// lookup returns a live cached account id and guest flag for token.
func (c *Cache) lookup(token string) (string, bool, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries[token]
if !ok || !c.now().Before(e.expires) {
return "", false
return "", false, false
}
return e.userID, true
return e.userID, e.isGuest, true
}
// store caches token -> userID, sweeping expired entries and bounding the size.
func (c *Cache) store(token, userID string) {
// store caches token -> (userID, isGuest), sweeping expired entries and bounding
// the size.
func (c *Cache) store(token, userID string, isGuest bool) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.entries) >= c.max {
c.evictLocked()
}
c.entries[token] = entry{userID: userID, expires: c.now().Add(c.ttl)}
c.entries[token] = entry{userID: userID, isGuest: isGuest, expires: c.now().Add(c.ttl)}
}
// evictLocked removes expired entries and, if still at capacity, drops arbitrary
+24 -9
View File
@@ -9,16 +9,17 @@ import (
type fakeResolver struct {
uid string
guest bool
err error
calls int
}
func (f *fakeResolver) ResolveSession(_ context.Context, _ string) (string, error) {
func (f *fakeResolver) ResolveSession(_ context.Context, _ string) (string, bool, error) {
f.calls++
if f.err != nil {
return "", f.err
return "", false, f.err
}
return f.uid, nil
return f.uid, f.guest, nil
}
func TestResolveCachesBackendHit(t *testing.T) {
@@ -26,7 +27,7 @@ func TestResolveCachesBackendHit(t *testing.T) {
c := NewCache(r, time.Minute, 10)
for i := 0; i < 3; i++ {
uid, err := c.Resolve(context.Background(), "tok")
uid, _, err := c.Resolve(context.Background(), "tok")
if err != nil || uid != "user-1" {
t.Fatalf("resolve #%d = (%q, %v)", i, uid, err)
}
@@ -36,10 +37,24 @@ func TestResolveCachesBackendHit(t *testing.T) {
}
}
func TestResolveCarriesAndCachesGuestFlag(t *testing.T) {
r := &fakeResolver{uid: "guest-1", guest: true}
c := NewCache(r, time.Minute, 10)
for i := 0; i < 2; i++ {
uid, guest, err := c.Resolve(context.Background(), "tok")
if err != nil || uid != "guest-1" || !guest {
t.Fatalf("resolve #%d = (%q, guest=%v, %v)", i, uid, guest, err)
}
}
if r.calls != 1 {
t.Fatalf("backend calls = %d, want 1 (guest flag cached)", r.calls)
}
}
func TestResolvePropagatesBackendError(t *testing.T) {
r := &fakeResolver{err: errors.New("nope")}
c := NewCache(r, time.Minute, 10)
if _, err := c.Resolve(context.Background(), "tok"); err == nil {
if _, _, err := c.Resolve(context.Background(), "tok"); err == nil {
t.Fatal("expected backend error to propagate")
}
}
@@ -50,11 +65,11 @@ func TestResolveReResolvesAfterTTL(t *testing.T) {
base := time.Now()
c.now = func() time.Time { return base }
if _, err := c.Resolve(context.Background(), "tok"); err != nil {
if _, _, err := c.Resolve(context.Background(), "tok"); err != nil {
t.Fatal(err)
}
c.now = func() time.Time { return base.Add(2 * time.Minute) } // past TTL
if _, err := c.Resolve(context.Background(), "tok"); err != nil {
if _, _, err := c.Resolve(context.Background(), "tok"); err != nil {
t.Fatal(err)
}
if r.calls != 2 {
@@ -65,9 +80,9 @@ func TestResolveReResolvesAfterTTL(t *testing.T) {
func TestInvalidateForcesReResolve(t *testing.T) {
r := &fakeResolver{uid: "user-1"}
c := NewCache(r, time.Minute, 10)
_, _ = c.Resolve(context.Background(), "tok")
_, _, _ = c.Resolve(context.Background(), "tok")
c.Invalidate("tok")
_, _ = c.Resolve(context.Background(), "tok")
_, _, _ = c.Resolve(context.Background(), "tok")
if r.calls != 2 {
t.Fatalf("backend calls = %d, want 2 after invalidate", r.calls)
}
+31
View File
@@ -324,6 +324,37 @@ func encodeChatList(r backendclient.ChatListResp) []byte {
return b.FinishedBytes()
}
// encodeFeedbackState builds a FeedbackState payload, nesting the reply when present.
func encodeFeedbackState(st backendclient.FeedbackStateResp) []byte {
b := flatbuffers.NewBuilder(128)
var reply flatbuffers.UOffsetT
if st.Reply != nil {
body := b.CreateString(st.Reply.Body)
fb.FeedbackReplyStart(b)
fb.FeedbackReplyAddBody(b, body)
fb.FeedbackReplyAddRepliedAtUnix(b, st.Reply.RepliedAtUnix)
reply = fb.FeedbackReplyEnd(b)
}
reason := b.CreateString(st.BlockedReason)
fb.FeedbackStateStart(b)
fb.FeedbackStateAddCanSend(b, st.CanSend)
fb.FeedbackStateAddBlockedReason(b, reason)
if st.Reply != nil {
fb.FeedbackStateAddReply(b, reply)
}
b.Finish(fb.FeedbackStateEnd(b))
return b.FinishedBytes()
}
// encodeFeedbackUnread builds a FeedbackUnread payload.
func encodeFeedbackUnread(u backendclient.FeedbackUnreadResp) []byte {
b := flatbuffers.NewBuilder(16)
fb.FeedbackUnreadStart(b)
fb.FeedbackUnreadAddReplyUnread(b, u.ReplyUnread)
b.Finish(fb.FeedbackUnreadEnd(b))
return b.FinishedBytes()
}
// buildGameView builds a GameView table and returns its offset.
func buildGameView(b *flatbuffers.Builder, g backendclient.GameResp) flatbuffers.UOffsetT {
return wire.BuildGameView(b, toWireGame(g))
+40
View File
@@ -43,6 +43,9 @@ const (
MsgDraftGet = "draft.get"
MsgDraftSave = "draft.save"
MsgGameHide = "game.hide"
MsgFeedbackSubmit = "feedback.submit"
MsgFeedbackGet = "feedback.get"
MsgFeedbackUnread = "feedback.unread"
)
// Request is one decoded Execute call.
@@ -62,6 +65,10 @@ type Op struct {
Auth bool
// Email marks the costly email-code path that gets a stricter rate sub-limit.
Email bool
// NonGuest marks an operation a guest account may not perform; the gateway
// rejects it with the guest_forbidden result code after resolving the session,
// before any backend call.
NonGuest bool
}
// Registry maps message types to their operations.
@@ -117,6 +124,9 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, defaultLan
r.ops[MsgDraftGet] = Op{Handler: getDraftHandler(backend), Auth: true}
r.ops[MsgDraftSave] = Op{Handler: saveDraftHandler(backend), Auth: true}
r.ops[MsgGameHide] = Op{Handler: hideGameHandler(backend), Auth: true}
r.ops[MsgFeedbackSubmit] = Op{Handler: feedbackSubmitHandler(backend), Auth: true, NonGuest: true}
r.ops[MsgFeedbackGet] = Op{Handler: feedbackGetHandler(backend), Auth: true}
r.ops[MsgFeedbackUnread] = Op{Handler: feedbackUnreadHandler(backend), Auth: true}
registerSocialOps(r, backend)
registerLinkOps(r, backend, tg, defaultLanguages)
return r
@@ -477,3 +487,33 @@ func hideGameHandler(backend *backendclient.Client) Handler {
return encodeAck(true), nil
}
}
func feedbackSubmitHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsFeedbackSubmitRequest(req.Payload, 0)
if err := backend.FeedbackSubmit(ctx, req.UserID, string(in.Body()), in.AttachmentBytes(), string(in.AttachmentName()), string(in.Channel()), req.ClientIP); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
func feedbackGetHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
st, err := backend.FeedbackGet(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeFeedbackState(st), nil
}
}
func feedbackUnreadHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
u, err := backend.FeedbackUnread(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeFeedbackUnread(u), nil
}
}
+34
View File
@@ -321,6 +321,40 @@ table ChatList {
messages:[ChatMessage];
}
// --- feedback (authenticated) ---
// FeedbackSubmitRequest submits a feedback message with an optional single
// attachment. attachment is the raw file bytes (empty for none); attachment_name
// carries the original file name (its extension keys the allow-list); channel is
// the submitting platform (telegram/ios/android/web). The response is an Ack.
table FeedbackSubmitRequest {
body:string;
attachment:[ubyte];
attachment_name:string;
channel:string;
}
// FeedbackReply is the operator's answer shown back to the player.
table FeedbackReply {
body:string;
replied_at_unix:long;
}
// FeedbackState is the player's feedback screen state. blocked_reason is "" (can
// send), "pending" or "banned"; reply is null when there is none to show. Fetching
// it (feedback.get) delivers any pending reply, clearing the badge.
table FeedbackState {
can_send:bool;
blocked_reason:string;
reply:FeedbackReply;
}
// FeedbackUnread reports whether the player has an undelivered operator reply, for
// the lobby/Info badge (feedback.unread; no side effect).
table FeedbackUnread {
reply_unread:bool;
}
// --- account, statistics, friends, blocks, invitations, history ---
// AccountRef is a referenced account with its display name resolved — the shared
+75
View File
@@ -0,0 +1,75 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type FeedbackReply struct {
_tab flatbuffers.Table
}
func GetRootAsFeedbackReply(buf []byte, offset flatbuffers.UOffsetT) *FeedbackReply {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &FeedbackReply{}
x.Init(buf, n+offset)
return x
}
func FinishFeedbackReplyBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsFeedbackReply(buf []byte, offset flatbuffers.UOffsetT) *FeedbackReply {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &FeedbackReply{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedFeedbackReplyBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *FeedbackReply) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *FeedbackReply) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *FeedbackReply) Body() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *FeedbackReply) RepliedAtUnix() int64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.GetInt64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *FeedbackReply) MutateRepliedAtUnix(n int64) bool {
return rcv._tab.MutateInt64Slot(6, n)
}
func FeedbackReplyStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func FeedbackReplyAddBody(builder *flatbuffers.Builder, body flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(body), 0)
}
func FeedbackReplyAddRepliedAtUnix(builder *flatbuffers.Builder, repliedAtUnix int64) {
builder.PrependInt64Slot(1, repliedAtUnix, 0)
}
func FeedbackReplyEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+91
View File
@@ -0,0 +1,91 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type FeedbackState struct {
_tab flatbuffers.Table
}
func GetRootAsFeedbackState(buf []byte, offset flatbuffers.UOffsetT) *FeedbackState {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &FeedbackState{}
x.Init(buf, n+offset)
return x
}
func FinishFeedbackStateBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsFeedbackState(buf []byte, offset flatbuffers.UOffsetT) *FeedbackState {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &FeedbackState{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedFeedbackStateBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *FeedbackState) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *FeedbackState) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *FeedbackState) CanSend() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.GetBool(o + rcv._tab.Pos)
}
return false
}
func (rcv *FeedbackState) MutateCanSend(n bool) bool {
return rcv._tab.MutateBoolSlot(4, n)
}
func (rcv *FeedbackState) BlockedReason() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *FeedbackState) Reply(obj *FeedbackReply) *FeedbackReply {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
x := rcv._tab.Indirect(o + rcv._tab.Pos)
if obj == nil {
obj = new(FeedbackReply)
}
obj.Init(rcv._tab.Bytes, x)
return obj
}
return nil
}
func FeedbackStateStart(builder *flatbuffers.Builder) {
builder.StartObject(3)
}
func FeedbackStateAddCanSend(builder *flatbuffers.Builder, canSend bool) {
builder.PrependBoolSlot(0, canSend, false)
}
func FeedbackStateAddBlockedReason(builder *flatbuffers.Builder, blockedReason flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(blockedReason), 0)
}
func FeedbackStateAddReply(builder *flatbuffers.Builder, reply flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(reply), 0)
}
func FeedbackStateEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+122
View File
@@ -0,0 +1,122 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type FeedbackSubmitRequest struct {
_tab flatbuffers.Table
}
func GetRootAsFeedbackSubmitRequest(buf []byte, offset flatbuffers.UOffsetT) *FeedbackSubmitRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &FeedbackSubmitRequest{}
x.Init(buf, n+offset)
return x
}
func FinishFeedbackSubmitRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsFeedbackSubmitRequest(buf []byte, offset flatbuffers.UOffsetT) *FeedbackSubmitRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &FeedbackSubmitRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedFeedbackSubmitRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *FeedbackSubmitRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *FeedbackSubmitRequest) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *FeedbackSubmitRequest) Body() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *FeedbackSubmitRequest) Attachment(j int) byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
a := rcv._tab.Vector(o)
return rcv._tab.GetByte(a + flatbuffers.UOffsetT(j*1))
}
return 0
}
func (rcv *FeedbackSubmitRequest) AttachmentLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.VectorLen(o)
}
return 0
}
func (rcv *FeedbackSubmitRequest) AttachmentBytes() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *FeedbackSubmitRequest) MutateAttachment(j int, n byte) bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
a := rcv._tab.Vector(o)
return rcv._tab.MutateByte(a+flatbuffers.UOffsetT(j*1), n)
}
return false
}
func (rcv *FeedbackSubmitRequest) AttachmentName() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *FeedbackSubmitRequest) Channel() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func FeedbackSubmitRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(4)
}
func FeedbackSubmitRequestAddBody(builder *flatbuffers.Builder, body flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(body), 0)
}
func FeedbackSubmitRequestAddAttachment(builder *flatbuffers.Builder, attachment flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(attachment), 0)
}
func FeedbackSubmitRequestStartAttachmentVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(1, numElems, 1)
}
func FeedbackSubmitRequestAddAttachmentName(builder *flatbuffers.Builder, attachmentName flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(attachmentName), 0)
}
func FeedbackSubmitRequestAddChannel(builder *flatbuffers.Builder, channel flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(channel), 0)
}
func FeedbackSubmitRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+64
View File
@@ -0,0 +1,64 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type FeedbackUnread struct {
_tab flatbuffers.Table
}
func GetRootAsFeedbackUnread(buf []byte, offset flatbuffers.UOffsetT) *FeedbackUnread {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &FeedbackUnread{}
x.Init(buf, n+offset)
return x
}
func FinishFeedbackUnreadBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsFeedbackUnread(buf []byte, offset flatbuffers.UOffsetT) *FeedbackUnread {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &FeedbackUnread{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedFeedbackUnreadBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *FeedbackUnread) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *FeedbackUnread) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *FeedbackUnread) ReplyUnread() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.GetBool(o + rcv._tab.Pos)
}
return false
}
func (rcv *FeedbackUnread) MutateReplyUnread(n bool) bool {
return rcv._tab.MutateBoolSlot(4, n)
}
func FeedbackUnreadStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func FeedbackUnreadAddReplyUnread(builder *flatbuffers.Builder, replyUnread bool) {
builder.PrependBoolSlot(0, replyUnread, false)
}
func FeedbackUnreadEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+64
View File
@@ -0,0 +1,64 @@
import { expect, test, type Page } from './fixtures';
// User feedback against the mock transport (no backend). The mock profile is a
// durable (non-guest) account, so the Settings → Info "Feedback" entry is shown.
async function loginLobby(page: Page): Promise<void> {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await expect(page.getByText('Your turn')).toBeVisible();
}
// openFeedback navigates lobby ⚙️ → Info tab → the Feedback screen.
async function openFeedback(page: Page): Promise<void> {
await page.getByRole('button', { name: /Settings/ }).click();
await expect(page.locator('.pane')).toHaveCount(1);
await page.getByRole('button', { name: 'Info', exact: true }).click();
// The About "Feedback" button: its accessible name gains the "1" badge once a reply
// is waiting, so match by substring rather than exact.
await page.getByRole('button', { name: /Feedback/ }).click();
}
test('feedback: submit a message, then resend is blocked', async ({ page }) => {
await loginLobby(page);
await openFeedback(page);
await expect(page.getByPlaceholder(/Describe the problem/)).toBeVisible();
await page.locator('textarea').fill('The board does not render on my phone.');
await page.getByRole('button', { name: 'Send', exact: true }).click();
await expect(page.getByText('Your message has been sent.')).toBeVisible();
await expect(page.getByRole('button', { name: 'Send', exact: true })).toBeDisabled();
});
test('feedback: an operator reply raises the badge and shows on the screen', async ({ page }) => {
await loginLobby(page);
// Simulate the operator answering: clears any pending message, sets the reply and
// pushes the admin_reply live event.
await page.evaluate(() => (window as unknown as { __mock: { adminReply(): void } }).__mock.adminReply());
// The lobby ⚙️ badge folds the awaiting reply into its count.
await expect(page.getByRole('button', { name: /Settings/ }).locator('.badge')).toBeVisible();
await openFeedback(page);
await expect(page.getByText('Reply to your last message')).toBeVisible();
// A URL in the reply renders as a link that opens in a new tab.
const link = page.getByRole('link', { name: 'https://example.com/help' });
await expect(link).toBeVisible();
await expect(link).toHaveAttribute('target', '_blank');
await expect(link).toHaveAttribute('rel', /noopener/);
});
test('feedback: sending a new message clears the previous reply', async ({ page }) => {
await loginLobby(page);
await page.evaluate(() => (window as unknown as { __mock: { adminReply(): void } }).__mock.adminReply());
await openFeedback(page);
await expect(page.getByText('Reply to your last message')).toBeVisible();
// A new message has no reply yet, so the previous reply must stop showing at once.
await page.locator('textarea').fill('a follow-up question');
await page.getByRole('button', { name: 'Send', exact: true }).click();
await expect(page.getByText('Your message has been sent.')).toBeVisible();
await expect(page.getByText('Reply to your last message')).toBeHidden();
});
+7 -3
View File
@@ -13,6 +13,7 @@
import Stats from './screens/Stats.svelte';
import Game from './game/Game.svelte';
import CommsHub from './game/CommsHub.svelte';
import Feedback from './screens/Feedback.svelte';
import Blocked from './screens/Blocked.svelte';
onMount(() => {
@@ -26,8 +27,9 @@
if (!insideTelegram()) return;
const r = router.route;
// The chat / check sub-screens step back to their game; every other sub-screen to the lobby.
const sub = r.name === 'gameChat' || r.name === 'gameCheck';
const target = sub ? `/game/${r.params.id}` : '/';
let target = '/';
if (r.name === 'gameChat' || r.name === 'gameCheck') target = `/game/${r.params.id}`;
else if (r.name === 'feedback') target = '/about'; // back to the Settings → Info tab
telegramBackButton(r.name !== 'lobby' && r.name !== 'login', () => navigate(target));
});
@@ -40,7 +42,7 @@
// back-to-the-game the wrong way. dir is read with the previous depth (the effect updates it
// only after the transition has captured its sign).
function routeDepth(name: RouteName): number {
if (name === 'gameChat' || name === 'gameCheck') return 2;
if (name === 'gameChat' || name === 'gameCheck' || name === 'feedback') return 2;
if (name === 'lobby' || name === 'login') return 0;
return 1;
}
@@ -93,6 +95,8 @@
<SettingsHub initialTab="about" />
{:else if router.route.name === 'friends'}
<SettingsHub initialTab="friends" />
{:else if router.route.name === 'feedback'}
<Feedback />
{:else if router.route.name === 'stats'}
<Stats />
{:else}
+4
View File
@@ -19,6 +19,10 @@ export { EnqueueRequest } from './scrabblefb/enqueue-request.js';
export { EvalRequest } from './scrabblefb/eval-request.js';
export { EvalResult } from './scrabblefb/eval-result.js';
export { ExchangeRequest } from './scrabblefb/exchange-request.js';
export { FeedbackReply } from './scrabblefb/feedback-reply.js';
export { FeedbackState } from './scrabblefb/feedback-state.js';
export { FeedbackSubmitRequest } from './scrabblefb/feedback-submit-request.js';
export { FeedbackUnread } from './scrabblefb/feedback-unread.js';
export { FriendCode } from './scrabblefb/friend-code.js';
export { FriendList } from './scrabblefb/friend-list.js';
export { FriendRespondRequest } from './scrabblefb/friend-respond-request.js';
@@ -0,0 +1,58 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class FeedbackReply {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):FeedbackReply {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsFeedbackReply(bb:flatbuffers.ByteBuffer, obj?:FeedbackReply):FeedbackReply {
return (obj || new FeedbackReply()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsFeedbackReply(bb:flatbuffers.ByteBuffer, obj?:FeedbackReply):FeedbackReply {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new FeedbackReply()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
body():string|null
body(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
body(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
repliedAtUnix():bigint {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0');
}
static startFeedbackReply(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addBody(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, bodyOffset, 0);
}
static addRepliedAtUnix(builder:flatbuffers.Builder, repliedAtUnix:bigint) {
builder.addFieldInt64(1, repliedAtUnix, BigInt('0'));
}
static endFeedbackReply(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createFeedbackReply(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset, repliedAtUnix:bigint):flatbuffers.Offset {
FeedbackReply.startFeedbackReply(builder);
FeedbackReply.addBody(builder, bodyOffset);
FeedbackReply.addRepliedAtUnix(builder, repliedAtUnix);
return FeedbackReply.endFeedbackReply(builder);
}
}
@@ -0,0 +1,64 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { FeedbackReply } from '../scrabblefb/feedback-reply.js';
export class FeedbackState {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):FeedbackState {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsFeedbackState(bb:flatbuffers.ByteBuffer, obj?:FeedbackState):FeedbackState {
return (obj || new FeedbackState()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsFeedbackState(bb:flatbuffers.ByteBuffer, obj?:FeedbackState):FeedbackState {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new FeedbackState()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
canSend():boolean {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
blockedReason():string|null
blockedReason(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
blockedReason(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
reply(obj?:FeedbackReply):FeedbackReply|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? (obj || new FeedbackReply()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
}
static startFeedbackState(builder:flatbuffers.Builder) {
builder.startObject(3);
}
static addCanSend(builder:flatbuffers.Builder, canSend:boolean) {
builder.addFieldInt8(0, +canSend, +false);
}
static addBlockedReason(builder:flatbuffers.Builder, blockedReasonOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, blockedReasonOffset, 0);
}
static addReply(builder:flatbuffers.Builder, replyOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, replyOffset, 0);
}
static endFeedbackState(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
}
@@ -0,0 +1,104 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class FeedbackSubmitRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):FeedbackSubmitRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsFeedbackSubmitRequest(bb:flatbuffers.ByteBuffer, obj?:FeedbackSubmitRequest):FeedbackSubmitRequest {
return (obj || new FeedbackSubmitRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsFeedbackSubmitRequest(bb:flatbuffers.ByteBuffer, obj?:FeedbackSubmitRequest):FeedbackSubmitRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new FeedbackSubmitRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
body():string|null
body(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
body(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
attachment(index: number):number|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.readUint8(this.bb!.__vector(this.bb_pos + offset) + index) : 0;
}
attachmentLength():number {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
attachmentArray():Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? new Uint8Array(this.bb!.bytes().buffer, this.bb!.bytes().byteOffset + this.bb!.__vector(this.bb_pos + offset), this.bb!.__vector_len(this.bb_pos + offset)) : null;
}
attachmentName():string|null
attachmentName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
attachmentName(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
channel():string|null
channel(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
channel(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startFeedbackSubmitRequest(builder:flatbuffers.Builder) {
builder.startObject(4);
}
static addBody(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, bodyOffset, 0);
}
static addAttachment(builder:flatbuffers.Builder, attachmentOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, attachmentOffset, 0);
}
static createAttachmentVector(builder:flatbuffers.Builder, data:number[]|Uint8Array):flatbuffers.Offset {
builder.startVector(1, data.length, 1);
for (let i = data.length - 1; i >= 0; i--) {
builder.addInt8(data[i]!);
}
return builder.endVector();
}
static startAttachmentVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(1, numElems, 1);
}
static addAttachmentName(builder:flatbuffers.Builder, attachmentNameOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, attachmentNameOffset, 0);
}
static addChannel(builder:flatbuffers.Builder, channelOffset:flatbuffers.Offset) {
builder.addFieldOffset(3, channelOffset, 0);
}
static endFeedbackSubmitRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createFeedbackSubmitRequest(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset, attachmentOffset:flatbuffers.Offset, attachmentNameOffset:flatbuffers.Offset, channelOffset:flatbuffers.Offset):flatbuffers.Offset {
FeedbackSubmitRequest.startFeedbackSubmitRequest(builder);
FeedbackSubmitRequest.addBody(builder, bodyOffset);
FeedbackSubmitRequest.addAttachment(builder, attachmentOffset);
FeedbackSubmitRequest.addAttachmentName(builder, attachmentNameOffset);
FeedbackSubmitRequest.addChannel(builder, channelOffset);
return FeedbackSubmitRequest.endFeedbackSubmitRequest(builder);
}
}
@@ -0,0 +1,46 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class FeedbackUnread {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):FeedbackUnread {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsFeedbackUnread(bb:flatbuffers.ByteBuffer, obj?:FeedbackUnread):FeedbackUnread {
return (obj || new FeedbackUnread()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsFeedbackUnread(bb:flatbuffers.ByteBuffer, obj?:FeedbackUnread):FeedbackUnread {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new FeedbackUnread()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
replyUnread():boolean {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startFeedbackUnread(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addReplyUnread(builder:flatbuffers.Builder, replyUnread:boolean) {
builder.addFieldInt8(0, +replyUnread, +false);
}
static endFeedbackUnread(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createFeedbackUnread(builder:flatbuffers.Builder, replyUnread:boolean):flatbuffers.Offset {
FeedbackUnread.startFeedbackUnread(builder);
FeedbackUnread.addReplyUnread(builder, replyUnread);
return FeedbackUnread.endFeedbackUnread(builder);
}
}
+26
View File
@@ -59,6 +59,9 @@ export const app = $state<{
notifications: number;
/** Unread chat-message count per game id, for the in-game score-bar and 💬 badges. */
chatUnread: Record<string, number>;
/** Whether an operator feedback reply awaits the player, for the lobby ⚙️ badge (combined
* with friend requests) and the Settings → Info badge. */
feedbackReplyUnread: boolean;
/** Monotonic counter bumped when the app returns to the foreground without the live stream
* having dropped. An open game watches it to refetch once, recovering an in-game event shed
* from a full hub buffer while suspended (the stream-drop case is covered by streamAlive). */
@@ -79,6 +82,7 @@ export const app = $state<{
localeLocked: false,
notifications: 0,
chatUnread: {},
feedbackReplyUnread: false,
resync: 0,
});
@@ -257,6 +261,10 @@ function openStream(): void {
if ((e.sub === 'invitation' || e.sub === 'invitation_update') && e.invitation) {
patchLobbyInvitation(e.invitation);
}
// An operator answered the player's feedback: raise the Settings/Info badge.
if (e.sub === 'admin_reply') {
app.feedbackReplyUnread = true;
}
void refreshNotifications();
}
},
@@ -299,6 +307,24 @@ export async function refreshNotifications(): Promise<void> {
}
}
/**
* refreshFeedbackBadge recomputes whether an operator feedback reply awaits the
* player (the Settings → Info badge, folded into the lobby ⚙️ badge). Authoritative
* poll, complementing the live 'admin_reply' push. Guests cannot submit feedback, so
* it is a no-op for them.
*/
export async function refreshFeedbackBadge(): Promise<void> {
if (!app.session || app.profile?.isGuest) {
app.feedbackReplyUnread = false;
return;
}
try {
app.feedbackReplyUnread = await gateway.feedbackUnread();
} catch {
// Best-effort; leave the previous flag on a transient failure.
}
}
function closeStream(): void {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { detectChannel } from './channel';
describe('detectChannel', () => {
it('prefers telegram over everything', () => {
expect(detectChannel({ telegram: true })).toBe('telegram');
expect(detectChannel({ telegram: true, capacitorPlatform: 'ios' })).toBe('telegram');
});
it('maps the native Capacitor platforms', () => {
expect(detectChannel({ telegram: false, capacitorPlatform: 'ios' })).toBe('ios');
expect(detectChannel({ telegram: false, capacitorPlatform: 'android' })).toBe('android');
});
it('falls back to web for capacitor web, missing, or unknown platforms', () => {
expect(detectChannel({ telegram: false, capacitorPlatform: 'web' })).toBe('web');
expect(detectChannel({ telegram: false })).toBe('web');
expect(detectChannel({ telegram: false, capacitorPlatform: 'desktop' })).toBe('web');
});
});
+30
View File
@@ -0,0 +1,30 @@
// The submitting platform ("channel") reported with a feedback message. Detected
// from the runtime environment: Telegram Mini App, the Capacitor native shell
// (iOS/Android), or a plain web browser. No new dependency — the Capacitor runtime
// global is feature-detected.
import { insideTelegram } from './telegram';
export type Channel = 'telegram' | 'ios' | 'android' | 'web';
/**
* detectChannel picks the channel from the runtime signals: telegram wins, then a
* native Capacitor platform (ios/android), else web. Pure, so it is unit-tested
* without a DOM.
*/
export function detectChannel(signals: { telegram: boolean; capacitorPlatform?: string }): Channel {
if (signals.telegram) return 'telegram';
if (signals.capacitorPlatform === 'ios' || signals.capacitorPlatform === 'android') {
return signals.capacitorPlatform;
}
return 'web';
}
/** clientChannel returns the submitting platform from the current runtime. */
export function clientChannel(): Channel {
const capacitorPlatform =
typeof window === 'undefined'
? undefined
: (window as unknown as { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
return detectChannel({ telegram: insideTelegram(), capacitorPlatform });
}
+9
View File
@@ -9,6 +9,7 @@ import type {
BlockStatus,
ChatMessage,
EvalResult,
FeedbackState,
FriendCode,
GameList,
GameView,
@@ -101,6 +102,14 @@ export interface GatewayClient {
chatList(gameId: string): Promise<ChatMessage[]>;
nudge(gameId: string): Promise<ChatMessage>;
// --- feedback ---
/** feedbackSubmit sends a feedback message with an optional single attachment. */
feedbackSubmit(body: string, attachment: Uint8Array | null, attachmentName: string, channel: string): Promise<void>;
/** feedbackGet returns the feedback screen state and marks any pending reply delivered. */
feedbackGet(): Promise<FeedbackState>;
/** feedbackUnread reports whether an operator reply awaits delivery (the badge). */
feedbackUnread(): Promise<boolean>;
// --- friends ---
friendsList(): Promise<AccountRef[]>;
friendsIncoming(): Promise<AccountRef[]>;
+60
View File
@@ -13,9 +13,12 @@ import {
decodeMatchResult,
decodeOutgoingList,
decodeSession,
decodeFeedbackState,
decodeFeedbackUnread,
decodeStateView,
decodeStats,
encodeCheckWord,
encodeFeedbackSubmit,
encodeDraftSave,
encodeExchange,
encodeStateRequest,
@@ -38,6 +41,63 @@ describe('codec', () => {
expect(decodeDraftView(b.asUint8Array())).toBe('{"x":1}');
});
it('round-trips a feedback submit and decodes state + unread', () => {
const att = new Uint8Array([1, 2, 3, 4]);
const req = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest(
new ByteBuffer(encodeFeedbackSubmit('please fix', att, 'shot.png', 'ios')),
);
expect(req.body()).toBe('please fix');
expect(req.attachmentName()).toBe('shot.png');
expect(req.channel()).toBe('ios');
expect(Array.from(req.attachmentArray() ?? [])).toEqual([1, 2, 3, 4]);
// No attachment: the vector is empty.
const req2 = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest(
new ByteBuffer(encodeFeedbackSubmit('hi', null, '', 'web')),
);
expect(req2.body()).toBe('hi');
expect(req2.attachmentLength()).toBe(0);
// State carrying a reply.
const b = new Builder(128);
const replyBody = b.createString('we are on it');
fb.FeedbackReply.startFeedbackReply(b);
fb.FeedbackReply.addBody(b, replyBody);
fb.FeedbackReply.addRepliedAtUnix(b, BigInt(1700000000));
const reply = fb.FeedbackReply.endFeedbackReply(b);
const reason = b.createString('');
fb.FeedbackState.startFeedbackState(b);
fb.FeedbackState.addCanSend(b, true);
fb.FeedbackState.addBlockedReason(b, reason);
fb.FeedbackState.addReply(b, reply);
b.finish(fb.FeedbackState.endFeedbackState(b));
expect(decodeFeedbackState(b.asUint8Array())).toEqual({
canSend: true,
blockedReason: '',
reply: { body: 'we are on it', repliedAtUnix: 1700000000 },
});
// State with no reply, blocked as pending.
const b2 = new Builder(64);
const reason2 = b2.createString('pending');
fb.FeedbackState.startFeedbackState(b2);
fb.FeedbackState.addCanSend(b2, false);
fb.FeedbackState.addBlockedReason(b2, reason2);
b2.finish(fb.FeedbackState.endFeedbackState(b2));
expect(decodeFeedbackState(b2.asUint8Array())).toEqual({
canSend: false,
blockedReason: 'pending',
reply: null,
});
// Unread badge flag.
const b3 = new Builder(16);
fb.FeedbackUnread.startFeedbackUnread(b3);
fb.FeedbackUnread.addReplyUnread(b3, true);
b3.finish(fb.FeedbackUnread.endFeedbackUnread(b3));
expect(decodeFeedbackUnread(b3.asUint8Array())).toBe(true);
});
it('decodes a temporary BlockStatus with reason', () => {
const b = new Builder(64);
const until = b.createString('2026-07-01T12:00:00Z');
+34
View File
@@ -12,6 +12,7 @@ import type {
BlockStatus,
ChatMessage,
EvalResult,
FeedbackState,
FriendCode,
GameList,
GameView,
@@ -437,6 +438,39 @@ export function decodeChatList(buf: Uint8Array): ChatMessage[] {
return out;
}
export function encodeFeedbackSubmit(
body: string,
attachment: Uint8Array | null,
attachmentName: string,
channel: string,
): Uint8Array {
const b = new Builder(256);
const bodyOff = b.createString(body);
const attOff = attachment && attachment.length > 0 ? fb.FeedbackSubmitRequest.createAttachmentVector(b, attachment) : 0;
const nameOff = b.createString(attachmentName);
const chOff = b.createString(channel);
fb.FeedbackSubmitRequest.startFeedbackSubmitRequest(b);
fb.FeedbackSubmitRequest.addBody(b, bodyOff);
if (attOff) fb.FeedbackSubmitRequest.addAttachment(b, attOff);
fb.FeedbackSubmitRequest.addAttachmentName(b, nameOff);
fb.FeedbackSubmitRequest.addChannel(b, chOff);
return finish(b, fb.FeedbackSubmitRequest.endFeedbackSubmitRequest(b));
}
export function decodeFeedbackState(buf: Uint8Array): FeedbackState {
const st = fb.FeedbackState.getRootAsFeedbackState(new ByteBuffer(buf));
const r = st.reply();
return {
canSend: st.canSend(),
blockedReason: s(st.blockedReason()),
reply: r ? { body: s(r.body()), repliedAtUnix: Number(r.repliedAtUnix()) } : null,
};
}
export function decodeFeedbackUnread(buf: Uint8Array): boolean {
return fb.FeedbackUnread.getRootAsFeedbackUnread(new ByteBuffer(buf)).replyUnread();
}
export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null {
const bb = new ByteBuffer(payload);
switch (kind) {
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import { attachmentError, linkify, MAX_ATTACHMENT_BYTES } from './feedback';
describe('attachmentError', () => {
it('accepts allowed extensions within the size cap', () => {
expect(attachmentError('shot.png', 1000)).toBe('');
expect(attachmentError('Photo.JPG', 1000)).toBe(''); // case-insensitive
expect(attachmentError('report.pdf', 1000)).toBe('');
expect(attachmentError('my.notes.docx', 1000)).toBe(''); // dotted name
expect(attachmentError('logs.7z', MAX_ATTACHMENT_BYTES)).toBe('');
});
it('rejects disallowed or extensionless names', () => {
expect(attachmentError('evil.exe', 10)).toBe('type');
expect(attachmentError('x.svg', 10)).toBe('type'); // XSS vector, not allowed
expect(attachmentError('x.html', 10)).toBe('type');
expect(attachmentError('README', 10)).toBe('type');
expect(attachmentError('', 10)).toBe('type');
});
it('rejects too-large files', () => {
expect(attachmentError('shot.png', MAX_ATTACHMENT_BYTES + 1)).toBe('size');
});
});
describe('linkify', () => {
it('returns a single text segment for plain text', () => {
expect(linkify('just text')).toEqual([{ text: 'just text' }]);
});
it('marks an http(s) URL as a link', () => {
expect(linkify('see https://example.com now')).toEqual([
{ text: 'see ' },
{ text: 'https://example.com', href: 'https://example.com' },
{ text: ' now' },
]);
});
it('keeps trailing punctuation out of the link', () => {
expect(linkify('go to https://example.com.')).toEqual([
{ text: 'go to ' },
{ text: 'https://example.com', href: 'https://example.com' },
{ text: '.' },
]);
expect(linkify('(see https://example.com/path)')).toEqual([
{ text: '(see ' },
{ text: 'https://example.com/path', href: 'https://example.com/path' },
{ text: ')' },
]);
});
it('handles multiple links', () => {
const segs = linkify('a http://x.io b https://y.io');
expect(segs.filter((s) => s.href).map((s) => s.href)).toEqual(['http://x.io', 'https://y.io']);
});
it('linkifies whitelisted non-http schemes (ftp, mailto, tel)', () => {
expect(linkify('ftp://files.example.com/x')).toContainEqual({
text: 'ftp://files.example.com/x',
href: 'ftp://files.example.com/x',
});
expect(linkify('write to mailto:help@example.com please')).toContainEqual({
text: 'mailto:help@example.com',
href: 'mailto:help@example.com',
});
expect(linkify('call tel:+1234')).toContainEqual({ text: 'tel:+1234', href: 'tel:+1234' });
});
it('never linkifies dangerous schemes', () => {
const segs = linkify('javascript:alert(1) data:text/html,x vbscript:msgbox');
expect(segs.some((s) => s.href)).toBe(false);
});
it('does not match a scheme inside a word (hotel:, email:)', () => {
expect(linkify('the hotel:foo and email:bar').some((s) => s.href)).toBe(false);
});
});
+68
View File
@@ -0,0 +1,68 @@
// Feedback client-side limits and the attachment pre-upload gate. The gate is by
// file extension only (the UI does not list the allowed types and never uploads a
// rejected file); the server re-checks the same limits as the trust boundary.
/** MAX_BODY caps the feedback message length, in characters (matches the backend). */
export const MAX_BODY = 1024;
/** MAX_ATTACHMENT_BYTES caps a single attachment's size (matches the backend). */
export const MAX_ATTACHMENT_BYTES = 1_000_000;
// Allowed attachment extensions, mirrored from the backend allow-list.
const ALLOWED_EXT = new Set([
'png', 'jpg', 'jpeg', 'webp', 'gif', // images
'pdf', 'txt', 'log', 'doc', 'docx', 'rtf', 'zip', 'gz', '7z',
]);
/**
* attachmentError validates a picked file by name and size, returning 'type' for a
* disallowed (or missing) extension, 'size' for a too-large file, or '' when it is
* acceptable. The caller shows a single generic "cannot attach" message regardless
* of which non-empty reason it is, so the allowed types are never revealed.
*/
export function attachmentError(name: string, size: number): '' | 'type' | 'size' {
const dot = name.lastIndexOf('.');
const ext = dot >= 0 ? name.slice(dot + 1).toLowerCase() : '';
if (!ext || !ALLOWED_EXT.has(ext)) return 'type';
if (size > MAX_ATTACHMENT_BYTES) return 'size';
return '';
}
/** ReplySegment is one run of the operator reply: plain text, or a link when href is set. */
export interface ReplySegment {
text: string;
href?: string;
}
// Links are recognised only for an explicit, whitelisted scheme, so dangerous ones
// (javascript:, data:, vbscript:) are never turned into an href. http/https/ftp use
// "scheme://host…"; mailto/tel use "scheme:rest". Matching stops at whitespace or '<'.
// The reply is operator-authored, so explicit schemes (no bare-domain guessing) are
// enough — which keeps this a few lines with no dependency.
const URL_RE = /\b(?:(?:https?|ftp):\/\/|(?:mailto|tel):)[^\s<]+/gi;
// Trailing punctuation that should not be part of the URL (kept as following text).
const TRAILING_RE = /[.,!?;:)\]}'"»]+$/;
/**
* linkify splits the operator reply into segments, marking http(s) URLs as links so
* the UI renders them as anchors (opened in a new tab) while everything else stays
* plain, escaped text. It never emits markup — the caller renders each segment with
* the framework's escaping — so it cannot introduce XSS.
*/
export function linkify(text: string): ReplySegment[] {
const out: ReplySegment[] = [];
let last = 0;
for (const m of text.matchAll(URL_RE)) {
const start = m.index;
let url = m[0];
const trail = TRAILING_RE.exec(url)?.[0] ?? '';
if (trail) url = url.slice(0, url.length - trail.length);
if (start > last) out.push({ text: text.slice(last, start) });
out.push({ text: url, href: url });
if (trail) out.push({ text: trail });
last = start + m[0].length;
}
if (last < text.length) out.push({ text: text.slice(last) });
if (out.length === 0) out.push({ text });
return out;
}
+6 -1
View File
@@ -22,8 +22,13 @@ if (isMock && typeof window !== 'undefined') {
};
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
// attaches a robot on a timer).
(window as unknown as { __mock?: { joinOpponent(): void; joinOpponentSilently(): void } }).__mock = {
(
window as unknown as {
__mock?: { joinOpponent(): void; joinOpponentSilently(): void; adminReply(): void };
}
).__mock = {
joinOpponent: () => (gateway as MockGateway).joinPendingOpponent(),
joinOpponentSilently: () => (gateway as MockGateway).joinPendingOpponentSilently(),
adminReply: () => (gateway as MockGateway).mockAdminReply(),
};
}
+11
View File
@@ -170,6 +170,17 @@ export const en = {
'about.description': 'A multiplatform Scrabble game.',
'about.version': 'Version {v}',
'feedback.title': 'Feedback',
'feedback.placeholder': 'Describe the problem or share your suggestion…',
'feedback.attach': 'Attach file',
'feedback.removeFile': 'Remove',
'feedback.send': 'Send',
'feedback.fileRejected': 'This file cant be attached.',
'feedback.sent': 'Your message has been sent.',
'feedback.waiting': 'We are reviewing your last message.',
'feedback.banned': 'Feedback submission is unavailable.',
'feedback.replyTitle': 'Reply to your last message',
'landing.tagline': 'Play Scrabble with friends or a smart robot — in your browser or on Telegram.',
'landing.playTelegram': 'Play in Telegram',
+11
View File
@@ -171,6 +171,17 @@ export const ru: Record<MessageKey, string> = {
'about.description': 'Мультиплатформенная игра в скрабл.',
'about.version': 'Версия {v}',
'feedback.title': 'Обратная связь',
'feedback.placeholder': 'Опишите проблему или поделитесь предложением…',
'feedback.attach': 'Прикрепить файл',
'feedback.removeFile': 'Удалить',
'feedback.send': 'Отправить',
'feedback.fileRejected': 'Этот файл нельзя прикрепить.',
'feedback.sent': 'Ваше сообщение отправлено',
'feedback.waiting': 'Ожидаем рассмотрения вашего последнего обращения',
'feedback.banned': 'Отправка обратной связи недоступна',
'feedback.replyTitle': 'Ответ на ваше последнее сообщение',
'landing.tagline': 'Играй в Скрэббл с друзьями или умным роботом — в браузере или в Telegram.',
'landing.playTelegram': 'Играть в Telegram',
+32
View File
@@ -16,6 +16,8 @@ import type {
BlockStatus,
ChatMessage,
EvalResult,
FeedbackReply,
FeedbackState,
FriendCode,
GameList,
GcgExport,
@@ -89,6 +91,11 @@ export class MockGateway implements GatewayClient {
private readonly profile: Profile = { ...PROFILE };
private readonly subs = new Set<(e: PushEvent) => void>();
private pendingMatch: string | null = null;
// Feedback slice: a submission marks a message pending (blocks resend); mockAdminReply
// clears it and raises the badge.
private feedbackPending = false;
private feedbackReply: FeedbackReply | null = null;
private feedbackReplyUnread = false;
// The most recently opened auto-match game still awaiting an opponent, for the e2e join hook.
private openGameId: string | null = null;
private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
@@ -409,6 +416,31 @@ export class MockGateway implements GatewayClient {
async chatList(gameId: string): Promise<ChatMessage[]> {
return [...this.game(gameId).chat];
}
async feedbackSubmit(_body: string, _attachment: Uint8Array | null, _attachmentName: string, _channel: string): Promise<void> {
this.feedbackPending = true;
this.feedbackReply = null; // a new message: the previous operator reply no longer shows
}
async feedbackGet(): Promise<FeedbackState> {
this.feedbackReplyUnread = false; // fetching the screen delivers (reads) the reply
return {
canSend: !this.feedbackPending,
blockedReason: this.feedbackPending ? 'pending' : '',
reply: this.feedbackReply,
};
}
async feedbackUnread(): Promise<boolean> {
return this.feedbackReplyUnread;
}
// mockAdminReply simulates an operator reply: it clears the pending message, sets the
// reply and raises the badge via a live admin_reply notification. e2e hook:
// window.__mock.adminReply.
mockAdminReply(body = 'Thanks — see https://example.com/help for details.'): void {
this.feedbackPending = false;
this.feedbackReply = { body, repliedAtUnix: Math.floor(Date.now() / 1000) };
this.feedbackReplyUnread = true;
this.emit({ kind: 'notify', sub: 'admin_reply' });
}
async nudge(gameId: string): Promise<ChatMessage> {
const g = this.game(gameId);
const msg: ChatMessage = {
+17
View File
@@ -105,6 +105,23 @@ export interface ChatMessage {
createdAtUnix: number;
}
/** FeedbackReply is the operator's answer shown on the feedback screen. */
export interface FeedbackReply {
body: string;
repliedAtUnix: number;
}
/**
* FeedbackState is the feedback screen state. blockedReason is "" (can send),
* "pending" (a previous message is still under review) or "banned"; reply is the
* operator's answer to show, or null.
*/
export interface FeedbackState {
canSend: boolean;
blockedReason: string;
reply: FeedbackReply | null;
}
export interface Profile {
userId: string;
displayName: string;
+3
View File
@@ -13,6 +13,7 @@ export type RouteName =
| 'settings'
| 'about'
| 'friends'
| 'feedback'
| 'stats'
| 'notfound';
@@ -53,6 +54,8 @@ export function parse(hash: string): Route {
return { name: 'about', params: {} };
case 'friends':
return { name: 'friends', params: {} };
case 'feedback':
return { name: 'feedback', params: {} };
case 'stats':
return { name: 'stats', params: {} };
default:
+9
View File
@@ -143,6 +143,15 @@ export function createTransport(baseUrl: string): GatewayClient {
async nudge(id) {
return codec.decodeChatMessage(await exec('chat.nudge', codec.encodeGameAction(id)));
},
async feedbackSubmit(body, attachment, attachmentName, channel) {
await exec('feedback.submit', codec.encodeFeedbackSubmit(body, attachment, attachmentName, channel));
},
async feedbackGet() {
return codec.decodeFeedbackState(await exec('feedback.get', codec.empty()));
},
async feedbackUnread() {
return codec.decodeFeedbackUnread(await exec('feedback.unread', codec.empty()));
},
async friendsList() {
return codec.decodeFriendList(await exec('friends.list', codec.empty()));
+30
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { t } from '../lib/i18n/index.svelte';
import { app } from '../lib/app.svelte';
import { navigate } from '../lib/router.svelte';
import { aboutContent } from '../lib/aboutContent';
// The auto-match move clock (mirrors backend game.DefaultTurnTimeout = 24h).
@@ -31,6 +32,12 @@
</section>
<p class="muted">{t('about.version', { v: version })}</p>
{#if !(app.profile?.isGuest ?? true)}
<button class="feedback" onclick={() => navigate('/feedback')}>
{t('feedback.title')}{#if app.feedbackReplyUnread}<span class="fbadge">1</span>{/if}
</button>
{/if}
</div>
<style>
@@ -73,4 +80,27 @@
font-size: 0.9rem;
margin: 0;
}
.feedback {
align-self: flex-start;
padding: 12px 18px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius);
font-weight: 600;
}
/* The reply badge sits on the accent button, so it inverts the accent colours. */
.fbadge {
display: inline-block;
margin-left: 8px;
min-width: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--accent-text);
color: var(--accent);
font-size: 0.78rem;
font-weight: 700;
line-height: 1.55;
text-align: center;
}
</style>
+221
View File
@@ -0,0 +1,221 @@
<script lang="ts">
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import { t } from '../lib/i18n/index.svelte';
import { app, handleError } from '../lib/app.svelte';
import { gateway } from '../lib/gateway';
import { connection } from '../lib/connection.svelte';
import { clientChannel } from '../lib/channel';
import { attachmentError, linkify, MAX_BODY } from '../lib/feedback';
import type { FeedbackState } from '../lib/model';
// The feedback screen: a message (+ optional single attachment) the player sends to
// the operators, plus the operator's last reply. The form is disabled while a
// previous message is under review or the account is banned from feedback.
let view = $state<FeedbackState | null>(null);
let body = $state('');
let file = $state<File | null>(null);
let fileError = $state(false);
let sending = $state(false);
let sent = $state(false);
let fileInput: HTMLInputElement;
const enabled = $derived(view?.canSend ?? false);
const reason = $derived(view?.blockedReason ?? '');
const canSend = $derived(enabled && body.trim().length > 0 && !sending && connection.online);
// The operator reply, split into text + http(s) links for rendering.
const replySegments = $derived(view?.reply ? linkify(view.reply.body) : []);
onMount(() => void load());
async function load() {
try {
view = await gateway.feedbackGet();
// Fetching the screen delivers (reads) any pending reply: clear the badge.
app.feedbackReplyUnread = false;
} catch (e) {
handleError(e);
}
}
function pickFile(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const f = input.files?.[0] ?? null;
if (!f) return;
if (attachmentError(f.name, f.size) !== '') {
fileError = true;
file = null;
input.value = '';
return;
}
fileError = false;
file = f;
}
function removeFile() {
file = null;
fileError = false;
if (fileInput) fileInput.value = '';
}
async function send() {
if (!canSend) return;
sending = true;
try {
let bytes: Uint8Array | null = null;
let name = '';
if (file) {
bytes = new Uint8Array(await file.arrayBuffer());
name = file.name;
}
await gateway.feedbackSubmit(body.trim(), bytes, name, clientChannel());
body = '';
removeFile();
sent = true;
// The message is now under review: block resending until the operator handles it.
// A new message has no reply yet, so the previous operator reply stops showing.
view = { canSend: false, blockedReason: 'pending', reply: null };
} catch (e) {
handleError(e);
} finally {
sending = false;
}
}
</script>
<Screen title={t('feedback.title')} back="/about">
<div class="page">
<textarea
bind:value={body}
maxlength={MAX_BODY}
rows="6"
placeholder={t('feedback.placeholder')}
disabled={!enabled}
></textarea>
<div class="row">
<input bind:this={fileInput} type="file" class="hidden" onchange={pickFile} />
{#if file}
<span class="fname">{file.name}</span>
<button type="button" class="neutral" onclick={removeFile}>{t('feedback.removeFile')}</button>
{:else}
<button
type="button"
class="neutral attach"
aria-label={t('feedback.attach')}
title={t('feedback.attach')}
onclick={() => fileInput.click()}
disabled={!enabled}>📎</button>
{/if}
<button type="button" class="cta" onclick={send} disabled={!canSend}>{t('feedback.send')}</button>
</div>
{#if fileError}<p class="err">{t('feedback.fileRejected')}</p>{/if}
{#if sent}
<p class="ok">{t('feedback.sent')}</p>
{:else if reason === 'pending'}
<p class="muted">{t('feedback.waiting')}</p>
{:else if reason === 'banned'}
<p class="muted">{t('feedback.banned')}</p>
{/if}
{#if view?.reply}
<section class="reply">
<h2>{t('feedback.replyTitle')}</h2>
<p class="replybody">{#each replySegments as seg, i (i)}{#if seg.href}<a href={seg.href} target="_blank" rel="noopener noreferrer">{seg.text}</a>{:else}{seg.text}{/if}{/each}</p>
</section>
{/if}
</div>
</Screen>
<style>
.page {
padding: var(--pad);
display: flex;
flex-direction: column;
gap: 12px;
}
textarea {
width: 100%;
box-sizing: border-box;
resize: vertical;
min-height: 7rem;
font: inherit;
}
.row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.fname {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-muted);
font-size: 0.9rem;
}
.cta {
margin-left: auto;
padding: 12px 18px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius);
font-weight: 600;
}
.neutral {
padding: 12px 14px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text);
border-radius: var(--radius);
font-weight: 600;
}
.cta:disabled,
.neutral:disabled {
opacity: 0.5;
}
.attach {
font-size: 1.15rem;
line-height: 1;
}
.hidden {
display: none;
}
.err {
color: var(--danger);
margin: 0;
}
.ok {
color: var(--accent);
margin: 0;
}
.muted {
color: var(--text-muted);
margin: 0;
}
.reply {
display: flex;
flex-direction: column;
gap: 6px;
border-top: 1px solid var(--surface-2);
padding-top: 10px;
}
.reply h2 {
font-size: 1.05rem;
margin: 0;
color: var(--text-muted);
}
.replybody {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
.replybody a {
color: var(--accent);
}
</style>
+7 -3
View File
@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import TabBar from '../components/TabBar.svelte';
import { app, handleError } from '../lib/app.svelte';
import { app, handleError, refreshFeedbackBadge } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte';
@@ -18,15 +18,19 @@
let incoming = $state<AccountRef[]>([]);
const guest = $derived(app.profile?.isGuest ?? true);
// The lobby ⚙️ badge is the shared count: incoming friend requests plus an awaiting
// operator feedback reply (the Settings → Info badge folds in here).
const settingsBadge = $derived(app.notifications + (app.feedbackReplyUnread ? 1 : 0));
async function load() {
try {
games = (await gateway.gamesList()).games;
if (!guest) {
[invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
// The ⚙️ badge counts only what lives behind it (incoming friend requests);
// The ⚙️ badge counts incoming friend requests plus an awaiting feedback reply;
// invitations surface in their own lobby section above.
app.notifications = incoming.length;
void refreshFeedbackBadge();
}
setLobby({ games, invitations, incoming });
// Warm the cache for the ongoing games so opening one from the lobby is instant. The list
@@ -234,7 +238,7 @@
<span class="sq">📊</span><span class="lbl">{t('lobby.stats')}</span>
</button>
<button class="tab" onclick={() => navigate('/settings')}>
<span class="sq">⚙️{#if app.notifications > 0}<span class="badge">{app.notifications}</span>{/if}</span>
<span class="sq">⚙️{#if settingsBadge > 0}<span class="badge">{settingsBadge}</span>{/if}</span>
<span class="lbl">{t('lobby.settings')}</span>
</button>
</TabBar>
+1 -1
View File
@@ -57,7 +57,7 @@
</button>
{/if}
<button class="tab" class:active={tab === 'about'} onclick={() => (tab = 'about')}>
<span class="sq" aria-hidden="true"></span><span class="lbl">{t('about.tab')}</span>
<span class="sq" aria-hidden="true">{#if app.feedbackReplyUnread}<span class="badge">1</span>{/if}</span><span class="lbl">{t('about.tab')}</span>
</button>
</TabBar>
{/snippet}