mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
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:
parent
aeaefaf675
commit
c05022611e
8 changed files with 772 additions and 13 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
141
apps/cards/apps/web/src/lib/components/ReportButton.svelte
Normal file
141
apps/cards/apps/web/src/lib/components/ReportButton.svelte
Normal 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}
|
||||
170
apps/cards/apps/web/src/routes/admin/reports/+page.svelte
Normal file
170
apps/cards/apps/web/src/routes/admin/reports/+page.svelte
Normal 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>
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue