Some checks are pending
CI / validate (push) Waiting to run
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>
379 lines
12 KiB
TypeScript
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;
|
|
}
|