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
+17 -18
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
@@ -9,8 +10,6 @@ import (
"os"
"strconv"
"time"
"github.com/jackc/pgx/v5"
)
const pageSize = 20
@@ -52,12 +51,12 @@ const songColumns = `
(select count(*) from reviews r where r.song_id = s.id),
case when s.submitted_by = $1
or exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)
then (select avg(r.score)::float from reviews r where r.song_id = s.id)
then (select avg(r.score) from reviews r where r.song_id = s.id)
end,
s.submitted_by = $1,
exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)`
func scanSongs(rows pgx.Rows) ([]*songSummary, error) {
func scanSongs(rows *sql.Rows) ([]*songSummary, error) {
defer rows.Close()
var out []*songSummary
for rows.Next() {
@@ -74,7 +73,7 @@ func scanSongs(rows pgx.Rows) ([]*songSummary, error) {
// The queue is a worklist: songs you can still review, oldest first, and never your own — you can
// never act on those, so they would sit at the front forever.
func (a *app) queue(ctx context.Context, viewerID, cursor int64) (*songList, error) {
rows, err := a.pool.Query(ctx, `select`+songColumns+`
rows, err := a.db.QueryContext(ctx, `select`+songColumns+`
from songs s join users u on u.id = s.submitted_by
where s.submitted_by <> $1
and not exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)
@@ -93,7 +92,7 @@ func (a *app) queue(ctx context.Context, viewerID, cursor int64) (*songList, err
// Everything, newest first. This is where a song lives once it has left the queue.
func (a *app) browse(ctx context.Context, viewerID, cursor int64) (*songList, error) {
rows, err := a.pool.Query(ctx, `select`+songColumns+`
rows, err := a.db.QueryContext(ctx, `select`+songColumns+`
from songs s join users u on u.id = s.submitted_by
where ($2 = 0 or s.id < $2)
order by s.created_at desc, s.id desc
@@ -166,7 +165,7 @@ func (s *songDetail) LyricLines() []lyricLine { return parseLRC(s.Lyrics) }
func (a *app) song(ctx context.Context, viewerID, songID int64) (*songDetail, error) {
var d songDetail
err := a.pool.QueryRow(ctx, `select`+songColumns+`,
err := a.db.QueryRowContext(ctx, `select`+songColumns+`,
coalesce(s.description, ''), coalesce(s.lyrics, ''), s.source_url
from songs s join users u on u.id = s.submitted_by
where s.id = $2`, viewerID, songID).
@@ -207,13 +206,13 @@ func (a *app) song(ctx context.Context, viewerID, songID int64) (*songDetail, er
// draining the queue never means navigating back to it.
func (a *app) nextInQueue(ctx context.Context, viewerID, exceptID int64) (int64, error) {
var id int64
err := a.pool.QueryRow(ctx, `
err := a.db.QueryRowContext(ctx, `
select s.id from songs s
where s.submitted_by <> $1 and s.id <> $2
and not exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)
order by s.created_at, s.id
limit 1`, viewerID, exceptID).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
return id, err
@@ -226,7 +225,7 @@ func (a *app) songPage(w http.ResponseWriter, r *http.Request) {
return
}
d, err := a.song(r.Context(), memberFrom(r.Context()).ID, id)
if errors.Is(err, pgx.ErrNoRows) {
if errors.Is(err, sql.ErrNoRows) {
http.NotFound(w, r)
return
} else if err != nil {
@@ -246,7 +245,7 @@ func (a *app) editLyrics(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
tag, err := a.pool.Exec(r.Context(),
res, err := a.db.ExecContext(r.Context(),
`update songs set lyrics = nullif($3, '') where id = $1 and submitted_by = $2`,
id, memberFrom(r.Context()).ID, cleanLyrics(r.FormValue("lyrics")))
if err != nil {
@@ -254,7 +253,7 @@ func (a *app) editLyrics(w http.ResponseWriter, r *http.Request) {
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
if tag.RowsAffected() == 0 {
if affected(res) == 0 {
http.NotFound(w, r)
return
}
@@ -284,7 +283,7 @@ func (a *app) editSong(w http.ResponseWriter, r *http.Request) {
return
}
tag, err := a.pool.Exec(r.Context(), `
res, err := a.db.ExecContext(r.Context(), `
update songs set title = $3, artist = $4, genre = $5, description = nullif($6, '')
where id = $1 and submitted_by = $2
and not exists (select 1 from reviews r where r.song_id = songs.id)`,
@@ -295,7 +294,7 @@ func (a *app) editSong(w http.ResponseWriter, r *http.Request) {
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
if tag.RowsAffected() == 0 {
if affected(res) == 0 {
a.flash(w, "Kappaletta ei voi enää muokata — sitä on jo arvosteltu.")
} else {
a.flash(w, "Tiedot tallennettu.")
@@ -310,7 +309,7 @@ func (a *app) deleteSong(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
tag, err := a.pool.Exec(r.Context(), `
res, err := a.db.ExecContext(r.Context(), `
delete from songs where id = $1 and submitted_by = $2
and not exists (select 1 from reviews r where r.song_id = songs.id)`,
id, memberFrom(r.Context()).ID)
@@ -319,7 +318,7 @@ func (a *app) deleteSong(w http.ResponseWriter, r *http.Request) {
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
if tag.RowsAffected() == 0 {
if affected(res) == 0 {
a.flash(w, "Kappaletta ei voi enää poistaa — sitä on jo arvosteltu.")
http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther)
return
@@ -348,8 +347,8 @@ func (a *app) audio(w http.ResponseWriter, r *http.Request) {
return
}
var name string
err = a.pool.QueryRow(r.Context(), `select audio_file from songs where id = $1`, id).Scan(&name)
if errors.Is(err, pgx.ErrNoRows) {
err = a.db.QueryRowContext(r.Context(), `select audio_file from songs where id = $1`, id).Scan(&name)
if errors.Is(err, sql.ErrNoRows) {
http.NotFound(w, r)
return
} else if err != nil {