feat(marketplace): Deck-Report + Author-Block + me/decks-Endpoints
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>
This commit is contained in:
Till JS 2026-05-14 02:04:54 +02:00
parent eb39faddb3
commit ff00c7d961
10 changed files with 3291 additions and 4 deletions

View file

@ -0,0 +1,9 @@
CREATE TABLE "marketplace"."author_blocks" (
"blocker_user_id" text NOT NULL,
"blocked_user_id" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "cards"."reviews" ADD COLUMN "prev_snapshot" jsonb DEFAULT 'null'::jsonb;--> statement-breakpoint
CREATE UNIQUE INDEX "author_blocks_pk" ON "marketplace"."author_blocks" USING btree ("blocker_user_id","blocked_user_id");--> statement-breakpoint
CREATE INDEX "author_blocks_blocker_idx" ON "marketplace"."author_blocks" USING btree ("blocker_user_id");

File diff suppressed because it is too large Load diff

View file

@ -22,6 +22,13 @@
"when": 1779075600000, "when": 1779075600000,
"tag": "0002_decks_archived_at", "tag": "0002_decks_archived_at",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1778712867859,
"tag": "0003_serious_peter_parker",
"breakpoints": true
} }
] ]
} }

View file

@ -50,6 +50,7 @@ export {
deckStars, deckStars,
deckSubscriptions, deckSubscriptions,
deckForks, deckForks,
authorBlocks,
deckPullRequests, deckPullRequests,
cardDiscussions, cardDiscussions,
pullRequestStatusEnum, pullRequestStatusEnum,

View file

@ -65,3 +65,26 @@ export const deckForks = marketplaceSchema.table(
sourceIdx: index('deck_forks_source_idx').on(t.sourceDeckId), sourceIdx: index('deck_forks_source_idx').on(t.sourceDeckId),
}) })
); );
/**
* User-zu-User-Block. Wenn `blocker` einen `blocked` blockt, soll der
* `blocker` in keiner Marketplace-Listing- oder Detail-Antwort mehr
* Decks dieses Authors sehen. Pflicht-Komponente für App-Store-
* Guideline 5.1.1(v) (UGC-Block-Mechanismus).
*
* Keyed auf User-IDs (text, plain auth.users-Ref) statt auf
* `authors.userId`, damit auch nicht-author User (z.B. ein Reader,
* der zukünftig Author werden könnte) blockierbar bleiben.
*/
export const authorBlocks = marketplaceSchema.table(
'author_blocks',
{
blockerUserId: text('blocker_user_id').notNull(),
blockedUserId: text('blocked_user_id').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
pk: uniqueIndex('author_blocks_pk').on(t.blockerUserId, t.blockedUserId),
blockerIdx: index('author_blocks_blocker_idx').on(t.blockerUserId),
})
);

View file

@ -23,6 +23,8 @@ import { subscriptionsRouter as marketplaceSubscriptionsRouter } from './routes/
import { forkRouter as marketplaceForkRouter } from './routes/marketplace/fork.ts'; import { forkRouter as marketplaceForkRouter } from './routes/marketplace/fork.ts';
import { pullRequestsRouter as marketplacePullRequestsRouter } from './routes/marketplace/pull-requests.ts'; import { pullRequestsRouter as marketplacePullRequestsRouter } from './routes/marketplace/pull-requests.ts';
import { discussionsRouter as marketplaceDiscussionsRouter } from './routes/marketplace/discussions.ts'; import { discussionsRouter as marketplaceDiscussionsRouter } from './routes/marketplace/discussions.ts';
import { moderationRouter as marketplaceModerationRouter } from './routes/marketplace/moderation.ts';
import { marketplaceMeRouter } from './routes/marketplace/me.ts';
const app = new Hono(); const app = new Hono();
@ -78,6 +80,8 @@ app.route('/api/v1/marketplace', marketplaceSubscriptionsRouter());
app.route('/api/v1/marketplace', marketplaceForkRouter()); app.route('/api/v1/marketplace', marketplaceForkRouter());
app.route('/api/v1/marketplace', marketplacePullRequestsRouter()); app.route('/api/v1/marketplace', marketplacePullRequestsRouter());
app.route('/api/v1/marketplace', marketplaceDiscussionsRouter()); app.route('/api/v1/marketplace', marketplaceDiscussionsRouter());
app.route('/api/v1/marketplace', marketplaceModerationRouter());
app.route('/api/v1/marketplace/me', marketplaceMeRouter());
app.route('/api/v1/marketplace/authors', marketplaceAuthorsRouter()); app.route('/api/v1/marketplace/authors', marketplaceAuthorsRouter());
app.route('/api/v1/marketplace/decks', marketplaceDecksRouter()); app.route('/api/v1/marketplace/decks', marketplaceDecksRouter());

View file

@ -7,6 +7,7 @@ import { cardContentHash } from '@cards/domain';
import { getDb, type CardsDb } from '../../db/connection.ts'; import { getDb, type CardsDb } from '../../db/connection.ts';
import { import {
aiModerationLog, aiModerationLog,
authorBlocks,
authors, authors,
publicDeckCards, publicDeckCards,
publicDeckVersions, publicDeckVersions,
@ -118,6 +119,24 @@ export function marketplaceDecksRouter(
const [deck] = await db.select().from(publicDecks).where(eq(publicDecks.slug, slug)).limit(1); 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) 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([ const [latestVersion, ownerRow] = await Promise.all([
deck.latestVersionId deck.latestVersionId
? db ? db

View file

@ -72,7 +72,8 @@ interface DeckListEntry {
async function browseImpl( async function browseImpl(
db: CardsDb, db: CardsDb,
filter: z.infer<typeof BrowseQuerySchema> filter: z.infer<typeof BrowseQuerySchema>,
signedInUserId?: string,
): Promise<{ items: DeckListEntry[]; total: number }> { ): Promise<{ items: DeckListEntry[]; total: number }> {
const limit = filter.limit ?? 20; const limit = filter.limit ?? 20;
const offset = filter.offset ?? 0; const offset = filter.offset ?? 0;
@ -98,6 +99,14 @@ async function browseImpl(
sql`EXISTS (SELECT 1 FROM marketplace.deck_tags dt JOIN marketplace.tag_definitions td ON td.id = dt.tag_id WHERE dt.deck_id = ${publicDecks.id} AND td.slug = ${filter.tag})` sql`EXISTS (SELECT 1 FROM marketplace.deck_tags dt JOIN marketplace.tag_definitions td ON td.id = dt.tag_id WHERE dt.deck_id = ${publicDecks.id} AND td.slug = ${filter.tag})`
); );
} }
// Block-Filter: wenn der anfragende User Authors blockiert hat,
// werden deren Decks aus dem Listing geworfen. Reine App-Store-
// Guideline-5.1.1(v)-Compliance — UGC-Block muss in Listings wirken.
if (signedInUserId) {
conditions.push(
sql`NOT EXISTS (SELECT 1 FROM marketplace.author_blocks ab WHERE ab.blocker_user_id = ${signedInUserId} AND ab.blocked_user_id = ${publicDecks.ownerUserId})`
);
}
const starCount = sql<number>`(SELECT count(*)::int FROM marketplace.deck_stars s WHERE s.deck_id = ${publicDecks.id})`; const starCount = sql<number>`(SELECT count(*)::int FROM marketplace.deck_stars s WHERE s.deck_id = ${publicDecks.id})`;
const subscriberCount = sql<number>`(SELECT count(*)::int FROM marketplace.deck_subscriptions s WHERE s.deck_id = ${publicDecks.id})`; const subscriberCount = sql<number>`(SELECT count(*)::int FROM marketplace.deck_subscriptions s WHERE s.deck_id = ${publicDecks.id})`;
@ -180,11 +189,12 @@ export function exploreRouter(
// GET /explore — Featured + Trending Side-by-Side. // GET /explore — Featured + Trending Side-by-Side.
r.get('/explore', async (c) => { r.get('/explore', async (c) => {
const db = dbOf(); const db = dbOf();
const userId = c.get('userId') as string | undefined;
const [featured, trending] = await Promise.all([ const [featured, trending] = await Promise.all([
browseImpl(db, { sort: 'popular', limit: 8 }).then((r) => browseImpl(db, { sort: 'popular', limit: 8 }, userId).then((r) =>
r.items.filter((d) => d.is_featured).slice(0, 8) r.items.filter((d) => d.is_featured).slice(0, 8)
), ),
browseImpl(db, { sort: 'trending', limit: 8 }), browseImpl(db, { sort: 'trending', limit: 8 }, userId),
]); ]);
return c.json({ featured, trending: trending.items }); return c.json({ featured, trending: trending.items });
}); });
@ -198,7 +208,8 @@ export function exploreRouter(
422 422
); );
} }
const result = await browseImpl(dbOf(), parsed.data); const userId = c.get('userId') as string | undefined;
const result = await browseImpl(dbOf(), parsed.data, userId);
return c.json(result); return c.json(result);
}); });

View file

@ -0,0 +1,104 @@
import { desc, eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { getDb, type CardsDb } from '../../db/connection.ts';
import { authMiddleware, type AuthVars } from '../../middleware/auth.ts';
import {
authorBlocks,
authors,
publicDeckVersions,
publicDecks,
} from '../../db/schema/index.ts';
/**
* Marketplace-Endpoints aus Sicht des angemeldeten Users:
*
* - `GET /me/decks` eigene Marketplace-Decks mit aktueller Version.
* Wird vom Native-Re-Publish-Flow benutzt, um zu erkennen ob ein
* privater Deck schon ein Marketplace-Pendant hat.
* - `GET /me/blocks` Liste der eigenen Author-Blocks (für die
* Block-Verwaltung in der App).
*/
export type MarketplaceMeDeps = { db?: CardsDb };
export function marketplaceMeRouter(deps: MarketplaceMeDeps = {}): Hono<{ Variables: AuthVars }> {
const r = new Hono<{ Variables: AuthVars }>();
const dbOf = () => deps.db ?? getDb();
r.use('*', authMiddleware);
r.get('/decks', async (c) => {
const userId = c.get('userId');
const db = dbOf();
const rows = await db
.select({
slug: publicDecks.slug,
title: publicDecks.title,
description: publicDecks.description,
language: publicDecks.language,
category: publicDecks.category,
license: publicDecks.license,
priceCredits: publicDecks.priceCredits,
isTakedown: publicDecks.isTakedown,
createdAt: publicDecks.createdAt,
latestSemver: publicDeckVersions.semver,
latestCardCount: publicDeckVersions.cardCount,
latestVersionId: publicDeckVersions.id,
latestPublishedAt: publicDeckVersions.publishedAt,
})
.from(publicDecks)
.leftJoin(publicDeckVersions, eq(publicDeckVersions.id, publicDecks.latestVersionId))
.where(eq(publicDecks.ownerUserId, userId))
.orderBy(desc(publicDecks.createdAt));
return c.json({
decks: rows.map((row) => ({
slug: row.slug,
title: row.title,
description: row.description,
language: row.language,
category: row.category,
license: row.license,
price_credits: row.priceCredits,
is_takedown: row.isTakedown,
created_at: row.createdAt.toISOString(),
latest_version: row.latestVersionId
? {
version_id: row.latestVersionId,
semver: row.latestSemver,
card_count: row.latestCardCount,
published_at: row.latestPublishedAt?.toISOString() ?? null,
}
: null,
})),
});
});
r.get('/blocks', async (c) => {
const userId = c.get('userId');
const db = dbOf();
const rows = await db
.select({
slug: authors.slug,
displayName: authors.displayName,
createdAt: authorBlocks.createdAt,
})
.from(authorBlocks)
.innerJoin(authors, eq(authors.userId, authorBlocks.blockedUserId))
.where(eq(authorBlocks.blockerUserId, userId))
.orderBy(desc(authorBlocks.createdAt));
return c.json({
blocks: rows.map((row) => ({
author_slug: row.slug,
display_name: row.displayName,
blocked_at: row.createdAt.toISOString(),
})),
});
});
return r;
}

View file

@ -0,0 +1,137 @@
import { and, eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { z } from 'zod';
import { getDb, type CardsDb } from '../../db/connection.ts';
import { authMiddleware, type AuthVars } from '../../middleware/auth.ts';
import { rateLimit, userKey } from '../../middleware/rate-limit.ts';
import { authorBlocks, authors, deckReports, publicDecks } from '../../db/schema/index.ts';
/**
* Moderation-Endpoints App-Review-Pflicht für User-Generated-Content
* (App-Store-Guideline 5.1.1(v)). Aus Cardecky-Native erreichbar als
* Melden" und „Author blockieren".
*
* - `POST /decks/:slug/report` Meldung zu einem Deck. Idempotent
* pro (deck, reporter, category) Doppel-Reports geben 200 mit
* `already_reported: true` zurück.
* - `POST /authors/:slug/block` Author blockieren. Decks des
* Authors verschwinden aus den Marketplace-Listings für den
* blockenden User. Idempotent.
* - `DELETE /authors/:slug/block` Block aufheben.
* - `GET /me/blocks` eigene Block-Liste.
*/
export type ModerationDeps = { db?: CardsDb };
const ReportSchema = z.object({
category: z.enum(['spam', 'copyright', 'nsfw', 'misinformation', 'hate', 'other']),
body: z.string().max(1000).optional(),
versionId: z.string().uuid().optional(),
cardContentHash: z.string().max(128).optional(),
});
export function moderationRouter(deps: ModerationDeps = {}): Hono<{ Variables: AuthVars }> {
const r = new Hono<{ Variables: AuthVars }>();
const dbOf = () => deps.db ?? getDb();
r.use('*', authMiddleware);
r.use('/decks/:slug/report', rateLimit({ scope: 'marketplace.report', windowMs: 60_000, max: 10, keyOf: userKey }));
r.use('/authors/:slug/block', rateLimit({ scope: 'marketplace.block', windowMs: 60_000, max: 30, keyOf: userKey }));
r.post('/decks/:slug/report', async (c) => {
const userId = c.get('userId');
const slug = c.req.param('slug');
const body = await c.req.json().catch(() => null);
const parsed = ReportSchema.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);
// Owner darf das eigene Deck nicht melden.
if (deck.ownerUserId === userId) {
return c.json({ error: 'cannot_report_own_deck' }, 422);
}
// Idempotent: gibt es schon einen offenen Report von diesem User
// mit gleicher Kategorie? Dann nur „already_reported" zurückgeben,
// kein zweites Insert.
const [existing] = await db
.select()
.from(deckReports)
.where(
and(
eq(deckReports.deckId, deck.id),
eq(deckReports.reporterUserId, userId),
eq(deckReports.category, parsed.data.category),
eq(deckReports.status, 'open'),
),
)
.limit(1);
if (existing) {
return c.json({ ok: true, already_reported: true, report_id: existing.id });
}
const [row] = await db
.insert(deckReports)
.values({
deckId: deck.id,
versionId: parsed.data.versionId,
cardContentHash: parsed.data.cardContentHash,
reporterUserId: userId,
category: parsed.data.category,
body: parsed.data.body,
})
.returning();
return c.json({ ok: true, already_reported: false, report_id: row.id }, 201);
});
r.post('/authors/:slug/block', async (c) => {
const userId = c.get('userId');
const slug = c.req.param('slug');
const db = dbOf();
const [author] = await db.select().from(authors).where(eq(authors.slug, slug)).limit(1);
if (!author) return c.json({ error: 'not_found' }, 404);
if (author.userId === userId) {
return c.json({ error: 'cannot_block_self' }, 422);
}
await db
.insert(authorBlocks)
.values({ blockerUserId: userId, blockedUserId: author.userId })
.onConflictDoNothing({ target: [authorBlocks.blockerUserId, authorBlocks.blockedUserId] });
return c.json({ ok: true });
});
r.delete('/authors/:slug/block', async (c) => {
const userId = c.get('userId');
const slug = c.req.param('slug');
const db = dbOf();
const [author] = await db.select().from(authors).where(eq(authors.slug, slug)).limit(1);
if (!author) return c.json({ error: 'not_found' }, 404);
await db
.delete(authorBlocks)
.where(
and(
eq(authorBlocks.blockerUserId, userId),
eq(authorBlocks.blockedUserId, author.userId),
),
);
return c.json({ ok: true });
});
return r;
}