Files
Levyraati26_go/reviews.go
T
Esa Kataja 1fe5211ae6 Replace Postgres with SQLite
Ten members and a handful of songs a week never needed a database server,
and the server was the last thing making this a two-container deployment.
modernc.org/sqlite is pure Go, so CGO_ENABLED=0 survives and the dependency
count is unchanged: pgx out, sqlite in.

The port stayed small because the driver matches $1-style placeholders
against argument ordinals exactly as pgx does, so no query needed rewriting
for parameters. What did change:

- timestamptz becomes timestamp holding UTC 'YYYY-MM-DD HH:MM:SS'. The
  declared type is what makes the driver return time.Time, and the
  fixed-width UTC string is what makes ordering and comparison against
  datetime('now') mean what they say.
- interval has no equivalent: sessions.idle_ttl is seconds, and the review
  edit window travels as a SQLite date modifier string.
- No stddev_pop, so the divisive and unified boards spell the population
  formula out, guarded with max(0.0, ...) because cancellation returns a
  tiny negative when every score is identical.
- foreign_keys is off by default, so the cascades only exist because the
  pragma is set on every connection.

Drops the postgres service, its healthcheck, the depends_on gate, the
startup retry loop and POSTGRES_PASSWORD. ./storage is now the whole
backup. Tests get a fresh database file per test and run everywhere
instead of skipping without TEST_DATABASE_URL.
2026-08-02 20:47:41 +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)
}