From c05022611e10412fbe83c28d2e38f54c695e7848 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 7 May 2026 23:24:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(cards):=20Phase=20=CE=B7.1=20=E2=80=94=20R?= =?UTF-8?q?eports=20+=20admin=20moderation=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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): - (icon or inline variant) with category picker + optional explanation. On /d/ as a discreet 🚩 next to the published-date stamp; in per non-own comment. - /d/ 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. --- apps/cards/apps/web/src/lib/api/cards-api.ts | 52 ++++ .../src/lib/components/CardDiscussions.svelte | 26 +- .../src/lib/components/ReportButton.svelte | 141 +++++++++ .../web/src/routes/admin/reports/+page.svelte | 170 +++++++++++ .../apps/web/src/routes/d/[slug]/+page.svelte | 16 +- services/cards-server/src/index.ts | 4 + .../cards-server/src/routes/moderation.ts | 96 ++++++ .../cards-server/src/services/moderation.ts | 280 ++++++++++++++++++ 8 files changed, 772 insertions(+), 13 deletions(-) create mode 100644 apps/cards/apps/web/src/lib/components/ReportButton.svelte create mode 100644 apps/cards/apps/web/src/routes/admin/reports/+page.svelte create mode 100644 services/cards-server/src/routes/moderation.ts create mode 100644 services/cards-server/src/services/moderation.ts diff --git a/apps/cards/apps/web/src/lib/api/cards-api.ts b/apps/cards/apps/web/src/lib/api/cards-api.ts index 2f3c286c4..c00a40489 100644 --- a/apps/cards/apps/web/src/lib/api/cards-api.ts +++ b/apps/cards/apps/web/src/lib/api/cards-api.ts @@ -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('/v1/reports', { method: 'POST', body: input }), + }, + admin: { + listReports: () => request('/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(`/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; diff --git a/apps/cards/apps/web/src/lib/components/CardDiscussions.svelte b/apps/cards/apps/web/src/lib/components/CardDiscussions.svelte index 78f4343ac..4c8ac3d52 100644 --- a/apps/cards/apps/web/src/lib/components/CardDiscussions.svelte +++ b/apps/cards/apps/web/src/lib/components/CardDiscussions.svelte @@ -1,6 +1,7 @@ + +{#if authStore.isAuthenticated} + {#if variant === 'icon'} + + {:else} + + {/if} +{/if} + +{#if open} + +{/if} diff --git a/apps/cards/apps/web/src/routes/admin/reports/+page.svelte b/apps/cards/apps/web/src/routes/admin/reports/+page.svelte new file mode 100644 index 000000000..41b4b3e05 --- /dev/null +++ b/apps/cards/apps/web/src/routes/admin/reports/+page.svelte @@ -0,0 +1,170 @@ + + + + Moderation — Cards + + +
+
+

Moderation-Inbox

+ {#if stage === 'ok'} + + {/if} +
+ + {#if stage === 'loading'} +

Lädt…

+ {:else if stage === 'forbidden' || !isAdmin} +

+ Nur Admins haben Zugang zur Moderation-Inbox. +

+ {:else if stage === 'error'} +

+ {error} +

+ {:else if reports.length === 0} +

+ Keine offenen Reports. +

+ {:else} +
    + {#each reports as r (r.id)} +
  • +
    +
    +
    + + {r.category} + + + {r.deckTitle} + + {#if r.cardContentHash} + · Karte {r.cardContentHash.slice(0, 8)}… + {/if} +
    +

    + {new Date(r.createdAt).toLocaleString('de-DE')} +

    +
    +
    + + {#if r.body} +

    + {r.body} +

    + {/if} + + {#if error} +

    {error}

    + {/if} + +
    + + + +
    +
  • + {/each} +
+ {/if} +
diff --git a/apps/cards/apps/web/src/routes/d/[slug]/+page.svelte b/apps/cards/apps/web/src/routes/d/[slug]/+page.svelte index d7c8b1a89..7a2c2e32d 100644 --- a/apps/cards/apps/web/src/routes/d/[slug]/+page.svelte +++ b/apps/cards/apps/web/src/routes/d/[slug]/+page.svelte @@ -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 @@

{error}

{/if} -

- Veröffentlicht: {new Date(deck.createdAt).toLocaleDateString('de-DE')} -

+
+ Veröffentlicht: {new Date(deck.createdAt).toLocaleDateString('de-DE')} + {#if !isOwner} + + {/if} +
+ + {#if deck.isTakedown} +

+ Dieses Deck wurde von der Moderation entfernt. +

+ {/if} {#if version} diff --git a/services/cards-server/src/index.ts b/services/cards-server/src/index.ts index 6db03357f..8e98754b0 100644 --- a/services/cards-server/src/index.ts +++ b/services/cards-server/src/index.ts @@ -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)); diff --git a/services/cards-server/src/routes/moderation.ts b/services/cards-server/src/routes/moderation.ts new file mode 100644 index 000000000..b5fae5147 --- /dev/null +++ b/services/cards-server/src/routes/moderation.ts @@ -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; +} diff --git a/services/cards-server/src/services/moderation.ts b/services/cards-server/src/services/moderation.ts new file mode 100644 index 000000000..0cb6a096a --- /dev/null +++ b/services/cards-server/src/services/moderation.ts @@ -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([ + '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 }; + } +}