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.
204 lines
6.6 KiB
Go
204 lines
6.6 KiB
Go
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 1–100."
|
||
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)
|
||
}
|