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
+54
View File
@@ -0,0 +1,54 @@
package feedback
import (
"path/filepath"
"strings"
)
// maxAttachmentBytes caps a single attachment's raw size. Chosen to fit, with the
// message text and the FlatBuffers framing, under the gateway's 1 MiB edge body
// cap, so the whole submit request passes without weakening that cap.
const maxAttachmentBytes = 1_000_000
// allowedExt is the attachment extension allow-list. It is mirrored on the UI as a
// pre-upload gate; the server re-checks here as the trust boundary (metadata only,
// the file content is never parsed). Images render inline in the console; the rest
// are download-only.
var allowedExt = map[string]bool{
"png": true, "jpg": true, "jpeg": true, "webp": true, "gif": true, // images
"pdf": true, "txt": true, "log": true, "doc": true, "docx": true,
"rtf": true, "zip": true, "gz": true, "7z": true,
}
// imageType maps an image extension to the content-type the console serves it with
// (loaded only via <img>, which never executes, so a renamed non-image is inert).
var imageType = map[string]string{
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
"webp": "image/webp", "gif": "image/gif",
}
// ext returns name's lower-cased extension without the leading dot.
func ext(name string) string {
return strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
}
// AllowedAttachment reports whether name's extension is on the allow-list.
func AllowedAttachment(name string) bool {
return allowedExt[ext(name)]
}
// IsImage reports whether name is an inline-previewable image by its extension.
func IsImage(name string) bool {
_, ok := imageType[ext(name)]
return ok
}
// ContentType returns the safe content-type the console serves the attachment
// with: the matching image type for an image, else application/octet-stream so a
// non-image is downloaded rather than rendered.
func ContentType(name string) string {
if t, ok := imageType[ext(name)]; ok {
return t
}
return "application/octet-stream"
}
@@ -0,0 +1,81 @@
package feedback
import "testing"
func TestAllowedAttachment(t *testing.T) {
tests := []struct {
name string
file string
want bool
}{
{"png image", "shot.png", true},
{"jpeg upper-case ext", "Photo.JPG", true},
{"pdf doc", "report.pdf", true},
{"archive 7z", "logs.7z", true},
{"doc with dotted name", "my.notes.docx", true},
{"disallowed exe", "evil.exe", false},
{"disallowed svg (xss vector)", "x.svg", false},
{"disallowed html", "x.html", false},
{"no extension", "README", false},
{"empty name", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := AllowedAttachment(tt.file); got != tt.want {
t.Errorf("AllowedAttachment(%q) = %v, want %v", tt.file, got, tt.want)
}
})
}
}
func TestIsImageAndContentType(t *testing.T) {
tests := []struct {
file string
isImage bool
ctype string
}{
{"a.png", true, "image/png"},
{"a.jpg", true, "image/jpeg"},
{"a.jpeg", true, "image/jpeg"},
{"a.webp", true, "image/webp"},
{"a.gif", true, "image/gif"},
{"a.pdf", false, "application/octet-stream"},
{"a.zip", false, "application/octet-stream"},
{"a.svg", false, "application/octet-stream"}, // even if it slipped past, never image/svg+xml
{"noext", false, "application/octet-stream"},
}
for _, tt := range tests {
t.Run(tt.file, func(t *testing.T) {
if got := IsImage(tt.file); got != tt.isImage {
t.Errorf("IsImage(%q) = %v, want %v", tt.file, got, tt.isImage)
}
if got := ContentType(tt.file); got != tt.ctype {
t.Errorf("ContentType(%q) = %q, want %q", tt.file, got, tt.ctype)
}
})
}
}
func TestNormalizeChannel(t *testing.T) {
tests := []struct {
in string
want string
}{
{"telegram", "telegram"},
{"ios", "ios"},
{"android", "android"},
{"web", "web"},
{" iOS ", "ios"}, // trimmed + lower-cased
{"TELEGRAM", "telegram"},
{"", "web"}, // unknown -> web
{"windows", "web"}, // unknown -> web
{"'; DROP", "web"}, // junk -> web
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
if got := normalizeChannel(tt.in); got != tt.want {
t.Errorf("normalizeChannel(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
+256
View File
@@ -0,0 +1,256 @@
package feedback
import (
"context"
"errors"
"net/netip"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/notify"
)
const (
// maxBodyRunes caps a feedback message (and an operator reply) length.
maxBodyRunes = 1024
// replyVisibleFor is how long an operator reply stays shown to the player after
// it is delivered (read).
replyVisibleFor = 7 * 24 * time.Hour
)
// Submit / reply validation errors. The transport layer maps them to stable result
// codes; the UI maps those to the messages shown on the feedback screen.
var (
ErrEmptyMessage = errors.New("feedback: empty message")
ErrMessageTooLong = errors.New("feedback: message too long")
ErrAttachmentTooLarge = errors.New("feedback: attachment too large")
ErrAttachmentType = errors.New("feedback: attachment type not allowed")
ErrGuestForbidden = errors.New("feedback: guests cannot submit feedback")
ErrBanned = errors.New("feedback: account is banned from feedback")
ErrPendingReview = errors.New("feedback: previous message still pending review")
)
// validChannels enumerates the submitting platforms a client may report; anything
// else is normalised to "web" (the channel is informational, never a gate).
var validChannels = map[string]bool{"telegram": true, "ios": true, "android": true, "web": true}
// Service is the feedback domain: the only writer of feedback_messages. It reads
// accounts for the guest/role gates and publishes the reply notification.
type Service struct {
store *Store
accounts *account.Store
pub notify.Publisher
now func() time.Time
}
// NewService constructs a Service. store owns feedback_messages; accounts supplies
// the guest flag and the feedback-ban role.
func NewService(store *Store, accounts *account.Store) *Service {
return &Service{
store: store,
accounts: accounts,
pub: notify.Nop{},
now: func() time.Time { return time.Now().UTC() },
}
}
// SetNotifier installs the live-event publisher used to push the "you have a reply"
// signal to the player. It must be called during startup wiring; the default is
// notify.Nop (no live events).
func (svc *Service) SetNotifier(p notify.Publisher) {
if p != nil {
svc.pub = p
}
}
// Submit stores a feedback message from accountID. It rejects guests, feedback-
// banned accounts and a sender who still has a message pending review, then
// validates the body (non-empty, within the rune limit) and the optional
// attachment (size and extension allow-list). senderIP is the gateway-forwarded
// client IP (validated); channel is the submitting platform.
func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, senderIP string) error {
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
return err
}
if acc.IsGuest {
return ErrGuestForbidden
}
banned, err := svc.accounts.HasRole(ctx, accountID, account.RoleFeedbackBanned)
if err != nil {
return err
}
if banned {
return ErrBanned
}
pending, err := svc.store.HasUnread(ctx, accountID)
if err != nil {
return err
}
if pending {
return ErrPendingReview
}
body = strings.TrimSpace(body)
if body == "" {
return ErrEmptyMessage
}
if utf8.RuneCountInString(body) > maxBodyRunes {
return ErrMessageTooLong
}
if len(attachment) > 0 {
if len(attachment) > maxAttachmentBytes {
return ErrAttachmentTooLarge
}
if !AllowedAttachment(attachmentName) {
return ErrAttachmentType
}
} else {
attachmentName = "" // a name without bytes carries no attachment
}
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, normalizeChannel(channel), parseIP(senderIP))
return err
}
// State is the player's feedback screen state. Reason is "" (can send), "pending"
// (a previous message is unreviewed) or "banned". Reply is the operator's answer to
// show, or nil. Fetching it delivers any pending replies (delivery counts as read),
// clearing the badge.
type State struct {
CanSend bool
BlockedReason string
Reply *Reply
}
// Reply is the operator's answer shown back to the player.
type Reply struct {
Body string
RepliedAt time.Time
}
// State computes the feedback screen state for accountID and marks any pending
// replies delivered.
func (svc *Service) State(ctx context.Context, accountID uuid.UUID) (State, error) {
banned, err := svc.accounts.HasRole(ctx, accountID, account.RoleFeedbackBanned)
if err != nil {
return State{}, err
}
pending, err := svc.store.HasUnread(ctx, accountID)
if err != nil {
return State{}, err
}
st := State{CanSend: !banned && !pending}
switch {
case banned:
st.BlockedReason = "banned"
case pending:
st.BlockedReason = "pending"
}
vr, ok, err := svc.store.LatestVisibleReply(ctx, accountID, svc.now().Add(-replyVisibleFor))
if err != nil {
return State{}, err
}
if ok {
st.Reply = &Reply{Body: vr.Body, RepliedAt: vr.RepliedAt}
}
// Opening the screen delivers every pending reply; mark them read to clear the
// badge (only the latest is shown, but all are now delivered).
if err := svc.store.MarkRepliesDelivered(ctx, accountID, svc.now()); err != nil {
return State{}, err
}
return st, nil
}
// ReplyUnread reports whether the account has an operator reply not yet delivered —
// the lobby/Info badge condition. It has no side effect (the lobby may poll it).
func (svc *Service) ReplyUnread(ctx context.Context, accountID uuid.UUID) (bool, error) {
return svc.store.HasUnreadReply(ctx, accountID)
}
// AdminList returns the filtered console feedback list, paginated.
func (svc *Service) AdminList(ctx context.Context, f AdminFilter, limit, offset int) ([]AdminRow, error) {
return svc.store.AdminList(ctx, f, limit, offset)
}
// AdminCount counts the filtered console feedback list.
func (svc *Service) AdminCount(ctx context.Context, f AdminFilter) (int, error) {
return svc.store.AdminCount(ctx, f)
}
// AdminGet loads one message for the console detail view, or ErrNotFound.
func (svc *Service) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error) {
return svc.store.AdminGet(ctx, id)
}
// CountUnread counts the active (unread, not archived) feedback queue for the
// dashboard.
func (svc *Service) CountUnread(ctx context.Context) (int, error) {
return svc.store.CountUnread(ctx)
}
// Attachment returns a message's file name and bytes, reporting false when absent.
func (svc *Service) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) {
return svc.store.Attachment(ctx, id)
}
// MarkRead marks a message dealt-with (the manual "read" action).
func (svc *Service) MarkRead(ctx context.Context, id uuid.UUID) error {
return svc.store.MarkRead(ctx, id, svc.now())
}
// Reply sets the operator reply on a message (marking it read), then pushes the
// "you have a reply" notification to the player.
func (svc *Service) Reply(ctx context.Context, id uuid.UUID, body string) error {
body = strings.TrimSpace(body)
if body == "" {
return ErrEmptyMessage
}
if utf8.RuneCountInString(body) > maxBodyRunes {
return ErrMessageTooLong
}
accountID, err := svc.store.Reply(ctx, id, body, svc.now())
if err != nil {
return err
}
svc.pub.Publish(notify.Notification(accountID, notify.NotifyAdminReply))
return nil
}
// Archive files a handled message away (marking it read).
func (svc *Service) Archive(ctx context.Context, id uuid.UUID) error {
return svc.store.Archive(ctx, id, svc.now())
}
// Delete physically removes a message and its attachment.
func (svc *Service) Delete(ctx context.Context, id uuid.UUID) error {
return svc.store.Delete(ctx, id)
}
// DeleteAllByAccount physically removes every message of an account.
func (svc *Service) DeleteAllByAccount(ctx context.Context, accountID uuid.UUID) error {
return svc.store.DeleteAllByAccount(ctx, accountID)
}
// parseIP returns a validated canonical IP string, or nil when raw is empty or not
// a valid address.
func parseIP(raw string) *string {
addr, err := netip.ParseAddr(strings.TrimSpace(raw))
if err != nil {
return nil
}
canon := addr.String()
return &canon
}
// normalizeChannel lower-cases and validates the client-reported channel, falling
// back to "web" for anything unrecognised.
func normalizeChannel(c string) string {
c = strings.ToLower(strings.TrimSpace(c))
if validChannels[c] {
return c
}
return "web"
}
+370
View File
@@ -0,0 +1,370 @@
// Package feedback owns user feedback: the flat list of messages a registered
// player sends to the operators (each with an optional single attachment), the
// anti-spam "one pending message at a time" gate, and the operator's inline reply
// delivered back to the player's app. It is modelled on the admin chat-moderation
// surface (internal/social/adminchat.go) and uses raw SQL throughout for the
// attachment bytea, the COALESCE-based "set once" stamps, and the reply-visibility
// window.
package feedback
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
)
// ErrNotFound is returned when no feedback message matches the lookup.
var ErrNotFound = errors.New("feedback: not found")
// Store is the Postgres-backed query surface for feedback_messages.
type Store struct {
db *sql.DB
}
// NewStore constructs a Store wrapping db.
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
// Insert stores one feedback message from accountID and returns its id. attachment
// is the raw file bytes (nil for none); attachmentName, ip and a non-default
// channel are stored as given. created_at defaults to now() in the database.
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel string, ip *string) (uuid.UUID, error) {
id, err := uuid.NewV7()
if err != nil {
return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err)
}
var att []byte // a nil []byte binds as bytea NULL
if len(attachment) > 0 {
att = attachment
}
var name *string
if attachmentName != "" {
name = &attachmentName
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO backend.feedback_messages
(message_id, account_id, body, attachment, attachment_name, channel, sender_ip)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
id, accountID, body, att, name, channel, ip); err != nil {
return uuid.Nil, fmt.Errorf("feedback: insert: %w", err)
}
return id, nil
}
// HasUnread reports whether the account has any message the operator has not yet
// dealt with (read_at IS NULL) — the anti-spam gate's condition.
func (s *Store) HasUnread(ctx context.Context, accountID uuid.UUID) (bool, error) {
var ok bool
if err := s.db.QueryRowContext(ctx,
`SELECT EXISTS (SELECT 1 FROM backend.feedback_messages WHERE account_id = $1 AND read_at IS NULL)`,
accountID).Scan(&ok); err != nil {
return false, fmt.Errorf("feedback: has-unread %s: %w", accountID, err)
}
return ok, nil
}
// HasUnreadReply reports whether the account has an operator reply not yet
// delivered to its app (reply_read_at IS NULL) — the badge's condition.
func (s *Store) HasUnreadReply(ctx context.Context, accountID uuid.UUID) (bool, error) {
var ok bool
if err := s.db.QueryRowContext(ctx,
`SELECT EXISTS (SELECT 1 FROM backend.feedback_messages
WHERE account_id = $1 AND reply_body IS NOT NULL AND reply_read_at IS NULL)`,
accountID).Scan(&ok); err != nil {
return false, fmt.Errorf("feedback: has-unread-reply %s: %w", accountID, err)
}
return ok, nil
}
// VisibleReply is the operator reply shown back to the player: the reply on their
// most recent replied message still inside the visibility window.
type VisibleReply struct {
MessageID uuid.UUID
Body string
RepliedAt time.Time
ReplyReadAt sql.NullTime
}
// LatestVisibleReply returns the account's most recent message that carries a reply
// still visible to the player — not yet delivered, or delivered after cutoff (one
// week ago). Reports false when there is none.
func (s *Store) LatestVisibleReply(ctx context.Context, accountID uuid.UUID, cutoff time.Time) (VisibleReply, bool, error) {
var vr VisibleReply
var repliedAt sql.NullTime
err := s.db.QueryRowContext(ctx,
`SELECT message_id, COALESCE(reply_body, ''), replied_at, reply_read_at
FROM backend.feedback_messages
WHERE account_id = $1 AND reply_body IS NOT NULL
AND (reply_read_at IS NULL OR reply_read_at > $2)
ORDER BY created_at DESC LIMIT 1`,
accountID, cutoff).Scan(&vr.MessageID, &vr.Body, &repliedAt, &vr.ReplyReadAt)
if errors.Is(err, sql.ErrNoRows) {
return VisibleReply{}, false, nil
}
if err != nil {
return VisibleReply{}, false, fmt.Errorf("feedback: latest visible reply %s: %w", accountID, err)
}
if repliedAt.Valid {
vr.RepliedAt = repliedAt.Time
}
return vr, true, nil
}
// MarkRepliesDelivered stamps reply_read_at on every not-yet-delivered reply of the
// account (delivery counts as read), clearing the badge when the player opens the
// feedback screen.
func (s *Store) MarkRepliesDelivered(ctx context.Context, accountID uuid.UUID, at time.Time) error {
if _, err := s.db.ExecContext(ctx,
`UPDATE backend.feedback_messages SET reply_read_at = $2
WHERE account_id = $1 AND reply_body IS NOT NULL AND reply_read_at IS NULL`,
accountID, at); err != nil {
return fmt.Errorf("feedback: mark replies delivered %s: %w", accountID, err)
}
return nil
}
// MarkRead stamps read_at the first time, marking the message dealt-with without any
// other action; a re-mark keeps the original time.
func (s *Store) MarkRead(ctx context.Context, id uuid.UUID, at time.Time) error {
if _, err := s.db.ExecContext(ctx,
`UPDATE backend.feedback_messages SET read_at = COALESCE(read_at, $2) WHERE message_id = $1`,
id, at); err != nil {
return fmt.Errorf("feedback: mark read %s: %w", id, err)
}
return nil
}
// Reply sets (or replaces) the operator reply on a message, marks it read, and
// resets its delivery so the player is notified again. Returns the message's
// account_id for the live notification, or ErrNotFound.
func (s *Store) Reply(ctx context.Context, id uuid.UUID, body string, at time.Time) (uuid.UUID, error) {
var accountID uuid.UUID
err := s.db.QueryRowContext(ctx,
`UPDATE backend.feedback_messages
SET reply_body = $2, replied_at = $3, reply_read_at = NULL, read_at = COALESCE(read_at, $3)
WHERE message_id = $1
RETURNING account_id`,
id, body, at).Scan(&accountID)
if errors.Is(err, sql.ErrNoRows) {
return uuid.Nil, ErrNotFound
}
if err != nil {
return uuid.Nil, fmt.Errorf("feedback: reply %s: %w", id, err)
}
return accountID, nil
}
// Archive files a handled message away and marks it read.
func (s *Store) Archive(ctx context.Context, id uuid.UUID, at time.Time) error {
if _, err := s.db.ExecContext(ctx,
`UPDATE backend.feedback_messages
SET archived_at = COALESCE(archived_at, $2), read_at = COALESCE(read_at, $2)
WHERE message_id = $1`,
id, at); err != nil {
return fmt.Errorf("feedback: archive %s: %w", id, err)
}
return nil
}
// Delete physically removes a message (with its attachment).
func (s *Store) Delete(ctx context.Context, id uuid.UUID) error {
if _, err := s.db.ExecContext(ctx,
`DELETE FROM backend.feedback_messages WHERE message_id = $1`, id); err != nil {
return fmt.Errorf("feedback: delete %s: %w", id, err)
}
return nil
}
// DeleteAllByAccount physically removes every message of an account.
func (s *Store) DeleteAllByAccount(ctx context.Context, accountID uuid.UUID) error {
if _, err := s.db.ExecContext(ctx,
`DELETE FROM backend.feedback_messages WHERE account_id = $1`, accountID); err != nil {
return fmt.Errorf("feedback: delete all %s: %w", accountID, err)
}
return nil
}
// Attachment returns a message's stored file name and bytes, reporting false when
// the message has no attachment or does not exist.
func (s *Store) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) {
var name string
var data []byte
err := s.db.QueryRowContext(ctx,
`SELECT COALESCE(attachment_name, ''), attachment FROM backend.feedback_messages WHERE message_id = $1`,
id).Scan(&name, &data)
if errors.Is(err, sql.ErrNoRows) {
return "", nil, false, nil
}
if err != nil {
return "", nil, false, fmt.Errorf("feedback: attachment %s: %w", id, err)
}
if data == nil {
return "", nil, false, nil
}
return name, data, true, nil
}
// AdminMessage is one feedback message in the operator console detail view: the
// message with its sender's resolved display name and source. The attachment bytes
// are not loaded here (served separately); only their presence and name are.
type AdminMessage struct {
ID uuid.UUID
AccountID uuid.UUID
SenderName string
Source string
Body string
Channel string
SenderIP string
HasAttachment bool
AttachmentName string
Read bool
Archived bool
Replied bool
ReplyBody string
RepliedAt time.Time
CreatedAt time.Time
}
// AdminRow is one feedback message in the console list (a lighter projection).
type AdminRow struct {
ID uuid.UUID
AccountID uuid.UUID
SenderName string
Source string
Channel string
HasAttachment bool
Read bool
Replied bool
Archived bool
CreatedAt time.Time
}
// AdminFilter narrows the console feedback list. Status is one of "unread"
// (default), "read" or "archived". NameMask/ExtMask are glob masks
// (account.LikePattern) on the sender's display name / any identity's external id;
// AccountID, when set, restricts to one account (the per-user link from /users).
type AdminFilter struct {
Status string
NameMask string
ExtMask string
AccountID uuid.UUID
}
// feedbackSource projects a sender's source: guest, robot, or its oldest identity
// kind ("—" when it has none). Mirrors social.adminMessageSource.
const feedbackSource = `CASE
WHEN a.is_guest THEN 'guest'
WHEN EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'robot') THEN 'robot'
ELSE COALESCE((SELECT i2.kind FROM backend.identities i2 WHERE i2.account_id = a.account_id ORDER BY i2.created_at ASC LIMIT 1), '—')
END`
// adminWhere builds the shared WHERE clause and its positional args (from $1).
func adminWhere(f AdminFilter) (string, []any) {
var where string
switch f.Status {
case "archived":
where = `m.archived_at IS NOT NULL`
case "read":
where = `m.read_at IS NOT NULL AND m.archived_at IS NULL`
default: // unread
where = `m.read_at IS NULL AND m.archived_at IS NULL`
}
var args []any
if f.AccountID != uuid.Nil {
args = append(args, f.AccountID)
where += fmt.Sprintf(` AND m.account_id = $%d`, len(args))
}
if name := account.LikePattern(f.NameMask); name != "" {
args = append(args, name)
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
}
if ext := account.LikePattern(f.ExtMask); ext != "" {
args = append(args, ext)
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities ie WHERE ie.account_id = a.account_id AND ie.external_id ILIKE $%d ESCAPE '\')`, len(args))
}
return where, args
}
// AdminList returns the filtered console feedback list, newest first, paginated.
func (s *Store) AdminList(ctx context.Context, f AdminFilter, limit, offset int) ([]AdminRow, error) {
where, args := adminWhere(f)
q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.channel,
(m.attachment IS NOT NULL), (m.read_at IS NOT NULL), (m.reply_body IS NOT NULL), (m.archived_at IS NOT NULL), m.created_at
FROM backend.feedback_messages m
JOIN backend.accounts a ON a.account_id = m.account_id
WHERE ` + where +
fmt.Sprintf(` ORDER BY m.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
args = append(args, limit, offset)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("feedback: admin list: %w", err)
}
defer rows.Close()
var out []AdminRow
for rows.Next() {
var r AdminRow
if err := rows.Scan(&r.ID, &r.AccountID, &r.SenderName, &r.Source, &r.Channel,
&r.HasAttachment, &r.Read, &r.Replied, &r.Archived, &r.CreatedAt); err != nil {
return nil, fmt.Errorf("feedback: scan admin row: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
// AdminCount counts the filtered console feedback list, for the pager.
func (s *Store) AdminCount(ctx context.Context, f AdminFilter) (int, error) {
where, args := adminWhere(f)
var n int
q := `SELECT COUNT(*) FROM backend.feedback_messages m JOIN backend.accounts a ON a.account_id = m.account_id WHERE ` + where
if err := s.db.QueryRowContext(ctx, q, args...).Scan(&n); err != nil {
return 0, fmt.Errorf("feedback: admin count: %w", err)
}
return n, nil
}
// AdminGet loads one message for the console detail view, or ErrNotFound.
func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error) {
var m AdminMessage
var repliedAt sql.NullTime
q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel,
COALESCE(m.sender_ip, ''), (m.attachment IS NOT NULL), COALESCE(m.attachment_name, ''),
(m.read_at IS NOT NULL), (m.archived_at IS NOT NULL), (m.reply_body IS NOT NULL),
COALESCE(m.reply_body, ''), m.replied_at, m.created_at
FROM backend.feedback_messages m
JOIN backend.accounts a ON a.account_id = m.account_id
WHERE m.message_id = $1`
err := s.db.QueryRowContext(ctx, q, id).Scan(
&m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel,
&m.SenderIP, &m.HasAttachment, &m.AttachmentName,
&m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return AdminMessage{}, ErrNotFound
}
if err != nil {
return AdminMessage{}, fmt.Errorf("feedback: admin get %s: %w", id, err)
}
if repliedAt.Valid {
m.RepliedAt = repliedAt.Time
}
return m, nil
}
// CountUnread counts the active (unread, not archived) feedback queue, for the
// console dashboard.
func (s *Store) CountUnread(ctx context.Context) (int, error) {
var n int
if err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM backend.feedback_messages WHERE read_at IS NULL AND archived_at IS NULL`,
).Scan(&n); err != nil {
return 0, fmt.Errorf("feedback: count unread: %w", err)
}
return n, nil
}