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) }