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:
Till JS 2026-05-07 22:24:45 +02:00
parent 61fc16e8e9
commit a8ddb6dea4
6 changed files with 269 additions and 3 deletions

View file

@ -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 ────────────────────────────────────────────────────

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

View file

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