wordeck/apps/api/src/routes/marketplace/explore.ts
Till JS d45f1c0079 Phase 12 R3: Marketplace γ + δ — Discovery + Engagement + Subscribe + Smart-Merge
Routes (additiv unter /api/v1/marketplace/*):

Discovery (optional-auth, anonymer Read erlaubt):
- GET /explore — featured + trending side-by-side
- GET /decks — browse mit q/tag/language/author/sort/limit/offset
  (sort: recent | popular | trending; trending = star-velocity 7d)
- GET /tags — flacher Tag-Tree

Engagement (auth pro Schreib-Route, optional-auth für GET state):
- POST/DELETE/GET /decks/:slug/star
- POST/DELETE/GET /authors/:slug/follow (cannot-follow-self → 409)

Subscribe + Version-Read + Smart-Merge-Diff:
- POST/DELETE/GET /decks/:slug/subscribe
- GET /me/subscriptions (mit update_available-Indicator)
- GET /decks/:slug/versions/:semver — voller Cards-Payload in ord-
  Reihenfolge
- GET /decks/:slug/diff?from=:semver — computeDiff (added/changed/
  removed/unchanged) basierend auf content_hash + ord-Heuristik für
  "changed an gleicher Position"

Fork + Smart-Merge-Pull (auth):
- POST /decks/:slug/fork — kopiert latest version in privaten
  cards.decks (forked_from_marketplace_* gesetzt) + cards.cards mit
  übernommenem content_hash + frische FSRS-Reviews
- POST /private/:deckId/pull-update — Smart-Merge: existing private
  hashes deduplizieren, nur added/changed cards einfügen (mit fresh
  reviews), unveränderte Karten BEHALTEN inkl. FSRS-State, removed
  cards bleiben lokal (server-authoritative User-Choice). Update der
  forked_from_marketplace_version_id auf latest.

Schema (R3a):
- cards.decks: 2 neue Columns forked_from_marketplace_deck_id +
  forked_from_marketplace_version_id (text, nullable). Drizzle-push
  grün.

Architektur-Highlights:
- @cards/domain.cardContentHash ist die single source of truth für
  Karten-Hashing; marketplace.deck_cards und cards.cards berechnen
  identisch → Smart-Merge ist hash-equality + INSERT-IGNORE statt
  Diff-Replay
- pgSchema-Trennung (marketplace.* vs. cards.*) zahlt sich aus:
  Marketplace-Read-Path (Public + Engagement) und privater Lern-Pfad
  haben separate FK-Welten und können unabhängig versioniert werden
- Hono-Middleware-Pattern: per-route authMiddleware/optionalAuth statt
  Sub-Router-Mount, weil ein Wildcard '*' auf einem Sub-Router via
  r.route('/', sub) sonst die Public-GET-Routes des Parents fängt
  (Hono-Routing-Subtilität, kostete eine Smoke-Iteration)

Verifikation:
- type-check 0 errors
- 6 neue Diff-Heuristik-Tests, 78 gesamt grün
- End-to-End-Smoke gegen lokale cards-api:
  · Cardecky-Author + Deck `r3-stoische-grundbegriffe` v1.0.0 (3 Karten)
  · Till browst (anon → 200), starred, folgt Cardecky, subscribed
  · Till forkt → privates Deck mit 3 Karten + 3 fresh FSRS-Reviews
  · SQL-Manipulation: Apatheia-Review auf state='review',
    stability=10, reps=3 (simuliert "schon gelernt")
  · Cardecky publisht v1.1.0: Apatheia + Eudaimonia unverändert,
    Logos präzisiert (changed), Tugendlehre neu (added)
  · Diff-Endpoint zeigt: unchanged=2, changed=1, added=1, removed=0
  · Till pull-update → cards_inserted=2 (changed.next + added)
  · Verifikation: card_count=5 (war 3), Apatheia-Review **identisch
    erhalten** (state=review, stability=10, reps=3, last_review IS
    NOT NULL), neue Karten state=new — FSRS-State der unveränderten
    Karte überlebt Smart-Merge unverletzt

Verbleibend: R4 ε (PRs + Card-Discussions), R5 Frontend-Routes
(/explore, /d/[slug], /u/[slug], /me/subscribed, /me/forks), R6
voller UI-E2E.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:27:39 +02:00

225 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { and, asc, count, desc, eq, ilike, or, sql } from 'drizzle-orm';
import { Hono } from 'hono';
import { z } from 'zod';
import { getDb, type CardsDb } from '../../db/connection.ts';
import {
authors,
deckTags,
publicDeckVersions,
publicDecks,
tagDefinitions,
} from '../../db/schema/index.ts';
import type { AuthVars } from '../../middleware/auth.ts';
import { optionalAuthMiddleware } from '../../middleware/marketplace/optional-auth.ts';
/**
* Discovery — Browse, Search, Featured, Trending, Tag-Tree.
*
* Pure read-only, optional-auth (anonymer Browse erlaubt; signed-in
* User kriegen später hier nicht zusätzliches, aber das State-Lesen
* für Star/Subscribe geht über die per-Deck-Endpoints).
*
* Search nutzt `ILIKE` über Title + Description — gut genug für
* Phase γ. tsvector-Upgrade hängt an Phase ι.
*
* Trending = Star-Velocity der letzten 7 Tage. Bei kleinem N gambar,
* fine sobald Volumen kommt — Algorithmus austauschbar ohne API-
* Änderung.
*
* Geport aus
* `cards-decommission-base:services/cards-server/src/services/explore.ts`,
* mit FQN-Anpassung von `cards.*` auf `marketplace.*`.
*/
export type MarketplaceExploreDeps = { db?: CardsDb };
const SortEnum = z.enum(['recent', 'popular', 'trending']);
const BrowseQuerySchema = z.object({
q: z.string().max(200).optional(),
tag: z.string().max(60).optional(),
language: z
.string()
.regex(/^[a-z]{2}$/)
.optional(),
author: z.string().max(60).optional(),
sort: SortEnum.optional(),
limit: z.coerce.number().int().min(1).max(100).optional(),
offset: z.coerce.number().int().min(0).optional(),
});
interface DeckListEntry {
slug: string;
title: string;
description: string | null;
language: string | null;
license: string;
price_credits: number;
card_count: number;
star_count: number;
subscriber_count: number;
is_featured: boolean;
created_at: string;
owner: {
slug: string;
display_name: string;
verified_mana: boolean;
verified_community: boolean;
};
}
async function browseImpl(
db: CardsDb,
filter: z.infer<typeof BrowseQuerySchema>
): Promise<{ items: DeckListEntry[]; total: number }> {
const limit = filter.limit ?? 20;
const offset = filter.offset ?? 0;
const sort = filter.sort ?? 'recent';
const conditions = [eq(publicDecks.isTakedown, false)];
if (filter.language) conditions.push(eq(publicDecks.language, filter.language));
if (filter.q) {
const like = `%${filter.q}%`;
const expr = or(ilike(publicDecks.title, like), ilike(publicDecks.description, like));
if (expr) conditions.push(expr);
}
if (filter.author) {
conditions.push(
eq(
publicDecks.ownerUserId,
sql<string>`(SELECT user_id FROM marketplace.authors WHERE slug = ${filter.author} LIMIT 1)`
)
);
}
if (filter.tag) {
conditions.push(
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})`
);
}
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 cardCountExpr = sql<number>`COALESCE((SELECT v.card_count FROM marketplace.deck_versions v WHERE v.id = ${publicDecks.latestVersionId}), 0)`;
const sortClause =
sort === 'popular'
? desc(starCount)
: sort === 'trending'
? desc(
sql<number>`(SELECT count(*)::int FROM marketplace.deck_stars s WHERE s.deck_id = ${publicDecks.id} AND s.starred_at >= now() - interval '7 days')`
)
: desc(publicDecks.createdAt);
const rows = await 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 totalRow = await db
.select({ value: count() })
.from(publicDecks)
.innerJoin(authors, eq(authors.userId, publicDecks.ownerUserId))
.where(and(...conditions));
return {
items: rows.map((r) => ({
slug: r.slug,
title: r.title,
description: r.description,
language: r.language,
license: r.license,
price_credits: r.priceCredits,
card_count: Number(r.cardCount),
star_count: Number(r.starCount),
subscriber_count: Number(r.subscriberCount),
is_featured: r.isFeatured,
created_at: r.createdAt.toISOString(),
owner: {
slug: r.ownerSlug,
display_name: r.ownerDisplayName,
verified_mana: r.ownerVerifiedMana,
verified_community: r.ownerVerifiedCommunity,
},
})),
total: totalRow[0]?.value ?? 0,
};
}
export function exploreRouter(
deps: MarketplaceExploreDeps = {}
): Hono<{ Variables: Partial<AuthVars> }> {
const r = new Hono<{ Variables: Partial<AuthVars> }>();
const dbOf = () => deps.db ?? getDb();
r.use('*', optionalAuthMiddleware);
// GET /explore — Featured + Trending Side-by-Side.
r.get('/explore', async (c) => {
const db = dbOf();
const [featured, trending] = await Promise.all([
browseImpl(db, { sort: 'popular', limit: 8 }).then((r) =>
r.items.filter((d) => d.is_featured).slice(0, 8)
),
browseImpl(db, { sort: 'trending', limit: 8 }),
]);
return c.json({ featured, trending: trending.items });
});
// GET /decks — Browse mit Filtern + Sortierung + Pagination.
r.get('/decks', async (c) => {
const parsed = BrowseQuerySchema.safeParse(Object.fromEntries(new URL(c.req.url).searchParams));
if (!parsed.success) {
return c.json(
{ error: 'invalid_query', issues: parsed.error.issues.map((i) => i.message) },
422
);
}
const result = await browseImpl(dbOf(), parsed.data);
return c.json(result);
});
// GET /tags — flacher Tag-Tree.
r.get('/tags', async (c) => {
const rows = await dbOf()
.select()
.from(tagDefinitions)
.orderBy(asc(tagDefinitions.name));
return c.json({
tags: rows.map((t) => ({
id: t.id,
slug: t.slug,
name: t.name,
parent_id: t.parentId,
description: t.description,
curated: t.curated,
})),
});
});
// Imports kept alive für künftige Routes (curated-tags, by-version-…).
void publicDeckVersions;
void deckTags;
return r;
}