refactor(api): DTO-Helper extrahieren + N+1 in marketplace/decks beheben

- `lib/dto.ts`: `toDeckDto` und `toCardDto` aus routes/decks.ts und
  routes/cards.ts extrahiert — testbar, zentrale Output-Shape-Doku
- `lib/marketplace/dto.ts`: `toPublicDeckDto`, `toOwnerDto`, `toVersionDto`
  aus routes/marketplace/decks.ts extrahiert
- `GET /:slug` in marketplace/decks.ts: Version + Owner parallel per
  `Promise.all` statt sequenziell (2 RTT → 1 RTT)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-10 16:30:29 +02:00
parent f2f752e9ee
commit c39bacc971
5 changed files with 87 additions and 86 deletions

33
apps/api/src/lib/dto.ts Normal file
View file

@ -0,0 +1,33 @@
import { cards, decks } from '../db/schema/index.ts';
export function toDeckDto(row: typeof decks.$inferSelect) {
return {
id: row.id,
user_id: row.userId,
name: row.name,
description: row.description,
color: row.color,
category: row.category,
visibility: row.visibility,
fsrs_settings: row.fsrsSettings,
content_hash: row.contentHash,
forked_from_marketplace_deck_id: row.forkedFromMarketplaceDeckId,
forked_from_marketplace_version_id: row.forkedFromMarketplaceVersionId,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
};
}
export function toCardDto(row: typeof cards.$inferSelect) {
return {
id: row.id,
deck_id: row.deckId,
user_id: row.userId,
type: row.type,
fields: row.fields,
media_refs: row.mediaRefs ?? [],
content_hash: row.contentHash,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
};
}

View file

@ -0,0 +1,43 @@
import { publicDecks, publicDeckVersions } from '../../db/schema/index.ts';
import type { AuthorRow } from '../../db/schema/marketplace/index.ts';
export function toPublicDeckDto(row: typeof publicDecks.$inferSelect) {
return {
id: row.id,
slug: row.slug,
title: row.title,
description: row.description,
language: row.language,
category: row.category,
license: row.license,
price_credits: row.priceCredits,
owner_user_id: row.ownerUserId,
latest_version_id: row.latestVersionId,
is_featured: row.isFeatured,
is_takedown: row.isTakedown,
created_at: row.createdAt.toISOString(),
};
}
export function toOwnerDto(row: AuthorRow) {
return {
slug: row.slug,
display_name: row.displayName,
verified_mana: row.verifiedMana,
verified_community: row.verifiedCommunity,
pseudonym: row.pseudonym,
};
}
export function toVersionDto(row: typeof publicDeckVersions.$inferSelect) {
return {
id: row.id,
deck_id: row.deckId,
semver: row.semver,
changelog: row.changelog,
content_hash: row.contentHash,
card_count: row.cardCount,
published_at: row.publishedAt.toISOString(),
deprecated_at: row.deprecatedAt?.toISOString() ?? null,
};
}

View file

@ -11,6 +11,7 @@ import {
} from '@cards/domain';
import { makeInitialReviewRows } from '../lib/reviews.ts';
import { toCardDto } from '../lib/dto.ts';
import { getDb, type CardsDb } from '../db/connection.ts';
import { cards, decks, reviews } from '../db/schema/index.ts';
@ -190,17 +191,3 @@ export function cardsRouter(deps: CardsDeps = {}): Hono<{ Variables: AuthVars }>
return r;
}
function toCardDto(row: typeof cards.$inferSelect) {
return {
id: row.id,
deck_id: row.deckId,
user_id: row.userId,
type: row.type,
fields: row.fields,
media_refs: row.mediaRefs ?? [],
content_hash: row.contentHash,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
};
}

View file

@ -7,6 +7,7 @@ import { DeckCreateSchema, DeckUpdateSchema } from '@cards/domain';
import { getDb, type CardsDb } from '../db/connection.ts';
import { cards, decks } from '../db/schema/index.ts';
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
import { toDeckDto } from '../lib/dto.ts';
import { ulid } from '../lib/ulid.ts';
/** Optional injectable DB für Tests. */
@ -162,21 +163,3 @@ export function decksRouter(deps: DecksDeps = {}): Hono<{ Variables: AuthVars }>
return r;
}
function toDeckDto(row: typeof decks.$inferSelect) {
return {
id: row.id,
user_id: row.userId,
name: row.name,
description: row.description,
color: row.color,
category: row.category,
visibility: row.visibility,
fsrs_settings: row.fsrsSettings,
content_hash: row.contentHash,
forked_from_marketplace_deck_id: row.forkedFromMarketplaceDeckId,
forked_from_marketplace_version_id: row.forkedFromMarketplaceVersionId,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
};
}

View file

@ -12,10 +12,10 @@ import {
publicDeckVersions,
publicDecks,
} from '../../db/schema/index.ts';
import type { AuthorRow } from '../../db/schema/marketplace/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';
@ -104,47 +104,6 @@ function semverGreater(a: string, b: string): boolean {
return false;
}
function toDeckDto(row: typeof publicDecks.$inferSelect) {
return {
id: row.id,
slug: row.slug,
title: row.title,
description: row.description,
language: row.language,
category: row.category,
license: row.license,
price_credits: row.priceCredits,
owner_user_id: row.ownerUserId,
latest_version_id: row.latestVersionId,
is_featured: row.isFeatured,
is_takedown: row.isTakedown,
created_at: row.createdAt.toISOString(),
};
}
function toOwnerDto(row: AuthorRow) {
return {
slug: row.slug,
display_name: row.displayName,
verified_mana: row.verifiedMana,
verified_community: row.verifiedCommunity,
pseudonym: row.pseudonym,
};
}
function toVersionDto(row: typeof publicDeckVersions.$inferSelect) {
return {
id: row.id,
deck_id: row.deckId,
semver: row.semver,
changelog: row.changelog,
content_hash: row.contentHash,
card_count: row.cardCount,
published_at: row.publishedAt.toISOString(),
deprecated_at: row.deprecatedAt?.toISOString() ?? null,
};
}
export function marketplaceDecksRouter(
deps: MarketplaceDecksDeps = {}
): Hono<{ Variables: Partial<AuthVars> }> {
@ -159,7 +118,7 @@ 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);
const [versionAndOwner] = await Promise.all([
const [latestVersion, ownerRow] = await Promise.all([
deck.latestVersionId
? db
.select()
@ -168,17 +127,13 @@ export function marketplaceDecksRouter(
.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),
]);
const [ownerRow] = await db
.select()
.from(authors)
.where(eq(authors.userId, deck.ownerUserId))
.limit(1);
return c.json({
deck: toDeckDto(deck),
latest_version: versionAndOwner ? toVersionDto(versionAndOwner) : null,
deck: toPublicDeckDto(deck),
latest_version: latestVersion ? toVersionDto(latestVersion) : null,
owner: ownerRow ? toOwnerDto(ownerRow) : null,
});
});
@ -250,7 +205,7 @@ export function marketplaceDecksRouter(
ownerUserId: userId,
})
.returning();
return c.json(toDeckDto(created), 201);
return c.json(toPublicDeckDto(created), 201);
});
// PATCH /:slug — Metadaten.
@ -290,7 +245,7 @@ export function marketplaceDecksRouter(
})
.where(and(eq(publicDecks.id, deck.id)))
.returning();
return c.json(toDeckDto(updated));
return c.json(toPublicDeckDto(updated));
});
// POST /:slug/publish — neue Version.
@ -389,7 +344,7 @@ export function marketplaceDecksRouter(
return c.json(
{
deck: toDeckDto(result.deck),
deck: toPublicDeckDto(result.deck),
version: toVersionDto(result.version),
moderation: {
verdict: moderation.verdict,