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
+11 -11
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"database/sql"
"errors"
"io"
"log/slog"
@@ -12,7 +13,6 @@ import (
"strings"
"time"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
)
@@ -47,12 +47,12 @@ func (a *app) avatarPath(userID int64) string {
// per-song opinion is gated, whole-history aggregate is public.
func (a *app) profile(ctx context.Context, viewerID, userID int64) (*profileView, error) {
var p profileView
err := a.pool.QueryRow(ctx, `
err := a.db.QueryRowContext(ctx, `
select u.id, u.name, u.email, u.avatar, u.created_at,
(select count(*) from songs s where s.submitted_by = u.id),
(select count(*) from reviews r where r.reviewer_id = u.id),
(select avg(r.score)::float from reviews r where r.reviewer_id = u.id),
(select avg(r.score)::float from reviews r
(select avg(r.score) from reviews r where r.reviewer_id = u.id),
(select avg(r.score) from reviews r
join songs s on s.id = r.song_id where s.submitted_by = u.id)
from users u where u.id = $1`, userID).
Scan(&p.ID, &p.Name, &p.Email, &p.Avatar, &p.CreatedAt,
@@ -67,7 +67,7 @@ func (a *app) profile(ctx context.Context, viewerID, userID int64) (*profileView
}
// Their songs, with the viewer's own reveal rule applied to each average.
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 = $2
order by s.created_at desc`, viewerID, userID)
@@ -90,7 +90,7 @@ func (a *app) profilePage(w http.ResponseWriter, r *http.Request) {
id = parsed
}
p, err := a.profile(r.Context(), me.ID, id)
if errors.Is(err, pgx.ErrNoRows) {
if errors.Is(err, sql.ErrNoRows) {
http.NotFound(w, r)
return
} else if err != nil {
@@ -120,7 +120,7 @@ func (a *app) editProfile(w http.ResponseWriter, r *http.Request) {
return
}
if _, err := a.pool.Exec(r.Context(),
if _, err := a.db.ExecContext(r.Context(),
`update users set name = $2, email = $3 where id = $1`, me.ID, name, email); isUnique(err) {
a.flash(w, "Sähköpostiosoite on jo käytössä.")
http.Redirect(w, r, "/profile", http.StatusSeeOther)
@@ -153,7 +153,7 @@ func (a *app) editProfile(w http.ResponseWriter, r *http.Request) {
func (a *app) changePassword(w http.ResponseWriter, r *http.Request, userID int64, current, next string) bool {
var hash string
if err := a.pool.QueryRow(r.Context(),
if err := a.db.QueryRowContext(r.Context(),
`select password_hash from users where id = $1`, userID).Scan(&hash); err != nil {
http.Error(w, "virhe", http.StatusInternalServerError)
return false
@@ -168,13 +168,13 @@ func (a *app) changePassword(w http.ResponseWriter, r *http.Request, userID int6
http.Error(w, "virhe", http.StatusInternalServerError)
return false
}
if _, err := a.pool.Exec(r.Context(),
if _, err := a.db.ExecContext(r.Context(),
`update users set password_hash = $2 where id = $1`, userID, string(newHash)); err != nil {
http.Error(w, "virhe", http.StatusInternalServerError)
return false
}
// Every other session dies; this browser keeps its own.
if _, err := a.pool.Exec(r.Context(),
if _, err := a.db.ExecContext(r.Context(),
`delete from sessions where user_id = $1 and token <> $2`, userID, sessionToken(r)); err != nil {
slog.Error("drop sessions", "ctx", "auth", "error", err, "user", userID)
}
@@ -202,7 +202,7 @@ func (a *app) saveAvatar(r *http.Request, userID int64, file io.Reader) error {
if err := toAvatarJPEG(r.Context(), tmp, out); err != nil {
return err
}
_, err = a.pool.Exec(r.Context(),
_, err = a.db.ExecContext(r.Context(),
`update users set avatar = $2 where id = $1`, userID, filepath.Base(out))
return err
}