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) => 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;

View file

@ -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')}

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 { 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} />

View file

@ -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));

View 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;
}

View 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 };
}
}