mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:01: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) =>
|
reject: (id: string) =>
|
||||||
request<{ ok: true }>(`/v1/pull-requests/${id}/reject`, { method: 'POST' }),
|
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: {
|
purchases: {
|
||||||
buy: (deckSlug: string) =>
|
buy: (deckSlug: string) =>
|
||||||
request<PurchaseResult>(`/v1/decks/${encodeURIComponent(deckSlug)}/purchase`, {
|
request<PurchaseResult>(`/v1/decks/${encodeURIComponent(deckSlug)}/purchase`, {
|
||||||
|
|
@ -398,6 +429,27 @@ export interface PullRequest {
|
||||||
resolvedAt: string | null;
|
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 {
|
export interface PurchaseResult {
|
||||||
purchase: {
|
purchase: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cardsApi, CardsApiError, type CardDiscussion } from '$lib/api/cards-api';
|
import { cardsApi, CardsApiError, type CardDiscussion } from '$lib/api/cards-api';
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
import ReportButton from './ReportButton.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
contentHash: string;
|
contentHash: string;
|
||||||
|
|
@ -83,16 +84,21 @@
|
||||||
<li class="rounded-lg border border-neutral-800 bg-neutral-900 p-2 text-sm">
|
<li class="rounded-lg border border-neutral-800 bg-neutral-900 p-2 text-sm">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<p class="whitespace-pre-line text-neutral-200">{c.body}</p>
|
<p class="whitespace-pre-line text-neutral-200">{c.body}</p>
|
||||||
{#if authStore.user?.id === c.authorUserId}
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
<button
|
{#if authStore.user?.id !== c.authorUserId}
|
||||||
class="shrink-0 text-xs text-neutral-600 hover:text-red-400"
|
<ReportButton {deckSlug} cardContentHash={c.cardContentHash} variant="icon" />
|
||||||
onclick={() => hide(c)}
|
{/if}
|
||||||
title="Ausblenden"
|
{#if authStore.user?.id === c.authorUserId}
|
||||||
aria-label="Ausblenden"
|
<button
|
||||||
>
|
class="text-xs text-neutral-600 hover:text-red-400"
|
||||||
✕
|
onclick={() => hide(c)}
|
||||||
</button>
|
title="Ausblenden"
|
||||||
{/if}
|
aria-label="Ausblenden"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-neutral-600">
|
<p class="mt-1 text-xs text-neutral-600">
|
||||||
{new Date(c.createdAt).toLocaleString('de-DE')}
|
{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 { cardDeckTable } from '$lib/data/database';
|
||||||
import PullRequestsSection from '$lib/components/PullRequestsSection.svelte';
|
import PullRequestsSection from '$lib/components/PullRequestsSection.svelte';
|
||||||
import DeckCardList from '$lib/components/DeckCardList.svelte';
|
import DeckCardList from '$lib/components/DeckCardList.svelte';
|
||||||
|
import ReportButton from '$lib/components/ReportButton.svelte';
|
||||||
|
|
||||||
const slug = $derived(page.params.slug as string);
|
const slug = $derived(page.params.slug as string);
|
||||||
|
|
||||||
|
|
@ -239,9 +240,18 @@
|
||||||
<p class="mt-3 text-sm text-red-400">{error}</p>
|
<p class="mt-3 text-sm text-red-400">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<p class="mt-10 text-xs text-neutral-500">
|
<div class="mt-10 flex items-center justify-between text-xs text-neutral-500">
|
||||||
Veröffentlicht: {new Date(deck.createdAt).toLocaleDateString('de-DE')}
|
<span>Veröffentlicht: {new Date(deck.createdAt).toLocaleDateString('de-DE')}</span>
|
||||||
</p>
|
{#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}
|
{#if version}
|
||||||
<DeckCardList deckSlug={deck.slug} semver={version.semver} />
|
<DeckCardList deckSlug={deck.slug} semver={version.semver} />
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import { SubscriptionService } from './services/subscriptions';
|
||||||
import { PullRequestService } from './services/pull-requests';
|
import { PullRequestService } from './services/pull-requests';
|
||||||
import { DiscussionService } from './services/discussions';
|
import { DiscussionService } from './services/discussions';
|
||||||
import { PurchaseService } from './services/purchases';
|
import { PurchaseService } from './services/purchases';
|
||||||
|
import { ModerationService } from './services/moderation';
|
||||||
import { createAuthorRoutes } from './routes/authors';
|
import { createAuthorRoutes } from './routes/authors';
|
||||||
import { createDeckRoutes } from './routes/decks';
|
import { createDeckRoutes } from './routes/decks';
|
||||||
import { createExploreRoutes } from './routes/explore';
|
import { createExploreRoutes } from './routes/explore';
|
||||||
|
|
@ -32,6 +33,7 @@ import { createSubscriptionRoutes } from './routes/subscriptions';
|
||||||
import { createPullRequestRoutes } from './routes/pull-requests';
|
import { createPullRequestRoutes } from './routes/pull-requests';
|
||||||
import { createDiscussionRoutes } from './routes/discussions';
|
import { createDiscussionRoutes } from './routes/discussions';
|
||||||
import { createPurchaseRoutes } from './routes/purchases';
|
import { createPurchaseRoutes } from './routes/purchases';
|
||||||
|
import { createModerationRoutes } from './routes/moderation';
|
||||||
import { createNotifyClient } from './lib/notify';
|
import { createNotifyClient } from './lib/notify';
|
||||||
import { createCreditsClient } from './lib/credits';
|
import { createCreditsClient } from './lib/credits';
|
||||||
|
|
||||||
|
|
@ -66,6 +68,7 @@ const purchaseService = new PurchaseService(
|
||||||
},
|
},
|
||||||
notify
|
notify
|
||||||
);
|
);
|
||||||
|
const moderationService = new ModerationService(db, notify);
|
||||||
|
|
||||||
// ─── App ────────────────────────────────────────────────────
|
// ─── App ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -109,6 +112,7 @@ v1.route('/', createSubscriptionRoutes(subscriptionService));
|
||||||
v1.route('/', createPullRequestRoutes(pullRequestService));
|
v1.route('/', createPullRequestRoutes(pullRequestService));
|
||||||
v1.route('/', createDiscussionRoutes(discussionService));
|
v1.route('/', createDiscussionRoutes(discussionService));
|
||||||
v1.route('/', createPurchaseRoutes(purchaseService));
|
v1.route('/', createPurchaseRoutes(purchaseService));
|
||||||
|
v1.route('/', createModerationRoutes(moderationService));
|
||||||
v1.route('/authors', createAuthorRoutes(authorService));
|
v1.route('/authors', createAuthorRoutes(authorService));
|
||||||
v1.route('/decks', createDeckRoutes(authorService, deckService, purchaseService));
|
v1.route('/decks', createDeckRoutes(authorService, deckService, purchaseService));
|
||||||
|
|
||||||
|
|
|
||||||
96
services/cards-server/src/routes/moderation.ts
Normal file
96
services/cards-server/src/routes/moderation.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { AuthUser } from '../middleware/jwt-auth';
|
||||||
|
import type { ModerationService } from '../services/moderation';
|
||||||
|
import { BadRequestError, ForbiddenError, UnauthorizedError } from '../lib/errors';
|
||||||
|
|
||||||
|
function requireUser(user: AuthUser | undefined): AuthUser {
|
||||||
|
if (!user || !user.userId) throw new UnauthorizedError();
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAdmin(user: AuthUser | undefined): AuthUser {
|
||||||
|
const u = requireUser(user);
|
||||||
|
if (u.role !== 'admin') throw new ForbiddenError('Admin role required');
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportSchema = z.object({
|
||||||
|
deckSlug: z.string().min(1),
|
||||||
|
cardContentHash: z.string().min(1).optional(),
|
||||||
|
category: z.enum(['spam', 'copyright', 'nsfw', 'misinformation', 'hate', 'other']),
|
||||||
|
body: z.string().max(2000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolveSchema = z.object({
|
||||||
|
action: z.enum(['dismiss', 'takedown', 'ban-author']),
|
||||||
|
notes: z.string().max(1000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const takedownSchema = z.object({
|
||||||
|
reason: z.string().max(1000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const verifySchema = z.object({
|
||||||
|
verifiedMana: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function createModerationRoutes(service: ModerationService) {
|
||||||
|
const router = new Hono<{ Variables: { user?: AuthUser } }>();
|
||||||
|
|
||||||
|
// User-facing — anyone authed can file a report.
|
||||||
|
router.post('/reports', async (c) => {
|
||||||
|
const user = requireUser(c.get('user'));
|
||||||
|
const parsed = reportSchema.safeParse(await c.req.json().catch(() => ({})));
|
||||||
|
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());
|
||||||
|
const row = await service.createReport(user.userId, parsed.data);
|
||||||
|
return c.json(row, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin inbox + actions.
|
||||||
|
router.get('/admin/reports', async (c) => {
|
||||||
|
requireAdmin(c.get('user'));
|
||||||
|
const list = await service.listOpen();
|
||||||
|
return c.json(list);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/admin/reports/:id/resolve', async (c) => {
|
||||||
|
const admin = requireAdmin(c.get('user'));
|
||||||
|
const parsed = resolveSchema.safeParse(await c.req.json().catch(() => ({})));
|
||||||
|
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());
|
||||||
|
const result = await service.resolveReport(admin.userId, c.req.param('id'), parsed.data);
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/admin/decks/:slug/takedown', async (c) => {
|
||||||
|
const admin = requireAdmin(c.get('user'));
|
||||||
|
const parsed = takedownSchema.safeParse(await c.req.json().catch(() => ({})));
|
||||||
|
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());
|
||||||
|
const result = await service.takedownDeck(
|
||||||
|
admin.userId,
|
||||||
|
c.req.param('slug'),
|
||||||
|
parsed.data.reason
|
||||||
|
);
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/admin/decks/:slug/restore', async (c) => {
|
||||||
|
const admin = requireAdmin(c.get('user'));
|
||||||
|
const result = await service.restoreDeck(admin.userId, c.req.param('slug'));
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/admin/authors/:slug/verify', async (c) => {
|
||||||
|
const admin = requireAdmin(c.get('user'));
|
||||||
|
const parsed = verifySchema.safeParse(await c.req.json().catch(() => ({})));
|
||||||
|
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());
|
||||||
|
const result = await service.setVerifiedMana(
|
||||||
|
admin.userId,
|
||||||
|
c.req.param('slug'),
|
||||||
|
parsed.data.verifiedMana
|
||||||
|
);
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
280
services/cards-server/src/services/moderation.ts
Normal file
280
services/cards-server/src/services/moderation.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
/**
|
||||||
|
* Phase η.1 — User-submitted reports + admin actions.
|
||||||
|
*
|
||||||
|
* Anyone authed can file a report against a deck (optionally scoped
|
||||||
|
* to one card via `cardContentHash`). Admins (`role === 'admin'`)
|
||||||
|
* pull the open inbox, dismiss false positives, take a deck down, or
|
||||||
|
* ban an author. The inbox auto-resolves all open reports for a deck
|
||||||
|
* when a takedown lands so admins don't have to chase duplicates.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { and, desc, eq, isNull } from 'drizzle-orm';
|
||||||
|
import type { Database } from '../db/connection';
|
||||||
|
import {
|
||||||
|
authors,
|
||||||
|
deckPullRequests,
|
||||||
|
deckReports,
|
||||||
|
publicDecks,
|
||||||
|
type reportCategoryEnum,
|
||||||
|
} from '../db/schema';
|
||||||
|
import { BadRequestError, ForbiddenError, NotFoundError } from '../lib/errors';
|
||||||
|
import type { NotifyClient } from '../lib/notify';
|
||||||
|
|
||||||
|
type ReportCategory = (typeof reportCategoryEnum.enumValues)[number];
|
||||||
|
|
||||||
|
export interface CreateReportInput {
|
||||||
|
deckSlug: string;
|
||||||
|
cardContentHash?: string;
|
||||||
|
category: ReportCategory;
|
||||||
|
body?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveReportInput {
|
||||||
|
action: 'dismiss' | 'takedown' | 'ban-author';
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_CATEGORIES = new Set<ReportCategory>([
|
||||||
|
'spam',
|
||||||
|
'copyright',
|
||||||
|
'nsfw',
|
||||||
|
'misinformation',
|
||||||
|
'hate',
|
||||||
|
'other',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export class ModerationService {
|
||||||
|
constructor(
|
||||||
|
private readonly db: Database,
|
||||||
|
private readonly notify?: NotifyClient
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createReport(reporterUserId: string, input: CreateReportInput) {
|
||||||
|
if (!VALID_CATEGORIES.has(input.category)) {
|
||||||
|
throw new BadRequestError(`Unknown report category: ${input.category}`);
|
||||||
|
}
|
||||||
|
const deck = await this.db.query.publicDecks.findFirst({
|
||||||
|
where: eq(publicDecks.slug, input.deckSlug),
|
||||||
|
});
|
||||||
|
if (!deck) throw new NotFoundError('Deck not found');
|
||||||
|
|
||||||
|
const [row] = await this.db
|
||||||
|
.insert(deckReports)
|
||||||
|
.values({
|
||||||
|
deckId: deck.id,
|
||||||
|
versionId: deck.latestVersionId ?? null,
|
||||||
|
cardContentHash: input.cardContentHash ?? null,
|
||||||
|
reporterUserId,
|
||||||
|
category: input.category,
|
||||||
|
body: input.body ?? null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listOpen(limit = 50) {
|
||||||
|
return this.db
|
||||||
|
.select({
|
||||||
|
id: deckReports.id,
|
||||||
|
deckId: deckReports.deckId,
|
||||||
|
deckSlug: publicDecks.slug,
|
||||||
|
deckTitle: publicDecks.title,
|
||||||
|
cardContentHash: deckReports.cardContentHash,
|
||||||
|
reporterUserId: deckReports.reporterUserId,
|
||||||
|
category: deckReports.category,
|
||||||
|
body: deckReports.body,
|
||||||
|
status: deckReports.status,
|
||||||
|
createdAt: deckReports.createdAt,
|
||||||
|
})
|
||||||
|
.from(deckReports)
|
||||||
|
.innerJoin(publicDecks, eq(deckReports.deckId, publicDecks.id))
|
||||||
|
.where(eq(deckReports.status, 'open'))
|
||||||
|
.orderBy(desc(deckReports.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveReport(adminUserId: string, reportId: string, input: ResolveReportInput) {
|
||||||
|
const report = await this.db.query.deckReports.findFirst({
|
||||||
|
where: eq(deckReports.id, reportId),
|
||||||
|
});
|
||||||
|
if (!report) throw new NotFoundError('Report not found');
|
||||||
|
if (report.status !== 'open') {
|
||||||
|
throw new BadRequestError(`Report already ${report.status}`);
|
||||||
|
}
|
||||||
|
const deck = await this.db.query.publicDecks.findFirst({
|
||||||
|
where: eq(publicDecks.id, report.deckId),
|
||||||
|
});
|
||||||
|
if (!deck) throw new NotFoundError('Deck disappeared');
|
||||||
|
|
||||||
|
if (input.action === 'dismiss') {
|
||||||
|
await this.markResolved(reportId, adminUserId, 'dismissed', input.notes);
|
||||||
|
return { action: 'dismissed' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.action === 'takedown') {
|
||||||
|
await this.takedownDeck(adminUserId, deck.slug, input.notes);
|
||||||
|
await this.markResolved(reportId, adminUserId, 'actioned', input.notes);
|
||||||
|
return { action: 'takedown' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.action === 'ban-author') {
|
||||||
|
await this.banAuthor(adminUserId, deck.ownerUserId, input.notes);
|
||||||
|
// A banned author's decks get taken down too — saves a click.
|
||||||
|
await this.takedownDeck(adminUserId, deck.slug, input.notes ?? 'Author banned');
|
||||||
|
await this.markResolved(reportId, adminUserId, 'actioned', input.notes);
|
||||||
|
return { action: 'ban-author' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BadRequestError(`Unknown action: ${input.action as string}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async markResolved(
|
||||||
|
reportId: string,
|
||||||
|
adminUserId: string,
|
||||||
|
status: 'dismissed' | 'actioned',
|
||||||
|
notes: string | undefined
|
||||||
|
) {
|
||||||
|
await this.db
|
||||||
|
.update(deckReports)
|
||||||
|
.set({
|
||||||
|
status,
|
||||||
|
resolvedBy: adminUserId,
|
||||||
|
resolvedAt: new Date(),
|
||||||
|
resolutionNotes: notes ?? null,
|
||||||
|
})
|
||||||
|
.where(eq(deckReports.id, reportId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async takedownDeck(adminUserId: string, deckSlug: string, reason?: string) {
|
||||||
|
const deck = await this.db.query.publicDecks.findFirst({
|
||||||
|
where: eq(publicDecks.slug, deckSlug),
|
||||||
|
});
|
||||||
|
if (!deck) throw new NotFoundError('Deck not found');
|
||||||
|
if (deck.isTakedown) return { alreadyDown: true };
|
||||||
|
|
||||||
|
await this.db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(publicDecks)
|
||||||
|
.set({
|
||||||
|
isTakedown: true,
|
||||||
|
takedownAt: new Date(),
|
||||||
|
takedownReason: reason ?? 'Moderation action',
|
||||||
|
})
|
||||||
|
.where(eq(publicDecks.id, deck.id));
|
||||||
|
|
||||||
|
// Auto-close any other open reports against the same deck.
|
||||||
|
await tx
|
||||||
|
.update(deckReports)
|
||||||
|
.set({
|
||||||
|
status: 'actioned',
|
||||||
|
resolvedBy: adminUserId,
|
||||||
|
resolvedAt: new Date(),
|
||||||
|
resolutionNotes: 'Auto-closed by takedown',
|
||||||
|
})
|
||||||
|
.where(and(eq(deckReports.deckId, deck.id), eq(deckReports.status, 'open')));
|
||||||
|
|
||||||
|
// Open PRs against the deck are no longer mergeable; mark them
|
||||||
|
// closed so authors / contributors see clear state.
|
||||||
|
await tx
|
||||||
|
.update(deckPullRequests)
|
||||||
|
.set({
|
||||||
|
status: 'closed',
|
||||||
|
resolvedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(deckPullRequests.deckId, deck.id), eq(deckPullRequests.status, 'open')));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.notify) {
|
||||||
|
void this.notify.send({
|
||||||
|
channel: 'email',
|
||||||
|
userId: deck.ownerUserId,
|
||||||
|
subject: `Dein Deck „${deck.title}" wurde entfernt`,
|
||||||
|
body: `Dein Deck „${deck.title}" wurde von der Moderation entfernt.${
|
||||||
|
reason ? `\n\nGrund: ${reason}` : ''
|
||||||
|
}\n\nDu hast 30 Tage Zeit, gegen die Entscheidung Einspruch einzulegen.`,
|
||||||
|
data: {
|
||||||
|
type: 'cards.deck.takedown',
|
||||||
|
deckSlug: deck.slug,
|
||||||
|
reason: reason ?? null,
|
||||||
|
},
|
||||||
|
externalId: `cards.deck.takedown.${deck.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { alreadyDown: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async banAuthor(adminUserId: string, targetUserId: string, reason?: string) {
|
||||||
|
const author = await this.db.query.authors.findFirst({
|
||||||
|
where: eq(authors.userId, targetUserId),
|
||||||
|
});
|
||||||
|
if (!author) throw new NotFoundError('Author not found');
|
||||||
|
if (author.bannedAt) return { alreadyBanned: true };
|
||||||
|
|
||||||
|
await this.db
|
||||||
|
.update(authors)
|
||||||
|
.set({ bannedAt: new Date() })
|
||||||
|
.where(eq(authors.userId, targetUserId));
|
||||||
|
|
||||||
|
// Take down every deck owned by the banned author.
|
||||||
|
const banned = await this.db
|
||||||
|
.select({ slug: publicDecks.slug })
|
||||||
|
.from(publicDecks)
|
||||||
|
.where(and(eq(publicDecks.ownerUserId, targetUserId), eq(publicDecks.isTakedown, false)));
|
||||||
|
for (const d of banned) {
|
||||||
|
await this.takedownDeck(adminUserId, d.slug, reason ?? 'Author banned');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { alreadyBanned: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async setVerifiedMana(adminUserId: string, authorSlug: string, verified: boolean) {
|
||||||
|
void adminUserId;
|
||||||
|
const author = await this.db.query.authors.findFirst({
|
||||||
|
where: eq(authors.slug, authorSlug),
|
||||||
|
});
|
||||||
|
if (!author) throw new NotFoundError('Author not found');
|
||||||
|
await this.db
|
||||||
|
.update(authors)
|
||||||
|
.set({ verifiedMana: verified })
|
||||||
|
.where(eq(authors.userId, author.userId));
|
||||||
|
|
||||||
|
if (this.notify) {
|
||||||
|
void this.notify.send({
|
||||||
|
channel: 'email',
|
||||||
|
userId: author.userId,
|
||||||
|
subject: verified ? '🛡️ Du bist jetzt Mana-Verifiziert' : 'Mana-Verifizierung entzogen',
|
||||||
|
body: verified
|
||||||
|
? 'Mana-e.V. hat dich als verifizierten Author bestätigt. Dein Author-Cut steigt von 80% auf 90%.'
|
||||||
|
: 'Deine Mana-Verifizierung wurde entzogen. Bei Fragen: kontakt@mana.how.',
|
||||||
|
data: {
|
||||||
|
type: 'cards.author.verified',
|
||||||
|
authorSlug,
|
||||||
|
verified,
|
||||||
|
},
|
||||||
|
externalId: `cards.author.verified.${author.userId}.${verified ? '1' : '0'}.${Date.now()}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { authorSlug, verifiedMana: verified };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lift a takedown — used during appeals. Reports stay closed.
|
||||||
|
*/
|
||||||
|
async restoreDeck(adminUserId: string, deckSlug: string) {
|
||||||
|
void adminUserId;
|
||||||
|
const deck = await this.db.query.publicDecks.findFirst({
|
||||||
|
where: eq(publicDecks.slug, deckSlug),
|
||||||
|
});
|
||||||
|
if (!deck) throw new NotFoundError('Deck not found');
|
||||||
|
if (!deck.isTakedown) throw new BadRequestError('Deck is not under takedown');
|
||||||
|
|
||||||
|
await this.db
|
||||||
|
.update(publicDecks)
|
||||||
|
.set({ isTakedown: false, takedownAt: null, takedownReason: null })
|
||||||
|
.where(eq(publicDecks.id, deck.id));
|
||||||
|
void isNull;
|
||||||
|
return { restored: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue