Skip to content

Commit f6b4701

Browse files
committed
Track proof feed reactions
1 parent 898d8cc commit f6b4701

16 files changed

Lines changed: 198 additions & 17 deletions

File tree

apps/web/README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Astro-Seite
1414
├─ GET /api/problems/:id-or-slug einzelnes Problem mit privaten User-States
1515
├─ POST /api/problems/:id/events privacy-freundliche View/Share Events
1616
├─ POST /api/problems/:id/signal
17+
├─ POST /api/problems/:id/reaction
1718
├─ POST /api/problems/:id/evidence
1819
├─ POST/DELETE /api/problems/:id/favorite
1920
├─ GET /auth/github GitHub OAuth start
@@ -31,10 +32,11 @@ Astro-Seite
3132
├─ users / identities / sessions
3233
├─ favorites
3334
├─ personal_tokens
34-
└─ problem_events
35+
├─ problem_events
36+
└─ proof_reactions
3537
```
3638

37-
Jedes Problem hat einen kurzen `title`, eine lösungsfreie `statement`-Beschreibung und eine eigene Detailseite unter `/problems/:slug`. `confirmations` und `evidence` speichern pro Problem und internem Account höchstens einen Datensatz. Legacy-Daten mit anonymer Teilnehmer-ID bleiben lesbar. `problem_events` speichert nur aggregierbare View/Share-Signale ohne IP-Adressen, User-Agent-Fingerprinting oder rohe Viewerprofile.
39+
Jedes Problem hat einen kurzen `title`, eine lösungsfreie `statement`-Beschreibung und eine eigene Detailseite unter `/problems/:slug`. `confirmations` und `evidence` speichern pro Problem und internem Account höchstens einen Datensatz. `proof_reactions` trennt die mobilen Feed-Reaktionen `yes`, `not_my_problem` und `skip` pro User/Problem von reinen View/Share-Events. Legacy-Daten mit anonymer Teilnehmer-ID bleiben lesbar. `problem_events` speichert nur aggregierbare View/Share-Signale ohne IP-Adressen, User-Agent-Fingerprinting oder rohe Viewerprofile.
3840

3941
## GitHub Auth und Skill Tokens
4042

@@ -101,6 +103,8 @@ Wrangler deployt den Worker `problemproof` auf die Custom Domain `problemproof.m
101103
"title": "Repo-Reflex vor Problemklärung",
102104
"statement": "",
103105
"confirmations": 38,
106+
"not_my_problem": 5,
107+
"skips": 2,
104108
"incidents": 12,
105109
"average_severity": 3.5,
106110
"views": 120,
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
CREATE TABLE IF NOT EXISTS proof_reactions (
2+
id INTEGER PRIMARY KEY AUTOINCREMENT,
3+
problem_id INTEGER NOT NULL REFERENCES problems(id) ON DELETE CASCADE,
4+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
5+
reaction_type TEXT NOT NULL CHECK (reaction_type IN ('yes', 'not_my_problem', 'skip')),
6+
source TEXT NOT NULL DEFAULT 'proof-feed',
7+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
8+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
9+
UNIQUE (problem_id, user_id)
10+
) STRICT;
11+
12+
CREATE INDEX IF NOT EXISTS idx_proof_reactions_problem_type
13+
ON proof_reactions(problem_id, reaction_type);
14+
15+
CREATE INDEX IF NOT EXISTS idx_proof_reactions_user_updated
16+
ON proof_reactions(user_id, updated_at DESC);
17+
18+
INSERT OR IGNORE INTO proof_reactions (problem_id, user_id, reaction_type, source, created_at, updated_at)
19+
SELECT problem_id, user_id, 'yes', 'confirmation-backfill', created_at, created_at
20+
FROM confirmations
21+
WHERE user_id IS NOT NULL;

apps/web/src/components/ProblemCard.astro

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const reportUrl = `mailto:developer@moinsen.dev?subject=${encodeURIComponent(`Pr
2929
<span>{problem.views_count} Views</span>
3030
<span>{problem.shares_count} Shares</span>
3131
<span>{problem.confirmations_count} Bestätigungen</span>
32+
<span>{problem.not_my_problem_count} Nein</span>
3233
<span>{problem.incidents_count} Vorfälle</span>
3334
</div>
3435
{workarounds.length > 0 && (

apps/web/src/lib/problems.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ const SELECT_PROBLEMS = `
1111
GROUP_CONCAT(DISTINCT NULLIF(e.workaround, '')) AS workarounds,
1212
(SELECT COUNT(*) FROM problem_events ev WHERE ev.problem_id = p.id AND ev.event_type = 'view') AS views_count,
1313
(SELECT COUNT(*) FROM problem_events ev WHERE ev.problem_id = p.id AND ev.event_type = 'share') AS shares_count,
14+
(SELECT COUNT(*) FROM proof_reactions pr WHERE pr.problem_id = p.id AND pr.reaction_type = 'not_my_problem') AS not_my_problem_count,
15+
(SELECT COUNT(*) FROM proof_reactions pr WHERE pr.problem_id = p.id AND pr.reaction_type = 'skip') AS skips_count,
1416
CASE WHEN ? IS NOT NULL AND EXISTS (
1517
SELECT 1 FROM favorites f WHERE f.problem_id = p.id AND f.user_id = ?
1618
) THEN 1 ELSE 0 END AS is_favorite,
@@ -19,14 +21,17 @@ const SELECT_PROBLEMS = `
1921
) THEN 1 ELSE 0 END AS user_confirmed,
2022
CASE WHEN ? IS NOT NULL AND EXISTS (
2123
SELECT 1 FROM evidence ue WHERE ue.problem_id = p.id AND ue.user_id = ?
22-
) THEN 1 ELSE 0 END AS user_incident
24+
) THEN 1 ELSE 0 END AS user_incident,
25+
CASE WHEN ? IS NOT NULL THEN (
26+
SELECT reaction_type FROM proof_reactions ur WHERE ur.problem_id = p.id AND ur.user_id = ? LIMIT 1
27+
) ELSE NULL END AS user_proof_reaction
2328
FROM problems p
2429
LEFT JOIN confirmations c ON c.problem_id = p.id
2530
LEFT JOIN evidence e ON e.problem_id = p.id`;
2631

2732
export async function getProblems(db: D1Database, filters: FeedFilters, userId: number | null = null): Promise<ProblemRow[]> {
2833
const where: string[] = [];
29-
const values: unknown[] = [userId, userId, userId, userId, userId, userId];
34+
const values: unknown[] = [userId, userId, userId, userId, userId, userId, userId, userId];
3035
if (filters.mode === 'needs-proof') where.push("p.proof_status = 'needs-proof'");
3136
if (filters.mode === 'strong') where.push("p.proof_status = 'strong'");
3237
if (filters.mode === 'favorites') where.push('p.id IN (SELECT problem_id FROM favorites WHERE user_id = ?)');
@@ -67,7 +72,7 @@ export async function getProblemByIdentifier(db: D1Database, identifier: string,
6772
WHERE ${where}
6873
GROUP BY p.id
6974
LIMIT 1
70-
`).bind(userId, userId, userId, userId, userId, userId, value).first<ProblemRow>();
75+
`).bind(userId, userId, userId, userId, userId, userId, userId, userId, value).first<ProblemRow>();
7176
return result ?? null;
7277
}
7378

apps/web/src/lib/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ export const ORIGINS = ['firsthand', 'hypothesis'] as const;
22
export const SOURCES = ['web', 'skill', 'api'] as const;
33
export const HAPPENED_OPTIONS = ['7-days', '30-days', '90-days', 'older'] as const;
44
export const FREQUENCIES = ['once', 'monthly', 'weekly', 'daily'] as const;
5+
export const PROOF_REACTIONS = ['yes', 'not_my_problem', 'skip'] as const;
56

67
export type Origin = (typeof ORIGINS)[number];
78
export type Source = (typeof SOURCES)[number];
89
export type Happened = (typeof HAPPENED_OPTIONS)[number];
910
export type Frequency = (typeof FREQUENCIES)[number];
11+
export type ProofReaction = (typeof PROOF_REACTIONS)[number];
1012

1113
export interface ProblemRow {
1214
id: number;
@@ -27,9 +29,12 @@ export interface ProblemRow {
2729
workarounds: string | null;
2830
views_count: number;
2931
shares_count: number;
32+
not_my_problem_count: number;
33+
skips_count: number;
3034
is_favorite: 0 | 1;
3135
user_confirmed: 0 | 1;
3236
user_incident: 0 | 1;
37+
user_proof_reaction: ProofReaction | null;
3338
}
3439

3540
export interface FeedFilters {

apps/web/src/pages/account.astro

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ const myProblems = user ? await env.DB.prepare(`
2121
COUNT(DISTINCT c.id) AS confirmations_count,
2222
COUNT(DISTINCT e.id) AS incidents_count,
2323
(SELECT COUNT(*) FROM problem_events ev WHERE ev.problem_id = p.id AND ev.event_type = 'view') AS views_count,
24-
(SELECT COUNT(*) FROM problem_events ev WHERE ev.problem_id = p.id AND ev.event_type = 'share') AS shares_count
24+
(SELECT COUNT(*) FROM problem_events ev WHERE ev.problem_id = p.id AND ev.event_type = 'share') AS shares_count,
25+
(SELECT COUNT(*) FROM proof_reactions pr WHERE pr.problem_id = p.id AND pr.reaction_type = 'not_my_problem') AS not_my_problem_count
2526
FROM problems p
2627
LEFT JOIN confirmations c ON c.problem_id = p.id
2728
LEFT JOIN evidence e ON e.problem_id = p.id
@@ -43,6 +44,7 @@ const myProblems = user ? await env.DB.prepare(`
4344
incidents_count: number;
4445
views_count: number;
4546
shares_count: number;
47+
not_my_problem_count: number;
4648
}>() : null;
4749
---
4850
@@ -89,6 +91,7 @@ const myProblems = user ? await env.DB.prepare(`
8991
<div><dt>Views</dt><dd>{problem.views_count}</dd></div>
9092
<div><dt>Shares</dt><dd>{problem.shares_count}</dd></div>
9193
<div><dt>Bestätigt</dt><dd>{problem.confirmations_count}</dd></div>
94+
<div><dt>Nein</dt><dd>{problem.not_my_problem_count}</dd></div>
9295
<div><dt>Vorfälle</dt><dd>{problem.incidents_count}</dd></div>
9396
</dl>
9497
</article>

apps/web/src/pages/api/problems/[id]/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,14 @@ export const GET: APIRoute = async ({ params, request, url }) => {
2626
averageSeverity: problem.average_severity,
2727
views: problem.views_count,
2828
shares: problem.shares_count,
29+
notMyProblem: problem.not_my_problem_count,
30+
skips: problem.skips_count,
2931
url: problemUrl(problem, url.origin),
3032
userState: {
3133
favorite: Boolean(problem.is_favorite),
3234
confirmed: Boolean(problem.user_confirmed),
3335
incident: Boolean(problem.user_incident),
36+
proofReaction: problem.user_proof_reaction,
3437
},
3538
});
3639
};
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { APIRoute } from 'astro';
2+
import { env } from 'cloudflare:workers';
3+
import { bearerUser, currentUser } from '../../../../lib/auth';
4+
import { json, readJson, requestError } from '../../../../lib/http';
5+
import { PROOF_REACTIONS, type ProofReaction } from '../../../../lib/types';
6+
7+
const writableReactions = ['not_my_problem', 'skip'] as const satisfies readonly ProofReaction[];
8+
9+
async function reactionCounts(problemId: number) {
10+
return env.DB.prepare(`
11+
SELECT
12+
(SELECT COUNT(*) FROM confirmations WHERE problem_id = ?) AS confirmations,
13+
(SELECT COUNT(*) FROM proof_reactions WHERE problem_id = ? AND reaction_type = 'not_my_problem') AS not_my_problem,
14+
(SELECT COUNT(*) FROM proof_reactions WHERE problem_id = ? AND reaction_type = 'skip') AS skips
15+
`).bind(problemId, problemId, problemId).first<{
16+
confirmations: number;
17+
not_my_problem: number;
18+
skips: number;
19+
}>();
20+
}
21+
22+
export const POST: APIRoute = async ({ params, request }) => {
23+
try {
24+
const user = await currentUser(request, env.DB, env) ?? await bearerUser(request, env.DB, env);
25+
if (!user) return json({ error: 'Bitte melde dich mit GitHub an.' }, { status: 401 });
26+
27+
const problemId = Number(params.id);
28+
if (!Number.isInteger(problemId) || problemId < 1) {
29+
return json({ error: 'Ungültige Problem-Reaktion.' }, { status: 422 });
30+
}
31+
32+
const body = (await readJson(request)) as Record<string, unknown>;
33+
const reaction = typeof body.reaction === 'string' ? body.reaction : '';
34+
if (!PROOF_REACTIONS.includes(reaction as ProofReaction) || !writableReactions.includes(reaction as (typeof writableReactions)[number])) {
35+
return json({ error: 'Ungültige Problem-Reaktion.' }, { status: 422 });
36+
}
37+
38+
const exists = await env.DB.prepare('SELECT 1 FROM problems WHERE id = ?').bind(problemId).first();
39+
if (!exists) return json({ error: 'Problem nicht gefunden.' }, { status: 404 });
40+
41+
const confirmed = await env.DB.prepare('SELECT 1 FROM confirmations WHERE problem_id = ? AND user_id = ?')
42+
.bind(problemId, user.id)
43+
.first();
44+
if (confirmed) {
45+
await env.DB.prepare(`
46+
INSERT OR IGNORE INTO proof_reactions (problem_id, user_id, reaction_type, source)
47+
VALUES (?, ?, 'yes', 'confirmation')
48+
`).bind(problemId, user.id).run();
49+
const counts = await reactionCounts(problemId);
50+
return json({
51+
reaction: 'yes',
52+
ignored: true,
53+
confirmations: counts?.confirmations ?? 0,
54+
notMyProblem: counts?.not_my_problem ?? 0,
55+
skips: counts?.skips ?? 0,
56+
});
57+
}
58+
59+
const source = typeof body.source === 'string' && body.source.trim()
60+
? body.source.trim().slice(0, 40)
61+
: 'proof-feed';
62+
await env.DB.prepare(`
63+
INSERT INTO proof_reactions (problem_id, user_id, reaction_type, source)
64+
VALUES (?, ?, ?, ?)
65+
ON CONFLICT(problem_id, user_id) DO UPDATE SET
66+
reaction_type = excluded.reaction_type,
67+
source = excluded.source,
68+
updated_at = CURRENT_TIMESTAMP
69+
`).bind(problemId, user.id, reaction, source).run();
70+
71+
const counts = await reactionCounts(problemId);
72+
return json({
73+
reaction,
74+
confirmations: counts?.confirmations ?? 0,
75+
notMyProblem: counts?.not_my_problem ?? 0,
76+
skips: counts?.skips ?? 0,
77+
});
78+
} catch (error) {
79+
console.error('record proof reaction failed', error);
80+
return requestError(error);
81+
}
82+
};

apps/web/src/pages/api/problems/[id]/signal.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,24 @@ export const POST: APIRoute = async ({ params, request }) => {
1818
INSERT OR IGNORE INTO confirmations (problem_id, participant_id, region, user_id)
1919
VALUES (?, ?, ?, ?)
2020
`).bind(problemId, participantId, region, user.id).run();
21+
await env.DB.prepare(`
22+
INSERT INTO proof_reactions (problem_id, user_id, reaction_type, source)
23+
VALUES (?, ?, 'yes', 'confirmation')
24+
ON CONFLICT(problem_id, user_id) DO UPDATE SET
25+
reaction_type = 'yes',
26+
source = 'confirmation',
27+
updated_at = CURRENT_TIMESTAMP
28+
`).bind(problemId, user.id).run();
2129
const count = await env.DB.prepare(`
22-
SELECT COUNT(*) AS total FROM confirmations WHERE problem_id = ?
23-
`).bind(problemId).first<{ total: number }>();
24-
return json({ confirmations: count?.total ?? 0 });
30+
SELECT
31+
(SELECT COUNT(*) FROM confirmations WHERE problem_id = ?) AS confirmations,
32+
(SELECT COUNT(*) FROM proof_reactions WHERE problem_id = ? AND reaction_type = 'not_my_problem') AS not_my_problem
33+
`).bind(problemId, problemId).first<{ confirmations: number; not_my_problem: number }>();
34+
return json({
35+
confirmations: count?.confirmations ?? 0,
36+
notMyProblem: count?.not_my_problem ?? 0,
37+
reaction: 'yes',
38+
});
2539
} catch (error) {
2640
console.error('confirm problem failed', error);
2741
return requestError(error);

apps/web/src/pages/api/v1/problems.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ export const GET: APIRoute = async ({ url }) => {
1717
COUNT(DISTINCT e.id) AS incidents,
1818
ROUND(AVG(e.severity), 1) AS average_severity,
1919
(SELECT COUNT(*) FROM problem_events ev WHERE ev.problem_id = p.id AND ev.event_type = 'view') AS views,
20-
(SELECT COUNT(*) FROM problem_events ev WHERE ev.problem_id = p.id AND ev.event_type = 'share') AS shares
20+
(SELECT COUNT(*) FROM problem_events ev WHERE ev.problem_id = p.id AND ev.event_type = 'share') AS shares,
21+
(SELECT COUNT(*) FROM proof_reactions pr WHERE pr.problem_id = p.id AND pr.reaction_type = 'not_my_problem') AS not_my_problem,
22+
(SELECT COUNT(*) FROM proof_reactions pr WHERE pr.problem_id = p.id AND pr.reaction_type = 'skip') AS skips
2123
FROM problems p
2224
LEFT JOIN confirmations c ON c.problem_id = p.id
2325
LEFT JOIN evidence e ON e.problem_id = p.id

0 commit comments

Comments
 (0)