From 419ea11b1402ce2f8de0a2c71dc1645c1b92b0fa Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 12:23:10 +0200 Subject: [PATCH 1/7] feat(feedback): in-app user feedback with admin review and account roles User-facing Feedback screen (Settings -> Info, registered accounts only): a message (<=1024 runes) plus one optional attachment, an anti-spam gate (one unreviewed message at a time), and the operator's inline reply with a Settings/Info badge. Server-rendered admin console section (/_gm/feedback): unread/read/archived queue with per-user search, detail with read/reply/ archive/delete/delete-all, safe attachment serving (nosniff, images inline via , others download-only). Introduces account_roles, the first per-account role table; feedback_banned blocks only feedback submission, granted/revoked from /users and the delete-with-block action. - migration 00004_feedback (feedback_messages + account_roles) + jetgen - backend internal/feedback (store+service), internal/account/roles.go - wire: FlatBuffers feedback.submit/get/unread; gateway guest gate (Op.NonGuest, is_guest via session resolve) -> guest_forbidden before any backend call - reply push reuses NotificationEvent with a new admin_reply sub-kind - UI: /feedback route + screen, attachment picker, badge, channel detection, i18n - tests: feedback unit (Go+UI), gateway guest-gate, inttest lifecycle, e2e - docs: PLAN stage 19, ARCHITECTURE s15, FUNCTIONAL(+ru), TESTING, READMEs --- PLAN.md | 45 +++ backend/README.md | 1 + backend/cmd/backend/main.go | 4 + backend/internal/account/roles.go | 92 +++++ .../internal/adminconsole/assets/console.css | 5 + backend/internal/adminconsole/render_test.go | 3 + .../adminconsole/templates/layout.gohtml | 1 + .../templates/pages/dashboard.gohtml | 1 + .../templates/pages/feedback.gohtml | 38 ++ .../templates/pages/feedback_detail.gohtml | 46 +++ .../templates/pages/user_detail.gohtml | 19 +- backend/internal/adminconsole/views.go | 60 +++ backend/internal/feedback/attachment.go | 54 +++ backend/internal/feedback/attachment_test.go | 81 ++++ backend/internal/feedback/service.go | 256 ++++++++++++ backend/internal/feedback/store.go | 370 ++++++++++++++++++ backend/internal/inttest/feedback_test.go | 227 +++++++++++ backend/internal/notify/notify.go | 4 + .../jet/backend/model/account_roles.go | 19 + .../jet/backend/model/feedback_messages.go | 29 ++ .../jet/backend/table/account_roles.go | 84 ++++ .../jet/backend/table/feedback_messages.go | 114 ++++++ .../jet/backend/table/table_use_schema.go | 2 + .../postgres/migrations/00004_feedback.sql | 54 +++ backend/internal/server/dto.go | 6 +- backend/internal/server/handlers.go | 20 + .../internal/server/handlers_admin_console.go | 19 + .../server/handlers_admin_feedback.go | 293 ++++++++++++++ backend/internal/server/handlers_auth.go | 9 +- backend/internal/server/handlers_feedback.go | 105 +++++ backend/internal/server/server.go | 6 + docs/ARCHITECTURE.md | 39 ++ docs/FUNCTIONAL.md | 21 + docs/FUNCTIONAL_ru.md | 22 ++ docs/TESTING.md | 9 + docs/UI_DESIGN.md | 12 +- gateway/internal/backendclient/api.go | 10 +- .../internal/backendclient/api_feedback.go | 56 +++ gateway/internal/connectsrv/server.go | 28 +- gateway/internal/connectsrv/server_test.go | 39 ++ gateway/internal/session/cache.go | 42 +- gateway/internal/session/cache_test.go | 33 +- gateway/internal/transcode/encode.go | 31 ++ gateway/internal/transcode/transcode.go | 40 ++ pkg/fbs/scrabble.fbs | 34 ++ pkg/fbs/scrabblefb/FeedbackReply.go | 75 ++++ pkg/fbs/scrabblefb/FeedbackState.go | 91 +++++ pkg/fbs/scrabblefb/FeedbackSubmitRequest.go | 122 ++++++ pkg/fbs/scrabblefb/FeedbackUnread.go | 64 +++ ui/e2e/feedback.spec.ts | 45 +++ ui/src/App.svelte | 10 +- ui/src/gen/fbs/scrabblefb.ts | 4 + ui/src/gen/fbs/scrabblefb/feedback-reply.ts | 58 +++ ui/src/gen/fbs/scrabblefb/feedback-state.ts | 64 +++ .../fbs/scrabblefb/feedback-submit-request.ts | 104 +++++ ui/src/gen/fbs/scrabblefb/feedback-unread.ts | 46 +++ ui/src/lib/app.svelte.ts | 26 ++ ui/src/lib/channel.test.ts | 20 + ui/src/lib/channel.ts | 30 ++ ui/src/lib/client.ts | 9 + ui/src/lib/codec.test.ts | 60 +++ ui/src/lib/codec.ts | 34 ++ ui/src/lib/feedback.test.ts | 24 ++ ui/src/lib/feedback.ts | 29 ++ ui/src/lib/gateway.ts | 7 +- ui/src/lib/i18n/en.ts | 11 + ui/src/lib/i18n/ru.ts | 11 + ui/src/lib/mock/client.ts | 31 ++ ui/src/lib/model.ts | 17 + ui/src/lib/routeparse.ts | 3 + ui/src/lib/transport.ts | 9 + ui/src/screens/About.svelte | 5 + ui/src/screens/Feedback.svelte | 189 +++++++++ ui/src/screens/Lobby.svelte | 10 +- ui/src/screens/SettingsHub.svelte | 2 +- 75 files changed, 3638 insertions(+), 55 deletions(-) create mode 100644 backend/internal/account/roles.go create mode 100644 backend/internal/adminconsole/templates/pages/feedback.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/feedback_detail.gohtml create mode 100644 backend/internal/feedback/attachment.go create mode 100644 backend/internal/feedback/attachment_test.go create mode 100644 backend/internal/feedback/service.go create mode 100644 backend/internal/feedback/store.go create mode 100644 backend/internal/inttest/feedback_test.go create mode 100644 backend/internal/postgres/jet/backend/model/account_roles.go create mode 100644 backend/internal/postgres/jet/backend/model/feedback_messages.go create mode 100644 backend/internal/postgres/jet/backend/table/account_roles.go create mode 100644 backend/internal/postgres/jet/backend/table/feedback_messages.go create mode 100644 backend/internal/postgres/migrations/00004_feedback.sql create mode 100644 backend/internal/server/handlers_admin_feedback.go create mode 100644 backend/internal/server/handlers_feedback.go create mode 100644 gateway/internal/backendclient/api_feedback.go create mode 100644 pkg/fbs/scrabblefb/FeedbackReply.go create mode 100644 pkg/fbs/scrabblefb/FeedbackState.go create mode 100644 pkg/fbs/scrabblefb/FeedbackSubmitRequest.go create mode 100644 pkg/fbs/scrabblefb/FeedbackUnread.go create mode 100644 ui/e2e/feedback.spec.ts create mode 100644 ui/src/gen/fbs/scrabblefb/feedback-reply.ts create mode 100644 ui/src/gen/fbs/scrabblefb/feedback-state.ts create mode 100644 ui/src/gen/fbs/scrabblefb/feedback-submit-request.ts create mode 100644 ui/src/gen/fbs/scrabblefb/feedback-unread.ts create mode 100644 ui/src/lib/channel.test.ts create mode 100644 ui/src/lib/channel.ts create mode 100644 ui/src/lib/feedback.test.ts create mode 100644 ui/src/lib/feedback.ts create mode 100644 ui/src/screens/Feedback.svelte diff --git a/PLAN.md b/PLAN.md index 7ccf1c1..45badb9 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 `` (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 diff --git a/backend/README.md b/backend/README.md index ba62f5d..5c7bc03 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 81d2cb2..a0185d8 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -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, diff --git a/backend/internal/account/roles.go b/backend/internal/account/roles.go new file mode 100644 index 0000000..8c2f432 --- /dev/null +++ b/backend/internal/account/roles.go @@ -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() +} diff --git a/backend/internal/adminconsole/assets/console.css b/backend/internal/adminconsole/assets/console.css index 0c802d7..8002a48 100644 --- a/backend/internal/adminconsole/assets/console.css +++ b/backend/internal/adminconsole/assets/console.css @@ -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; } diff --git a/backend/internal/adminconsole/render_test.go b/backend/internal/adminconsole/render_test.go index 8d9d7b9..b6539bb 100644 --- a/backend/internal/adminconsole/render_test.go +++ b/backend/internal/adminconsole/render_test.go @@ -25,6 +25,7 @@ func TestRendererRendersEveryPage(t *testing.T) { {"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya", FlaggedHighRate: true}}, Pager: NewPager(1, 50, 1)}, "high-rate"}, {"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"}, {"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"}, + {"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"}, {"throttled", ThrottledView{ Episodes: []ThrottleEpisodeRow{{Class: "user", Key: "a1", UserID: "a1", Rejected: 1234, FirstSeen: "2026-06-10 12:00", LastSeen: "2026-06-10 12:05"}}, Flagged: []FlaggedAccountRow{{ID: "a1", DisplayName: "Kaya", FlaggedAt: "2026-06-10 12:05"}}, @@ -35,6 +36,8 @@ func TestRendererRendersEveryPage(t *testing.T) { {"game_detail", GameDetailView{ID: "g1", Variant: "scrabble_en", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"}, {"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"}, {"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1"}}, Pager: NewPager(1, 50, 1)}, "good luck"}, + {"feedback", FeedbackView{Items: []FeedbackRow{{ID: "f1", AccountID: "a1", SenderName: "Kaya", Source: "telegram", Channel: "web", HasAttachment: true, Replied: true}}, Status: "unread", Pager: NewPager(1, 50, 1)}, "replied"}, + {"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "ios", Body: "please fix the board", HasAttachment: true, AttachmentName: "shot.png", IsImage: true, Banned: true}, "please fix the board"}, {"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"}, {"dictionary", DictionaryView{ActiveVersion: "v1.0.0", Variants: []VariantVersions{{Variant: "scrabble_en", Versions: []string{"v1.0.0"}}}, Changes: []DictChangeRow{{Variant: "scrabble_en", Word: "qi", Action: "add"}}}, "Update dictionaries"}, {"dictionary_preview", DictionaryPreviewView{Version: "v1.1.0", Token: "0123456789abcdef0123456789abcdef", ActiveVersion: "v1.0.0", Variants: []VariantDiffRow{{Variant: "scrabble_en", AddedCount: 2, RemovedCount: 1, AddedSample: []string{"qi", "za"}, RemovedSample: []string{"xqz"}, RemovedTruncated: true}}}, "v1.1.0"}, diff --git a/backend/internal/adminconsole/templates/layout.gohtml b/backend/internal/adminconsole/templates/layout.gohtml index 770ac90..421cb71 100644 --- a/backend/internal/adminconsole/templates/layout.gohtml +++ b/backend/internal/adminconsole/templates/layout.gohtml @@ -16,6 +16,7 @@ Users Games Complaints + Feedback Messages Throttled Reasons diff --git a/backend/internal/adminconsole/templates/pages/dashboard.gohtml b/backend/internal/adminconsole/templates/pages/dashboard.gohtml index ed835a3..267a74d 100644 --- a/backend/internal/adminconsole/templates/pages/dashboard.gohtml +++ b/backend/internal/adminconsole/templates/pages/dashboard.gohtml @@ -7,6 +7,7 @@

Games

{{.Games}}

Active games

{{.ActiveGames}}

Open complaints

{{.OpenComplaints}}

+

Unread feedback

{{.OpenFeedback}}

Pending dict changes

{{.PendingChanges}}

diff --git a/backend/internal/adminconsole/templates/pages/feedback.gohtml b/backend/internal/adminconsole/templates/pages/feedback.gohtml new file mode 100644 index 0000000..75ce714 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/feedback.gohtml @@ -0,0 +1,38 @@ +{{define "content" -}} +

Feedback

+{{with .Data}} + +
+ +{{if .UserID}}{{end}} + + + +
+ + + +{{range .Items}} + + + + + + + + + +{{else}}{{end}} + +
SenderSourceChannelAttachReplyStateFiled
{{.SenderName}}{{.Source}}{{.Channel}}{{if .HasAttachment}}yes{{else}}{{end}}{{if .Replied}}replied{{else}}{{end}}{{if .Archived}}archived{{else if .Read}}read{{else}}unread{{end}}{{.CreatedAt}}
no feedback
+ +{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml b/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml new file mode 100644 index 0000000..76a4ef4 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml @@ -0,0 +1,46 @@ +{{define "content" -}} +{{with .Data}} +

Feedback

+ +

Message

+
    +
  • From {{.SenderName}} ({{.Source}})
  • +
  • Channel {{.Channel}}
  • +
  • IP {{if .IP}}{{.IP}}{{else}}none{{end}}
  • +
  • Filed {{.CreatedAt}}
  • +
  • State {{if .Archived}}archived{{else if .Read}}read{{else}}unread{{end}}
  • +{{if .Banned}}
  • Feedback sender is banned from feedback
  • {{end}} +
+
{{.Body}}
+{{if .HasAttachment}} +

Attachment

+{{if .IsImage}}

attachment

{{end}} +

download {{.AttachmentName}}

+{{end}} +
+{{if .Replied}} +

Current reply

+
{{.ReplyBody}}
+

sent {{.RepliedAt}}

+
+{{end}} +

{{if .Replied}}Re-reply{{else}}Reply{{end}}

+
+ +
+
+
+

Actions

+
+
+
+ +
+
+
+ +
+
+
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index aa18410..5d4b14b 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -1,7 +1,7 @@ {{define "content" -}} {{with .Data}}

{{.DisplayName}}

- +

Account

    @@ -67,6 +67,23 @@
+

Roles

+{{$id := .ID}} +{{if .Roles}} + + + +{{range .Roles}} + +{{end}} + +
Role
{{.}}
+{{else}}

no roles

{{end}} +
+ +
+
+
{{if .MoveChart}}

Move timing

Think time per move number across all games — min · mean · max.

diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index 4a3ef41..7eb2a17 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -42,6 +42,7 @@ type DashboardView struct { Games int ActiveGames int OpenComplaints int + OpenFeedback int PendingChanges int // ActiveVersion is the dictionary version new games pin (the persisted active // version), distinct from the per-variant resident versions. @@ -142,6 +143,10 @@ type UserDetailView struct { // form; Reasons is the operator-editable reason picklist offered in the block form. Suspension SuspensionView Reasons []ReasonOption + // Roles is the account's current roles (each revocable); KnownRoles is the set the + // grant form offers. The first role is the feedback ban (see internal/account). + Roles []string + KnownRoles []string } // SuspensionView is an account's current manual-block state shown on the user card: whether it @@ -362,3 +367,58 @@ type MessageView struct { Body string Back string } + +// FeedbackView is the paginated user-feedback queue. Status is the active +// unread/read/archived filter; NameMask/ExtMask are the sender glob filters; +// UserID pins the list to one account (the per-user link from /users); +// FilterQuery is the active filters URL-encoded for the pager links (already +// escaped, hence template.URL — see UsersView.FilterQuery). +type FeedbackView struct { + Items []FeedbackRow + Status string + NameMask string + ExtMask string + UserID string + Pager Pager + FilterQuery template.URL +} + +// FeedbackRow is one feedback message in the queue: its sender (linked to the user +// card), source, channel, whether it has an attachment / a reply, its state and +// time. +type FeedbackRow struct { + ID string + AccountID string + SenderName string + Source string + Channel string + HasAttachment bool + Read bool + Replied bool + Archived bool + CreatedAt string +} + +// FeedbackDetailView is one feedback message with its body, attachment, state and +// the reply / archive / delete forms. Body, AttachmentName, SenderName and IP are +// user-controlled and rendered as plain auto-escaped text. IsImage gates the inline +// preview; Banned shows whether the sender already holds the feedback ban. +type FeedbackDetailView struct { + ID string + AccountID string + SenderName string + Source string + Channel string + IP string + Body string + HasAttachment bool + AttachmentName string + IsImage bool + Read bool + Archived bool + Replied bool + ReplyBody string + RepliedAt string + CreatedAt string + Banned bool +} diff --git a/backend/internal/feedback/attachment.go b/backend/internal/feedback/attachment.go new file mode 100644 index 0000000..50c36be --- /dev/null +++ b/backend/internal/feedback/attachment.go @@ -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 , 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" +} diff --git a/backend/internal/feedback/attachment_test.go b/backend/internal/feedback/attachment_test.go new file mode 100644 index 0000000..4d77c4e --- /dev/null +++ b/backend/internal/feedback/attachment_test.go @@ -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) + } + }) + } +} diff --git a/backend/internal/feedback/service.go b/backend/internal/feedback/service.go new file mode 100644 index 0000000..2f1eed1 --- /dev/null +++ b/backend/internal/feedback/service.go @@ -0,0 +1,256 @@ +package feedback + +import ( + "context" + "errors" + "net/netip" + "strings" + "time" + "unicode/utf8" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/notify" +) + +const ( + // maxBodyRunes caps a feedback message (and an operator reply) length. + maxBodyRunes = 1024 + // replyVisibleFor is how long an operator reply stays shown to the player after + // it is delivered (read). + replyVisibleFor = 7 * 24 * time.Hour +) + +// Submit / reply validation errors. The transport layer maps them to stable result +// codes; the UI maps those to the messages shown on the feedback screen. +var ( + ErrEmptyMessage = errors.New("feedback: empty message") + ErrMessageTooLong = errors.New("feedback: message too long") + ErrAttachmentTooLarge = errors.New("feedback: attachment too large") + ErrAttachmentType = errors.New("feedback: attachment type not allowed") + ErrGuestForbidden = errors.New("feedback: guests cannot submit feedback") + ErrBanned = errors.New("feedback: account is banned from feedback") + ErrPendingReview = errors.New("feedback: previous message still pending review") +) + +// validChannels enumerates the submitting platforms a client may report; anything +// else is normalised to "web" (the channel is informational, never a gate). +var validChannels = map[string]bool{"telegram": true, "ios": true, "android": true, "web": true} + +// Service is the feedback domain: the only writer of feedback_messages. It reads +// accounts for the guest/role gates and publishes the reply notification. +type Service struct { + store *Store + accounts *account.Store + pub notify.Publisher + now func() time.Time +} + +// NewService constructs a Service. store owns feedback_messages; accounts supplies +// the guest flag and the feedback-ban role. +func NewService(store *Store, accounts *account.Store) *Service { + return &Service{ + store: store, + accounts: accounts, + pub: notify.Nop{}, + now: func() time.Time { return time.Now().UTC() }, + } +} + +// SetNotifier installs the live-event publisher used to push the "you have a reply" +// signal to the player. It must be called during startup wiring; the default is +// notify.Nop (no live events). +func (svc *Service) SetNotifier(p notify.Publisher) { + if p != nil { + svc.pub = p + } +} + +// Submit stores a feedback message from accountID. It rejects guests, feedback- +// banned accounts and a sender who still has a message pending review, then +// validates the body (non-empty, within the rune limit) and the optional +// attachment (size and extension allow-list). senderIP is the gateway-forwarded +// client IP (validated); channel is the submitting platform. +func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, senderIP string) error { + acc, err := svc.accounts.GetByID(ctx, accountID) + if err != nil { + return err + } + if acc.IsGuest { + return ErrGuestForbidden + } + banned, err := svc.accounts.HasRole(ctx, accountID, account.RoleFeedbackBanned) + if err != nil { + return err + } + if banned { + return ErrBanned + } + pending, err := svc.store.HasUnread(ctx, accountID) + if err != nil { + return err + } + if pending { + return ErrPendingReview + } + body = strings.TrimSpace(body) + if body == "" { + return ErrEmptyMessage + } + if utf8.RuneCountInString(body) > maxBodyRunes { + return ErrMessageTooLong + } + if len(attachment) > 0 { + if len(attachment) > maxAttachmentBytes { + return ErrAttachmentTooLarge + } + if !AllowedAttachment(attachmentName) { + return ErrAttachmentType + } + } else { + attachmentName = "" // a name without bytes carries no attachment + } + _, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, normalizeChannel(channel), parseIP(senderIP)) + return err +} + +// State is the player's feedback screen state. Reason is "" (can send), "pending" +// (a previous message is unreviewed) or "banned". Reply is the operator's answer to +// show, or nil. Fetching it delivers any pending replies (delivery counts as read), +// clearing the badge. +type State struct { + CanSend bool + BlockedReason string + Reply *Reply +} + +// Reply is the operator's answer shown back to the player. +type Reply struct { + Body string + RepliedAt time.Time +} + +// State computes the feedback screen state for accountID and marks any pending +// replies delivered. +func (svc *Service) State(ctx context.Context, accountID uuid.UUID) (State, error) { + banned, err := svc.accounts.HasRole(ctx, accountID, account.RoleFeedbackBanned) + if err != nil { + return State{}, err + } + pending, err := svc.store.HasUnread(ctx, accountID) + if err != nil { + return State{}, err + } + st := State{CanSend: !banned && !pending} + switch { + case banned: + st.BlockedReason = "banned" + case pending: + st.BlockedReason = "pending" + } + vr, ok, err := svc.store.LatestVisibleReply(ctx, accountID, svc.now().Add(-replyVisibleFor)) + if err != nil { + return State{}, err + } + if ok { + st.Reply = &Reply{Body: vr.Body, RepliedAt: vr.RepliedAt} + } + // Opening the screen delivers every pending reply; mark them read to clear the + // badge (only the latest is shown, but all are now delivered). + if err := svc.store.MarkRepliesDelivered(ctx, accountID, svc.now()); err != nil { + return State{}, err + } + return st, nil +} + +// ReplyUnread reports whether the account has an operator reply not yet delivered — +// the lobby/Info badge condition. It has no side effect (the lobby may poll it). +func (svc *Service) ReplyUnread(ctx context.Context, accountID uuid.UUID) (bool, error) { + return svc.store.HasUnreadReply(ctx, accountID) +} + +// AdminList returns the filtered console feedback list, paginated. +func (svc *Service) AdminList(ctx context.Context, f AdminFilter, limit, offset int) ([]AdminRow, error) { + return svc.store.AdminList(ctx, f, limit, offset) +} + +// AdminCount counts the filtered console feedback list. +func (svc *Service) AdminCount(ctx context.Context, f AdminFilter) (int, error) { + return svc.store.AdminCount(ctx, f) +} + +// AdminGet loads one message for the console detail view, or ErrNotFound. +func (svc *Service) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error) { + return svc.store.AdminGet(ctx, id) +} + +// CountUnread counts the active (unread, not archived) feedback queue for the +// dashboard. +func (svc *Service) CountUnread(ctx context.Context) (int, error) { + return svc.store.CountUnread(ctx) +} + +// Attachment returns a message's file name and bytes, reporting false when absent. +func (svc *Service) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) { + return svc.store.Attachment(ctx, id) +} + +// MarkRead marks a message dealt-with (the manual "read" action). +func (svc *Service) MarkRead(ctx context.Context, id uuid.UUID) error { + return svc.store.MarkRead(ctx, id, svc.now()) +} + +// Reply sets the operator reply on a message (marking it read), then pushes the +// "you have a reply" notification to the player. +func (svc *Service) Reply(ctx context.Context, id uuid.UUID, body string) error { + body = strings.TrimSpace(body) + if body == "" { + return ErrEmptyMessage + } + if utf8.RuneCountInString(body) > maxBodyRunes { + return ErrMessageTooLong + } + accountID, err := svc.store.Reply(ctx, id, body, svc.now()) + if err != nil { + return err + } + svc.pub.Publish(notify.Notification(accountID, notify.NotifyAdminReply)) + return nil +} + +// Archive files a handled message away (marking it read). +func (svc *Service) Archive(ctx context.Context, id uuid.UUID) error { + return svc.store.Archive(ctx, id, svc.now()) +} + +// Delete physically removes a message and its attachment. +func (svc *Service) Delete(ctx context.Context, id uuid.UUID) error { + return svc.store.Delete(ctx, id) +} + +// DeleteAllByAccount physically removes every message of an account. +func (svc *Service) DeleteAllByAccount(ctx context.Context, accountID uuid.UUID) error { + return svc.store.DeleteAllByAccount(ctx, accountID) +} + +// parseIP returns a validated canonical IP string, or nil when raw is empty or not +// a valid address. +func parseIP(raw string) *string { + addr, err := netip.ParseAddr(strings.TrimSpace(raw)) + if err != nil { + return nil + } + canon := addr.String() + return &canon +} + +// normalizeChannel lower-cases and validates the client-reported channel, falling +// back to "web" for anything unrecognised. +func normalizeChannel(c string) string { + c = strings.ToLower(strings.TrimSpace(c)) + if validChannels[c] { + return c + } + return "web" +} diff --git a/backend/internal/feedback/store.go b/backend/internal/feedback/store.go new file mode 100644 index 0000000..7d63cb3 --- /dev/null +++ b/backend/internal/feedback/store.go @@ -0,0 +1,370 @@ +// Package feedback owns user feedback: the flat list of messages a registered +// player sends to the operators (each with an optional single attachment), the +// anti-spam "one pending message at a time" gate, and the operator's inline reply +// delivered back to the player's app. It is modelled on the admin chat-moderation +// surface (internal/social/adminchat.go) and uses raw SQL throughout for the +// attachment bytea, the COALESCE-based "set once" stamps, and the reply-visibility +// window. +package feedback + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" +) + +// ErrNotFound is returned when no feedback message matches the lookup. +var ErrNotFound = errors.New("feedback: not found") + +// Store is the Postgres-backed query surface for feedback_messages. +type Store struct { + db *sql.DB +} + +// NewStore constructs a Store wrapping db. +func NewStore(db *sql.DB) *Store { + return &Store{db: db} +} + +// Insert stores one feedback message from accountID and returns its id. attachment +// is the raw file bytes (nil for none); attachmentName, ip and a non-default +// channel are stored as given. created_at defaults to now() in the database. +func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel string, ip *string) (uuid.UUID, error) { + id, err := uuid.NewV7() + if err != nil { + return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err) + } + var att []byte // a nil []byte binds as bytea NULL + if len(attachment) > 0 { + att = attachment + } + var name *string + if attachmentName != "" { + name = &attachmentName + } + if _, err := s.db.ExecContext(ctx, + `INSERT INTO backend.feedback_messages + (message_id, account_id, body, attachment, attachment_name, channel, sender_ip) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + id, accountID, body, att, name, channel, ip); err != nil { + return uuid.Nil, fmt.Errorf("feedback: insert: %w", err) + } + return id, nil +} + +// HasUnread reports whether the account has any message the operator has not yet +// dealt with (read_at IS NULL) — the anti-spam gate's condition. +func (s *Store) HasUnread(ctx context.Context, accountID uuid.UUID) (bool, error) { + var ok bool + if err := s.db.QueryRowContext(ctx, + `SELECT EXISTS (SELECT 1 FROM backend.feedback_messages WHERE account_id = $1 AND read_at IS NULL)`, + accountID).Scan(&ok); err != nil { + return false, fmt.Errorf("feedback: has-unread %s: %w", accountID, err) + } + return ok, nil +} + +// HasUnreadReply reports whether the account has an operator reply not yet +// delivered to its app (reply_read_at IS NULL) — the badge's condition. +func (s *Store) HasUnreadReply(ctx context.Context, accountID uuid.UUID) (bool, error) { + var ok bool + if err := s.db.QueryRowContext(ctx, + `SELECT EXISTS (SELECT 1 FROM backend.feedback_messages + WHERE account_id = $1 AND reply_body IS NOT NULL AND reply_read_at IS NULL)`, + accountID).Scan(&ok); err != nil { + return false, fmt.Errorf("feedback: has-unread-reply %s: %w", accountID, err) + } + return ok, nil +} + +// VisibleReply is the operator reply shown back to the player: the reply on their +// most recent replied message still inside the visibility window. +type VisibleReply struct { + MessageID uuid.UUID + Body string + RepliedAt time.Time + ReplyReadAt sql.NullTime +} + +// LatestVisibleReply returns the account's most recent message that carries a reply +// still visible to the player — not yet delivered, or delivered after cutoff (one +// week ago). Reports false when there is none. +func (s *Store) LatestVisibleReply(ctx context.Context, accountID uuid.UUID, cutoff time.Time) (VisibleReply, bool, error) { + var vr VisibleReply + var repliedAt sql.NullTime + err := s.db.QueryRowContext(ctx, + `SELECT message_id, COALESCE(reply_body, ''), replied_at, reply_read_at + FROM backend.feedback_messages + WHERE account_id = $1 AND reply_body IS NOT NULL + AND (reply_read_at IS NULL OR reply_read_at > $2) + ORDER BY created_at DESC LIMIT 1`, + accountID, cutoff).Scan(&vr.MessageID, &vr.Body, &repliedAt, &vr.ReplyReadAt) + if errors.Is(err, sql.ErrNoRows) { + return VisibleReply{}, false, nil + } + if err != nil { + return VisibleReply{}, false, fmt.Errorf("feedback: latest visible reply %s: %w", accountID, err) + } + if repliedAt.Valid { + vr.RepliedAt = repliedAt.Time + } + return vr, true, nil +} + +// MarkRepliesDelivered stamps reply_read_at on every not-yet-delivered reply of the +// account (delivery counts as read), clearing the badge when the player opens the +// feedback screen. +func (s *Store) MarkRepliesDelivered(ctx context.Context, accountID uuid.UUID, at time.Time) error { + if _, err := s.db.ExecContext(ctx, + `UPDATE backend.feedback_messages SET reply_read_at = $2 + WHERE account_id = $1 AND reply_body IS NOT NULL AND reply_read_at IS NULL`, + accountID, at); err != nil { + return fmt.Errorf("feedback: mark replies delivered %s: %w", accountID, err) + } + return nil +} + +// MarkRead stamps read_at the first time, marking the message dealt-with without any +// other action; a re-mark keeps the original time. +func (s *Store) MarkRead(ctx context.Context, id uuid.UUID, at time.Time) error { + if _, err := s.db.ExecContext(ctx, + `UPDATE backend.feedback_messages SET read_at = COALESCE(read_at, $2) WHERE message_id = $1`, + id, at); err != nil { + return fmt.Errorf("feedback: mark read %s: %w", id, err) + } + return nil +} + +// Reply sets (or replaces) the operator reply on a message, marks it read, and +// resets its delivery so the player is notified again. Returns the message's +// account_id for the live notification, or ErrNotFound. +func (s *Store) Reply(ctx context.Context, id uuid.UUID, body string, at time.Time) (uuid.UUID, error) { + var accountID uuid.UUID + err := s.db.QueryRowContext(ctx, + `UPDATE backend.feedback_messages + SET reply_body = $2, replied_at = $3, reply_read_at = NULL, read_at = COALESCE(read_at, $3) + WHERE message_id = $1 + RETURNING account_id`, + id, body, at).Scan(&accountID) + if errors.Is(err, sql.ErrNoRows) { + return uuid.Nil, ErrNotFound + } + if err != nil { + return uuid.Nil, fmt.Errorf("feedback: reply %s: %w", id, err) + } + return accountID, nil +} + +// Archive files a handled message away and marks it read. +func (s *Store) Archive(ctx context.Context, id uuid.UUID, at time.Time) error { + if _, err := s.db.ExecContext(ctx, + `UPDATE backend.feedback_messages + SET archived_at = COALESCE(archived_at, $2), read_at = COALESCE(read_at, $2) + WHERE message_id = $1`, + id, at); err != nil { + return fmt.Errorf("feedback: archive %s: %w", id, err) + } + return nil +} + +// Delete physically removes a message (with its attachment). +func (s *Store) Delete(ctx context.Context, id uuid.UUID) error { + if _, err := s.db.ExecContext(ctx, + `DELETE FROM backend.feedback_messages WHERE message_id = $1`, id); err != nil { + return fmt.Errorf("feedback: delete %s: %w", id, err) + } + return nil +} + +// DeleteAllByAccount physically removes every message of an account. +func (s *Store) DeleteAllByAccount(ctx context.Context, accountID uuid.UUID) error { + if _, err := s.db.ExecContext(ctx, + `DELETE FROM backend.feedback_messages WHERE account_id = $1`, accountID); err != nil { + return fmt.Errorf("feedback: delete all %s: %w", accountID, err) + } + return nil +} + +// Attachment returns a message's stored file name and bytes, reporting false when +// the message has no attachment or does not exist. +func (s *Store) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) { + var name string + var data []byte + err := s.db.QueryRowContext(ctx, + `SELECT COALESCE(attachment_name, ''), attachment FROM backend.feedback_messages WHERE message_id = $1`, + id).Scan(&name, &data) + if errors.Is(err, sql.ErrNoRows) { + return "", nil, false, nil + } + if err != nil { + return "", nil, false, fmt.Errorf("feedback: attachment %s: %w", id, err) + } + if data == nil { + return "", nil, false, nil + } + return name, data, true, nil +} + +// AdminMessage is one feedback message in the operator console detail view: the +// message with its sender's resolved display name and source. The attachment bytes +// are not loaded here (served separately); only their presence and name are. +type AdminMessage struct { + ID uuid.UUID + AccountID uuid.UUID + SenderName string + Source string + Body string + Channel string + SenderIP string + HasAttachment bool + AttachmentName string + Read bool + Archived bool + Replied bool + ReplyBody string + RepliedAt time.Time + CreatedAt time.Time +} + +// AdminRow is one feedback message in the console list (a lighter projection). +type AdminRow struct { + ID uuid.UUID + AccountID uuid.UUID + SenderName string + Source string + Channel string + HasAttachment bool + Read bool + Replied bool + Archived bool + CreatedAt time.Time +} + +// AdminFilter narrows the console feedback list. Status is one of "unread" +// (default), "read" or "archived". NameMask/ExtMask are glob masks +// (account.LikePattern) on the sender's display name / any identity's external id; +// AccountID, when set, restricts to one account (the per-user link from /users). +type AdminFilter struct { + Status string + NameMask string + ExtMask string + AccountID uuid.UUID +} + +// feedbackSource projects a sender's source: guest, robot, or its oldest identity +// kind ("—" when it has none). Mirrors social.adminMessageSource. +const feedbackSource = `CASE + WHEN a.is_guest THEN 'guest' + WHEN EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'robot') THEN 'robot' + ELSE COALESCE((SELECT i2.kind FROM backend.identities i2 WHERE i2.account_id = a.account_id ORDER BY i2.created_at ASC LIMIT 1), '—') +END` + +// adminWhere builds the shared WHERE clause and its positional args (from $1). +func adminWhere(f AdminFilter) (string, []any) { + var where string + switch f.Status { + case "archived": + where = `m.archived_at IS NOT NULL` + case "read": + where = `m.read_at IS NOT NULL AND m.archived_at IS NULL` + default: // unread + where = `m.read_at IS NULL AND m.archived_at IS NULL` + } + var args []any + if f.AccountID != uuid.Nil { + args = append(args, f.AccountID) + where += fmt.Sprintf(` AND m.account_id = $%d`, len(args)) + } + if name := account.LikePattern(f.NameMask); name != "" { + args = append(args, name) + where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args)) + } + if ext := account.LikePattern(f.ExtMask); ext != "" { + args = append(args, ext) + where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities ie WHERE ie.account_id = a.account_id AND ie.external_id ILIKE $%d ESCAPE '\')`, len(args)) + } + return where, args +} + +// AdminList returns the filtered console feedback list, newest first, paginated. +func (s *Store) AdminList(ctx context.Context, f AdminFilter, limit, offset int) ([]AdminRow, error) { + where, args := adminWhere(f) + q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.channel, + (m.attachment IS NOT NULL), (m.read_at IS NOT NULL), (m.reply_body IS NOT NULL), (m.archived_at IS NOT NULL), m.created_at + FROM backend.feedback_messages m + JOIN backend.accounts a ON a.account_id = m.account_id + WHERE ` + where + + fmt.Sprintf(` ORDER BY m.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2) + args = append(args, limit, offset) + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("feedback: admin list: %w", err) + } + defer rows.Close() + var out []AdminRow + for rows.Next() { + var r AdminRow + if err := rows.Scan(&r.ID, &r.AccountID, &r.SenderName, &r.Source, &r.Channel, + &r.HasAttachment, &r.Read, &r.Replied, &r.Archived, &r.CreatedAt); err != nil { + return nil, fmt.Errorf("feedback: scan admin row: %w", err) + } + out = append(out, r) + } + return out, rows.Err() +} + +// AdminCount counts the filtered console feedback list, for the pager. +func (s *Store) AdminCount(ctx context.Context, f AdminFilter) (int, error) { + where, args := adminWhere(f) + var n int + q := `SELECT COUNT(*) FROM backend.feedback_messages m JOIN backend.accounts a ON a.account_id = m.account_id WHERE ` + where + if err := s.db.QueryRowContext(ctx, q, args...).Scan(&n); err != nil { + return 0, fmt.Errorf("feedback: admin count: %w", err) + } + return n, nil +} + +// AdminGet loads one message for the console detail view, or ErrNotFound. +func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error) { + var m AdminMessage + var repliedAt sql.NullTime + q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel, + COALESCE(m.sender_ip, ''), (m.attachment IS NOT NULL), COALESCE(m.attachment_name, ''), + (m.read_at IS NOT NULL), (m.archived_at IS NOT NULL), (m.reply_body IS NOT NULL), + COALESCE(m.reply_body, ''), m.replied_at, m.created_at + FROM backend.feedback_messages m + JOIN backend.accounts a ON a.account_id = m.account_id + WHERE m.message_id = $1` + err := s.db.QueryRowContext(ctx, q, id).Scan( + &m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel, + &m.SenderIP, &m.HasAttachment, &m.AttachmentName, + &m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + return AdminMessage{}, ErrNotFound + } + if err != nil { + return AdminMessage{}, fmt.Errorf("feedback: admin get %s: %w", id, err) + } + if repliedAt.Valid { + m.RepliedAt = repliedAt.Time + } + return m, nil +} + +// CountUnread counts the active (unread, not archived) feedback queue, for the +// console dashboard. +func (s *Store) CountUnread(ctx context.Context) (int, error) { + var n int + if err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM backend.feedback_messages WHERE read_at IS NULL AND archived_at IS NULL`, + ).Scan(&n); err != nil { + return 0, fmt.Errorf("feedback: count unread: %w", err) + } + return n, nil +} diff --git a/backend/internal/inttest/feedback_test.go b/backend/internal/inttest/feedback_test.go new file mode 100644 index 0000000..57bec32 --- /dev/null +++ b/backend/internal/inttest/feedback_test.go @@ -0,0 +1,227 @@ +//go:build integration + +package inttest + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/feedback" +) + +func newFeedbackService() *feedback.Service { + return feedback.NewService(feedback.NewStore(testDB), account.NewStore(testDB)) +} + +// latestFeedbackID returns the id of the account's single message (across states). +func latestFeedbackID(t *testing.T, svc *feedback.Service, acc uuid.UUID) uuid.UUID { + t.Helper() + ctx := context.Background() + for _, status := range []string{"unread", "read", "archived"} { + rows, err := svc.AdminList(ctx, feedback.AdminFilter{AccountID: acc, Status: status}, 50, 0) + if err != nil { + t.Fatalf("admin list %s: %v", status, err) + } + if len(rows) > 0 { + return rows[0].ID + } + } + t.Fatal("no feedback message for account") + return uuid.Nil +} + +func TestFeedbackGuestRejected(t *testing.T) { + svc := newFeedbackService() + guest := provisionGuest(t) + if err := svc.Submit(context.Background(), guest, "hi", nil, "", "web", "1.2.3.4"); !errors.Is(err, feedback.ErrGuestForbidden) { + t.Fatalf("guest submit err = %v, want ErrGuestForbidden", err) + } +} + +func TestFeedbackSubmitGateAndReplyLifecycle(t *testing.T) { + ctx := context.Background() + svc := newFeedbackService() + acc := provisionAccount(t) + + if err := svc.Submit(ctx, acc, " please fix the board ", []byte("PNGDATA"), "shot.png", "ios", "9.9.9.9"); err != nil { + t.Fatalf("submit: %v", err) + } + // Anti-spam gate: a second message is refused while the first is unreviewed. + if err := svc.Submit(ctx, acc, "again", nil, "", "web", ""); !errors.Is(err, feedback.ErrPendingReview) { + t.Fatalf("second submit err = %v, want ErrPendingReview", err) + } + if st, err := svc.State(ctx, acc); err != nil { + t.Fatal(err) + } else if st.CanSend || st.BlockedReason != "pending" || st.Reply != nil { + t.Fatalf("state = %+v, want pending / no reply", st) + } + + id := latestFeedbackID(t, svc, acc) + m, err := svc.AdminGet(ctx, id) + if err != nil { + t.Fatalf("admin get: %v", err) + } + if m.Body != "please fix the board" { // trimmed + t.Fatalf("body = %q, want trimmed", m.Body) + } + if !m.HasAttachment || m.AttachmentName != "shot.png" || m.Channel != "ios" || m.SenderIP != "9.9.9.9" { + t.Fatalf("admin message = %+v", m) + } + if name, data, ok, err := svc.Attachment(ctx, id); err != nil || !ok || name != "shot.png" || string(data) != "PNGDATA" { + t.Fatalf("attachment = (%q, %q, %v, %v)", name, data, ok, err) + } + + // Reply: marks the message read (so the player can send again) and is undelivered. + if err := svc.Reply(ctx, id, "We are on it."); err != nil { + t.Fatalf("reply: %v", err) + } + if unread, err := svc.ReplyUnread(ctx, acc); err != nil || !unread { + t.Fatalf("reply unread = %v (err %v), want true", unread, err) + } + st, err := svc.State(ctx, acc) + if err != nil { + t.Fatal(err) + } + if !st.CanSend { + t.Fatal("want canSend true after the message was read via reply") + } + if st.Reply == nil || st.Reply.Body != "We are on it." { + t.Fatalf("reply not shown: %+v", st.Reply) + } + // Fetching the state delivered the reply: the badge clears. + if unread, err := svc.ReplyUnread(ctx, acc); err != nil || unread { + t.Fatalf("reply unread = %v (err %v), want false after State", unread, err) + } + + // The reply is hidden one week after it was delivered. + if _, err := testDB.ExecContext(ctx, + `UPDATE backend.feedback_messages SET reply_read_at = now() - interval '8 days' WHERE message_id = $1`, id); err != nil { + t.Fatalf("age reply: %v", err) + } + if st, err := svc.State(ctx, acc); err != nil { + t.Fatal(err) + } else if st.Reply != nil { + t.Fatalf("reply should be hidden after a week, got %+v", st.Reply) + } +} + +func TestFeedbackBanRole(t *testing.T) { + ctx := context.Background() + svc := newFeedbackService() + accounts := account.NewStore(testDB) + acc := provisionAccount(t) + + if err := accounts.GrantRole(ctx, acc, account.RoleFeedbackBanned); err != nil { + t.Fatalf("grant role: %v", err) + } + if err := svc.Submit(ctx, acc, "hi", nil, "", "web", ""); !errors.Is(err, feedback.ErrBanned) { + t.Fatalf("banned submit err = %v, want ErrBanned", err) + } + if st, err := svc.State(ctx, acc); err != nil { + t.Fatal(err) + } else if st.CanSend || st.BlockedReason != "banned" { + t.Fatalf("state = %+v, want banned", st) + } + // Revoke restores submission (the /users unblock path). + if err := accounts.RevokeRole(ctx, acc, account.RoleFeedbackBanned); err != nil { + t.Fatalf("revoke role: %v", err) + } + if err := svc.Submit(ctx, acc, "hi again", nil, "", "web", ""); err != nil { + t.Fatalf("submit after unban: %v", err) + } +} + +func TestFeedbackValidation(t *testing.T) { + ctx := context.Background() + svc := newFeedbackService() + tests := []struct { + name string + body string + attachment []byte + attachmentName string + want error + }{ + {"empty body", " ", nil, "", feedback.ErrEmptyMessage}, + {"too long", strings.Repeat("a", 1025), nil, "", feedback.ErrMessageTooLong}, + {"bad type", "ok", []byte("x"), "evil.exe", feedback.ErrAttachmentType}, + {"too large", "ok", make([]byte, 1_000_001), "a.png", feedback.ErrAttachmentTooLarge}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + acc := provisionAccount(t) // fresh account so the pending gate never fires first + if err := svc.Submit(ctx, acc, tt.body, tt.attachment, tt.attachmentName, "web", ""); !errors.Is(err, tt.want) { + t.Fatalf("submit err = %v, want %v", err, tt.want) + } + }) + } +} + +func TestFeedbackAdminLifecycle(t *testing.T) { + ctx := context.Background() + svc := newFeedbackService() + acc := provisionAccount(t) + + if err := svc.Submit(ctx, acc, "first report", nil, "", "web", ""); err != nil { + t.Fatalf("submit: %v", err) + } + id := latestFeedbackID(t, svc, acc) + + inCount := func(status string) int { + t.Helper() + n, err := svc.AdminCount(ctx, feedback.AdminFilter{AccountID: acc, Status: status}) + if err != nil { + t.Fatalf("count %s: %v", status, err) + } + return n + } + + if inCount("unread") != 1 || inCount("read") != 0 || inCount("archived") != 0 { + t.Fatalf("after submit: unread=%d read=%d archived=%d", inCount("unread"), inCount("read"), inCount("archived")) + } + // Marking read moves it from the unread queue to the read list. + if err := svc.MarkRead(ctx, id); err != nil { + t.Fatalf("mark read: %v", err) + } + if inCount("unread") != 0 || inCount("read") != 1 { + t.Fatalf("after read: unread=%d read=%d", inCount("unread"), inCount("read")) + } + // Archiving moves it to the archive. + if err := svc.Archive(ctx, id); err != nil { + t.Fatalf("archive: %v", err) + } + if inCount("read") != 0 || inCount("archived") != 1 { + t.Fatalf("after archive: read=%d archived=%d", inCount("read"), inCount("archived")) + } + // Deleting removes it entirely. + if err := svc.Delete(ctx, id); err != nil { + t.Fatalf("delete: %v", err) + } + if inCount("archived") != 0 { + t.Fatalf("after delete: archived=%d, want 0", inCount("archived")) + } +} + +func TestFeedbackDeleteAllByAccount(t *testing.T) { + ctx := context.Background() + svc := newFeedbackService() + acc := provisionAccount(t) + + if err := svc.Submit(ctx, acc, "one", nil, "", "web", ""); err != nil { + t.Fatalf("submit: %v", err) + } + if err := svc.DeleteAllByAccount(ctx, acc); err != nil { + t.Fatalf("delete all: %v", err) + } + // The pending gate is clear again (no messages left), so the account can submit. + if has, err := svc.ReplyUnread(ctx, acc); err != nil || has { + t.Fatalf("reply unread after delete-all = %v (err %v)", has, err) + } + if err := svc.Submit(ctx, acc, "fresh", nil, "", "web", ""); err != nil { + t.Fatalf("submit after delete-all: %v", err) + } +} diff --git a/backend/internal/notify/notify.go b/backend/internal/notify/notify.go index 2ecc030..dbe1bec 100644 --- a/backend/internal/notify/notify.go +++ b/backend/internal/notify/notify.go @@ -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 diff --git a/backend/internal/postgres/jet/backend/model/account_roles.go b/backend/internal/postgres/jet/backend/model/account_roles.go new file mode 100644 index 0000000..bb1a858 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/account_roles.go @@ -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 +} diff --git a/backend/internal/postgres/jet/backend/model/feedback_messages.go b/backend/internal/postgres/jet/backend/model/feedback_messages.go new file mode 100644 index 0000000..a4b2607 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/feedback_messages.go @@ -0,0 +1,29 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type FeedbackMessages struct { + MessageID uuid.UUID `sql:"primary_key"` + AccountID uuid.UUID + Body string + Attachment *[]byte + AttachmentName *string + SenderIP *string + Channel string + ReadAt *time.Time + ArchivedAt *time.Time + ReplyBody *string + RepliedAt *time.Time + ReplyReadAt *time.Time + CreatedAt time.Time +} diff --git a/backend/internal/postgres/jet/backend/table/account_roles.go b/backend/internal/postgres/jet/backend/table/account_roles.go new file mode 100644 index 0000000..2948bbf --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/account_roles.go @@ -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, + } +} diff --git a/backend/internal/postgres/jet/backend/table/feedback_messages.go b/backend/internal/postgres/jet/backend/table/feedback_messages.go new file mode 100644 index 0000000..ebf1c71 --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/feedback_messages.go @@ -0,0 +1,114 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var FeedbackMessages = newFeedbackMessagesTable("backend", "feedback_messages", "") + +type feedbackMessagesTable struct { + postgres.Table + + // Columns + MessageID postgres.ColumnString + AccountID postgres.ColumnString + Body postgres.ColumnString + Attachment postgres.ColumnBytea + AttachmentName postgres.ColumnString + SenderIP postgres.ColumnString + Channel postgres.ColumnString + ReadAt postgres.ColumnTimestampz + ArchivedAt postgres.ColumnTimestampz + ReplyBody postgres.ColumnString + RepliedAt postgres.ColumnTimestampz + ReplyReadAt postgres.ColumnTimestampz + CreatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type FeedbackMessagesTable struct { + feedbackMessagesTable + + EXCLUDED feedbackMessagesTable +} + +// AS creates new FeedbackMessagesTable with assigned alias +func (a FeedbackMessagesTable) AS(alias string) *FeedbackMessagesTable { + return newFeedbackMessagesTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new FeedbackMessagesTable with assigned schema name +func (a FeedbackMessagesTable) FromSchema(schemaName string) *FeedbackMessagesTable { + return newFeedbackMessagesTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new FeedbackMessagesTable with assigned table prefix +func (a FeedbackMessagesTable) WithPrefix(prefix string) *FeedbackMessagesTable { + return newFeedbackMessagesTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new FeedbackMessagesTable with assigned table suffix +func (a FeedbackMessagesTable) WithSuffix(suffix string) *FeedbackMessagesTable { + return newFeedbackMessagesTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newFeedbackMessagesTable(schemaName, tableName, alias string) *FeedbackMessagesTable { + return &FeedbackMessagesTable{ + feedbackMessagesTable: newFeedbackMessagesTableImpl(schemaName, tableName, alias), + EXCLUDED: newFeedbackMessagesTableImpl("", "excluded", ""), + } +} + +func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackMessagesTable { + var ( + MessageIDColumn = postgres.StringColumn("message_id") + AccountIDColumn = postgres.StringColumn("account_id") + BodyColumn = postgres.StringColumn("body") + AttachmentColumn = postgres.ByteaColumn("attachment") + AttachmentNameColumn = postgres.StringColumn("attachment_name") + SenderIPColumn = postgres.StringColumn("sender_ip") + ChannelColumn = postgres.StringColumn("channel") + ReadAtColumn = postgres.TimestampzColumn("read_at") + ArchivedAtColumn = postgres.TimestampzColumn("archived_at") + ReplyBodyColumn = postgres.StringColumn("reply_body") + RepliedAtColumn = postgres.TimestampzColumn("replied_at") + ReplyReadAtColumn = postgres.TimestampzColumn("reply_read_at") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn} + mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn} + defaultColumns = postgres.ColumnList{CreatedAtColumn} + ) + + return feedbackMessagesTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + MessageID: MessageIDColumn, + AccountID: AccountIDColumn, + Body: BodyColumn, + Attachment: AttachmentColumn, + AttachmentName: AttachmentNameColumn, + SenderIP: SenderIPColumn, + Channel: ChannelColumn, + ReadAt: ReadAtColumn, + ArchivedAt: ArchivedAtColumn, + ReplyBody: ReplyBodyColumn, + RepliedAt: RepliedAtColumn, + ReplyReadAt: ReplyReadAtColumn, + CreatedAt: CreatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/table_use_schema.go b/backend/internal/postgres/jet/backend/table/table_use_schema.go index 60f5d15..18e9bec 100644 --- a/backend/internal/postgres/jet/backend/table/table_use_schema.go +++ b/backend/internal/postgres/jet/backend/table/table_use_schema.go @@ -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) diff --git a/backend/internal/postgres/migrations/00004_feedback.sql b/backend/internal/postgres/migrations/00004_feedback.sql new file mode 100644 index 0000000..8785c16 --- /dev/null +++ b/backend/internal/postgres/migrations/00004_feedback.sql @@ -0,0 +1,54 @@ +-- +goose Up +-- User feedback ("Обратная связь"): a flat list of messages a registered player sends to the +-- operators from Settings -> Info, each with an optional single attachment, plus the operator's +-- inline reply shown back to the player. Distinct from the in-game chat_messages (peer-to-peer) +-- and from the out-of-app Telegram messages. Also introduces account_roles, the project's first +-- per-account role table, replacing per-feature boolean flags. See docs/ARCHITECTURE.md. +SET search_path = backend, pg_catalog; + +-- One feedback message. read_at marks "the operator has dealt with this" (set by every console +-- action: read / reply / archive; never by merely opening the detail). The anti-spam gate forbids +-- a new submission while the player has any read_at IS NULL message. archived_at files a handled +-- message away. The reply lives on the same row: reply_body/replied_at are stamped when the +-- operator answers; reply_read_at is stamped when the player's app first fetches the reply for +-- display ("delivered = read"), and the reply is hidden from the player one week after that. +-- attachment is the raw bytes (capped at 1,000,000 in Go); attachment_name carries the original +-- file name (its extension drives the safe content-type at serve time). sender_ip is the +-- gateway-forwarded client IP (validated, like chat); channel is the submitting platform +-- (telegram/ios/android/web, validated in Go). +CREATE TABLE feedback_messages ( + message_id uuid PRIMARY KEY, + account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + body text NOT NULL, + attachment bytea, + attachment_name text, + sender_ip text, + channel text NOT NULL, + read_at timestamptz, + archived_at timestamptz, + reply_body text, + replied_at timestamptz, + reply_read_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +-- The per-player probes (anti-spam "has unread", "latest message", "latest reply") and the +-- console's user-scoped search all look an account up newest-first. +CREATE INDEX feedback_messages_account_idx ON feedback_messages (account_id, created_at DESC); +-- The console queue lists messages newest-first across all accounts. +CREATE INDEX feedback_messages_created_idx ON feedback_messages (created_at DESC); + +-- Named per-account roles. A reusable replacement for per-feature boolean flags: the first role, +-- 'feedback_banned', forbids only feedback submission (not the whole account, unlike a +-- suspension). Roles are validated in Go (no CHECK here) so new ones need no migration. Granted +-- from the feedback console (the "block" checkbox on delete) and granted/revoked from /users. +CREATE TABLE account_roles ( + account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + role text NOT NULL, + granted_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (account_id, role) +); + +-- +goose Down +SET search_path = backend, pg_catalog; +DROP TABLE IF EXISTS account_roles; +DROP TABLE IF EXISTS feedback_messages; diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 87eee89..bf92a15 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -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 diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 7f49515..671f5c1 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -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" } diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 8bcbf15..6619d6a 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -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) } diff --git a/backend/internal/server/handlers_admin_feedback.go b/backend/internal/server/handlers_admin_feedback.go new file mode 100644 index 0000000..70b0ebf --- /dev/null +++ b/backend/internal/server/handlers_admin_feedback.go @@ -0,0 +1,293 @@ +package server + +import ( + "context" + "html/template" + "net/http" + "net/url" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/adminconsole" + "scrabble/backend/internal/feedback" +) + +// consoleFeedback renders the paginated feedback queue, filtered by state +// (unread/read/archived), optionally pinned to one sender (?user=) and narrowed by +// sender glob masks (?name / ?ext). +func (s *Server) consoleFeedback(c *gin.Context) { + ctx := c.Request.Context() + page := consolePage(c) + status := normalizeFeedbackStatus(c.Query("status")) + user, _ := uuid.Parse(strings.TrimSpace(c.Query("user"))) + filter := feedback.AdminFilter{ + Status: status, + NameMask: c.Query("name"), + ExtMask: c.Query("ext"), + AccountID: user, + } + total, _ := s.feedback.AdminCount(ctx, filter) + rows, err := s.feedback.AdminList(ctx, filter, adminPageSize, (page-1)*adminPageSize) + if err != nil { + s.consoleError(c, err) + return + } + q := url.Values{} + q.Set("status", status) + if filter.AccountID != uuid.Nil { + q.Set("user", filter.AccountID.String()) + } + if strings.TrimSpace(filter.NameMask) != "" { + q.Set("name", filter.NameMask) + } + if strings.TrimSpace(filter.ExtMask) != "" { + q.Set("ext", filter.ExtMask) + } + view := adminconsole.FeedbackView{ + Status: status, Pager: adminconsole.NewPager(page, adminPageSize, total), + NameMask: filter.NameMask, ExtMask: filter.ExtMask, + FilterQuery: template.URL(q.Encode()), + } + if filter.AccountID != uuid.Nil { + view.UserID = filter.AccountID.String() + } + for _, r := range rows { + view.Items = append(view.Items, adminconsole.FeedbackRow{ + ID: r.ID.String(), AccountID: r.AccountID.String(), SenderName: r.SenderName, + Source: r.Source, Channel: r.Channel, HasAttachment: r.HasAttachment, + Read: r.Read, Replied: r.Replied, Archived: r.Archived, CreatedAt: fmtTime(r.CreatedAt), + }) + } + s.renderConsole(c, "feedback", "feedback", "Feedback", view) +} + +// consoleFeedbackDetail renders one feedback message with its actions. Opening it +// does NOT mark it read (only the explicit actions do). +func (s *Server) consoleFeedbackDetail(c *gin.Context) { + ctx := c.Request.Context() + id, ok := s.consoleUUID(c, "/_gm/feedback") + if !ok { + return + } + m, err := s.feedback.AdminGet(ctx, id) + if err != nil { + s.consoleError(c, err) + return + } + view := adminconsole.FeedbackDetailView{ + ID: m.ID.String(), AccountID: m.AccountID.String(), SenderName: m.SenderName, + Source: m.Source, Channel: m.Channel, IP: m.SenderIP, Body: m.Body, + HasAttachment: m.HasAttachment, AttachmentName: m.AttachmentName, IsImage: feedback.IsImage(m.AttachmentName), + Read: m.Read, Archived: m.Archived, Replied: m.Replied, ReplyBody: m.ReplyBody, + RepliedAt: fmtTime(m.RepliedAt), CreatedAt: fmtTime(m.CreatedAt), + } + if banned, err := s.accounts.HasRole(ctx, m.AccountID, account.RoleFeedbackBanned); err == nil { + view.Banned = banned + } + s.renderConsole(c, "feedback_detail", "feedback", "Feedback", view) +} + +// consoleFeedbackAttachment serves a message's attachment. It is always sent with +// X-Content-Type-Options: nosniff; images are served with their type for an inline +// 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 +} diff --git a/backend/internal/server/handlers_auth.go b/backend/internal/server/handlers_auth.go index 9e93957..8a9b993 100644 --- a/backend/internal/server/handlers_auth.go +++ b/backend/internal/server/handlers_auth.go @@ -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). diff --git a/backend/internal/server/handlers_feedback.go b/backend/internal/server/handlers_feedback.go new file mode 100644 index 0000000..235c884 --- /dev/null +++ b/backend/internal/server/handlers_feedback.go @@ -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}) +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 5c681be..981397e 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -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, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 07a84a5..0e80f7d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 ``, 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. diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index d8f292f..efefead 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 "Ответ на ваше последнее сообщение"; 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,12 @@ From the user card the operator can also **top up a player's hint wallet**: an a (1–100 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, 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). diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 95083c6..dad4291 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -177,6 +177,18 @@ UTC), суточного окна отсутствия (away; сетка по 10 сразу (без отдельного режима редактирования). Привязка email и Telegram, а также слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние». +### Обратная связь +Зарегистрированный игрок обращается к операторам из Settings → Info: экран **«Обратная связь»** с +сообщением (до 1024 символов) и необязательным вложением (один файл, до ~1 МБ — изображения, PDF, +текст/лог, офисные документы, RTF или архивы; неподходящий файл отклоняется на форме без перечисления +допустимых типов). После отправки форма очищается и подтверждает «Ваше сообщение отправлено», а +повторная отправка блокируется, пока оператор не разобрал обращение — при повторном входе на экране +«Ожидаем рассмотрения вашего последнего обращения». Ответ оператора показывается под формой как +«Ответ на ваше последнее сообщение»; он помечается прочитанным, как только экран его показал, и +исчезает через неделю. Бейдж на вкладке Settings (и на Info внутри неё) сигналит о непрочитанном +ответе. Гость отправлять обратную связь не может (пункт скрыт). Игрок, которому оператор запретил +обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной. + ### История и статистика Завершённые партии архивируются в независимом от словаря виде и экспортируются в GCG; экспорт доступен **только после завершения партии** (экспорт идущей партии @@ -231,3 +243,13 @@ high-rate флага. С карточки пользователя операт начисление (1–100 подсказок за раз), которое **только увеличивает** баланс на карточке. Начисления **только в плюс** — понизить кошелёк из консоли нельзя (игрок теряет подсказки только тратя их в партии), поэтому ошибочно начисленное через консоль там не отнять. + +Консоль ведёт и очередь **обратной связи** (`/_gm/feedback`): присланные игроками сообщения с фильтром +**непрочитанные / прочитанные / архив** и поиском по пользователю, каждое — с отправителем, источником, +каналом, IP и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка +в приложение), отправить в архив, удалить или удалить все сообщения этого игрока — и вместе с удалением +**запретить игроку обратную связь** (роль `feedback_banned`, отличная от полной блокировки аккаунта: +останавливает только отправку обратной связи). Роли перечислены и выдаются/снимаются на карточке +пользователя. Открытие сообщения не помечает его прочитанным — это делают только явные действия; тело +сообщения и вложения показываются защищённо (текст экранируется, вложения отдаются на скачивание, а не +рендерятся). diff --git a/docs/TESTING.md b/docs/TESTING.md index aaa1933..4429c89 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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 diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index d69f44a..fec6d72 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -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 ``, 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 diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index b6eeb25..e1e034f 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -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. diff --git a/gateway/internal/backendclient/api_feedback.go b/gateway/internal/backendclient/api_feedback.go new file mode 100644 index 0000000..2894c3d --- /dev/null +++ b/gateway/internal/backendclient/api_feedback.go @@ -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 +} diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index dd2bdd1..5796e88 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -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 " header, diff --git a/gateway/internal/connectsrv/server_test.go b/gateway/internal/connectsrv/server_test.go index 0a4f605..17dc3bc 100644 --- a/gateway/internal/connectsrv/server_test.go +++ b/gateway/internal/connectsrv/server_test.go @@ -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") diff --git a/gateway/internal/session/cache.go b/gateway/internal/session/cache.go index 6e254a5..061b4f3 100644 --- a/gateway/internal/session/cache.go +++ b/gateway/internal/session/cache.go @@ -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 diff --git a/gateway/internal/session/cache_test.go b/gateway/internal/session/cache_test.go index 173e601..8dc1607 100644 --- a/gateway/internal/session/cache_test.go +++ b/gateway/internal/session/cache_test.go @@ -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) } diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 95f5de2..0a293f2 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -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)) diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index 035cccc..b176709 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -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 + } +} diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index b45f2c5..d451d79 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -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 diff --git a/pkg/fbs/scrabblefb/FeedbackReply.go b/pkg/fbs/scrabblefb/FeedbackReply.go new file mode 100644 index 0000000..a4b2b7c --- /dev/null +++ b/pkg/fbs/scrabblefb/FeedbackReply.go @@ -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() +} diff --git a/pkg/fbs/scrabblefb/FeedbackState.go b/pkg/fbs/scrabblefb/FeedbackState.go new file mode 100644 index 0000000..80ea818 --- /dev/null +++ b/pkg/fbs/scrabblefb/FeedbackState.go @@ -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() +} diff --git a/pkg/fbs/scrabblefb/FeedbackSubmitRequest.go b/pkg/fbs/scrabblefb/FeedbackSubmitRequest.go new file mode 100644 index 0000000..0b18c4c --- /dev/null +++ b/pkg/fbs/scrabblefb/FeedbackSubmitRequest.go @@ -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() +} diff --git a/pkg/fbs/scrabblefb/FeedbackUnread.go b/pkg/fbs/scrabblefb/FeedbackUnread.go new file mode 100644 index 0000000..5dc2035 --- /dev/null +++ b/pkg/fbs/scrabblefb/FeedbackUnread.go @@ -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() +} diff --git a/ui/e2e/feedback.spec.ts b/ui/e2e/feedback.spec.ts new file mode 100644 index 0000000..e4f9d97 --- /dev/null +++ b/ui/e2e/feedback.spec.ts @@ -0,0 +1,45 @@ +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 { + 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 { + await page.getByRole('button', { name: /Settings/ }).click(); + await expect(page.locator('.pane')).toHaveCount(1); + await page.getByRole('button', { name: 'Info', exact: true }).click(); + await page.getByRole('button', { name: 'Feedback', exact: true }).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(); + await expect(page.getByText(/looking into it/)).toBeVisible(); +}); diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 7c6348e..e07701e 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -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 @@ {:else if router.route.name === 'friends'} + {:else if router.route.name === 'feedback'} + {:else if router.route.name === 'stats'} {:else} diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index 8bca894..ae8efe2 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -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'; diff --git a/ui/src/gen/fbs/scrabblefb/feedback-reply.ts b/ui/src/gen/fbs/scrabblefb/feedback-reply.ts new file mode 100644 index 0000000..5726835 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/feedback-reply.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/feedback-state.ts b/ui/src/gen/fbs/scrabblefb/feedback-state.ts new file mode 100644 index 0000000..358f487 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/feedback-state.ts @@ -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; +} + +} diff --git a/ui/src/gen/fbs/scrabblefb/feedback-submit-request.ts b/ui/src/gen/fbs/scrabblefb/feedback-submit-request.ts new file mode 100644 index 0000000..db72963 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/feedback-submit-request.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/feedback-unread.ts b/ui/src/gen/fbs/scrabblefb/feedback-unread.ts new file mode 100644 index 0000000..5d830e7 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/feedback-unread.ts @@ -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); +} +} diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 8b219ae..a079f9f 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -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; + /** 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 { } } +/** + * 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 { + 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); diff --git a/ui/src/lib/channel.test.ts b/ui/src/lib/channel.test.ts new file mode 100644 index 0000000..a6bc071 --- /dev/null +++ b/ui/src/lib/channel.test.ts @@ -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'); + }); +}); diff --git a/ui/src/lib/channel.ts b/ui/src/lib/channel.ts new file mode 100644 index 0000000..ccfb8c6 --- /dev/null +++ b/ui/src/lib/channel.ts @@ -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 }); +} diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index c73a775..8b6849d 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -9,6 +9,7 @@ import type { BlockStatus, ChatMessage, EvalResult, + FeedbackState, FriendCode, GameList, GameView, @@ -101,6 +102,14 @@ export interface GatewayClient { chatList(gameId: string): Promise; nudge(gameId: string): Promise; + // --- feedback --- + /** feedbackSubmit sends a feedback message with an optional single attachment. */ + feedbackSubmit(body: string, attachment: Uint8Array | null, attachmentName: string, channel: string): Promise; + /** feedbackGet returns the feedback screen state and marks any pending reply delivered. */ + feedbackGet(): Promise; + /** feedbackUnread reports whether an operator reply awaits delivery (the badge). */ + feedbackUnread(): Promise; + // --- friends --- friendsList(): Promise; friendsIncoming(): Promise; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index af3e8c0..5bd3516 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -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'); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index aac290f..4b8f916 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -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) { diff --git a/ui/src/lib/feedback.test.ts b/ui/src/lib/feedback.test.ts new file mode 100644 index 0000000..dac96a5 --- /dev/null +++ b/ui/src/lib/feedback.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { attachmentError, 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'); + }); +}); diff --git a/ui/src/lib/feedback.ts b/ui/src/lib/feedback.ts new file mode 100644 index 0000000..18bea07 --- /dev/null +++ b/ui/src/lib/feedback.ts @@ -0,0 +1,29 @@ +// 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 ''; +} diff --git a/ui/src/lib/gateway.ts b/ui/src/lib/gateway.ts index 43dd0f8..3a47386 100644 --- a/ui/src/lib/gateway.ts +++ b/ui/src/lib/gateway.ts @@ -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(), }; } diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 5d38e95..1235164 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -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 can’t 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', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 93ae48b..b667fee 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -171,6 +171,17 @@ export const ru: Record = { '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', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 964e251..8afd844 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -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,30 @@ export class MockGateway implements GatewayClient { async chatList(gameId: string): Promise { return [...this.game(gameId).chat]; } + async feedbackSubmit(_body: string, _attachment: Uint8Array | null, _attachmentName: string, _channel: string): Promise { + this.feedbackPending = true; + } + async feedbackGet(): Promise { + this.feedbackReplyUnread = false; // fetching the screen delivers (reads) the reply + return { + canSend: !this.feedbackPending, + blockedReason: this.feedbackPending ? 'pending' : '', + reply: this.feedbackReply, + }; + } + async feedbackUnread(): Promise { + 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 — we are looking into it.'): 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 { const g = this.game(gameId); const msg: ChatMessage = { diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index eb16c78..5726802 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -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; diff --git a/ui/src/lib/routeparse.ts b/ui/src/lib/routeparse.ts index 8e4146b..434c902 100644 --- a/ui/src/lib/routeparse.ts +++ b/ui/src/lib/routeparse.ts @@ -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: diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 94df8af..3b2874b 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -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())); diff --git a/ui/src/screens/About.svelte b/ui/src/screens/About.svelte index 6189239..f80242a 100644 --- a/ui/src/screens/About.svelte +++ b/ui/src/screens/About.svelte @@ -1,6 +1,7 @@ + + +
+ + +
+ + {#if file} + {file.name} + + {:else} + + {/if} + +
+ + {#if fileError}

{t('feedback.fileRejected')}

{/if} + + {#if sent} +

{t('feedback.sent')}

+ {:else if reason === 'pending'} +

{t('feedback.waiting')}

+ {:else if reason === 'banned'} +

{t('feedback.banned')}

+ {/if} + + {#if view?.reply} +
+

{t('feedback.replyTitle')}

+

{view.reply.body}

+
+ {/if} +
+
+ + diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index 847f896..166c805 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -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([]); 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 @@ 📊{t('lobby.stats')} diff --git a/ui/src/screens/SettingsHub.svelte b/ui/src/screens/SettingsHub.svelte index 1bd18fa..bdc1d9c 100644 --- a/ui/src/screens/SettingsHub.svelte +++ b/ui/src/screens/SettingsHub.svelte @@ -57,7 +57,7 @@ {/if} {/snippet} -- 2.52.0 From 5287794a72e1e2c1e7b64d01a9c09df9a60114da Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 12:45:00 +0200 Subject: [PATCH 2/7] fix(feedback): theme buttons, badge the Info button, simplify admin actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - style the About feedback button and the form's Send/attach/remove buttons with the accent/border tokens used by the New Game CTA, so they follow light/dark theme (the previous .btn/.ghost classes were not defined globally); the attach button is a neutral 📎 icon button with an aria-label - show a round badge on the About 'Feedback' button when a reply is waiting - admin: one 'ban from feedback' checkbox shared by Delete and Delete-all (via button formaction); hide Mark read when already read and Archive when archived - e2e: match the About button by substring (its name gains the badge) --- .../templates/pages/feedback_detail.gohtml | 13 +++---- ui/e2e/feedback.spec.ts | 4 +- ui/src/screens/About.svelte | 27 ++++++++++++- ui/src/screens/Feedback.svelte | 38 ++++++++++++++++--- 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml b/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml index 76a4ef4..9f85def 100644 --- a/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml @@ -31,15 +31,14 @@

Actions

-
-
+{{if not .Read}}
{{end}} +{{if not .Archived}}
{{end}}
-
-
-
- -
+
+ + +
{{end}} diff --git a/ui/e2e/feedback.spec.ts b/ui/e2e/feedback.spec.ts index e4f9d97..d08cb5f 100644 --- a/ui/e2e/feedback.spec.ts +++ b/ui/e2e/feedback.spec.ts @@ -14,7 +14,9 @@ async function openFeedback(page: Page): Promise { await page.getByRole('button', { name: /Settings/ }).click(); await expect(page.locator('.pane')).toHaveCount(1); await page.getByRole('button', { name: 'Info', exact: true }).click(); - await page.getByRole('button', { name: 'Feedback', 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 }) => { diff --git a/ui/src/screens/About.svelte b/ui/src/screens/About.svelte index f80242a..0daa6f3 100644 --- a/ui/src/screens/About.svelte +++ b/ui/src/screens/About.svelte @@ -34,7 +34,9 @@

{t('about.version', { v: version })}

{#if !(app.profile?.isGuest ?? true)} - + {/if}
@@ -78,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; + } diff --git a/ui/src/screens/Feedback.svelte b/ui/src/screens/Feedback.svelte index 748b884..1877065 100644 --- a/ui/src/screens/Feedback.svelte +++ b/ui/src/screens/Feedback.svelte @@ -94,13 +94,17 @@ {#if file} {file.name} - + {:else} - + {/if} - + {#if fileError}

{t('feedback.fileRejected')}

{/if} @@ -151,8 +155,30 @@ color: var(--text-muted); font-size: 0.9rem; } - .row .btn { + .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; -- 2.52.0 From 1ae43080ec6218869ff0eca4ca4137da13fcb421 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 12:59:18 +0200 Subject: [PATCH 3/7] fix(feedback): show the operator reply only on the player's latest message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reply was bound to 'the latest message that has a reply', so after the player read a reply and sent a new (unanswered) message, the old reply kept showing as 'Ответ на ваше последнее сообщение'. Bind it to the single most-recent message instead: sending any new message immediately drops the previous reply (the new message has no reply yet), well before the one-week window. Client clears the reply optimistically on submit; the mock mirrors it; inttest covers the case. --- backend/internal/feedback/store.go | 26 ++++++++--------- backend/internal/inttest/feedback_test.go | 35 +++++++++++++++++++++++ ui/e2e/feedback.spec.ts | 13 +++++++++ ui/src/lib/mock/client.ts | 1 + ui/src/screens/Feedback.svelte | 3 +- 5 files changed, 64 insertions(+), 14 deletions(-) diff --git a/backend/internal/feedback/store.go b/backend/internal/feedback/store.go index 7d63cb3..7d9c4a6 100644 --- a/backend/internal/feedback/store.go +++ b/backend/internal/feedback/store.go @@ -86,25 +86,25 @@ func (s *Store) HasUnreadReply(ctx context.Context, accountID uuid.UUID) (bool, // VisibleReply is the operator reply shown back to the player: the reply on their // most recent replied message still inside the visibility window. type VisibleReply struct { - MessageID uuid.UUID - Body string - RepliedAt time.Time - ReplyReadAt sql.NullTime + Body string + RepliedAt time.Time } -// LatestVisibleReply returns the account's most recent message that carries a reply -// still visible to the player — not yet delivered, or delivered after cutoff (one -// week ago). Reports false when there is none. +// 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 message_id, COALESCE(reply_body, ''), replied_at, reply_read_at - FROM backend.feedback_messages - WHERE account_id = $1 AND reply_body IS NOT NULL - AND (reply_read_at IS NULL OR reply_read_at > $2) - ORDER BY created_at DESC LIMIT 1`, - accountID, cutoff).Scan(&vr.MessageID, &vr.Body, &repliedAt, &vr.ReplyReadAt) + `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 } diff --git a/backend/internal/inttest/feedback_test.go b/backend/internal/inttest/feedback_test.go index 57bec32..98b3ee3 100644 --- a/backend/internal/inttest/feedback_test.go +++ b/backend/internal/inttest/feedback_test.go @@ -110,6 +110,41 @@ func TestFeedbackSubmitGateAndReplyLifecycle(t *testing.T) { } } +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 TestFeedbackBanRole(t *testing.T) { ctx := context.Background() svc := newFeedbackService() diff --git a/ui/e2e/feedback.spec.ts b/ui/e2e/feedback.spec.ts index d08cb5f..8982ce3 100644 --- a/ui/e2e/feedback.spec.ts +++ b/ui/e2e/feedback.spec.ts @@ -45,3 +45,16 @@ test('feedback: an operator reply raises the badge and shows on the screen', asy await expect(page.getByText('Reply to your last message')).toBeVisible(); await expect(page.getByText(/looking into it/)).toBeVisible(); }); + +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(); +}); diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 8afd844..5970bd1 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -418,6 +418,7 @@ export class MockGateway implements GatewayClient { } async feedbackSubmit(_body: string, _attachment: Uint8Array | null, _attachmentName: string, _channel: string): Promise { this.feedbackPending = true; + this.feedbackReply = null; // a new message: the previous operator reply no longer shows } async feedbackGet(): Promise { this.feedbackReplyUnread = false; // fetching the screen delivers (reads) the reply diff --git a/ui/src/screens/Feedback.svelte b/ui/src/screens/Feedback.svelte index 1877065..7c69b2e 100644 --- a/ui/src/screens/Feedback.svelte +++ b/ui/src/screens/Feedback.svelte @@ -71,7 +71,8 @@ removeFile(); sent = true; // The message is now under review: block resending until the operator handles it. - view = { canSend: false, blockedReason: 'pending', reply: view?.reply ?? null }; + // 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 { -- 2.52.0 From 2a4ce319d95a0b34bedb61aa805f73ca7291e9e9 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 13:07:47 +0200 Subject: [PATCH 4/7] feat(feedback): show the sender's interface language and connector bot in admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the console feedback detail, show the sender's interface language (account preferred_language) always, and — for a message that arrived through an external connector (currently Telegram) — the bot they last used (en/ru, from the account's service_language). --- backend/internal/adminconsole/render_test.go | 2 +- .../templates/pages/feedback_detail.gohtml | 3 +- backend/internal/adminconsole/views.go | 39 +++++++++++-------- .../server/handlers_admin_feedback.go | 8 ++++ 4 files changed, 33 insertions(+), 19 deletions(-) diff --git a/backend/internal/adminconsole/render_test.go b/backend/internal/adminconsole/render_test.go index b6539bb..f7bee46 100644 --- a/backend/internal/adminconsole/render_test.go +++ b/backend/internal/adminconsole/render_test.go @@ -37,7 +37,7 @@ func TestRendererRendersEveryPage(t *testing.T) { {"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"}, {"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1"}}, Pager: NewPager(1, 50, 1)}, "good luck"}, {"feedback", FeedbackView{Items: []FeedbackRow{{ID: "f1", AccountID: "a1", SenderName: "Kaya", Source: "telegram", Channel: "web", HasAttachment: true, Replied: true}}, Status: "unread", Pager: NewPager(1, 50, 1)}, "replied"}, - {"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "ios", Body: "please fix the board", HasAttachment: true, AttachmentName: "shot.png", IsImage: true, Banned: true}, "please fix the board"}, + {"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"}, diff --git a/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml b/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml index 9f85def..3ba3e4d 100644 --- a/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml @@ -5,7 +5,8 @@

Message

  • From {{.SenderName}} ({{.Source}})
  • -
  • Channel {{.Channel}}
  • +
  • Channel {{.Channel}}{{if .BotLanguage}} (bot: {{.BotLanguage}}){{end}}
  • +
  • Interface language {{.InterfaceLanguage}}
  • IP {{if .IP}}{{.IP}}{{else}}none{{end}}
  • Filed {{.CreatedAt}}
  • State {{if .Archived}}archived{{else if .Read}}read{{else}}unread{{end}}
  • diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index 7eb2a17..d482238 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -404,21 +404,26 @@ type FeedbackRow struct { // user-controlled and rendered as plain auto-escaped text. IsImage gates the inline // preview; Banned shows whether the sender already holds the feedback ban. type FeedbackDetailView struct { - ID string - AccountID string - SenderName string - Source string - Channel string - IP string - Body string - HasAttachment bool - AttachmentName string - IsImage bool - Read bool - Archived bool - Replied bool - ReplyBody string - RepliedAt string - CreatedAt string - Banned bool + 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 } diff --git a/backend/internal/server/handlers_admin_feedback.go b/backend/internal/server/handlers_admin_feedback.go index 70b0ebf..973f96d 100644 --- a/backend/internal/server/handlers_admin_feedback.go +++ b/backend/internal/server/handlers_admin_feedback.go @@ -87,6 +87,14 @@ func (s *Server) consoleFeedbackDetail(c *gin.Context) { if banned, err := s.accounts.HasRole(ctx, m.AccountID, account.RoleFeedbackBanned); err == nil { view.Banned = banned } + if acc, err := s.accounts.GetByID(ctx, m.AccountID); err == nil { + view.InterfaceLanguage = acc.PreferredLanguage + // The connector bot (the account's last service-language bot) is meaningful only + // for a message that arrived through an external connector — currently Telegram. + if m.Channel == "telegram" && acc.ServiceLanguage != "" { + view.BotLanguage = acc.ServiceLanguage + } + } s.renderConsole(c, "feedback_detail", "feedback", "Feedback", view) } -- 2.52.0 From 49b67a0354b98b8f5e9b1470a393941445486f6b Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 13:11:13 +0200 Subject: [PATCH 5/7] docs(feedback): note interface language + connector bot in the admin detail --- docs/FUNCTIONAL.md | 5 +++-- docs/FUNCTIONAL_ru.md | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index efefead..f1372f1 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -239,8 +239,9 @@ the console can never lower a wallet (a player only loses hints by spending them 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, IP -and any attachment. The operator can mark a message read, **reply** to the player (delivered +**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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index dad4291..1e05f48 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -246,7 +246,8 @@ high-rate флага. С карточки пользователя операт Консоль ведёт и очередь **обратной связи** (`/_gm/feedback`): присланные игроками сообщения с фильтром **непрочитанные / прочитанные / архив** и поиском по пользователю, каждое — с отправителем, источником, -каналом, IP и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка +каналом (и языком бота-коннектора — en/ru — для сообщения из Telegram), языком интерфейса отправителя, +IP и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка в приложение), отправить в архив, удалить или удалить все сообщения этого игрока — и вместе с удалением **запретить игроку обратную связь** (роль `feedback_banned`, отличная от полной блокировки аккаунта: останавливает только отправку обратной связи). Роли перечислены и выдаются/снимаются на карточке -- 2.52.0 From 55ed87fb117b83b54ac891904304034ed979cc88 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 13:25:27 +0200 Subject: [PATCH 6/7] feat(feedback): snapshot the sender's language and connector bot at submit Store the sender's interface language (lang) and, for a message that arrived through an external connector (Telegram), the bot language (channel_lang) on the feedback row at submit time, so the operator console shows the state as it was rather than the account's current settings (same snapshot discipline as a suspension reason). Added additively in migration 00005. The console detail reads these columns instead of loading the account live. --- backend/internal/feedback/service.go | 10 ++++- backend/internal/feedback/store.go | 43 +++++++++++------- backend/internal/inttest/feedback_test.go | 45 +++++++++++++++++++ .../jet/backend/model/feedback_messages.go | 2 + .../jet/backend/table/feedback_messages.go | 10 ++++- .../postgres/migrations/00004_feedback.sql | 3 +- .../migrations/00005_feedback_languages.sql | 15 +++++++ .../server/handlers_admin_feedback.go | 11 +---- 8 files changed, 111 insertions(+), 28 deletions(-) create mode 100644 backend/internal/postgres/migrations/00005_feedback_languages.sql diff --git a/backend/internal/feedback/service.go b/backend/internal/feedback/service.go index 2f1eed1..d0b22be 100644 --- a/backend/internal/feedback/service.go +++ b/backend/internal/feedback/service.go @@ -111,7 +111,15 @@ func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string } else { attachmentName = "" // a name without bytes carries no attachment } - _, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, normalizeChannel(channel), parseIP(senderIP)) + 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 } diff --git a/backend/internal/feedback/store.go b/backend/internal/feedback/store.go index 7d9c4a6..2e64e39 100644 --- a/backend/internal/feedback/store.go +++ b/backend/internal/feedback/store.go @@ -34,8 +34,11 @@ func NewStore(db *sql.DB) *Store { // Insert stores one feedback message from accountID and returns its id. attachment // is the raw file bytes (nil for none); attachmentName, ip and a non-default -// channel are stored as given. created_at defaults to now() in the database. -func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel string, ip *string) (uuid.UUID, error) { +// 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) @@ -44,20 +47,24 @@ func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, at if len(attachment) > 0 { att = attachment } - var name *string - if attachmentName != "" { - name = &attachmentName - } if _, err := s.db.ExecContext(ctx, `INSERT INTO backend.feedback_messages - (message_id, account_id, body, attachment, attachment_name, channel, sender_ip) - VALUES ($1, $2, $3, $4, $5, $6, $7)`, - id, accountID, body, att, name, channel, ip); err != nil { + (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) { @@ -215,12 +222,16 @@ func (s *Store) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, b // 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 + 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 @@ -335,6 +346,7 @@ 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 @@ -343,6 +355,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error 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) { diff --git a/backend/internal/inttest/feedback_test.go b/backend/internal/inttest/feedback_test.go index 98b3ee3..6aadcfa 100644 --- a/backend/internal/inttest/feedback_test.go +++ b/backend/internal/inttest/feedback_test.go @@ -145,6 +145,51 @@ func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) { } } +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() diff --git a/backend/internal/postgres/jet/backend/model/feedback_messages.go b/backend/internal/postgres/jet/backend/model/feedback_messages.go index a4b2607..ef0dff8 100644 --- a/backend/internal/postgres/jet/backend/model/feedback_messages.go +++ b/backend/internal/postgres/jet/backend/model/feedback_messages.go @@ -20,6 +20,8 @@ type FeedbackMessages struct { AttachmentName *string SenderIP *string Channel string + Lang *string + ChannelLang *string ReadAt *time.Time ArchivedAt *time.Time ReplyBody *string diff --git a/backend/internal/postgres/jet/backend/table/feedback_messages.go b/backend/internal/postgres/jet/backend/table/feedback_messages.go index ebf1c71..7b16c19 100644 --- a/backend/internal/postgres/jet/backend/table/feedback_messages.go +++ b/backend/internal/postgres/jet/backend/table/feedback_messages.go @@ -24,6 +24,8 @@ type feedbackMessagesTable struct { AttachmentName postgres.ColumnString SenderIP postgres.ColumnString Channel postgres.ColumnString + Lang postgres.ColumnString + ChannelLang postgres.ColumnString ReadAt postgres.ColumnTimestampz ArchivedAt postgres.ColumnTimestampz ReplyBody postgres.ColumnString @@ -78,14 +80,16 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM 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, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn} - mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn} + 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} ) @@ -100,6 +104,8 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM AttachmentName: AttachmentNameColumn, SenderIP: SenderIPColumn, Channel: ChannelColumn, + Lang: LangColumn, + ChannelLang: ChannelLangColumn, ReadAt: ReadAtColumn, ArchivedAt: ArchivedAtColumn, ReplyBody: ReplyBodyColumn, diff --git a/backend/internal/postgres/migrations/00004_feedback.sql b/backend/internal/postgres/migrations/00004_feedback.sql index 8785c16..c4fd632 100644 --- a/backend/internal/postgres/migrations/00004_feedback.sql +++ b/backend/internal/postgres/migrations/00004_feedback.sql @@ -15,7 +15,8 @@ SET search_path = backend, pg_catalog; -- 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). +-- (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, diff --git a/backend/internal/postgres/migrations/00005_feedback_languages.sql b/backend/internal/postgres/migrations/00005_feedback_languages.sql new file mode 100644 index 0000000..ea3a92c --- /dev/null +++ b/backend/internal/postgres/migrations/00005_feedback_languages.sql @@ -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; diff --git a/backend/internal/server/handlers_admin_feedback.go b/backend/internal/server/handlers_admin_feedback.go index 973f96d..76e7e2f 100644 --- a/backend/internal/server/handlers_admin_feedback.go +++ b/backend/internal/server/handlers_admin_feedback.go @@ -79,7 +79,8 @@ func (s *Server) consoleFeedbackDetail(c *gin.Context) { } view := adminconsole.FeedbackDetailView{ ID: m.ID.String(), AccountID: m.AccountID.String(), SenderName: m.SenderName, - Source: m.Source, Channel: m.Channel, IP: m.SenderIP, Body: m.Body, + 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), @@ -87,14 +88,6 @@ func (s *Server) consoleFeedbackDetail(c *gin.Context) { if banned, err := s.accounts.HasRole(ctx, m.AccountID, account.RoleFeedbackBanned); err == nil { view.Banned = banned } - if acc, err := s.accounts.GetByID(ctx, m.AccountID); err == nil { - view.InterfaceLanguage = acc.PreferredLanguage - // The connector bot (the account's last service-language bot) is meaningful only - // for a message that arrived through an external connector — currently Telegram. - if m.Channel == "telegram" && acc.ServiceLanguage != "" { - view.BotLanguage = acc.ServiceLanguage - } - } s.renderConsole(c, "feedback_detail", "feedback", "Feedback", view) } -- 2.52.0 From 277954c47f8008280cb4b9bd52f652378f2d4e84 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 13:25:36 +0200 Subject: [PATCH 7/7] feat(feedback): render links in the operator reply (open in a new tab) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linkify the operator reply on the feedback screen: http/https/ftp/mailto/tel URLs become anchors (target=_blank rel=noopener), everything else stays escaped text. A small whitelisted regex (no dependency) — the reply is operator-authored, so explicit schemes suffice; dangerous schemes (javascript:/data:) are never linked, and \b avoids matching a scheme inside a word (hotel:, email:). --- docs/FUNCTIONAL.md | 4 +-- docs/FUNCTIONAL_ru.md | 4 +-- ui/e2e/feedback.spec.ts | 6 +++- ui/src/lib/feedback.test.ts | 55 +++++++++++++++++++++++++++++++++- ui/src/lib/feedback.ts | 39 ++++++++++++++++++++++++ ui/src/lib/mock/client.ts | 2 +- ui/src/screens/Feedback.svelte | 9 ++++-- 7 files changed, 110 insertions(+), 9 deletions(-) diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index f1372f1..36c1c57 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -179,8 +179,8 @@ PDF, text/log, office documents, RTF or archives; an unsupported file is refused 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 "Ответ на ваше последнее сообщение"; 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 +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. diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 1e05f48..2a7d38a 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -184,8 +184,8 @@ UTC), суточного окна отсутствия (away; сетка по 10 допустимых типов). После отправки форма очищается и подтверждает «Ваше сообщение отправлено», а повторная отправка блокируется, пока оператор не разобрал обращение — при повторном входе на экране «Ожидаем рассмотрения вашего последнего обращения». Ответ оператора показывается под формой как -«Ответ на ваше последнее сообщение»; он помечается прочитанным, как только экран его показал, и -исчезает через неделю. Бейдж на вкладке Settings (и на Info внутри неё) сигналит о непрочитанном +«Ответ на ваше последнее сообщение» (ссылки в нём открываются в новой вкладке); он помечается +прочитанным, как только экран его показал, и исчезает через неделю. Бейдж на вкладке Settings (и на Info внутри неё) сигналит о непрочитанном ответе. Гость отправлять обратную связь не может (пункт скрыт). Игрок, которому оператор запретил обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной. diff --git a/ui/e2e/feedback.spec.ts b/ui/e2e/feedback.spec.ts index 8982ce3..c4dce6c 100644 --- a/ui/e2e/feedback.spec.ts +++ b/ui/e2e/feedback.spec.ts @@ -43,7 +43,11 @@ test('feedback: an operator reply raises the badge and shows on the screen', asy await openFeedback(page); await expect(page.getByText('Reply to your last message')).toBeVisible(); - await expect(page.getByText(/looking into it/)).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 }) => { diff --git a/ui/src/lib/feedback.test.ts b/ui/src/lib/feedback.test.ts index dac96a5..b1419bb 100644 --- a/ui/src/lib/feedback.test.ts +++ b/ui/src/lib/feedback.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { attachmentError, MAX_ATTACHMENT_BYTES } from './feedback'; +import { attachmentError, linkify, MAX_ATTACHMENT_BYTES } from './feedback'; describe('attachmentError', () => { it('accepts allowed extensions within the size cap', () => { @@ -22,3 +22,56 @@ describe('attachmentError', () => { 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); + }); +}); diff --git a/ui/src/lib/feedback.ts b/ui/src/lib/feedback.ts index 18bea07..e47ace0 100644 --- a/ui/src/lib/feedback.ts +++ b/ui/src/lib/feedback.ts @@ -27,3 +27,42 @@ export function attachmentError(name: string, size: number): '' | 'type' | 'size 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; +} diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 5970bd1..22cefa3 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -435,7 +435,7 @@ export class MockGateway implements GatewayClient { // 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 — we are looking into it.'): void { + 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; diff --git a/ui/src/screens/Feedback.svelte b/ui/src/screens/Feedback.svelte index 7c69b2e..8a39455 100644 --- a/ui/src/screens/Feedback.svelte +++ b/ui/src/screens/Feedback.svelte @@ -6,7 +6,7 @@ import { gateway } from '../lib/gateway'; import { connection } from '../lib/connection.svelte'; import { clientChannel } from '../lib/channel'; - import { attachmentError, MAX_BODY } from '../lib/feedback'; + 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 @@ -23,6 +23,8 @@ 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()); @@ -121,7 +123,7 @@ {#if view?.reply}

    {t('feedback.replyTitle')}

    -

    {view.reply.body}

    +

    {#each replySegments as seg, i (i)}{#if seg.href}{seg.text}{:else}{seg.text}{/if}{/each}

    {/if} @@ -213,4 +215,7 @@ white-space: pre-wrap; word-break: break-word; } + .replybody a { + color: var(--accent); + } -- 2.52.0