Add the submission pipeline and the review loop

Steps 3 and 4 of the build order. A member can now upload a song, watch it
convert, publish it, and review what everyone else has published.

Pipeline:
- ffprobe reads tags synchronously at submit so prefill never races typing;
  ffmpeg converts to Opus in the background, two at a time
- ffmpeg succeeding is the validation — no container sniffing
- publish moves the file inside the transaction, so a song row and its .ogg
  appear together or neither does
- five submissions per rolling 24h, failures excluded

Reviews and the reveal rule:
- the queue is unreviewed songs only, oldest first, never your own
- other people's reviews and the average are withheld in the query, not the
  template — a hidden average is never sent
- 30 minutes to edit or delete your own review, enforced in the WHERE clause
- deleting the last review unlocks the song for its submitter again

The waiting page has one button: the metadata form autosaves after a pause in
typing, and Julkaise submits it and publishes in the same request, so nothing
is lost without JS.

Genres store an English code and render a Finnish label.
This commit is contained in:
Esa Kataja
2026-07-31 21:42:49 +03:00
parent 41c8a2914f
commit 80d3e36679
19 changed files with 2040 additions and 16 deletions
+200
View File
@@ -0,0 +1,200 @@
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/jackc/pgx/v5"
)
const (
editWindow = 30 * time.Minute
maxReview = 5000
)
type review struct {
ID int64
SongID int64
ReviewerID int64
Reviewer string
Score int
Text string
CreatedAt time.Time
UpdatedAt time.Time
Own bool
}
// The window is measured from updated_at, so an edit extends it. It gates deletion as well as
// editing: for 30 minutes a review is yours to change or withdraw, after that it is on the record.
func (r *review) EditableUntil() time.Time { return r.UpdatedAt.Add(editWindow) }
func (r *review) CanEdit() bool { return r.Own && time.Now().Before(r.EditableUntil()) }
func (r *review) Initials() string {
m := member{Name: r.Reviewer}
return m.Initials()
}
func (a *app) reviewsFor(ctx context.Context, songID, viewerID int64) ([]*review, error) {
rows, err := a.pool.Query(ctx, `
select r.id, r.song_id, r.reviewer_id, u.name, r.score, r.text, r.created_at, r.updated_at,
r.reviewer_id = $2
from reviews r join users u on u.id = r.reviewer_id
where r.song_id = $1
order by r.created_at`, songID, viewerID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*review
for rows.Next() {
var v review
if err := rows.Scan(&v.ID, &v.SongID, &v.ReviewerID, &v.Reviewer, &v.Score, &v.Text,
&v.CreatedAt, &v.UpdatedAt, &v.Own); err != nil {
return nil, err
}
out = append(out, &v)
}
return out, rows.Err()
}
func (a *app) viewerReview(ctx context.Context, songID, viewerID int64) (*review, error) {
var v review
err := a.pool.QueryRow(ctx, `
select r.id, r.song_id, r.reviewer_id, u.name, r.score, r.text, r.created_at, r.updated_at, true
from reviews r join users u on u.id = r.reviewer_id
where r.song_id = $1 and r.reviewer_id = $2`, songID, viewerID).
Scan(&v.ID, &v.SongID, &v.ReviewerID, &v.Reviewer, &v.Score, &v.Text,
&v.CreatedAt, &v.UpdatedAt, &v.Own)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return &v, err
}
func reviewInput(r *http.Request) (int, string, string) {
score, _ := strconv.Atoi(r.FormValue("score"))
text := clean(r.FormValue("text"), maxReview)
switch {
case score < 1 || score > 100:
return 0, "", "Pisteiden tulee olla 1100."
case text == "":
return 0, "", "Kirjoita muutama sana."
}
return score, text, ""
}
func (a *app) createReview(w http.ResponseWriter, r *http.Request) {
songID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
me := memberFrom(r.Context())
score, text, problem := reviewInput(r)
if problem != "" {
a.flash(w, problem)
http.Redirect(w, r, fmt.Sprintf("/songs/%d", songID), http.StatusSeeOther)
return
}
// You cannot review your own song, and the unique constraint is what stops a second review —
// no read-then-write race to lose.
var submitter int64
err = a.pool.QueryRow(r.Context(), `select submitted_by from songs where id = $1`, songID).Scan(&submitter)
if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r)
return
} else if err != nil {
slog.Error("load song", "ctx", "reviews", "error", err, "song", songID)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
if submitter == me.ID {
http.Error(w, "omaa kappaletta ei voi arvostella", http.StatusForbidden)
return
}
_, err = a.pool.Exec(r.Context(),
`insert into reviews (song_id, reviewer_id, score, text) values ($1, $2, $3, $4)`,
songID, me.ID, score, text)
if isUnique(err) {
a.flash(w, "Olet jo arvostellut tämän kappaleen.")
http.Redirect(w, r, fmt.Sprintf("/songs/%d", songID), http.StatusSeeOther)
return
} else if err != nil {
slog.Error("create review", "ctx", "reviews", "error", err, "song", songID)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
slog.Info("review written", "ctx", "reviews", "song", songID, "user", me.ID)
a.flash(w, "Arvostelu tallennettu. Nyt näet muidenkin arvostelut.")
http.Redirect(w, r, fmt.Sprintf("/songs/%d", songID), http.StatusSeeOther)
}
// Both edit and delete are gated by the same window, in the same WHERE clause — the database
// decides, so there is no clock-check in Go to get subtly wrong.
func (a *app) editReview(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
score, text, problem := reviewInput(r)
if problem != "" {
a.flash(w, problem)
http.Redirect(w, r, r.FormValue("from"), http.StatusSeeOther)
return
}
var songID int64
err = a.pool.QueryRow(r.Context(), `
update reviews set score = $3, text = $4, updated_at = now()
where id = $1 and reviewer_id = $2 and updated_at > now() - $5::interval
returning song_id`,
id, memberFrom(r.Context()).ID, score, text, editWindow.String()).Scan(&songID)
if errors.Is(err, pgx.ErrNoRows) {
a.flash(w, "Muokkausaika on umpeutunut.")
http.Redirect(w, r, r.FormValue("from"), http.StatusSeeOther)
return
} else if err != nil {
slog.Error("edit review", "ctx", "reviews", "error", err, "review", id)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
a.flash(w, "Arvostelu päivitetty.")
http.Redirect(w, r, fmt.Sprintf("/songs/%d", songID), http.StatusSeeOther)
}
// Deleting the last review unlocks the song for its submitter again — locked is a live state, and
// the 30-minute window is what keeps that from being a rug-pull months later.
func (a *app) deleteReview(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
var songID int64
err = a.pool.QueryRow(r.Context(), `
delete from reviews
where id = $1 and reviewer_id = $2 and updated_at > now() - $3::interval
returning song_id`,
id, memberFrom(r.Context()).ID, editWindow.String()).Scan(&songID)
if errors.Is(err, pgx.ErrNoRows) {
a.flash(w, "Muokkausaika on umpeutunut.")
http.Redirect(w, r, r.FormValue("from"), http.StatusSeeOther)
return
} else if err != nil {
slog.Error("delete review", "ctx", "reviews", "error", err, "review", id)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
slog.Info("review deleted", "ctx", "reviews", "review", id, "song", songID)
a.flash(w, "Arvostelu poistettu.")
http.Redirect(w, r, fmt.Sprintf("/songs/%d", songID), http.StatusSeeOther)
}