feat(cards): Phase ε — Pull-Requests + Card-Discussions

Server (cards-server):
- PullRequestService: create / list / get / merge / close / reject.
  Merge applies the PR's {add, modify, remove} diff to the latest
  version's cards in a single transaction, writes a new
  deck_version + deck_cards, bumps latest_version_id, and stamps
  the PR with mergedIntoVersionId.
- DiscussionService: post / listForCard / hide. Threads are keyed
  by card_content_hash so they survive version bumps.
- Routes mounted under /v1: POST/GET /decks/:slug/pull-requests,
  GET /pull-requests/:id, POST /pull-requests/:id/{merge,close,reject},
  GET/POST /cards/:contentHash/discussions, POST /discussions/:id/hide.

Frontend (cards-web):
- cardsApi.pullRequests + cardsApi.discussions client surface.
- <PullRequestsSection> on /d/:slug — lists PRs with diff preview;
  owner sees Merge/Reject/Close buttons.
- <SuggestEditModal> + "✏️ Verbessern" button on /learn/:deckId for
  cards from a subscribed deck — submits a one-card modify (or
  remove) PR using the card's serverContentHash as the previous
  hash.
- Deck/Card DTOs gain subscribedFromSlug + serverContentHash so the
  learn page can decide whether to show the suggest-edit affordance.
This commit is contained in:
Till JS 2026-05-07 21:56:20 +02:00
parent c84742005b
commit 61fc16e8e9
12 changed files with 1045 additions and 0 deletions

View file

@ -21,11 +21,15 @@ import { DeckService } from './services/decks';
import { ExploreService } from './services/explore';
import { EngagementService } from './services/engagement';
import { SubscriptionService } from './services/subscriptions';
import { PullRequestService } from './services/pull-requests';
import { DiscussionService } from './services/discussions';
import { createAuthorRoutes } from './routes/authors';
import { createDeckRoutes } from './routes/decks';
import { createExploreRoutes } from './routes/explore';
import { createEngagementRoutes } from './routes/engagement';
import { createSubscriptionRoutes } from './routes/subscriptions';
import { createPullRequestRoutes } from './routes/pull-requests';
import { createDiscussionRoutes } from './routes/discussions';
// ─── Bootstrap ──────────────────────────────────────────────
@ -37,6 +41,8 @@ const deckService = new DeckService(db, config.manaLlmUrl);
const exploreService = new ExploreService(db);
const engagementService = new EngagementService(db);
const subscriptionService = new SubscriptionService(db);
const pullRequestService = new PullRequestService(db);
const discussionService = new DiscussionService(db);
// ─── App ────────────────────────────────────────────────────
@ -77,6 +83,8 @@ v1.use('/*', optionalAuth(config.manaAuthUrl));
v1.route('/', createExploreRoutes(exploreService));
v1.route('/', createEngagementRoutes(engagementService));
v1.route('/', createSubscriptionRoutes(subscriptionService));
v1.route('/', createPullRequestRoutes(pullRequestService));
v1.route('/', createDiscussionRoutes(discussionService));
v1.route('/authors', createAuthorRoutes(authorService));
v1.route('/decks', createDeckRoutes(authorService, deckService));

View file

@ -0,0 +1,47 @@
import { Hono } from 'hono';
import { z } from 'zod';
import type { AuthUser } from '../middleware/jwt-auth';
import type { DiscussionService } from '../services/discussions';
import { BadRequestError, UnauthorizedError } from '../lib/errors';
function requireUser(user: AuthUser | undefined): AuthUser {
if (!user || !user.userId) throw new UnauthorizedError();
return user;
}
const postSchema = z.object({
deckSlug: z.string().min(1),
body: z.string().min(1).max(4000),
parentId: z.string().uuid().optional(),
});
export function createDiscussionRoutes(service: DiscussionService) {
const router = new Hono<{ Variables: { user?: AuthUser } }>();
router.get('/cards/:contentHash/discussions', async (c) => {
const list = await service.listForCard(c.req.param('contentHash'));
return c.json(list);
});
router.post('/cards/:contentHash/discussions', async (c) => {
const user = requireUser(c.get('user'));
const parsed = postSchema.safeParse(await c.req.json().catch(() => ({})));
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());
const row = await service.post(
user.userId,
parsed.data.deckSlug,
c.req.param('contentHash'),
parsed.data.body,
parsed.data.parentId
);
return c.json(row, 201);
});
router.post('/discussions/:id/hide', async (c) => {
const user = requireUser(c.get('user'));
await service.hide(user.userId, c.req.param('id'));
return c.json({ ok: true });
});
return router;
}

View file

@ -0,0 +1,99 @@
import { Hono } from 'hono';
import { z } from 'zod';
import type { AuthUser } from '../middleware/jwt-auth';
import type { PullRequestService } from '../services/pull-requests';
import { BadRequestError, UnauthorizedError } from '../lib/errors';
function requireUser(user: AuthUser | undefined): AuthUser {
if (!user || !user.userId) throw new UnauthorizedError();
return user;
}
const cardTypes = [
'basic',
'basic-reverse',
'cloze',
'type-in',
'image-occlusion',
'audio',
'multiple-choice',
] as const;
const cardPayloadSchema = z.object({
type: z.enum(cardTypes),
fields: z.record(z.string(), 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(),
});
export function createPullRequestRoutes(service: PullRequestService) {
const router = new Hono<{ Variables: { user?: AuthUser } }>();
router.post('/decks/:slug/pull-requests', async (c) => {
const user = requireUser(c.get('user'));
const parsed = createPrSchema.safeParse(await c.req.json().catch(() => ({})));
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());
const pr = await service.create(user.userId, c.req.param('slug'), parsed.data);
return c.json(pr, 201);
});
router.get('/decks/:slug/pull-requests', async (c) => {
const url = new URL(c.req.url);
const status = url.searchParams.get('status');
const valid = ['open', 'merged', 'closed', 'rejected'] as const;
const statusFilter = (valid as readonly string[]).includes(status ?? '')
? (status as (typeof valid)[number])
: undefined;
const list = await service.list(c.req.param('slug'), statusFilter);
return c.json(list);
});
router.get('/pull-requests/:id', async (c) => {
const pr = await service.get(c.req.param('id'));
return c.json(pr);
});
router.post('/pull-requests/:id/close', async (c) => {
const user = requireUser(c.get('user'));
await service.close(user.userId, c.req.param('id'));
return c.json({ ok: true });
});
router.post('/pull-requests/:id/reject', async (c) => {
const user = requireUser(c.get('user'));
await service.reject(user.userId, c.req.param('id'));
return c.json({ ok: true });
});
router.post('/pull-requests/:id/merge', async (c) => {
const user = requireUser(c.get('user'));
const parsed = mergeSchema.safeParse(await c.req.json().catch(() => ({})));
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());
const result = await service.merge(user.userId, c.req.param('id'), parsed.data);
return c.json(result, 201);
});
return router;
}

View file

@ -0,0 +1,84 @@
/**
* Card discussions lightweight inline threads keyed by
* `card_content_hash` (not card-id) so a thread survives across
* version bumps as long as the card content stays.
*
* Threads are flat-with-parent: every reply has `parent_id`
* something else in the same `card_content_hash` group. The UI
* renders a one-level-deep tree (Reddit-style with a max depth)
* if we want full nesting later it's already there.
*/
import { and, asc, eq } from 'drizzle-orm';
import type { Database } from '../db/connection';
import { cardDiscussions, publicDecks } from '../db/schema';
import { ForbiddenError, NotFoundError } from '../lib/errors';
export class DiscussionService {
constructor(private readonly db: Database) {}
async post(
userId: string,
deckSlug: string,
cardContentHash: string,
body: string,
parentId?: string
) {
const deck = await this.db.query.publicDecks.findFirst({
where: eq(publicDecks.slug, deckSlug),
});
if (!deck) throw new NotFoundError('Deck not found');
if (parentId) {
const parent = await this.db.query.cardDiscussions.findFirst({
where: eq(cardDiscussions.id, parentId),
});
if (!parent) throw new NotFoundError('Parent comment not found');
if (parent.cardContentHash !== cardContentHash) {
throw new ForbiddenError('Parent comment is on a different card');
}
}
const [row] = await this.db
.insert(cardDiscussions)
.values({
cardContentHash,
deckId: deck.id,
authorUserId: userId,
parentId: parentId ?? null,
body,
})
.returning();
return row;
}
async listForCard(cardContentHash: string) {
const rows = await this.db
.select()
.from(cardDiscussions)
.where(
and(eq(cardDiscussions.cardContentHash, cardContentHash), eq(cardDiscussions.hidden, false))
)
.orderBy(asc(cardDiscussions.createdAt));
return rows;
}
async hide(actorUserId: string, discussionId: string) {
const row = await this.db.query.cardDiscussions.findFirst({
where: eq(cardDiscussions.id, discussionId),
});
if (!row) throw new NotFoundError('Discussion not found');
const deck = await this.db.query.publicDecks.findFirst({
where: eq(publicDecks.id, row.deckId),
});
if (!deck) throw new NotFoundError('Deck not found');
// Author of the comment OR deck owner can hide.
if (row.authorUserId !== actorUserId && deck.ownerUserId !== actorUserId) {
throw new ForbiddenError('Not allowed to hide this comment');
}
await this.db
.update(cardDiscussions)
.set({ hidden: true })
.where(eq(cardDiscussions.id, discussionId));
}
}

View file

@ -0,0 +1,263 @@
/**
* 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';
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) {}
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();
return pr;
}
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));
}
/**
* 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 };
});
return { pullRequest: { ...pr, status: 'merged' as const }, version: result.version };
}
}