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