feat(cards): Phase η.1 — Reports + admin moderation actions

Server (cards-server):
- ModerationService: createReport, listOpen, resolveReport,
  takedownDeck, banAuthor, setVerifiedMana, restoreDeck. Takedown
  also auto-closes any sibling open reports against the deck and
  closes any open PRs (so contributors see clean state). Ban
  cascades to all of the author's decks.
- routes/moderation.ts: POST /v1/reports (any authed user),
  GET /v1/admin/reports (admin only), POST /v1/admin/reports/:id/
  resolve, POST /v1/admin/decks/:slug/{takedown,restore}, POST
  /v1/admin/authors/:slug/verify. Admin gate is `role === 'admin'`
  for now — verified-mana-only mods land later.
- Notify hooks: takedown emails the deck owner, mana-verify status
  change emails the author.

Frontend (cards-web):
- <ReportButton> (icon or inline variant) with category picker
  + optional explanation. On /d/<slug> as a discreet 🚩 next to
  the published-date stamp; in <CardDiscussions> per non-own
  comment.
- /d/<slug> shows a red "wurde von der Moderation entfernt" banner
  when isTakedown is true.
- /admin/reports inbox: lists open reports with category badges,
  Abweisen / Deck entfernen / Author bannen actions. Renders a
  forbidden state if the current user isn't admin.
This commit is contained in:
Till JS 2026-05-07 23:24:23 +02:00
parent aeaefaf675
commit c05022611e
8 changed files with 772 additions and 13 deletions

View file

@ -241,6 +241,37 @@ export const cardsApi = {
reject: (id: string) =>
request<{ ok: true }>(`/v1/pull-requests/${id}/reject`, { method: 'POST' }),
},
moderation: {
report: (input: {
deckSlug: string;
cardContentHash?: string;
category: ReportCategory;
body?: string;
}) => request<DeckReport>('/v1/reports', { method: 'POST', body: input }),
},
admin: {
listReports: () => request<DeckReportItem[]>('/v1/admin/reports'),
resolveReport: (id: string, input: { action: ResolveAction; notes?: string }) =>
request<{ action: ResolveAction }>(`/v1/admin/reports/${id}/resolve`, {
method: 'POST',
body: input,
}),
takedownDeck: (slug: string, reason?: string) =>
request<{ alreadyDown: boolean }>(`/v1/admin/decks/${encodeURIComponent(slug)}/takedown`, {
method: 'POST',
body: { reason },
}),
restoreDeck: (slug: string) =>
request<{ restored: boolean }>(`/v1/admin/decks/${encodeURIComponent(slug)}/restore`, {
method: 'POST',
body: {},
}),
verifyAuthor: (slug: string, verifiedMana: boolean) =>
request<{ authorSlug: string; verifiedMana: boolean }>(
`/v1/admin/authors/${encodeURIComponent(slug)}/verify`,
{ method: 'POST', body: { verifiedMana } }
),
},
purchases: {
buy: (deckSlug: string) =>
request<PurchaseResult>(`/v1/decks/${encodeURIComponent(deckSlug)}/purchase`, {
@ -398,6 +429,27 @@ export interface PullRequest {
resolvedAt: string | null;
}
export type ReportCategory = 'spam' | 'copyright' | 'nsfw' | 'misinformation' | 'hate' | 'other';
export type ResolveAction = 'dismiss' | 'takedown' | 'ban-author';
export interface DeckReport {
id: string;
deckId: string;
versionId: string | null;
cardContentHash: string | null;
reporterUserId: string;
category: ReportCategory;
body: string | null;
status: 'open' | 'dismissed' | 'actioned';
createdAt: string;
}
export interface DeckReportItem extends DeckReport {
deckSlug: string;
deckTitle: string;
}
export interface PurchaseResult {
purchase: {
id: string;

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { cardsApi, CardsApiError, type CardDiscussion } from '$lib/api/cards-api';
import { authStore } from '$lib/stores/auth.svelte';
import ReportButton from './ReportButton.svelte';
interface Props {
contentHash: string;
@ -83,16 +84,21 @@
<li class="rounded-lg border border-neutral-800 bg-neutral-900 p-2 text-sm">
<div class="flex items-start justify-between gap-2">
<p class="whitespace-pre-line text-neutral-200">{c.body}</p>
{#if authStore.user?.id === c.authorUserId}
<button
class="shrink-0 text-xs text-neutral-600 hover:text-red-400"
onclick={() => hide(c)}
title="Ausblenden"
aria-label="Ausblenden"
>
</button>
{/if}
<div class="flex shrink-0 items-center gap-2">
{#if authStore.user?.id !== c.authorUserId}
<ReportButton {deckSlug} cardContentHash={c.cardContentHash} variant="icon" />
{/if}
{#if authStore.user?.id === c.authorUserId}
<button
class="text-xs text-neutral-600 hover:text-red-400"
onclick={() => hide(c)}
title="Ausblenden"
aria-label="Ausblenden"
>
</button>
{/if}
</div>
</div>
<p class="mt-1 text-xs text-neutral-600">
{new Date(c.createdAt).toLocaleString('de-DE')}

View file

@ -0,0 +1,141 @@
<script lang="ts">
import { cardsApi, CardsApiError, type ReportCategory } from '$lib/api/cards-api';
import { authStore } from '$lib/stores/auth.svelte';
interface Props {
deckSlug: string;
cardContentHash?: string;
variant?: 'inline' | 'icon';
}
let { deckSlug, cardContentHash, variant = 'inline' }: Props = $props();
let open = $state(false);
let category = $state<ReportCategory>('spam');
let body = $state('');
let busy = $state(false);
let error = $state<string | null>(null);
let done = $state(false);
const CATEGORIES: { value: ReportCategory; label: string }[] = [
{ value: 'spam', label: 'Spam' },
{ value: 'copyright', label: 'Urheberrecht' },
{ value: 'nsfw', label: 'NSFW' },
{ value: 'misinformation', label: 'Falschinformation' },
{ value: 'hate', label: 'Hass' },
{ value: 'other', label: 'Sonstiges' },
];
function close() {
open = false;
error = null;
body = '';
done = false;
}
async function submit() {
busy = true;
error = null;
try {
await cardsApi.moderation.report({
deckSlug,
cardContentHash,
category,
body: body.trim() || undefined,
});
done = true;
setTimeout(close, 1500);
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
} finally {
busy = false;
}
}
</script>
{#if authStore.isAuthenticated}
{#if variant === 'icon'}
<button
class="text-xs text-neutral-600 hover:text-amber-300"
onclick={() => (open = true)}
title="Melden"
aria-label="Melden"
>
🚩
</button>
{:else}
<button class="text-xs text-neutral-500 hover:text-amber-300" onclick={() => (open = true)}>
🚩 Melden
</button>
{/if}
{/if}
{#if open}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4"
role="dialog"
aria-modal="true"
>
<div class="w-full max-w-md rounded-xl border border-neutral-800 bg-neutral-950 p-5">
<header class="mb-4 flex items-center justify-between">
<h2 class="text-base font-semibold">
{cardContentHash ? 'Karte melden' : 'Deck melden'}
</h2>
<button
class="text-neutral-400 hover:text-neutral-100"
onclick={close}
aria-label="Schließen">✕</button
>
</header>
{#if done}
<p
class="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-sm text-emerald-300"
>
Danke — die Moderation prüft den Bericht.
</p>
{:else}
<label class="mb-3 block">
<span class="mb-1 block text-xs text-neutral-400">Kategorie</span>
<select
class="w-full rounded-lg border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm"
bind:value={category}
>
{#each CATEGORIES as c (c.value)}
<option value={c.value}>{c.label}</option>
{/each}
</select>
</label>
<label class="mb-4 block">
<span class="mb-1 block text-xs text-neutral-400">Begründung (optional)</span>
<textarea
class="w-full rounded-lg border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm"
rows="3"
bind:value={body}
placeholder="Was stimmt nicht?"
></textarea>
</label>
{#if error}
<p class="mb-3 text-sm text-red-400">{error}</p>
{/if}
<div class="flex justify-end gap-2">
<button
class="rounded-lg border border-neutral-800 px-4 py-2 text-sm hover:border-neutral-700"
onclick={close}
disabled={busy}>Abbrechen</button
>
<button
class="rounded-lg bg-amber-500 px-4 py-2 text-sm text-amber-950 hover:bg-amber-400 disabled:opacity-50"
onclick={submit}
disabled={busy}
>
{busy ? 'Sende…' : 'Melden'}
</button>
</div>
{/if}
</div>
</div>
{/if}

View file

@ -0,0 +1,170 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
cardsApi,
CardsApiError,
type DeckReportItem,
type ResolveAction,
} from '$lib/api/cards-api';
import { authStore } from '$lib/stores/auth.svelte';
let stage = $state<'loading' | 'forbidden' | 'ok' | 'error'>('loading');
let reports = $state<DeckReportItem[]>([]);
let error = $state<string | null>(null);
let busy = $state<string | null>(null);
const isAdmin = $derived((authStore.user as { role?: string } | undefined)?.role === 'admin');
onMount(load);
async function load() {
stage = 'loading';
try {
reports = await cardsApi.admin.listReports();
stage = 'ok';
} catch (e) {
if (e instanceof CardsApiError && e.status === 403) {
stage = 'forbidden';
return;
}
error = e instanceof CardsApiError ? e.message : (e as Error).message;
stage = 'error';
}
}
async function resolve(report: DeckReportItem, action: ResolveAction) {
const messages: Record<ResolveAction, string> = {
dismiss: 'Diesen Bericht als unbegründet abweisen?',
takedown: `Deck „${report.deckTitle}" entfernen?`,
'ban-author': `Author dieses Decks bannen? (Alle ihre Decks werden entfernt.)`,
};
if (!confirm(messages[action])) return;
const notes =
action === 'dismiss'
? undefined
: (prompt('Notiz für interne Doku (optional):') ?? undefined);
busy = report.id;
try {
await cardsApi.admin.resolveReport(report.id, { action, notes });
reports = reports.filter((r) => r.id !== report.id);
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
} finally {
busy = null;
}
}
function badgeClass(c: DeckReportItem['category']) {
const map: Record<DeckReportItem['category'], string> = {
spam: 'bg-amber-500/15 text-amber-300',
copyright: 'bg-blue-500/15 text-blue-300',
nsfw: 'bg-pink-500/15 text-pink-300',
misinformation: 'bg-violet-500/15 text-violet-300',
hate: 'bg-red-500/15 text-red-300',
other: 'bg-neutral-800 text-neutral-300',
};
return map[c];
}
</script>
<svelte:head>
<title>Moderation — Cards</title>
</svelte:head>
<main class="mx-auto max-w-3xl px-6 py-8">
<header class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-semibold tracking-tight">Moderation-Inbox</h1>
{#if stage === 'ok'}
<button class="text-xs text-neutral-500 hover:text-neutral-200" onclick={load}>
Aktualisieren
</button>
{/if}
</header>
{#if stage === 'loading'}
<p class="py-12 text-center text-sm text-neutral-400">Lädt…</p>
{:else if stage === 'forbidden' || !isAdmin}
<p
class="rounded-xl border border-neutral-800 bg-neutral-900 p-8 text-center text-sm text-neutral-400"
>
Nur Admins haben Zugang zur Moderation-Inbox.
</p>
{:else if stage === 'error'}
<p class="rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-400">
{error}
</p>
{:else if reports.length === 0}
<p
class="rounded-xl border border-neutral-800 bg-neutral-900 p-8 text-center text-sm text-neutral-500"
>
Keine offenen Reports.
</p>
{:else}
<ul class="space-y-3">
{#each reports as r (r.id)}
<li class="rounded-xl border border-neutral-800 bg-neutral-900 p-4">
<header class="mb-2 flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="rounded-full px-2 py-0.5 text-xs {badgeClass(r.category)}">
{r.category}
</span>
<a
href="/d/{r.deckSlug}"
class="truncate text-sm font-medium hover:text-indigo-300"
>
{r.deckTitle}
</a>
{#if r.cardContentHash}
<span class="text-xs text-neutral-500"
>· Karte {r.cardContentHash.slice(0, 8)}</span
>
{/if}
</div>
<p class="mt-1 text-xs text-neutral-500">
{new Date(r.createdAt).toLocaleString('de-DE')}
</p>
</div>
</header>
{#if r.body}
<p
class="mb-3 whitespace-pre-line rounded-lg bg-neutral-950 p-2 text-sm text-neutral-300"
>
{r.body}
</p>
{/if}
{#if error}
<p class="mb-2 text-xs text-red-400">{error}</p>
{/if}
<div class="flex flex-wrap gap-2">
<button
class="rounded-lg border border-neutral-700 px-3 py-1.5 text-xs hover:bg-neutral-800 disabled:opacity-50"
onclick={() => resolve(r, 'dismiss')}
disabled={busy === r.id}
>
Abweisen
</button>
<button
class="rounded-lg bg-amber-500 px-3 py-1.5 text-xs text-amber-950 hover:bg-amber-400 disabled:opacity-50"
onclick={() => resolve(r, 'takedown')}
disabled={busy === r.id}
>
Deck entfernen
</button>
<button
class="rounded-lg bg-red-500 px-3 py-1.5 text-xs text-white hover:bg-red-400 disabled:opacity-50"
onclick={() => resolve(r, 'ban-author')}
disabled={busy === r.id}
>
Author bannen
</button>
</div>
</li>
{/each}
</ul>
{/if}
</main>

View file

@ -12,6 +12,7 @@
import { cardDeckTable } from '$lib/data/database';
import PullRequestsSection from '$lib/components/PullRequestsSection.svelte';
import DeckCardList from '$lib/components/DeckCardList.svelte';
import ReportButton from '$lib/components/ReportButton.svelte';
const slug = $derived(page.params.slug as string);
@ -239,9 +240,18 @@
<p class="mt-3 text-sm text-red-400">{error}</p>
{/if}
<p class="mt-10 text-xs text-neutral-500">
Veröffentlicht: {new Date(deck.createdAt).toLocaleDateString('de-DE')}
</p>
<div class="mt-10 flex items-center justify-between text-xs text-neutral-500">
<span>Veröffentlicht: {new Date(deck.createdAt).toLocaleDateString('de-DE')}</span>
{#if !isOwner}
<ReportButton deckSlug={deck.slug} />
{/if}
</div>
{#if deck.isTakedown}
<p class="mt-3 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-300">
Dieses Deck wurde von der Moderation entfernt.
</p>
{/if}
{#if version}
<DeckCardList deckSlug={deck.slug} semver={version.semver} />