Files
Levyraati26_go/reviews.go
T
Esa Kataja 0f15ae0bfc Release 2026.08.02-1
SQLite replaces Postgres, and two fixes from using the thing.

- The database is a file under ./storage instead of a second container. Ten
  members never needed a database server, and the driver is pure Go, so the
  build stays CGO_ENABLED=0 and the dependency count is unchanged. One bind
  mount is now the whole backup: no pgdata, no healthcheck-gated depends_on,
  no startup retry loop. Timestamps are UTC text, idle_ttl is seconds, the
  divisive/unified boards carry their own stddev, and foreign keys are on by
  pragma. Tests get a database file each and run without any setup
- Invites are copied, not clicked. An invite is something to send, and the
  anchor opened the join form in the admin's own browser
- Feedback asks for more than faults: the footer reads "Ongelmia? Ideoita?
  Palautetta?" and the page behind it invites ideas rather than only bugs
- Kuuntele YouTubessa opens in a new tab, so a half-typed review survives it
2026-08-02 20:58:52 +03:00

204 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"time"
)
const (
editWindow = 30 * time.Minute
maxReview = 5000
)
// The same window as a SQLite date modifier, for the two statements that enforce it. SQLite has no
// interval type to bind, so the unit travels in the string.
var editWindowAgo = fmt.Sprintf("-%d seconds", int(editWindow.Seconds()))
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.db.QueryContext(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.db.QueryRowContext(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, sql.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.db.QueryRowContext(r.Context(), `select submitted_by from songs where id = $1`, songID).Scan(&submitter)
if errors.Is(err, sql.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.db.ExecContext(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.db.QueryRowContext(r.Context(), `
update reviews set score = $3, text = $4, updated_at = datetime('now')
where id = $1 and reviewer_id = $2 and updated_at > datetime('now', $5)
returning song_id`,
id, memberFrom(r.Context()).ID, score, text, editWindowAgo).Scan(&songID)
if errors.Is(err, sql.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.db.QueryRowContext(r.Context(), `
delete from reviews
where id = $1 and reviewer_id = $2 and updated_at > datetime('now', $3)
returning song_id`,
id, memberFrom(r.Context()).ID, editWindowAgo).Scan(&songID)
if errors.Is(err, sql.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)
}