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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -50,6 +50,7 @@ export {
|
||||||
deckStars,
|
deckStars,
|
||||||
deckSubscriptions,
|
deckSubscriptions,
|
||||||
deckForks,
|
deckForks,
|
||||||
|
authorBlocks,
|
||||||
deckPullRequests,
|
deckPullRequests,
|
||||||
cardDiscussions,
|
cardDiscussions,
|
||||||
pullRequestStatusEnum,
|
pullRequestStatusEnum,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
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