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") tmpl := templates.Must("account/user_notes.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Parse the username out of the URL parameters. // 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. // Find this user.
user, err := models.FindUser(username) user, err := models.FindUser(username)
@ -108,7 +111,7 @@ func UserNotes() http.HandlerFunc {
} }
// Paginate feedback & reports. // 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) session.FlashError(w, r, "Paginating feedback on this user: %s", err)
} else { } else {
feedback = fb feedback = fb
@ -141,6 +144,7 @@ func UserNotes() http.HandlerFunc {
"MyNote": myNote, "MyNote": myNote,
// Admin concerns. // Admin concerns.
"Show": show,
"Feedback": feedback, "Feedback": feedback,
"FeedbackPager": fbPager, "FeedbackPager": fbPager,
"OtherNotes": otherNotes, "OtherNotes": otherNotes,

View File

@ -69,6 +69,15 @@ func Feedback() http.HandlerFunc {
// Are we visiting a linked resource (via TableID)? // Are we visiting a linked resource (via TableID)?
if fb != nil && fb.TableID > 0 && visit { 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 { switch fb.TableName {
case "users": case "users":
user, err := models.GetUser(fb.TableID) user, err := models.GetUser(fb.TableID)
@ -81,15 +90,29 @@ func Feedback() http.HandlerFunc {
case "photos": case "photos":
pic, err := models.GetPhoto(fb.TableID) pic, err := models.GetPhoto(fb.TableID)
if err != nil { 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) session.FlashError(w, r, "Couldn't get photo %d: %s", fb.TableID, err)
} else { } else {
// Going to the user's profile page? // Going to the user's profile page?
if profile { if profile {
user, err := models.GetUser(pic.UserID)
if err != nil { // Going forward: the aboutUser will be populated, this is for legacy reports.
session.FlashError(w, r, "Couldn't visit user %d: %s", fb.TableID, err) if aboutUser == nil {
} else { if user, err := models.GetUser(pic.UserID); err == nil {
templates.Redirect(w, "/u/"+user.Username) 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 return
} }
} }

View File

@ -79,6 +79,9 @@ func Report() http.HandlerFunc {
log.Debug("Got chat report: %+v", report) 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. // Create an admin Feedback model.
fb := &models.Feedback{ fb := &models.Feedback{
Intent: "report", Intent: "report",
@ -87,7 +90,7 @@ func Report() http.HandlerFunc {
"A message was reported on the chat room!\n\n"+ "A message was reported on the chat room!\n\n"+
"* From username: [%s](/u/%s)\n"+ "* From username: [%s](/u/%s)\n"+
"* About username: [%s](/u/%s)\n"+ "* About username: [%s](/u/%s)\n"+
"* Channel: **%s**\n"+ "* Channel: [**%s**](/u/%s)\n"+
"* Timestamp: %s\n"+ "* Timestamp: %s\n"+
"* Classification: %s\n"+ "* Classification: %s\n"+
"* User comment: %s\n\n"+ "* User comment: %s\n\n"+
@ -95,7 +98,7 @@ func Report() http.HandlerFunc {
"The reported message on chat was:\n\n%s", "The reported message on chat was:\n\n%s",
report.FromUsername, report.FromUsername, report.FromUsername, report.FromUsername,
report.AboutUsername, report.AboutUsername, report.AboutUsername, report.AboutUsername,
report.Channel, report.Channel, otherUsername,
report.Timestamp, report.Timestamp,
report.Reason, report.Reason,
report.Comment, report.Comment,
@ -116,6 +119,7 @@ func Report() http.HandlerFunc {
if err == nil { if err == nil {
fb.TableName = "users" fb.TableName = "users"
fb.TableID = targetUser.ID fb.TableID = targetUser.ID
fb.AboutUserID = targetUser.ID
} else { } else {
log.Error("BareRTC Chat Feedback: couldn't find user ID for AboutUsername=%s: %s", report.AboutUsername, err) 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") != "" trap2 = r.FormValue("comment") != ""
tableID int tableID int
tableName string tableName string
tableLabel string // front-end user feedback about selected report item tableLabel string // front-end user feedback about selected report item
messageRequired = true // unless we have a table ID to work with 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." 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" tableName = "users"
if user, err := models.GetUser(uint64(tableID)); err == nil { if user, err := models.GetUser(uint64(tableID)); err == nil {
tableLabel = fmt.Sprintf(`User account "%s"`, user.Username) tableLabel = fmt.Sprintf(`User account "%s"`, user.Username)
aboutUser = user
} else { } else {
log.Error("/contact: couldn't produce table label for user %d: %s", tableID, err) 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 pic, err := models.GetPhoto(uint64(tableID)); err == nil {
if user, err := models.GetUser(pic.UserID); err == nil { if user, err := models.GetUser(pic.UserID); err == nil {
tableLabel = fmt.Sprintf(`A profile photo of user account "%s"`, user.Username) tableLabel = fmt.Sprintf(`A profile photo of user account "%s"`, user.Username)
aboutUser = user
} else { } else {
log.Error("/contact: couldn't produce table label for user %d: %s", tableID, err) 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]" var username = "[unavailable]"
if sender, err := models.GetUser(msg.SourceUserID); err == nil { if sender, err := models.GetUser(msg.SourceUserID); err == nil {
username = sender.Username username = sender.Username
aboutUser = sender
} }
footer = fmt.Sprintf(` footer = fmt.Sprintf(`
@ -100,6 +104,7 @@ From: <a href="/u/%s">@%s</a>
// Find this comment. // Find this comment.
if comment, err := models.GetComment(uint64(tableID)); err == nil { if comment, err := models.GetComment(uint64(tableID)); err == nil {
tableLabel = fmt.Sprintf(`A comment written by "%s"`, comment.User.Username) tableLabel = fmt.Sprintf(`A comment written by "%s"`, comment.User.Username)
aboutUser = &comment.User
} else { } else {
log.Error("/contact: couldn't produce table label for comment %d: %s", tableID, err) 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), TableID: uint64(tableID),
} }
if aboutUser != nil {
fb.AboutUserID = aboutUser.ID
}
if currentUser != nil && currentUser.ID > 0 { if currentUser != nil && currentUser.ID > 0 {
fb.UserID = currentUser.ID fb.UserID = currentUser.ID
} else if replyTo != "" { } else if replyTo != "" {

View File

@ -12,6 +12,7 @@ import (
type Feedback struct { type Feedback struct {
ID uint64 `gorm:"primaryKey"` ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"index"` // if logged-in user posted this 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 Acknowledged bool `gorm:"index"` // admin dashboard "read" status
Intent string Intent string
Subject 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 // 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 // of their current photo IDs. Additionally, it will look for chat room reports which were about their
// username. // 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 ( var (
fb = []*Feedback{} fb = []*Feedback{}
photoIDs, _ = user.AllPhotoIDs() photoIDs, _ = user.AllPhotoIDs()
wheres = []string{} wheres = []string{}
placeholders = []interface{}{} placeholders = []interface{}{}
like = "%" + user.Username + "%"
) )
wheres = append(wheres, ` // How to apply the search filters?
(table_name = 'users' AND table_id = ?) OR switch show {
(table_name = 'photos' AND table_id IN ?) OR case "about":
message LIKE ? wheres = append(wheres, `
`) about_user_id = ? OR
placeholders = append(placeholders, user.ID, photoIDs, user.Username) (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( query := DB.Where(
strings.Join(wheres, " AND "), strings.Join(wheres, " AND "),

View File

@ -187,11 +187,38 @@
<div class="card-content"> <div class="card-content">
{{if .FeedbackPager.Total}} {{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}}). Found <strong>{{.FeedbackPager.Total}}</strong> report{{Pluralize64 .FeedbackPager.Total}} about this user (page {{.FeedbackPager.Page}} of {{.FeedbackPager.Pages}}).
</span> </div>
{{end}} {{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"> <div class="my-4">
{{SimplePager .FeedbackPager}} {{SimplePager .FeedbackPager}}
</div> </div>
@ -224,29 +251,29 @@
{{if ne .TableID 0}} - {{.TableID}}{{end}} {{if ne .TableID 0}} - {{.TableID}}{{end}}
{{else if eq .TableName "users"}} {{else if eq .TableName "users"}}
Users: {{.TableID}} 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" class="fa fa-external-link ml-2"
target="_blank" target="_blank"
title="Visit the reported user's profile"></a> title="Visit the reported user's profile"></a>
{{else if eq .TableName "photos"}} {{else if eq .TableName "photos"}}
Photos: {{.TableID}} 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" class="fa fa-external-link mx-2"
target="_blank" target="_blank"
title="Visit the reported photo"></a> 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" class="fa fa-user"
target="_blank" target="_blank"
title="Visit the user profile who owns the reported photo"></a> title="Visit the user profile who owns the reported photo"></a>
{{else if eq .TableName "messages"}} {{else if eq .TableName "messages"}}
Messages: {{.TableID}} 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" class="fa fa-ghost ml-2"
target="_blank" target="_blank"
title="Impersonate the reporter and view this message thread"></a> title="Impersonate the reporter and view this message thread"></a>
{{else}} {{else}}
{{.TableName}}: {{.TableID}} {{.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}} {{end}}
</td> </td>
</tr> </tr>