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