Split voting into clean read/write endpoints

Replaces the GET-mutates-state interactions route with a proper split:
- GET  /entry/{pid}/votes  reads the tally (public, read-only)
- POST /entry/{pid}/vote   casts/toggles the vote (auth, mode in body)

Shared voteTally/writeTally helpers back both handlers. Invalid mode now
returns 400. Frontend updated to read via GET and vote via POST.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 11:03:33 +02:00
parent be8dbb4902
commit 38da498754
4 changed files with 96 additions and 64 deletions
+65 -43
View File
@@ -106,58 +106,23 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) {
})
}
// handleInteractions liest den Abstimmungsstand eines Beitrags und schaltet bei
// mode=left/right die Stimme des angemeldeten Nutzers um:
// - keine bisherige Stimme -> neue Stimme
// - gleiche Stimme erneut -> Stimme zurückziehen (Toggle)
// - andere Stimme -> auf den neuen Modus wechseln
// mode=none liest nur (z. B. beim Laden des Feeds).
func handleInteractions(w http.ResponseWriter, r *http.Request) {
pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige pid")
return
}
mode := chi.URLParam(r, "mode")
var uid int64
if s, ok := getSession(r); ok {
uid = s.UID
}
if mode == "left" || mode == "right" {
if uid == 0 {
writeError(w, http.StatusUnauthorized, "Melde dich an, um abzustimmen!")
return
}
var prev string
err := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&prev)
switch {
case err == sql.ErrNoRows:
db.Exec(`INSERT INTO vote (uid, pid, mode) VALUES (?, ?, ?)`, uid, pid, mode)
case err == nil && prev == mode:
db.Exec(`DELETE FROM vote WHERE uid = ? AND pid = ?`, uid, pid)
case err == nil:
db.Exec(`UPDATE vote SET mode = ? WHERE uid = ? AND pid = ?`, mode, uid, pid)
default:
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
return
}
}
selected := "none"
// voteTally liefert die Zähler und die eigene Auswahl (selected) für einen Beitrag.
// Ist uid == 0 (nicht eingeloggt), ist selected immer "none".
func voteTally(pid, uid int64) (left, right int, selected string) {
selected = "none"
if uid != 0 {
var cur string
if err := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&cur); err == nil {
selected = cur
}
}
var left, right int
db.QueryRow(`SELECT COUNT(*) FROM vote WHERE pid = ? AND mode = 'left'`, pid).Scan(&left)
db.QueryRow(`SELECT COUNT(*) FROM vote WHERE pid = ? AND mode = 'right'`, pid).Scan(&right)
return
}
func writeTally(w http.ResponseWriter, pid, uid int64) {
left, right, selected := voteTally(pid, uid)
writeJSON(w, http.StatusOK, map[string]any{
"pid": pid,
"left": left,
@@ -166,6 +131,63 @@ func handleInteractions(w http.ResponseWriter, r *http.Request) {
})
}
// handleVotes liest den Abstimmungsstand eines Beitrags (read-only, öffentlich).
func handleVotes(w http.ResponseWriter, r *http.Request) {
pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige pid")
return
}
var uid int64
if s, ok := getSession(r); ok {
uid = s.UID
}
writeTally(w, pid, uid)
}
// handleVote gibt die Stimme des angemeldeten Nutzers ab oder schaltet sie um:
// - keine bisherige Stimme -> neue Stimme
// - gleiche Stimme erneut -> Stimme zurückziehen (Toggle)
// - andere Stimme -> auf den neuen Modus wechseln
func handleVote(w http.ResponseWriter, r *http.Request) {
pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige pid")
return
}
uid := uidFromContext(r.Context())
mode := r.FormValue("mode")
if mode != "left" && mode != "right" {
writeError(w, http.StatusBadRequest, "Ungültiger Modus (left oder right)")
return
}
var prev string
readErr := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&prev)
if readErr != nil && readErr != sql.ErrNoRows {
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
return
}
var execErr error
switch {
case readErr == sql.ErrNoRows:
_, execErr = db.Exec(`INSERT INTO vote (uid, pid, mode) VALUES (?, ?, ?)`, uid, pid, mode)
case prev == mode:
_, execErr = db.Exec(`DELETE FROM vote WHERE uid = ? AND pid = ?`, uid, pid)
default:
_, execErr = db.Exec(`UPDATE vote SET mode = ? WHERE uid = ? AND pid = ?`, mode, uid, pid)
}
if execErr != nil {
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
return
}
writeTally(w, pid, uid)
}
// storeImage skaliert ein hochgeladenes Bild auf max. 1024px und speichert es unter static/media.
// Hinweis: animierte GIFs werden derzeit auf das erste Frame reduziert.
func storeImage(fh *multipart.FileHeader) (string, error) {