// 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. lang (interface language), version (client app build) and // browserTZ (the client's detected "±HH:MM" UTC offset) are snapshots taken now, so the operator // later sees the state at submit time. created_at defaults to now() in the database. func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang, version, browserTZ 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 } if _, err := s.db.ExecContext(ctx, `INSERT INTO backend.feedback_messages (message_id, account_id, body, attachment, attachment_name, channel, lang, app_version, browser_tz, sender_ip) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), nullStr(version), nullStr(browserTZ), ip); err != nil { return uuid.Nil, fmt.Errorf("feedback: insert: %w", err) } return id, nil } // nullStr maps an empty string to a NULL text bind, else the value. func nullStr(s string) *string { if s == "" { return nil } return &s } // 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 { Body string RepliedAt time.Time } // LatestVisibleReply returns the reply on the account's **most recent** message, if // that message carries one still visible to the player — not yet delivered, or // delivered after cutoff (one week ago). Binding it to the single latest message // means that the moment the player sends a newer message the previous reply stops // showing (the new message has no reply yet). 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 COALESCE(reply_body, ''), replied_at FROM ( SELECT reply_body, replied_at, reply_read_at FROM backend.feedback_messages WHERE account_id = $1 ORDER BY created_at DESC LIMIT 1 ) latest WHERE reply_body IS NOT NULL AND (reply_read_at IS NULL OR reply_read_at > $2)`, accountID, cutoff).Scan(&vr.Body, &repliedAt) 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 // Lang is the sender's interface language, snapshotted at submit time. Lang string // Version is the client app build the report was sent from, snapshotted at submit time. Version string // BrowserTZ is the client's detected "±HH:MM" UTC offset at submit time, snapshotted so the // filed time can be shown in the sender's browser-local zone even before they save a profile. BrowserTZ string // TimeZone is the sender account's stored zone ("±HH:MM" offset, IANA name, or ""), for // rendering CreatedAt in the sender's own configured time alongside UTC. TimeZone 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.lang, ''), COALESCE(m.app_version, ''), COALESCE(m.browser_tz, ''), a.time_zone, 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.Lang, &m.Version, &m.BrowserTZ, &m.TimeZone, &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 }