Abstimmung Ja/Nein statt Links/Rechts
Modus-Werte yes/no statt left/right in DB, API und Statistik; Knöpfe "Ja"/"Nein". Bestehende Stimmen müssen per SQL migriert werden. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3d2447fe35
commit
b6cf8c7fa0
+9
-9
@@ -187,8 +187,8 @@ func TestCreateEntryRequiresAuth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type tally struct {
|
type tally struct {
|
||||||
Left int `json:"left"`
|
Yes int `json:"yes"`
|
||||||
Right int `json:"right"`
|
No int `json:"no"`
|
||||||
Selected string `json:"selected"`
|
Selected string `json:"selected"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,16 +209,16 @@ func TestVoting(t *testing.T) {
|
|||||||
return tl
|
return tl
|
||||||
}
|
}
|
||||||
|
|
||||||
if got := vote(alice, "left"); got.Left != 1 || got.Selected != "left" {
|
if got := vote(alice, "yes"); got.Yes != 1 || got.Selected != "yes" {
|
||||||
t.Fatalf("alice left: %+v", got)
|
t.Fatalf("alice yes: %+v", got)
|
||||||
}
|
}
|
||||||
if got := vote(bob, "right"); got.Left != 1 || got.Right != 1 {
|
if got := vote(bob, "no"); got.Yes != 1 || got.No != 1 {
|
||||||
t.Fatalf("bob right: %+v", got)
|
t.Fatalf("bob no: %+v", got)
|
||||||
}
|
}
|
||||||
if got := vote(alice, "left"); got.Left != 0 || got.Selected != "none" {
|
if got := vote(alice, "yes"); got.Yes != 0 || got.Selected != "none" {
|
||||||
t.Fatalf("alice toggle off: %+v", got)
|
t.Fatalf("alice toggle off: %+v", got)
|
||||||
}
|
}
|
||||||
if got := vote(alice, "right"); got.Right != 2 || got.Selected != "right" {
|
if got := vote(alice, "no"); got.No != 2 || got.Selected != "no" {
|
||||||
t.Fatalf("alice switch: %+v", got)
|
t.Fatalf("alice switch: %+v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,7 +230,7 @@ func TestVoteRequiresAuthAndValidMode(t *testing.T) {
|
|||||||
voteURL := fmt.Sprintf("%s/api/entry/%d/vote", srv.URL, pid)
|
voteURL := fmt.Sprintf("%s/api/entry/%d/vote", srv.URL, pid)
|
||||||
|
|
||||||
anon := newClient(t)
|
anon := newClient(t)
|
||||||
resp := postForm(t, anon, voteURL, url.Values{"mode": {"left"}})
|
resp := postForm(t, anon, voteURL, url.Values{"mode": {"yes"}})
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusUnauthorized {
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
t.Fatalf("anon vote: erwartet 401, bekam %d", resp.StatusCode)
|
t.Fatalf("anon vote: erwartet 401, bekam %d", resp.StatusCode)
|
||||||
|
|||||||
@@ -540,20 +540,20 @@ func handleBump(w http.ResponseWriter, r *http.Request) error {
|
|||||||
// writeTally schreibt die Zähler und die eigene Auswahl (selected) für einen
|
// writeTally schreibt die Zähler und die eigene Auswahl (selected) für einen
|
||||||
// Beitrag. Ist uid == 0 (nicht eingeloggt), ist selected immer "none".
|
// Beitrag. Ist uid == 0 (nicht eingeloggt), ist selected immer "none".
|
||||||
func writeTally(w http.ResponseWriter, pid, uid int64) error {
|
func writeTally(w http.ResponseWriter, pid, uid int64) error {
|
||||||
var left, right int64
|
var yes, no int64
|
||||||
var selected string
|
var selected string
|
||||||
if err := db.QueryRow(
|
if err := db.QueryRow(
|
||||||
`SELECT COUNT(*) FILTER (WHERE mode = 'left'),
|
`SELECT COUNT(*) FILTER (WHERE mode = 'yes'),
|
||||||
COUNT(*) FILTER (WHERE mode = 'right'),
|
COUNT(*) FILTER (WHERE mode = 'no'),
|
||||||
COALESCE(MAX(mode) FILTER (WHERE uid = $2), 'none')
|
COALESCE(MAX(mode) FILTER (WHERE uid = $2), 'none')
|
||||||
FROM vote WHERE pid = $1`, pid, uid).
|
FROM vote WHERE pid = $1`, pid, uid).
|
||||||
Scan(&left, &right, &selected); err != nil {
|
Scan(&yes, &no, &selected); err != nil {
|
||||||
return Internal(err)
|
return Internal(err)
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"pid": pid,
|
"pid": pid,
|
||||||
"left": left,
|
"yes": yes,
|
||||||
"right": right,
|
"no": no,
|
||||||
"selected": selected,
|
"selected": selected,
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
@@ -585,8 +585,8 @@ func handleVote(w http.ResponseWriter, r *http.Request) error {
|
|||||||
uid := uidFromContext(r.Context())
|
uid := uidFromContext(r.Context())
|
||||||
|
|
||||||
mode := r.FormValue("mode")
|
mode := r.FormValue("mode")
|
||||||
if mode != "left" && mode != "right" {
|
if mode != "yes" && mode != "no" {
|
||||||
return Invalid("vote.bad_mode", "Ungültiger Modus (left oder right)").At("mode")
|
return Invalid("vote.bad_mode", "Ungültiger Modus (yes oder no)").At("mode")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nur auf existierende Beiträge abstimmen, sonst entstehen Waisen-Votes.
|
// Nur auf existierende Beiträge abstimmen, sonst entstehen Waisen-Votes.
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function Votes({ pid }: { pid: number }) {
|
|||||||
let counts = "– / –";
|
let counts = "– / –";
|
||||||
if (vote.isError) counts = errMsg(vote.error, "Fehler");
|
if (vote.isError) counts = errMsg(vote.error, "Fehler");
|
||||||
else if (tally.isError) counts = errMsg(tally.error, "Fehler");
|
else if (tally.isError) counts = errMsg(tally.error, "Fehler");
|
||||||
else if (t) counts = `${t.left} / ${t.right}`;
|
else if (t) counts = `${t.yes} / ${t.no}`;
|
||||||
|
|
||||||
const button = (mode: VoteMode, label: string) => (
|
const button = (mode: VoteMode, label: string) => (
|
||||||
<button
|
<button
|
||||||
@@ -61,9 +61,9 @@ export function Votes({ pid }: { pid: number }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="row">
|
<div className="row">
|
||||||
{button("left", "Links")}
|
{button("yes", "Ja")}
|
||||||
<span className="muted">{counts}</span>
|
<span className="muted">{counts}</span>
|
||||||
{button("right", "Rechts")}
|
{button("no", "Nein")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ export function StatsPage() {
|
|||||||
<NumCard value={s.entries} label="Beiträge" />
|
<NumCard value={s.entries} label="Beiträge" />
|
||||||
<NumCard value={s.toplevel} label="davon Threads" />
|
<NumCard value={s.toplevel} label="davon Threads" />
|
||||||
<NumCard value={s.replies} label="davon Antworten" />
|
<NumCard value={s.replies} label="davon Antworten" />
|
||||||
<NumCard value={s.votes.left} label="Stimmen Links" />
|
<NumCard value={s.votes.yes} label="Stimmen Ja" />
|
||||||
<NumCard value={s.votes.right} label="Stimmen Rechts" />
|
<NumCard value={s.votes.no} label="Stimmen Nein" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ export interface Thread {
|
|||||||
replies: Entry[];
|
replies: Entry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VoteMode = "left" | "right";
|
export type VoteMode = "yes" | "no";
|
||||||
|
|
||||||
export interface VoteTally {
|
export interface VoteTally {
|
||||||
pid: number;
|
pid: number;
|
||||||
left: number;
|
yes: number;
|
||||||
right: number;
|
no: number;
|
||||||
selected: VoteMode | "none";
|
selected: VoteMode | "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ export interface StatsDetail {
|
|||||||
entries: number;
|
entries: number;
|
||||||
toplevel: number;
|
toplevel: number;
|
||||||
replies: number;
|
replies: number;
|
||||||
votes: { left: number; right: number };
|
votes: { yes: number; no: number };
|
||||||
impressions_total: number;
|
impressions_total: number;
|
||||||
visitors_total: number;
|
visitors_total: number;
|
||||||
daily: { day: string; impressions: number; visitors: number }[];
|
daily: { day: string; impressions: number; visitors: number }[];
|
||||||
|
|||||||
+3
-3
@@ -85,12 +85,12 @@ Antwort). Ohne passenden Nachfahren fehlt das Feld.
|
|||||||
|---------|------|------|---------|--------|
|
|---------|------|------|---------|--------|
|
||||||
| GET | `/api/entry/feed/{page}` | – | – | `200 [Entry]` mit `latest`: Threads nach letzter Aktivität, 20 pro Seite, Seite 0–100, `[]` am Ende |
|
| GET | `/api/entry/feed/{page}` | – | – | `200 [Entry]` mit `latest`: Threads nach letzter Aktivität, 20 pro Seite, Seite 0–100, `[]` am Ende |
|
||||||
| GET | `/api/entry/{pid}/thread` | – | – | `200 {"entry", "ancestors": [Entry], "replies": [Entry]}`; `ancestors` Root zuerst, `replies` (max. 100) nach letzter Aktivität, mit `latest` |
|
| GET | `/api/entry/{pid}/thread` | – | – | `200 {"entry", "ancestors": [Entry], "replies": [Entry]}`; `ancestors` Root zuerst, `replies` (max. 100) nach letzter Aktivität, mit `latest` |
|
||||||
| GET | `/api/entry/{pid}/votes` | – | – | `200 {"pid","left","right","selected"}` (`selected`: `left`/`right`/`none`) |
|
| GET | `/api/entry/{pid}/votes` | – | – | `200 {"pid","yes","no","selected"}` (`selected`: `yes`/`no`/`none`) |
|
||||||
| POST | `/api/entry/{pid}/report` | – | `reason` (optional, max. 500) | `200 {"status":"reported"}`. Ohne Session anonym (uid 0); Angemeldete legen keine zweite offene Meldung an; gelöschte Beiträge `404`; 10/min pro IP |
|
| POST | `/api/entry/{pid}/report` | – | `reason` (optional, max. 500) | `200 {"status":"reported"}`. Ohne Session anonym (uid 0); Angemeldete legen keine zweite offene Meldung an; gelöschte Beiträge `404`; 10/min pro IP |
|
||||||
| POST | `/api/entry/create` | ✓ | multipart: `content` (max. 1000), `file` (optional), `reply_to` (optional) | `201 {"pid","content","filepath","reply_to"}` |
|
| POST | `/api/entry/create` | ✓ | multipart: `content` (max. 1000), `file` (optional), `reply_to` (optional) | `201 {"pid","content","filepath","reply_to"}` |
|
||||||
| POST | `/api/entry/{pid}/edit` | ✓ Autor | `content` | `200 {"pid","content"}` |
|
| POST | `/api/entry/{pid}/edit` | ✓ Autor | `content` | `200 {"pid","content"}` |
|
||||||
| POST | `/api/entry/{pid}/delete` | ✓ Autor | – | `200 {"status":"deleted"}` (Soft-Delete, Thread bleibt) |
|
| POST | `/api/entry/{pid}/delete` | ✓ Autor | – | `200 {"status":"deleted"}` (Soft-Delete, Thread bleibt) |
|
||||||
| POST | `/api/entry/{pid}/vote` | ✓ | `mode` = `left`/`right` | `200` wie `votes`. Gleiche Stimme erneut zieht sie zurück |
|
| POST | `/api/entry/{pid}/vote` | ✓ | `mode` = `yes`/`no` | `200` wie `votes`. Gleiche Stimme erneut zieht sie zurück |
|
||||||
| POST | `/api/entry/{pid}/bump` | ✓ | – | `200 {"pid","bump_count","last_bump","last_activity","retry_after"}` |
|
| POST | `/api/entry/{pid}/bump` | ✓ | – | `200 {"pid","bump_count","last_bump","last_activity","retry_after"}` |
|
||||||
|
|
||||||
Beim Bump wächst der Cooldown mit jedem Repost um einen Tag. Im Cooldown
|
Beim Bump wächst der Cooldown mit jedem Repost um einen Tag. Im Cooldown
|
||||||
@@ -119,7 +119,7 @@ TOR-Anfragen auf `create` werden mit `403 tor.blocked` abgewiesen.
|
|||||||
| Methode | Pfad | Erfolg |
|
| Methode | Pfad | Erfolg |
|
||||||
|---------|------|--------|
|
|---------|------|--------|
|
||||||
| GET | `/api/stats` | `200 {"users","entries"}` |
|
| GET | `/api/stats` | `200 {"users","entries"}` |
|
||||||
| GET | `/api/stats/detail` | `200 {"users","entries","toplevel","replies","votes":{"left","right"},"impressions_total","visitors_total","daily":[…],"top_asns":[…]}` |
|
| GET | `/api/stats/detail` | `200 {"users","entries","toplevel","replies","votes":{"yes","no"},"impressions_total","visitors_total","daily":[…],"top_asns":[…]}` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -11,4 +11,4 @@
|
|||||||
- RSS / Atom Feed
|
- RSS / Atom Feed
|
||||||
- Feed ohne JS (früherer SSR-Stand: Tag `ssr-archiv`)
|
- Feed ohne JS (früherer SSR-Stand: Tag `ssr-archiv`)
|
||||||
- robots.txt / Sitemap
|
- robots.txt / Sitemap
|
||||||
- Links/Rechts Auswertung
|
- Ja/Nein Auswertung
|
||||||
|
|||||||
@@ -240,14 +240,14 @@ func handleStats(w http.ResponseWriter, r *http.Request) error {
|
|||||||
// /stats (Tagline, bei jedem Aufruf) darf das hier ruhig mehrere Aggregate
|
// /stats (Tagline, bei jedem Aufruf) darf das hier ruhig mehrere Aggregate
|
||||||
// rechnen. Öffentlich, kein Auth.
|
// rechnen. Öffentlich, kein Auth.
|
||||||
func handleStatsDetail(w http.ResponseWriter, r *http.Request) error {
|
func handleStatsDetail(w http.ResponseWriter, r *http.Request) error {
|
||||||
var users, entries, toplevel, votesLeft, votesRight int64
|
var users, entries, toplevel, votesYes, votesNo int64
|
||||||
if err := db.QueryRow(
|
if err := db.QueryRow(
|
||||||
`SELECT (SELECT COUNT(*) FROM account),
|
`SELECT (SELECT COUNT(*) FROM account),
|
||||||
(SELECT COUNT(*) FROM entry WHERE deleted = 0),
|
(SELECT COUNT(*) FROM entry WHERE deleted = 0),
|
||||||
(SELECT COUNT(*) FROM entry WHERE deleted = 0 AND reply_to = 0),
|
(SELECT COUNT(*) FROM entry WHERE deleted = 0 AND reply_to = 0),
|
||||||
(SELECT COUNT(*) FROM vote WHERE mode = 'left'),
|
(SELECT COUNT(*) FROM vote WHERE mode = 'yes'),
|
||||||
(SELECT COUNT(*) FROM vote WHERE mode = 'right')`).
|
(SELECT COUNT(*) FROM vote WHERE mode = 'no')`).
|
||||||
Scan(&users, &entries, &toplevel, &votesLeft, &votesRight); err != nil {
|
Scan(&users, &entries, &toplevel, &votesYes, &votesNo); err != nil {
|
||||||
return Internal(err)
|
return Internal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +313,7 @@ func handleStatsDetail(w http.ResponseWriter, r *http.Request) error {
|
|||||||
"entries": entries,
|
"entries": entries,
|
||||||
"toplevel": toplevel,
|
"toplevel": toplevel,
|
||||||
"replies": entries - toplevel,
|
"replies": entries - toplevel,
|
||||||
"votes": map[string]any{"left": votesLeft, "right": votesRight},
|
"votes": map[string]any{"yes": votesYes, "no": votesNo},
|
||||||
"impressions_total": imprTotal,
|
"impressions_total": imprTotal,
|
||||||
"visitors_total": visitorsTotal,
|
"visitors_total": visitorsTotal,
|
||||||
"daily": daily,
|
"daily": daily,
|
||||||
|
|||||||
Reference in New Issue
Block a user