Merge pull request 'feat(feedback): обратная связь от пользователей с разбором в админке и ролями' (#64) from feature/feedback into development
This commit was merged in pull request #64.
This commit is contained in:
@@ -52,6 +52,7 @@ independent (see ARCHITECTURE §9.1).
|
||||
| 16 | Deploy infra & test contour (Dockerfiles, gateway static UI, compose, observability) | **done** |
|
||||
| 17 | Test-contour verification & defect fixes | **done** |
|
||||
| 18 | Prod contour deploy (SSH export/import, manual after merge) | todo |
|
||||
| 19 | User feedback (in-app submit + attachment, admin review/reply, account roles) | **done** |
|
||||
|
||||
Scaffolding is incremental: `go.work` lists only existing modules; each stage
|
||||
adds the modules it needs.
|
||||
@@ -419,6 +420,37 @@ parameterised for this; the test contour leaves it `:80` behind the host caddy).
|
||||
Open details (re-interview): export/import vs a registry trade-off; prod domain/cert source (ACME vs a
|
||||
provided cert) at the contour caddy; prod VPN; rollback.
|
||||
|
||||
### Stage 19 — User feedback *(done)*
|
||||
A user→operator feedback channel, sequenced after the numbered stages but shipped **before** the Stage 18
|
||||
prod deploy. Scope: a **Feedback** screen under Settings → Info where a registered player sends a message
|
||||
(≤1024 runes) with an optional single attachment; the operator reviews and replies in the server-rendered
|
||||
**admin console**; the reply is delivered back in-app with a Settings/Info badge. Introduces the project's
|
||||
**first per-account role** (`account_roles`), the reusable replacement for per-feature flags: the
|
||||
`feedback_banned` role blocks **only** feedback submission (not the whole account, unlike a suspension),
|
||||
granted from the feedback section's delete-with-block and granted/revoked from `/users`.
|
||||
|
||||
- **Data:** migration `00004_feedback` — `feedback_messages` (body, attachment `bytea`, sender_ip, channel,
|
||||
read/archived/reply/reply-read timestamps) + `account_roles` (account_id, role). New backend domain
|
||||
`internal/feedback` (store + service), modelled on `internal/social` + the admin chat-moderation surface.
|
||||
- **Anti-spam gate:** a player with an unreviewed message cannot send another ("Ожидаем рассмотрения вашего
|
||||
последнего обращения"); enforced server-side. This *is* the rate limit — no separate one.
|
||||
- **Reply lifecycle:** the reply lives on the message row; shown on the feedback screen ("Ответ на ваше
|
||||
последнее сообщение") for the player's latest replied message; it becomes read the moment the screen
|
||||
fetches it (delivery = read) and is hidden one week after. The badge (lobby ⚙️, combined with friend
|
||||
requests; Settings → Info "1") rides the existing `NotificationEvent` with a new `admin_reply` sub-kind —
|
||||
no wire-schema change for the push.
|
||||
- **Wire:** new FlatBuffers `feedback.submit` / `feedback.get` / `feedback.unread`; `feedback.submit` is
|
||||
**guest-gated at the gateway** via a new `Op.NonGuest` flag (the session resolve now carries `is_guest`),
|
||||
so a guest's submission never reaches the backend; the backend rejects guests too.
|
||||
- **Security:** body + attachment limits enforced server-side (the UI limits are UX only); attachments
|
||||
served with `X-Content-Type-Options: nosniff`, images inline-previewed via `<img>` (which never
|
||||
executes), everything else download-only; the UI gates the attachment by extension (allowed types not
|
||||
listed) and the backend mirrors the allow-list + size as the trust boundary; the console renders user
|
||||
content as auto-escaped text (`html/template`).
|
||||
Open details settled at planning: attachment ≤1,000,000 B (keeps the 1 MiB edge cap); allowed types
|
||||
(images + pdf/txt/log/doc/docx/rtf/zip/gz/7z); channel from the client (telegram/ios/android/web); guests
|
||||
cannot submit; three-way admin filter.
|
||||
|
||||
## Refinements logged during implementation
|
||||
|
||||
- **Stage 0**: solver `replace` deferred to Stage 2 (nothing imports it yet;
|
||||
@@ -1476,6 +1508,19 @@ provided cert) at the contour caddy; prod VPN; rollback.
|
||||
`proactiveNudgeGap` unit test, the retimed `TestRobotProactiveNudge`, `TestVariantLanguage`,
|
||||
emit (`your_turn`/`game_over` language) and a `TestNudgeRoutedByGameLanguage` integration test.
|
||||
|
||||
### Stage 19 — user feedback
|
||||
- Dropped the planned `feedback_messages.attachment_type` column: the safe content-type is derived from the
|
||||
file-name extension at both validate and serve time (one source of truth), so a stored column was
|
||||
redundant.
|
||||
- The admin filter is **three-way** (Непрочитанные / Прочитанные / Архив) rather than the two first
|
||||
sketched, mirroring complaints' `open·resolved·all`, so a read-but-unarchived message is never
|
||||
unreachable (owner-approved at planning).
|
||||
- The guest gate was extended to the **gateway** at the owner's request: a new `Op.NonGuest` flag plus
|
||||
`is_guest` threaded through the session resolve + gateway cache reject a guest's `feedback.submit` with
|
||||
the `guest_forbidden` result code before any backend call (the backend keeps its own guest check as the
|
||||
backstop). `handleResolveSession` now loads the account best-effort to report `is_guest` (a read error
|
||||
falls back to false so the auth hot path stays resilient).
|
||||
|
||||
## Deferred TODOs (cross-stage)
|
||||
|
||||
- ~~**TODO-1 — publish & version the solver.**~~ **Done in Stage 14.** `scrabble-solver` is
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
// Per-account roles. A role is a named capability or restriction attached to an
|
||||
// account, the reusable replacement for per-feature boolean flags. The set is
|
||||
// expected to grow; roles are validated against KnownRoles in Go so adding one
|
||||
// needs no migration. Granted/revoked from the admin console (/users and the
|
||||
// feedback section).
|
||||
const (
|
||||
// RoleFeedbackBanned forbids the account from submitting feedback (only that;
|
||||
// it is not a full account suspension). See internal/feedback.
|
||||
RoleFeedbackBanned = "feedback_banned"
|
||||
)
|
||||
|
||||
// KnownRoles is the set of roles the console may grant or revoke; an operator
|
||||
// cannot assign an unrecognised role.
|
||||
var KnownRoles = []string{RoleFeedbackBanned}
|
||||
|
||||
// IsKnownRole reports whether role is a recognised account role.
|
||||
func IsKnownRole(role string) bool {
|
||||
for _, r := range KnownRoles {
|
||||
if r == role {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GrantRole gives the account the role, idempotently (a repeat grant is a no-op).
|
||||
func (s *Store) GrantRole(ctx context.Context, accountID uuid.UUID, role string) error {
|
||||
stmt := table.AccountRoles.
|
||||
INSERT(table.AccountRoles.AccountID, table.AccountRoles.Role).
|
||||
VALUES(accountID, role).
|
||||
ON_CONFLICT(table.AccountRoles.AccountID, table.AccountRoles.Role).DO_NOTHING()
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("account: grant role %q to %s: %w", role, accountID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeRole removes the role from the account, idempotently.
|
||||
func (s *Store) RevokeRole(ctx context.Context, accountID uuid.UUID, role string) error {
|
||||
stmt := table.AccountRoles.
|
||||
DELETE().
|
||||
WHERE(table.AccountRoles.AccountID.EQ(postgres.UUID(accountID)).
|
||||
AND(table.AccountRoles.Role.EQ(postgres.String(role))))
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("account: revoke role %q from %s: %w", role, accountID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasRole reports whether the account holds the role.
|
||||
func (s *Store) HasRole(ctx context.Context, accountID uuid.UUID, role string) (bool, error) {
|
||||
var ok bool
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT EXISTS (SELECT 1 FROM backend.account_roles WHERE account_id = $1 AND role = $2)`,
|
||||
accountID, role).Scan(&ok)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("account: has-role %q %s: %w", role, accountID, err)
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// ListRoles returns the account's roles, oldest grant first.
|
||||
func (s *Store) ListRoles(ctx context.Context, accountID uuid.UUID) ([]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT role FROM backend.account_roles WHERE account_id = $1 ORDER BY granted_at ASC`,
|
||||
accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("account: list roles %s: %w", accountID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var role string
|
||||
if err := rows.Scan(&role); err != nil {
|
||||
return nil, fmt.Errorf("account: scan role: %w", err)
|
||||
}
|
||||
out = append(out, role)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -116,3 +116,8 @@ code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
|
||||
.lg-min { color: var(--ok); }
|
||||
.lg-avg { color: var(--accent); }
|
||||
.lg-max { color: var(--danger); }
|
||||
|
||||
/* Feedback: user-controlled message bodies are wrapped and escaped (never HTML);
|
||||
an image attachment is previewed inline, bounded so it cannot dominate the page. */
|
||||
.msgbody { white-space: pre-wrap; word-break: break-word; background: var(--bg); padding: 0.6rem 0.8rem; border-radius: 6px; margin: 0.6rem 0; }
|
||||
.attach { max-width: 100%; max-height: 480px; height: auto; border: 1px solid var(--line); border-radius: 6px; }
|
||||
|
||||
@@ -25,6 +25,7 @@ func TestRendererRendersEveryPage(t *testing.T) {
|
||||
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya", FlaggedHighRate: true}}, Pager: NewPager(1, 50, 1)}, "high-rate"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"},
|
||||
{"throttled", ThrottledView{
|
||||
Episodes: []ThrottleEpisodeRow{{Class: "user", Key: "a1", UserID: "a1", Rejected: 1234, FirstSeen: "2026-06-10 12:00", LastSeen: "2026-06-10 12:05"}},
|
||||
Flagged: []FlaggedAccountRow{{ID: "a1", DisplayName: "Kaya", FlaggedAt: "2026-06-10 12:05"}},
|
||||
@@ -35,6 +36,8 @@ func TestRendererRendersEveryPage(t *testing.T) {
|
||||
{"game_detail", GameDetailView{ID: "g1", Variant: "scrabble_en", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
|
||||
{"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"},
|
||||
{"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1"}}, Pager: NewPager(1, 50, 1)}, "good luck"},
|
||||
{"feedback", FeedbackView{Items: []FeedbackRow{{ID: "f1", AccountID: "a1", SenderName: "Kaya", Source: "telegram", Channel: "web", HasAttachment: true, Replied: true}}, Status: "unread", Pager: NewPager(1, 50, 1)}, "replied"},
|
||||
{"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "telegram", InterfaceLanguage: "en", BotLanguage: "ru", Body: "please fix the board", HasAttachment: true, AttachmentName: "shot.png", IsImage: true, Banned: true}, "bot: ru"},
|
||||
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"},
|
||||
{"dictionary", DictionaryView{ActiveVersion: "v1.0.0", Variants: []VariantVersions{{Variant: "scrabble_en", Versions: []string{"v1.0.0"}}}, Changes: []DictChangeRow{{Variant: "scrabble_en", Word: "qi", Action: "add"}}}, "Update dictionaries"},
|
||||
{"dictionary_preview", DictionaryPreviewView{Version: "v1.1.0", Token: "0123456789abcdef0123456789abcdef", ActiveVersion: "v1.0.0", Variants: []VariantDiffRow{{Variant: "scrabble_en", AddedCount: 2, RemovedCount: 1, AddedSample: []string{"qi", "za"}, RemovedSample: []string{"xqz"}, RemovedTruncated: true}}}, "v1.1.0"},
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
|
||||
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
|
||||
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
|
||||
<a href="/_gm/feedback"{{if eq .ActiveNav "feedback"}} class="active"{{end}}>Feedback</a>
|
||||
<a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a>
|
||||
<a href="/_gm/throttled"{{if eq .ActiveNav "throttled"}} class="active"{{end}}>Throttled</a>
|
||||
<a href="/_gm/reasons"{{if eq .ActiveNav "reasons"}} class="active"{{end}}>Reasons</a>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<a class="card" href="/_gm/games"><h2>Games</h2><p class="bignum">{{.Games}}</p></a>
|
||||
<a class="card" href="/_gm/games?status=active"><h2>Active games</h2><p class="bignum">{{.ActiveGames}}</p></a>
|
||||
<a class="card" href="/_gm/complaints?status=open"><h2>Open complaints</h2><p class="bignum">{{.OpenComplaints}}</p></a>
|
||||
<a class="card" href="/_gm/feedback?status=unread"><h2>Unread feedback</h2><p class="bignum">{{.OpenFeedback}}</p></a>
|
||||
<a class="card" href="/_gm/dictionary"><h2>Pending dict changes</h2><p class="bignum">{{.PendingChanges}}</p></a>
|
||||
</div>
|
||||
<section class="panel">
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{{define "content" -}}
|
||||
<h1>Feedback</h1>
|
||||
{{with .Data}}
|
||||
<nav class="subnav">
|
||||
<a href="/_gm/feedback?status=unread"{{if eq .Status "unread"}} class="active"{{end}}>unread</a> ·
|
||||
<a href="/_gm/feedback?status=read"{{if eq .Status "read"}} class="active"{{end}}>read</a> ·
|
||||
<a href="/_gm/feedback?status=archived"{{if eq .Status "archived"}} class="active"{{end}}>archived</a>
|
||||
</nav>
|
||||
<form class="form" method="get" action="/_gm/feedback">
|
||||
<input type="hidden" name="status" value="{{.Status}}">
|
||||
{{if .UserID}}<input type="hidden" name="user" value="{{.UserID}}">{{end}}
|
||||
<input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)">
|
||||
<input name="ext" value="{{.ExtMask}}" placeholder="external id mask (* ?)">
|
||||
<button type="submit">Filter</button>
|
||||
</form>
|
||||
<table class="list">
|
||||
<thead><tr><th>Sender</th><th>Source</th><th>Channel</th><th>Attach</th><th>Reply</th><th>State</th><th>Filed</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td><a href="/_gm/feedback/{{.ID}}">{{.SenderName}}</a></td>
|
||||
<td>{{.Source}}</td>
|
||||
<td>{{.Channel}}</td>
|
||||
<td>{{if .HasAttachment}}yes{{else}}<span class="note">—</span>{{end}}</td>
|
||||
<td>{{if .Replied}}<span class="ok">replied</span>{{else}}<span class="note">—</span>{{end}}</td>
|
||||
<td>{{if .Archived}}archived{{else if .Read}}read{{else}}<span class="warn">unread</span>{{end}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
</tr>
|
||||
{{else}}<tr><td colspan="7"><span class="note">no feedback</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<nav class="pager">
|
||||
{{if .Pager.HasPrev}}<a href="/_gm/feedback?{{.FilterQuery}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||
{{if .Pager.HasNext}}<a href="/_gm/feedback?{{.FilterQuery}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,46 @@
|
||||
{{define "content" -}}
|
||||
{{with .Data}}
|
||||
<h1>Feedback</h1>
|
||||
<nav class="subnav"><a href="/_gm/feedback">« feedback</a> · <a href="/_gm/users/{{.AccountID}}">user</a></nav>
|
||||
<section class="panel"><h2>Message</h2>
|
||||
<ul class="kv">
|
||||
<li><b>From</b> <a href="/_gm/users/{{.AccountID}}">{{.SenderName}}</a> ({{.Source}})</li>
|
||||
<li><b>Channel</b> {{.Channel}}{{if .BotLanguage}} (bot: {{.BotLanguage}}){{end}}</li>
|
||||
<li><b>Interface language</b> {{.InterfaceLanguage}}</li>
|
||||
<li><b>IP</b> {{if .IP}}<code>{{.IP}}</code>{{else}}<span class="note">none</span>{{end}}</li>
|
||||
<li><b>Filed</b> {{.CreatedAt}}</li>
|
||||
<li><b>State</b> {{if .Archived}}archived{{else if .Read}}read{{else}}<span class="warn">unread</span>{{end}}</li>
|
||||
{{if .Banned}}<li><b>Feedback</b> <span class="warn">sender is banned from feedback</span></li>{{end}}
|
||||
</ul>
|
||||
<div class="msgbody">{{.Body}}</div>
|
||||
{{if .HasAttachment}}
|
||||
<h3>Attachment</h3>
|
||||
{{if .IsImage}}<p><img class="attach" src="/_gm/feedback/{{.ID}}/attachment" alt="attachment"></p>{{end}}
|
||||
<p><a href="/_gm/feedback/{{.ID}}/attachment" download>download {{.AttachmentName}}</a></p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{if .Replied}}
|
||||
<section class="panel"><h2>Current reply</h2>
|
||||
<div class="msgbody">{{.ReplyBody}}</div>
|
||||
<p class="note">sent {{.RepliedAt}}</p>
|
||||
</section>
|
||||
{{end}}
|
||||
<section class="panel"><h2>{{if .Replied}}Re-reply{{else}}Reply{{end}}</h2>
|
||||
<form class="form col" method="post" action="/_gm/feedback/{{.ID}}/reply">
|
||||
<label>Reply <textarea name="reply" required></textarea></label>
|
||||
<div><button type="submit">Send reply</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel"><h2>Actions</h2>
|
||||
{{if not .Read}}<form class="form" method="post" action="/_gm/feedback/{{.ID}}/read"><button type="submit">Mark read</button></form>{{end}}
|
||||
{{if not .Archived}}<form class="form" method="post" action="/_gm/feedback/{{.ID}}/archive"><button type="submit">Archive</button></form>{{end}}
|
||||
<form class="form col" method="post" action="/_gm/feedback/{{.ID}}/delete">
|
||||
<label><input type="checkbox" name="block" value="1"> ban the player from feedback</label>
|
||||
<div>
|
||||
<button type="submit">Delete</button>
|
||||
<button type="submit" formaction="/_gm/feedback/{{.ID}}/delete-all">Delete all from this player</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -1,7 +1,7 @@
|
||||
{{define "content" -}}
|
||||
{{with .Data}}
|
||||
<h1>{{.DisplayName}}</h1>
|
||||
<nav class="subnav"><a href="/_gm/users">« users</a> · <a href="/_gm/messages?user={{.ID}}">messages</a></nav>
|
||||
<nav class="subnav"><a href="/_gm/users">« users</a> · <a href="/_gm/messages?user={{.ID}}">messages</a> · <a href="/_gm/feedback?user={{.ID}}">feedback</a></nav>
|
||||
<div class="cards">
|
||||
<section class="panel"><h2>Account</h2>
|
||||
<ul class="kv">
|
||||
@@ -67,6 +67,23 @@
|
||||
<div><button type="submit">{{if .Suspension.Blocked}}Re-block{{else}}Block{{end}}</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel"><h2>Roles</h2>
|
||||
{{$id := .ID}}
|
||||
{{if .Roles}}
|
||||
<table class="list">
|
||||
<thead><tr><th>Role</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Roles}}
|
||||
<tr><td><code>{{.}}</code></td><td><form class="form" method="post" action="/_gm/users/{{$id}}/revoke-role"><input type="hidden" name="role" value="{{.}}"><button type="submit">Revoke</button></form></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}<p class="note">no roles</p>{{end}}
|
||||
<form class="form col" method="post" action="/_gm/users/{{$id}}/grant-role">
|
||||
<label>Grant role <select name="role">{{range .KnownRoles}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
|
||||
<div><button type="submit">Grant</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{{if .MoveChart}}
|
||||
<section class="panel"><h2>Move timing</h2>
|
||||
<p class="note">Think time per move number across all games — <span class="lg lg-min">min</span> · <span class="lg lg-avg">mean</span> · <span class="lg lg-max">max</span>.</p>
|
||||
|
||||
@@ -42,6 +42,7 @@ type DashboardView struct {
|
||||
Games int
|
||||
ActiveGames int
|
||||
OpenComplaints int
|
||||
OpenFeedback int
|
||||
PendingChanges int
|
||||
// ActiveVersion is the dictionary version new games pin (the persisted active
|
||||
// version), distinct from the per-variant resident versions.
|
||||
@@ -142,6 +143,10 @@ type UserDetailView struct {
|
||||
// form; Reasons is the operator-editable reason picklist offered in the block form.
|
||||
Suspension SuspensionView
|
||||
Reasons []ReasonOption
|
||||
// Roles is the account's current roles (each revocable); KnownRoles is the set the
|
||||
// grant form offers. The first role is the feedback ban (see internal/account).
|
||||
Roles []string
|
||||
KnownRoles []string
|
||||
}
|
||||
|
||||
// SuspensionView is an account's current manual-block state shown on the user card: whether it
|
||||
@@ -362,3 +367,63 @@ type MessageView struct {
|
||||
Body string
|
||||
Back string
|
||||
}
|
||||
|
||||
// FeedbackView is the paginated user-feedback queue. Status is the active
|
||||
// unread/read/archived filter; NameMask/ExtMask are the sender glob filters;
|
||||
// UserID pins the list to one account (the per-user link from /users);
|
||||
// FilterQuery is the active filters URL-encoded for the pager links (already
|
||||
// escaped, hence template.URL — see UsersView.FilterQuery).
|
||||
type FeedbackView struct {
|
||||
Items []FeedbackRow
|
||||
Status string
|
||||
NameMask string
|
||||
ExtMask string
|
||||
UserID string
|
||||
Pager Pager
|
||||
FilterQuery template.URL
|
||||
}
|
||||
|
||||
// FeedbackRow is one feedback message in the queue: its sender (linked to the user
|
||||
// card), source, channel, whether it has an attachment / a reply, its state and
|
||||
// time.
|
||||
type FeedbackRow struct {
|
||||
ID string
|
||||
AccountID string
|
||||
SenderName string
|
||||
Source string
|
||||
Channel string
|
||||
HasAttachment bool
|
||||
Read bool
|
||||
Replied bool
|
||||
Archived bool
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// FeedbackDetailView is one feedback message with its body, attachment, state and
|
||||
// the reply / archive / delete forms. Body, AttachmentName, SenderName and IP are
|
||||
// user-controlled and rendered as plain auto-escaped text. IsImage gates the inline
|
||||
// <img> preview; Banned shows whether the sender already holds the feedback ban.
|
||||
type FeedbackDetailView struct {
|
||||
ID string
|
||||
AccountID string
|
||||
SenderName string
|
||||
Source string
|
||||
Channel string
|
||||
// InterfaceLanguage is the sender's interface language (account preference);
|
||||
// BotLanguage is the connector bot they last used (en/ru), set only for a
|
||||
// message that arrived through an external connector (Telegram).
|
||||
InterfaceLanguage string
|
||||
BotLanguage string
|
||||
IP string
|
||||
Body string
|
||||
HasAttachment bool
|
||||
AttachmentName string
|
||||
IsImage bool
|
||||
Read bool
|
||||
Archived bool
|
||||
Replied bool
|
||||
ReplyBody string
|
||||
RepliedAt string
|
||||
CreatedAt string
|
||||
Banned bool
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// maxAttachmentBytes caps a single attachment's raw size. Chosen to fit, with the
|
||||
// message text and the FlatBuffers framing, under the gateway's 1 MiB edge body
|
||||
// cap, so the whole submit request passes without weakening that cap.
|
||||
const maxAttachmentBytes = 1_000_000
|
||||
|
||||
// allowedExt is the attachment extension allow-list. It is mirrored on the UI as a
|
||||
// pre-upload gate; the server re-checks here as the trust boundary (metadata only,
|
||||
// the file content is never parsed). Images render inline in the console; the rest
|
||||
// are download-only.
|
||||
var allowedExt = map[string]bool{
|
||||
"png": true, "jpg": true, "jpeg": true, "webp": true, "gif": true, // images
|
||||
"pdf": true, "txt": true, "log": true, "doc": true, "docx": true,
|
||||
"rtf": true, "zip": true, "gz": true, "7z": true,
|
||||
}
|
||||
|
||||
// imageType maps an image extension to the content-type the console serves it with
|
||||
// (loaded only via <img>, which never executes, so a renamed non-image is inert).
|
||||
var imageType = map[string]string{
|
||||
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
|
||||
"webp": "image/webp", "gif": "image/gif",
|
||||
}
|
||||
|
||||
// ext returns name's lower-cased extension without the leading dot.
|
||||
func ext(name string) string {
|
||||
return strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
|
||||
}
|
||||
|
||||
// AllowedAttachment reports whether name's extension is on the allow-list.
|
||||
func AllowedAttachment(name string) bool {
|
||||
return allowedExt[ext(name)]
|
||||
}
|
||||
|
||||
// IsImage reports whether name is an inline-previewable image by its extension.
|
||||
func IsImage(name string) bool {
|
||||
_, ok := imageType[ext(name)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ContentType returns the safe content-type the console serves the attachment
|
||||
// with: the matching image type for an image, else application/octet-stream so a
|
||||
// non-image is downloaded rather than rendered.
|
||||
func ContentType(name string) string {
|
||||
if t, ok := imageType[ext(name)]; ok {
|
||||
return t
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package feedback
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAllowedAttachment(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
file string
|
||||
want bool
|
||||
}{
|
||||
{"png image", "shot.png", true},
|
||||
{"jpeg upper-case ext", "Photo.JPG", true},
|
||||
{"pdf doc", "report.pdf", true},
|
||||
{"archive 7z", "logs.7z", true},
|
||||
{"doc with dotted name", "my.notes.docx", true},
|
||||
{"disallowed exe", "evil.exe", false},
|
||||
{"disallowed svg (xss vector)", "x.svg", false},
|
||||
{"disallowed html", "x.html", false},
|
||||
{"no extension", "README", false},
|
||||
{"empty name", "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := AllowedAttachment(tt.file); got != tt.want {
|
||||
t.Errorf("AllowedAttachment(%q) = %v, want %v", tt.file, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsImageAndContentType(t *testing.T) {
|
||||
tests := []struct {
|
||||
file string
|
||||
isImage bool
|
||||
ctype string
|
||||
}{
|
||||
{"a.png", true, "image/png"},
|
||||
{"a.jpg", true, "image/jpeg"},
|
||||
{"a.jpeg", true, "image/jpeg"},
|
||||
{"a.webp", true, "image/webp"},
|
||||
{"a.gif", true, "image/gif"},
|
||||
{"a.pdf", false, "application/octet-stream"},
|
||||
{"a.zip", false, "application/octet-stream"},
|
||||
{"a.svg", false, "application/octet-stream"}, // even if it slipped past, never image/svg+xml
|
||||
{"noext", false, "application/octet-stream"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.file, func(t *testing.T) {
|
||||
if got := IsImage(tt.file); got != tt.isImage {
|
||||
t.Errorf("IsImage(%q) = %v, want %v", tt.file, got, tt.isImage)
|
||||
}
|
||||
if got := ContentType(tt.file); got != tt.ctype {
|
||||
t.Errorf("ContentType(%q) = %q, want %q", tt.file, got, tt.ctype)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeChannel(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"telegram", "telegram"},
|
||||
{"ios", "ios"},
|
||||
{"android", "android"},
|
||||
{"web", "web"},
|
||||
{" iOS ", "ios"}, // trimmed + lower-cased
|
||||
{"TELEGRAM", "telegram"},
|
||||
{"", "web"}, // unknown -> web
|
||||
{"windows", "web"}, // unknown -> web
|
||||
{"'; DROP", "web"}, // junk -> web
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.in, func(t *testing.T) {
|
||||
if got := normalizeChannel(tt.in); got != tt.want {
|
||||
t.Errorf("normalizeChannel(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/notify"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxBodyRunes caps a feedback message (and an operator reply) length.
|
||||
maxBodyRunes = 1024
|
||||
// replyVisibleFor is how long an operator reply stays shown to the player after
|
||||
// it is delivered (read).
|
||||
replyVisibleFor = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// Submit / reply validation errors. The transport layer maps them to stable result
|
||||
// codes; the UI maps those to the messages shown on the feedback screen.
|
||||
var (
|
||||
ErrEmptyMessage = errors.New("feedback: empty message")
|
||||
ErrMessageTooLong = errors.New("feedback: message too long")
|
||||
ErrAttachmentTooLarge = errors.New("feedback: attachment too large")
|
||||
ErrAttachmentType = errors.New("feedback: attachment type not allowed")
|
||||
ErrGuestForbidden = errors.New("feedback: guests cannot submit feedback")
|
||||
ErrBanned = errors.New("feedback: account is banned from feedback")
|
||||
ErrPendingReview = errors.New("feedback: previous message still pending review")
|
||||
)
|
||||
|
||||
// validChannels enumerates the submitting platforms a client may report; anything
|
||||
// else is normalised to "web" (the channel is informational, never a gate).
|
||||
var validChannels = map[string]bool{"telegram": true, "ios": true, "android": true, "web": true}
|
||||
|
||||
// Service is the feedback domain: the only writer of feedback_messages. It reads
|
||||
// accounts for the guest/role gates and publishes the reply notification.
|
||||
type Service struct {
|
||||
store *Store
|
||||
accounts *account.Store
|
||||
pub notify.Publisher
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService constructs a Service. store owns feedback_messages; accounts supplies
|
||||
// the guest flag and the feedback-ban role.
|
||||
func NewService(store *Store, accounts *account.Store) *Service {
|
||||
return &Service{
|
||||
store: store,
|
||||
accounts: accounts,
|
||||
pub: notify.Nop{},
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
|
||||
// SetNotifier installs the live-event publisher used to push the "you have a reply"
|
||||
// signal to the player. It must be called during startup wiring; the default is
|
||||
// notify.Nop (no live events).
|
||||
func (svc *Service) SetNotifier(p notify.Publisher) {
|
||||
if p != nil {
|
||||
svc.pub = p
|
||||
}
|
||||
}
|
||||
|
||||
// Submit stores a feedback message from accountID. It rejects guests, feedback-
|
||||
// banned accounts and a sender who still has a message pending review, then
|
||||
// validates the body (non-empty, within the rune limit) and the optional
|
||||
// attachment (size and extension allow-list). senderIP is the gateway-forwarded
|
||||
// client IP (validated); channel is the submitting platform.
|
||||
func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, senderIP string) error {
|
||||
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if acc.IsGuest {
|
||||
return ErrGuestForbidden
|
||||
}
|
||||
banned, err := svc.accounts.HasRole(ctx, accountID, account.RoleFeedbackBanned)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if banned {
|
||||
return ErrBanned
|
||||
}
|
||||
pending, err := svc.store.HasUnread(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pending {
|
||||
return ErrPendingReview
|
||||
}
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return ErrEmptyMessage
|
||||
}
|
||||
if utf8.RuneCountInString(body) > maxBodyRunes {
|
||||
return ErrMessageTooLong
|
||||
}
|
||||
if len(attachment) > 0 {
|
||||
if len(attachment) > maxAttachmentBytes {
|
||||
return ErrAttachmentTooLarge
|
||||
}
|
||||
if !AllowedAttachment(attachmentName) {
|
||||
return ErrAttachmentType
|
||||
}
|
||||
} else {
|
||||
attachmentName = "" // a name without bytes carries no attachment
|
||||
}
|
||||
ch := normalizeChannel(channel)
|
||||
// Snapshot the languages at submit time (acc is already loaded for the guest check):
|
||||
// the sender's interface language, and the connector bot language when the message
|
||||
// came through an external connector (currently Telegram).
|
||||
var channelLang string
|
||||
if ch == "telegram" {
|
||||
channelLang = acc.ServiceLanguage
|
||||
}
|
||||
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, channelLang, parseIP(senderIP))
|
||||
return err
|
||||
}
|
||||
|
||||
// State is the player's feedback screen state. Reason is "" (can send), "pending"
|
||||
// (a previous message is unreviewed) or "banned". Reply is the operator's answer to
|
||||
// show, or nil. Fetching it delivers any pending replies (delivery counts as read),
|
||||
// clearing the badge.
|
||||
type State struct {
|
||||
CanSend bool
|
||||
BlockedReason string
|
||||
Reply *Reply
|
||||
}
|
||||
|
||||
// Reply is the operator's answer shown back to the player.
|
||||
type Reply struct {
|
||||
Body string
|
||||
RepliedAt time.Time
|
||||
}
|
||||
|
||||
// State computes the feedback screen state for accountID and marks any pending
|
||||
// replies delivered.
|
||||
func (svc *Service) State(ctx context.Context, accountID uuid.UUID) (State, error) {
|
||||
banned, err := svc.accounts.HasRole(ctx, accountID, account.RoleFeedbackBanned)
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
pending, err := svc.store.HasUnread(ctx, accountID)
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
st := State{CanSend: !banned && !pending}
|
||||
switch {
|
||||
case banned:
|
||||
st.BlockedReason = "banned"
|
||||
case pending:
|
||||
st.BlockedReason = "pending"
|
||||
}
|
||||
vr, ok, err := svc.store.LatestVisibleReply(ctx, accountID, svc.now().Add(-replyVisibleFor))
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
if ok {
|
||||
st.Reply = &Reply{Body: vr.Body, RepliedAt: vr.RepliedAt}
|
||||
}
|
||||
// Opening the screen delivers every pending reply; mark them read to clear the
|
||||
// badge (only the latest is shown, but all are now delivered).
|
||||
if err := svc.store.MarkRepliesDelivered(ctx, accountID, svc.now()); err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// ReplyUnread reports whether the account has an operator reply not yet delivered —
|
||||
// the lobby/Info badge condition. It has no side effect (the lobby may poll it).
|
||||
func (svc *Service) ReplyUnread(ctx context.Context, accountID uuid.UUID) (bool, error) {
|
||||
return svc.store.HasUnreadReply(ctx, accountID)
|
||||
}
|
||||
|
||||
// AdminList returns the filtered console feedback list, paginated.
|
||||
func (svc *Service) AdminList(ctx context.Context, f AdminFilter, limit, offset int) ([]AdminRow, error) {
|
||||
return svc.store.AdminList(ctx, f, limit, offset)
|
||||
}
|
||||
|
||||
// AdminCount counts the filtered console feedback list.
|
||||
func (svc *Service) AdminCount(ctx context.Context, f AdminFilter) (int, error) {
|
||||
return svc.store.AdminCount(ctx, f)
|
||||
}
|
||||
|
||||
// AdminGet loads one message for the console detail view, or ErrNotFound.
|
||||
func (svc *Service) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error) {
|
||||
return svc.store.AdminGet(ctx, id)
|
||||
}
|
||||
|
||||
// CountUnread counts the active (unread, not archived) feedback queue for the
|
||||
// dashboard.
|
||||
func (svc *Service) CountUnread(ctx context.Context) (int, error) {
|
||||
return svc.store.CountUnread(ctx)
|
||||
}
|
||||
|
||||
// Attachment returns a message's file name and bytes, reporting false when absent.
|
||||
func (svc *Service) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) {
|
||||
return svc.store.Attachment(ctx, id)
|
||||
}
|
||||
|
||||
// MarkRead marks a message dealt-with (the manual "read" action).
|
||||
func (svc *Service) MarkRead(ctx context.Context, id uuid.UUID) error {
|
||||
return svc.store.MarkRead(ctx, id, svc.now())
|
||||
}
|
||||
|
||||
// Reply sets the operator reply on a message (marking it read), then pushes the
|
||||
// "you have a reply" notification to the player.
|
||||
func (svc *Service) Reply(ctx context.Context, id uuid.UUID, body string) error {
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return ErrEmptyMessage
|
||||
}
|
||||
if utf8.RuneCountInString(body) > maxBodyRunes {
|
||||
return ErrMessageTooLong
|
||||
}
|
||||
accountID, err := svc.store.Reply(ctx, id, body, svc.now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
svc.pub.Publish(notify.Notification(accountID, notify.NotifyAdminReply))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Archive files a handled message away (marking it read).
|
||||
func (svc *Service) Archive(ctx context.Context, id uuid.UUID) error {
|
||||
return svc.store.Archive(ctx, id, svc.now())
|
||||
}
|
||||
|
||||
// Delete physically removes a message and its attachment.
|
||||
func (svc *Service) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
return svc.store.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// DeleteAllByAccount physically removes every message of an account.
|
||||
func (svc *Service) DeleteAllByAccount(ctx context.Context, accountID uuid.UUID) error {
|
||||
return svc.store.DeleteAllByAccount(ctx, accountID)
|
||||
}
|
||||
|
||||
// parseIP returns a validated canonical IP string, or nil when raw is empty or not
|
||||
// a valid address.
|
||||
func parseIP(raw string) *string {
|
||||
addr, err := netip.ParseAddr(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
canon := addr.String()
|
||||
return &canon
|
||||
}
|
||||
|
||||
// normalizeChannel lower-cases and validates the client-reported channel, falling
|
||||
// back to "web" for anything unrecognised.
|
||||
func normalizeChannel(c string) string {
|
||||
c = strings.ToLower(strings.TrimSpace(c))
|
||||
if validChannels[c] {
|
||||
return c
|
||||
}
|
||||
return "web"
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
// Package feedback owns user feedback: the flat list of messages a registered
|
||||
// player sends to the operators (each with an optional single attachment), the
|
||||
// anti-spam "one pending message at a time" gate, and the operator's inline reply
|
||||
// delivered back to the player's app. It is modelled on the admin chat-moderation
|
||||
// surface (internal/social/adminchat.go) and uses raw SQL throughout for the
|
||||
// attachment bytea, the COALESCE-based "set once" stamps, and the reply-visibility
|
||||
// window.
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when no feedback message matches the lookup.
|
||||
var ErrNotFound = errors.New("feedback: not found")
|
||||
|
||||
// Store is the Postgres-backed query surface for feedback_messages.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore constructs a Store wrapping db.
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// Insert stores one feedback message from accountID and returns its id. attachment
|
||||
// is the raw file bytes (nil for none); attachmentName, ip and a non-default
|
||||
// channel are stored as given. lang (the sender's interface language) and channelLang
|
||||
// (the connector bot language, empty for a non-connector channel) are snapshots taken
|
||||
// now, so the operator later sees the state at submit time. created_at defaults to
|
||||
// now() in the database.
|
||||
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang, channelLang string, ip *string) (uuid.UUID, error) {
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err)
|
||||
}
|
||||
var att []byte // a nil []byte binds as bytea NULL
|
||||
if len(attachment) > 0 {
|
||||
att = attachment
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO backend.feedback_messages
|
||||
(message_id, account_id, body, attachment, attachment_name, channel, lang, channel_lang, sender_ip)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), nullStr(channelLang), ip); err != nil {
|
||||
return uuid.Nil, fmt.Errorf("feedback: insert: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// nullStr maps an empty string to a NULL text bind, else the value.
|
||||
func nullStr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// HasUnread reports whether the account has any message the operator has not yet
|
||||
// dealt with (read_at IS NULL) — the anti-spam gate's condition.
|
||||
func (s *Store) HasUnread(ctx context.Context, accountID uuid.UUID) (bool, error) {
|
||||
var ok bool
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT EXISTS (SELECT 1 FROM backend.feedback_messages WHERE account_id = $1 AND read_at IS NULL)`,
|
||||
accountID).Scan(&ok); err != nil {
|
||||
return false, fmt.Errorf("feedback: has-unread %s: %w", accountID, err)
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// HasUnreadReply reports whether the account has an operator reply not yet
|
||||
// delivered to its app (reply_read_at IS NULL) — the badge's condition.
|
||||
func (s *Store) HasUnreadReply(ctx context.Context, accountID uuid.UUID) (bool, error) {
|
||||
var ok bool
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT EXISTS (SELECT 1 FROM backend.feedback_messages
|
||||
WHERE account_id = $1 AND reply_body IS NOT NULL AND reply_read_at IS NULL)`,
|
||||
accountID).Scan(&ok); err != nil {
|
||||
return false, fmt.Errorf("feedback: has-unread-reply %s: %w", accountID, err)
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// VisibleReply is the operator reply shown back to the player: the reply on their
|
||||
// most recent replied message still inside the visibility window.
|
||||
type VisibleReply struct {
|
||||
Body string
|
||||
RepliedAt time.Time
|
||||
}
|
||||
|
||||
// LatestVisibleReply returns the reply on the account's **most recent** message, if
|
||||
// that message carries one still visible to the player — not yet delivered, or
|
||||
// delivered after cutoff (one week ago). Binding it to the single latest message
|
||||
// means that the moment the player sends a newer message the previous reply stops
|
||||
// showing (the new message has no reply yet). Reports false when there is none.
|
||||
func (s *Store) LatestVisibleReply(ctx context.Context, accountID uuid.UUID, cutoff time.Time) (VisibleReply, bool, error) {
|
||||
var vr VisibleReply
|
||||
var repliedAt sql.NullTime
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(reply_body, ''), replied_at FROM (
|
||||
SELECT reply_body, replied_at, reply_read_at FROM backend.feedback_messages
|
||||
WHERE account_id = $1 ORDER BY created_at DESC LIMIT 1
|
||||
) latest
|
||||
WHERE reply_body IS NOT NULL AND (reply_read_at IS NULL OR reply_read_at > $2)`,
|
||||
accountID, cutoff).Scan(&vr.Body, &repliedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return VisibleReply{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return VisibleReply{}, false, fmt.Errorf("feedback: latest visible reply %s: %w", accountID, err)
|
||||
}
|
||||
if repliedAt.Valid {
|
||||
vr.RepliedAt = repliedAt.Time
|
||||
}
|
||||
return vr, true, nil
|
||||
}
|
||||
|
||||
// MarkRepliesDelivered stamps reply_read_at on every not-yet-delivered reply of the
|
||||
// account (delivery counts as read), clearing the badge when the player opens the
|
||||
// feedback screen.
|
||||
func (s *Store) MarkRepliesDelivered(ctx context.Context, accountID uuid.UUID, at time.Time) error {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE backend.feedback_messages SET reply_read_at = $2
|
||||
WHERE account_id = $1 AND reply_body IS NOT NULL AND reply_read_at IS NULL`,
|
||||
accountID, at); err != nil {
|
||||
return fmt.Errorf("feedback: mark replies delivered %s: %w", accountID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkRead stamps read_at the first time, marking the message dealt-with without any
|
||||
// other action; a re-mark keeps the original time.
|
||||
func (s *Store) MarkRead(ctx context.Context, id uuid.UUID, at time.Time) error {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE backend.feedback_messages SET read_at = COALESCE(read_at, $2) WHERE message_id = $1`,
|
||||
id, at); err != nil {
|
||||
return fmt.Errorf("feedback: mark read %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reply sets (or replaces) the operator reply on a message, marks it read, and
|
||||
// resets its delivery so the player is notified again. Returns the message's
|
||||
// account_id for the live notification, or ErrNotFound.
|
||||
func (s *Store) Reply(ctx context.Context, id uuid.UUID, body string, at time.Time) (uuid.UUID, error) {
|
||||
var accountID uuid.UUID
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`UPDATE backend.feedback_messages
|
||||
SET reply_body = $2, replied_at = $3, reply_read_at = NULL, read_at = COALESCE(read_at, $3)
|
||||
WHERE message_id = $1
|
||||
RETURNING account_id`,
|
||||
id, body, at).Scan(&accountID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return uuid.Nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("feedback: reply %s: %w", id, err)
|
||||
}
|
||||
return accountID, nil
|
||||
}
|
||||
|
||||
// Archive files a handled message away and marks it read.
|
||||
func (s *Store) Archive(ctx context.Context, id uuid.UUID, at time.Time) error {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE backend.feedback_messages
|
||||
SET archived_at = COALESCE(archived_at, $2), read_at = COALESCE(read_at, $2)
|
||||
WHERE message_id = $1`,
|
||||
id, at); err != nil {
|
||||
return fmt.Errorf("feedback: archive %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete physically removes a message (with its attachment).
|
||||
func (s *Store) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM backend.feedback_messages WHERE message_id = $1`, id); err != nil {
|
||||
return fmt.Errorf("feedback: delete %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAllByAccount physically removes every message of an account.
|
||||
func (s *Store) DeleteAllByAccount(ctx context.Context, accountID uuid.UUID) error {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM backend.feedback_messages WHERE account_id = $1`, accountID); err != nil {
|
||||
return fmt.Errorf("feedback: delete all %s: %w", accountID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Attachment returns a message's stored file name and bytes, reporting false when
|
||||
// the message has no attachment or does not exist.
|
||||
func (s *Store) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) {
|
||||
var name string
|
||||
var data []byte
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(attachment_name, ''), attachment FROM backend.feedback_messages WHERE message_id = $1`,
|
||||
id).Scan(&name, &data)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, false, fmt.Errorf("feedback: attachment %s: %w", id, err)
|
||||
}
|
||||
if data == nil {
|
||||
return "", nil, false, nil
|
||||
}
|
||||
return name, data, true, nil
|
||||
}
|
||||
|
||||
// AdminMessage is one feedback message in the operator console detail view: the
|
||||
// message with its sender's resolved display name and source. The attachment bytes
|
||||
// are not loaded here (served separately); only their presence and name are.
|
||||
type AdminMessage struct {
|
||||
ID uuid.UUID
|
||||
AccountID uuid.UUID
|
||||
SenderName string
|
||||
Source string
|
||||
Body string
|
||||
Channel string
|
||||
// Lang is the sender's interface language and ChannelLang the connector bot language
|
||||
// (en/ru, empty for a non-connector channel) — both snapshotted at submit time.
|
||||
Lang string
|
||||
ChannelLang string
|
||||
SenderIP string
|
||||
HasAttachment bool
|
||||
AttachmentName string
|
||||
Read bool
|
||||
Archived bool
|
||||
Replied bool
|
||||
ReplyBody string
|
||||
RepliedAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// AdminRow is one feedback message in the console list (a lighter projection).
|
||||
type AdminRow struct {
|
||||
ID uuid.UUID
|
||||
AccountID uuid.UUID
|
||||
SenderName string
|
||||
Source string
|
||||
Channel string
|
||||
HasAttachment bool
|
||||
Read bool
|
||||
Replied bool
|
||||
Archived bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// AdminFilter narrows the console feedback list. Status is one of "unread"
|
||||
// (default), "read" or "archived". NameMask/ExtMask are glob masks
|
||||
// (account.LikePattern) on the sender's display name / any identity's external id;
|
||||
// AccountID, when set, restricts to one account (the per-user link from /users).
|
||||
type AdminFilter struct {
|
||||
Status string
|
||||
NameMask string
|
||||
ExtMask string
|
||||
AccountID uuid.UUID
|
||||
}
|
||||
|
||||
// feedbackSource projects a sender's source: guest, robot, or its oldest identity
|
||||
// kind ("—" when it has none). Mirrors social.adminMessageSource.
|
||||
const feedbackSource = `CASE
|
||||
WHEN a.is_guest THEN 'guest'
|
||||
WHEN EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'robot') THEN 'robot'
|
||||
ELSE COALESCE((SELECT i2.kind FROM backend.identities i2 WHERE i2.account_id = a.account_id ORDER BY i2.created_at ASC LIMIT 1), '—')
|
||||
END`
|
||||
|
||||
// adminWhere builds the shared WHERE clause and its positional args (from $1).
|
||||
func adminWhere(f AdminFilter) (string, []any) {
|
||||
var where string
|
||||
switch f.Status {
|
||||
case "archived":
|
||||
where = `m.archived_at IS NOT NULL`
|
||||
case "read":
|
||||
where = `m.read_at IS NOT NULL AND m.archived_at IS NULL`
|
||||
default: // unread
|
||||
where = `m.read_at IS NULL AND m.archived_at IS NULL`
|
||||
}
|
||||
var args []any
|
||||
if f.AccountID != uuid.Nil {
|
||||
args = append(args, f.AccountID)
|
||||
where += fmt.Sprintf(` AND m.account_id = $%d`, len(args))
|
||||
}
|
||||
if name := account.LikePattern(f.NameMask); name != "" {
|
||||
args = append(args, name)
|
||||
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
|
||||
}
|
||||
if ext := account.LikePattern(f.ExtMask); ext != "" {
|
||||
args = append(args, ext)
|
||||
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities ie WHERE ie.account_id = a.account_id AND ie.external_id ILIKE $%d ESCAPE '\')`, len(args))
|
||||
}
|
||||
return where, args
|
||||
}
|
||||
|
||||
// AdminList returns the filtered console feedback list, newest first, paginated.
|
||||
func (s *Store) AdminList(ctx context.Context, f AdminFilter, limit, offset int) ([]AdminRow, error) {
|
||||
where, args := adminWhere(f)
|
||||
q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.channel,
|
||||
(m.attachment IS NOT NULL), (m.read_at IS NOT NULL), (m.reply_body IS NOT NULL), (m.archived_at IS NOT NULL), m.created_at
|
||||
FROM backend.feedback_messages m
|
||||
JOIN backend.accounts a ON a.account_id = m.account_id
|
||||
WHERE ` + where +
|
||||
fmt.Sprintf(` ORDER BY m.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
|
||||
args = append(args, limit, offset)
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("feedback: admin list: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AdminRow
|
||||
for rows.Next() {
|
||||
var r AdminRow
|
||||
if err := rows.Scan(&r.ID, &r.AccountID, &r.SenderName, &r.Source, &r.Channel,
|
||||
&r.HasAttachment, &r.Read, &r.Replied, &r.Archived, &r.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("feedback: scan admin row: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AdminCount counts the filtered console feedback list, for the pager.
|
||||
func (s *Store) AdminCount(ctx context.Context, f AdminFilter) (int, error) {
|
||||
where, args := adminWhere(f)
|
||||
var n int
|
||||
q := `SELECT COUNT(*) FROM backend.feedback_messages m JOIN backend.accounts a ON a.account_id = m.account_id WHERE ` + where
|
||||
if err := s.db.QueryRowContext(ctx, q, args...).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("feedback: admin count: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// AdminGet loads one message for the console detail view, or ErrNotFound.
|
||||
func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error) {
|
||||
var m AdminMessage
|
||||
var repliedAt sql.NullTime
|
||||
q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel,
|
||||
COALESCE(m.lang, ''), COALESCE(m.channel_lang, ''),
|
||||
COALESCE(m.sender_ip, ''), (m.attachment IS NOT NULL), COALESCE(m.attachment_name, ''),
|
||||
(m.read_at IS NOT NULL), (m.archived_at IS NOT NULL), (m.reply_body IS NOT NULL),
|
||||
COALESCE(m.reply_body, ''), m.replied_at, m.created_at
|
||||
FROM backend.feedback_messages m
|
||||
JOIN backend.accounts a ON a.account_id = m.account_id
|
||||
WHERE m.message_id = $1`
|
||||
err := s.db.QueryRowContext(ctx, q, id).Scan(
|
||||
&m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel,
|
||||
&m.Lang, &m.ChannelLang,
|
||||
&m.SenderIP, &m.HasAttachment, &m.AttachmentName,
|
||||
&m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return AdminMessage{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return AdminMessage{}, fmt.Errorf("feedback: admin get %s: %w", id, err)
|
||||
}
|
||||
if repliedAt.Valid {
|
||||
m.RepliedAt = repliedAt.Time
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// CountUnread counts the active (unread, not archived) feedback queue, for the
|
||||
// console dashboard.
|
||||
func (s *Store) CountUnread(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM backend.feedback_messages WHERE read_at IS NULL AND archived_at IS NULL`,
|
||||
).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("feedback: count unread: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/feedback"
|
||||
)
|
||||
|
||||
func newFeedbackService() *feedback.Service {
|
||||
return feedback.NewService(feedback.NewStore(testDB), account.NewStore(testDB))
|
||||
}
|
||||
|
||||
// latestFeedbackID returns the id of the account's single message (across states).
|
||||
func latestFeedbackID(t *testing.T, svc *feedback.Service, acc uuid.UUID) uuid.UUID {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
for _, status := range []string{"unread", "read", "archived"} {
|
||||
rows, err := svc.AdminList(ctx, feedback.AdminFilter{AccountID: acc, Status: status}, 50, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("admin list %s: %v", status, err)
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
return rows[0].ID
|
||||
}
|
||||
}
|
||||
t.Fatal("no feedback message for account")
|
||||
return uuid.Nil
|
||||
}
|
||||
|
||||
func TestFeedbackGuestRejected(t *testing.T) {
|
||||
svc := newFeedbackService()
|
||||
guest := provisionGuest(t)
|
||||
if err := svc.Submit(context.Background(), guest, "hi", nil, "", "web", "1.2.3.4"); !errors.Is(err, feedback.ErrGuestForbidden) {
|
||||
t.Fatalf("guest submit err = %v, want ErrGuestForbidden", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedbackSubmitGateAndReplyLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newFeedbackService()
|
||||
acc := provisionAccount(t)
|
||||
|
||||
if err := svc.Submit(ctx, acc, " please fix the board ", []byte("PNGDATA"), "shot.png", "ios", "9.9.9.9"); err != nil {
|
||||
t.Fatalf("submit: %v", err)
|
||||
}
|
||||
// Anti-spam gate: a second message is refused while the first is unreviewed.
|
||||
if err := svc.Submit(ctx, acc, "again", nil, "", "web", ""); !errors.Is(err, feedback.ErrPendingReview) {
|
||||
t.Fatalf("second submit err = %v, want ErrPendingReview", err)
|
||||
}
|
||||
if st, err := svc.State(ctx, acc); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if st.CanSend || st.BlockedReason != "pending" || st.Reply != nil {
|
||||
t.Fatalf("state = %+v, want pending / no reply", st)
|
||||
}
|
||||
|
||||
id := latestFeedbackID(t, svc, acc)
|
||||
m, err := svc.AdminGet(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("admin get: %v", err)
|
||||
}
|
||||
if m.Body != "please fix the board" { // trimmed
|
||||
t.Fatalf("body = %q, want trimmed", m.Body)
|
||||
}
|
||||
if !m.HasAttachment || m.AttachmentName != "shot.png" || m.Channel != "ios" || m.SenderIP != "9.9.9.9" {
|
||||
t.Fatalf("admin message = %+v", m)
|
||||
}
|
||||
if name, data, ok, err := svc.Attachment(ctx, id); err != nil || !ok || name != "shot.png" || string(data) != "PNGDATA" {
|
||||
t.Fatalf("attachment = (%q, %q, %v, %v)", name, data, ok, err)
|
||||
}
|
||||
|
||||
// Reply: marks the message read (so the player can send again) and is undelivered.
|
||||
if err := svc.Reply(ctx, id, "We are on it."); err != nil {
|
||||
t.Fatalf("reply: %v", err)
|
||||
}
|
||||
if unread, err := svc.ReplyUnread(ctx, acc); err != nil || !unread {
|
||||
t.Fatalf("reply unread = %v (err %v), want true", unread, err)
|
||||
}
|
||||
st, err := svc.State(ctx, acc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !st.CanSend {
|
||||
t.Fatal("want canSend true after the message was read via reply")
|
||||
}
|
||||
if st.Reply == nil || st.Reply.Body != "We are on it." {
|
||||
t.Fatalf("reply not shown: %+v", st.Reply)
|
||||
}
|
||||
// Fetching the state delivered the reply: the badge clears.
|
||||
if unread, err := svc.ReplyUnread(ctx, acc); err != nil || unread {
|
||||
t.Fatalf("reply unread = %v (err %v), want false after State", unread, err)
|
||||
}
|
||||
|
||||
// The reply is hidden one week after it was delivered.
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`UPDATE backend.feedback_messages SET reply_read_at = now() - interval '8 days' WHERE message_id = $1`, id); err != nil {
|
||||
t.Fatalf("age reply: %v", err)
|
||||
}
|
||||
if st, err := svc.State(ctx, acc); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if st.Reply != nil {
|
||||
t.Fatalf("reply should be hidden after a week, got %+v", st.Reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newFeedbackService()
|
||||
acc := provisionAccount(t)
|
||||
|
||||
// msg1, replied → the player can send again and currently sees the reply.
|
||||
if err := svc.Submit(ctx, acc, "first", nil, "", "web", ""); err != nil {
|
||||
t.Fatalf("submit msg1: %v", err)
|
||||
}
|
||||
if err := svc.Reply(ctx, latestFeedbackID(t, svc, acc), "the answer"); err != nil {
|
||||
t.Fatalf("reply: %v", err)
|
||||
}
|
||||
if st, err := svc.State(ctx, acc); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if st.Reply == nil || st.Reply.Body != "the answer" {
|
||||
t.Fatalf("reply not shown before the new message: %+v", st.Reply)
|
||||
}
|
||||
|
||||
// Sending a new message immediately drops the previous reply (it now belongs to an
|
||||
// older message), even though it is well within the one-week window.
|
||||
if err := svc.Submit(ctx, acc, "second", nil, "", "web", ""); err != nil {
|
||||
t.Fatalf("submit msg2: %v", err)
|
||||
}
|
||||
st, err := svc.State(ctx, acc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Reply != nil {
|
||||
t.Fatalf("reply should be hidden after a new message, got %+v", st.Reply)
|
||||
}
|
||||
if st.CanSend || st.BlockedReason != "pending" {
|
||||
t.Fatalf("state after new message = %+v, want pending", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedbackSnapshotsLanguages(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newFeedbackService()
|
||||
acc := provisionAccount(t)
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`UPDATE backend.accounts SET preferred_language = 'en', service_language = 'ru' WHERE account_id = $1`, acc); err != nil {
|
||||
t.Fatalf("set languages: %v", err)
|
||||
}
|
||||
// A Telegram (connector) message snapshots both the interface language and the bot.
|
||||
if err := svc.Submit(ctx, acc, "from telegram", nil, "", "telegram", ""); err != nil {
|
||||
t.Fatalf("submit: %v", err)
|
||||
}
|
||||
id := latestFeedbackID(t, svc, acc)
|
||||
if m, err := svc.AdminGet(ctx, id); err != nil {
|
||||
t.Fatalf("admin get: %v", err)
|
||||
} else if m.Lang != "en" || m.ChannelLang != "ru" {
|
||||
t.Fatalf("snapshot = lang %q / channel_lang %q, want en / ru", m.Lang, m.ChannelLang)
|
||||
}
|
||||
// Changing the account afterwards must not change the stored snapshot.
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`UPDATE backend.accounts SET preferred_language = 'ru', service_language = 'en' WHERE account_id = $1`, acc); err != nil {
|
||||
t.Fatalf("change languages: %v", err)
|
||||
}
|
||||
if m, err := svc.AdminGet(ctx, id); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if m.Lang != "en" || m.ChannelLang != "ru" {
|
||||
t.Fatalf("snapshot drifted after account change = lang %q / channel_lang %q", m.Lang, m.ChannelLang)
|
||||
}
|
||||
|
||||
// A non-connector channel records no bot language even when the account has one.
|
||||
acc2 := provisionAccount(t)
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`UPDATE backend.accounts SET preferred_language = 'en', service_language = 'ru' WHERE account_id = $1`, acc2); err != nil {
|
||||
t.Fatalf("set languages 2: %v", err)
|
||||
}
|
||||
if err := svc.Submit(ctx, acc2, "from web", nil, "", "web", ""); err != nil {
|
||||
t.Fatalf("submit web: %v", err)
|
||||
}
|
||||
if m, err := svc.AdminGet(ctx, latestFeedbackID(t, svc, acc2)); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if m.Lang != "en" || m.ChannelLang != "" {
|
||||
t.Fatalf("web snapshot = lang %q / channel_lang %q, want en / empty", m.Lang, m.ChannelLang)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedbackBanRole(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newFeedbackService()
|
||||
accounts := account.NewStore(testDB)
|
||||
acc := provisionAccount(t)
|
||||
|
||||
if err := accounts.GrantRole(ctx, acc, account.RoleFeedbackBanned); err != nil {
|
||||
t.Fatalf("grant role: %v", err)
|
||||
}
|
||||
if err := svc.Submit(ctx, acc, "hi", nil, "", "web", ""); !errors.Is(err, feedback.ErrBanned) {
|
||||
t.Fatalf("banned submit err = %v, want ErrBanned", err)
|
||||
}
|
||||
if st, err := svc.State(ctx, acc); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if st.CanSend || st.BlockedReason != "banned" {
|
||||
t.Fatalf("state = %+v, want banned", st)
|
||||
}
|
||||
// Revoke restores submission (the /users unblock path).
|
||||
if err := accounts.RevokeRole(ctx, acc, account.RoleFeedbackBanned); err != nil {
|
||||
t.Fatalf("revoke role: %v", err)
|
||||
}
|
||||
if err := svc.Submit(ctx, acc, "hi again", nil, "", "web", ""); err != nil {
|
||||
t.Fatalf("submit after unban: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedbackValidation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newFeedbackService()
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
attachment []byte
|
||||
attachmentName string
|
||||
want error
|
||||
}{
|
||||
{"empty body", " ", nil, "", feedback.ErrEmptyMessage},
|
||||
{"too long", strings.Repeat("a", 1025), nil, "", feedback.ErrMessageTooLong},
|
||||
{"bad type", "ok", []byte("x"), "evil.exe", feedback.ErrAttachmentType},
|
||||
{"too large", "ok", make([]byte, 1_000_001), "a.png", feedback.ErrAttachmentTooLarge},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
acc := provisionAccount(t) // fresh account so the pending gate never fires first
|
||||
if err := svc.Submit(ctx, acc, tt.body, tt.attachment, tt.attachmentName, "web", ""); !errors.Is(err, tt.want) {
|
||||
t.Fatalf("submit err = %v, want %v", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedbackAdminLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newFeedbackService()
|
||||
acc := provisionAccount(t)
|
||||
|
||||
if err := svc.Submit(ctx, acc, "first report", nil, "", "web", ""); err != nil {
|
||||
t.Fatalf("submit: %v", err)
|
||||
}
|
||||
id := latestFeedbackID(t, svc, acc)
|
||||
|
||||
inCount := func(status string) int {
|
||||
t.Helper()
|
||||
n, err := svc.AdminCount(ctx, feedback.AdminFilter{AccountID: acc, Status: status})
|
||||
if err != nil {
|
||||
t.Fatalf("count %s: %v", status, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
if inCount("unread") != 1 || inCount("read") != 0 || inCount("archived") != 0 {
|
||||
t.Fatalf("after submit: unread=%d read=%d archived=%d", inCount("unread"), inCount("read"), inCount("archived"))
|
||||
}
|
||||
// Marking read moves it from the unread queue to the read list.
|
||||
if err := svc.MarkRead(ctx, id); err != nil {
|
||||
t.Fatalf("mark read: %v", err)
|
||||
}
|
||||
if inCount("unread") != 0 || inCount("read") != 1 {
|
||||
t.Fatalf("after read: unread=%d read=%d", inCount("unread"), inCount("read"))
|
||||
}
|
||||
// Archiving moves it to the archive.
|
||||
if err := svc.Archive(ctx, id); err != nil {
|
||||
t.Fatalf("archive: %v", err)
|
||||
}
|
||||
if inCount("read") != 0 || inCount("archived") != 1 {
|
||||
t.Fatalf("after archive: read=%d archived=%d", inCount("read"), inCount("archived"))
|
||||
}
|
||||
// Deleting removes it entirely.
|
||||
if err := svc.Delete(ctx, id); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if inCount("archived") != 0 {
|
||||
t.Fatalf("after delete: archived=%d, want 0", inCount("archived"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedbackDeleteAllByAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newFeedbackService()
|
||||
acc := provisionAccount(t)
|
||||
|
||||
if err := svc.Submit(ctx, acc, "one", nil, "", "web", ""); err != nil {
|
||||
t.Fatalf("submit: %v", err)
|
||||
}
|
||||
if err := svc.DeleteAllByAccount(ctx, acc); err != nil {
|
||||
t.Fatalf("delete all: %v", err)
|
||||
}
|
||||
// The pending gate is clear again (no messages left), so the account can submit.
|
||||
if has, err := svc.ReplyUnread(ctx, acc); err != nil || has {
|
||||
t.Fatalf("reply unread after delete-all = %v (err %v)", has, err)
|
||||
}
|
||||
if err := svc.Submit(ctx, acc, "fresh", nil, "", "web", ""); err != nil {
|
||||
t.Fatalf("submit after delete-all: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,10 @@ const (
|
||||
// becomes an out-of-app push.
|
||||
NotifyInvitationUpdate = "invitation_update"
|
||||
NotifyGameStarted = "game_started"
|
||||
// NotifyAdminReply tells the player an operator has answered their feedback, so
|
||||
// the client raises the Settings/Info badge and re-fetches the reply. It carries
|
||||
// no payload (the reply is fetched on the feedback screen). In-app only.
|
||||
NotifyAdminReply = "admin_reply"
|
||||
)
|
||||
|
||||
// Intent is one live event destined for a single user. Payload is the
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AccountRoles struct {
|
||||
AccountID uuid.UUID `sql:"primary_key"`
|
||||
Role string `sql:"primary_key"`
|
||||
GrantedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FeedbackMessages struct {
|
||||
MessageID uuid.UUID `sql:"primary_key"`
|
||||
AccountID uuid.UUID
|
||||
Body string
|
||||
Attachment *[]byte
|
||||
AttachmentName *string
|
||||
SenderIP *string
|
||||
Channel string
|
||||
Lang *string
|
||||
ChannelLang *string
|
||||
ReadAt *time.Time
|
||||
ArchivedAt *time.Time
|
||||
ReplyBody *string
|
||||
RepliedAt *time.Time
|
||||
ReplyReadAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var AccountRoles = newAccountRolesTable("backend", "account_roles", "")
|
||||
|
||||
type accountRolesTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
AccountID postgres.ColumnString
|
||||
Role postgres.ColumnString
|
||||
GrantedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type AccountRolesTable struct {
|
||||
accountRolesTable
|
||||
|
||||
EXCLUDED accountRolesTable
|
||||
}
|
||||
|
||||
// AS creates new AccountRolesTable with assigned alias
|
||||
func (a AccountRolesTable) AS(alias string) *AccountRolesTable {
|
||||
return newAccountRolesTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new AccountRolesTable with assigned schema name
|
||||
func (a AccountRolesTable) FromSchema(schemaName string) *AccountRolesTable {
|
||||
return newAccountRolesTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new AccountRolesTable with assigned table prefix
|
||||
func (a AccountRolesTable) WithPrefix(prefix string) *AccountRolesTable {
|
||||
return newAccountRolesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new AccountRolesTable with assigned table suffix
|
||||
func (a AccountRolesTable) WithSuffix(suffix string) *AccountRolesTable {
|
||||
return newAccountRolesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newAccountRolesTable(schemaName, tableName, alias string) *AccountRolesTable {
|
||||
return &AccountRolesTable{
|
||||
accountRolesTable: newAccountRolesTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newAccountRolesTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newAccountRolesTableImpl(schemaName, tableName, alias string) accountRolesTable {
|
||||
var (
|
||||
AccountIDColumn = postgres.StringColumn("account_id")
|
||||
RoleColumn = postgres.StringColumn("role")
|
||||
GrantedAtColumn = postgres.TimestampzColumn("granted_at")
|
||||
allColumns = postgres.ColumnList{AccountIDColumn, RoleColumn, GrantedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{GrantedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{GrantedAtColumn}
|
||||
)
|
||||
|
||||
return accountRolesTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
AccountID: AccountIDColumn,
|
||||
Role: RoleColumn,
|
||||
GrantedAt: GrantedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var FeedbackMessages = newFeedbackMessagesTable("backend", "feedback_messages", "")
|
||||
|
||||
type feedbackMessagesTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
MessageID postgres.ColumnString
|
||||
AccountID postgres.ColumnString
|
||||
Body postgres.ColumnString
|
||||
Attachment postgres.ColumnBytea
|
||||
AttachmentName postgres.ColumnString
|
||||
SenderIP postgres.ColumnString
|
||||
Channel postgres.ColumnString
|
||||
Lang postgres.ColumnString
|
||||
ChannelLang postgres.ColumnString
|
||||
ReadAt postgres.ColumnTimestampz
|
||||
ArchivedAt postgres.ColumnTimestampz
|
||||
ReplyBody postgres.ColumnString
|
||||
RepliedAt postgres.ColumnTimestampz
|
||||
ReplyReadAt postgres.ColumnTimestampz
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type FeedbackMessagesTable struct {
|
||||
feedbackMessagesTable
|
||||
|
||||
EXCLUDED feedbackMessagesTable
|
||||
}
|
||||
|
||||
// AS creates new FeedbackMessagesTable with assigned alias
|
||||
func (a FeedbackMessagesTable) AS(alias string) *FeedbackMessagesTable {
|
||||
return newFeedbackMessagesTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new FeedbackMessagesTable with assigned schema name
|
||||
func (a FeedbackMessagesTable) FromSchema(schemaName string) *FeedbackMessagesTable {
|
||||
return newFeedbackMessagesTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new FeedbackMessagesTable with assigned table prefix
|
||||
func (a FeedbackMessagesTable) WithPrefix(prefix string) *FeedbackMessagesTable {
|
||||
return newFeedbackMessagesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new FeedbackMessagesTable with assigned table suffix
|
||||
func (a FeedbackMessagesTable) WithSuffix(suffix string) *FeedbackMessagesTable {
|
||||
return newFeedbackMessagesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newFeedbackMessagesTable(schemaName, tableName, alias string) *FeedbackMessagesTable {
|
||||
return &FeedbackMessagesTable{
|
||||
feedbackMessagesTable: newFeedbackMessagesTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newFeedbackMessagesTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackMessagesTable {
|
||||
var (
|
||||
MessageIDColumn = postgres.StringColumn("message_id")
|
||||
AccountIDColumn = postgres.StringColumn("account_id")
|
||||
BodyColumn = postgres.StringColumn("body")
|
||||
AttachmentColumn = postgres.ByteaColumn("attachment")
|
||||
AttachmentNameColumn = postgres.StringColumn("attachment_name")
|
||||
SenderIPColumn = postgres.StringColumn("sender_ip")
|
||||
ChannelColumn = postgres.StringColumn("channel")
|
||||
LangColumn = postgres.StringColumn("lang")
|
||||
ChannelLangColumn = postgres.StringColumn("channel_lang")
|
||||
ReadAtColumn = postgres.TimestampzColumn("read_at")
|
||||
ArchivedAtColumn = postgres.TimestampzColumn("archived_at")
|
||||
ReplyBodyColumn = postgres.StringColumn("reply_body")
|
||||
RepliedAtColumn = postgres.TimestampzColumn("replied_at")
|
||||
ReplyReadAtColumn = postgres.TimestampzColumn("reply_read_at")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, LangColumn, ChannelLangColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, LangColumn, ChannelLangColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{CreatedAtColumn}
|
||||
)
|
||||
|
||||
return feedbackMessagesTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
MessageID: MessageIDColumn,
|
||||
AccountID: AccountIDColumn,
|
||||
Body: BodyColumn,
|
||||
Attachment: AttachmentColumn,
|
||||
AttachmentName: AttachmentNameColumn,
|
||||
SenderIP: SenderIPColumn,
|
||||
Channel: ChannelColumn,
|
||||
Lang: LangColumn,
|
||||
ChannelLang: ChannelLangColumn,
|
||||
ReadAt: ReadAtColumn,
|
||||
ArchivedAt: ArchivedAtColumn,
|
||||
ReplyBody: ReplyBodyColumn,
|
||||
RepliedAt: RepliedAtColumn,
|
||||
ReplyReadAt: ReplyReadAtColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ package table
|
||||
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
|
||||
// this method only once at the beginning of the program.
|
||||
func UseSchema(schema string) {
|
||||
AccountRoles = AccountRoles.FromSchema(schema)
|
||||
AccountStats = AccountStats.FromSchema(schema)
|
||||
AccountSuspensions = AccountSuspensions.FromSchema(schema)
|
||||
Accounts = Accounts.FromSchema(schema)
|
||||
@@ -18,6 +19,7 @@ func UseSchema(schema string) {
|
||||
Complaints = Complaints.FromSchema(schema)
|
||||
DictionaryState = DictionaryState.FromSchema(schema)
|
||||
EmailConfirmations = EmailConfirmations.FromSchema(schema)
|
||||
FeedbackMessages = FeedbackMessages.FromSchema(schema)
|
||||
FriendCodes = FriendCodes.FromSchema(schema)
|
||||
Friendships = Friendships.FromSchema(schema)
|
||||
GameDrafts = GameDrafts.FromSchema(schema)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
-- +goose Up
|
||||
-- User feedback ("Обратная связь"): a flat list of messages a registered player sends to the
|
||||
-- operators from Settings -> Info, each with an optional single attachment, plus the operator's
|
||||
-- inline reply shown back to the player. Distinct from the in-game chat_messages (peer-to-peer)
|
||||
-- and from the out-of-app Telegram messages. Also introduces account_roles, the project's first
|
||||
-- per-account role table, replacing per-feature boolean flags. See docs/ARCHITECTURE.md.
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
-- One feedback message. read_at marks "the operator has dealt with this" (set by every console
|
||||
-- action: read / reply / archive; never by merely opening the detail). The anti-spam gate forbids
|
||||
-- a new submission while the player has any read_at IS NULL message. archived_at files a handled
|
||||
-- message away. The reply lives on the same row: reply_body/replied_at are stamped when the
|
||||
-- operator answers; reply_read_at is stamped when the player's app first fetches the reply for
|
||||
-- display ("delivered = read"), and the reply is hidden from the player one week after that.
|
||||
-- attachment is the raw bytes (capped at 1,000,000 in Go); attachment_name carries the original
|
||||
-- file name (its extension drives the safe content-type at serve time). sender_ip is the
|
||||
-- gateway-forwarded client IP (validated, like chat); channel is the submitting platform
|
||||
-- (telegram/ios/android/web, validated in Go). The sender's interface/bot language snapshots
|
||||
-- (lang, channel_lang) are added additively in 00005.
|
||||
CREATE TABLE feedback_messages (
|
||||
message_id uuid PRIMARY KEY,
|
||||
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||
body text NOT NULL,
|
||||
attachment bytea,
|
||||
attachment_name text,
|
||||
sender_ip text,
|
||||
channel text NOT NULL,
|
||||
read_at timestamptz,
|
||||
archived_at timestamptz,
|
||||
reply_body text,
|
||||
replied_at timestamptz,
|
||||
reply_read_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
-- The per-player probes (anti-spam "has unread", "latest message", "latest reply") and the
|
||||
-- console's user-scoped search all look an account up newest-first.
|
||||
CREATE INDEX feedback_messages_account_idx ON feedback_messages (account_id, created_at DESC);
|
||||
-- The console queue lists messages newest-first across all accounts.
|
||||
CREATE INDEX feedback_messages_created_idx ON feedback_messages (created_at DESC);
|
||||
|
||||
-- Named per-account roles. A reusable replacement for per-feature boolean flags: the first role,
|
||||
-- 'feedback_banned', forbids only feedback submission (not the whole account, unlike a
|
||||
-- suspension). Roles are validated in Go (no CHECK here) so new ones need no migration. Granted
|
||||
-- from the feedback console (the "block" checkbox on delete) and granted/revoked from /users.
|
||||
CREATE TABLE account_roles (
|
||||
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||
role text NOT NULL,
|
||||
granted_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (account_id, role)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
SET search_path = backend, pg_catalog;
|
||||
DROP TABLE IF EXISTS account_roles;
|
||||
DROP TABLE IF EXISTS feedback_messages;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- +goose Up
|
||||
-- Snapshot the sender's languages on each feedback message, taken at submit time so the
|
||||
-- operator console shows the state as it was, not the account's current settings (the same
|
||||
-- snapshot discipline as a suspension's reason). lang is the sender's interface language;
|
||||
-- channel_lang is the connector bot language (en/ru) when the message arrived through an
|
||||
-- external connector (Telegram), else NULL. Additive over 00004 so it applies forward-safe.
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
ALTER TABLE feedback_messages ADD COLUMN lang text;
|
||||
ALTER TABLE feedback_messages ADD COLUMN channel_lang text;
|
||||
|
||||
-- +goose Down
|
||||
SET search_path = backend, pg_catalog;
|
||||
ALTER TABLE feedback_messages DROP COLUMN IF EXISTS channel_lang;
|
||||
ALTER TABLE feedback_messages DROP COLUMN IF EXISTS lang;
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/accountmerge"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/feedback"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/session"
|
||||
@@ -77,6 +78,11 @@ func (s *Server) registerRoutes() {
|
||||
u.PUT("/games/:id/draft", s.handleSaveDraft)
|
||||
u.POST("/games/:id/hide", s.handleHideGame)
|
||||
}
|
||||
if s.feedback != nil {
|
||||
u.POST("/feedback", s.handleFeedbackSubmit)
|
||||
u.GET("/feedback", s.handleFeedbackState)
|
||||
u.GET("/feedback/unread", s.handleFeedbackUnread)
|
||||
}
|
||||
if s.matchmaker != nil {
|
||||
u.POST("/lobby/enqueue", s.handleEnqueue)
|
||||
}
|
||||
@@ -232,6 +238,20 @@ func statusForError(err error) (int, string) {
|
||||
return http.StatusConflict, "request_declined"
|
||||
case errors.Is(err, social.ErrFriendCodeInvalid):
|
||||
return http.StatusUnprocessableEntity, "friend_code_invalid"
|
||||
case errors.Is(err, feedback.ErrGuestForbidden):
|
||||
return http.StatusForbidden, "feedback_guest_forbidden"
|
||||
case errors.Is(err, feedback.ErrBanned):
|
||||
return http.StatusForbidden, "feedback_banned"
|
||||
case errors.Is(err, feedback.ErrPendingReview):
|
||||
return http.StatusConflict, "feedback_pending"
|
||||
case errors.Is(err, feedback.ErrEmptyMessage):
|
||||
return http.StatusUnprocessableEntity, "feedback_empty"
|
||||
case errors.Is(err, feedback.ErrMessageTooLong):
|
||||
return http.StatusUnprocessableEntity, "feedback_too_long"
|
||||
case errors.Is(err, feedback.ErrAttachmentTooLarge):
|
||||
return http.StatusUnprocessableEntity, "feedback_attachment_too_large"
|
||||
case errors.Is(err, feedback.ErrAttachmentType):
|
||||
return http.StatusUnprocessableEntity, "feedback_attachment_type"
|
||||
default:
|
||||
return http.StatusInternalServerError, "internal"
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
||||
gm.POST("/users/:id/grant-hints", s.consoleGrantHints)
|
||||
gm.POST("/users/:id/block", s.consoleBlockUser)
|
||||
gm.POST("/users/:id/unblock", s.consoleUnblockUser)
|
||||
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
|
||||
gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
|
||||
gm.GET("/reasons", s.consoleReasons)
|
||||
gm.POST("/reasons", s.consoleCreateReason)
|
||||
gm.POST("/reasons/:id/update", s.consoleUpdateReason)
|
||||
@@ -68,6 +70,16 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
||||
gm.GET("/complaints", s.consoleComplaints)
|
||||
gm.GET("/complaints/:id", s.consoleComplaintDetail)
|
||||
gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
|
||||
if s.feedback != nil {
|
||||
gm.GET("/feedback", s.consoleFeedback)
|
||||
gm.GET("/feedback/:id", s.consoleFeedbackDetail)
|
||||
gm.GET("/feedback/:id/attachment", s.consoleFeedbackAttachment)
|
||||
gm.POST("/feedback/:id/read", s.consoleFeedbackRead)
|
||||
gm.POST("/feedback/:id/reply", s.consoleFeedbackReply)
|
||||
gm.POST("/feedback/:id/archive", s.consoleFeedbackArchive)
|
||||
gm.POST("/feedback/:id/delete", s.consoleFeedbackDelete)
|
||||
gm.POST("/feedback/:id/delete-all", s.consoleFeedbackDeleteAll)
|
||||
}
|
||||
gm.GET("/messages", s.consoleMessages)
|
||||
gm.GET("/messages.csv", s.consoleMessagesCSV)
|
||||
gm.GET("/dictionary", s.consoleDictionary)
|
||||
@@ -87,6 +99,9 @@ func (s *Server) consoleDashboard(c *gin.Context) {
|
||||
view.Games, _ = s.games.CountGames(ctx, "")
|
||||
view.ActiveGames, _ = s.games.CountGames(ctx, game.StatusActive)
|
||||
view.OpenComplaints, _ = s.games.CountComplaints(ctx, game.StatusComplaintOpen)
|
||||
if s.feedback != nil {
|
||||
view.OpenFeedback, _ = s.feedback.CountUnread(ctx)
|
||||
}
|
||||
if changes, err := s.games.DictionaryChanges(ctx); err == nil {
|
||||
view.PendingChanges = len(changes)
|
||||
}
|
||||
@@ -316,6 +331,10 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
||||
view.Reasons = append(view.Reasons, adminconsole.ReasonOption{ID: r.ID.String(), TextEn: r.TextEn, TextRu: r.TextRu})
|
||||
}
|
||||
}
|
||||
if roles, err := s.accounts.ListRoles(ctx, id); err == nil {
|
||||
view.Roles = roles
|
||||
}
|
||||
view.KnownRoles = account.KnownRoles
|
||||
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/adminconsole"
|
||||
"scrabble/backend/internal/feedback"
|
||||
)
|
||||
|
||||
// consoleFeedback renders the paginated feedback queue, filtered by state
|
||||
// (unread/read/archived), optionally pinned to one sender (?user=) and narrowed by
|
||||
// sender glob masks (?name / ?ext).
|
||||
func (s *Server) consoleFeedback(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
page := consolePage(c)
|
||||
status := normalizeFeedbackStatus(c.Query("status"))
|
||||
user, _ := uuid.Parse(strings.TrimSpace(c.Query("user")))
|
||||
filter := feedback.AdminFilter{
|
||||
Status: status,
|
||||
NameMask: c.Query("name"),
|
||||
ExtMask: c.Query("ext"),
|
||||
AccountID: user,
|
||||
}
|
||||
total, _ := s.feedback.AdminCount(ctx, filter)
|
||||
rows, err := s.feedback.AdminList(ctx, filter, adminPageSize, (page-1)*adminPageSize)
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("status", status)
|
||||
if filter.AccountID != uuid.Nil {
|
||||
q.Set("user", filter.AccountID.String())
|
||||
}
|
||||
if strings.TrimSpace(filter.NameMask) != "" {
|
||||
q.Set("name", filter.NameMask)
|
||||
}
|
||||
if strings.TrimSpace(filter.ExtMask) != "" {
|
||||
q.Set("ext", filter.ExtMask)
|
||||
}
|
||||
view := adminconsole.FeedbackView{
|
||||
Status: status, Pager: adminconsole.NewPager(page, adminPageSize, total),
|
||||
NameMask: filter.NameMask, ExtMask: filter.ExtMask,
|
||||
FilterQuery: template.URL(q.Encode()),
|
||||
}
|
||||
if filter.AccountID != uuid.Nil {
|
||||
view.UserID = filter.AccountID.String()
|
||||
}
|
||||
for _, r := range rows {
|
||||
view.Items = append(view.Items, adminconsole.FeedbackRow{
|
||||
ID: r.ID.String(), AccountID: r.AccountID.String(), SenderName: r.SenderName,
|
||||
Source: r.Source, Channel: r.Channel, HasAttachment: r.HasAttachment,
|
||||
Read: r.Read, Replied: r.Replied, Archived: r.Archived, CreatedAt: fmtTime(r.CreatedAt),
|
||||
})
|
||||
}
|
||||
s.renderConsole(c, "feedback", "feedback", "Feedback", view)
|
||||
}
|
||||
|
||||
// consoleFeedbackDetail renders one feedback message with its actions. Opening it
|
||||
// does NOT mark it read (only the explicit actions do).
|
||||
func (s *Server) consoleFeedbackDetail(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
id, ok := s.consoleUUID(c, "/_gm/feedback")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
m, err := s.feedback.AdminGet(ctx, id)
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
view := adminconsole.FeedbackDetailView{
|
||||
ID: m.ID.String(), AccountID: m.AccountID.String(), SenderName: m.SenderName,
|
||||
Source: m.Source, Channel: m.Channel, InterfaceLanguage: m.Lang, BotLanguage: m.ChannelLang,
|
||||
IP: m.SenderIP, Body: m.Body,
|
||||
HasAttachment: m.HasAttachment, AttachmentName: m.AttachmentName, IsImage: feedback.IsImage(m.AttachmentName),
|
||||
Read: m.Read, Archived: m.Archived, Replied: m.Replied, ReplyBody: m.ReplyBody,
|
||||
RepliedAt: fmtTime(m.RepliedAt), CreatedAt: fmtTime(m.CreatedAt),
|
||||
}
|
||||
if banned, err := s.accounts.HasRole(ctx, m.AccountID, account.RoleFeedbackBanned); err == nil {
|
||||
view.Banned = banned
|
||||
}
|
||||
s.renderConsole(c, "feedback_detail", "feedback", "Feedback", view)
|
||||
}
|
||||
|
||||
// consoleFeedbackAttachment serves a message's attachment. It is always sent with
|
||||
// X-Content-Type-Options: nosniff; images are served with their type for an inline
|
||||
// <img> preview (which never executes), everything else as an octet-stream download
|
||||
// (so a non-image — including a renamed one — is downloaded, never rendered).
|
||||
func (s *Server) consoleFeedbackAttachment(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, "/_gm/feedback")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
name, data, ok, err := s.feedback.Attachment(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
s.renderConsoleMessage(c, "No attachment", "this message has no attachment", "/_gm/feedback/"+id.String())
|
||||
return
|
||||
}
|
||||
ctype := "application/octet-stream"
|
||||
disposition := `attachment; filename="` + sanitizeFilename(name) + `"`
|
||||
if feedback.IsImage(name) {
|
||||
ctype = feedback.ContentType(name)
|
||||
disposition = "inline"
|
||||
}
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("Content-Disposition", disposition)
|
||||
c.Data(http.StatusOK, ctype, data)
|
||||
}
|
||||
|
||||
// consoleFeedbackRead marks a message dealt-with without any other action.
|
||||
func (s *Server) consoleFeedbackRead(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, "/_gm/feedback")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/feedback/" + id.String()
|
||||
if err := s.feedback.MarkRead(c.Request.Context(), id); err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
s.renderConsoleMessage(c, "Marked read", "the message is marked read", back)
|
||||
}
|
||||
|
||||
// consoleFeedbackReply sends the operator's reply to the player (marking the
|
||||
// message read and notifying the player's app).
|
||||
func (s *Server) consoleFeedbackReply(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, "/_gm/feedback")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/feedback/" + id.String()
|
||||
body := trimForm(c, "reply")
|
||||
if body == "" {
|
||||
s.renderConsoleMessage(c, "Empty reply", "enter a reply before sending", back)
|
||||
return
|
||||
}
|
||||
if err := s.feedback.Reply(c.Request.Context(), id, body); err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
s.renderConsoleMessage(c, "Replied", "the reply was sent to the player", back)
|
||||
}
|
||||
|
||||
// consoleFeedbackArchive files a handled message away (marking it read).
|
||||
func (s *Server) consoleFeedbackArchive(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, "/_gm/feedback")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.feedback.Archive(c.Request.Context(), id); err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
s.renderConsoleMessage(c, "Archived", "the message was archived", "/_gm/feedback")
|
||||
}
|
||||
|
||||
// consoleFeedbackDelete physically deletes one message, optionally banning the
|
||||
// sender from feedback (the "block" checkbox).
|
||||
func (s *Server) consoleFeedbackDelete(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
id, ok := s.consoleUUID(c, "/_gm/feedback")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
m, err := s.feedback.AdminGet(ctx, id)
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
if err := s.feedback.Delete(ctx, id); err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
extra, err := s.maybeBan(ctx, m.AccountID, trimForm(c, "block") != "")
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
s.renderConsoleMessage(c, "Deleted", "the message was deleted"+extra, "/_gm/feedback")
|
||||
}
|
||||
|
||||
// consoleFeedbackDeleteAll physically deletes every message from the sender of the
|
||||
// given message, optionally banning the sender from feedback.
|
||||
func (s *Server) consoleFeedbackDeleteAll(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
id, ok := s.consoleUUID(c, "/_gm/feedback")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
m, err := s.feedback.AdminGet(ctx, id)
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
if err := s.feedback.DeleteAllByAccount(ctx, m.AccountID); err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
extra, err := s.maybeBan(ctx, m.AccountID, trimForm(c, "block") != "")
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
s.renderConsoleMessage(c, "Deleted", "all messages from the player were deleted"+extra, "/_gm/feedback")
|
||||
}
|
||||
|
||||
// maybeBan grants the feedback-ban role to accountID when ban is set, returning a
|
||||
// suffix for the result message.
|
||||
func (s *Server) maybeBan(ctx context.Context, accountID uuid.UUID, ban bool) (string, error) {
|
||||
if !ban {
|
||||
return "", nil
|
||||
}
|
||||
if err := s.accounts.GrantRole(ctx, accountID, account.RoleFeedbackBanned); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return " and the player was banned from feedback", nil
|
||||
}
|
||||
|
||||
// consoleGrantRole grants a known role to an account (the /users role panel).
|
||||
func (s *Server) consoleGrantRole(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, "/_gm/users")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/users/" + id.String()
|
||||
role := trimForm(c, "role")
|
||||
if !account.IsKnownRole(role) {
|
||||
s.renderConsoleMessage(c, "Invalid role", "the selected role is not recognised", back)
|
||||
return
|
||||
}
|
||||
if err := s.accounts.GrantRole(c.Request.Context(), id, role); err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
s.renderConsoleMessage(c, "Role granted", "granted "+role, back)
|
||||
}
|
||||
|
||||
// consoleRevokeRole removes a role from an account (the /users role panel).
|
||||
func (s *Server) consoleRevokeRole(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, "/_gm/users")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/users/" + id.String()
|
||||
role := trimForm(c, "role")
|
||||
if role == "" {
|
||||
s.renderConsoleMessage(c, "Invalid role", "no role given", back)
|
||||
return
|
||||
}
|
||||
if err := s.accounts.RevokeRole(c.Request.Context(), id, role); err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
s.renderConsoleMessage(c, "Role revoked", "revoked "+role, back)
|
||||
}
|
||||
|
||||
// normalizeFeedbackStatus keeps only a recognised feedback status filter, else
|
||||
// "unread" (the default queue).
|
||||
func normalizeFeedbackStatus(s string) string {
|
||||
switch s {
|
||||
case "read", "archived":
|
||||
return s
|
||||
default:
|
||||
return "unread"
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeFilename strips characters unsafe in a Content-Disposition filename
|
||||
// (control chars, quotes and path separators) to prevent header injection.
|
||||
func sanitizeFilename(name string) string {
|
||||
name = strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r == '"' || r == '\\' || r == '/' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, name)
|
||||
if name == "" {
|
||||
return "attachment"
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -176,7 +176,14 @@ func (s *Server) handleResolveSession(c *gin.Context) {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resolveResponse{UserID: sess.AccountID.String()})
|
||||
// is_guest is best-effort: a transient account read must not fail an otherwise
|
||||
// valid resolve (the auth hot path), so a read error falls back to false; the
|
||||
// per-operation backend gate remains the authoritative guest check.
|
||||
resp := resolveResponse{UserID: sess.AccountID.String()}
|
||||
if acc, err := s.accounts.GetByID(c.Request.Context(), sess.AccountID); err == nil {
|
||||
resp.IsGuest = acc.IsGuest
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handleRevokeSession revokes the session for a token (idempotent).
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// feedbackSubmitRequest is the player's feedback submission. Attachment is the
|
||||
// base64-encoded file bytes (empty for none); AttachmentName carries the original
|
||||
// file name (its extension is the allow-list key); Channel is the submitting
|
||||
// platform (telegram/ios/android/web).
|
||||
type feedbackSubmitRequest struct {
|
||||
Body string `json:"body"`
|
||||
Attachment string `json:"attachment"`
|
||||
AttachmentName string `json:"attachment_name"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
|
||||
// feedbackReplyDTO is the operator's reply shown back to the player.
|
||||
type feedbackReplyDTO struct {
|
||||
Body string `json:"body"`
|
||||
RepliedAtUnix int64 `json:"replied_at_unix"`
|
||||
}
|
||||
|
||||
// feedbackStateResponse is the player's feedback screen state. BlockedReason is
|
||||
// "" (can send), "pending" or "banned"; Reply is omitted when there is none.
|
||||
type feedbackStateResponse struct {
|
||||
CanSend bool `json:"can_send"`
|
||||
BlockedReason string `json:"blocked_reason"`
|
||||
Reply *feedbackReplyDTO `json:"reply,omitempty"`
|
||||
}
|
||||
|
||||
// feedbackUnreadResponse reports whether the player has an undelivered reply, for
|
||||
// the lobby/Info badge.
|
||||
type feedbackUnreadResponse struct {
|
||||
ReplyUnread bool `json:"reply_unread"`
|
||||
}
|
||||
|
||||
// handleFeedbackSubmit stores a feedback message from the authenticated player.
|
||||
// The sender IP comes from the gateway-forwarded X-Forwarded-For header. Guests
|
||||
// and feedback-banned accounts are refused (also gated at the gateway).
|
||||
func (s *Server) handleFeedbackSubmit(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
var req feedbackSubmitRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
var attachment []byte
|
||||
if req.Attachment != "" {
|
||||
data, err := base64.StdEncoding.DecodeString(req.Attachment)
|
||||
if err != nil {
|
||||
abortBadRequest(c, "invalid attachment encoding")
|
||||
return
|
||||
}
|
||||
attachment = data
|
||||
}
|
||||
if err := s.feedback.Submit(c.Request.Context(), uid, req.Body, attachment, req.AttachmentName, req.Channel, clientIP(c)); err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
// handleFeedbackState returns the player's feedback screen state and marks any
|
||||
// pending operator replies delivered (clearing the badge).
|
||||
func (s *Server) handleFeedbackState(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
st, err := s.feedback.State(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
resp := feedbackStateResponse{CanSend: st.CanSend, BlockedReason: st.BlockedReason}
|
||||
if st.Reply != nil {
|
||||
resp.Reply = &feedbackReplyDTO{Body: st.Reply.Body, RepliedAtUnix: st.Reply.RepliedAt.Unix()}
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handleFeedbackUnread reports whether the player has an undelivered operator
|
||||
// reply, for the lobby/Info badge. It has no side effect.
|
||||
func (s *Server) handleFeedbackUnread(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
unread, err := s.feedback.ReplyUnread(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, feedbackUnreadResponse{ReplyUnread: unread})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -703,6 +703,7 @@ promotions) is future work and would deliver short markdown messages (text + lin
|
||||
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
|
||||
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
|
||||
| Manual account block (suspension) | backend: a per-request gate refuses a blocked account on every `/api/v1/user/*` route except the block-status probe with **403 `account_blocked`**; the operator blocks/unblocks from the admin console (§11) |
|
||||
| User feedback gate | backend rejects a guest or a `feedback_banned` account from submitting; the **gateway** also rejects a guest's `feedback.submit` (the `Op.NonGuest` flag + `is_guest` from session resolve) with **`guest_forbidden`** before any backend call; attachments are served `nosniff` with a download disposition for non-images (§15) |
|
||||
| Admin authentication | a single Basic-Auth gate on `/_gm/*`, forwarded **verbatim** to the backend's server-rendered admin console (and, in the deployed contour, routing `/_gm/grafana/*` to Grafana). In the deploy the **caddy** owns this gate (§13); a local non-caddy run uses the gateway's own `GATEWAY_ADMIN_*` proxy, which the per-IP admin limiter class guards ahead of its Basic-Auth — the caddy-fronted path has no limiter (stock caddy), an accepted gap. The backend trusts the proxy (no admin principal) and guards its state-changing POSTs with a **same-origin** check — the console's CSRF defence. No operator identity is tracked |
|
||||
| backend ↔ gateway ↔ connector trust | the network (only gateway may reach backend; the connector serves unauthenticated gRPC on the internal segment) |
|
||||
|
||||
@@ -817,3 +818,41 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
|
||||
`BACKEND_DICT_DIR`.
|
||||
- After any push, the run is watched to green before a stage is declared done
|
||||
(`python3 ~/.claude/bin/gitea-ci-watch.py`).
|
||||
|
||||
## 15. User feedback & account roles
|
||||
|
||||
Players reach the operators through a **Feedback** screen (Settings → Info, registered accounts
|
||||
only). A message (≤1024 runes) plus an optional single attachment is stored in
|
||||
`feedback_messages`; the sender's IP (gateway-forwarded, as for chat) and the submitting
|
||||
**channel** (telegram/ios/android/web, client-reported and validated) are recorded. The domain
|
||||
is `internal/feedback` (store + service), modelled on the admin chat-moderation surface.
|
||||
|
||||
**Anti-spam.** A player with an unreviewed message (`read_at IS NULL`) cannot submit another; the
|
||||
gate is server-side. Because the operator must act before the next message, this is itself the
|
||||
rate limit — there is no separate per-user feedback limiter.
|
||||
|
||||
**Operator review** happens in the server-rendered console (`/_gm/feedback`): an
|
||||
unread / read / archived queue with per-user search (the `/users` glob masks), a detail card
|
||||
(user content rendered as auto-escaped `html/template` text), and the read / reply / archive /
|
||||
delete / delete-all actions — each marks the message read; merely opening the detail does not.
|
||||
The attachment is served from `/_gm/feedback/:id/attachment` with `X-Content-Type-Options:
|
||||
nosniff`: images inline (loaded only via `<img>`, which never executes — a renamed non-image is
|
||||
inert), everything else as an `application/octet-stream` download. The UI gates the attachment by
|
||||
file extension (the allow-list is not shown to the user) and the backend mirrors that allow-list
|
||||
plus the ≤1,000,000-byte size cap as the trust boundary; file *content* is not inspected. The
|
||||
1,000,000-byte cap keeps the whole `feedback.submit` request under the gateway's 1 MiB edge body
|
||||
cap (§12) without weakening it.
|
||||
|
||||
**Reply delivery.** The operator's reply lives on the message row and is shown back on the
|
||||
feedback screen ("Ответ на ваше последнее сообщение") for the player's most recent replied
|
||||
message. It becomes "read by the player" the instant the screen fetches it (delivery = read) and
|
||||
is hidden one week after. A Settings → Info badge — folded into the lobby ⚙️ badge together with
|
||||
the friend-request count — signals an undelivered reply; it rides the existing `NotificationEvent`
|
||||
with a new `admin_reply` sub-kind (no new push schema) plus an authoritative poll on lobby load.
|
||||
|
||||
**Account roles.** `account_roles` (account_id, role) is the project's first per-account role
|
||||
table — the reusable replacement for per-feature boolean flags. The first role, `feedback_banned`,
|
||||
blocks **only** feedback submission (unlike a suspension, the whole-account block of §12). It is
|
||||
granted from the feedback section (the delete-with-block checkbox) and granted/revoked from the
|
||||
`/users` console card. Roles are validated against a known set in Go, so adding one needs no
|
||||
migration.
|
||||
|
||||
@@ -172,6 +172,18 @@ block toggles. The profile form is edited inline (no separate edit mode). Linkin
|
||||
an email or Telegram and merging accounts are covered under "Accounts, linking &
|
||||
merge".
|
||||
|
||||
### Feedback
|
||||
A registered player reaches the operators from Settings → Info: a **Feedback** screen with a
|
||||
message (up to 1024 characters) and an optional single attachment (one file, up to ~1 MB — images,
|
||||
PDF, text/log, office documents, RTF or archives; an unsupported file is refused on the form
|
||||
without naming the allowed types). After sending, the form clears and confirms "Ваше сообщение
|
||||
отправлено", and sending is blocked until the operator has dealt with that message — on re-entry
|
||||
the screen reads "Ожидаем рассмотрения вашего последнего обращения". The operator's reply appears
|
||||
below the form as "Ответ на ваше последнее сообщение" (any link in it opens in a new tab); it is
|
||||
marked read once the screen shows it and disappears a week later. A badge on the Settings tab (and on Info inside it) flags an
|
||||
unanswered reply. Guests cannot send feedback (the entry is hidden). A player the operator has
|
||||
barred from feedback (a role, not a full account block) sees the send control disabled.
|
||||
|
||||
### History & statistics
|
||||
Finished games are archived in a dictionary-independent form and exportable to
|
||||
GCG; the export is offered **only once a game is finished** (exporting a live game
|
||||
@@ -225,3 +237,13 @@ From the user card the operator can also **top up a player's hint wallet**: an a
|
||||
(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
|
||||
(with the connector bot language — en/ru — for a Telegram message), the sender's interface
|
||||
language, IP and any attachment. The operator can mark a message read, **reply** to the player (delivered
|
||||
in-app), archive it, delete it, or delete every message from that player — and, alongside a delete,
|
||||
**bar the player from feedback** (a `feedback_banned` role, distinct from a full account block: it
|
||||
stops only feedback submission). Roles are listed and granted/revoked on the user card. Opening a
|
||||
message does not mark it read — only the explicit actions do; message bodies and attachments are
|
||||
shown defensively (text escaped, attachments downloaded rather than rendered).
|
||||
|
||||
@@ -177,6 +177,18 @@ UTC), суточного окна отсутствия (away; сетка по 10
|
||||
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
|
||||
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние».
|
||||
|
||||
### Обратная связь
|
||||
Зарегистрированный игрок обращается к операторам из Settings → Info: экран **«Обратная связь»** с
|
||||
сообщением (до 1024 символов) и необязательным вложением (один файл, до ~1 МБ — изображения, PDF,
|
||||
текст/лог, офисные документы, RTF или архивы; неподходящий файл отклоняется на форме без перечисления
|
||||
допустимых типов). После отправки форма очищается и подтверждает «Ваше сообщение отправлено», а
|
||||
повторная отправка блокируется, пока оператор не разобрал обращение — при повторном входе на экране
|
||||
«Ожидаем рассмотрения вашего последнего обращения». Ответ оператора показывается под формой как
|
||||
«Ответ на ваше последнее сообщение» (ссылки в нём открываются в новой вкладке); он помечается
|
||||
прочитанным, как только экран его показал, и исчезает через неделю. Бейдж на вкладке Settings (и на Info внутри неё) сигналит о непрочитанном
|
||||
ответе. Гость отправлять обратную связь не может (пункт скрыт). Игрок, которому оператор запретил
|
||||
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
|
||||
|
||||
### История и статистика
|
||||
Завершённые партии архивируются в независимом от словаря виде и экспортируются
|
||||
в GCG; экспорт доступен **только после завершения партии** (экспорт идущей партии
|
||||
@@ -231,3 +243,14 @@ high-rate флага. С карточки пользователя операт
|
||||
начисление (1–100 подсказок за раз), которое **только увеличивает** баланс на карточке. Начисления
|
||||
**только в плюс** — понизить кошелёк из консоли нельзя (игрок теряет подсказки только тратя их в
|
||||
партии), поэтому ошибочно начисленное через консоль там не отнять.
|
||||
|
||||
Консоль ведёт и очередь **обратной связи** (`/_gm/feedback`): присланные игроками сообщения с фильтром
|
||||
**непрочитанные / прочитанные / архив** и поиском по пользователю, каждое — с отправителем, источником,
|
||||
каналом (и языком бота-коннектора — en/ru — для сообщения из Telegram), языком интерфейса отправителя,
|
||||
IP и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка
|
||||
в приложение), отправить в архив, удалить или удалить все сообщения этого игрока — и вместе с удалением
|
||||
**запретить игроку обратную связь** (роль `feedback_banned`, отличная от полной блокировки аккаунта:
|
||||
останавливает только отправку обратной связи). Роли перечислены и выдаются/снимаются на карточке
|
||||
пользователя. Открытие сообщения не помечает его прочитанным — это делают только явные действия; тело
|
||||
сообщения и вложения показываются защищённо (текст экранируется, вложения отдаются на скачивание, а не
|
||||
рендерятся).
|
||||
|
||||
@@ -136,6 +136,15 @@ tests or touching CI.
|
||||
postgres_exporter** Grafana dashboard on the contour. Two passes are recorded — the
|
||||
early [`REPORT-R2.md`](../loadtest/REPORT-R2.md) and the final, tuned
|
||||
[`REPORT-R7.md`](../loadtest/REPORT-R7.md). See [`../loadtest/README.md`](../loadtest/README.md).
|
||||
- **User feedback** — `internal/feedback` unit tests cover the attachment allow-list /
|
||||
content-type and the channel normaliser; the UI covers `detectChannel`, the attachment gate and
|
||||
the feedback wire round-trip (`channel` / `feedback` / `codec` tests) plus a Playwright e2e
|
||||
(submit → confirm + resend blocked; an operator reply raises the badge and shows on the screen).
|
||||
The gateway `connectsrv` test asserts the **guest gate** (a guest's `feedback.submit` →
|
||||
`guest_forbidden` with no backend call; a non-gated authenticated op still passes). Postgres-backed
|
||||
`inttest` drives the **feedback lifecycle** end to end: the guest / ban / pending gates, the reply
|
||||
read + one-week visibility window, the account-role grant/revoke, and the admin queue filters with
|
||||
delete / delete-all. The admin-console render test renders the feedback list + detail pages.
|
||||
|
||||
## Principles
|
||||
|
||||
|
||||
+11
-1
@@ -33,7 +33,9 @@ Login uses `Screen`.
|
||||
hub tabs: ⚙️ Settings / 👤 Profile / 🤝 Friends / ℹ️ About (Friends hidden for guests);
|
||||
comms hub tabs: 💬 Chat / 🔎 Dictionary (Dictionary only while the game is active). The
|
||||
routes `/settings|/profile|/friends|/about` and `/game/:id/{chat,check}` survive as hub
|
||||
entry points (so a Telegram friend-code deep-link still lands on the Friends tab).
|
||||
entry points (so a Telegram friend-code deep-link still lands on the Friends tab). The
|
||||
**Feedback** screen is its own deeper route `/feedback` (back → `/about`, the Info tab), so
|
||||
it slides in/out like a sub-screen rather than switching a tab in place.
|
||||
- **Tab bar** (`TabBar.svelte`): square, borderless, evenly distributed buttons — a large
|
||||
emoji icon over a tiny truncated label (the icon is `aria-hidden`, so the label names the
|
||||
button). A press highlights a rounded **square** behind the icon; a hub's **selected** tab
|
||||
@@ -252,6 +254,14 @@ IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use
|
||||
row is turn-driven: on your turn the message field shows until your one allowed message is
|
||||
sent, then a short caption replaces it until the next turn; on the opponent's turn the 🛎️
|
||||
nudge takes the field's place.
|
||||
- **Feedback** (`screens/Feedback.svelte`, reached from a button at the bottom of the ℹ️ Info
|
||||
tab, registered accounts only): a multi-line message field over a row that pairs a **ghost**
|
||||
attach button (a hidden `<input type=file>`, with a chosen-file name + a remove control) with
|
||||
the filled accent **Send** button. A rejected file shows one generic notice (the allowed types
|
||||
are not advertised). The send controls disable while a previous message is under review or the
|
||||
account is barred; the operator's reply renders below as a titled block. An undelivered reply
|
||||
raises a badge on the lobby ⚙️ tab (combined with the friend-request count) and a "1" on the
|
||||
Info tab.
|
||||
|
||||
## Caveat
|
||||
|
||||
|
||||
@@ -207,14 +207,16 @@ func (c *Client) EmailLogin(ctx context.Context, email, code string) (SessionRes
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ResolveSession maps a token to its account id (gateway session-cache miss).
|
||||
func (c *Client) ResolveSession(ctx context.Context, token string) (string, error) {
|
||||
// ResolveSession maps a token to its account id and guest flag (gateway
|
||||
// session-cache miss). The guest flag lets the edge gate guest-forbidden ops.
|
||||
func (c *Client) ResolveSession(ctx context.Context, token string) (string, bool, error) {
|
||||
var out struct {
|
||||
UserID string `json:"user_id"`
|
||||
UserID string `json:"user_id"`
|
||||
IsGuest bool `json:"is_guest"`
|
||||
}
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/resolve", "", "",
|
||||
map[string]string{"token": token}, &out)
|
||||
return out.UserID, err
|
||||
return out.UserID, out.IsGuest, err
|
||||
}
|
||||
|
||||
// Profile returns the authenticated account's profile.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package backendclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// FeedbackReplyResp is the operator reply shown back to the player.
|
||||
type FeedbackReplyResp struct {
|
||||
Body string `json:"body"`
|
||||
RepliedAtUnix int64 `json:"replied_at_unix"`
|
||||
}
|
||||
|
||||
// FeedbackStateResp is the player's feedback screen state. Reply is nil when there
|
||||
// is none to show.
|
||||
type FeedbackStateResp struct {
|
||||
CanSend bool `json:"can_send"`
|
||||
BlockedReason string `json:"blocked_reason"`
|
||||
Reply *FeedbackReplyResp `json:"reply,omitempty"`
|
||||
}
|
||||
|
||||
// FeedbackUnreadResp reports whether the player has an undelivered operator reply.
|
||||
type FeedbackUnreadResp struct {
|
||||
ReplyUnread bool `json:"reply_unread"`
|
||||
}
|
||||
|
||||
// FeedbackSubmit posts a feedback message. The attachment bytes are base64-encoded
|
||||
// into the JSON body for the internal hop; clientIP rides X-Forwarded-For.
|
||||
func (c *Client) FeedbackSubmit(ctx context.Context, userID, body string, attachment []byte, attachmentName, channel, clientIP string) error {
|
||||
payload := map[string]string{
|
||||
"body": body,
|
||||
"attachment": "",
|
||||
"attachment_name": attachmentName,
|
||||
"channel": channel,
|
||||
}
|
||||
if len(attachment) > 0 {
|
||||
payload["attachment"] = base64.StdEncoding.EncodeToString(attachment)
|
||||
}
|
||||
return c.do(ctx, http.MethodPost, "/api/v1/user/feedback", userID, clientIP, payload, nil)
|
||||
}
|
||||
|
||||
// FeedbackGet returns the player's feedback screen state, marking any pending reply
|
||||
// delivered (clearing the badge).
|
||||
func (c *Client) FeedbackGet(ctx context.Context, userID string) (FeedbackStateResp, error) {
|
||||
var out FeedbackStateResp
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/user/feedback", userID, "", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// FeedbackUnread reports whether the player has an undelivered operator reply.
|
||||
func (c *Client) FeedbackUnread(ctx context.Context, userID string) (FeedbackUnreadResp, error) {
|
||||
var out FeedbackUnreadResp
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/user/feedback/unread", userID, "", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
@@ -207,11 +207,20 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
|
||||
|
||||
tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP}
|
||||
if op.Auth {
|
||||
uid, err := s.resolve(ctx, req.Header())
|
||||
uid, isGuest, err := s.resolve(ctx, req.Header())
|
||||
if err != nil {
|
||||
result = "unauthenticated"
|
||||
return nil, err
|
||||
}
|
||||
// A guest may not perform a non-guest operation: reject it here as a domain
|
||||
// outcome, before any backend call.
|
||||
if op.NonGuest && isGuest {
|
||||
result = "domain"
|
||||
return connect.NewResponse(&edgev1.ExecuteResponse{
|
||||
RequestId: req.Msg.GetRequestId(),
|
||||
ResultCode: "guest_forbidden",
|
||||
}), nil
|
||||
}
|
||||
// A valid session proving an authenticated request is an "action" for the
|
||||
// active_users gauge, counted before the rate-limit/domain outcome.
|
||||
s.metrics.recordActive(uid)
|
||||
@@ -254,7 +263,7 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
|
||||
// Subscribe streams the authenticated user's live events with a keep-alive
|
||||
// heartbeat until the client disconnects.
|
||||
func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error {
|
||||
uid, err := s.resolve(ctx, req.Header())
|
||||
uid, _, err := s.resolve(ctx, req.Header())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -332,14 +341,15 @@ func (s *Server) limitAdmin(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// resolve extracts and resolves the Authorization bearer token to an account id,
|
||||
// returning a Connect Unauthenticated error when it is missing or unknown.
|
||||
func (s *Server) resolve(ctx context.Context, h http.Header) (string, error) {
|
||||
// resolve extracts and resolves the Authorization bearer token to an account id
|
||||
// and its guest flag, returning a Connect Unauthenticated error when it is missing
|
||||
// or unknown.
|
||||
func (s *Server) resolve(ctx context.Context, h http.Header) (string, bool, error) {
|
||||
token := bearerToken(h.Get("Authorization"))
|
||||
if token == "" {
|
||||
return "", connect.NewError(connect.CodeUnauthenticated, errMissingToken)
|
||||
return "", false, connect.NewError(connect.CodeUnauthenticated, errMissingToken)
|
||||
}
|
||||
uid, err := s.sessions.Resolve(ctx, token)
|
||||
uid, isGuest, err := s.sessions.Resolve(ctx, token)
|
||||
if err != nil {
|
||||
// An unknown or expired token (a backend 4xx) is the client's problem and
|
||||
// stays silent; anything else — a resolve timeout, a refused connection, a
|
||||
@@ -350,9 +360,9 @@ func (s *Server) resolve(ctx context.Context, h http.Header) (string, error) {
|
||||
if !errors.As(err, &apiErr) || apiErr.Status >= http.StatusInternalServerError {
|
||||
s.log.Warn("session resolve failed", zap.Error(err))
|
||||
}
|
||||
return "", connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
|
||||
return "", false, connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
|
||||
}
|
||||
return uid, nil
|
||||
return uid, isGuest, nil
|
||||
}
|
||||
|
||||
// bearerToken extracts the token from an "Authorization: Bearer <token>" header,
|
||||
|
||||
@@ -69,6 +69,45 @@ func TestExecuteGuestAuthOK(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteGuestGate verifies the NonGuest op flag: a guest is rejected with the
|
||||
// guest_forbidden result code before any backend call, while a guest may still run
|
||||
// an authenticated op that is not guest-gated.
|
||||
func TestExecuteGuestGate(t *testing.T) {
|
||||
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/internal/sessions/resolve":
|
||||
_, _ = w.Write([]byte(`{"user_id":"g-1","is_guest":true}`))
|
||||
case "/api/v1/user/feedback/unread":
|
||||
_, _ = w.Write([]byte(`{"reply_unread":false}`))
|
||||
case "/api/v1/user/feedback":
|
||||
t.Errorf("guest feedback.submit must not reach the backend")
|
||||
default:
|
||||
t.Errorf("unexpected backend path %s", r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
submit := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgFeedbackSubmit, RequestId: "rq-1"})
|
||||
submit.Header().Set("Authorization", "Bearer tok")
|
||||
resp, err := client.Execute(context.Background(), submit)
|
||||
if err != nil {
|
||||
t.Fatalf("execute submit: %v", err)
|
||||
}
|
||||
if resp.Msg.GetResultCode() != "guest_forbidden" {
|
||||
t.Fatalf("submit result = %q, want guest_forbidden", resp.Msg.GetResultCode())
|
||||
}
|
||||
|
||||
unread := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgFeedbackUnread})
|
||||
unread.Header().Set("Authorization", "Bearer tok")
|
||||
resp, err = client.Execute(context.Background(), unread)
|
||||
if err != nil {
|
||||
t.Fatalf("execute unread: %v", err)
|
||||
}
|
||||
if resp.Msg.GetResultCode() != "ok" {
|
||||
t.Fatalf("unread result = %q, want ok", resp.Msg.GetResultCode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteAuthedRequiresSession(t *testing.T) {
|
||||
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("backend must not be called without a session")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { expect, test, type Page } from './fixtures';
|
||||
|
||||
// User feedback against the mock transport (no backend). The mock profile is a
|
||||
// durable (non-guest) account, so the Settings → Info "Feedback" entry is shown.
|
||||
|
||||
async function loginLobby(page: Page): Promise<void> {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
await expect(page.getByText('Your turn')).toBeVisible();
|
||||
}
|
||||
|
||||
// openFeedback navigates lobby ⚙️ → Info tab → the Feedback screen.
|
||||
async function openFeedback(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: /Settings/ }).click();
|
||||
await expect(page.locator('.pane')).toHaveCount(1);
|
||||
await page.getByRole('button', { name: 'Info', exact: true }).click();
|
||||
// The About "Feedback" button: its accessible name gains the "1" badge once a reply
|
||||
// is waiting, so match by substring rather than exact.
|
||||
await page.getByRole('button', { name: /Feedback/ }).click();
|
||||
}
|
||||
|
||||
test('feedback: submit a message, then resend is blocked', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await openFeedback(page);
|
||||
|
||||
await expect(page.getByPlaceholder(/Describe the problem/)).toBeVisible();
|
||||
await page.locator('textarea').fill('The board does not render on my phone.');
|
||||
await page.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
|
||||
await expect(page.getByText('Your message has been sent.')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Send', exact: true })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('feedback: an operator reply raises the badge and shows on the screen', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
|
||||
// Simulate the operator answering: clears any pending message, sets the reply and
|
||||
// pushes the admin_reply live event.
|
||||
await page.evaluate(() => (window as unknown as { __mock: { adminReply(): void } }).__mock.adminReply());
|
||||
|
||||
// The lobby ⚙️ badge folds the awaiting reply into its count.
|
||||
await expect(page.getByRole('button', { name: /Settings/ }).locator('.badge')).toBeVisible();
|
||||
|
||||
await openFeedback(page);
|
||||
await expect(page.getByText('Reply to your last message')).toBeVisible();
|
||||
// A URL in the reply renders as a link that opens in a new tab.
|
||||
const link = page.getByRole('link', { name: 'https://example.com/help' });
|
||||
await expect(link).toBeVisible();
|
||||
await expect(link).toHaveAttribute('target', '_blank');
|
||||
await expect(link).toHaveAttribute('rel', /noopener/);
|
||||
});
|
||||
|
||||
test('feedback: sending a new message clears the previous reply', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await page.evaluate(() => (window as unknown as { __mock: { adminReply(): void } }).__mock.adminReply());
|
||||
await openFeedback(page);
|
||||
await expect(page.getByText('Reply to your last message')).toBeVisible();
|
||||
|
||||
// A new message has no reply yet, so the previous reply must stop showing at once.
|
||||
await page.locator('textarea').fill('a follow-up question');
|
||||
await page.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
await expect(page.getByText('Your message has been sent.')).toBeVisible();
|
||||
await expect(page.getByText('Reply to your last message')).toBeHidden();
|
||||
});
|
||||
+7
-3
@@ -13,6 +13,7 @@
|
||||
import Stats from './screens/Stats.svelte';
|
||||
import Game from './game/Game.svelte';
|
||||
import CommsHub from './game/CommsHub.svelte';
|
||||
import Feedback from './screens/Feedback.svelte';
|
||||
import Blocked from './screens/Blocked.svelte';
|
||||
|
||||
onMount(() => {
|
||||
@@ -26,8 +27,9 @@
|
||||
if (!insideTelegram()) return;
|
||||
const r = router.route;
|
||||
// The chat / check sub-screens step back to their game; every other sub-screen to the lobby.
|
||||
const sub = r.name === 'gameChat' || r.name === 'gameCheck';
|
||||
const target = sub ? `/game/${r.params.id}` : '/';
|
||||
let target = '/';
|
||||
if (r.name === 'gameChat' || r.name === 'gameCheck') target = `/game/${r.params.id}`;
|
||||
else if (r.name === 'feedback') target = '/about'; // back to the Settings → Info tab
|
||||
telegramBackButton(r.name !== 'lobby' && r.name !== 'login', () => navigate(target));
|
||||
});
|
||||
|
||||
@@ -40,7 +42,7 @@
|
||||
// back-to-the-game the wrong way. dir is read with the previous depth (the effect updates it
|
||||
// only after the transition has captured its sign).
|
||||
function routeDepth(name: RouteName): number {
|
||||
if (name === 'gameChat' || name === 'gameCheck') return 2;
|
||||
if (name === 'gameChat' || name === 'gameCheck' || name === 'feedback') return 2;
|
||||
if (name === 'lobby' || name === 'login') return 0;
|
||||
return 1;
|
||||
}
|
||||
@@ -93,6 +95,8 @@
|
||||
<SettingsHub initialTab="about" />
|
||||
{:else if router.route.name === 'friends'}
|
||||
<SettingsHub initialTab="friends" />
|
||||
{:else if router.route.name === 'feedback'}
|
||||
<Feedback />
|
||||
{:else if router.route.name === 'stats'}
|
||||
<Stats />
|
||||
{:else}
|
||||
|
||||
@@ -19,6 +19,10 @@ export { EnqueueRequest } from './scrabblefb/enqueue-request.js';
|
||||
export { EvalRequest } from './scrabblefb/eval-request.js';
|
||||
export { EvalResult } from './scrabblefb/eval-result.js';
|
||||
export { ExchangeRequest } from './scrabblefb/exchange-request.js';
|
||||
export { FeedbackReply } from './scrabblefb/feedback-reply.js';
|
||||
export { FeedbackState } from './scrabblefb/feedback-state.js';
|
||||
export { FeedbackSubmitRequest } from './scrabblefb/feedback-submit-request.js';
|
||||
export { FeedbackUnread } from './scrabblefb/feedback-unread.js';
|
||||
export { FriendCode } from './scrabblefb/friend-code.js';
|
||||
export { FriendList } from './scrabblefb/friend-list.js';
|
||||
export { FriendRespondRequest } from './scrabblefb/friend-respond-request.js';
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
export class FeedbackReply {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):FeedbackReply {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsFeedbackReply(bb:flatbuffers.ByteBuffer, obj?:FeedbackReply):FeedbackReply {
|
||||
return (obj || new FeedbackReply()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsFeedbackReply(bb:flatbuffers.ByteBuffer, obj?:FeedbackReply):FeedbackReply {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new FeedbackReply()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
body():string|null
|
||||
body(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
body(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
repliedAtUnix():bigint {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0');
|
||||
}
|
||||
|
||||
static startFeedbackReply(builder:flatbuffers.Builder) {
|
||||
builder.startObject(2);
|
||||
}
|
||||
|
||||
static addBody(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, bodyOffset, 0);
|
||||
}
|
||||
|
||||
static addRepliedAtUnix(builder:flatbuffers.Builder, repliedAtUnix:bigint) {
|
||||
builder.addFieldInt64(1, repliedAtUnix, BigInt('0'));
|
||||
}
|
||||
|
||||
static endFeedbackReply(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createFeedbackReply(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset, repliedAtUnix:bigint):flatbuffers.Offset {
|
||||
FeedbackReply.startFeedbackReply(builder);
|
||||
FeedbackReply.addBody(builder, bodyOffset);
|
||||
FeedbackReply.addRepliedAtUnix(builder, repliedAtUnix);
|
||||
return FeedbackReply.endFeedbackReply(builder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { FeedbackReply } from '../scrabblefb/feedback-reply.js';
|
||||
|
||||
|
||||
export class FeedbackState {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):FeedbackState {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsFeedbackState(bb:flatbuffers.ByteBuffer, obj?:FeedbackState):FeedbackState {
|
||||
return (obj || new FeedbackState()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsFeedbackState(bb:flatbuffers.ByteBuffer, obj?:FeedbackState):FeedbackState {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new FeedbackState()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
canSend():boolean {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||
}
|
||||
|
||||
blockedReason():string|null
|
||||
blockedReason(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
blockedReason(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
reply(obj?:FeedbackReply):FeedbackReply|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? (obj || new FeedbackReply()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
|
||||
}
|
||||
|
||||
static startFeedbackState(builder:flatbuffers.Builder) {
|
||||
builder.startObject(3);
|
||||
}
|
||||
|
||||
static addCanSend(builder:flatbuffers.Builder, canSend:boolean) {
|
||||
builder.addFieldInt8(0, +canSend, +false);
|
||||
}
|
||||
|
||||
static addBlockedReason(builder:flatbuffers.Builder, blockedReasonOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(1, blockedReasonOffset, 0);
|
||||
}
|
||||
|
||||
static addReply(builder:flatbuffers.Builder, replyOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(2, replyOffset, 0);
|
||||
}
|
||||
|
||||
static endFeedbackState(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
export class FeedbackSubmitRequest {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):FeedbackSubmitRequest {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsFeedbackSubmitRequest(bb:flatbuffers.ByteBuffer, obj?:FeedbackSubmitRequest):FeedbackSubmitRequest {
|
||||
return (obj || new FeedbackSubmitRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsFeedbackSubmitRequest(bb:flatbuffers.ByteBuffer, obj?:FeedbackSubmitRequest):FeedbackSubmitRequest {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new FeedbackSubmitRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
body():string|null
|
||||
body(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
body(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
attachment(index: number):number|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.readUint8(this.bb!.__vector(this.bb_pos + offset) + index) : 0;
|
||||
}
|
||||
|
||||
attachmentLength():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
attachmentArray():Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? new Uint8Array(this.bb!.bytes().buffer, this.bb!.bytes().byteOffset + this.bb!.__vector(this.bb_pos + offset), this.bb!.__vector_len(this.bb_pos + offset)) : null;
|
||||
}
|
||||
|
||||
attachmentName():string|null
|
||||
attachmentName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
attachmentName(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
channel():string|null
|
||||
channel(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
channel(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 10);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
static startFeedbackSubmitRequest(builder:flatbuffers.Builder) {
|
||||
builder.startObject(4);
|
||||
}
|
||||
|
||||
static addBody(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, bodyOffset, 0);
|
||||
}
|
||||
|
||||
static addAttachment(builder:flatbuffers.Builder, attachmentOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(1, attachmentOffset, 0);
|
||||
}
|
||||
|
||||
static createAttachmentVector(builder:flatbuffers.Builder, data:number[]|Uint8Array):flatbuffers.Offset {
|
||||
builder.startVector(1, data.length, 1);
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
builder.addInt8(data[i]!);
|
||||
}
|
||||
return builder.endVector();
|
||||
}
|
||||
|
||||
static startAttachmentVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(1, numElems, 1);
|
||||
}
|
||||
|
||||
static addAttachmentName(builder:flatbuffers.Builder, attachmentNameOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(2, attachmentNameOffset, 0);
|
||||
}
|
||||
|
||||
static addChannel(builder:flatbuffers.Builder, channelOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(3, channelOffset, 0);
|
||||
}
|
||||
|
||||
static endFeedbackSubmitRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createFeedbackSubmitRequest(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset, attachmentOffset:flatbuffers.Offset, attachmentNameOffset:flatbuffers.Offset, channelOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
FeedbackSubmitRequest.startFeedbackSubmitRequest(builder);
|
||||
FeedbackSubmitRequest.addBody(builder, bodyOffset);
|
||||
FeedbackSubmitRequest.addAttachment(builder, attachmentOffset);
|
||||
FeedbackSubmitRequest.addAttachmentName(builder, attachmentNameOffset);
|
||||
FeedbackSubmitRequest.addChannel(builder, channelOffset);
|
||||
return FeedbackSubmitRequest.endFeedbackSubmitRequest(builder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
export class FeedbackUnread {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):FeedbackUnread {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsFeedbackUnread(bb:flatbuffers.ByteBuffer, obj?:FeedbackUnread):FeedbackUnread {
|
||||
return (obj || new FeedbackUnread()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsFeedbackUnread(bb:flatbuffers.ByteBuffer, obj?:FeedbackUnread):FeedbackUnread {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new FeedbackUnread()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
replyUnread():boolean {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||
}
|
||||
|
||||
static startFeedbackUnread(builder:flatbuffers.Builder) {
|
||||
builder.startObject(1);
|
||||
}
|
||||
|
||||
static addReplyUnread(builder:flatbuffers.Builder, replyUnread:boolean) {
|
||||
builder.addFieldInt8(0, +replyUnread, +false);
|
||||
}
|
||||
|
||||
static endFeedbackUnread(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createFeedbackUnread(builder:flatbuffers.Builder, replyUnread:boolean):flatbuffers.Offset {
|
||||
FeedbackUnread.startFeedbackUnread(builder);
|
||||
FeedbackUnread.addReplyUnread(builder, replyUnread);
|
||||
return FeedbackUnread.endFeedbackUnread(builder);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,9 @@ export const app = $state<{
|
||||
notifications: number;
|
||||
/** Unread chat-message count per game id, for the in-game score-bar and 💬 badges. */
|
||||
chatUnread: Record<string, number>;
|
||||
/** Whether an operator feedback reply awaits the player, for the lobby ⚙️ badge (combined
|
||||
* with friend requests) and the Settings → Info badge. */
|
||||
feedbackReplyUnread: boolean;
|
||||
/** Monotonic counter bumped when the app returns to the foreground without the live stream
|
||||
* having dropped. An open game watches it to refetch once, recovering an in-game event shed
|
||||
* from a full hub buffer while suspended (the stream-drop case is covered by streamAlive). */
|
||||
@@ -79,6 +82,7 @@ export const app = $state<{
|
||||
localeLocked: false,
|
||||
notifications: 0,
|
||||
chatUnread: {},
|
||||
feedbackReplyUnread: false,
|
||||
resync: 0,
|
||||
});
|
||||
|
||||
@@ -257,6 +261,10 @@ function openStream(): void {
|
||||
if ((e.sub === 'invitation' || e.sub === 'invitation_update') && e.invitation) {
|
||||
patchLobbyInvitation(e.invitation);
|
||||
}
|
||||
// An operator answered the player's feedback: raise the Settings/Info badge.
|
||||
if (e.sub === 'admin_reply') {
|
||||
app.feedbackReplyUnread = true;
|
||||
}
|
||||
void refreshNotifications();
|
||||
}
|
||||
},
|
||||
@@ -299,6 +307,24 @@ export async function refreshNotifications(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* refreshFeedbackBadge recomputes whether an operator feedback reply awaits the
|
||||
* player (the Settings → Info badge, folded into the lobby ⚙️ badge). Authoritative
|
||||
* poll, complementing the live 'admin_reply' push. Guests cannot submit feedback, so
|
||||
* it is a no-op for them.
|
||||
*/
|
||||
export async function refreshFeedbackBadge(): Promise<void> {
|
||||
if (!app.session || app.profile?.isGuest) {
|
||||
app.feedbackReplyUnread = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
app.feedbackReplyUnread = await gateway.feedbackUnread();
|
||||
} catch {
|
||||
// Best-effort; leave the previous flag on a transient failure.
|
||||
}
|
||||
}
|
||||
|
||||
function closeStream(): void {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// The submitting platform ("channel") reported with a feedback message. Detected
|
||||
// from the runtime environment: Telegram Mini App, the Capacitor native shell
|
||||
// (iOS/Android), or a plain web browser. No new dependency — the Capacitor runtime
|
||||
// global is feature-detected.
|
||||
|
||||
import { insideTelegram } from './telegram';
|
||||
|
||||
export type Channel = 'telegram' | 'ios' | 'android' | 'web';
|
||||
|
||||
/**
|
||||
* detectChannel picks the channel from the runtime signals: telegram wins, then a
|
||||
* native Capacitor platform (ios/android), else web. Pure, so it is unit-tested
|
||||
* without a DOM.
|
||||
*/
|
||||
export function detectChannel(signals: { telegram: boolean; capacitorPlatform?: string }): Channel {
|
||||
if (signals.telegram) return 'telegram';
|
||||
if (signals.capacitorPlatform === 'ios' || signals.capacitorPlatform === 'android') {
|
||||
return signals.capacitorPlatform;
|
||||
}
|
||||
return 'web';
|
||||
}
|
||||
|
||||
/** clientChannel returns the submitting platform from the current runtime. */
|
||||
export function clientChannel(): Channel {
|
||||
const capacitorPlatform =
|
||||
typeof window === 'undefined'
|
||||
? undefined
|
||||
: (window as unknown as { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
|
||||
return detectChannel({ telegram: insideTelegram(), capacitorPlatform });
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
BlockStatus,
|
||||
ChatMessage,
|
||||
EvalResult,
|
||||
FeedbackState,
|
||||
FriendCode,
|
||||
GameList,
|
||||
GameView,
|
||||
@@ -101,6 +102,14 @@ export interface GatewayClient {
|
||||
chatList(gameId: string): Promise<ChatMessage[]>;
|
||||
nudge(gameId: string): Promise<ChatMessage>;
|
||||
|
||||
// --- feedback ---
|
||||
/** feedbackSubmit sends a feedback message with an optional single attachment. */
|
||||
feedbackSubmit(body: string, attachment: Uint8Array | null, attachmentName: string, channel: string): Promise<void>;
|
||||
/** feedbackGet returns the feedback screen state and marks any pending reply delivered. */
|
||||
feedbackGet(): Promise<FeedbackState>;
|
||||
/** feedbackUnread reports whether an operator reply awaits delivery (the badge). */
|
||||
feedbackUnread(): Promise<boolean>;
|
||||
|
||||
// --- friends ---
|
||||
friendsList(): Promise<AccountRef[]>;
|
||||
friendsIncoming(): Promise<AccountRef[]>;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { attachmentError, linkify, MAX_ATTACHMENT_BYTES } from './feedback';
|
||||
|
||||
describe('attachmentError', () => {
|
||||
it('accepts allowed extensions within the size cap', () => {
|
||||
expect(attachmentError('shot.png', 1000)).toBe('');
|
||||
expect(attachmentError('Photo.JPG', 1000)).toBe(''); // case-insensitive
|
||||
expect(attachmentError('report.pdf', 1000)).toBe('');
|
||||
expect(attachmentError('my.notes.docx', 1000)).toBe(''); // dotted name
|
||||
expect(attachmentError('logs.7z', MAX_ATTACHMENT_BYTES)).toBe('');
|
||||
});
|
||||
|
||||
it('rejects disallowed or extensionless names', () => {
|
||||
expect(attachmentError('evil.exe', 10)).toBe('type');
|
||||
expect(attachmentError('x.svg', 10)).toBe('type'); // XSS vector, not allowed
|
||||
expect(attachmentError('x.html', 10)).toBe('type');
|
||||
expect(attachmentError('README', 10)).toBe('type');
|
||||
expect(attachmentError('', 10)).toBe('type');
|
||||
});
|
||||
|
||||
it('rejects too-large files', () => {
|
||||
expect(attachmentError('shot.png', MAX_ATTACHMENT_BYTES + 1)).toBe('size');
|
||||
});
|
||||
});
|
||||
|
||||
describe('linkify', () => {
|
||||
it('returns a single text segment for plain text', () => {
|
||||
expect(linkify('just text')).toEqual([{ text: 'just text' }]);
|
||||
});
|
||||
|
||||
it('marks an http(s) URL as a link', () => {
|
||||
expect(linkify('see https://example.com now')).toEqual([
|
||||
{ text: 'see ' },
|
||||
{ text: 'https://example.com', href: 'https://example.com' },
|
||||
{ text: ' now' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps trailing punctuation out of the link', () => {
|
||||
expect(linkify('go to https://example.com.')).toEqual([
|
||||
{ text: 'go to ' },
|
||||
{ text: 'https://example.com', href: 'https://example.com' },
|
||||
{ text: '.' },
|
||||
]);
|
||||
expect(linkify('(see https://example.com/path)')).toEqual([
|
||||
{ text: '(see ' },
|
||||
{ text: 'https://example.com/path', href: 'https://example.com/path' },
|
||||
{ text: ')' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles multiple links', () => {
|
||||
const segs = linkify('a http://x.io b https://y.io');
|
||||
expect(segs.filter((s) => s.href).map((s) => s.href)).toEqual(['http://x.io', 'https://y.io']);
|
||||
});
|
||||
|
||||
it('linkifies whitelisted non-http schemes (ftp, mailto, tel)', () => {
|
||||
expect(linkify('ftp://files.example.com/x')).toContainEqual({
|
||||
text: 'ftp://files.example.com/x',
|
||||
href: 'ftp://files.example.com/x',
|
||||
});
|
||||
expect(linkify('write to mailto:help@example.com please')).toContainEqual({
|
||||
text: 'mailto:help@example.com',
|
||||
href: 'mailto:help@example.com',
|
||||
});
|
||||
expect(linkify('call tel:+1234')).toContainEqual({ text: 'tel:+1234', href: 'tel:+1234' });
|
||||
});
|
||||
|
||||
it('never linkifies dangerous schemes', () => {
|
||||
const segs = linkify('javascript:alert(1) data:text/html,x vbscript:msgbox');
|
||||
expect(segs.some((s) => s.href)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not match a scheme inside a word (hotel:, email:)', () => {
|
||||
expect(linkify('the hotel:foo and email:bar').some((s) => s.href)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// Feedback client-side limits and the attachment pre-upload gate. The gate is by
|
||||
// file extension only (the UI does not list the allowed types and never uploads a
|
||||
// rejected file); the server re-checks the same limits as the trust boundary.
|
||||
|
||||
/** MAX_BODY caps the feedback message length, in characters (matches the backend). */
|
||||
export const MAX_BODY = 1024;
|
||||
|
||||
/** MAX_ATTACHMENT_BYTES caps a single attachment's size (matches the backend). */
|
||||
export const MAX_ATTACHMENT_BYTES = 1_000_000;
|
||||
|
||||
// Allowed attachment extensions, mirrored from the backend allow-list.
|
||||
const ALLOWED_EXT = new Set([
|
||||
'png', 'jpg', 'jpeg', 'webp', 'gif', // images
|
||||
'pdf', 'txt', 'log', 'doc', 'docx', 'rtf', 'zip', 'gz', '7z',
|
||||
]);
|
||||
|
||||
/**
|
||||
* attachmentError validates a picked file by name and size, returning 'type' for a
|
||||
* disallowed (or missing) extension, 'size' for a too-large file, or '' when it is
|
||||
* acceptable. The caller shows a single generic "cannot attach" message regardless
|
||||
* of which non-empty reason it is, so the allowed types are never revealed.
|
||||
*/
|
||||
export function attachmentError(name: string, size: number): '' | 'type' | 'size' {
|
||||
const dot = name.lastIndexOf('.');
|
||||
const ext = dot >= 0 ? name.slice(dot + 1).toLowerCase() : '';
|
||||
if (!ext || !ALLOWED_EXT.has(ext)) return 'type';
|
||||
if (size > MAX_ATTACHMENT_BYTES) return 'size';
|
||||
return '';
|
||||
}
|
||||
|
||||
/** ReplySegment is one run of the operator reply: plain text, or a link when href is set. */
|
||||
export interface ReplySegment {
|
||||
text: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
// Links are recognised only for an explicit, whitelisted scheme, so dangerous ones
|
||||
// (javascript:, data:, vbscript:) are never turned into an href. http/https/ftp use
|
||||
// "scheme://host…"; mailto/tel use "scheme:rest". Matching stops at whitespace or '<'.
|
||||
// The reply is operator-authored, so explicit schemes (no bare-domain guessing) are
|
||||
// enough — which keeps this a few lines with no dependency.
|
||||
const URL_RE = /\b(?:(?:https?|ftp):\/\/|(?:mailto|tel):)[^\s<]+/gi;
|
||||
// Trailing punctuation that should not be part of the URL (kept as following text).
|
||||
const TRAILING_RE = /[.,!?;:)\]}'"»]+$/;
|
||||
|
||||
/**
|
||||
* linkify splits the operator reply into segments, marking http(s) URLs as links so
|
||||
* the UI renders them as anchors (opened in a new tab) while everything else stays
|
||||
* plain, escaped text. It never emits markup — the caller renders each segment with
|
||||
* the framework's escaping — so it cannot introduce XSS.
|
||||
*/
|
||||
export function linkify(text: string): ReplySegment[] {
|
||||
const out: ReplySegment[] = [];
|
||||
let last = 0;
|
||||
for (const m of text.matchAll(URL_RE)) {
|
||||
const start = m.index;
|
||||
let url = m[0];
|
||||
const trail = TRAILING_RE.exec(url)?.[0] ?? '';
|
||||
if (trail) url = url.slice(0, url.length - trail.length);
|
||||
if (start > last) out.push({ text: text.slice(last, start) });
|
||||
out.push({ text: url, href: url });
|
||||
if (trail) out.push({ text: trail });
|
||||
last = start + m[0].length;
|
||||
}
|
||||
if (last < text.length) out.push({ text: text.slice(last) });
|
||||
if (out.length === 0) out.push({ text });
|
||||
return out;
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -171,6 +171,17 @@ export const ru: Record<MessageKey, string> = {
|
||||
'about.description': 'Мультиплатформенная игра в скрабл.',
|
||||
'about.version': 'Версия {v}',
|
||||
|
||||
'feedback.title': 'Обратная связь',
|
||||
'feedback.placeholder': 'Опишите проблему или поделитесь предложением…',
|
||||
'feedback.attach': 'Прикрепить файл',
|
||||
'feedback.removeFile': 'Удалить',
|
||||
'feedback.send': 'Отправить',
|
||||
'feedback.fileRejected': 'Этот файл нельзя прикрепить.',
|
||||
'feedback.sent': 'Ваше сообщение отправлено',
|
||||
'feedback.waiting': 'Ожидаем рассмотрения вашего последнего обращения',
|
||||
'feedback.banned': 'Отправка обратной связи недоступна',
|
||||
'feedback.replyTitle': 'Ответ на ваше последнее сообщение',
|
||||
|
||||
'landing.tagline': 'Играй в Скрэббл с друзьями или умным роботом — в браузере или в Telegram.',
|
||||
'landing.playTelegram': 'Играть в Telegram',
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import type {
|
||||
BlockStatus,
|
||||
ChatMessage,
|
||||
EvalResult,
|
||||
FeedbackReply,
|
||||
FeedbackState,
|
||||
FriendCode,
|
||||
GameList,
|
||||
GcgExport,
|
||||
@@ -89,6 +91,11 @@ export class MockGateway implements GatewayClient {
|
||||
private readonly profile: Profile = { ...PROFILE };
|
||||
private readonly subs = new Set<(e: PushEvent) => void>();
|
||||
private pendingMatch: string | null = null;
|
||||
// Feedback slice: a submission marks a message pending (blocks resend); mockAdminReply
|
||||
// clears it and raises the badge.
|
||||
private feedbackPending = false;
|
||||
private feedbackReply: FeedbackReply | null = null;
|
||||
private feedbackReplyUnread = false;
|
||||
// The most recently opened auto-match game still awaiting an opponent, for the e2e join hook.
|
||||
private openGameId: string | null = null;
|
||||
private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
|
||||
@@ -409,6 +416,31 @@ export class MockGateway implements GatewayClient {
|
||||
async chatList(gameId: string): Promise<ChatMessage[]> {
|
||||
return [...this.game(gameId).chat];
|
||||
}
|
||||
async feedbackSubmit(_body: string, _attachment: Uint8Array | null, _attachmentName: string, _channel: string): Promise<void> {
|
||||
this.feedbackPending = true;
|
||||
this.feedbackReply = null; // a new message: the previous operator reply no longer shows
|
||||
}
|
||||
async feedbackGet(): Promise<FeedbackState> {
|
||||
this.feedbackReplyUnread = false; // fetching the screen delivers (reads) the reply
|
||||
return {
|
||||
canSend: !this.feedbackPending,
|
||||
blockedReason: this.feedbackPending ? 'pending' : '',
|
||||
reply: this.feedbackReply,
|
||||
};
|
||||
}
|
||||
async feedbackUnread(): Promise<boolean> {
|
||||
return this.feedbackReplyUnread;
|
||||
}
|
||||
|
||||
// mockAdminReply simulates an operator reply: it clears the pending message, sets the
|
||||
// reply and raises the badge via a live admin_reply notification. e2e hook:
|
||||
// window.__mock.adminReply.
|
||||
mockAdminReply(body = 'Thanks — see https://example.com/help for details.'): void {
|
||||
this.feedbackPending = false;
|
||||
this.feedbackReply = { body, repliedAtUnix: Math.floor(Date.now() / 1000) };
|
||||
this.feedbackReplyUnread = true;
|
||||
this.emit({ kind: 'notify', sub: 'admin_reply' });
|
||||
}
|
||||
async nudge(gameId: string): Promise<ChatMessage> {
|
||||
const g = this.game(gameId);
|
||||
const msg: ChatMessage = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { app } from '../lib/app.svelte';
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
import { aboutContent } from '../lib/aboutContent';
|
||||
|
||||
// The auto-match move clock (mirrors backend game.DefaultTurnTimeout = 24h).
|
||||
@@ -31,6 +32,12 @@
|
||||
</section>
|
||||
|
||||
<p class="muted">{t('about.version', { v: version })}</p>
|
||||
|
||||
{#if !(app.profile?.isGuest ?? true)}
|
||||
<button class="feedback" onclick={() => navigate('/feedback')}>
|
||||
{t('feedback.title')}{#if app.feedbackReplyUnread}<span class="fbadge">1</span>{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -73,4 +80,27 @@
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
.feedback {
|
||||
align-self: flex-start;
|
||||
padding: 12px 18px;
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
border-radius: var(--radius);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* The reply badge sits on the accent button, so it inverts the accent colours. */
|
||||
.fbadge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
min-width: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-text);
|
||||
color: var(--accent);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.55;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Screen from '../components/Screen.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { app, handleError } from '../lib/app.svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { clientChannel } from '../lib/channel';
|
||||
import { attachmentError, linkify, MAX_BODY } from '../lib/feedback';
|
||||
import type { FeedbackState } from '../lib/model';
|
||||
|
||||
// The feedback screen: a message (+ optional single attachment) the player sends to
|
||||
// the operators, plus the operator's last reply. The form is disabled while a
|
||||
// previous message is under review or the account is banned from feedback.
|
||||
let view = $state<FeedbackState | null>(null);
|
||||
let body = $state('');
|
||||
let file = $state<File | null>(null);
|
||||
let fileError = $state(false);
|
||||
let sending = $state(false);
|
||||
let sent = $state(false);
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
const enabled = $derived(view?.canSend ?? false);
|
||||
const reason = $derived(view?.blockedReason ?? '');
|
||||
const canSend = $derived(enabled && body.trim().length > 0 && !sending && connection.online);
|
||||
// The operator reply, split into text + http(s) links for rendering.
|
||||
const replySegments = $derived(view?.reply ? linkify(view.reply.body) : []);
|
||||
|
||||
onMount(() => void load());
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
view = await gateway.feedbackGet();
|
||||
// Fetching the screen delivers (reads) any pending reply: clear the badge.
|
||||
app.feedbackReplyUnread = false;
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
function pickFile(e: Event) {
|
||||
const input = e.currentTarget as HTMLInputElement;
|
||||
const f = input.files?.[0] ?? null;
|
||||
if (!f) return;
|
||||
if (attachmentError(f.name, f.size) !== '') {
|
||||
fileError = true;
|
||||
file = null;
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
fileError = false;
|
||||
file = f;
|
||||
}
|
||||
|
||||
function removeFile() {
|
||||
file = null;
|
||||
fileError = false;
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
|
||||
async function send() {
|
||||
if (!canSend) return;
|
||||
sending = true;
|
||||
try {
|
||||
let bytes: Uint8Array | null = null;
|
||||
let name = '';
|
||||
if (file) {
|
||||
bytes = new Uint8Array(await file.arrayBuffer());
|
||||
name = file.name;
|
||||
}
|
||||
await gateway.feedbackSubmit(body.trim(), bytes, name, clientChannel());
|
||||
body = '';
|
||||
removeFile();
|
||||
sent = true;
|
||||
// The message is now under review: block resending until the operator handles it.
|
||||
// A new message has no reply yet, so the previous operator reply stops showing.
|
||||
view = { canSend: false, blockedReason: 'pending', reply: null };
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
sending = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Screen title={t('feedback.title')} back="/about">
|
||||
<div class="page">
|
||||
<textarea
|
||||
bind:value={body}
|
||||
maxlength={MAX_BODY}
|
||||
rows="6"
|
||||
placeholder={t('feedback.placeholder')}
|
||||
disabled={!enabled}
|
||||
></textarea>
|
||||
|
||||
<div class="row">
|
||||
<input bind:this={fileInput} type="file" class="hidden" onchange={pickFile} />
|
||||
{#if file}
|
||||
<span class="fname">{file.name}</span>
|
||||
<button type="button" class="neutral" onclick={removeFile}>{t('feedback.removeFile')}</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="neutral attach"
|
||||
aria-label={t('feedback.attach')}
|
||||
title={t('feedback.attach')}
|
||||
onclick={() => fileInput.click()}
|
||||
disabled={!enabled}>📎</button>
|
||||
{/if}
|
||||
<button type="button" class="cta" onclick={send} disabled={!canSend}>{t('feedback.send')}</button>
|
||||
</div>
|
||||
|
||||
{#if fileError}<p class="err">{t('feedback.fileRejected')}</p>{/if}
|
||||
|
||||
{#if sent}
|
||||
<p class="ok">{t('feedback.sent')}</p>
|
||||
{:else if reason === 'pending'}
|
||||
<p class="muted">{t('feedback.waiting')}</p>
|
||||
{:else if reason === 'banned'}
|
||||
<p class="muted">{t('feedback.banned')}</p>
|
||||
{/if}
|
||||
|
||||
{#if view?.reply}
|
||||
<section class="reply">
|
||||
<h2>{t('feedback.replyTitle')}</h2>
|
||||
<p class="replybody">{#each replySegments as seg, i (i)}{#if seg.href}<a href={seg.href} target="_blank" rel="noopener noreferrer">{seg.text}</a>{:else}{seg.text}{/if}{/each}</p>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</Screen>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: var(--pad);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
min-height: 7rem;
|
||||
font: inherit;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.fname {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.cta {
|
||||
margin-left: auto;
|
||||
padding: 12px 18px;
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
border-radius: var(--radius);
|
||||
font-weight: 600;
|
||||
}
|
||||
.neutral {
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius);
|
||||
font-weight: 600;
|
||||
}
|
||||
.cta:disabled,
|
||||
.neutral:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.attach {
|
||||
font-size: 1.15rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.err {
|
||||
color: var(--danger);
|
||||
margin: 0;
|
||||
}
|
||||
.ok {
|
||||
color: var(--accent);
|
||||
margin: 0;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
.reply {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
border-top: 1px solid var(--surface-2);
|
||||
padding-top: 10px;
|
||||
}
|
||||
.reply h2 {
|
||||
font-size: 1.05rem;
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.replybody {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.replybody a {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import Screen from '../components/Screen.svelte';
|
||||
import TabBar from '../components/TabBar.svelte';
|
||||
import { app, handleError } from '../lib/app.svelte';
|
||||
import { app, handleError, refreshFeedbackBadge } from '../lib/app.svelte';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
@@ -18,15 +18,19 @@
|
||||
let incoming = $state<AccountRef[]>([]);
|
||||
|
||||
const guest = $derived(app.profile?.isGuest ?? true);
|
||||
// The lobby ⚙️ badge is the shared count: incoming friend requests plus an awaiting
|
||||
// operator feedback reply (the Settings → Info badge folds in here).
|
||||
const settingsBadge = $derived(app.notifications + (app.feedbackReplyUnread ? 1 : 0));
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
games = (await gateway.gamesList()).games;
|
||||
if (!guest) {
|
||||
[invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
|
||||
// The ⚙️ badge counts only what lives behind it (incoming friend requests);
|
||||
// The ⚙️ badge counts incoming friend requests plus an awaiting feedback reply;
|
||||
// invitations surface in their own lobby section above.
|
||||
app.notifications = incoming.length;
|
||||
void refreshFeedbackBadge();
|
||||
}
|
||||
setLobby({ games, invitations, incoming });
|
||||
// Warm the cache for the ongoing games so opening one from the lobby is instant. The list
|
||||
@@ -234,7 +238,7 @@
|
||||
<span class="sq">📊</span><span class="lbl">{t('lobby.stats')}</span>
|
||||
</button>
|
||||
<button class="tab" onclick={() => navigate('/settings')}>
|
||||
<span class="sq">⚙️{#if app.notifications > 0}<span class="badge">{app.notifications}</span>{/if}</span>
|
||||
<span class="sq">⚙️{#if settingsBadge > 0}<span class="badge">{settingsBadge}</span>{/if}</span>
|
||||
<span class="lbl">{t('lobby.settings')}</span>
|
||||
</button>
|
||||
</TabBar>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</button>
|
||||
{/if}
|
||||
<button class="tab" class:active={tab === 'about'} onclick={() => (tab = 'about')}>
|
||||
<span class="sq" aria-hidden="true">ℹ️</span><span class="lbl">{t('about.tab')}</span>
|
||||
<span class="sq" aria-hidden="true">ℹ️{#if app.feedbackReplyUnread}<span class="badge">1</span>{/if}</span><span class="lbl">{t('about.tab')}</span>
|
||||
</button>
|
||||
</TabBar>
|
||||
{/snippet}
|
||||
|
||||
Reference in New Issue
Block a user