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