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>
This commit is contained in:
parent
7dbbf63523
commit
d45f1c0079
10 changed files with 1170 additions and 11 deletions
|
|
@ -21,6 +21,14 @@ export const decks = cardsSchema.table(
|
|||
.default('private'),
|
||||
fsrsSettings: jsonb('fsrs_settings').notNull().default(sql`'{}'::jsonb`),
|
||||
contentHash: text('content_hash'),
|
||||
// Marketplace-Lineage (Phase 12 R3): wenn dieses private Deck via
|
||||
// `POST /api/v1/marketplace/decks/:slug/fork` aus einem
|
||||
// öffentlichen Deck entstanden ist, zeigen diese Pointer auf
|
||||
// die marketplace-Quelle. NULL = nicht-geforkt (einfach selbst
|
||||
// angelegt). Wird beim Smart-Merge-Pull benutzt um die richtige
|
||||
// Quelle nachzuladen.
|
||||
forkedFromMarketplaceDeckId: text('forked_from_marketplace_deck_id'),
|
||||
forkedFromMarketplaceVersionId: text('forked_from_marketplace_version_id'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ import { mediaRouter } from './routes/media.ts';
|
|||
import { decksGenerateRouter } from './routes/decks-generate.ts';
|
||||
import { authorsRouter as marketplaceAuthorsRouter } from './routes/marketplace/authors.ts';
|
||||
import { marketplaceDecksRouter } from './routes/marketplace/decks.ts';
|
||||
import { exploreRouter as marketplaceExploreRouter } from './routes/marketplace/explore.ts';
|
||||
import { engagementRouter as marketplaceEngagementRouter } from './routes/marketplace/engagement.ts';
|
||||
import { subscriptionsRouter as marketplaceSubscriptionsRouter } from './routes/marketplace/subscriptions.ts';
|
||||
import { forkRouter as marketplaceForkRouter } from './routes/marketplace/fork.ts';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
|
|
@ -50,6 +54,14 @@ app.route('/api/v1/decks/generate', decksGenerateRouter());
|
|||
|
||||
// Marketplace (Phase 12). Eigenes pgSchema, additive Routen unter /v1/marketplace/*.
|
||||
// Plan: docs/playbooks/MARKETPLACE_RESTORE.md.
|
||||
//
|
||||
// Mount-Reihenfolge ist signifikant: spezifischere Routes vor /authors
|
||||
// und /decks, damit z.B. /marketplace/me/subscriptions nicht zu authors
|
||||
// oder decks geroutet wird.
|
||||
app.route('/api/v1/marketplace', marketplaceExploreRouter());
|
||||
app.route('/api/v1/marketplace', marketplaceEngagementRouter());
|
||||
app.route('/api/v1/marketplace', marketplaceSubscriptionsRouter());
|
||||
app.route('/api/v1/marketplace', marketplaceForkRouter());
|
||||
app.route('/api/v1/marketplace/authors', marketplaceAuthorsRouter());
|
||||
app.route('/api/v1/marketplace/decks', marketplaceDecksRouter());
|
||||
|
||||
|
|
|
|||
89
apps/api/src/lib/marketplace/diff.ts
Normal file
89
apps/api/src/lib/marketplace/diff.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* Smart-Merge-Diff zwischen zwei Versionen oder zwischen einer Version
|
||||
* und der Hash-Liste eines privaten geforkten Decks.
|
||||
*
|
||||
* Klassifikation:
|
||||
* - **unchanged**: content_hash identisch zwischen alt und neu →
|
||||
* unveränderte Karte, FSRS-State bleibt.
|
||||
* - **added**: Hash nur in neu → komplett neue Karte.
|
||||
* - **removed**: Hash nur in alt → Karte aus dem Deck entfernt.
|
||||
* - **changed** (Heuristik): Karte am selben `ord` ist „added" und
|
||||
* gleichzeitig an demselben `ord` eine „removed". Pull-Requests
|
||||
* bringen später eine echte Karten-Lineage; bis dahin reicht die
|
||||
* Position-Heuristik.
|
||||
*
|
||||
* Geport aus
|
||||
* `cards-decommission-base:services/cards-server/src/services/subscriptions.ts`
|
||||
* (`diffSince`-Funktion, ord-Pairing-Heuristik 1:1).
|
||||
*/
|
||||
|
||||
export interface DiffCardForHash {
|
||||
contentHash: string;
|
||||
ord: number;
|
||||
}
|
||||
|
||||
export interface DiffCardFull {
|
||||
contentHash: string;
|
||||
type: string;
|
||||
fields: Record<string, string>;
|
||||
ord: number;
|
||||
}
|
||||
|
||||
export interface DiffPayload {
|
||||
from: { semver?: string; versionId?: string };
|
||||
to: { semver: string; versionId: string };
|
||||
added: DiffCardFull[];
|
||||
changed: { previous: { contentHash: string }; next: DiffCardFull }[];
|
||||
unchanged: { contentHash: string; ord: number }[];
|
||||
removed: { contentHash: string }[];
|
||||
}
|
||||
|
||||
export interface ComputeDiffInput {
|
||||
from: DiffCardForHash[];
|
||||
to: DiffCardFull[];
|
||||
fromInfo: { semver?: string; versionId?: string };
|
||||
toInfo: { semver: string; versionId: string };
|
||||
}
|
||||
|
||||
export function computeDiff(input: ComputeDiffInput): DiffPayload {
|
||||
const { from, to, fromInfo, toInfo } = input;
|
||||
const fromHashes = new Set(from.map((c) => c.contentHash));
|
||||
const toHashes = new Set(to.map((c) => c.contentHash));
|
||||
|
||||
const unchanged: { contentHash: string; ord: number }[] = [];
|
||||
|
||||
// Build ord → hash map of removed cards (in `from` but not in `to`).
|
||||
const removedByOrd = new Map<number, string>();
|
||||
for (const c of from) {
|
||||
if (!toHashes.has(c.contentHash)) removedByOrd.set(c.ord, c.contentHash);
|
||||
}
|
||||
|
||||
const added: DiffCardFull[] = [];
|
||||
const changed: { previous: { contentHash: string }; next: DiffCardFull }[] = [];
|
||||
|
||||
for (const c of to) {
|
||||
if (fromHashes.has(c.contentHash)) {
|
||||
unchanged.push({ contentHash: c.contentHash, ord: c.ord });
|
||||
} else if (removedByOrd.has(c.ord)) {
|
||||
const previousHash = removedByOrd.get(c.ord)!;
|
||||
removedByOrd.delete(c.ord);
|
||||
changed.push({ previous: { contentHash: previousHash }, next: c });
|
||||
} else {
|
||||
added.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
// Was in removedByOrd übrig bleibt = echte Removals (nicht ord-paired).
|
||||
const removed: { contentHash: string }[] = [...removedByOrd.values()].map((h) => ({
|
||||
contentHash: h,
|
||||
}));
|
||||
|
||||
return {
|
||||
from: fromInfo,
|
||||
to: toInfo,
|
||||
added,
|
||||
changed,
|
||||
unchanged,
|
||||
removed,
|
||||
};
|
||||
}
|
||||
|
|
@ -156,12 +156,12 @@ export function marketplaceDecksRouter(
|
|||
});
|
||||
});
|
||||
|
||||
// Authenticated endpoints folgen.
|
||||
const auth = new Hono<{ Variables: AuthVars }>();
|
||||
auth.use('*', authMiddleware);
|
||||
// Authenticated endpoints — per-route authMiddleware statt Sub-
|
||||
// Router-Mount, damit das Wildcard nicht die Public-GET-Routes
|
||||
// fängt.
|
||||
|
||||
// POST / — Deck-Init.
|
||||
auth.post('/', async (c) => {
|
||||
r.post('/', authMiddleware, async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const parsed = InitSchema.safeParse(body);
|
||||
|
|
@ -226,7 +226,7 @@ export function marketplaceDecksRouter(
|
|||
});
|
||||
|
||||
// PATCH /:slug — Metadaten.
|
||||
auth.patch('/:slug', async (c) => {
|
||||
r.patch('/:slug', authMiddleware, async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const slug = c.req.param('slug');
|
||||
const body = await c.req.json().catch(() => null);
|
||||
|
|
@ -265,7 +265,7 @@ export function marketplaceDecksRouter(
|
|||
});
|
||||
|
||||
// POST /:slug/publish — neue Version.
|
||||
auth.post('/:slug/publish', async (c) => {
|
||||
r.post('/:slug/publish', authMiddleware, async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const slug = c.req.param('slug');
|
||||
const body = await c.req.json().catch(() => null);
|
||||
|
|
@ -372,9 +372,5 @@ export function marketplaceDecksRouter(
|
|||
);
|
||||
});
|
||||
|
||||
// Auth-Sub-Router gemountet auf '/'. Hono routet zuerst exakte
|
||||
// Pfade auf `r` (GET /:slug), Rest fließt auf den auth-Mount.
|
||||
r.route('/', auth);
|
||||
|
||||
return r;
|
||||
}
|
||||
|
|
|
|||
130
apps/api/src/routes/marketplace/engagement.ts
Normal file
130
apps/api/src/routes/marketplace/engagement.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { and, eq } from 'drizzle-orm';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { getDb, type CardsDb } from '../../db/connection.ts';
|
||||
import { authorFollows, authors, deckStars, publicDecks } from '../../db/schema/index.ts';
|
||||
import { authMiddleware, type AuthVars } from '../../middleware/auth.ts';
|
||||
import { optionalAuthMiddleware } from '../../middleware/marketplace/optional-auth.ts';
|
||||
|
||||
/**
|
||||
* Engagement-Primitives — Stars (Bookmarks für Decks) + Follows
|
||||
* (Folgen von Authoren).
|
||||
*
|
||||
* Stars sind idempotent: doppeltes POST = no-op.
|
||||
* Follows sind idempotent: Self-Follow wird mit 409 abgelehnt.
|
||||
*
|
||||
* State-Endpoints (`GET /:slug/star`, `GET /:slug/follow`) sind
|
||||
* optional-auth: anonym = false, signed-in = wahre Antwort.
|
||||
*/
|
||||
|
||||
export type MarketplaceEngagementDeps = { db?: CardsDb };
|
||||
|
||||
async function findDeckBySlug(db: CardsDb, slug: string) {
|
||||
const [row] = await db.select().from(publicDecks).where(eq(publicDecks.slug, slug)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async function findAuthorBySlug(db: CardsDb, slug: string) {
|
||||
const [row] = await db.select().from(authors).where(eq(authors.slug, slug)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export function engagementRouter(
|
||||
deps: MarketplaceEngagementDeps = {}
|
||||
): Hono<{ Variables: Partial<AuthVars> }> {
|
||||
const r = new Hono<{ Variables: Partial<AuthVars> }>();
|
||||
const dbOf = () => deps.db ?? getDb();
|
||||
|
||||
// ─── Stars (Decks) ───────────────────────────────────────────────
|
||||
// Per-route authMiddleware statt Sub-Router-Mount, damit die
|
||||
// Public-State-Routes (`GET /decks/:slug/star` mit optional-auth)
|
||||
// nicht durch ein Wildcard-Middleware gefangen werden.
|
||||
|
||||
r.post('/decks/:slug/star', authMiddleware, async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const slug = c.req.param('slug');
|
||||
const db = dbOf();
|
||||
const deck = await findDeckBySlug(db, slug);
|
||||
if (!deck) return c.json({ error: 'not_found' }, 404);
|
||||
await db.insert(deckStars).values({ userId, deckId: deck.id }).onConflictDoNothing();
|
||||
return c.json({ starred: true });
|
||||
});
|
||||
|
||||
r.delete('/decks/:slug/star', authMiddleware, async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const slug = c.req.param('slug');
|
||||
const db = dbOf();
|
||||
const deck = await findDeckBySlug(db, slug);
|
||||
if (!deck) return c.json({ error: 'not_found' }, 404);
|
||||
await db
|
||||
.delete(deckStars)
|
||||
.where(and(eq(deckStars.userId, userId), eq(deckStars.deckId, deck.id)));
|
||||
return c.json({ starred: false });
|
||||
});
|
||||
|
||||
// GET /decks/:slug/star — own state, optional-auth (anon = false).
|
||||
r.get('/decks/:slug/star', optionalAuthMiddleware, async (c) => {
|
||||
const userId = c.get('userId');
|
||||
if (!userId) return c.json({ starred: false });
|
||||
const slug = c.req.param('slug');
|
||||
const db = dbOf();
|
||||
const rows = await db
|
||||
.select({ id: deckStars.deckId })
|
||||
.from(deckStars)
|
||||
.innerJoin(publicDecks, eq(publicDecks.id, deckStars.deckId))
|
||||
.where(and(eq(deckStars.userId, userId), eq(publicDecks.slug, slug)))
|
||||
.limit(1);
|
||||
return c.json({ starred: rows.length > 0 });
|
||||
});
|
||||
|
||||
// ─── Follows (Authors) ───────────────────────────────────────────
|
||||
|
||||
r.post('/authors/:slug/follow', authMiddleware, async (c) => {
|
||||
const followerUserId = c.get('userId');
|
||||
const slug = c.req.param('slug');
|
||||
const db = dbOf();
|
||||
const author = await findAuthorBySlug(db, slug);
|
||||
if (!author) return c.json({ error: 'not_found' }, 404);
|
||||
if (author.userId === followerUserId) {
|
||||
return c.json({ error: 'cannot_follow_self' }, 409);
|
||||
}
|
||||
await db
|
||||
.insert(authorFollows)
|
||||
.values({ followerUserId, authorUserId: author.userId })
|
||||
.onConflictDoNothing();
|
||||
return c.json({ following: true });
|
||||
});
|
||||
|
||||
r.delete('/authors/:slug/follow', authMiddleware, async (c) => {
|
||||
const followerUserId = c.get('userId');
|
||||
const slug = c.req.param('slug');
|
||||
const db = dbOf();
|
||||
const author = await findAuthorBySlug(db, slug);
|
||||
if (!author) return c.json({ error: 'not_found' }, 404);
|
||||
await db
|
||||
.delete(authorFollows)
|
||||
.where(
|
||||
and(
|
||||
eq(authorFollows.followerUserId, followerUserId),
|
||||
eq(authorFollows.authorUserId, author.userId)
|
||||
)
|
||||
);
|
||||
return c.json({ following: false });
|
||||
});
|
||||
|
||||
r.get('/authors/:slug/follow', optionalAuthMiddleware, async (c) => {
|
||||
const followerUserId = c.get('userId');
|
||||
if (!followerUserId) return c.json({ following: false });
|
||||
const slug = c.req.param('slug');
|
||||
const db = dbOf();
|
||||
const rows = await db
|
||||
.select({ id: authorFollows.authorUserId })
|
||||
.from(authorFollows)
|
||||
.innerJoin(authors, eq(authors.userId, authorFollows.authorUserId))
|
||||
.where(and(eq(authorFollows.followerUserId, followerUserId), eq(authors.slug, slug)))
|
||||
.limit(1);
|
||||
return c.json({ following: rows.length > 0 });
|
||||
});
|
||||
|
||||
return r;
|
||||
}
|
||||
225
apps/api/src/routes/marketplace/explore.ts
Normal file
225
apps/api/src/routes/marketplace/explore.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
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;
|
||||
}
|
||||
343
apps/api/src/routes/marketplace/fork.ts
Normal file
343
apps/api/src/routes/marketplace/fork.ts
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
import { and, asc, eq } from 'drizzle-orm';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
cardContentHash,
|
||||
newReview,
|
||||
subIndexCount,
|
||||
subIndexCountForCloze,
|
||||
} from '@cards/domain';
|
||||
|
||||
import { getDb, type CardsDb } from '../../db/connection.ts';
|
||||
import { cards, decks, reviews } from '../../db/schema/index.ts';
|
||||
import {
|
||||
publicDeckCards,
|
||||
publicDeckVersions,
|
||||
publicDecks,
|
||||
} from '../../db/schema/index.ts';
|
||||
import { authMiddleware, type AuthVars } from '../../middleware/auth.ts';
|
||||
import { ulid } from '../../lib/ulid.ts';
|
||||
import { computeDiff } from '../../lib/marketplace/diff.ts';
|
||||
|
||||
/**
|
||||
* Fork + Smart-Merge-Pull-Update.
|
||||
*
|
||||
* **Fork**: kopiert ein Marketplace-Deck (latest published Version) in
|
||||
* eine eigene private `cards.decks`-Row + erzeugt private `cards.cards`-
|
||||
* Zeilen aus den `marketplace.deck_cards`. FSRS-Reviews kommen frisch
|
||||
* (newReview pro subIndex). `cards.decks.forked_from_marketplace_deck_id`
|
||||
* + `…version_id` werden gesetzt — das ist der Anker für spätere
|
||||
* Smart-Merge-Pulls.
|
||||
*
|
||||
* **Pull-Update**: gegen einen privaten geforkten Deck. Berechnet Diff
|
||||
* zwischen geforkter Version und aktueller Marketplace-Latest. Neue
|
||||
* Karten werden eingefügt; geänderte Karten kriegen einen neuen Card-
|
||||
* Insert (mit neuem content_hash) — der **alte** Card-Eintrag bleibt
|
||||
* bestehen, damit existierende FSRS-Reviews intakt bleiben. Removed-
|
||||
* Cards bleiben ebenfalls (User behält History; Pull entfernt nichts
|
||||
* Lokales). Im UI werden sie später dezent als „nicht mehr im Original"
|
||||
* markiert (R5).
|
||||
*
|
||||
* Architektur-Begründung:
|
||||
* - **content_hash-basierter Dedupe** ist die Smart-Merge-Magic:
|
||||
* `cards.cards.content_hash` matcht `marketplace.deck_cards.content_hash`,
|
||||
* identisch berechnet via `@cards/domain.cardContentHash`.
|
||||
* Unveränderte Karten = identisches Hash → schon da → Insert
|
||||
* übersprungen → FSRS bleibt.
|
||||
* - **Removed-Cards bleiben lokal**: anders als das alte Dexie-Sync
|
||||
* ist hier der server-authoritative Stand: User entscheidet selbst,
|
||||
* was er löscht. Re-Pull soll keine User-Daten löschen.
|
||||
*/
|
||||
|
||||
export type MarketplaceForkDeps = { db?: CardsDb };
|
||||
|
||||
const ForkBodySchema = z.object({
|
||||
color: z.string().max(20).optional(),
|
||||
});
|
||||
|
||||
async function findDeckBySlug(db: CardsDb, slug: string) {
|
||||
const [row] = await db.select().from(publicDecks).where(eq(publicDecks.slug, slug)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async function loadVersionCards(db: CardsDb, versionId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(publicDeckCards)
|
||||
.where(eq(publicDeckCards.versionId, versionId))
|
||||
.orderBy(asc(publicDeckCards.ord));
|
||||
}
|
||||
|
||||
function subIndexCountFor(type: string, fields: Record<string, string>): number {
|
||||
if (type === 'cloze') return subIndexCountForCloze(fields.text ?? '');
|
||||
if (type === 'image-occlusion') {
|
||||
// image-occlusion hat dynamische subIndexes via mask_regions —
|
||||
// im Marketplace-Fork bisher nicht unterstützt. Default 1.
|
||||
return 1;
|
||||
}
|
||||
return subIndexCount(type);
|
||||
}
|
||||
|
||||
function buildInitialReviews(
|
||||
userId: string,
|
||||
cardId: string,
|
||||
count: number,
|
||||
now: Date
|
||||
) {
|
||||
return Array.from({ length: count }, (_, subIndex) => {
|
||||
const review = newReview({ userId, cardId, subIndex, now });
|
||||
return {
|
||||
cardId: review.card_id,
|
||||
subIndex: review.sub_index,
|
||||
userId: review.user_id,
|
||||
due: new Date(review.due),
|
||||
stability: review.stability,
|
||||
difficulty: review.difficulty,
|
||||
elapsedDays: review.elapsed_days,
|
||||
scheduledDays: review.scheduled_days,
|
||||
learningSteps: review.learning_steps,
|
||||
reps: review.reps,
|
||||
lapses: review.lapses,
|
||||
state: review.state,
|
||||
lastReview: review.last_review ? new Date(review.last_review) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function forkRouter(deps: MarketplaceForkDeps = {}): Hono<{ Variables: AuthVars }> {
|
||||
const r = new Hono<{ Variables: AuthVars }>();
|
||||
const dbOf = () => deps.db ?? getDb();
|
||||
|
||||
r.use('*', authMiddleware);
|
||||
|
||||
// POST /decks/:slug/fork — Marketplace → privater Deck.
|
||||
r.post('/decks/:slug/fork', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const slug = c.req.param('slug');
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const parsed = ForkBodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return c.json(
|
||||
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
|
||||
422
|
||||
);
|
||||
}
|
||||
|
||||
const db = dbOf();
|
||||
const sourceDeck = await findDeckBySlug(db, slug);
|
||||
if (!sourceDeck) return c.json({ error: 'not_found' }, 404);
|
||||
if (sourceDeck.isTakedown) return c.json({ error: 'takedown_active' }, 403);
|
||||
if (!sourceDeck.latestVersionId) return c.json({ error: 'no_published_version' }, 409);
|
||||
|
||||
const sourceCards = await loadVersionCards(db, sourceDeck.latestVersionId);
|
||||
if (sourceCards.length === 0) return c.json({ error: 'empty_version' }, 409);
|
||||
|
||||
const newDeckId = ulid();
|
||||
const now = new Date();
|
||||
|
||||
const newDeck = await db.transaction(async (tx) => {
|
||||
const [deck] = await tx
|
||||
.insert(decks)
|
||||
.values({
|
||||
id: newDeckId,
|
||||
userId,
|
||||
name: sourceDeck.title,
|
||||
description: sourceDeck.description,
|
||||
color: parsed.data.color ?? '#0ea5e9', // sky-500 — Fork-Default
|
||||
visibility: 'private',
|
||||
fsrsSettings: {},
|
||||
forkedFromMarketplaceDeckId: sourceDeck.id,
|
||||
forkedFromMarketplaceVersionId: sourceDeck.latestVersionId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
for (const sourceCard of sourceCards) {
|
||||
const cardId = ulid();
|
||||
await tx.insert(cards).values({
|
||||
id: cardId,
|
||||
deckId: newDeckId,
|
||||
userId,
|
||||
type: sourceCard.type,
|
||||
fields: sourceCard.fields,
|
||||
mediaRefs: [],
|
||||
contentHash: sourceCard.contentHash,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const subIndexes = subIndexCountFor(
|
||||
sourceCard.type,
|
||||
sourceCard.fields as Record<string, string>
|
||||
);
|
||||
const initialReviews = buildInitialReviews(userId, cardId, subIndexes, now);
|
||||
if (initialReviews.length > 0) {
|
||||
await tx.insert(reviews).values(initialReviews);
|
||||
}
|
||||
}
|
||||
|
||||
return deck;
|
||||
});
|
||||
|
||||
return c.json(
|
||||
{
|
||||
deck: {
|
||||
id: newDeck.id,
|
||||
name: newDeck.name,
|
||||
description: newDeck.description,
|
||||
color: newDeck.color,
|
||||
forked_from_marketplace_deck_id: newDeck.forkedFromMarketplaceDeckId,
|
||||
forked_from_marketplace_version_id: newDeck.forkedFromMarketplaceVersionId,
|
||||
},
|
||||
cards_created: sourceCards.length,
|
||||
},
|
||||
201
|
||||
);
|
||||
});
|
||||
|
||||
// POST /decks/:private-deck-id/pull-update — Smart-Merge-Pull.
|
||||
r.post('/private/:deckId/pull-update', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const deckId = c.req.param('deckId');
|
||||
const db = dbOf();
|
||||
|
||||
const [privateDeck] = await db
|
||||
.select()
|
||||
.from(decks)
|
||||
.where(and(eq(decks.id, deckId), eq(decks.userId, userId)))
|
||||
.limit(1);
|
||||
if (!privateDeck) return c.json({ error: 'not_found' }, 404);
|
||||
if (!privateDeck.forkedFromMarketplaceDeckId) {
|
||||
return c.json({ error: 'not_a_fork' }, 422);
|
||||
}
|
||||
if (!privateDeck.forkedFromMarketplaceVersionId) {
|
||||
return c.json({ error: 'fork_pointer_missing' }, 500);
|
||||
}
|
||||
|
||||
const [sourceDeck] = await db
|
||||
.select()
|
||||
.from(publicDecks)
|
||||
.where(eq(publicDecks.id, privateDeck.forkedFromMarketplaceDeckId))
|
||||
.limit(1);
|
||||
if (!sourceDeck) return c.json({ error: 'source_deck_gone' }, 404);
|
||||
if (!sourceDeck.latestVersionId) return c.json({ error: 'no_published_version' }, 409);
|
||||
|
||||
// Already up-to-date?
|
||||
if (sourceDeck.latestVersionId === privateDeck.forkedFromMarketplaceVersionId) {
|
||||
return c.json({
|
||||
up_to_date: true,
|
||||
added: 0,
|
||||
changed: 0,
|
||||
removed: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const [fromCards, toCards, latestVersion, fromVersion] = await Promise.all([
|
||||
db
|
||||
.select({ contentHash: publicDeckCards.contentHash, ord: publicDeckCards.ord })
|
||||
.from(publicDeckCards)
|
||||
.where(eq(publicDeckCards.versionId, privateDeck.forkedFromMarketplaceVersionId)),
|
||||
loadVersionCards(db, sourceDeck.latestVersionId),
|
||||
db
|
||||
.select()
|
||||
.from(publicDeckVersions)
|
||||
.where(eq(publicDeckVersions.id, sourceDeck.latestVersionId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db
|
||||
.select()
|
||||
.from(publicDeckVersions)
|
||||
.where(eq(publicDeckVersions.id, privateDeck.forkedFromMarketplaceVersionId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null),
|
||||
]);
|
||||
if (!latestVersion || !fromVersion) {
|
||||
return c.json({ error: 'version_resolution_failed' }, 500);
|
||||
}
|
||||
|
||||
const diff = computeDiff({
|
||||
from: fromCards,
|
||||
to: toCards.map((card) => ({
|
||||
contentHash: card.contentHash,
|
||||
type: card.type,
|
||||
fields: card.fields as Record<string, string>,
|
||||
ord: card.ord,
|
||||
})),
|
||||
fromInfo: { semver: fromVersion.semver, versionId: fromVersion.id },
|
||||
toInfo: { semver: latestVersion.semver, versionId: latestVersion.id },
|
||||
});
|
||||
|
||||
// Apply: added + changed.next werden als neue private Cards
|
||||
// eingefügt (mit neuem content_hash, da identisch zur
|
||||
// Marketplace-Karte). Unveränderte Karten existieren schon
|
||||
// privat — wir können den Insert sicher überspringen, weil
|
||||
// (deck_id, content_hash) im privaten cards.cards einzigartig
|
||||
// ist (gleiche Hash-Funktion via @cards/domain).
|
||||
// Removed-Cards bleiben lokal (server-authoritative User-Choice).
|
||||
const now = new Date();
|
||||
|
||||
// Existing private content-hashes lookup, damit wir keine
|
||||
// Duplikate einfügen falls die Heuristik mal danebenliegt.
|
||||
const existingPrivate = await db
|
||||
.select({ contentHash: cards.contentHash })
|
||||
.from(cards)
|
||||
.where(and(eq(cards.deckId, deckId), eq(cards.userId, userId)));
|
||||
const existingHashes = new Set(existingPrivate.map((row) => row.contentHash));
|
||||
|
||||
const toInsert = [...diff.added, ...diff.changed.map((entry) => entry.next)].filter(
|
||||
(card) => !existingHashes.has(card.contentHash)
|
||||
);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const card of toInsert) {
|
||||
const cardId = ulid();
|
||||
// Content-Hash kommt aus Marketplace; wir haben die Karte
|
||||
// schon nicht im privaten Set, also sicher zu inserten.
|
||||
const computedHash = await cardContentHash({
|
||||
type: card.type,
|
||||
fields: card.fields,
|
||||
});
|
||||
await tx.insert(cards).values({
|
||||
id: cardId,
|
||||
deckId,
|
||||
userId,
|
||||
type: card.type,
|
||||
fields: card.fields,
|
||||
mediaRefs: [],
|
||||
contentHash: computedHash,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const subIndexes = subIndexCountFor(card.type, card.fields);
|
||||
const initialReviews = buildInitialReviews(userId, cardId, subIndexes, now);
|
||||
if (initialReviews.length > 0) {
|
||||
await tx.insert(reviews).values(initialReviews);
|
||||
}
|
||||
}
|
||||
|
||||
// Update Fork-Pointer auf die neue Version.
|
||||
await tx
|
||||
.update(decks)
|
||||
.set({
|
||||
forkedFromMarketplaceVersionId: sourceDeck.latestVersionId,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(decks.id, deckId));
|
||||
});
|
||||
|
||||
return c.json({
|
||||
up_to_date: false,
|
||||
from: { semver: fromVersion.semver, versionId: fromVersion.id },
|
||||
to: { semver: latestVersion.semver, versionId: latestVersion.id },
|
||||
added: diff.added.length,
|
||||
changed: diff.changed.length,
|
||||
removed: diff.removed.length,
|
||||
cards_inserted: toInsert.length,
|
||||
});
|
||||
});
|
||||
|
||||
return r;
|
||||
}
|
||||
252
apps/api/src/routes/marketplace/subscriptions.ts
Normal file
252
apps/api/src/routes/marketplace/subscriptions.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import { and, asc, eq } from 'drizzle-orm';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { getDb, type CardsDb } from '../../db/connection.ts';
|
||||
import {
|
||||
deckSubscriptions,
|
||||
publicDeckCards,
|
||||
publicDeckVersions,
|
||||
publicDecks,
|
||||
} from '../../db/schema/index.ts';
|
||||
import { authMiddleware, type AuthVars } from '../../middleware/auth.ts';
|
||||
import { optionalAuthMiddleware } from '../../middleware/marketplace/optional-auth.ts';
|
||||
import { computeDiff } from '../../lib/marketplace/diff.ts';
|
||||
|
||||
/**
|
||||
* Subscriptions + Version-Read + Smart-Merge-Diff.
|
||||
*
|
||||
* - `POST /decks/:slug/subscribe` — Intent-Tracking. Wenn paid:
|
||||
* Purchase-Check (R3 dormant).
|
||||
* - `DELETE /decks/:slug/subscribe` — abbestellen.
|
||||
* - `GET /me/subscriptions` — eigene Subs + update-Indikator.
|
||||
* - `GET /decks/:slug/versions/:semver` — voller Version-Payload mit
|
||||
* Cards in stable ord-Order.
|
||||
* - `GET /decks/:slug/diff?from=:semver` — Smart-Merge-Payload.
|
||||
*
|
||||
* Geport aus
|
||||
* `cards-decommission-base:services/cards-server/src/services/subscriptions.ts`.
|
||||
*/
|
||||
|
||||
export type MarketplaceSubscriptionsDeps = { db?: CardsDb };
|
||||
|
||||
async function findDeckBySlug(db: CardsDb, slug: string) {
|
||||
const [row] = await db.select().from(publicDecks).where(eq(publicDecks.slug, slug)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async function findVersion(db: CardsDb, deckId: string, semver: string) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(publicDeckVersions)
|
||||
.where(and(eq(publicDeckVersions.deckId, deckId), eq(publicDeckVersions.semver, semver)))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async function loadVersionCards(db: CardsDb, versionId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(publicDeckCards)
|
||||
.where(eq(publicDeckCards.versionId, versionId))
|
||||
.orderBy(asc(publicDeckCards.ord));
|
||||
}
|
||||
|
||||
export function subscriptionsRouter(
|
||||
deps: MarketplaceSubscriptionsDeps = {}
|
||||
): Hono<{ Variables: Partial<AuthVars> }> {
|
||||
const r = new Hono<{ Variables: Partial<AuthVars> }>();
|
||||
const dbOf = () => deps.db ?? getDb();
|
||||
|
||||
// ─── Subscribe / Unsubscribe (auth — per-route middleware
|
||||
// statt route-mount, damit die Public-Read-Routes weiter unten
|
||||
// nicht vom Wildcard-Middleware des Auth-Subrouters gefangen werden) ──
|
||||
|
||||
r.post('/decks/:slug/subscribe', authMiddleware, async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const slug = c.req.param('slug');
|
||||
const db = dbOf();
|
||||
const deck = await findDeckBySlug(db, slug);
|
||||
if (!deck) return c.json({ error: 'not_found' }, 404);
|
||||
if (deck.isTakedown) return c.json({ error: 'takedown_active' }, 403);
|
||||
if (!deck.latestVersionId) {
|
||||
return c.json({ error: 'no_published_version' }, 409);
|
||||
}
|
||||
// Paid-Decks brauchen einen Purchase-Beleg — Pipeline kommt mit
|
||||
// Phase ζ wieder; bis dahin: nur Owner darf seinen eigenen paid
|
||||
// Deck testweise subscriben.
|
||||
if (deck.priceCredits > 0 && deck.ownerUserId !== userId) {
|
||||
return c.json({ error: 'paid_deck_purchase_required' }, 402);
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(deckSubscriptions)
|
||||
.values({
|
||||
userId,
|
||||
deckId: deck.id,
|
||||
currentVersionId: deck.latestVersionId,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [deckSubscriptions.userId, deckSubscriptions.deckId],
|
||||
set: { currentVersionId: deck.latestVersionId },
|
||||
});
|
||||
|
||||
return c.json(
|
||||
{
|
||||
subscribed: true,
|
||||
deck_slug: slug,
|
||||
current_version_id: deck.latestVersionId,
|
||||
},
|
||||
201
|
||||
);
|
||||
});
|
||||
|
||||
r.delete('/decks/:slug/subscribe', authMiddleware, async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const slug = c.req.param('slug');
|
||||
const db = dbOf();
|
||||
const deck = await findDeckBySlug(db, slug);
|
||||
if (!deck) return c.json({ error: 'not_found' }, 404);
|
||||
await db
|
||||
.delete(deckSubscriptions)
|
||||
.where(and(eq(deckSubscriptions.userId, userId), eq(deckSubscriptions.deckId, deck.id)));
|
||||
return c.json({ subscribed: false });
|
||||
});
|
||||
|
||||
r.get('/me/subscriptions', authMiddleware, async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const rows = await dbOf()
|
||||
.select({
|
||||
deckSlug: publicDecks.slug,
|
||||
deckTitle: publicDecks.title,
|
||||
deckDescription: publicDecks.description,
|
||||
deckLatestVersionId: publicDecks.latestVersionId,
|
||||
subscribedAt: deckSubscriptions.subscribedAt,
|
||||
notifyUpdates: deckSubscriptions.notifyUpdates,
|
||||
currentVersionId: deckSubscriptions.currentVersionId,
|
||||
})
|
||||
.from(deckSubscriptions)
|
||||
.innerJoin(publicDecks, eq(publicDecks.id, deckSubscriptions.deckId))
|
||||
.where(eq(deckSubscriptions.userId, userId))
|
||||
.orderBy(asc(deckSubscriptions.subscribedAt));
|
||||
|
||||
return c.json({
|
||||
subscriptions: rows.map((row) => ({
|
||||
deck_slug: row.deckSlug,
|
||||
deck_title: row.deckTitle,
|
||||
deck_description: row.deckDescription,
|
||||
subscribed_at: row.subscribedAt.toISOString(),
|
||||
notify_updates: row.notifyUpdates,
|
||||
current_version_id: row.currentVersionId,
|
||||
latest_version_id: row.deckLatestVersionId,
|
||||
update_available:
|
||||
row.deckLatestVersionId !== null && row.currentVersionId !== row.deckLatestVersionId,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
r.get('/decks/:slug/subscribe', authMiddleware, async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const slug = c.req.param('slug');
|
||||
const db = dbOf();
|
||||
const rows = await db
|
||||
.select({ id: deckSubscriptions.deckId, currentVersionId: deckSubscriptions.currentVersionId })
|
||||
.from(deckSubscriptions)
|
||||
.innerJoin(publicDecks, eq(publicDecks.id, deckSubscriptions.deckId))
|
||||
.where(and(eq(deckSubscriptions.userId, userId), eq(publicDecks.slug, slug)))
|
||||
.limit(1);
|
||||
if (rows.length === 0) return c.json({ subscribed: false });
|
||||
return c.json({ subscribed: true, current_version_id: rows[0].currentVersionId });
|
||||
});
|
||||
|
||||
// ─── Public-Read: Version + Diff ─────────────────────────────────
|
||||
|
||||
r.get('/decks/:slug/versions/:semver', optionalAuthMiddleware, async (c) => {
|
||||
const slug = c.req.param('slug');
|
||||
const semver = c.req.param('semver');
|
||||
const db = dbOf();
|
||||
const deck = await findDeckBySlug(db, slug);
|
||||
if (!deck) return c.json({ error: 'not_found' }, 404);
|
||||
const version = await findVersion(db, deck.id, semver);
|
||||
if (!version) return c.json({ error: 'version_not_found' }, 404);
|
||||
const cards = await loadVersionCards(db, version.id);
|
||||
|
||||
return c.json({
|
||||
version: {
|
||||
id: version.id,
|
||||
deck_id: version.deckId,
|
||||
semver: version.semver,
|
||||
changelog: version.changelog,
|
||||
content_hash: version.contentHash,
|
||||
card_count: version.cardCount,
|
||||
published_at: version.publishedAt.toISOString(),
|
||||
},
|
||||
cards: cards.map((card) => ({
|
||||
content_hash: card.contentHash,
|
||||
type: card.type,
|
||||
fields: card.fields,
|
||||
ord: card.ord,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
r.get('/decks/:slug/diff', optionalAuthMiddleware, async (c) => {
|
||||
const slug = c.req.param('slug');
|
||||
const fromSemver = c.req.query('from');
|
||||
if (!fromSemver) return c.json({ error: 'missing_query', detail: 'from=:semver' }, 422);
|
||||
|
||||
const db = dbOf();
|
||||
const deck = await findDeckBySlug(db, slug);
|
||||
if (!deck) return c.json({ error: 'not_found' }, 404);
|
||||
if (!deck.latestVersionId) return c.json({ error: 'no_published_version' }, 409);
|
||||
|
||||
const [latest, from] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(publicDeckVersions)
|
||||
.where(eq(publicDeckVersions.id, deck.latestVersionId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null),
|
||||
findVersion(db, deck.id, fromSemver),
|
||||
]);
|
||||
if (!latest) return c.json({ error: 'latest_version_missing' }, 500);
|
||||
if (!from) return c.json({ error: 'from_version_not_found' }, 404);
|
||||
|
||||
// Empty diff bei Identität.
|
||||
if (from.id === latest.id) {
|
||||
return c.json({
|
||||
from: { semver: fromSemver, versionId: from.id },
|
||||
to: { semver: latest.semver, versionId: latest.id },
|
||||
added: [],
|
||||
changed: [],
|
||||
unchanged: [],
|
||||
removed: [],
|
||||
});
|
||||
}
|
||||
|
||||
const [fromCards, toCardsRaw] = await Promise.all([
|
||||
db
|
||||
.select({ contentHash: publicDeckCards.contentHash, ord: publicDeckCards.ord })
|
||||
.from(publicDeckCards)
|
||||
.where(eq(publicDeckCards.versionId, from.id)),
|
||||
loadVersionCards(db, latest.id),
|
||||
]);
|
||||
|
||||
const toCards = toCardsRaw.map((card) => ({
|
||||
contentHash: card.contentHash,
|
||||
type: card.type,
|
||||
fields: card.fields as Record<string, string>,
|
||||
ord: card.ord,
|
||||
}));
|
||||
|
||||
const diff = computeDiff({
|
||||
from: fromCards,
|
||||
to: toCards,
|
||||
fromInfo: { semver: fromSemver, versionId: from.id },
|
||||
toInfo: { semver: latest.semver, versionId: latest.id },
|
||||
});
|
||||
|
||||
return c.json(diff);
|
||||
});
|
||||
|
||||
return r;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue