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:
Esa Kataja
2026-07-31 22:34:04 +03:00
parent f33f4fa4d6
commit f1e907bac3
16 changed files with 991 additions and 6 deletions
+14 -2
View File
@@ -29,8 +29,10 @@ type adminMember struct {
}
type dashboard struct {
Invites []adminInvite
Members []adminMember
Invites []adminInvite
Members []adminMember
Songs []adminSong
OpenCount int
}
func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
@@ -77,6 +79,16 @@ func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
return
}
if d.Songs, err = a.adminSongs(r.Context()); err != nil {
adminError(w, "songs", err)
return
}
if err := a.pool.QueryRow(r.Context(),
`select count(*)::int from reports where resolved_at is null`).Scan(&d.OpenCount); err != nil {
adminError(w, "reports", err)
return
}
a.render(w, r, http.StatusOK, "admin.html", page{Title: "Ylläpito", Admin: true, Data: d})
}
+14 -1
View File
@@ -96,7 +96,7 @@ func main() {
if err := sweep(ctx, pool); err != nil {
fatal("startup sweep", "error", err)
}
for _, dir := range []string{"audio", "tmp"} {
for _, dir := range []string{"audio", "tmp", "avatars"} {
if err := os.MkdirAll(filepath.Join(cfg.storageDir, dir), 0o755); err != nil {
fatal("storage dir", "error", err, "dir", dir)
}
@@ -141,6 +141,15 @@ func (a *app) memberMux() *http.ServeMux {
mux.HandleFunc("POST /songs/{id}", a.requireMember(a.editSong))
mux.HandleFunc("POST /songs/{id}/delete", a.requireMember(a.deleteSong))
mux.HandleFunc("GET /audio/{id}", a.requireMember(a.audio))
mux.HandleFunc("GET /avatars/{id}", a.avatar) // public: avatars are not secret
mux.HandleFunc("GET /stats", a.requireMember(a.statsPage))
mux.HandleFunc("GET /profile", a.requireMember(a.profilePage))
mux.HandleFunc("GET /profile/{id}", a.requireMember(a.profilePage))
mux.HandleFunc("POST /profile", a.requireMember(a.editProfile))
mux.HandleFunc("GET /report", a.requireMember(a.reportPage))
mux.HandleFunc("POST /report", a.requireMember(a.createReport))
mux.HandleFunc("POST /songs/{id}/review", a.requireMember(a.createReview))
mux.HandleFunc("POST /reviews/{id}", a.requireMember(a.editReview))
@@ -164,6 +173,10 @@ func (a *app) adminMux() *http.ServeMux {
mux.HandleFunc("POST /admin/invites", a.createInvite)
mux.HandleFunc("POST /admin/users/{id}/ban", a.toggleBan)
mux.HandleFunc("POST /admin/users/{id}/password", a.resetPassword)
mux.HandleFunc("POST /admin/songs/{id}/delete", a.adminDeleteSong)
mux.HandleFunc("GET /admin/reports", a.adminReports)
mux.HandleFunc("POST /admin/reports/{id}/resolve", a.resolveReport)
mux.HandleFunc("GET /admin/audio/{id}", a.adminAudio)
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin", http.StatusSeeOther)
})
+11
View File
@@ -176,6 +176,17 @@ func downloadYouTube(ctx context.Context, url, outTemplate string) (string, erro
return "", nil
}
// toAvatarJPEG normalises any image ffmpeg understands into a 256px square JPEG. The re-encode is
// the validation and the size cap in one — webp and avif included, which stdlib image cannot read.
func toAvatarJPEG(ctx context.Context, in, out string) error {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
return exec.CommandContext(ctx, "ffmpeg", "-nostdin", "-y", "-i", in,
"-vf", "scale=256:256:force_original_aspect_ratio=increase,crop=256:256",
"-frames:v", "1", "-q:v", "3", out).Run()
}
// convertToOpus is also the validation: if ffmpeg produced an Opus stream, the upload was audio.
// No container sniffing, no magic-byte library. Returns the stderr tail on failure, which is worth
// showing — "Invalid data found when processing input" beats "submission failed".
+232
View File
@@ -0,0 +1,232 @@
package main
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
)
const maxAvatarBytes = 5 << 20
type profileStats struct {
SongsSubmitted int
ReviewsWritten int
AverageGiven *float64
AverageReceived *float64
}
type profileView struct {
ID int64
Name string
Email string // only filled for your own profile
Avatar *string
CreatedAt time.Time
Own bool
Stats profileStats
Songs []*songSummary
Errors map[string]string
}
func (p *profileView) Initials() string { m := member{Name: p.Name}; return m.Initials() }
func (a *app) avatarPath(userID int64) string {
return filepath.Join(a.cfg.storageDir, "avatars", strconv.FormatInt(userID, 10)+".jpg")
}
// Counts and history-wide averages only. A member's per-song opinions stay on the song pages —
// per-song opinion is gated, whole-history aggregate is public.
func (a *app) profile(ctx context.Context, viewerID, userID int64) (*profileView, error) {
var p profileView
err := a.pool.QueryRow(ctx, `
select u.id, u.name, u.email, u.avatar, u.created_at,
(select count(*) from songs s where s.submitted_by = u.id),
(select count(*) from reviews r where r.reviewer_id = u.id),
(select avg(r.score)::float from reviews r where r.reviewer_id = u.id),
(select avg(r.score)::float from reviews r
join songs s on s.id = r.song_id where s.submitted_by = u.id)
from users u where u.id = $1`, userID).
Scan(&p.ID, &p.Name, &p.Email, &p.Avatar, &p.CreatedAt,
&p.Stats.SongsSubmitted, &p.Stats.ReviewsWritten,
&p.Stats.AverageGiven, &p.Stats.AverageReceived)
if err != nil {
return nil, err
}
p.Own = viewerID == userID
if !p.Own {
p.Email = ""
}
// Their songs, with the viewer's own reveal rule applied to each average.
rows, err := a.pool.Query(ctx, `select`+songColumns+`
from songs s join users u on u.id = s.submitted_by
where s.submitted_by = $2
order by s.created_at desc`, viewerID, userID)
if err != nil {
return nil, err
}
p.Songs, err = scanSongs(rows)
return &p, err
}
func (a *app) profilePage(w http.ResponseWriter, r *http.Request) {
me := memberFrom(r.Context())
id := me.ID
if raw := r.PathValue("id"); raw != "" {
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
id = parsed
}
p, err := a.profile(r.Context(), me.ID, id)
if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r)
return
} else if err != nil {
slog.Error("profile", "ctx", "auth", "error", err, "user", id)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
a.render(w, r, http.StatusOK, "profile.html", page{Title: p.Name, Data: p})
}
// Editing your own profile: name, email, password, avatar. Changing the password requires the
// current one and drops your other sessions.
func (a *app) editProfile(w http.ResponseWriter, r *http.Request) {
me := memberFrom(r.Context())
if err := r.ParseMultipartForm(maxAvatarBytes); err != nil && !errors.Is(err, http.ErrNotMultipart) {
a.flash(w, "Kuva on liian suuri. Enintään 5 MB.")
http.Redirect(w, r, "/profile", http.StatusSeeOther)
return
}
name := clean(r.FormValue("name"), 50)
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
if name == "" || !strings.Contains(email, "@") {
a.flash(w, "Tarkista nimi ja sähköpostiosoite.")
http.Redirect(w, r, "/profile", http.StatusSeeOther)
return
}
if _, err := a.pool.Exec(r.Context(),
`update users set name = $2, email = $3 where id = $1`, me.ID, name, email); isUnique(err) {
a.flash(w, "Sähköpostiosoite on jo käytössä.")
http.Redirect(w, r, "/profile", http.StatusSeeOther)
return
} else if err != nil {
slog.Error("edit profile", "ctx", "auth", "error", err, "user", me.ID)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
if newPassword := r.FormValue("new_password"); newPassword != "" {
if !a.changePassword(w, r, me.ID, r.FormValue("current_password"), newPassword) {
return
}
}
if file, _, err := r.FormFile("avatar"); err == nil {
defer file.Close()
if err := a.saveAvatar(r, me.ID, file); err != nil {
slog.Error("avatar", "ctx", "auth", "error", err, "user", me.ID)
a.flash(w, "Kuvaa ei voitu käsitellä. Onko se varmasti kuva?")
http.Redirect(w, r, "/profile", http.StatusSeeOther)
return
}
}
a.flash(w, "Tiedot tallennettu.")
http.Redirect(w, r, "/profile", http.StatusSeeOther)
}
func (a *app) changePassword(w http.ResponseWriter, r *http.Request, userID int64, current, next string) bool {
var hash string
if err := a.pool.QueryRow(r.Context(),
`select password_hash from users where id = $1`, userID).Scan(&hash); err != nil {
http.Error(w, "virhe", http.StatusInternalServerError)
return false
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(current)) != nil {
a.flash(w, "Nykyinen salasana ei täsmää.")
http.Redirect(w, r, "/profile", http.StatusSeeOther)
return false
}
newHash, err := bcrypt.GenerateFromPassword([]byte(next), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "virhe", http.StatusInternalServerError)
return false
}
if _, err := a.pool.Exec(r.Context(),
`update users set password_hash = $2 where id = $1`, userID, string(newHash)); err != nil {
http.Error(w, "virhe", http.StatusInternalServerError)
return false
}
// Every other session dies; this browser keeps its own.
if _, err := a.pool.Exec(r.Context(),
`delete from sessions where user_id = $1 and token <> $2`, userID, sessionToken(r)); err != nil {
slog.Error("drop sessions", "ctx", "auth", "error", err, "user", userID)
}
slog.Info("password changed", "ctx", "auth", "user", userID)
return true
}
// The re-encode through ffmpeg is the validation, the same trick as audio: it handles webp and
// avif (stdlib image does not), and it caps what ends up on disk.
func (a *app) saveAvatar(r *http.Request, userID int64, file io.Reader) error {
tmp := filepath.Join(a.cfg.storageDir, "tmp", "avatar-"+strconv.FormatInt(userID, 10))
dst, err := os.Create(tmp)
if err != nil {
return err
}
_, err = io.Copy(dst, io.LimitReader(file, maxAvatarBytes))
dst.Close()
if err != nil {
os.Remove(tmp)
return err
}
defer os.Remove(tmp)
out := a.avatarPath(userID)
if err := toAvatarJPEG(r.Context(), tmp, out); err != nil {
return err
}
_, err = a.pool.Exec(r.Context(),
`update users set avatar = $2 where id = $1`, userID, filepath.Base(out))
return err
}
// Avatars are public: they are not secret, and gating them buys nothing.
func (a *app) avatar(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
f, err := os.Open(a.avatarPath(id))
if err != nil {
// No upload: the template renders initials instead, and a client sees avatar_url null.
http.NotFound(w, r)
return
}
defer f.Close()
info, err := f.Stat()
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=300")
http.ServeContent(w, r, "avatar.jpg", info.ModTime(), f)
}
+10
View File
@@ -17,6 +17,16 @@ var assetFS embed.FS
var funcs = template.FuncMap{
"fidate": func(t time.Time) string { return t.Local().Format("2.1.2006 15:04") },
"score": func(f *float64) string { return strconv.FormatFloat(*f, 'f', 1, 64) },
"value": func(f float64) string { return strconv.FormatFloat(f, 'f', 1, 64) },
// Lets one board partial be called with a title and a list, instead of two near-identical
// partials per leaderboard.
"dict": func(pairs ...any) map[string]any {
m := map[string]any{}
for i := 0; i+1 < len(pairs); i += 2 {
m[pairs[i].(string)] = pairs[i+1]
}
return m
},
}
// Each page is parsed with the layout into its own set, so two pages may both define "content".
+197
View File
@@ -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)
}
+8 -1
View File
@@ -269,12 +269,19 @@ func (a *app) deleteSong(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther)
return
}
os.Remove(a.audioPath(id))
removeFile(a.audioPath(id))
slog.Info("song deleted", "ctx", "songs", "song", id)
a.flash(w, "Kappale poistettu.")
http.Redirect(w, r, "/songs", http.StatusSeeOther)
}
// A missing file is fine — the row is gone either way — but anything else is worth knowing about.
func removeFile(path string) {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
slog.Error("remove file", "ctx", "songs", "error", err, "path", path)
}
}
// --- audio ---
// Auth-gated, Range-capable, and not under /api because it serves bytes rather than JSON.
+39
View File
@@ -551,3 +551,42 @@ code { background: var(--surface-raised); padding: 0.1rem var(--space-1);
*, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; }
.songcard:hover { transform: none; }
}
/* --- stats --- */
.boards { display: grid; grid-template-columns: 1fr; gap: var(--space-5); }
@media (min-width: 700px) { .boards { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1100px) { .boards { grid-template-columns: repeat(3, 1fr); } }
.board { background: var(--surface); border: 1px solid var(--hairline); border-radius: var(--radius);
padding: var(--space-4); margin: 0; box-shadow: var(--shadow-card); }
.board h2 { font-size: 1.1rem; margin-bottom: var(--space-3); }
/* A counter rather than a list marker: `display: grid` on the <li> suppresses markers. */
.board-list { list-style: none; counter-reset: rank; margin: 0; padding: 0; }
.board-list li { display: grid; grid-template-columns: 1.6rem 1fr auto;
column-gap: var(--space-2); align-items: baseline;
padding: var(--space-2) 0; border-bottom: 1px solid var(--hairline); }
.board-list li:last-child { border-bottom: 0; }
.board-list li::before { counter-increment: rank; content: counter(rank) "."; color: var(--muted);
font-family: var(--font-display); }
.board-list li a { grid-column: 2; }
.board-list li .value { grid-column: 3; grid-row: 1; font-family: var(--font-display);
font-size: 1.1rem; color: var(--gold-1); }
.board-list li .small { grid-column: 2; }
.board-list li .small:last-child { grid-column: 3; text-align: right; }
/* --- profile --- */
.profilehead { display: flex; align-items: center; gap: var(--space-4); margin-bottom: var(--space-5); }
.profilehead h1 { margin: 0; }
.avatar.big { width: 4.5rem; height: 4.5rem; font-size: 1.6rem; object-fit: cover; }
img.avatar { object-fit: cover; }
.statgrid { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-4);
margin-bottom: var(--space-6); }
@media (min-width: 700px) { .statgrid { grid-template-columns: repeat(4, 1fr); } }
.statcard { background: var(--surface); border: 1px solid var(--hairline); border-radius: var(--radius);
padding: var(--space-5); text-align: center; display: flex; flex-direction: column;
gap: var(--space-1); box-shadow: var(--shadow-card); }
.statvalue { font-family: var(--font-display); font-size: 2rem; font-weight: 700; color: var(--gold-1); }
+148
View File
@@ -0,0 +1,148 @@
package main
import (
"context"
"log/slog"
"net/http"
)
// A song needs this many reviews to qualify for any ranking: with ten members it means a third of
// the club has weighed in, which is a real threshold rather than a formality.
const minReviews = 3
type songStat struct {
ID int64
Title string
Artist string
Value float64
ReviewCount int
}
type userStat struct {
ID int64
Name string
Value float64
Count int
Avatar *string
}
func (u *userStat) Initials() string { m := member{Name: u.Name}; return m.Initials() }
type stats struct {
MinReviews int
TopSongs []songStat
BottomSongs []songStat
MostDivisive []songStat
MostUnified []songStat
MostReviewed []songStat
Harshest []userStat
MostGenerous []userStat
MostActive []userStat
MostProlific []userStat
}
// Every leaderboard is ordered and limited in SQL, and every one carries a deterministic tie-break:
// ties are common in a ten-person club, and without one Postgres may return a different ten each
// time, so the page visibly reshuffles between reloads for no reason.
func (a *app) songLeaderboard(ctx context.Context, valueExpr, direction string) ([]songStat, error) {
rows, err := a.pool.Query(ctx, `
select s.id, s.title, s.artist, `+valueExpr+`::float as value, count(r.id)::int as reviews
from songs s join reviews r on r.song_id = s.id
group by s.id
having count(r.id) >= $1
order by value `+direction+`, reviews desc, s.id asc
limit 10`, minReviews)
if err != nil {
return nil, err
}
defer rows.Close()
var out []songStat
for rows.Next() {
var s songStat
if err := rows.Scan(&s.ID, &s.Title, &s.Artist, &s.Value, &s.ReviewCount); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// Reviewer boards need a minimum too, or one enthusiastic 100 makes someone the most generous
// member in the club forever.
func (a *app) reviewerLeaderboard(ctx context.Context, valueExpr, direction string) ([]userStat, error) {
rows, err := a.pool.Query(ctx, `
select u.id, u.name, u.avatar, `+valueExpr+`::float as value, count(r.id)::int as n
from users u join reviews r on r.reviewer_id = u.id
group by u.id
having count(r.id) >= $1
order by value `+direction+`, n desc, u.id asc
limit 10`, minReviews)
if err != nil {
return nil, err
}
defer rows.Close()
var out []userStat
for rows.Next() {
var u userStat
if err := rows.Scan(&u.ID, &u.Name, &u.Avatar, &u.Value, &u.Count); err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
func (a *app) mostProlific(ctx context.Context) ([]userStat, error) {
rows, err := a.pool.Query(ctx, `
select u.id, u.name, u.avatar, count(s.id)::float, count(s.id)::int
from users u join songs s on s.submitted_by = u.id
group by u.id
order by count(s.id) desc, u.id asc
limit 10`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []userStat
for rows.Next() {
var u userStat
if err := rows.Scan(&u.ID, &u.Name, &u.Avatar, &u.Value, &u.Count); err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
// The reveal rule does not apply here: leaderboards are always public. That is the whole point of
// /stats being a page you walk to deliberately.
func (a *app) statsPage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
s := stats{MinReviews: minReviews}
var err error
for _, load := range []func() error{
func() (err error) { s.TopSongs, err = a.songLeaderboard(ctx, "avg(r.score)", "desc"); return },
func() (err error) { s.BottomSongs, err = a.songLeaderboard(ctx, "avg(r.score)", "asc"); return },
func() (err error) {
s.MostDivisive, err = a.songLeaderboard(ctx, "stddev_pop(r.score)", "desc")
return
},
func() (err error) { s.MostUnified, err = a.songLeaderboard(ctx, "stddev_pop(r.score)", "asc"); return },
func() (err error) { s.MostReviewed, err = a.songLeaderboard(ctx, "count(r.id)", "desc"); return },
func() (err error) { s.Harshest, err = a.reviewerLeaderboard(ctx, "avg(r.score)", "asc"); return },
func() (err error) { s.MostGenerous, err = a.reviewerLeaderboard(ctx, "avg(r.score)", "desc"); return },
func() (err error) { s.MostActive, err = a.reviewerLeaderboard(ctx, "count(r.id)", "desc"); return },
func() (err error) { s.MostProlific, err = a.mostProlific(ctx); return },
} {
if err = load(); err != nil {
slog.Error("stats", "ctx", "songs", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
}
a.render(w, r, http.StatusOK, "stats.html", page{Title: "Tilastot", Data: s})
}
+120
View File
@@ -0,0 +1,120 @@
package main
import (
"context"
"testing"
)
// A song qualifies only at minReviews, and the order is decided in SQL — with a tie-break, so the
// same ten come back in the same order every time.
func TestLeaderboardThresholdAndOrder(t *testing.T) {
a := testApp(t)
ctx := context.Background()
submitter := a.seedMember(t, "[email protected]")
var reviewers []int64
for _, e := range []string{"[email protected]", "[email protected]", "[email protected]"} {
reviewers = append(reviewers, a.seedMember(t, e))
}
loved := a.seedSong(t, submitter, "Rakastettu")
hated := a.seedSong(t, submitter, "Vihattu")
ignored := a.seedSong(t, submitter, "Kahdesti arvosteltu")
for _, r := range reviewers {
a.seedReview(t, loved, r, 90)
a.seedReview(t, hated, r, 20)
}
// One short of the threshold.
a.seedReview(t, ignored, reviewers[0], 100)
a.seedReview(t, ignored, reviewers[1], 100)
top, err := a.songLeaderboard(ctx, "avg(r.score)", "desc")
if err != nil {
t.Fatal(err)
}
if len(top) != 2 {
t.Fatalf("top has %d entries, want 2 — the third song is below %d reviews", len(top), minReviews)
}
if top[0].ID != loved || top[1].ID != hated {
t.Fatalf("top order is %d, %d — want %d first", top[0].ID, top[1].ID, loved)
}
if top[0].Value != 90 || top[0].ReviewCount != 3 {
t.Fatalf("top entry = %v with %d reviews, want 90 and 3", top[0].Value, top[0].ReviewCount)
}
bottom, err := a.songLeaderboard(ctx, "avg(r.score)", "asc")
if err != nil {
t.Fatal(err)
}
if bottom[0].ID != hated {
t.Fatalf("bottom starts with %d, want %d", bottom[0].ID, hated)
}
// Identical scores everywhere means stddev 0, so unified beats divisive on the same data.
unified, err := a.songLeaderboard(ctx, "stddev_pop(r.score)", "asc")
if err != nil {
t.Fatal(err)
}
if unified[0].Value != 0 {
t.Fatalf("most unified has stddev %v, want 0", unified[0].Value)
}
// Ties are the common case in a ten-person club: the same query must return the same order.
first, _ := a.songLeaderboard(ctx, "count(r.id)", "desc")
second, _ := a.songLeaderboard(ctx, "count(r.id)", "desc")
for i := range first {
if first[i].ID != second[i].ID {
t.Fatal("a tied leaderboard reshuffles between calls — the tie-break is missing")
}
}
}
// Profiles are counts and history-wide averages. They never carry per-song opinions.
func TestProfileStats(t *testing.T) {
a := testApp(t)
ctx := context.Background()
aino := a.seedMember(t, "[email protected]")
bertta := a.seedMember(t, "[email protected]")
song := a.seedSong(t, aino, "Testikappale")
a.seedReview(t, song, bertta, 80)
other := a.seedSong(t, bertta, "Berttan kappale")
a.seedReview(t, other, aino, 40)
p, err := a.profile(ctx, bertta, aino)
if err != nil {
t.Fatal(err)
}
if p.Stats.SongsSubmitted != 1 || p.Stats.ReviewsWritten != 1 {
t.Fatalf("counts = %d songs, %d reviews; want 1 and 1",
p.Stats.SongsSubmitted, p.Stats.ReviewsWritten)
}
if p.Stats.AverageGiven == nil || *p.Stats.AverageGiven != 40 {
t.Fatalf("average given = %v, want 40", p.Stats.AverageGiven)
}
if p.Stats.AverageReceived == nil || *p.Stats.AverageReceived != 80 {
t.Fatalf("average received = %v, want 80", p.Stats.AverageReceived)
}
// Viewing someone else's profile does not expose their email.
if p.Email != "" {
t.Fatalf("another member's email leaked: %q", p.Email)
}
// Their songs still obey the viewer's own reveal rule. Bertta reviewed this one, so she sees
// its average here.
if len(p.Songs) != 1 {
t.Fatalf("profile lists %d songs, want 1", len(p.Songs))
}
if p.Songs[0].Average == nil {
t.Fatal("a reviewer cannot see the average of a song they reviewed")
}
// A third member who has reviewed nothing must not learn it from the profile page.
cecilia := a.seedMember(t, "[email protected]")
p, err = a.profile(ctx, cecilia, aino)
if err != nil {
t.Fatal(err)
}
if p.Songs[0].Average != nil {
t.Fatal("profile leaked a song average to someone who has not reviewed it")
}
}
+30
View File
@@ -1,5 +1,6 @@
{{define "content"}}
<h1>Ylläpito</h1>
<p><a href="/admin/reports">Palautteet</a>{{if .Data.OpenCount}} <span class="badge pending">{{.Data.OpenCount}} avointa</span>{{end}}</p>
<section class="adminsection">
<header>
@@ -59,4 +60,33 @@
</table>
</div>
</section>
<section class="adminsection">
<header><h2>Kappaleet</h2></header>
<div class="body">
<table>
<thead><tr><th>Kappale</th><th>Lähettäjä</th><th>Arvostelut</th><th>Julkaistu</th><th></th></tr></thead>
<tbody>
{{range .Data.Songs}}
<tr>
<td>{{.Title}} <span class="muted">— {{.Artist}}</span></td>
<td>{{.Submitter}}</td>
<td>{{.Reviews}}</td>
<td class="nowrap">{{fidate .CreatedAt}}</td>
<td class="actions">
<a href="/admin/audio/{{.ID}}">Kuuntele</a>
<form method="post" action="/admin/songs/{{.ID}}/delete"
onsubmit="return confirm('Poistetaanko kappale ja kaikki sen arvostelut?')">
<button type="submit" class="ghost danger">Poista</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="5" class="muted">Ei kappaleita.</td></tr>
{{end}}
</tbody>
</table>
</div>
</section>
{{end}}
+23
View File
@@ -0,0 +1,23 @@
{{define "content"}}
<h1>Palautteet</h1>
<p><a href="/admin">← Ylläpito</a></p>
{{range .Data}}
<article class="review{{if .Open}} own{{end}}">
<header>
<span class="badge {{if .Open}}pending{{else}}reviewed{{end}}">{{if .Open}}avoin{{else}}käsitelty{{end}}</span>
<span class="who">{{.Reporter}}</span>
<span class="meta">{{fidate .CreatedAt}}{{if .Page}} · {{.Page}}{{end}}</span>
</header>
<p>{{.Body}}</p>
<p class="meta break">{{.UserAgent}}</p>
{{if .Open}}
<form method="post" action="/admin/reports/{{.ID}}/resolve">
<button type="submit" class="ghost">Merkitse käsitellyksi</button>
</form>
{{end}}
</article>
{{else}}
<p class="empty">Ei palautteita.</p>
{{end}}
{{end}}
+14 -2
View File
@@ -22,13 +22,17 @@
<a href="/" {{if eq .Path "/"}}aria-current="page"{{end}}>Jono</a>
<a href="/songs" {{if eq .Path "/songs"}}aria-current="page"{{end}}>Kappaleet</a>
<a href="/submit" {{if eq .Path "/submit"}}aria-current="page"{{end}}>Lähetä</a>
<a href="/stats" {{if eq .Path "/stats"}}aria-current="page"{{end}}>Tilastot</a>
</nav>
<div class="userblock">
<span class="lines">
<span class="name">{{.Member.Name}}</span>
<span class="email">{{.Member.Email}}</span>
</span>
<span class="avatar" title="{{.Member.Name}}">{{.Member.Initials}}</span>
<a href="/profile" title="{{.Member.Name}}">
{{if .Member.Avatar}}<img class="avatar" src="/avatars/{{.Member.ID}}" alt="Oma profiili">
{{else}}<span class="avatar">{{.Member.Initials}}</span>{{end}}
</a>
<form method="post" action="/logout"><button class="link">Kirjaudu ulos</button></form>
<!-- <details> is the mobile panel: no JS, and Esc/click-away come free. -->
@@ -38,6 +42,8 @@
<a href="/">Jono</a>
<a href="/songs">Kappaleet</a>
<a href="/submit">Lähetä</a>
<a href="/stats">Tilastot</a>
<a href="/profile">Oma profiili</a>
<form method="post" action="/logout"><button type="submit">Kirjaudu ulos</button></form>
</div>
</details>
@@ -51,7 +57,13 @@
<main {{if .Narrow}}class="narrow"{{end}}>{{template "content" .}}</main>
<footer class="sitefooter">Levyraati — kymmenen kaverin levyraati.</footer>
<footer class="sitefooter">
{{if .Member}}
<!-- The server already knows where they were, so the path travels in the link — no JS. -->
<a href="/report?from={{.Path}}">Ilmoita ongelmasta</a> ·
{{end}}
Levyraati — kymmenen kaverin levyraati.
</footer>
{{with .Flash}}
<div class="toasts">
+45
View File
@@ -0,0 +1,45 @@
{{define "content"}}
{{$p := .Data}}
<div class="profilehead">
{{if $p.Avatar}}
<img class="avatar big" src="/avatars/{{$p.ID}}" alt="">
{{else}}
<span class="avatar big">{{$p.Initials}}</span>
{{end}}
<div>
<h1>{{$p.Name}}</h1>
<p class="muted">Liittyi {{fidate $p.CreatedAt}}{{if $p.Email}} · {{$p.Email}}{{end}}</p>
</div>
</div>
<div class="statgrid">
<div class="statcard"><span class="statvalue">{{$p.Stats.SongsSubmitted}}</span><span class="muted small">kappaletta</span></div>
<div class="statcard"><span class="statvalue">{{$p.Stats.ReviewsWritten}}</span><span class="muted small">arvostelua</span></div>
<div class="statcard"><span class="statvalue">{{if $p.Stats.AverageGiven}}{{score $p.Stats.AverageGiven}}{{else}}—{{end}}</span><span class="muted small">antanut ka.</span></div>
<div class="statcard"><span class="statvalue">{{if $p.Stats.AverageReceived}}{{score $p.Stats.AverageReceived}}{{else}}—{{end}}</span><span class="muted small">saanut ka.</span></div>
</div>
{{if $p.Own}}
<details class="editbox">
<summary>Muokkaa tietoja</summary>
<form method="post" action="/profile" enctype="multipart/form-data" class="stack">
<label>Nimi <input name="name" value="{{$p.Name}}" maxlength="50" required></label>
<label>Sähköposti <input type="email" name="email" value="{{$p.Email}}" required></label>
<label>Kuva <input type="file" name="avatar" accept="image/*"></label>
<label>Nykyinen salasana <input type="password" name="current_password" autocomplete="current-password"></label>
<label>Uusi salasana <input type="password" name="new_password" autocomplete="new-password"></label>
<button type="submit">Tallenna</button>
</form>
<p class="muted small">Salasanan vaihto vaatii nykyisen salasanan ja kirjaa ulos muut laitteesi.</p>
</details>
{{end}}
<section>
<h2>Kappaleet</h2>
{{if $p.Songs}}
<div class="songgrid">{{range $p.Songs}}{{template "songcard" .}}{{end}}</div>
{{else}}
<p class="muted">Ei vielä yhtään kappaletta.</p>
{{end}}
</section>
{{end}}
+30
View File
@@ -0,0 +1,30 @@
{{define "content"}}
<h1>Palaute</h1>
<p class="muted">Kerro mikä on rikki tai ärsyttää. Ei kategorioita eikä prioriteetteja — yksi
virke riittää.</p>
<form method="post" action="/report" class="stack">
<input type="hidden" name="from" value="{{.Data.From}}">
<label>Palaute
<textarea name="body" rows="6" maxlength="2000" required autofocus
placeholder="Esim. soitin ei toimi puhelimella."></textarea>
</label>
<button type="submit">Lähetä palaute</button>
</form>
<p class="muted small">Lähetämme mukaan sivun, jolla olit ({{.Data.From}}), sekä selaimen tiedot.</p>
{{with .Data.Mine}}
<section>
<h2>Omat palautteet</h2>
{{range .}}
<article class="review{{if .Open}} own{{end}}">
<header>
<span class="badge {{if .Open}}pending{{else}}reviewed{{end}}">{{if .Open}}avoin{{else}}käsitelty{{end}}</span>
<span class="meta">{{fidate .CreatedAt}}{{if .Page}} · {{.Page}}{{end}}</span>
</header>
<p>{{.Body}}</p>
</article>
{{end}}
</section>
{{end}}
{{end}}
+56
View File
@@ -0,0 +1,56 @@
{{define "songboard"}}
<section class="board">
<h2>{{.Title}}</h2>
{{if .Items}}
<ol class="board-list">
{{range .Items}}
<li>
<a href="/songs/{{.ID}}">{{.Title}}</a>
<span class="muted small">{{.Artist}}</span>
<span class="value">{{if $.Count}}{{.ReviewCount}}{{else}}{{value .Value}}{{end}}</span>
{{if not $.Count}}<span class="muted small">{{.ReviewCount}} arv.</span>{{end}}
</li>
{{end}}
</ol>
{{else}}
<p class="muted small">Ei vielä tarpeeksi arvosteluja.</p>
{{end}}
</section>
{{end}}
{{define "userboard"}}
<section class="board">
<h2>{{.Title}}</h2>
{{if .Items}}
<ol class="board-list">
{{range .Items}}
<li>
<a href="/profile/{{.ID}}">{{.Name}}</a>
<span class="value">{{if $.Count}}{{.Count}}{{else}}{{value .Value}}{{end}}</span>
{{if not $.Count}}<span class="muted small">{{.Count}} kpl</span>{{end}}
</li>
{{end}}
</ol>
{{else}}
<p class="muted small">Ei vielä tarpeeksi arvosteluja.</p>
{{end}}
</section>
{{end}}
{{define "content"}}
<h1>Tilastot</h1>
<p class="muted">Kappale pääsee listoille kun sillä on vähintään {{.Data.MinReviews}} arvostelua.
Tilastot näkyvät kaikille — täällä pisteitä ei piiloteta.</p>
<div class="boards">
{{template "songboard" dict "Title" "Parhaat" "Items" .Data.TopSongs}}
{{template "songboard" dict "Title" "Heikoimmat" "Items" .Data.BottomSongs}}
{{template "songboard" dict "Title" "Riitaisimmat" "Items" .Data.MostDivisive}}
{{template "songboard" dict "Title" "Yksimielisimmät" "Items" .Data.MostUnified}}
{{template "songboard" dict "Title" "Eniten arvosteltu" "Items" .Data.MostReviewed "Count" true}}
{{template "userboard" dict "Title" "Ankarin arvostelija" "Items" .Data.Harshest}}
{{template "userboard" dict "Title" "Anteliain" "Items" .Data.MostGenerous}}
{{template "userboard" dict "Title" "Ahkerin arvostelija" "Items" .Data.MostActive "Count" true}}
{{template "userboard" dict "Title" "Ahkerin lähettäjä" "Items" .Data.MostProlific "Count" true}}
</div>
{{end}}