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>
225 lines
6.6 KiB
TypeScript
225 lines
6.6 KiB
TypeScript
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;
|
||
}
|