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
This commit is contained in:
Esa Kataja
2026-08-02 20:58:52 +03:00
parent 400b5d3833
commit 0f15ae0bfc
33 changed files with 480 additions and 396 deletions
+20 -17
View File
@@ -2,14 +2,13 @@ package main
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/jackc/pgx/v5"
)
const (
@@ -17,6 +16,10 @@ const (
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
@@ -40,7 +43,7 @@ func (r *review) Initials() string {
}
func (a *app) reviewsFor(ctx context.Context, songID, viewerID int64) ([]*review, error) {
rows, err := a.pool.Query(ctx, `
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
@@ -64,13 +67,13 @@ func (a *app) reviewsFor(ctx context.Context, songID, viewerID int64) ([]*review
func (a *app) viewerReview(ctx context.Context, songID, viewerID int64) (*review, error) {
var v review
err := a.pool.QueryRow(ctx, `
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, pgx.ErrNoRows) {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return &v, err
@@ -105,8 +108,8 @@ func (a *app) createReview(w http.ResponseWriter, r *http.Request) {
// 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) {
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 {
@@ -119,7 +122,7 @@ func (a *app) createReview(w http.ResponseWriter, r *http.Request) {
return
}
_, err = a.pool.Exec(r.Context(),
_, 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) {
@@ -153,12 +156,12 @@ func (a *app) editReview(w http.ResponseWriter, r *http.Request) {
}
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
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, editWindow.String()).Scan(&songID)
if errors.Is(err, pgx.ErrNoRows) {
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
@@ -180,12 +183,12 @@ func (a *app) deleteReview(w http.ResponseWriter, r *http.Request) {
return
}
var songID int64
err = a.pool.QueryRow(r.Context(), `
err = a.db.QueryRowContext(r.Context(), `
delete from reviews
where id = $1 and reviewer_id = $2 and updated_at > now() - $3::interval
where id = $1 and reviewer_id = $2 and updated_at > datetime('now', $3)
returning song_id`,
id, memberFrom(r.Context()).ID, editWindow.String()).Scan(&songID)
if errors.Is(err, pgx.ErrNoRows) {
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