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
+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');