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>
280 lines
8.4 KiB
TypeScript
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 };
|
|
}
|
|
}
|