From 419ea11b1402ce2f8de0a2c71dc1645c1b92b0fa Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 12:23:10 +0200 Subject: [PATCH] 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}