cards/docs/marketplace/archive/code/services/moderation.ts
Till JS 7dbbf63523 Phase 12 R2: Marketplace-Backend α + β — Authors + Deck-Init + Publish
Routes (additiv unter /api/v1/marketplace/*):
- POST/GET /authors/me — eigenes Author-Profil anlegen/updaten/lesen
- GET /authors/:slug — public Profile-Lookup (banned-reason gestrippt)
- POST /decks — Deck-Init (Slug-Validation + Pflicht-Author-Profil +
  CHECK auf paid + Pro-License)
- POST /decks/:slug/publish — Versions-Snapshot mit per-Karte
  cardContentHash aus @cards/domain, per-Version-Hash, AI-Mod-Stub-Log,
  atomarer latest_version_id-Bump in Drizzle-Transaction
- PATCH /decks/:slug — Metadaten-Update (Owner-Only)
- GET /decks/:slug — Public-Detail mit optional-auth-Middleware

Geport aus cards-decommission-base:services/cards-server/, mit
Greenfield-Anpassungen:
- Hashing über @cards/domain.cardContentHash (gemeinsame SoT
  zwischen privatem cards.cards und marketplace.deck_cards), per-
  Version-Hash als SHA-256 über sortierte Karten-Hashes mit Ord-Prefix
- AI-Moderation als R2-Stub (pass+rationale+model='stub'),
  echte mana-llm-Anbindung in späterer Welle
- Auth-Middleware-Shape an Greenfield (userId/tier/authMode in
  c.get(...) statt user-Object), optional-auth als Schwester für
  anonymen Public-Read
- Hono-typing: outer Marketplace-Decks-Router ist Partial<AuthVars>
  weil Public-GET kein JWT braucht; Auth-Subroute ist strict

Lese-Referenz:
- 3331 LOC altes cards-server-Code (routes, services, middleware,
  lib) unter docs/marketplace/archive/code/ archiviert. Read-only,
  nicht im Build-Path.

Verifikation:
- 16 neue Vitest-Tests (Slug + Version-Hash), 72 gesamt grün
- type-check 0 errors
- E2E-Smoke gegen lokale cards-api: Cardecky-Author + Deck
  r2-stoische-ethik mit 3 Karten v1.0.0 (basic + basic + cloze),
  per-Karten-Hashes geschrieben, ai_moderation_log-Row da, semver-409
  + paid-422-Errors verifiziert. Smoke-Daten danach aufgeräumt.

Verbleibend für R3+: Discovery (explore + search), Engagement (stars/
subscribe/fork), Smart-Merge mit FSRS-State-Erhalt; danach R4 PRs +
Card-Discussions, R5 Frontend-Routes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:13:58 +02:00

280 lines
8.4 KiB
TypeScript

/**
* 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 };
}
}