mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
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:
parent
c84742005b
commit
61fc16e8e9
12 changed files with 1045 additions and 0 deletions
|
|
@ -209,6 +209,49 @@ export const cardsApi = {
|
|||
{ auth: 'optional' }
|
||||
),
|
||||
},
|
||||
pullRequests: {
|
||||
create: (
|
||||
deckSlug: string,
|
||||
input: {
|
||||
title: string;
|
||||
body?: string;
|
||||
diff: PullRequestDiffInput;
|
||||
}
|
||||
) =>
|
||||
request<PullRequest>(`/v1/decks/${encodeURIComponent(deckSlug)}/pull-requests`, {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
}),
|
||||
list: (deckSlug: string, status?: 'open' | 'merged' | 'closed' | 'rejected') => {
|
||||
const qs = status ? `?status=${status}` : '';
|
||||
return request<PullRequest[]>(
|
||||
`/v1/decks/${encodeURIComponent(deckSlug)}/pull-requests${qs}`,
|
||||
{ auth: 'optional' }
|
||||
);
|
||||
},
|
||||
get: (id: string) => request<PullRequest>(`/v1/pull-requests/${id}`, { auth: 'optional' }),
|
||||
merge: (id: string, opts: { newSemver?: string; mergeNote?: string } = {}) =>
|
||||
request<{ pullRequest: PullRequest; version: PublicDeckVersion }>(
|
||||
`/v1/pull-requests/${id}/merge`,
|
||||
{ method: 'POST', body: opts }
|
||||
),
|
||||
close: (id: string) =>
|
||||
request<{ ok: true }>(`/v1/pull-requests/${id}/close`, { method: 'POST' }),
|
||||
reject: (id: string) =>
|
||||
request<{ ok: true }>(`/v1/pull-requests/${id}/reject`, { method: 'POST' }),
|
||||
},
|
||||
discussions: {
|
||||
listForCard: (contentHash: string) =>
|
||||
request<CardDiscussion[]>(`/v1/cards/${encodeURIComponent(contentHash)}/discussions`, {
|
||||
auth: 'optional',
|
||||
}),
|
||||
post: (contentHash: string, input: { deckSlug: string; body: string; parentId?: string }) =>
|
||||
request<CardDiscussion>(`/v1/cards/${encodeURIComponent(contentHash)}/discussions`, {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
}),
|
||||
hide: (id: string) => request<{ ok: true }>(`/v1/discussions/${id}/hide`, { method: 'POST' }),
|
||||
},
|
||||
};
|
||||
|
||||
// Override author lookup to send token opportunistically — public reads.
|
||||
|
|
@ -312,3 +355,39 @@ export interface DiffPayload {
|
|||
unchanged: { contentHash: string; ord: number }[];
|
||||
removed: { contentHash: string }[];
|
||||
}
|
||||
|
||||
export interface PullRequestDiffInput {
|
||||
add: { type: string; fields: Record<string, string> }[];
|
||||
modify: { previousContentHash: string; type: string; fields: Record<string, string> }[];
|
||||
remove: { contentHash: string }[];
|
||||
}
|
||||
|
||||
export type PullRequestStatus = 'open' | 'merged' | 'closed' | 'rejected';
|
||||
|
||||
export interface PullRequest {
|
||||
id: string;
|
||||
deckId: string;
|
||||
authorUserId: string;
|
||||
status: PullRequestStatus;
|
||||
title: string;
|
||||
body: string | null;
|
||||
diff: {
|
||||
add: { type: string; fields: Record<string, string> }[];
|
||||
modify: { contentHash: string; fields: Record<string, string> }[];
|
||||
remove: { contentHash: string }[];
|
||||
};
|
||||
mergedIntoVersionId: string | null;
|
||||
createdAt: string;
|
||||
resolvedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CardDiscussion {
|
||||
id: string;
|
||||
cardContentHash: string;
|
||||
deckId: string;
|
||||
authorUserId: string;
|
||||
parentId: string | null;
|
||||
body: string;
|
||||
hidden: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,233 @@
|
|||
<script lang="ts">
|
||||
import { cardsApi, CardsApiError, type PullRequest } from '$lib/api/cards-api';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
interface Props {
|
||||
deckSlug: string;
|
||||
ownerUserId: string;
|
||||
onMerged?: () => void;
|
||||
}
|
||||
|
||||
let { deckSlug, ownerUserId, onMerged }: Props = $props();
|
||||
|
||||
let prs = $state<PullRequest[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let actionBusy = $state<string | null>(null);
|
||||
let expanded = $state<Record<string, boolean>>({});
|
||||
|
||||
const viewerIsOwner = $derived(authStore.user?.id === ownerUserId);
|
||||
|
||||
$effect(() => {
|
||||
void deckSlug;
|
||||
load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
prs = await cardsApi.pullRequests.list(deckSlug);
|
||||
} catch (e) {
|
||||
error = e instanceof CardsApiError ? e.message : (e as Error).message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function merge(pr: PullRequest) {
|
||||
if (!confirm(`PR „${pr.title}" mergen? Erstellt eine neue Version.`)) return;
|
||||
actionBusy = pr.id;
|
||||
error = null;
|
||||
try {
|
||||
await cardsApi.pullRequests.merge(pr.id);
|
||||
await load();
|
||||
onMerged?.();
|
||||
} catch (e) {
|
||||
error = e instanceof CardsApiError ? e.message : (e as Error).message;
|
||||
} finally {
|
||||
actionBusy = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function close(pr: PullRequest) {
|
||||
actionBusy = pr.id;
|
||||
error = null;
|
||||
try {
|
||||
await cardsApi.pullRequests.close(pr.id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e instanceof CardsApiError ? e.message : (e as Error).message;
|
||||
} finally {
|
||||
actionBusy = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function reject(pr: PullRequest) {
|
||||
actionBusy = pr.id;
|
||||
error = null;
|
||||
try {
|
||||
await cardsApi.pullRequests.reject(pr.id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e instanceof CardsApiError ? e.message : (e as Error).message;
|
||||
} finally {
|
||||
actionBusy = null;
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadgeClass(s: PullRequest['status']) {
|
||||
if (s === 'open') return 'bg-emerald-500/15 text-emerald-300';
|
||||
if (s === 'merged') return 'bg-violet-500/15 text-violet-300';
|
||||
if (s === 'rejected') return 'bg-red-500/15 text-red-300';
|
||||
return 'bg-neutral-800 text-neutral-400';
|
||||
}
|
||||
|
||||
function diffSummary(pr: PullRequest) {
|
||||
return `+${pr.diff.add.length} · ~${pr.diff.modify.length} · −${pr.diff.remove.length}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="mt-10">
|
||||
<header class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-neutral-400">
|
||||
Pull Requests {prs.length > 0 ? `(${prs.length})` : ''}
|
||||
</h2>
|
||||
<button
|
||||
class="text-xs text-neutral-500 hover:text-neutral-300"
|
||||
onclick={load}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Lädt…' : 'Aktualisieren'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<p class="mb-3 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if loading && prs.length === 0}
|
||||
<p class="rounded-xl border border-neutral-800 bg-neutral-900 p-4 text-sm text-neutral-500">
|
||||
Lädt…
|
||||
</p>
|
||||
{:else if prs.length === 0}
|
||||
<p class="rounded-xl border border-neutral-800 bg-neutral-900 p-4 text-sm text-neutral-500">
|
||||
Noch keine Pull Requests. Abonnenten können Verbesserungen vorschlagen.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each prs as pr (pr.id)}
|
||||
<li class="rounded-xl border border-neutral-800 bg-neutral-900 p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs {statusBadgeClass(pr.status)}">
|
||||
{pr.status}
|
||||
</span>
|
||||
<h3 class="truncate font-medium text-neutral-100">{pr.title}</h3>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-neutral-500">
|
||||
{diffSummary(pr)} · {new Date(pr.createdAt).toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="shrink-0 text-xs text-neutral-500 hover:text-neutral-300"
|
||||
onclick={() => (expanded[pr.id] = !expanded[pr.id])}
|
||||
>
|
||||
{expanded[pr.id] ? 'Einklappen' : 'Details'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if expanded[pr.id]}
|
||||
{#if pr.body}
|
||||
<p class="mt-3 whitespace-pre-line text-sm text-neutral-300">{pr.body}</p>
|
||||
{/if}
|
||||
|
||||
{#if pr.diff.modify.length > 0}
|
||||
<div class="mt-3">
|
||||
<div class="mb-1 text-xs uppercase text-neutral-500">Geändert</div>
|
||||
<ul class="space-y-2">
|
||||
{#each pr.diff.modify as m (m.contentHash)}
|
||||
<li class="rounded-lg border border-neutral-800 bg-neutral-950 p-2 text-xs">
|
||||
<div class="text-neutral-500">
|
||||
← {m.contentHash.slice(0, 12)}…
|
||||
</div>
|
||||
{#each Object.entries(m.fields) as [k, v]}
|
||||
<div class="mt-1">
|
||||
<span class="text-neutral-500">{k}:</span>
|
||||
<span class="text-neutral-200">{v}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if pr.diff.add.length > 0}
|
||||
<div class="mt-3">
|
||||
<div class="mb-1 text-xs uppercase text-neutral-500">
|
||||
Neu (+{pr.diff.add.length})
|
||||
</div>
|
||||
<ul class="space-y-2">
|
||||
{#each pr.diff.add as a, i (i)}
|
||||
<li class="rounded-lg border border-neutral-800 bg-neutral-950 p-2 text-xs">
|
||||
<div class="text-neutral-500">{a.type}</div>
|
||||
{#each Object.entries(a.fields) as [k, v]}
|
||||
<div class="mt-1">
|
||||
<span class="text-neutral-500">{k}:</span>
|
||||
<span class="text-neutral-200">{v}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if pr.diff.remove.length > 0}
|
||||
<div class="mt-3">
|
||||
<div class="mb-1 text-xs uppercase text-neutral-500">
|
||||
Entfernt (−{pr.diff.remove.length})
|
||||
</div>
|
||||
<ul class="space-y-1 text-xs text-neutral-400">
|
||||
{#each pr.diff.remove as r (r.contentHash)}
|
||||
<li>· {r.contentHash.slice(0, 12)}…</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if pr.status === 'open' && viewerIsOwner}
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button
|
||||
class="rounded-lg bg-violet-500 px-3 py-1.5 text-xs text-white hover:bg-violet-400 disabled:opacity-50"
|
||||
onclick={() => merge(pr)}
|
||||
disabled={actionBusy === pr.id}
|
||||
>
|
||||
{actionBusy === pr.id ? 'Mergt…' : 'Mergen'}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg border border-red-500/40 px-3 py-1.5 text-xs text-red-300 hover:bg-red-500/10 disabled:opacity-50"
|
||||
onclick={() => reject(pr)}
|
||||
disabled={actionBusy === pr.id}
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg border border-neutral-700 px-3 py-1.5 text-xs hover:bg-neutral-800 disabled:opacity-50"
|
||||
onclick={() => close(pr)}
|
||||
disabled={actionBusy === pr.id}
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
190
apps/cards/apps/web/src/lib/components/SuggestEditModal.svelte
Normal file
190
apps/cards/apps/web/src/lib/components/SuggestEditModal.svelte
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
<script lang="ts">
|
||||
import { cardsApi, CardsApiError } from '$lib/api/cards-api';
|
||||
import type { Card } from '@mana/cards-core';
|
||||
|
||||
type Mode = 'modify' | 'remove';
|
||||
|
||||
interface Props {
|
||||
card: Card;
|
||||
deckSlug: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmitted?: () => void;
|
||||
}
|
||||
|
||||
let { card, deckSlug, open, onClose, onSubmitted }: Props = $props();
|
||||
|
||||
let mode = $state<Mode>('modify');
|
||||
let title = $state('');
|
||||
let body = $state('');
|
||||
let editedFields = $state<Record<string, string>>({});
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let success = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
editedFields = { ...card.fields };
|
||||
title = `Verbesserung: Karte ${card.order + 1}`;
|
||||
body = '';
|
||||
mode = 'modify';
|
||||
error = null;
|
||||
success = false;
|
||||
}
|
||||
});
|
||||
|
||||
const fieldKeys = $derived(Object.keys(editedFields));
|
||||
|
||||
const hasChanges = $derived.by(() => {
|
||||
if (mode === 'remove') return true;
|
||||
return fieldKeys.some((k) => editedFields[k] !== card.fields[k]);
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
if (!card.serverContentHash) {
|
||||
error = 'Diese Karte stammt nicht aus einem abonnierten Deck.';
|
||||
return;
|
||||
}
|
||||
if (!hasChanges) {
|
||||
error = 'Keine Änderungen zu vorschlagen.';
|
||||
return;
|
||||
}
|
||||
if (!title.trim()) {
|
||||
error = 'Titel fehlt.';
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
error = null;
|
||||
try {
|
||||
const diff =
|
||||
mode === 'remove'
|
||||
? {
|
||||
add: [],
|
||||
modify: [],
|
||||
remove: [{ contentHash: card.serverContentHash }],
|
||||
}
|
||||
: {
|
||||
add: [],
|
||||
modify: [
|
||||
{
|
||||
previousContentHash: card.serverContentHash,
|
||||
type: card.type,
|
||||
fields: editedFields,
|
||||
},
|
||||
],
|
||||
remove: [],
|
||||
};
|
||||
await cardsApi.pullRequests.create(deckSlug, {
|
||||
title: title.trim(),
|
||||
body: body.trim() || undefined,
|
||||
diff,
|
||||
});
|
||||
success = true;
|
||||
onSubmitted?.();
|
||||
setTimeout(() => onClose(), 1200);
|
||||
} catch (e) {
|
||||
error = e instanceof CardsApiError ? e.message : (e as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="w-full max-w-xl rounded-xl border border-neutral-800 bg-neutral-950 p-6">
|
||||
<header class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">Verbesserung vorschlagen</h2>
|
||||
<button
|
||||
class="text-neutral-400 hover:text-neutral-100"
|
||||
onclick={onClose}
|
||||
aria-label="Schließen">✕</button
|
||||
>
|
||||
</header>
|
||||
|
||||
{#if success}
|
||||
<p
|
||||
class="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-sm text-emerald-300"
|
||||
>
|
||||
Pull Request gesendet — der Author wird benachrichtigt.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="mb-4 inline-flex rounded-lg border border-neutral-800 p-1">
|
||||
<button
|
||||
class="rounded px-3 py-1 text-xs"
|
||||
class:bg-neutral-800={mode === 'modify'}
|
||||
onclick={() => (mode = 'modify')}>Inhalt ändern</button
|
||||
>
|
||||
<button
|
||||
class="rounded px-3 py-1 text-xs"
|
||||
class:bg-neutral-800={mode === 'remove'}
|
||||
onclick={() => (mode = 'remove')}>Karte entfernen</button
|
||||
>
|
||||
</div>
|
||||
|
||||
<label class="mb-3 block">
|
||||
<span class="mb-1 block text-xs text-neutral-400">Titel</span>
|
||||
<input
|
||||
class="w-full rounded-lg border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm"
|
||||
bind:value={title}
|
||||
placeholder="Kurzbeschreibung der Verbesserung"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{#if mode === 'modify'}
|
||||
<div class="mb-3 space-y-2">
|
||||
{#each fieldKeys as key (key)}
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-neutral-400">{key}</span>
|
||||
<textarea
|
||||
class="w-full rounded-lg border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm"
|
||||
rows="2"
|
||||
bind:value={editedFields[key]}
|
||||
></textarea>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p
|
||||
class="mb-3 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-amber-200"
|
||||
>
|
||||
Diese Karte wird beim Merge aus dem Deck entfernt.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<label class="mb-4 block">
|
||||
<span class="mb-1 block text-xs text-neutral-400">Begründung (optional)</span>
|
||||
<textarea
|
||||
class="w-full rounded-lg border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm"
|
||||
rows="3"
|
||||
bind:value={body}
|
||||
placeholder="Warum ist diese Änderung sinnvoll?"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
{#if error}
|
||||
<p class="mb-3 text-sm text-red-400">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
class="rounded-lg border border-neutral-800 px-4 py-2 text-sm hover:border-neutral-700"
|
||||
onclick={onClose}
|
||||
disabled={busy}>Abbrechen</button
|
||||
>
|
||||
<button
|
||||
class="rounded-lg bg-indigo-500 px-4 py-2 text-sm text-white hover:bg-indigo-400 disabled:opacity-50"
|
||||
onclick={submit}
|
||||
disabled={busy || !hasChanges}
|
||||
>
|
||||
{busy ? 'Sende…' : 'PR senden'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -39,6 +39,8 @@ export function toDeck(local: LocalDeck): Deck {
|
|||
cardCount: local.cardCount,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? local.createdAt ?? new Date().toISOString(),
|
||||
subscribedFromSlug: local.subscribedFromSlug,
|
||||
subscribedAtVersion: local.subscribedAtVersion,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -70,6 +72,7 @@ export function toCard(local: LocalCard): Card {
|
|||
order: local.order,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? local.createdAt ?? new Date().toISOString(),
|
||||
serverContentHash: local.serverContentHash,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
} from '$lib/api/cards-api';
|
||||
import { isSubscribedLocally, subscribeAndPull, unsubscribe } from '$lib/services/subscribe';
|
||||
import { cardDeckTable } from '$lib/data/database';
|
||||
import PullRequestsSection from '$lib/components/PullRequestsSection.svelte';
|
||||
|
||||
const slug = $derived(page.params.slug as string);
|
||||
|
||||
|
|
@ -202,6 +203,8 @@
|
|||
<p class="mt-10 text-xs text-neutral-500">
|
||||
Veröffentlicht: {new Date(deck.createdAt).toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
|
||||
<PullRequestsSection deckSlug={deck.slug} ownerUserId={deck.ownerUserId} onMerged={load} />
|
||||
</article>
|
||||
{/if}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import { reviewStore } from '$lib/stores/reviews.svelte';
|
||||
import { studyBlockStore } from '$lib/stores/study-blocks.svelte';
|
||||
import CardFace from '$lib/components/CardFace.svelte';
|
||||
import SuggestEditModal from '$lib/components/SuggestEditModal.svelte';
|
||||
import type { Card, CardReview, ReviewGrade } from '@mana/cards-core';
|
||||
|
||||
const deckId = $derived(page.params.deckId as string);
|
||||
|
|
@ -22,6 +23,9 @@
|
|||
|
||||
const current = $derived(queue[currentIndex]);
|
||||
const deckTitle = $derived($deckQuery?.title ?? 'Deck');
|
||||
const subscribedSlug = $derived($deckQuery?.subscribedFromSlug);
|
||||
const canSuggest = $derived(!!subscribedSlug && !!current?.card.serverContentHash);
|
||||
let suggestOpen = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const snap = $dueQuery;
|
||||
|
|
@ -135,6 +139,18 @@
|
|||
onTypedAnswer={(v) => (typedAnswer = v)}
|
||||
/>
|
||||
|
||||
{#if canSuggest}
|
||||
<div class="mt-3 text-right">
|
||||
<button
|
||||
class="text-xs text-neutral-500 hover:text-indigo-300"
|
||||
onclick={() => (suggestOpen = true)}
|
||||
title="Verbesserung dieser Karte vorschlagen"
|
||||
>
|
||||
✏️ Verbessern
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !showBack}
|
||||
<button
|
||||
class="mt-6 w-full rounded-lg bg-indigo-500 py-3 text-base text-white hover:bg-indigo-400"
|
||||
|
|
@ -178,3 +194,12 @@
|
|||
<div class="text-center text-sm text-neutral-400">Lade…</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if subscribedSlug && current}
|
||||
<SuggestEditModal
|
||||
card={current.card}
|
||||
deckSlug={subscribedSlug}
|
||||
open={suggestOpen}
|
||||
onClose={() => (suggestOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -128,6 +128,10 @@ export interface Deck {
|
|||
cardCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** Marketplace slug if this deck was pulled from a subscription. */
|
||||
subscribedFromSlug?: string;
|
||||
/** Semver of the subscribed-from version that's currently local. */
|
||||
subscribedAtVersion?: string;
|
||||
}
|
||||
|
||||
export interface Card {
|
||||
|
|
@ -148,6 +152,13 @@ export interface Card {
|
|||
nextReview?: string;
|
||||
/** @deprecated legacy DTO field — read from cardReviews going forward. */
|
||||
reviewCount?: number;
|
||||
|
||||
/**
|
||||
* For cards from a subscribed deck: the server's content-hash for
|
||||
* the card as it was published. The PR-creation flow uses this as
|
||||
* `previousContentHash` when proposing a "modify" diff.
|
||||
*/
|
||||
serverContentHash?: string;
|
||||
}
|
||||
|
||||
export interface CardReview {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
47
services/cards-server/src/routes/discussions.ts
Normal file
47
services/cards-server/src/routes/discussions.ts
Normal 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;
|
||||
}
|
||||
99
services/cards-server/src/routes/pull-requests.ts
Normal file
99
services/cards-server/src/routes/pull-requests.ts
Normal 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;
|
||||
}
|
||||
84
services/cards-server/src/services/discussions.ts
Normal file
84
services/cards-server/src/services/discussions.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
263
services/cards-server/src/services/pull-requests.ts
Normal file
263
services/cards-server/src/services/pull-requests.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue