feat(marketplace): Deck-Report + Author-Block + me/decks-Endpoints
Some checks are pending
CI / validate (push) Waiting to run
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:
parent
eb39faddb3
commit
ff00c7d961
10 changed files with 3291 additions and 4 deletions
9
apps/api/src/db/migrations/0003_serious_peter_parker.sql
Normal file
9
apps/api/src/db/migrations/0003_serious_peter_parker.sql
Normal 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");
|
||||
2972
apps/api/src/db/migrations/meta/0003_snapshot.json
Normal file
2972
apps/api/src/db/migrations/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -22,6 +22,13 @@
|
|||
"when": 1779075600000,
|
||||
"tag": "0002_decks_archived_at",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1778712867859,
|
||||
"tag": "0003_serious_peter_parker",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -50,6 +50,7 @@ export {
|
|||
deckStars,
|
||||
deckSubscriptions,
|
||||
deckForks,
|
||||
authorBlocks,
|
||||
deckPullRequests,
|
||||
cardDiscussions,
|
||||
pullRequestStatusEnum,
|
||||
|
|
|
|||
|
|
@ -65,3 +65,26 @@ export const deckForks = marketplaceSchema.table(
|
|||
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),
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import { subscriptionsRouter as marketplaceSubscriptionsRouter } from './routes/
|
|||
import { forkRouter as marketplaceForkRouter } from './routes/marketplace/fork.ts';
|
||||
import { pullRequestsRouter as marketplacePullRequestsRouter } from './routes/marketplace/pull-requests.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();
|
||||
|
||||
|
|
@ -78,6 +80,8 @@ app.route('/api/v1/marketplace', marketplaceSubscriptionsRouter());
|
|||
app.route('/api/v1/marketplace', marketplaceForkRouter());
|
||||
app.route('/api/v1/marketplace', marketplacePullRequestsRouter());
|
||||
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/decks', marketplaceDecksRouter());
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { cardContentHash } from '@cards/domain';
|
|||
import { getDb, type CardsDb } from '../../db/connection.ts';
|
||||
import {
|
||||
aiModerationLog,
|
||||
authorBlocks,
|
||||
authors,
|
||||
publicDeckCards,
|
||||
publicDeckVersions,
|
||||
|
|
@ -118,6 +119,24 @@ export function marketplaceDecksRouter(
|
|||
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
|
||||
|
|
|
|||
|
|
@ -72,7 +72,8 @@ interface DeckListEntry {
|
|||
|
||||
async function browseImpl(
|
||||
db: CardsDb,
|
||||
filter: z.infer<typeof BrowseQuerySchema>
|
||||
filter: z.infer<typeof BrowseQuerySchema>,
|
||||
signedInUserId?: string,
|
||||
): Promise<{ items: DeckListEntry[]; total: number }> {
|
||||
const limit = filter.limit ?? 20;
|
||||
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})`
|
||||
);
|
||||
}
|
||||
// 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 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.
|
||||
r.get('/explore', async (c) => {
|
||||
const db = dbOf();
|
||||
const userId = c.get('userId') as string | undefined;
|
||||
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)
|
||||
),
|
||||
browseImpl(db, { sort: 'trending', limit: 8 }),
|
||||
browseImpl(db, { sort: 'trending', limit: 8 }, userId),
|
||||
]);
|
||||
return c.json({ featured, trending: trending.items });
|
||||
});
|
||||
|
|
@ -198,7 +208,8 @@ export function exploreRouter(
|
|||
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);
|
||||
});
|
||||
|
||||
|
|
|
|||
104
apps/api/src/routes/marketplace/me.ts
Normal file
104
apps/api/src/routes/marketplace/me.ts
Normal 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;
|
||||
}
|
||||
137
apps/api/src/routes/marketplace/moderation.ts
Normal file
137
apps/api/src/routes/marketplace/moderation.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue