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
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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user