From 92a1d5804f2002910f82f8cbdcca4e7319f8c43b Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 9 May 2026 15:50:16 +0200 Subject: [PATCH] =?UTF-8?q?Phase=2012=20R4:=20Marketplace=20=CE=B5=20?= =?UTF-8?q?=E2=80=94=20Pull-Requests=20+=20Card-Discussions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull-Requests (Diff-Modell add/modify/remove, GitHub-style 3-way merge in der DB-Transaktion): - POST /decks/:slug/pull-requests (auth) — neuer PR mit diff.{add,modify,remove}; previousContentHash für modify identifiziert die zu ersetzende Karte by content-hash, type bleibt aus dem alten Eintrag (modify ist field-only Replace) - GET /decks/:slug/pull-requests (optional-auth) — Liste mit Status- Filter (open/merged/closed/rejected) - GET /pull-requests/:id (optional-auth) — Detail - POST /pull-requests/:id/close (auth) — Author oder Deck-Owner - POST /pull-requests/:id/reject (auth) — nur Deck-Owner; getrennt von close, damit der PR-Author klares Feedback hat - POST /pull-requests/:id/merge (auth) — nur Deck-Owner; baut neue card-list aus latest version + diff (removes weglassen, modifies fields-replace, adds anhängen mit re-counted ord), schreibt publicDeckVersions + publicDeckCards atomar in einer Drizzle- Transaction, bumpt latestVersionId und setzt PR auf merged. Default-Semver-Bump: minor (1.0.0 → 1.1.0). Authorenüberschreibbar via mergeNote/newSemver-Body-Felder. Card-Discussions (Threads pro card_content_hash, überleben Versions-Bumps solange Karten-Inhalt bleibt): - POST /decks/:slug/cards/:hash/discussions (auth) — neuer Thread oder Reply (parent_id muss in derselben card_content_hash-Gruppe leben → 422 sonst) - GET /cards/:hash/discussions (optional-auth) — Liste sichtbarer Comments, hidden gefiltert - GET /decks/:slug/discussions/counts (optional-auth) — Bulk-Count pro card_content_hash für Deck-Übersicht (kein N+1) - POST /discussions/:id/hide (auth) — Soft-Hide (Author oder Deck- Owner); kein Delete, Audit-Trail bleibt Helpers: - lib/marketplace/semver.ts — bumpMinor, isSemver, semverGreater (klein, ohne Range-Logik). Wird von PRs + später vom decks.ts publish-Flow konsumiert. Bug-Fix: - routes/marketplace/fork.ts hatte r.use('*', authMiddleware) am Anfang. An dem /api/v1/marketplace-Mount-Punkt fängt das Wildcard alle nachfolgenden Router-Mounts (PRs, Discussions) → anonymer GET /pull-requests wurde mit 401 abgelehnt. Refactor auf per- route authMiddleware (Pattern wie in subscriptions.ts und engagement.ts seit R3). Lessons learned dokumentiert in der STATUS.md-Zeile. Verifikation: - type-check 0 errors - 11 neue Semver-Tests, 89 gesamt grün - E2E-Smoke gegen lokale cards-api durch: · Cardecky publisht v1.0.0 (Apatheia, Eudaimonia, Logos) · Till's PR: modify Eudaimonia-Back, remove Logos, add Tugendlehre · Till's Merge-Versuch → 403 (deck_owner_only) · Cardecky merged → v1.1.0 atomar, card_count=3, ord-Reihenfolge: [Apatheia, Eudaimonia-mit-neuem-Back, Tugendlehre] · Re-Merge → 409 (pr_already_merged) · Till's Discussion-Thread + Cardecky-Reply mit parent_id · Cross-Card-parent abgelehnt → 422 · Hide → Comment verschwindet aus Liste, total von 2 auf 1 · Bulk-Counts liefert {hash → 2} · Smart-Merge-Pull v1.0.0→v1.1.0 wertet PR-Merge korrekt aus (changed=2 via Eudaimonia + Logos↔Tugendlehre ord-Heuristik) Verbleibend: R5 Frontend-Routes (/explore, /d/[slug], /u/[slug], /me/{published,subscribed,forks}), R6 voller UI-E2E. Co-Authored-By: Claude Opus 4.7 (1M context) --- STATUS.md | 2 +- apps/api/src/index.ts | 4 + apps/api/src/lib/marketplace/semver.ts | 31 ++ .../api/src/routes/marketplace/discussions.ts | 165 ++++++++ apps/api/src/routes/marketplace/fork.ts | 10 +- .../src/routes/marketplace/pull-requests.ts | 383 ++++++++++++++++++ apps/api/tests/marketplace-semver.test.ts | 48 +++ 7 files changed, 638 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/lib/marketplace/semver.ts create mode 100644 apps/api/src/routes/marketplace/discussions.ts create mode 100644 apps/api/src/routes/marketplace/pull-requests.ts create mode 100644 apps/api/tests/marketplace-semver.test.ts diff --git a/STATUS.md b/STATUS.md index ffb8a25..e537869 100644 --- a/STATUS.md +++ b/STATUS.md @@ -98,7 +98,7 @@ Vollständiger Plan: [`mana/docs/playbooks/CARDS_GREENFIELD.md`](../mana/docs/pl | 9 | Polish (DSGVO-UI, Settings, Account, Statistik, i18n, A11y, Media, Image-Occlusion) | 🟡 weit | Card-Edit + Cloze-Editor + Inbox-Banner + Account/DSGVO + Statistik + Pre-Flight-Swap + i18n DE/EN + A11y-Pass + Cloze-Hint-Anzeige + Anki-Re-Import-Dedupe + MinIO-Media-Upload + Image-Occlusion durch (9a–9l). Verbleibend: type-in, audio, multiple-choice (Schema vorbereitet) | | 10 | Production-Deploy (Mac Mini, Cloudflare-Tunnel) | ✅ live 2026-05-08 | cardecky.mana.how + cardecky-api.mana.how, alte cards.* via nginx-301-Redirect | | 11 | Decommission Cards-Modul aus mana-monorepo | ✅ 2026-05-08 | apps/cards, services/cards-server, packages/cards-core, mana-app cards-Modul + cross-refs entfernt (4 Commits, type-check 0 errors) | -| 12 | Marketplace-Restore (R0–R6) | 🟡 R0+R1+R2+R3 durch | Plan: [`docs/playbooks/MARKETPLACE_RESTORE.md`](docs/playbooks/MARKETPLACE_RESTORE.md). R0 (Doku + Restore-Plan + Strategie-B-Klarstellung): ✅. R1 (16 Tabellen + 5 Enums in `marketplace`-pgSchema): ✅. R2 (Backend α + β: Author-Routen + Deck-Init + Publish-Flow): ✅. **R3 (γ + δ Discovery + Engagement + Subscribe + Smart-Merge): ✅** — `GET /explore` (featured + trending), `GET /decks` (browse mit q/tag/lang/author/sort/limit/offset), `GET /tags`, `POST/DELETE/GET /decks/:slug/star`, `POST/DELETE/GET /authors/:slug/follow`, `POST/DELETE/GET /decks/:slug/subscribe`, `GET /me/subscriptions` (mit update_available-Flag), `GET /decks/:slug/versions/:semver`, `GET /decks/:slug/diff?from=:semver`, `POST /decks/:slug/fork` (private cards.decks-Kopie mit `forked_from_marketplace_*`-Pointern + frischen FSRS-Reviews), `POST /private/:deckId/pull-update` (Smart-Merge-Pull: hash-equality dedupe via `@cards/domain.cardContentHash` lässt unveränderte Karten **inkl. ihrer FSRS-Reviews komplett in Ruhe**, neue/geänderte Karten kommen als private Insert dazu). 6 neue Diff-Heuristik-Unit-Tests, 78 gesamt grün. **End-to-End-Smoke verifiziert**: Cardecky publisht v1.0.0 → Till forkt → Till studiert Apatheia (state=review, stability=10, reps=3) → Cardecky publisht v1.1.0 (Logos geändert + Tugendlehre neu) → Till pull-update → Apatheia-Review intakt, +Tugendlehre + neue Logos-Karte als zusätzliche Inserts. Verbleibend: R4 ε (PRs + Discussions), R5 Frontend-Routes, R6 voller End-to-End-Smoke gegen UI. | +| 12 | Marketplace-Restore (R0–R6) | 🟡 R0+R1+R2+R3+R4 durch | Plan: [`docs/playbooks/MARKETPLACE_RESTORE.md`](docs/playbooks/MARKETPLACE_RESTORE.md). R0 (Doku): ✅. R1 (Schema): ✅. R2 (α+β Authors + Publish): ✅. R3 (γ+δ Discovery + Engagement + Subscribe + Smart-Merge mit FSRS-State-Erhalt): ✅. **R4 (ε Pull-Requests + Card-Discussions): ✅** — `POST/GET /decks/:slug/pull-requests` (PR-Erstellung mit add/modify/remove-Diff, public List), `GET /pull-requests/:id`, `POST /pull-requests/:id/{close,reject,merge}` mit Lifecycle-Enforcement (open→merged|closed|rejected), Merge ist Owner-only und erzeugt atomar eine neue Version mit semver-minor-Bump (1.0.0→1.1.0 default), bumpt `latest_version_id`, schreibt PR-Resolution. Card-Discussions: `POST /decks/:slug/cards/:hash/discussions` (auth, Threads keyed auf `card_content_hash` damit sie Versions-Bumps überleben), `GET /cards/:hash/discussions` (public read, hidden filtered), `GET /decks/:slug/discussions/counts` (Bulk pro Karte), `POST /discussions/:id/hide` (Author oder Deck-Owner). 11 neue Semver-Unit-Tests, 89 gesamt grün. **E2E-Smoke**: Cardecky publisht v1.0.0 (Apatheia, Eudaimonia, Logos) → Till submitted PR (modify Eudaimonia-Back, remove Logos, add Tugendlehre) → Till's Merge-Versuch wird mit 403 abgelehnt (deck_owner_only) → Cardecky merged → v1.1.0 entsteht atomar mit korrektem Karten-Mix in Ord-Reihenfolge → re-merge wird mit 409 abgelehnt → Till postet Frage zur Apatheia-Karte → Cardecky antwortet mit parent_id (Threading) → Cross-Card-Parent wird mit 422 abgelehnt → Hide-Operation versteckt vom Read aus → Bulk-Counts korrekt → Smart-Merge-Pull gegen v1.0.0→v1.1.0 zeigt 2 changed (Eudaimonia + Logos↔Tugendlehre über ord-Heuristik), 0 cards_inserted weil bereits-private-via-Fork. Bug-Fix: `r.use('*', authMiddleware)` in fork.ts wäre an dem `/api/v1/marketplace`-Mount-Punkt nachfolgende Router-Mounts (PRs, Discussions) versehentlich gefangen — Refactor auf per-route Middleware. Verbleibend: R5 Frontend-Routes, R6 voller UI-E2E. | Legende: ✅ erledigt + verifiziert · 🚧 blockiert · ⏸ noch nicht begonnen diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 3c20b13..d5335c5 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -19,6 +19,8 @@ import { exploreRouter as marketplaceExploreRouter } from './routes/marketplace/ 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'; +import { pullRequestsRouter as marketplacePullRequestsRouter } from './routes/marketplace/pull-requests.ts'; +import { discussionsRouter as marketplaceDiscussionsRouter } from './routes/marketplace/discussions.ts'; const app = new Hono(); @@ -62,6 +64,8 @@ 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', marketplacePullRequestsRouter()); +app.route('/api/v1/marketplace', marketplaceDiscussionsRouter()); app.route('/api/v1/marketplace/authors', marketplaceAuthorsRouter()); app.route('/api/v1/marketplace/decks', marketplaceDecksRouter()); diff --git a/apps/api/src/lib/marketplace/semver.ts b/apps/api/src/lib/marketplace/semver.ts new file mode 100644 index 0000000..41b41a9 --- /dev/null +++ b/apps/api/src/lib/marketplace/semver.ts @@ -0,0 +1,31 @@ +/** + * Mini-SemVer-Helpers — keine Range-/Caret-/Tilde-Logik, nur die zwei + * Operationen die der Marketplace braucht. + */ + +const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/; + +export function isSemver(value: string): boolean { + return SEMVER_RE.test(value); +} + +/** `1.2.3` → `1.3.0`. Default-Bump beim PR-Merge. */ +export function bumpMinor(semver: string): string { + const m = semver.match(SEMVER_RE); + if (!m) return '1.0.0'; + return `${m[1]}.${Number(m[2]) + 1}.0`; +} + +/** Strict-greater Vergleich. Wirft NICHT bei ungültigem Input — gibt `false`. */ +export function semverGreater(a: string, b: string): boolean { + const ma = a.match(SEMVER_RE); + const mb = b.match(SEMVER_RE); + if (!ma || !mb) return false; + for (let i = 1; i <= 3; i++) { + const da = Number.parseInt(ma[i], 10); + const db = Number.parseInt(mb[i], 10); + if (da > db) return true; + if (da < db) return false; + } + return false; +} diff --git a/apps/api/src/routes/marketplace/discussions.ts b/apps/api/src/routes/marketplace/discussions.ts new file mode 100644 index 0000000..716f7cc --- /dev/null +++ b/apps/api/src/routes/marketplace/discussions.ts @@ -0,0 +1,165 @@ +import { and, asc, eq, sql } from 'drizzle-orm'; +import { Hono } from 'hono'; +import { z } from 'zod'; + +import { getDb, type CardsDb } from '../../db/connection.ts'; +import { cardDiscussions, publicDecks } from '../../db/schema/index.ts'; +import { authMiddleware, type AuthVars } from '../../middleware/auth.ts'; +import { optionalAuthMiddleware } from '../../middleware/marketplace/optional-auth.ts'; + +/** + * Card-Discussions — leichtgewichtige Inline-Threads keyed auf + * `card_content_hash` (nicht card-id), damit ein Thread Versions-Bumps + * überlebt solange der Karten-Inhalt bleibt. + * + * Threads sind flach-mit-parent: jede Reply hat optionalen `parent_id` + * → noch eine Discussion in derselben `card_content_hash`-Gruppe. UI + * rendert MVP-flach, eine Threading-Tiefe ist Schema-vorbereitet. + * + * Geport aus + * `cards-decommission-base:services/cards-server/src/services/discussions.ts`. + */ + +export type MarketplaceDiscussionsDeps = { db?: CardsDb }; + +const PostSchema = z.object({ + body: z.string().min(1).max(2000), + parentId: z.string().uuid().optional(), +}); + +function toDto(row: typeof cardDiscussions.$inferSelect) { + return { + id: row.id, + card_content_hash: row.cardContentHash, + deck_id: row.deckId, + author_user_id: row.authorUserId, + parent_id: row.parentId, + body: row.body, + hidden: row.hidden, + created_at: row.createdAt.toISOString(), + }; +} + +async function findDeckBySlug(db: CardsDb, slug: string) { + const [row] = await db.select().from(publicDecks).where(eq(publicDecks.slug, slug)).limit(1); + return row ?? null; +} + +export function discussionsRouter( + deps: MarketplaceDiscussionsDeps = {} +): Hono<{ Variables: Partial }> { + const r = new Hono<{ Variables: Partial }>(); + const dbOf = () => deps.db ?? getDb(); + + // POST /decks/:slug/cards/:hash/discussions — neuer Thread/Reply + r.post('/decks/:slug/cards/:hash/discussions', authMiddleware, async (c) => { + const userId = c.get('userId'); + const slug = c.req.param('slug'); + const cardHash = c.req.param('hash'); + const body = await c.req.json().catch(() => null); + const parsed = PostSchema.safeParse(body); + if (!parsed.success) { + return c.json( + { error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) }, + 422 + ); + } + + const db = dbOf(); + const deck = await findDeckBySlug(db, slug); + if (!deck) return c.json({ error: 'not_found' }, 404); + + // Parent muss in derselben card_content_hash-Gruppe leben. + if (parsed.data.parentId) { + const [parent] = await db + .select() + .from(cardDiscussions) + .where(eq(cardDiscussions.id, parsed.data.parentId)) + .limit(1); + if (!parent) return c.json({ error: 'parent_not_found' }, 404); + if (parent.cardContentHash !== cardHash) { + return c.json({ error: 'parent_belongs_to_different_card' }, 422); + } + } + + const [row] = await db + .insert(cardDiscussions) + .values({ + cardContentHash: cardHash, + deckId: deck.id, + authorUserId: userId, + parentId: parsed.data.parentId ?? null, + body: parsed.data.body, + }) + .returning(); + return c.json(toDto(row), 201); + }); + + // GET /cards/:hash/discussions — alle sichtbaren Comments für eine Karte + r.get('/cards/:hash/discussions', optionalAuthMiddleware, async (c) => { + const cardHash = c.req.param('hash'); + const rows = await dbOf() + .select() + .from(cardDiscussions) + .where( + and( + eq(cardDiscussions.cardContentHash, cardHash), + eq(cardDiscussions.hidden, false) + ) + ) + .orderBy(asc(cardDiscussions.createdAt)); + return c.json({ discussions: rows.map(toDto), total: rows.length }); + }); + + // GET /decks/:slug/discussions/counts — bulk-counts pro card_content_hash + r.get('/decks/:slug/discussions/counts', optionalAuthMiddleware, async (c) => { + const slug = c.req.param('slug'); + const db = dbOf(); + const deck = await findDeckBySlug(db, slug); + if (!deck) return c.json({ error: 'not_found' }, 404); + + const rows = await db + .select({ + contentHash: cardDiscussions.cardContentHash, + count: sql`count(*)::int`.as('count'), + }) + .from(cardDiscussions) + .where(and(eq(cardDiscussions.deckId, deck.id), eq(cardDiscussions.hidden, false))) + .groupBy(cardDiscussions.cardContentHash); + + const counts: Record = {}; + for (const row of rows) counts[row.contentHash] = row.count; + return c.json({ counts }); + }); + + // POST /discussions/:id/hide — Soft-Hide (Author oder Deck-Owner) + r.post('/discussions/:id/hide', authMiddleware, async (c) => { + const userId = c.get('userId'); + const id = c.req.param('id'); + const db = dbOf(); + const [row] = await db + .select() + .from(cardDiscussions) + .where(eq(cardDiscussions.id, id)) + .limit(1); + if (!row) return c.json({ error: 'not_found' }, 404); + + const [deck] = await db + .select() + .from(publicDecks) + .where(eq(publicDecks.id, row.deckId)) + .limit(1); + if (!deck) return c.json({ error: 'deck_not_found' }, 404); + if (row.authorUserId !== userId && deck.ownerUserId !== userId) { + return c.json({ error: 'forbidden', detail: 'comment_author_or_deck_owner_only' }, 403); + } + + await db + .update(cardDiscussions) + .set({ hidden: true }) + .where(eq(cardDiscussions.id, id)); + return c.json({ hidden: true }); + }); + + return r; +} diff --git a/apps/api/src/routes/marketplace/fork.ts b/apps/api/src/routes/marketplace/fork.ts index 93875f2..bb4dfb9 100644 --- a/apps/api/src/routes/marketplace/fork.ts +++ b/apps/api/src/routes/marketplace/fork.ts @@ -109,10 +109,12 @@ export function forkRouter(deps: MarketplaceForkDeps = {}): Hono<{ Variables: Au const r = new Hono<{ Variables: AuthVars }>(); const dbOf = () => deps.db ?? getDb(); - r.use('*', authMiddleware); + // Per-route Middleware: ein Wildcard `r.use('*', …)` würde an dem + // /api/v1/marketplace-Mount-Punkt sonst nachfolgende Router-Mounts + // (PRs, Discussions) versehentlich fangen. Lessons learned aus R3. // POST /decks/:slug/fork — Marketplace → privater Deck. - r.post('/decks/:slug/fork', async (c) => { + r.post('/decks/:slug/fork', authMiddleware, async (c) => { const userId = c.get('userId'); const slug = c.req.param('slug'); const body = await c.req.json().catch(() => ({})); @@ -197,8 +199,8 @@ export function forkRouter(deps: MarketplaceForkDeps = {}): Hono<{ Variables: Au ); }); - // POST /decks/:private-deck-id/pull-update — Smart-Merge-Pull. - r.post('/private/:deckId/pull-update', async (c) => { + // POST /private/:deckId/pull-update — Smart-Merge-Pull. + r.post('/private/:deckId/pull-update', authMiddleware, async (c) => { const userId = c.get('userId'); const deckId = c.req.param('deckId'); const db = dbOf(); diff --git a/apps/api/src/routes/marketplace/pull-requests.ts b/apps/api/src/routes/marketplace/pull-requests.ts new file mode 100644 index 0000000..170d123 --- /dev/null +++ b/apps/api/src/routes/marketplace/pull-requests.ts @@ -0,0 +1,383 @@ +import { and, asc, desc, eq } from 'drizzle-orm'; +import { Hono } from 'hono'; +import { z } from 'zod'; + +import { cardContentHash } from '@cards/domain'; + +import { getDb, type CardsDb } from '../../db/connection.ts'; +import { + deckPullRequests, + 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 { bumpMinor, isSemver } from '../../lib/marketplace/semver.ts'; +import { hashVersionCards } from '../../lib/marketplace/version-hash.ts'; + +/** + * Pull-Requests auf Decks. Differentiator zu Anki/Quizlet: Subscriber + * können einen card-level Patch einreichen, der Deck-Author reviewed + + * merged, und der Merge erzeugt automatisch eine neue Version, die + * über Smart-Merge zu allen anderen Subscribern fließt. + * + * Diff-Modell (3-way): + * - add: neue Karten (Server picked next ord) + * - modify: ersetzt existierende Karten by previousContentHash + * - remove: drop Karten by contentHash + * + * Status-Lifecycle: + * open ──merge──► merged (erzeugt eine neue deck_version) + * open ──close──► closed (Author oder PR-Author kann) + * open ──reject─► rejected (nur Deck-Author — getrennt von „closed", + * damit der PR-Author klares Feedback hat) + * + * Default-Semver-Bump beim Merge: minor (1.2.0 → 1.3.0). Author kann + * im Merge-Body überschreiben. + * + * Geport aus + * `cards-decommission-base:services/cards-server/src/services/pull-requests.ts` + * (Notify-Calls über mana-notify ausgelassen — eigene Welle). + */ + +export type MarketplacePullRequestsDeps = { db?: CardsDb }; + +const CardTypeEnum = z.enum([ + 'basic', + 'basic-reverse', + 'cloze', + 'type-in', + 'image-occlusion', + 'audio', + 'multiple-choice', +]); + +const CardPayloadSchema = z.object({ + type: CardTypeEnum, + fields: z.record(z.string()), +}); + +const CreatePrSchema = z.object({ + title: z.string().min(1).max(140), + body: z.string().max(4000).optional(), + diff: z.object({ + add: z.array(CardPayloadSchema).default([]), + modify: z + .array( + CardPayloadSchema.extend({ + previousContentHash: z.string().min(1), + }) + ) + .default([]), + remove: z.array(z.object({ contentHash: z.string().min(1) })).default([]), + }), +}); + +const MergeSchema = z.object({ + newSemver: z + .string() + .regex(/^\d+\.\d+\.\d+$/) + .optional(), + mergeNote: z.string().max(2000).optional(), +}); + +interface StoredDiff { + add: { type: string; fields: Record }[]; + modify: { contentHash: string; fields: Record }[]; + remove: { contentHash: string }[]; +} + +function toPrDto(pr: typeof deckPullRequests.$inferSelect) { + return { + id: pr.id, + deck_id: pr.deckId, + author_user_id: pr.authorUserId, + status: pr.status, + title: pr.title, + body: pr.body, + diff: pr.diff, + merged_into_version_id: pr.mergedIntoVersionId, + created_at: pr.createdAt.toISOString(), + resolved_at: pr.resolvedAt?.toISOString() ?? null, + }; +} + +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 findPrById(db: CardsDb, id: string) { + const [row] = await db.select().from(deckPullRequests).where(eq(deckPullRequests.id, id)).limit(1); + return row ?? null; +} + +export function pullRequestsRouter( + deps: MarketplacePullRequestsDeps = {} +): Hono<{ Variables: Partial }> { + const r = new Hono<{ Variables: Partial }>(); + const dbOf = () => deps.db ?? getDb(); + + // ─── Create + List + Get ───────────────────────────────────────── + + r.post('/decks/:slug/pull-requests', authMiddleware, async (c) => { + const userId = c.get('userId'); + const slug = c.req.param('slug'); + const body = await c.req.json().catch(() => null); + const parsed = CreatePrSchema.safeParse(body); + if (!parsed.success) { + return c.json( + { error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) }, + 422 + ); + } + + 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); + + const total = + parsed.data.diff.add.length + + parsed.data.diff.modify.length + + parsed.data.diff.remove.length; + if (total === 0) return c.json({ error: 'empty_diff' }, 422); + + const storedDiff: StoredDiff = { + add: parsed.data.diff.add.map((card) => ({ type: card.type, fields: card.fields })), + modify: parsed.data.diff.modify.map((m) => ({ + contentHash: m.previousContentHash, + fields: m.fields, + })), + remove: parsed.data.diff.remove.map((r) => ({ contentHash: r.contentHash })), + }; + + const [pr] = await db + .insert(deckPullRequests) + .values({ + deckId: deck.id, + authorUserId: userId, + title: parsed.data.title, + body: parsed.data.body, + status: 'open', + diff: storedDiff, + }) + .returning(); + return c.json(toPrDto(pr), 201); + }); + + r.get('/decks/:slug/pull-requests', optionalAuthMiddleware, async (c) => { + const slug = c.req.param('slug'); + const status = c.req.query('status'); + const validStatuses = ['open', 'merged', 'closed', 'rejected'] as const; + const isValidStatus = (s: string | undefined): s is (typeof validStatuses)[number] => + !!s && (validStatuses as readonly string[]).includes(s); + + const db = dbOf(); + const deck = await findDeckBySlug(db, slug); + if (!deck) return c.json({ error: 'not_found' }, 404); + + const where = isValidStatus(status) + ? and(eq(deckPullRequests.deckId, deck.id), eq(deckPullRequests.status, status)) + : eq(deckPullRequests.deckId, deck.id); + const rows = await db + .select() + .from(deckPullRequests) + .where(where) + .orderBy(desc(deckPullRequests.createdAt)); + return c.json({ pull_requests: rows.map(toPrDto), total: rows.length }); + }); + + r.get('/pull-requests/:id', optionalAuthMiddleware, async (c) => { + const id = c.req.param('id'); + const db = dbOf(); + const pr = await findPrById(db, id); + if (!pr) return c.json({ error: 'not_found' }, 404); + return c.json(toPrDto(pr)); + }); + + // ─── Lifecycle: Close, Reject, Merge ───────────────────────────── + + r.post('/pull-requests/:id/close', authMiddleware, async (c) => { + const userId = c.get('userId'); + const id = c.req.param('id'); + const db = dbOf(); + const pr = await findPrById(db, id); + if (!pr) return c.json({ error: 'not_found' }, 404); + if (pr.status !== 'open') return c.json({ error: `pr_already_${pr.status}` }, 409); + + const [deck] = await db.select().from(publicDecks).where(eq(publicDecks.id, pr.deckId)).limit(1); + if (!deck) return c.json({ error: 'deck_not_found' }, 404); + if (pr.authorUserId !== userId && deck.ownerUserId !== userId) { + return c.json({ error: 'forbidden', detail: 'pr_author_or_deck_owner_only' }, 403); + } + await db + .update(deckPullRequests) + .set({ status: 'closed', resolvedAt: new Date() }) + .where(eq(deckPullRequests.id, id)); + return c.json({ status: 'closed' }); + }); + + r.post('/pull-requests/:id/reject', authMiddleware, async (c) => { + const userId = c.get('userId'); + const id = c.req.param('id'); + const db = dbOf(); + const pr = await findPrById(db, id); + if (!pr) return c.json({ error: 'not_found' }, 404); + if (pr.status !== 'open') return c.json({ error: `pr_already_${pr.status}` }, 409); + + const [deck] = await db.select().from(publicDecks).where(eq(publicDecks.id, pr.deckId)).limit(1); + if (!deck) return c.json({ error: 'deck_not_found' }, 404); + if (deck.ownerUserId !== userId) { + return c.json({ error: 'forbidden', detail: 'deck_owner_only' }, 403); + } + await db + .update(deckPullRequests) + .set({ status: 'rejected', resolvedAt: new Date() }) + .where(eq(deckPullRequests.id, id)); + return c.json({ status: 'rejected' }); + }); + + r.post('/pull-requests/:id/merge', authMiddleware, async (c) => { + const userId = c.get('userId'); + const id = c.req.param('id'); + const body = await c.req.json().catch(() => ({})); + const parsed = MergeSchema.safeParse(body); + if (!parsed.success) { + return c.json( + { error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) }, + 422 + ); + } + + const db = dbOf(); + const pr = await findPrById(db, id); + if (!pr) return c.json({ error: 'not_found' }, 404); + if (pr.status !== 'open') return c.json({ error: `pr_already_${pr.status}` }, 409); + + const [deck] = await db.select().from(publicDecks).where(eq(publicDecks.id, pr.deckId)).limit(1); + if (!deck) return c.json({ error: 'deck_not_found' }, 404); + if (deck.ownerUserId !== userId) { + return c.json({ error: 'forbidden', detail: 'deck_owner_only' }, 403); + } + if (!deck.latestVersionId) { + return c.json({ error: 'no_published_version' }, 409); + } + + const [latest] = await db + .select() + .from(publicDeckVersions) + .where(eq(publicDeckVersions.id, deck.latestVersionId)) + .limit(1); + if (!latest) return c.json({ error: 'latest_version_missing' }, 500); + + const newSemver = parsed.data.newSemver ?? bumpMinor(latest.semver); + if (!isSemver(newSemver)) return c.json({ error: 'invalid_semver' }, 422); + + const currentCards = await db + .select() + .from(publicDeckCards) + .where(eq(publicDeckCards.versionId, latest.id)) + .orderBy(asc(publicDeckCards.ord)); + + const diff = pr.diff as StoredDiff; + const removedHashes = new Set(diff.remove.map((entry) => entry.contentHash)); + const modifyByHash = new Map(diff.modify.map((entry) => [entry.contentHash, entry.fields])); + + // Apply: removed werden weggelassen, modified ersetzen die fields + // (type bleibt aus dem alten Card-Eintrag, weil der PR keinen + // type-Switch erlaubt — modify ist ein field-only Replace), added + // werden hinten angehängt mit re-counted ord. + const merged: { type: string; fields: Record; ord: number }[] = []; + let nextOrd = 0; + for (const card of currentCards) { + if (removedHashes.has(card.contentHash)) continue; + const replaced = modifyByHash.get(card.contentHash); + merged.push({ + type: card.type, + fields: replaced ?? (card.fields as Record), + ord: nextOrd++, + }); + } + for (const added of diff.add) { + merged.push({ type: added.type, fields: added.fields, ord: nextOrd++ }); + } + + if (merged.length === 0) { + return c.json({ error: 'merge_would_empty_deck' }, 422); + } + + const versionContentHash = await hashVersionCards(merged); + const cardHashes = await Promise.all( + merged.map((card) => cardContentHash({ type: card.type, fields: card.fields })) + ); + + const result = await db.transaction(async (tx) => { + const [version] = await tx + .insert(publicDeckVersions) + .values({ + deckId: deck.id, + semver: newSemver, + changelog: + parsed.data.mergeNote ?? + `Merged PR: ${pr.title} (+${diff.add.length} added, ~${diff.modify.length} modified, −${diff.remove.length} removed)`, + contentHash: versionContentHash, + cardCount: merged.length, + }) + .returning(); + + await tx.insert(publicDeckCards).values( + merged.map((card, i) => ({ + versionId: version.id, + type: card.type as + | 'basic' + | 'basic-reverse' + | 'cloze' + | 'type-in' + | 'image-occlusion' + | 'audio' + | 'multiple-choice', + fields: card.fields, + ord: card.ord, + contentHash: cardHashes[i], + })) + ); + + await tx + .update(publicDecks) + .set({ latestVersionId: version.id }) + .where(eq(publicDecks.id, deck.id)); + + const [updatedPr] = await tx + .update(deckPullRequests) + .set({ + status: 'merged', + mergedIntoVersionId: version.id, + resolvedAt: new Date(), + }) + .where(eq(deckPullRequests.id, id)) + .returning(); + + return { version, pr: updatedPr }; + }); + + return c.json( + { + pull_request: toPrDto(result.pr), + version: { + id: result.version.id, + deck_id: result.version.deckId, + semver: result.version.semver, + content_hash: result.version.contentHash, + card_count: result.version.cardCount, + changelog: result.version.changelog, + published_at: result.version.publishedAt.toISOString(), + }, + }, + 201 + ); + }); + + return r; +} diff --git a/apps/api/tests/marketplace-semver.test.ts b/apps/api/tests/marketplace-semver.test.ts new file mode 100644 index 0000000..99a2ed1 --- /dev/null +++ b/apps/api/tests/marketplace-semver.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { bumpMinor, isSemver, semverGreater } from '../src/lib/marketplace/semver.ts'; + +describe('isSemver', () => { + it('accepts strict X.Y.Z', () => { + expect(isSemver('1.0.0')).toBe(true); + expect(isSemver('0.0.1')).toBe(true); + expect(isSemver('123.456.789')).toBe(true); + }); + it('rejects pre-releases', () => { + expect(isSemver('1.0.0-rc1')).toBe(false); + }); + it('rejects 2-segment', () => { + expect(isSemver('1.0')).toBe(false); + }); +}); + +describe('bumpMinor', () => { + it('1.2.3 → 1.3.0', () => { + expect(bumpMinor('1.2.3')).toBe('1.3.0'); + }); + it('0.9.99 → 0.10.0', () => { + expect(bumpMinor('0.9.99')).toBe('0.10.0'); + }); + it('invalid → 1.0.0', () => { + expect(bumpMinor('garbage')).toBe('1.0.0'); + }); +}); + +describe('semverGreater', () => { + it('major', () => { + expect(semverGreater('2.0.0', '1.99.99')).toBe(true); + }); + it('minor', () => { + expect(semverGreater('1.2.0', '1.1.99')).toBe(true); + }); + it('patch', () => { + expect(semverGreater('1.0.1', '1.0.0')).toBe(true); + }); + it('equal is not greater', () => { + expect(semverGreater('1.0.0', '1.0.0')).toBe(false); + }); + it('invalid input → false', () => { + expect(semverGreater('garbage', '1.0.0')).toBe(false); + expect(semverGreater('1.0.0', 'garbage')).toBe(false); + }); +});