package main import ( "context" "log/slog" "net/http" "strconv" "strings" "time" ) const maxReportBody = 2000 type report struct { ID int64 Body string Page string UserAgent string Reporter string ResolvedAt *time.Time CreatedAt time.Time } func (r *report) Open() bool { return r.ResolvedAt == nil } type reportPage struct { From string Mine []*report } // Free text and nothing else. No category, no priority, no severity — with ten users a sentence and // a page URL beat a taxonomy nobody fills in honestly. func (a *app) reportPage(w http.ResponseWriter, r *http.Request) { mine, err := a.myReports(r.Context(), memberFrom(r.Context()).ID) if err != nil { slog.Error("list reports", "ctx", "reports", "error", err) http.Error(w, "virhe", http.StatusInternalServerError) return } from := r.URL.Query().Get("from") if !strings.HasPrefix(from, "/") { from = "/" // never redirect off-site on the strength of a query parameter } a.render(w, r, http.StatusOK, "report.html", page{Title: "Palaute", Data: reportPage{From: from, Mine: mine}}) } // Seeing your own past reports is what stops the same bug arriving four times. func (a *app) myReports(ctx context.Context, userID int64) ([]*report, error) { rows, err := a.db.QueryContext(ctx, ` select id, body, coalesce(page, ''), resolved_at, created_at from reports where user_id = $1 order by created_at desc`, userID) if err != nil { return nil, err } defer rows.Close() var out []*report for rows.Next() { var rep report if err := rows.Scan(&rep.ID, &rep.Body, &rep.Page, &rep.ResolvedAt, &rep.CreatedAt); err != nil { return nil, err } out = append(out, &rep) } return out, rows.Err() } func (a *app) createReport(w http.ResponseWriter, r *http.Request) { me := memberFrom(r.Context()) body := clean(r.FormValue("body"), maxReportBody) from := r.FormValue("from") if !strings.HasPrefix(from, "/") { from = "/" } if body == "" { a.flash(w, "Kirjoita muutama sana siitä, mikä meni pieleen.") http.Redirect(w, r, "/report?from="+from, http.StatusSeeOther) return } // "Only on my phone" is the most common bug report and this answers it without asking. _, err := a.db.ExecContext(r.Context(), ` insert into reports (user_id, body, page, user_agent) values ($1, $2, nullif($3, ''), $4)`, me.ID, body, from, clean(r.Header.Get("User-Agent"), 300)) if err != nil { slog.Error("create report", "ctx", "reports", "error", err) http.Error(w, "virhe", http.StatusInternalServerError) return } slog.Info("report filed", "ctx", "reports", "user", me.ID) a.flash(w, "Kiitos! Palaute on perillä.") http.Redirect(w, r, from, http.StatusSeeOther) } // --- admin --- func (a *app) adminReports(w http.ResponseWriter, r *http.Request) { rows, err := a.db.QueryContext(r.Context(), ` select rep.id, rep.body, coalesce(rep.page, ''), coalesce(rep.user_agent, ''), u.name, rep.resolved_at, rep.created_at from reports rep join users u on u.id = rep.user_id order by rep.resolved_at nulls first, rep.created_at desc`) if err != nil { adminError(w, "reports", err) return } defer rows.Close() var out []*report for rows.Next() { var rep report if err := rows.Scan(&rep.ID, &rep.Body, &rep.Page, &rep.UserAgent, &rep.Reporter, &rep.ResolvedAt, &rep.CreatedAt); err != nil { adminError(w, "reports", err) return } out = append(out, &rep) } if err := rows.Err(); err != nil { adminError(w, "reports", err) return } a.render(w, r, http.StatusOK, "admin_reports.html", page{Title: "Palautteet", Admin: true, Data: out}) } // A nullable timestamp rather than a status enum: smaller, and it tells you *when*. func (a *app) resolveReport(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { http.NotFound(w, r) return } if _, err := a.db.ExecContext(r.Context(), `update reports set resolved_at = case when resolved_at is null then datetime('now') end where id = $1`, id); err != nil { adminError(w, "reports", err) return } http.Redirect(w, r, "/admin/reports", http.StatusSeeOther) } // The admin deletes a song unconditionally — a separate route from the submitter's, rather than one // route with a branch. The row and the file go together here too. func (a *app) adminDeleteSong(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { http.NotFound(w, r) return } res, err := a.db.ExecContext(r.Context(), `delete from songs where id = $1`, id) if err != nil { adminError(w, "songs", err) return } if affected(res) > 0 { removeFile(a.audioPath(id)) slog.Info("song deleted by admin", "ctx", "songs", "song", id) a.flash(w, "Kappale poistettu.") } http.Redirect(w, r, "/admin", http.StatusSeeOther) } type adminSong struct { ID int64 Title string Artist string Submitter string Reviews int CreatedAt time.Time } func (a *app) adminSongs(ctx context.Context) ([]adminSong, error) { rows, err := a.db.QueryContext(ctx, ` select s.id, s.title, s.artist, u.name, (select count(*) from reviews r where r.song_id = s.id), s.created_at from songs s join users u on u.id = s.submitted_by order by s.created_at desc`) if err != nil { return nil, err } defer rows.Close() var out []adminSong for rows.Next() { var s adminSong if err := rows.Scan(&s.ID, &s.Title, &s.Artist, &s.Submitter, &s.Reviews, &s.CreatedAt); err != nil { return nil, err } out = append(out, s) } return out, rows.Err() } // Moderating a complaint means listening to the song, so the admin surface has its own audio route // rather than branching auth inside the member handler. func (a *app) adminAudio(w http.ResponseWriter, r *http.Request) { a.audio(w, r) }