Improvements to Feedback & Reports

* Add an AboutUserID field to feedbacks, so when the report is about a
  picture that is later deleted, the feedback can still link to the
  original owner's account instead of showing an error.
* Add filters to the User Notes page so the admin can see:
  * All feedback From or About the user or their content (default)
  * Feedback created by the user
  * Feedback about the user or their content
  * Fuzzy search for any feedback containing the user's name.
* On chat room reports: make the @channel ID a clickable user profile
  link for convenience.
This commit is contained in:
Noah Petherbridge 2024-10-17 19:21:18 -07:00
parent 704124157d
commit e146c09850
6 changed files with 122 additions and 25 deletions

View File

@ -18,7 +18,10 @@ func UserNotes() http.HandlerFunc {
tmpl := templates.Must("account/user_notes.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Parse the username out of the URL parameters.
var username = r.PathValue("username")
var (
username = r.PathValue("username")
show = r.FormValue("show") // admin feedback filter
)
// Find this user.
user, err := models.FindUser(username)
@ -108,7 +111,7 @@ func UserNotes() http.HandlerFunc {
}
// Paginate feedback & reports.
if fb, err := models.PaginateFeedbackAboutUser(user, fbPager); err != nil {
if fb, err := models.PaginateFeedbackAboutUser(user, show, fbPager); err != nil {
session.FlashError(w, r, "Paginating feedback on this user: %s", err)
} else {
feedback = fb
@ -141,6 +144,7 @@ func UserNotes() http.HandlerFunc {
"MyNote": myNote,
// Admin concerns.
"Show": show,
"Feedback": feedback,
"FeedbackPager": fbPager,
"OtherNotes": otherNotes,

View File

@ -69,6 +69,15 @@ func Feedback() http.HandlerFunc {
// Are we visiting a linked resource (via TableID)?
if fb != nil && fb.TableID > 0 && visit {
// New (Oct 17 '24): feedbacks may carry an AboutUserID, e.g. for photos in case the reported
// photo is removed then the associated owner of the photo is still carried in the report.
var aboutUser *models.User
if fb.AboutUserID > 0 {
if user, err := models.GetUser(fb.AboutUserID); err == nil {
aboutUser = user
}
}
switch fb.TableName {
case "users":
user, err := models.GetUser(fb.TableID)
@ -81,15 +90,29 @@ func Feedback() http.HandlerFunc {
case "photos":
pic, err := models.GetPhoto(fb.TableID)
if err != nil {
// If there was an About User, visit their profile page instead.
if aboutUser != nil {
session.FlashError(w, r, "The photo #%d was deleted, visiting the owner's profile page instead.", fb.TableID)
templates.Redirect(w, "/u/"+aboutUser.Username)
return
}
session.FlashError(w, r, "Couldn't get photo %d: %s", fb.TableID, err)
} else {
// Going to the user's profile page?
if profile {
user, err := models.GetUser(pic.UserID)
if err != nil {
session.FlashError(w, r, "Couldn't visit user %d: %s", fb.TableID, err)
} else {
templates.Redirect(w, "/u/"+user.Username)
// Going forward: the aboutUser will be populated, this is for legacy reports.
if aboutUser == nil {
if user, err := models.GetUser(pic.UserID); err == nil {
aboutUser = user
} else {
session.FlashError(w, r, "Couldn't visit user %d: %s", fb.TableID, err)
}
}
if aboutUser != nil {
templates.Redirect(w, "/u/"+aboutUser.Username)
return
}
}

View File

@ -79,6 +79,9 @@ func Report() http.HandlerFunc {
log.Debug("Got chat report: %+v", report)
// Make a clickable profile link for the channel ID (other user).
otherUsername := strings.TrimPrefix(report.Channel, "@")
// Create an admin Feedback model.
fb := &models.Feedback{
Intent: "report",
@ -87,7 +90,7 @@ func Report() http.HandlerFunc {
"A message was reported on the chat room!\n\n"+
"* From username: [%s](/u/%s)\n"+
"* About username: [%s](/u/%s)\n"+
"* Channel: **%s**\n"+
"* Channel: [**%s**](/u/%s)\n"+
"* Timestamp: %s\n"+
"* Classification: %s\n"+
"* User comment: %s\n\n"+
@ -95,7 +98,7 @@ func Report() http.HandlerFunc {
"The reported message on chat was:\n\n%s",
report.FromUsername, report.FromUsername,
report.AboutUsername, report.AboutUsername,
report.Channel,
report.Channel, otherUsername,
report.Timestamp,
report.Reason,
report.Comment,
@ -116,6 +119,7 @@ func Report() http.HandlerFunc {
if err == nil {
fb.TableName = "users"
fb.TableID = targetUser.ID
fb.AboutUserID = targetUser.ID
} else {
log.Error("BareRTC Chat Feedback: couldn't find user ID for AboutUsername=%s: %s", report.AboutUsername, err)
}

View File

@ -32,8 +32,9 @@ func Contact() http.HandlerFunc {
trap2 = r.FormValue("comment") != ""
tableID int
tableName string
tableLabel string // front-end user feedback about selected report item
messageRequired = true // unless we have a table ID to work with
tableLabel string // front-end user feedback about selected report item
aboutUser *models.User // associated user (e.g. owner of reported photo)
messageRequired = true // unless we have a table ID to work with
success = "Thank you for your feedback! Your message has been delivered to the website administrators."
)
@ -56,6 +57,7 @@ func Contact() http.HandlerFunc {
tableName = "users"
if user, err := models.GetUser(uint64(tableID)); err == nil {
tableLabel = fmt.Sprintf(`User account "%s"`, user.Username)
aboutUser = user
} else {
log.Error("/contact: couldn't produce table label for user %d: %s", tableID, err)
}
@ -66,6 +68,7 @@ func Contact() http.HandlerFunc {
if pic, err := models.GetPhoto(uint64(tableID)); err == nil {
if user, err := models.GetUser(pic.UserID); err == nil {
tableLabel = fmt.Sprintf(`A profile photo of user account "%s"`, user.Username)
aboutUser = user
} else {
log.Error("/contact: couldn't produce table label for user %d: %s", tableID, err)
}
@ -81,6 +84,7 @@ func Contact() http.HandlerFunc {
var username = "[unavailable]"
if sender, err := models.GetUser(msg.SourceUserID); err == nil {
username = sender.Username
aboutUser = sender
}
footer = fmt.Sprintf(`
@ -100,6 +104,7 @@ From: <a href="/u/%s">@%s</a>
// Find this comment.
if comment, err := models.GetComment(uint64(tableID)); err == nil {
tableLabel = fmt.Sprintf(`A comment written by "%s"`, comment.User.Username)
aboutUser = &comment.User
} else {
log.Error("/contact: couldn't produce table label for comment %d: %s", tableID, err)
}
@ -166,6 +171,10 @@ From: <a href="/u/%s">@%s</a>
TableID: uint64(tableID),
}
if aboutUser != nil {
fb.AboutUserID = aboutUser.ID
}
if currentUser != nil && currentUser.ID > 0 {
fb.UserID = currentUser.ID
} else if replyTo != "" {

View File

@ -12,6 +12,7 @@ import (
type Feedback struct {
ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"index"` // if logged-in user posted this
AboutUserID uint64 // associated 'about' user (e.g., owner of a reported photo)
Acknowledged bool `gorm:"index"` // admin dashboard "read" status
Intent string
Subject string
@ -99,20 +100,49 @@ func PaginateFeedback(acknowledged bool, intent, subject string, search *Search,
// It returns reports where table_name=users and their user ID, or where table_name=photos and about any
// of their current photo IDs. Additionally, it will look for chat room reports which were about their
// username.
func PaginateFeedbackAboutUser(user *User, pager *Pagination) ([]*Feedback, error) {
//
// The 'show' parameter applies some basic filter choices:
//
// - Blank string (default) = all reports From or About this user
// - "about" = all reports About this user (by table_name=users table_id=userID, or table_name=photos
// for any of their existing photo IDs)
// - "from" = all reports From this user (where reporting user_id is the user's ID)
// - "fuzzy" = fuzzy full text search on all reports that contain the user's username.
func PaginateFeedbackAboutUser(user *User, show string, pager *Pagination) ([]*Feedback, error) {
var (
fb = []*Feedback{}
photoIDs, _ = user.AllPhotoIDs()
wheres = []string{}
placeholders = []interface{}{}
like = "%" + user.Username + "%"
)
wheres = append(wheres, `
(table_name = 'users' AND table_id = ?) OR
(table_name = 'photos' AND table_id IN ?) OR
message LIKE ?
`)
placeholders = append(placeholders, user.ID, photoIDs, user.Username)
// How to apply the search filters?
switch show {
case "about":
wheres = append(wheres, `
about_user_id = ? OR
(table_name = 'users' AND table_id = ?) OR
(table_name = 'photos' AND table_id IN ?)
`)
placeholders = append(placeholders, user.ID, user.ID, photoIDs)
case "from":
wheres = append(wheres, "user_id = ?")
placeholders = append(placeholders, user.ID)
case "fuzzy":
wheres = append(wheres, "message LIKE ?")
placeholders = append(placeholders, like)
default:
// Default=everything.
wheres = append(wheres, `
user_id = ? OR
about_user_id = ? OR
(table_name = 'users' AND table_id = ?) OR
(table_name = 'photos' AND table_id IN ?) OR
message LIKE ?
`)
placeholders = append(placeholders, user.ID, user.ID, user.ID, photoIDs, like)
}
query := DB.Where(
strings.Join(wheres, " AND "),

View File

@ -187,11 +187,38 @@
<div class="card-content">
{{if .FeedbackPager.Total}}
<span>
<div class="block">
Found <strong>{{.FeedbackPager.Total}}</strong> report{{Pluralize64 .FeedbackPager.Total}} about this user (page {{.FeedbackPager.Page}} of {{.FeedbackPager.Pages}}).
</span>
</div>
{{end}}
<!-- Simple filters -->
<form action="{{.Request.URL.Path}}" method="GET">
<div class="columns">
<div class="column is-narrow">
<label class="label">Show:</label>
</div>
<div class="column">
<div class="select is-fullwidth">
<select name="show">
<optgroup label="By user account">
<option value="">All reports from or about this user</option>
<option value="about"{{if eq .Show "about"}} selected{{end}}>Reports about this user or their photos</option>
<option value="from"{{if eq .Show "from"}} selected{{end}}>Reports from this user about others</option>
</optgroup>
<optgroup label="Fuzzy search">
<option value="fuzzy"{{if eq .Show "fuzzy"}} selected{{end}}>All reports that contain this user's name (@{{.User.Username}})</option>
</optgroup>
</select>
</div>
</div>
<div class="column is-narrow">
<a href="{{.Request.URL.Path}}" class="button">Reset</a>
<button type="submit" class="button is-primary">Apply</button>
</div>
</div>
</form>
<div class="my-4">
{{SimplePager .FeedbackPager}}
</div>
@ -224,29 +251,29 @@
{{if ne .TableID 0}} - {{.TableID}}{{end}}
{{else if eq .TableName "users"}}
Users: {{.TableID}}
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true"
<a href="/admin/feedback?id={{.ID}}&visit=true"
class="fa fa-external-link ml-2"
target="_blank"
title="Visit the reported user's profile"></a>
{{else if eq .TableName "photos"}}
Photos: {{.TableID}}
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true"
<a href="/admin/feedback?id={{.ID}}&visit=true"
class="fa fa-external-link mx-2"
target="_blank"
title="Visit the reported photo"></a>
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true&profile=true"
<a href="/admin/feedback?id={{.ID}}&visit=true&profile=true"
class="fa fa-user"
target="_blank"
title="Visit the user profile who owns the reported photo"></a>
{{else if eq .TableName "messages"}}
Messages: {{.TableID}}
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true"
<a href="/admin/feedback?id={{.ID}}&visit=true"
class="fa fa-ghost ml-2"
target="_blank"
title="Impersonate the reporter and view this message thread"></a>
{{else}}
{{.TableName}}: {{.TableID}}
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true" class="fa fa-external-link ml-2" target="_blank"></a>
<a href="/admin/feedback?id={{.ID}}&visit=true" class="fa fa-external-link ml-2" target="_blank"></a>
{{end}}
</td>
</tr>