Routes (additiv unter /api/v1/marketplace/*): - POST/GET /authors/me — eigenes Author-Profil anlegen/updaten/lesen - GET /authors/:slug — public Profile-Lookup (banned-reason gestrippt) - POST /decks — Deck-Init (Slug-Validation + Pflicht-Author-Profil + CHECK auf paid + Pro-License) - POST /decks/:slug/publish — Versions-Snapshot mit per-Karte cardContentHash aus @cards/domain, per-Version-Hash, AI-Mod-Stub-Log, atomarer latest_version_id-Bump in Drizzle-Transaction - PATCH /decks/:slug — Metadaten-Update (Owner-Only) - GET /decks/:slug — Public-Detail mit optional-auth-Middleware Geport aus cards-decommission-base:services/cards-server/, mit Greenfield-Anpassungen: - Hashing über @cards/domain.cardContentHash (gemeinsame SoT zwischen privatem cards.cards und marketplace.deck_cards), per- Version-Hash als SHA-256 über sortierte Karten-Hashes mit Ord-Prefix - AI-Moderation als R2-Stub (pass+rationale+model='stub'), echte mana-llm-Anbindung in späterer Welle - Auth-Middleware-Shape an Greenfield (userId/tier/authMode in c.get(...) statt user-Object), optional-auth als Schwester für anonymen Public-Read - Hono-typing: outer Marketplace-Decks-Router ist Partial<AuthVars> weil Public-GET kein JWT braucht; Auth-Subroute ist strict Lese-Referenz: - 3331 LOC altes cards-server-Code (routes, services, middleware, lib) unter docs/marketplace/archive/code/ archiviert. Read-only, nicht im Build-Path. Verifikation: - 16 neue Vitest-Tests (Slug + Version-Hash), 72 gesamt grün - type-check 0 errors - E2E-Smoke gegen lokale cards-api: Cardecky-Author + Deck r2-stoische-ethik mit 3 Karten v1.0.0 (basic + basic + cloze), per-Karten-Hashes geschrieben, ai_moderation_log-Row da, semver-409 + paid-422-Errors verifiziert. Smoke-Daten danach aufgeräumt. Verbleibend für R3+: Discovery (explore + search), Engagement (stars/ subscribe/fork), Smart-Merge mit FSRS-State-Erhalt; danach R4 PRs + Card-Discussions, R5 Frontend-Routes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
318 lines
10 KiB
TypeScript
318 lines
10 KiB
TypeScript
/**
|
||
* Pull-requests on decks. The differentiator vs. Anki/Quizlet/etc.:
|
||
* subscribers can submit a card-level patch, the deck author reviews
|
||
* + merges, and the merge auto-creates a new version that ripples
|
||
* through every other subscriber's smart-merge.
|
||
*
|
||
* The diff payload mirrors GitHub's three-way model in the small:
|
||
* - add: cards to insert (server picks the next ord)
|
||
* - modify: replace existing cards by previous-content-hash
|
||
* - remove: drop cards by content-hash
|
||
*
|
||
* Status lifecycle:
|
||
* open ──merge──► merged (creates a new deck_version)
|
||
* open ──close──► closed (author OR PR-author can close)
|
||
* open ──reject─► rejected (author-only — distinct from "closed"
|
||
* so the PR-author sees clear feedback)
|
||
*
|
||
* Merging bumps the deck's semver minor by default (1.2.0 → 1.3.0)
|
||
* unless the request specifies otherwise. Author can override at
|
||
* merge-time.
|
||
*/
|
||
|
||
import { and, desc, eq } from 'drizzle-orm';
|
||
import type { Database } from '../db/connection';
|
||
import { deckPullRequests, publicDeckCards, publicDeckVersions, publicDecks } from '../db/schema';
|
||
import { hashCard, hashVersionCards } from '../lib/hash';
|
||
import { BadRequestError, ForbiddenError, NotFoundError } from '../lib/errors';
|
||
import type { NotifyClient } from '../lib/notify';
|
||
|
||
export interface PullRequestDiffInput {
|
||
add: { type: string; fields: Record<string, string> }[];
|
||
modify: { previousContentHash: string; type: string; fields: Record<string, string> }[];
|
||
remove: { contentHash: string }[];
|
||
}
|
||
|
||
export interface CreatePullRequestInput {
|
||
title: string;
|
||
body?: string;
|
||
diff: PullRequestDiffInput;
|
||
}
|
||
|
||
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
|
||
|
||
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`;
|
||
}
|
||
|
||
export class PullRequestService {
|
||
constructor(
|
||
private readonly db: Database,
|
||
private readonly notify?: NotifyClient
|
||
) {}
|
||
|
||
async create(authorUserId: string, deckSlug: string, input: CreatePullRequestInput) {
|
||
const deck = await this.db.query.publicDecks.findFirst({
|
||
where: eq(publicDecks.slug, deckSlug),
|
||
});
|
||
if (!deck) throw new NotFoundError('Deck not found');
|
||
if (deck.isTakedown) throw new ForbiddenError('Deck under takedown');
|
||
|
||
const total = input.diff.add.length + input.diff.modify.length + input.diff.remove.length;
|
||
if (total === 0) throw new BadRequestError('Diff is empty');
|
||
|
||
const [pr] = await this.db
|
||
.insert(deckPullRequests)
|
||
.values({
|
||
deckId: deck.id,
|
||
authorUserId,
|
||
title: input.title,
|
||
body: input.body,
|
||
status: 'open',
|
||
diff: {
|
||
add: input.diff.add,
|
||
modify: input.diff.modify.map((m) => ({
|
||
contentHash: m.previousContentHash,
|
||
fields: m.fields,
|
||
})),
|
||
remove: input.diff.remove,
|
||
},
|
||
})
|
||
.returning();
|
||
|
||
// Don't notify on self-PRs (author proposing a change to their own deck).
|
||
if (this.notify && deck.ownerUserId !== authorUserId) {
|
||
void this.notify.send({
|
||
channel: 'email',
|
||
userId: deck.ownerUserId,
|
||
subject: `Neuer Pull Request für „${deck.title}"`,
|
||
body: `Du hast einen neuen Pull Request bekommen: „${input.title}"\n\nÖffne ${this.deckUrl(deckSlug)}, um zu reviewen.`,
|
||
data: {
|
||
type: 'cards.pr.created',
|
||
deckSlug,
|
||
prId: pr.id,
|
||
url: this.deckUrl(deckSlug),
|
||
},
|
||
externalId: `cards.pr.created.${pr.id}`,
|
||
});
|
||
}
|
||
|
||
return pr;
|
||
}
|
||
|
||
private deckUrl(slug: string): string {
|
||
const base = process.env.CARDS_WEB_URL || 'https://cardecky.mana.how';
|
||
return `${base}/d/${slug}`;
|
||
}
|
||
|
||
async list(deckSlug: string, status?: 'open' | 'merged' | 'closed' | 'rejected') {
|
||
const deck = await this.db.query.publicDecks.findFirst({
|
||
where: eq(publicDecks.slug, deckSlug),
|
||
});
|
||
if (!deck) throw new NotFoundError('Deck not found');
|
||
|
||
const where = status
|
||
? and(eq(deckPullRequests.deckId, deck.id), eq(deckPullRequests.status, status))
|
||
: eq(deckPullRequests.deckId, deck.id);
|
||
return this.db
|
||
.select()
|
||
.from(deckPullRequests)
|
||
.where(where)
|
||
.orderBy(desc(deckPullRequests.createdAt));
|
||
}
|
||
|
||
async get(prId: string) {
|
||
const pr = await this.db.query.deckPullRequests.findFirst({
|
||
where: eq(deckPullRequests.id, prId),
|
||
});
|
||
if (!pr) throw new NotFoundError('Pull request not found');
|
||
return pr;
|
||
}
|
||
|
||
async close(actorUserId: string, prId: string): Promise<void> {
|
||
const pr = await this.get(prId);
|
||
const deck = await this.db.query.publicDecks.findFirst({
|
||
where: eq(publicDecks.id, pr.deckId),
|
||
});
|
||
if (!deck) throw new NotFoundError('Deck not found');
|
||
// Either the deck owner or the PR author can close.
|
||
if (pr.authorUserId !== actorUserId && deck.ownerUserId !== actorUserId) {
|
||
throw new ForbiddenError('Only PR author or deck owner can close');
|
||
}
|
||
if (pr.status !== 'open') throw new BadRequestError(`PR already ${pr.status}`);
|
||
await this.db
|
||
.update(deckPullRequests)
|
||
.set({ status: 'closed', resolvedAt: new Date() })
|
||
.where(eq(deckPullRequests.id, prId));
|
||
}
|
||
|
||
async reject(actorUserId: string, prId: string): Promise<void> {
|
||
const pr = await this.get(prId);
|
||
const deck = await this.db.query.publicDecks.findFirst({
|
||
where: eq(publicDecks.id, pr.deckId),
|
||
});
|
||
if (!deck) throw new NotFoundError('Deck not found');
|
||
if (deck.ownerUserId !== actorUserId) {
|
||
throw new ForbiddenError('Only the deck owner can reject');
|
||
}
|
||
if (pr.status !== 'open') throw new BadRequestError(`PR already ${pr.status}`);
|
||
await this.db
|
||
.update(deckPullRequests)
|
||
.set({ status: 'rejected', resolvedAt: new Date() })
|
||
.where(eq(deckPullRequests.id, prId));
|
||
|
||
if (this.notify && pr.authorUserId !== actorUserId) {
|
||
void this.notify.send({
|
||
channel: 'email',
|
||
userId: pr.authorUserId,
|
||
subject: `Pull Request „${pr.title}" abgelehnt`,
|
||
body: `Dein Pull Request für „${deck.title}" wurde abgelehnt. Siehe ${this.deckUrl(deck.slug)}.`,
|
||
data: { type: 'cards.pr.rejected', prId: pr.id, deckSlug: deck.slug },
|
||
externalId: `cards.pr.rejected.${pr.id}`,
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Merge a PR. Builds a brand-new version's card list by applying
|
||
* the PR's diff to the deck's latest version, then writes the
|
||
* usual version + cards rows and bumps `latest_version_id`.
|
||
*
|
||
* The merge happens in a single transaction so a partial failure
|
||
* doesn't leave the deck pointing at an empty version.
|
||
*/
|
||
async merge(
|
||
actorUserId: string,
|
||
prId: string,
|
||
opts: { newSemver?: string; mergeNote?: string } = {}
|
||
) {
|
||
const pr = await this.get(prId);
|
||
if (pr.status !== 'open') throw new BadRequestError(`PR already ${pr.status}`);
|
||
|
||
const deck = await this.db.query.publicDecks.findFirst({
|
||
where: eq(publicDecks.id, pr.deckId),
|
||
});
|
||
if (!deck) throw new NotFoundError('Deck not found');
|
||
if (deck.ownerUserId !== actorUserId) {
|
||
throw new ForbiddenError('Only the deck owner can merge');
|
||
}
|
||
if (!deck.latestVersionId) {
|
||
throw new BadRequestError('Deck has no published version yet — publish first');
|
||
}
|
||
const latest = await this.db.query.publicDeckVersions.findFirst({
|
||
where: eq(publicDeckVersions.id, deck.latestVersionId),
|
||
});
|
||
if (!latest) throw new NotFoundError('Latest version row missing');
|
||
|
||
const newSemver = opts.newSemver ?? bumpMinor(latest.semver);
|
||
if (!SEMVER_RE.test(newSemver)) {
|
||
throw new BadRequestError(`Invalid semver: ${newSemver}`);
|
||
}
|
||
|
||
// Pull current cards as the base for the merge.
|
||
const currentCards = await this.db
|
||
.select()
|
||
.from(publicDeckCards)
|
||
.where(eq(publicDeckCards.versionId, latest.id))
|
||
.orderBy(publicDeckCards.ord);
|
||
|
||
const diff = pr.diff as {
|
||
add: { type: string; fields: Record<string, string> }[];
|
||
modify: { contentHash: string; fields: Record<string, string> }[];
|
||
remove: { contentHash: string }[];
|
||
};
|
||
|
||
const removedHashes = new Set(diff.remove.map((r) => r.contentHash));
|
||
const modifyByHash = new Map(diff.modify.map((m) => [m.contentHash, m.fields]));
|
||
|
||
const merged: { type: string; fields: Record<string, string>; ord: number }[] = [];
|
||
let nextOrd = 0;
|
||
for (const c of currentCards) {
|
||
if (removedHashes.has(c.contentHash)) continue;
|
||
const replaced = modifyByHash.get(c.contentHash);
|
||
merged.push({
|
||
type: c.type,
|
||
fields: replaced ?? (c.fields as Record<string, string>),
|
||
ord: nextOrd++,
|
||
});
|
||
}
|
||
for (const a of diff.add) {
|
||
merged.push({ type: a.type, fields: a.fields, ord: nextOrd++ });
|
||
}
|
||
|
||
if (merged.length === 0) {
|
||
throw new BadRequestError('Merge would result in an empty deck — refusing');
|
||
}
|
||
|
||
const versionContentHash = hashVersionCards(merged);
|
||
|
||
const result = await this.db.transaction(async (tx) => {
|
||
const [version] = await tx
|
||
.insert(publicDeckVersions)
|
||
.values({
|
||
deckId: deck.id,
|
||
semver: newSemver,
|
||
changelog:
|
||
opts.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((c) => ({
|
||
versionId: version.id,
|
||
type: c.type as
|
||
| 'basic'
|
||
| 'basic-reverse'
|
||
| 'cloze'
|
||
| 'type-in'
|
||
| 'image-occlusion'
|
||
| 'audio'
|
||
| 'multiple-choice',
|
||
fields: c.fields,
|
||
ord: c.ord,
|
||
contentHash: hashCard({ type: c.type, fields: c.fields }),
|
||
}))
|
||
);
|
||
|
||
await tx
|
||
.update(publicDecks)
|
||
.set({ latestVersionId: version.id })
|
||
.where(eq(publicDecks.id, deck.id));
|
||
|
||
await tx
|
||
.update(deckPullRequests)
|
||
.set({
|
||
status: 'merged',
|
||
mergedIntoVersionId: version.id,
|
||
resolvedAt: new Date(),
|
||
})
|
||
.where(eq(deckPullRequests.id, prId));
|
||
|
||
return { version };
|
||
});
|
||
|
||
if (this.notify && pr.authorUserId !== actorUserId) {
|
||
void this.notify.send({
|
||
channel: 'email',
|
||
userId: pr.authorUserId,
|
||
subject: `Pull Request „${pr.title}" gemerged`,
|
||
body: `Dein Pull Request für „${deck.title}" ist live in v${newSemver}. Danke für den Beitrag!`,
|
||
data: {
|
||
type: 'cards.pr.merged',
|
||
prId: pr.id,
|
||
deckSlug: deck.slug,
|
||
newSemver,
|
||
url: this.deckUrl(deck.slug),
|
||
},
|
||
externalId: `cards.pr.merged.${pr.id}`,
|
||
});
|
||
}
|
||
|
||
return { pullRequest: { ...pr, status: 'merged' as const }, version: result.version };
|
||
}
|
||
}
|