mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +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} />
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { SubscriptionService } from './services/subscriptions';
|
|||
import { PullRequestService } from './services/pull-requests';
|
||||
import { DiscussionService } from './services/discussions';
|
||||
import { PurchaseService } from './services/purchases';
|
||||
import { ModerationService } from './services/moderation';
|
||||
import { createAuthorRoutes } from './routes/authors';
|
||||
import { createDeckRoutes } from './routes/decks';
|
||||
import { createExploreRoutes } from './routes/explore';
|
||||
|
|
@ -32,6 +33,7 @@ import { createSubscriptionRoutes } from './routes/subscriptions';
|
|||
import { createPullRequestRoutes } from './routes/pull-requests';
|
||||
import { createDiscussionRoutes } from './routes/discussions';
|
||||
import { createPurchaseRoutes } from './routes/purchases';
|
||||
import { createModerationRoutes } from './routes/moderation';
|
||||
import { createNotifyClient } from './lib/notify';
|
||||
import { createCreditsClient } from './lib/credits';
|
||||
|
||||
|
|
@ -66,6 +68,7 @@ const purchaseService = new PurchaseService(
|
|||
},
|
||||
notify
|
||||
);
|
||||
const moderationService = new ModerationService(db, notify);
|
||||
|
||||
// ─── App ────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -109,6 +112,7 @@ v1.route('/', createSubscriptionRoutes(subscriptionService));
|
|||
v1.route('/', createPullRequestRoutes(pullRequestService));
|
||||
v1.route('/', createDiscussionRoutes(discussionService));
|
||||
v1.route('/', createPurchaseRoutes(purchaseService));
|
||||
v1.route('/', createModerationRoutes(moderationService));
|
||||
v1.route('/authors', createAuthorRoutes(authorService));
|
||||
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