cards/apps/api/src/routes/marketplace/decks.ts
Till JS ff00c7d961
Some checks are pending
CI / validate (push) Waiting to run
feat(marketplace): Deck-Report + Author-Block + me/decks-Endpoints
Cardecky-Marketplace bekommt die App-Store-Guideline-5.1.1(v)-
Pflicht-Komponenten für User-Generated-Content: User können einzelne
Decks melden und Authors blockieren. Plus `GET /me/decks` für den
Native-Re-Publish-Flow.

Schema (Migration 0003)
- Neue Tabelle `marketplace.author_blocks (blocker_user_id,
  blocked_user_id, created_at)` mit Unique-Index auf dem Tupel
- `deckReports` lag schon im Schema, jetzt erstmals durch Routes
  erreichbar

Routes
- POST /api/v1/marketplace/decks/:slug/report — auth, 10/min Rate-
  Limit, Kategorie-Enum (spam, copyright, nsfw, misinformation, hate,
  other), optional `body` ≤ 1000 Zeichen. Idempotent pro (deck,
  reporter, category): doppeltes Melden liefert `already_reported:
  true` ohne Fehler. Owner darf eigenes Deck nicht melden.
- POST /api/v1/marketplace/authors/:slug/block — idempotent
  (onConflictDoNothing). Self-Block geht 422.
- DELETE /api/v1/marketplace/authors/:slug/block
- GET /api/v1/marketplace/me/blocks — eigene Block-Liste mit
  display_name + blocked_at
- GET /api/v1/marketplace/me/decks — eigene Marketplace-Decks mit
  latest_version (semver, card_count, published_at). Native nutzt das
  für die „Neue Version"-Auswahl im Publish-Flow

Listing-Filter
- explore.ts: `browseImpl` nimmt `signedInUserId?` und filtert
  blockierte Author-Decks per `NOT EXISTS`. Wirkt auf /explore +
  /decks (Browse mit Filtern)
- decks.ts: `GET /:slug` returnt 404 wenn der Viewer den Author
  blockiert hat — bewusst 404 statt 403, UGC-Block soll ohne Hinweis
  auf den Block wirken

Mount: zwei neue Router auf /api/v1/marketplace (moderation) und
/api/v1/marketplace/me. 104/104 Vitest-Tests grün.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 02:04:54 +02:00

379 lines
12 KiB
TypeScript

import { and, eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { z } from 'zod';
import { cardContentHash } from '@cards/domain';
import { getDb, type CardsDb } from '../../db/connection.ts';
import {
aiModerationLog,
authorBlocks,
authors,
publicDeckCards,
publicDeckVersions,
publicDecks,
} from '../../db/schema/index.ts';
import { authMiddleware, type AuthVars } from '../../middleware/auth.ts';
import { optionalAuthMiddleware } from '../../middleware/marketplace/optional-auth.ts';
import { moderateDeckContent } from '../../lib/marketplace/ai-moderation.ts';
import { toPublicDeckDto, toOwnerDto, toVersionDto } from '../../lib/marketplace/dto.ts';
import { validateSlug } from '../../lib/marketplace/slug.ts';
import { hashVersionCards } from '../../lib/marketplace/version-hash.ts';
/**
* Deck-Routen für den Marketplace.
*
* - `POST /` — Deck-Init (Slug, Titel, optionale Lizenz/Preis).
* - `GET /:slug` — Public-Deck-Detail mit `latest_version`.
* Optional-Auth: signed-in User sieht später
* zusätzlich Subscribe/Star-State (R3).
* - `POST /:slug/publish` — Neue Version publishen. Nur Owner.
* Karten + Hashes + AI-Mod-Log + atomarer
* Bump des `latest_version_id`.
* - `PATCH /:slug` — Metadaten-Update. Nur Owner.
*
* Geschichte: ported aus
* `cards-decommission-base:services/cards-server/src/{routes,services}/decks.ts`,
* mit Greenfield-Anpassungen (Hashing über `@cards/domain`,
* AI-Mod-Stub statt mana-llm-Call, Auth-Vars-Shape).
*/
export type MarketplaceDecksDeps = { db?: CardsDb };
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
const MarketplaceCategorySchema = z.enum([
'language', 'medicine', 'science', 'math', 'history',
'law', 'technology', 'arts', 'music', 'sport', 'other',
]);
const InitSchema = z.object({
slug: z.string(),
title: z.string().min(1).max(120),
description: z.string().max(2000).optional(),
language: z
.string()
.regex(/^[a-z]{2}$/, 'language must be ISO-639-1 (e.g. de, en)')
.optional(),
license: z.string().max(60).optional(),
priceCredits: z.number().int().min(0).max(100_000).optional(),
category: MarketplaceCategorySchema.optional(),
});
const PatchSchema = z.object({
title: z.string().min(1).max(120).optional(),
description: z.string().max(2000).optional(),
language: z.string().regex(/^[a-z]{2}$/).optional(),
license: z.string().max(60).optional(),
priceCredits: z.number().int().min(0).max(100_000).optional(),
category: MarketplaceCategorySchema.optional(),
});
const CardTypeSchema = z.enum([
'basic',
'basic-reverse',
'cloze',
'type-in',
'image-occlusion',
'audio',
'multiple-choice',
]);
const PublishSchema = z.object({
semver: z.string().regex(SEMVER_RE, 'semver must look like 1.0.0'),
changelog: z.string().max(2000).optional(),
cards: z
.array(
z.object({
type: CardTypeSchema,
fields: z.record(z.string()),
})
)
.min(1, 'A version needs at least one card'),
});
function semverGreater(a: string, b: string): boolean {
const ma = a.match(SEMVER_RE);
const mb = b.match(SEMVER_RE);
if (!ma || !mb) return false;
for (let i = 1; i <= 3; i++) {
const da = Number.parseInt(ma[i], 10);
const db = Number.parseInt(mb[i], 10);
if (da > db) return true;
if (da < db) return false;
}
return false;
}
export function marketplaceDecksRouter(
deps: MarketplaceDecksDeps = {}
): Hono<{ Variables: Partial<AuthVars> }> {
const r = new Hono<{ Variables: Partial<AuthVars> }>();
const dbOf = () => deps.db ?? getDb();
// Path-scoped Middleware: GET /:slug ist optional-auth (anonymer
// Read erlaubt), alles andere strict-auth.
r.get('/:slug', optionalAuthMiddleware, async (c) => {
const slug = c.req.param('slug');
const db = dbOf();
const [deck] = await db.select().from(publicDecks).where(eq(publicDecks.slug, slug)).limit(1);
if (!deck) return c.json({ error: 'not_found' }, 404);
// Block-Filter: wenn der angemeldete User den Author blockiert
// hat, behandeln wir das Deck wie nicht-existent. Bewusst 404
// statt 403 — UGC-Block soll ohne Hinweis auf den Block wirken.
const viewerId = c.get('userId') as string | undefined;
if (viewerId && viewerId !== deck.ownerUserId) {
const [block] = await db
.select({ blockedUserId: authorBlocks.blockedUserId })
.from(authorBlocks)
.where(
and(
eq(authorBlocks.blockerUserId, viewerId),
eq(authorBlocks.blockedUserId, deck.ownerUserId),
),
)
.limit(1);
if (block) return c.json({ error: 'not_found' }, 404);
}
const [latestVersion, ownerRow] = await Promise.all([
deck.latestVersionId
? db
.select()
.from(publicDeckVersions)
.where(eq(publicDeckVersions.id, deck.latestVersionId))
.limit(1)
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
db.select().from(authors).where(eq(authors.userId, deck.ownerUserId)).limit(1)
.then((rows) => rows[0] ?? null),
]);
return c.json({
deck: toPublicDeckDto(deck),
latest_version: latestVersion ? toVersionDto(latestVersion) : null,
owner: ownerRow ? toOwnerDto(ownerRow) : null,
});
});
// Authenticated endpoints — per-route authMiddleware statt Sub-
// Router-Mount, damit das Wildcard nicht die Public-GET-Routes
// fängt.
// POST / — Deck-Init.
r.post('/', authMiddleware, async (c) => {
const userId = c.get('userId');
const body = await c.req.json().catch(() => null);
const parsed = InitSchema.safeParse(body);
if (!parsed.success) {
return c.json(
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
422
);
}
const validation = validateSlug(parsed.data.slug);
if (!validation.ok) {
return c.json({ error: 'invalid_slug', reason: validation.reason }, 422);
}
const license = parsed.data.license ?? 'Cardecky-Personal-Use-1.0';
const priceCredits = parsed.data.priceCredits ?? 0;
if (priceCredits > 0 && license !== 'Cardecky-Pro-Only-1.0') {
return c.json(
{
error: 'paid_decks_require_pro_license',
detail: 'priceCredits > 0 ⇒ license must be Cardecky-Pro-Only-1.0',
},
422
);
}
const db = dbOf();
// Author-Profil-Pflicht — kein Decksanlegen ohne authors-Row.
const [author] = await db.select().from(authors).where(eq(authors.userId, userId)).limit(1);
if (!author) {
return c.json(
{ error: 'no_author_profile', detail: 'POST /api/v1/marketplace/authors/me first' },
400
);
}
if (author.bannedAt) {
return c.json({ error: 'author_banned', reason: author.bannedReason ?? 'no_reason' }, 403);
}
const [bySlug] = await db
.select()
.from(publicDecks)
.where(eq(publicDecks.slug, parsed.data.slug))
.limit(1);
if (bySlug) return c.json({ error: 'slug_taken' }, 409);
const [created] = await db
.insert(publicDecks)
.values({
slug: parsed.data.slug,
title: parsed.data.title,
description: parsed.data.description,
language: parsed.data.language,
category: parsed.data.category,
license,
priceCredits,
ownerUserId: userId,
})
.returning();
return c.json(toPublicDeckDto(created), 201);
});
// PATCH /:slug — Metadaten.
r.patch('/:slug', authMiddleware, async (c) => {
const userId = c.get('userId');
const slug = c.req.param('slug');
const body = await c.req.json().catch(() => null);
const parsed = PatchSchema.safeParse(body);
if (!parsed.success) {
return c.json(
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
422
);
}
const db = dbOf();
const [deck] = await db.select().from(publicDecks).where(eq(publicDecks.slug, slug)).limit(1);
if (!deck) return c.json({ error: 'not_found' }, 404);
if (deck.ownerUserId !== userId) return c.json({ error: 'forbidden' }, 403);
if (deck.isTakedown) return c.json({ error: 'takedown_active' }, 403);
const license = parsed.data.license ?? deck.license;
const priceCredits = parsed.data.priceCredits ?? deck.priceCredits;
if (priceCredits > 0 && license !== 'Cardecky-Pro-Only-1.0') {
return c.json({ error: 'paid_decks_require_pro_license' }, 422);
}
const [updated] = await db
.update(publicDecks)
.set({
...(parsed.data.title !== undefined && { title: parsed.data.title }),
...(parsed.data.description !== undefined && { description: parsed.data.description }),
...(parsed.data.language !== undefined && { language: parsed.data.language }),
...(parsed.data.category !== undefined && { category: parsed.data.category }),
...(parsed.data.license !== undefined && { license }),
...(parsed.data.priceCredits !== undefined && { priceCredits }),
})
.where(and(eq(publicDecks.id, deck.id)))
.returning();
return c.json(toPublicDeckDto(updated));
});
// POST /:slug/publish — neue Version.
r.post('/:slug/publish', authMiddleware, async (c) => {
const userId = c.get('userId');
const slug = c.req.param('slug');
const body = await c.req.json().catch(() => null);
const parsed = PublishSchema.safeParse(body);
if (!parsed.success) {
return c.json(
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
422
);
}
const db = dbOf();
const [deck] = await db.select().from(publicDecks).where(eq(publicDecks.slug, slug)).limit(1);
if (!deck) return c.json({ error: 'not_found' }, 404);
if (deck.ownerUserId !== userId) return c.json({ error: 'forbidden' }, 403);
if (deck.isTakedown) return c.json({ error: 'takedown_active' }, 403);
// Semver muss strikt größer als latest sein — lineare History.
if (deck.latestVersionId) {
const [latest] = await db
.select()
.from(publicDeckVersions)
.where(eq(publicDeckVersions.id, deck.latestVersionId))
.limit(1);
if (latest && !semverGreater(parsed.data.semver, latest.semver)) {
return c.json(
{ error: 'semver_not_greater', latest: latest.semver, got: parsed.data.semver },
409
);
}
}
// 1) AI-Mod (Stub in R2).
const moderation = await moderateDeckContent({
title: deck.title,
description: deck.description ?? undefined,
cards: parsed.data.cards.map((card) => ({ fields: card.fields })),
});
if (moderation.verdict === 'block') {
return c.json(
{ error: 'moderation_block', categories: moderation.categories, rationale: moderation.rationale },
403
);
}
// 2) Hashes berechnen.
const cardsWithOrd = parsed.data.cards.map((card, i) => ({ ...card, ord: i }));
const versionContentHash = await hashVersionCards(cardsWithOrd);
const cardHashes = await Promise.all(
cardsWithOrd.map((card) => cardContentHash({ type: card.type, fields: card.fields }))
);
// 3) Insert Version + Cards + AI-Log + flip latest_version_id atomar.
const result = await db.transaction(async (tx) => {
const [version] = await tx
.insert(publicDeckVersions)
.values({
deckId: deck.id,
semver: parsed.data.semver,
changelog: parsed.data.changelog,
contentHash: versionContentHash,
cardCount: cardsWithOrd.length,
})
.returning();
await tx.insert(publicDeckCards).values(
cardsWithOrd.map((card, i) => ({
versionId: version.id,
type: card.type,
fields: card.fields,
ord: card.ord,
contentHash: cardHashes[i],
}))
);
await tx.insert(aiModerationLog).values({
versionId: version.id,
verdict: moderation.verdict,
categories: moderation.categories,
model: moderation.model,
rationale: moderation.rationale,
});
const [updatedDeck] = await tx
.update(publicDecks)
.set({ latestVersionId: version.id })
.where(eq(publicDecks.id, deck.id))
.returning();
return { deck: updatedDeck, version };
});
return c.json(
{
deck: toPublicDeckDto(result.deck),
version: toVersionDto(result.version),
moderation: {
verdict: moderation.verdict,
categories: moderation.categories,
model: moderation.model,
},
},
201
);
});
return r;
}