feat(feedback): in-app user feedback with admin review and account roles
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s

User-facing Feedback screen (Settings -> Info, registered accounts only): a
message (<=1024 runes) plus one optional attachment, an anti-spam gate (one
unreviewed message at a time), and the operator's inline reply with a
Settings/Info badge. Server-rendered admin console section (/_gm/feedback):
unread/read/archived queue with per-user search, detail with read/reply/
archive/delete/delete-all, safe attachment serving (nosniff, images inline via
<img>, others download-only). Introduces account_roles, the first per-account
role table; feedback_banned blocks only feedback submission, granted/revoked
from /users and the delete-with-block action.

- migration 00004_feedback (feedback_messages + account_roles) + jetgen
- backend internal/feedback (store+service), internal/account/roles.go
- wire: FlatBuffers feedback.submit/get/unread; gateway guest gate (Op.NonGuest,
  is_guest via session resolve) -> guest_forbidden before any backend call
- reply push reuses NotificationEvent with a new admin_reply sub-kind
- UI: /feedback route + screen, attachment picker, badge, channel detection, i18n
- tests: feedback unit (Go+UI), gateway guest-gate, inttest lifecycle, e2e
- docs: PLAN stage 19, ARCHITECTURE s15, FUNCTIONAL(+ru), TESTING, READMEs
This commit is contained in:
Ilia Denisov
2026-06-15 12:23:10 +02:00
parent fc848157d6
commit 419ea11b14
75 changed files with 3638 additions and 55 deletions
+26
View File
@@ -59,6 +59,9 @@ export const app = $state<{
notifications: number;
/** Unread chat-message count per game id, for the in-game score-bar and 💬 badges. */
chatUnread: Record<string, number>;
/** Whether an operator feedback reply awaits the player, for the lobby ⚙️ badge (combined
* with friend requests) and the Settings → Info badge. */
feedbackReplyUnread: boolean;
/** Monotonic counter bumped when the app returns to the foreground without the live stream
* having dropped. An open game watches it to refetch once, recovering an in-game event shed
* from a full hub buffer while suspended (the stream-drop case is covered by streamAlive). */
@@ -79,6 +82,7 @@ export const app = $state<{
localeLocked: false,
notifications: 0,
chatUnread: {},
feedbackReplyUnread: false,
resync: 0,
});
@@ -257,6 +261,10 @@ function openStream(): void {
if ((e.sub === 'invitation' || e.sub === 'invitation_update') && e.invitation) {
patchLobbyInvitation(e.invitation);
}
// An operator answered the player's feedback: raise the Settings/Info badge.
if (e.sub === 'admin_reply') {
app.feedbackReplyUnread = true;
}
void refreshNotifications();
}
},
@@ -299,6 +307,24 @@ export async function refreshNotifications(): Promise<void> {
}
}
/**
* refreshFeedbackBadge recomputes whether an operator feedback reply awaits the
* player (the Settings → Info badge, folded into the lobby ⚙️ badge). Authoritative
* poll, complementing the live 'admin_reply' push. Guests cannot submit feedback, so
* it is a no-op for them.
*/
export async function refreshFeedbackBadge(): Promise<void> {
if (!app.session || app.profile?.isGuest) {
app.feedbackReplyUnread = false;
return;
}
try {
app.feedbackReplyUnread = await gateway.feedbackUnread();
} catch {
// Best-effort; leave the previous flag on a transient failure.
}
}
function closeStream(): void {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { detectChannel } from './channel';
describe('detectChannel', () => {
it('prefers telegram over everything', () => {
expect(detectChannel({ telegram: true })).toBe('telegram');
expect(detectChannel({ telegram: true, capacitorPlatform: 'ios' })).toBe('telegram');
});
it('maps the native Capacitor platforms', () => {
expect(detectChannel({ telegram: false, capacitorPlatform: 'ios' })).toBe('ios');
expect(detectChannel({ telegram: false, capacitorPlatform: 'android' })).toBe('android');
});
it('falls back to web for capacitor web, missing, or unknown platforms', () => {
expect(detectChannel({ telegram: false, capacitorPlatform: 'web' })).toBe('web');
expect(detectChannel({ telegram: false })).toBe('web');
expect(detectChannel({ telegram: false, capacitorPlatform: 'desktop' })).toBe('web');
});
});
+30
View File
@@ -0,0 +1,30 @@
// The submitting platform ("channel") reported with a feedback message. Detected
// from the runtime environment: Telegram Mini App, the Capacitor native shell
// (iOS/Android), or a plain web browser. No new dependency — the Capacitor runtime
// global is feature-detected.
import { insideTelegram } from './telegram';
export type Channel = 'telegram' | 'ios' | 'android' | 'web';
/**
* detectChannel picks the channel from the runtime signals: telegram wins, then a
* native Capacitor platform (ios/android), else web. Pure, so it is unit-tested
* without a DOM.
*/
export function detectChannel(signals: { telegram: boolean; capacitorPlatform?: string }): Channel {
if (signals.telegram) return 'telegram';
if (signals.capacitorPlatform === 'ios' || signals.capacitorPlatform === 'android') {
return signals.capacitorPlatform;
}
return 'web';
}
/** clientChannel returns the submitting platform from the current runtime. */
export function clientChannel(): Channel {
const capacitorPlatform =
typeof window === 'undefined'
? undefined
: (window as unknown as { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
return detectChannel({ telegram: insideTelegram(), capacitorPlatform });
}
+9
View File
@@ -9,6 +9,7 @@ import type {
BlockStatus,
ChatMessage,
EvalResult,
FeedbackState,
FriendCode,
GameList,
GameView,
@@ -101,6 +102,14 @@ export interface GatewayClient {
chatList(gameId: string): Promise<ChatMessage[]>;
nudge(gameId: string): Promise<ChatMessage>;
// --- feedback ---
/** feedbackSubmit sends a feedback message with an optional single attachment. */
feedbackSubmit(body: string, attachment: Uint8Array | null, attachmentName: string, channel: string): Promise<void>;
/** feedbackGet returns the feedback screen state and marks any pending reply delivered. */
feedbackGet(): Promise<FeedbackState>;
/** feedbackUnread reports whether an operator reply awaits delivery (the badge). */
feedbackUnread(): Promise<boolean>;
// --- friends ---
friendsList(): Promise<AccountRef[]>;
friendsIncoming(): Promise<AccountRef[]>;
+60
View File
@@ -13,9 +13,12 @@ import {
decodeMatchResult,
decodeOutgoingList,
decodeSession,
decodeFeedbackState,
decodeFeedbackUnread,
decodeStateView,
decodeStats,
encodeCheckWord,
encodeFeedbackSubmit,
encodeDraftSave,
encodeExchange,
encodeStateRequest,
@@ -38,6 +41,63 @@ describe('codec', () => {
expect(decodeDraftView(b.asUint8Array())).toBe('{"x":1}');
});
it('round-trips a feedback submit and decodes state + unread', () => {
const att = new Uint8Array([1, 2, 3, 4]);
const req = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest(
new ByteBuffer(encodeFeedbackSubmit('please fix', att, 'shot.png', 'ios')),
);
expect(req.body()).toBe('please fix');
expect(req.attachmentName()).toBe('shot.png');
expect(req.channel()).toBe('ios');
expect(Array.from(req.attachmentArray() ?? [])).toEqual([1, 2, 3, 4]);
// No attachment: the vector is empty.
const req2 = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest(
new ByteBuffer(encodeFeedbackSubmit('hi', null, '', 'web')),
);
expect(req2.body()).toBe('hi');
expect(req2.attachmentLength()).toBe(0);
// State carrying a reply.
const b = new Builder(128);
const replyBody = b.createString('we are on it');
fb.FeedbackReply.startFeedbackReply(b);
fb.FeedbackReply.addBody(b, replyBody);
fb.FeedbackReply.addRepliedAtUnix(b, BigInt(1700000000));
const reply = fb.FeedbackReply.endFeedbackReply(b);
const reason = b.createString('');
fb.FeedbackState.startFeedbackState(b);
fb.FeedbackState.addCanSend(b, true);
fb.FeedbackState.addBlockedReason(b, reason);
fb.FeedbackState.addReply(b, reply);
b.finish(fb.FeedbackState.endFeedbackState(b));
expect(decodeFeedbackState(b.asUint8Array())).toEqual({
canSend: true,
blockedReason: '',
reply: { body: 'we are on it', repliedAtUnix: 1700000000 },
});
// State with no reply, blocked as pending.
const b2 = new Builder(64);
const reason2 = b2.createString('pending');
fb.FeedbackState.startFeedbackState(b2);
fb.FeedbackState.addCanSend(b2, false);
fb.FeedbackState.addBlockedReason(b2, reason2);
b2.finish(fb.FeedbackState.endFeedbackState(b2));
expect(decodeFeedbackState(b2.asUint8Array())).toEqual({
canSend: false,
blockedReason: 'pending',
reply: null,
});
// Unread badge flag.
const b3 = new Builder(16);
fb.FeedbackUnread.startFeedbackUnread(b3);
fb.FeedbackUnread.addReplyUnread(b3, true);
b3.finish(fb.FeedbackUnread.endFeedbackUnread(b3));
expect(decodeFeedbackUnread(b3.asUint8Array())).toBe(true);
});
it('decodes a temporary BlockStatus with reason', () => {
const b = new Builder(64);
const until = b.createString('2026-07-01T12:00:00Z');
+34
View File
@@ -12,6 +12,7 @@ import type {
BlockStatus,
ChatMessage,
EvalResult,
FeedbackState,
FriendCode,
GameList,
GameView,
@@ -437,6 +438,39 @@ export function decodeChatList(buf: Uint8Array): ChatMessage[] {
return out;
}
export function encodeFeedbackSubmit(
body: string,
attachment: Uint8Array | null,
attachmentName: string,
channel: string,
): Uint8Array {
const b = new Builder(256);
const bodyOff = b.createString(body);
const attOff = attachment && attachment.length > 0 ? fb.FeedbackSubmitRequest.createAttachmentVector(b, attachment) : 0;
const nameOff = b.createString(attachmentName);
const chOff = b.createString(channel);
fb.FeedbackSubmitRequest.startFeedbackSubmitRequest(b);
fb.FeedbackSubmitRequest.addBody(b, bodyOff);
if (attOff) fb.FeedbackSubmitRequest.addAttachment(b, attOff);
fb.FeedbackSubmitRequest.addAttachmentName(b, nameOff);
fb.FeedbackSubmitRequest.addChannel(b, chOff);
return finish(b, fb.FeedbackSubmitRequest.endFeedbackSubmitRequest(b));
}
export function decodeFeedbackState(buf: Uint8Array): FeedbackState {
const st = fb.FeedbackState.getRootAsFeedbackState(new ByteBuffer(buf));
const r = st.reply();
return {
canSend: st.canSend(),
blockedReason: s(st.blockedReason()),
reply: r ? { body: s(r.body()), repliedAtUnix: Number(r.repliedAtUnix()) } : null,
};
}
export function decodeFeedbackUnread(buf: Uint8Array): boolean {
return fb.FeedbackUnread.getRootAsFeedbackUnread(new ByteBuffer(buf)).replyUnread();
}
export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null {
const bb = new ByteBuffer(payload);
switch (kind) {
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { attachmentError, MAX_ATTACHMENT_BYTES } from './feedback';
describe('attachmentError', () => {
it('accepts allowed extensions within the size cap', () => {
expect(attachmentError('shot.png', 1000)).toBe('');
expect(attachmentError('Photo.JPG', 1000)).toBe(''); // case-insensitive
expect(attachmentError('report.pdf', 1000)).toBe('');
expect(attachmentError('my.notes.docx', 1000)).toBe(''); // dotted name
expect(attachmentError('logs.7z', MAX_ATTACHMENT_BYTES)).toBe('');
});
it('rejects disallowed or extensionless names', () => {
expect(attachmentError('evil.exe', 10)).toBe('type');
expect(attachmentError('x.svg', 10)).toBe('type'); // XSS vector, not allowed
expect(attachmentError('x.html', 10)).toBe('type');
expect(attachmentError('README', 10)).toBe('type');
expect(attachmentError('', 10)).toBe('type');
});
it('rejects too-large files', () => {
expect(attachmentError('shot.png', MAX_ATTACHMENT_BYTES + 1)).toBe('size');
});
});
+29
View File
@@ -0,0 +1,29 @@
// Feedback client-side limits and the attachment pre-upload gate. The gate is by
// file extension only (the UI does not list the allowed types and never uploads a
// rejected file); the server re-checks the same limits as the trust boundary.
/** MAX_BODY caps the feedback message length, in characters (matches the backend). */
export const MAX_BODY = 1024;
/** MAX_ATTACHMENT_BYTES caps a single attachment's size (matches the backend). */
export const MAX_ATTACHMENT_BYTES = 1_000_000;
// Allowed attachment extensions, mirrored from the backend allow-list.
const ALLOWED_EXT = new Set([
'png', 'jpg', 'jpeg', 'webp', 'gif', // images
'pdf', 'txt', 'log', 'doc', 'docx', 'rtf', 'zip', 'gz', '7z',
]);
/**
* attachmentError validates a picked file by name and size, returning 'type' for a
* disallowed (or missing) extension, 'size' for a too-large file, or '' when it is
* acceptable. The caller shows a single generic "cannot attach" message regardless
* of which non-empty reason it is, so the allowed types are never revealed.
*/
export function attachmentError(name: string, size: number): '' | 'type' | 'size' {
const dot = name.lastIndexOf('.');
const ext = dot >= 0 ? name.slice(dot + 1).toLowerCase() : '';
if (!ext || !ALLOWED_EXT.has(ext)) return 'type';
if (size > MAX_ATTACHMENT_BYTES) return 'size';
return '';
}
+6 -1
View File
@@ -22,8 +22,13 @@ if (isMock && typeof window !== 'undefined') {
};
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
// attaches a robot on a timer).
(window as unknown as { __mock?: { joinOpponent(): void; joinOpponentSilently(): void } }).__mock = {
(
window as unknown as {
__mock?: { joinOpponent(): void; joinOpponentSilently(): void; adminReply(): void };
}
).__mock = {
joinOpponent: () => (gateway as MockGateway).joinPendingOpponent(),
joinOpponentSilently: () => (gateway as MockGateway).joinPendingOpponentSilently(),
adminReply: () => (gateway as MockGateway).mockAdminReply(),
};
}
+11
View File
@@ -170,6 +170,17 @@ export const en = {
'about.description': 'A multiplatform Scrabble game.',
'about.version': 'Version {v}',
'feedback.title': 'Feedback',
'feedback.placeholder': 'Describe the problem or share your suggestion…',
'feedback.attach': 'Attach file',
'feedback.removeFile': 'Remove',
'feedback.send': 'Send',
'feedback.fileRejected': 'This file cant be attached.',
'feedback.sent': 'Your message has been sent.',
'feedback.waiting': 'We are reviewing your last message.',
'feedback.banned': 'Feedback submission is unavailable.',
'feedback.replyTitle': 'Reply to your last message',
'landing.tagline': 'Play Scrabble with friends or a smart robot — in your browser or on Telegram.',
'landing.playTelegram': 'Play in Telegram',
+11
View File
@@ -171,6 +171,17 @@ export const ru: Record<MessageKey, string> = {
'about.description': 'Мультиплатформенная игра в скрабл.',
'about.version': 'Версия {v}',
'feedback.title': 'Обратная связь',
'feedback.placeholder': 'Опишите проблему или поделитесь предложением…',
'feedback.attach': 'Прикрепить файл',
'feedback.removeFile': 'Удалить',
'feedback.send': 'Отправить',
'feedback.fileRejected': 'Этот файл нельзя прикрепить.',
'feedback.sent': 'Ваше сообщение отправлено',
'feedback.waiting': 'Ожидаем рассмотрения вашего последнего обращения',
'feedback.banned': 'Отправка обратной связи недоступна',
'feedback.replyTitle': 'Ответ на ваше последнее сообщение',
'landing.tagline': 'Играй в Скрэббл с друзьями или умным роботом — в браузере или в Telegram.',
'landing.playTelegram': 'Играть в Telegram',
+31
View File
@@ -16,6 +16,8 @@ import type {
BlockStatus,
ChatMessage,
EvalResult,
FeedbackReply,
FeedbackState,
FriendCode,
GameList,
GcgExport,
@@ -89,6 +91,11 @@ export class MockGateway implements GatewayClient {
private readonly profile: Profile = { ...PROFILE };
private readonly subs = new Set<(e: PushEvent) => void>();
private pendingMatch: string | null = null;
// Feedback slice: a submission marks a message pending (blocks resend); mockAdminReply
// clears it and raises the badge.
private feedbackPending = false;
private feedbackReply: FeedbackReply | null = null;
private feedbackReplyUnread = false;
// The most recently opened auto-match game still awaiting an opponent, for the e2e join hook.
private openGameId: string | null = null;
private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
@@ -409,6 +416,30 @@ 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;
}
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 — we are looking into it.'): void {
this.feedbackPending = false;
this.feedbackReply = { body, repliedAtUnix: Math.floor(Date.now() / 1000) };
this.feedbackReplyUnread = true;
this.emit({ kind: 'notify', sub: 'admin_reply' });
}
async nudge(gameId: string): Promise<ChatMessage> {
const g = this.game(gameId);
const msg: ChatMessage = {
+17
View File
@@ -105,6 +105,23 @@ export interface ChatMessage {
createdAtUnix: number;
}
/** FeedbackReply is the operator's answer shown on the feedback screen. */
export interface FeedbackReply {
body: string;
repliedAtUnix: number;
}
/**
* FeedbackState is the feedback screen state. blockedReason is "" (can send),
* "pending" (a previous message is still under review) or "banned"; reply is the
* operator's answer to show, or null.
*/
export interface FeedbackState {
canSend: boolean;
blockedReason: string;
reply: FeedbackReply | null;
}
export interface Profile {
userId: string;
displayName: string;
+3
View File
@@ -13,6 +13,7 @@ export type RouteName =
| 'settings'
| 'about'
| 'friends'
| 'feedback'
| 'stats'
| 'notfound';
@@ -53,6 +54,8 @@ export function parse(hash: string): Route {
return { name: 'about', params: {} };
case 'friends':
return { name: 'friends', params: {} };
case 'feedback':
return { name: 'feedback', params: {} };
case 'stats':
return { name: 'stats', params: {} };
default:
+9
View File
@@ -143,6 +143,15 @@ export function createTransport(baseUrl: string): GatewayClient {
async nudge(id) {
return codec.decodeChatMessage(await exec('chat.nudge', codec.encodeGameAction(id)));
},
async feedbackSubmit(body, attachment, attachmentName, channel) {
await exec('feedback.submit', codec.encodeFeedbackSubmit(body, attachment, attachmentName, channel));
},
async feedbackGet() {
return codec.decodeFeedbackState(await exec('feedback.get', codec.empty()));
},
async feedbackUnread() {
return codec.decodeFeedbackUnread(await exec('feedback.unread', codec.empty()));
},
async friendsList() {
return codec.decodeFriendList(await exec('friends.list', codec.empty()));