diff --git a/apps/cards/apps/web/src/lib/components/CardDiscussions.svelte b/apps/cards/apps/web/src/lib/components/CardDiscussions.svelte
new file mode 100644
index 000000000..78f4343ac
--- /dev/null
+++ b/apps/cards/apps/web/src/lib/components/CardDiscussions.svelte
@@ -0,0 +1,128 @@
+
+
+
diff --git a/apps/cards/apps/web/src/routes/learn/[deckId]/+page.svelte b/apps/cards/apps/web/src/routes/learn/[deckId]/+page.svelte
index 505b7af75..755244c5d 100644
--- a/apps/cards/apps/web/src/routes/learn/[deckId]/+page.svelte
+++ b/apps/cards/apps/web/src/routes/learn/[deckId]/+page.svelte
@@ -7,6 +7,7 @@
import { studyBlockStore } from '$lib/stores/study-blocks.svelte';
import CardFace from '$lib/components/CardFace.svelte';
import SuggestEditModal from '$lib/components/SuggestEditModal.svelte';
+ import CardDiscussions from '$lib/components/CardDiscussions.svelte';
import type { Card, CardReview, ReviewGrade } from '@mana/cards-core';
const deckId = $derived(page.params.deckId as string);
@@ -26,6 +27,14 @@
const subscribedSlug = $derived($deckQuery?.subscribedFromSlug);
const canSuggest = $derived(!!subscribedSlug && !!current?.card.serverContentHash);
let suggestOpen = $state(false);
+ let discussionsOpen = $state(false);
+
+ $effect(() => {
+ // Collapse the discussion panel whenever the card changes so the
+ // learner isn't visually overloaded between cards.
+ void current?.card.id;
+ discussionsOpen = false;
+ });
$effect(() => {
const snap = $dueQuery;
@@ -140,7 +149,14 @@
/>
{#if canSuggest}
-
+
+
+
+ {#if discussionsOpen && subscribedSlug && current?.card.serverContentHash}
+
+ {/if}
{/if}
{#if !showBack}
diff --git a/apps/cards/docs/MARKETPLACE_PLAN.md b/apps/cards/docs/MARKETPLACE_PLAN.md
index 2e7ff40ae..18b5a4199 100644
--- a/apps/cards/docs/MARKETPLACE_PLAN.md
+++ b/apps/cards/docs/MARKETPLACE_PLAN.md
@@ -581,6 +581,12 @@ Marktplatz ohne Decks ist nutzlos. Drei parallele Hebel:
- **Keine Pflicht-Klarnamen**. Pseudonyme bleiben gleichberechtigt. Verifizierung ist Bonus, nicht Pflicht.
- **Kein Marketplace-Cut über 30 %**. Apple-App-Store-Hass ist real, wir bleiben fair.
+## 13a. Bekannte Limitierungen / „macht später"
+
+- **PR-Merge-Heuristik ist stale-blind** (Phase ε.1): `merge()` baut die neue Version aus `currentCards` zusammen, indem es Removes anwendet, dann Modifies-by-Hash, dann Adds. Wenn der Author zwischen PR-Open und Merge selbst eine Karte geändert hat, deren `previousContentHash` der PR matched, gewinnt **stumm** der PR — kein Konflikt-Hinweis. Akzeptabel solange wir wenige PRs/Tag haben; irgendwann brauchen wir entweder (a) ein „PR-rebase" Konzept (PR rerunst auf neuesten Cards, bei Konflikt → status=`stale`), oder (b) optimistic locking via `baseVersionId` auf der PR-Row mit Reject bei Mismatch.
+- **Keine Multi-Card-Diff-Visualisierung** (Phase ε.2): PR-Diff-Preview zeigt jeden Block (`add`/`modify`/`remove`) flach. Bei großen PRs mit 50+ Karten wird das unübersichtlich — vermutlich pre-Phase-ζ noch nicht relevant, danach ggf. Side-by-side-Vergleich pro modify.
+- **Discussion-Threading ist 1-Level** (Phase ε.2): Server speichert schon `parent_id`, aber das UI rendert flach. Bei Bedarf später ein Antworten-Button + visuelle Einrückung — kein Schema-Change nötig.
+
## 14. Offene Punkte die später entschieden werden müssen
- **Mobile-Push-Notifications** für Subscribe-Updates: native PWA-Push reicht aktuell, aber Browser-API ist hin- und her — könnte Phase ι in einen eigenen Push-Service auslagern müssen.
diff --git a/services/cards-server/src/index.ts b/services/cards-server/src/index.ts
index c76544372..d9695fb68 100644
--- a/services/cards-server/src/index.ts
+++ b/services/cards-server/src/index.ts
@@ -30,18 +30,24 @@ import { createEngagementRoutes } from './routes/engagement';
import { createSubscriptionRoutes } from './routes/subscriptions';
import { createPullRequestRoutes } from './routes/pull-requests';
import { createDiscussionRoutes } from './routes/discussions';
+import { createNotifyClient } from './lib/notify';
// ─── Bootstrap ──────────────────────────────────────────────
const config = loadConfig();
const db = getDb(config.databaseUrl);
+const notify = createNotifyClient({
+ url: config.manaNotifyUrl,
+ serviceKey: config.serviceKey,
+});
+
const authorService = new AuthorService(db);
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 pullRequestService = new PullRequestService(db, notify);
const discussionService = new DiscussionService(db);
// ─── App ────────────────────────────────────────────────────
diff --git a/services/cards-server/src/lib/notify.ts b/services/cards-server/src/lib/notify.ts
new file mode 100644
index 000000000..4d8e8fd1c
--- /dev/null
+++ b/services/cards-server/src/lib/notify.ts
@@ -0,0 +1,51 @@
+/**
+ * Thin client for mana-notify. Fire-and-forget by design — a failed
+ * notification must never roll back a domain action (PR merge, etc.),
+ * so all callers `void` the promise and we just log on failure.
+ *
+ * `appId: 'cards'` keeps these notifications grouped in user
+ * preferences so a learner can mute "PR activity" without losing
+ * other Mana mail.
+ */
+
+interface SendInput {
+ channel: 'email' | 'push' | 'webhook';
+ userId: string;
+ subject: string;
+ body: string;
+ data?: Record
;
+ externalId?: string;
+}
+
+interface NotifyClient {
+ send(input: SendInput): Promise;
+}
+
+export function createNotifyClient(opts: { url: string; serviceKey: string }): NotifyClient {
+ return {
+ async send(input) {
+ try {
+ await fetch(`${opts.url}/api/v1/notifications/send`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Service-Key': opts.serviceKey,
+ },
+ body: JSON.stringify({
+ channel: input.channel,
+ appId: 'cards',
+ userId: input.userId,
+ subject: input.subject,
+ body: input.body,
+ data: input.data,
+ externalId: input.externalId,
+ }),
+ });
+ } catch (err) {
+ console.warn('[cards-server] notify failed', err);
+ }
+ },
+ };
+}
+
+export type { NotifyClient };
diff --git a/services/cards-server/src/services/pull-requests.ts b/services/cards-server/src/services/pull-requests.ts
index dcffc7cf4..327093191 100644
--- a/services/cards-server/src/services/pull-requests.ts
+++ b/services/cards-server/src/services/pull-requests.ts
@@ -25,6 +25,7 @@ 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 }[];
@@ -47,7 +48,10 @@ function bumpMinor(semver: string): string {
}
export class PullRequestService {
- constructor(private readonly db: Database) {}
+ 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({
@@ -77,9 +81,32 @@ export class PullRequestService {
},
})
.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://cards.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),
@@ -135,6 +162,17 @@ export class PullRequestService {
.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}`,
+ });
+ }
}
/**
@@ -258,6 +296,23 @@ export class PullRequestService {
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 };
}
}