mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
feat(cards): Phase ε.3 — PR notifications + Card-Discussions UI
- mana-notify integration in cards-server: PR-create notifies the
deck owner, merge/reject notifies the PR author. Fire-and-forget
via lib/notify.ts so a notify-service outage never rolls back a
domain action. ExternalIDs are deterministic (cards.pr.{event}.{id})
so retries dedupe.
- <CardDiscussions> on the learn page: collapsed by default, opens
via "💬 Diskussion" alongside the "✏️ Verbessern" trigger. Resets
whenever the current card changes so the panel doesn't bleed
between flashcards.
- MARKETPLACE_PLAN.md §13a — known limitations: PR-merge is
stale-blind (no rebase yet), diff-preview flat, threading 1-level.
This commit is contained in:
parent
61fc16e8e9
commit
a8ddb6dea4
6 changed files with 269 additions and 3 deletions
128
apps/cards/apps/web/src/lib/components/CardDiscussions.svelte
Normal file
128
apps/cards/apps/web/src/lib/components/CardDiscussions.svelte
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<script lang="ts">
|
||||
import { cardsApi, CardsApiError, type CardDiscussion } from '$lib/api/cards-api';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
interface Props {
|
||||
contentHash: string;
|
||||
deckSlug: string;
|
||||
}
|
||||
|
||||
let { contentHash, deckSlug }: Props = $props();
|
||||
|
||||
let comments = $state<CardDiscussion[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let draft = $state('');
|
||||
let posting = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
// Re-load whenever the card under review changes.
|
||||
void contentHash;
|
||||
comments = [];
|
||||
load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
comments = await cardsApi.discussions.listForCard(contentHash);
|
||||
} catch (e) {
|
||||
error = e instanceof CardsApiError ? e.message : (e as Error).message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function post() {
|
||||
const body = draft.trim();
|
||||
if (!body) return;
|
||||
posting = true;
|
||||
error = null;
|
||||
try {
|
||||
const row = await cardsApi.discussions.post(contentHash, { deckSlug, body });
|
||||
comments = [...comments, row];
|
||||
draft = '';
|
||||
} catch (e) {
|
||||
error = e instanceof CardsApiError ? e.message : (e as Error).message;
|
||||
} finally {
|
||||
posting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function hide(c: CardDiscussion) {
|
||||
if (!confirm('Kommentar ausblenden?')) return;
|
||||
try {
|
||||
await cardsApi.discussions.hide(c.id);
|
||||
comments = comments.filter((x) => x.id !== c.id);
|
||||
} catch (e) {
|
||||
error = e instanceof CardsApiError ? e.message : (e as Error).message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="mt-4 rounded-xl border border-neutral-800 bg-neutral-950 p-4">
|
||||
<header class="mb-2 flex items-center justify-between">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Diskussion {comments.length > 0 ? `(${comments.length})` : ''}
|
||||
</h3>
|
||||
{#if loading}
|
||||
<span class="text-xs text-neutral-600">Lädt…</span>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<p class="mb-2 text-xs text-red-400">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if comments.length === 0 && !loading}
|
||||
<p class="text-xs text-neutral-600">Noch keine Kommentare zu dieser Karte.</p>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each comments as c (c.id)}
|
||||
<li class="rounded-lg border border-neutral-800 bg-neutral-900 p-2 text-sm">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<p class="whitespace-pre-line text-neutral-200">{c.body}</p>
|
||||
{#if authStore.user?.id === c.authorUserId}
|
||||
<button
|
||||
class="shrink-0 text-xs text-neutral-600 hover:text-red-400"
|
||||
onclick={() => hide(c)}
|
||||
title="Ausblenden"
|
||||
aria-label="Ausblenden"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-neutral-600">
|
||||
{new Date(c.createdAt).toLocaleString('de-DE')}
|
||||
</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if authStore.isAuthenticated}
|
||||
<form
|
||||
class="mt-3 flex gap-2"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
post();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
class="flex-1 rounded-lg border border-neutral-800 bg-neutral-900 px-3 py-1.5 text-sm"
|
||||
placeholder="Kommentar zur Karte…"
|
||||
bind:value={draft}
|
||||
disabled={posting}
|
||||
/>
|
||||
<button
|
||||
class="rounded-lg bg-indigo-500 px-3 py-1.5 text-xs text-white hover:bg-indigo-400 disabled:opacity-50"
|
||||
type="submit"
|
||||
disabled={posting || !draft.trim()}
|
||||
>
|
||||
{posting ? 'Sende…' : 'Senden'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</aside>
|
||||
|
|
@ -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}
|
||||
<div class="mt-3 text-right">
|
||||
<div class="mt-3 flex justify-end gap-3">
|
||||
<button
|
||||
class="text-xs text-neutral-500 hover:text-neutral-200"
|
||||
onclick={() => (discussionsOpen = !discussionsOpen)}
|
||||
title="Kommentare zur Karte"
|
||||
>
|
||||
💬 {discussionsOpen ? 'Diskussion ausblenden' : 'Diskussion'}
|
||||
</button>
|
||||
<button
|
||||
class="text-xs text-neutral-500 hover:text-indigo-300"
|
||||
onclick={() => (suggestOpen = true)}
|
||||
|
|
@ -149,6 +165,10 @@
|
|||
✏️ Verbessern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if discussionsOpen && subscribedSlug && current?.card.serverContentHash}
|
||||
<CardDiscussions contentHash={current.card.serverContentHash} deckSlug={subscribedSlug} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !showBack}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────
|
||||
|
|
|
|||
51
services/cards-server/src/lib/notify.ts
Normal file
51
services/cards-server/src/lib/notify.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
externalId?: string;
|
||||
}
|
||||
|
||||
interface NotifyClient {
|
||||
send(input: SendInput): Promise<void>;
|
||||
}
|
||||
|
||||
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 };
|
||||
|
|
@ -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<string, string> }[];
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue