Files
Levyraati26_go/src/reviews.go
T
Esa Kataja b01d08b1e1 Move the package into src/
Thirty-seven entries in the root, most of them .go files. The assets had to
come along: //go:embed cannot reach outside its own directory, so
templates/, static/ and migrations/ live beside the code that embeds them,
and testdata/ beside the test that reads it. storage/ stays put — runtime
data, not source.

go build now needs -o. Without it the output would be named after the
package directory and collide with src/ itself.
2026-09-05 13:53:04 +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)
}