cards/docs/marketplace/archive/code/services/pull-requests.ts
Till JS 7dbbf63523 Phase 12 R2: Marketplace-Backend α + β — Authors + Deck-Init + Publish
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>
2026-05-09 15:13:58 +02:00

318 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 };
}
}