Phase 12 R4: Marketplace ε — Pull-Requests + Card-Discussions
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) <noreply@anthropic.com>
This commit is contained in:
parent
d45f1c0079
commit
92a1d5804f
7 changed files with 638 additions and 5 deletions
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
31
apps/api/src/lib/marketplace/semver.ts
Normal file
31
apps/api/src/lib/marketplace/semver.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
165
apps/api/src/routes/marketplace/discussions.ts
Normal file
165
apps/api/src/routes/marketplace/discussions.ts
Normal file
|
|
@ -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<AuthVars> }> {
|
||||
const r = new Hono<{ Variables: Partial<AuthVars> }>();
|
||||
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<number>`count(*)::int`.as('count'),
|
||||
})
|
||||
.from(cardDiscussions)
|
||||
.where(and(eq(cardDiscussions.deckId, deck.id), eq(cardDiscussions.hidden, false)))
|
||||
.groupBy(cardDiscussions.cardContentHash);
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
383
apps/api/src/routes/marketplace/pull-requests.ts
Normal file
383
apps/api/src/routes/marketplace/pull-requests.ts
Normal file
|
|
@ -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<string, string> }[];
|
||||
modify: { contentHash: string; fields: Record<string, string> }[];
|
||||
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<AuthVars> }> {
|
||||
const r = new Hono<{ Variables: Partial<AuthVars> }>();
|
||||
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<string, string>; 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<string, string>),
|
||||
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;
|
||||
}
|
||||
48
apps/api/tests/marketplace-semver.test.ts
Normal file
48
apps/api/tests/marketplace-semver.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue