Add stats, profiles, avatars and palaute
Step 6. The surfaces around the review loop. - Nine leaderboards, ordered and limited in SQL, each with a deterministic tie-break so a tied board doesn't reshuffle between reloads. Min 3 reviews to qualify, for reviewer boards too - Profiles show counts and history-wide averages and the member's songs, never a list of their reviews — per-song opinion stays gated - Avatars: 5MB in, 256px JPEG out, ffmpeg's re-encode being the validation. No upload still means initials, and avatars are public - Changing your own password requires the current one and drops your other sessions - Palaute: free text plus the page you were on, carried in a footer link, and the user agent from the header. Reporters see their own; the admin resolves them with a timestamp rather than a status enum - Admin gained the song list with delete, the reports page, and an open-report count on the dashboard Two theme fixes the screenshots caught: leaderboard ranks need a CSS counter because display:grid suppresses list markers, and count-based boards were printing 3.0 where they mean 3.
This commit is contained in:
+197
@@ -0,0 +1,197 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxReportBody = 2000
|
||||
|
||||
type report struct {
|
||||
ID int64
|
||||
Body string
|
||||
Page string
|
||||
UserAgent string
|
||||
Reporter string
|
||||
ResolvedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (r *report) Open() bool { return r.ResolvedAt == nil }
|
||||
|
||||
type reportPage struct {
|
||||
From string
|
||||
Mine []*report
|
||||
}
|
||||
|
||||
// Free text and nothing else. No category, no priority, no severity — with ten users a sentence and
|
||||
// a page URL beat a taxonomy nobody fills in honestly.
|
||||
func (a *app) reportPage(w http.ResponseWriter, r *http.Request) {
|
||||
mine, err := a.myReports(r.Context(), memberFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
slog.Error("list reports", "ctx", "reports", "error", err)
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
from := r.URL.Query().Get("from")
|
||||
if !strings.HasPrefix(from, "/") {
|
||||
from = "/" // never redirect off-site on the strength of a query parameter
|
||||
}
|
||||
a.render(w, r, http.StatusOK, "report.html",
|
||||
page{Title: "Palaute", Data: reportPage{From: from, Mine: mine}})
|
||||
}
|
||||
|
||||
// Seeing your own past reports is what stops the same bug arriving four times.
|
||||
func (a *app) myReports(ctx context.Context, userID int64) ([]*report, error) {
|
||||
rows, err := a.pool.Query(ctx, `
|
||||
select id, body, coalesce(page, ''), resolved_at, created_at
|
||||
from reports where user_id = $1 order by created_at desc`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*report
|
||||
for rows.Next() {
|
||||
var rep report
|
||||
if err := rows.Scan(&rep.ID, &rep.Body, &rep.Page, &rep.ResolvedAt, &rep.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &rep)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (a *app) createReport(w http.ResponseWriter, r *http.Request) {
|
||||
me := memberFrom(r.Context())
|
||||
body := clean(r.FormValue("body"), maxReportBody)
|
||||
from := r.FormValue("from")
|
||||
if !strings.HasPrefix(from, "/") {
|
||||
from = "/"
|
||||
}
|
||||
if body == "" {
|
||||
a.flash(w, "Kirjoita muutama sana siitä, mikä meni pieleen.")
|
||||
http.Redirect(w, r, "/report?from="+from, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// "Only on my phone" is the most common bug report and this answers it without asking.
|
||||
_, err := a.pool.Exec(r.Context(), `
|
||||
insert into reports (user_id, body, page, user_agent) values ($1, $2, nullif($3, ''), $4)`,
|
||||
me.ID, body, from, clean(r.Header.Get("User-Agent"), 300))
|
||||
if err != nil {
|
||||
slog.Error("create report", "ctx", "reports", "error", err)
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
slog.Info("report filed", "ctx", "reports", "user", me.ID)
|
||||
a.flash(w, "Kiitos! Palaute on perillä.")
|
||||
http.Redirect(w, r, from, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- admin ---
|
||||
|
||||
func (a *app) adminReports(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := a.pool.Query(r.Context(), `
|
||||
select rep.id, rep.body, coalesce(rep.page, ''), coalesce(rep.user_agent, ''),
|
||||
u.name, rep.resolved_at, rep.created_at
|
||||
from reports rep join users u on u.id = rep.user_id
|
||||
order by rep.resolved_at nulls first, rep.created_at desc`)
|
||||
if err != nil {
|
||||
adminError(w, "reports", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*report
|
||||
for rows.Next() {
|
||||
var rep report
|
||||
if err := rows.Scan(&rep.ID, &rep.Body, &rep.Page, &rep.UserAgent, &rep.Reporter,
|
||||
&rep.ResolvedAt, &rep.CreatedAt); err != nil {
|
||||
adminError(w, "reports", err)
|
||||
return
|
||||
}
|
||||
out = append(out, &rep)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
adminError(w, "reports", err)
|
||||
return
|
||||
}
|
||||
a.render(w, r, http.StatusOK, "admin_reports.html",
|
||||
page{Title: "Palautteet", Admin: true, Data: out})
|
||||
}
|
||||
|
||||
// A nullable timestamp rather than a status enum: smaller, and it tells you *when*.
|
||||
func (a *app) resolveReport(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if _, err := a.pool.Exec(r.Context(),
|
||||
`update reports set resolved_at = case when resolved_at is null then now() end where id = $1`,
|
||||
id); err != nil {
|
||||
adminError(w, "reports", err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/reports", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// The admin deletes a song unconditionally — a separate route from the submitter's, rather than one
|
||||
// route with a branch. The row and the file go together here too.
|
||||
func (a *app) adminDeleteSong(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
tag, err := a.pool.Exec(r.Context(), `delete from songs where id = $1`, id)
|
||||
if err != nil {
|
||||
adminError(w, "songs", err)
|
||||
return
|
||||
}
|
||||
if tag.RowsAffected() > 0 {
|
||||
removeFile(a.audioPath(id))
|
||||
slog.Info("song deleted by admin", "ctx", "songs", "song", id)
|
||||
a.flash(w, "Kappale poistettu.")
|
||||
}
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type adminSong struct {
|
||||
ID int64
|
||||
Title string
|
||||
Artist string
|
||||
Submitter string
|
||||
Reviews int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (a *app) adminSongs(ctx context.Context) ([]adminSong, error) {
|
||||
rows, err := a.pool.Query(ctx, `
|
||||
select s.id, s.title, s.artist, u.name,
|
||||
(select count(*) from reviews r where r.song_id = s.id)::int, s.created_at
|
||||
from songs s join users u on u.id = s.submitted_by
|
||||
order by s.created_at desc`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []adminSong
|
||||
for rows.Next() {
|
||||
var s adminSong
|
||||
if err := rows.Scan(&s.ID, &s.Title, &s.Artist, &s.Submitter, &s.Reviews, &s.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Moderating a complaint means listening to the song, so the admin surface has its own audio route
|
||||
// rather than branching auth inside the member handler.
|
||||
func (a *app) adminAudio(w http.ResponseWriter, r *http.Request) {
|
||||
a.audio(w, r)
|
||||
}
|
||||
Reference in New Issue
Block a user