Routes (additiv unter /api/v1/marketplace/*): - POST/GET /authors/me — eigenes Author-Profil anlegen/updaten/lesen - GET /authors/:slug — public Profile-Lookup (banned-reason gestrippt) - POST /decks — Deck-Init (Slug-Validation + Pflicht-Author-Profil + CHECK auf paid + Pro-License) - POST /decks/:slug/publish — Versions-Snapshot mit per-Karte cardContentHash aus @cards/domain, per-Version-Hash, AI-Mod-Stub-Log, atomarer latest_version_id-Bump in Drizzle-Transaction - PATCH /decks/:slug — Metadaten-Update (Owner-Only) - GET /decks/:slug — Public-Detail mit optional-auth-Middleware Geport aus cards-decommission-base:services/cards-server/, mit Greenfield-Anpassungen: - Hashing über @cards/domain.cardContentHash (gemeinsame SoT zwischen privatem cards.cards und marketplace.deck_cards), per- Version-Hash als SHA-256 über sortierte Karten-Hashes mit Ord-Prefix - AI-Moderation als R2-Stub (pass+rationale+model='stub'), echte mana-llm-Anbindung in späterer Welle - Auth-Middleware-Shape an Greenfield (userId/tier/authMode in c.get(...) statt user-Object), optional-auth als Schwester für anonymen Public-Read - Hono-typing: outer Marketplace-Decks-Router ist Partial<AuthVars> weil Public-GET kein JWT braucht; Auth-Subroute ist strict Lese-Referenz: - 3331 LOC altes cards-server-Code (routes, services, middleware, lib) unter docs/marketplace/archive/code/ archiviert. Read-only, nicht im Build-Path. Verifikation: - 16 neue Vitest-Tests (Slug + Version-Hash), 72 gesamt grün - type-check 0 errors - E2E-Smoke gegen lokale cards-api: Cardecky-Author + Deck r2-stoische-ethik mit 3 Karten v1.0.0 (basic + basic + cloze), per-Karten-Hashes geschrieben, ai_moderation_log-Row da, semver-409 + paid-422-Errors verifiziert. Smoke-Daten danach aufgeräumt. Verbleibend für R3+: Discovery (explore + search), Engagement (stars/ subscribe/fork), Smart-Merge mit FSRS-State-Erhalt; danach R4 PRs + Card-Discussions, R5 Frontend-Routes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
195 lines
5.8 KiB
TypeScript
195 lines
5.8 KiB
TypeScript
/**
|
||
* Discovery service — browse, search, featured, trending, per-author
|
||
* deck lists, tag hierarchy. Pure read-only.
|
||
*
|
||
* Search uses Postgres `to_tsvector` over (title, description) so we
|
||
* don't depend on a separate index for Phase γ; Meilisearch lands in
|
||
* Phase ι if/when this becomes the bottleneck. Trending = simple
|
||
* recent-stars-velocity over the last 7 days; gamed at small N, fine
|
||
* once volume picks up — replaceable without API changes.
|
||
*/
|
||
|
||
import { and, desc, eq, gte, ilike, isNull, or, sql, count } from 'drizzle-orm';
|
||
import type { Database } from '../db/connection';
|
||
import {
|
||
authors,
|
||
deckStars,
|
||
deckSubscriptions,
|
||
deckTags,
|
||
publicDecks,
|
||
publicDeckVersions,
|
||
tagDefinitions,
|
||
} from '../db/schema';
|
||
|
||
export interface DeckListEntry {
|
||
slug: string;
|
||
title: string;
|
||
description: string | null;
|
||
language: string | null;
|
||
license: string;
|
||
priceCredits: number;
|
||
cardCount: number;
|
||
starCount: number;
|
||
subscriberCount: number;
|
||
isFeatured: boolean;
|
||
createdAt: Date;
|
||
owner: { slug: string; displayName: string; verifiedMana: boolean; verifiedCommunity: boolean };
|
||
}
|
||
|
||
const SORT_OPTIONS = ['recent', 'popular', 'trending'] as const;
|
||
export type SortOption = (typeof SORT_OPTIONS)[number];
|
||
|
||
export interface BrowseFilter {
|
||
q?: string;
|
||
tag?: string;
|
||
language?: string;
|
||
authorSlug?: string;
|
||
sort?: SortOption;
|
||
limit?: number;
|
||
offset?: number;
|
||
}
|
||
|
||
export class ExploreService {
|
||
constructor(private readonly db: Database) {}
|
||
|
||
async browse(filter: BrowseFilter): Promise<{ items: DeckListEntry[]; total: number }> {
|
||
const limit = Math.min(filter.limit ?? 20, 100);
|
||
const offset = filter.offset ?? 0;
|
||
const sort = filter.sort ?? 'recent';
|
||
|
||
// Base join: deck × owner-author × latest-version. We hit
|
||
// Drizzle's relational query API for predictable joins instead
|
||
// of building a giant select-with-joins by hand.
|
||
const conditions = [eq(publicDecks.isTakedown, false)];
|
||
if (filter.language) conditions.push(eq(publicDecks.language, filter.language));
|
||
if (filter.q) {
|
||
conditions.push(
|
||
or(
|
||
ilike(publicDecks.title, `%${filter.q}%`),
|
||
ilike(publicDecks.description, `%${filter.q}%`)
|
||
)!
|
||
);
|
||
}
|
||
if (filter.authorSlug) {
|
||
conditions.push(
|
||
eq(
|
||
publicDecks.ownerUserId,
|
||
sql<string>`(SELECT user_id FROM cards.authors WHERE slug = ${filter.authorSlug} LIMIT 1)`
|
||
)
|
||
);
|
||
}
|
||
if (filter.tag) {
|
||
conditions.push(
|
||
sql`EXISTS (SELECT 1 FROM cards.deck_tags dt JOIN cards.tag_definitions td ON td.id = dt.tag_id WHERE dt.deck_id = ${publicDecks.id} AND td.slug = ${filter.tag})`
|
||
);
|
||
}
|
||
|
||
// Pre-compute counts via subqueries; avoids N+1.
|
||
const starCount = sql<number>`(SELECT count(*)::int FROM cards.deck_stars s WHERE s.deck_id = ${publicDecks.id})`;
|
||
const subscriberCount = sql<number>`(SELECT count(*)::int FROM cards.deck_subscriptions s WHERE s.deck_id = ${publicDecks.id})`;
|
||
const cardCountExpr = sql<number>`COALESCE((SELECT v.card_count FROM cards.deck_versions v WHERE v.id = ${publicDecks.latestVersionId}), 0)`;
|
||
|
||
const sortClause =
|
||
sort === 'popular'
|
||
? desc(starCount)
|
||
: sort === 'trending'
|
||
? desc(
|
||
sql<number>`(SELECT count(*)::int FROM cards.deck_stars s WHERE s.deck_id = ${publicDecks.id} AND s.starred_at >= now() - interval '7 days')`
|
||
)
|
||
: desc(publicDecks.createdAt);
|
||
|
||
const baseQuery = this.db
|
||
.select({
|
||
slug: publicDecks.slug,
|
||
title: publicDecks.title,
|
||
description: publicDecks.description,
|
||
language: publicDecks.language,
|
||
license: publicDecks.license,
|
||
priceCredits: publicDecks.priceCredits,
|
||
cardCount: cardCountExpr,
|
||
starCount,
|
||
subscriberCount,
|
||
isFeatured: publicDecks.isFeatured,
|
||
createdAt: publicDecks.createdAt,
|
||
ownerSlug: authors.slug,
|
||
ownerDisplayName: authors.displayName,
|
||
ownerVerifiedMana: authors.verifiedMana,
|
||
ownerVerifiedCommunity: authors.verifiedCommunity,
|
||
})
|
||
.from(publicDecks)
|
||
.innerJoin(authors, eq(authors.userId, publicDecks.ownerUserId))
|
||
.where(and(...conditions))
|
||
.orderBy(sortClause)
|
||
.limit(limit)
|
||
.offset(offset);
|
||
|
||
const totalQuery = this.db
|
||
.select({ value: count() })
|
||
.from(publicDecks)
|
||
.innerJoin(authors, eq(authors.userId, publicDecks.ownerUserId))
|
||
.where(and(...conditions));
|
||
|
||
const [rows, totalResult] = await Promise.all([baseQuery, totalQuery]);
|
||
|
||
return {
|
||
items: rows.map((r) => ({
|
||
slug: r.slug,
|
||
title: r.title,
|
||
description: r.description,
|
||
language: r.language,
|
||
license: r.license,
|
||
priceCredits: r.priceCredits,
|
||
cardCount: Number(r.cardCount),
|
||
starCount: Number(r.starCount),
|
||
subscriberCount: Number(r.subscriberCount),
|
||
isFeatured: r.isFeatured,
|
||
createdAt: r.createdAt,
|
||
owner: {
|
||
slug: r.ownerSlug,
|
||
displayName: r.ownerDisplayName,
|
||
verifiedMana: r.ownerVerifiedMana,
|
||
verifiedCommunity: r.ownerVerifiedCommunity,
|
||
},
|
||
})),
|
||
total: totalResult[0]?.value ?? 0,
|
||
};
|
||
}
|
||
|
||
/** Featured + Trending side-by-side for the /explore landing. */
|
||
async explore(): Promise<{ featured: DeckListEntry[]; trending: DeckListEntry[] }> {
|
||
const [featuredResult, trendingResult] = await Promise.all([
|
||
this.browse({ sort: 'popular', limit: 8 }).then((r) =>
|
||
r.items.filter((d) => d.isFeatured).slice(0, 8)
|
||
),
|
||
this.browse({ sort: 'trending', limit: 8 }),
|
||
]);
|
||
return { featured: featuredResult, trending: trendingResult.items };
|
||
}
|
||
|
||
async tagTree() {
|
||
const rows = await this.db
|
||
.select()
|
||
.from(tagDefinitions)
|
||
.orderBy(tagDefinitions.parentId, tagDefinitions.name);
|
||
return rows;
|
||
}
|
||
|
||
async curatedTagsOnly() {
|
||
return this.db
|
||
.select()
|
||
.from(tagDefinitions)
|
||
.where(eq(tagDefinitions.curated, true))
|
||
.orderBy(tagDefinitions.name);
|
||
}
|
||
|
||
// Silence unused-binding lint for imports that downstream queries
|
||
// will pull in.
|
||
_keepAlive() {
|
||
void deckSubscriptions;
|
||
void deckStars;
|
||
void deckTags;
|
||
void publicDeckVersions;
|
||
void isNull;
|
||
void gte;
|
||
}
|
||
}
|