feat(community): Phase 3.B — loop closure (notifications + my-wishes page)

Schließt den Loop zwischen Submit und Ship. User kriegt jetzt:
- Toast beim nächsten App-Start, wenn ein eigener oder unterstützter
  Wisch ›planned/in_progress/completed/declined‹ wurde
- /profile/my-wishes als persönliche Roadmap mit drei Tabs:
  Eigene · Unterstützt · Inbox

Server (mana-analytics):
- Neue Tabelle feedback_notifications mit ON DELETE CASCADE auf
  user_feedback. Migration 0004 lokal + prod eingespielt.
- adminUpdate enqueued bei jeder Status-Transition Author-
  Notifications. AdminResponse-Edits feuern eine eigene
  'admin_response'-Notify. tryGrantShipBonus hängt zusätzlich
  Reactioner-Notifications dran (›Dein Like ist gelandet, +25 Mana‹).
- Endpoints:
    GET  /api/v1/feedback/me/notifications?unread_only=true&limit=N
    POST /api/v1/feedback/me/notifications/:id/read
    POST /api/v1/feedback/me/notifications/read-all
    GET  /api/v1/feedback/me/reacted    (für die My-Wishes-Page)

Package (@mana/feedback):
- FeedbackNotification + NotificationKind types exportiert
- service.getNotifications/markNotificationRead/markAllNotificationsRead
- service.getMyReactedItems

Web:
- lib/notifications/feedback-toaster.svelte.ts: Boot-Pull + 60s-Poll,
  rendert unread-notifications via toast-store, markiert sofort read.
  In (app)/+layout.svelte's authReady-Hook gestartet/gestoppt.
- /profile/my-wishes: Tab-View über getMyFeedback + getMyReactedItems
  + getNotifications. Tabs zeigen Counter-Badges, unread-Badge in der
  Inbox-Sektion. ›Alle als gelesen markieren‹-Action vorhanden.

Pre-launch saubere Lösung — kein Polling-Spam (60s), Mark-Read direkt
nach Toast-Display, fail-soft an mehreren Stellen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-27 14:55:01 +02:00
parent c94b67395a
commit 3a18a5e50d
11 changed files with 933 additions and 7 deletions

View file

@ -104,6 +104,35 @@ export const feedbackReactions = feedbackSchema.table(
})
);
// Per-user notification inbox. Server enqueues rows whenever a feedback
// status changes (author + reactioners get a row each). Web polls
// /api/v1/feedback/me/notifications and renders unread ones as toasts.
// `read_at IS NULL` is the inbox; the partial index keeps fetches O(log n).
export const feedbackNotifications = feedbackSchema.table(
'feedback_notifications',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
feedbackId: uuid('feedback_id')
.notNull()
.references(() => userFeedback.id, { onDelete: 'cascade' }),
// 'status_planned' | 'status_in_progress' | 'status_completed'
// | 'status_declined' | 'admin_response' | 'reactioner_bonus'
// (when their reacted-on item shipped — keeps loop closed for
// Sympathisanten, not just original authors).
kind: text('kind').notNull(),
title: text('title').notNull(),
body: text('body'),
creditsAwarded: integer('credits_awarded').default(0).notNull(),
readAt: timestamp('read_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
unreadIdx: index('feedback_notifications_unread_idx').on(table.userId, table.createdAt),
feedbackIdx: index('feedback_notifications_feedback_idx').on(table.feedbackId),
})
);
// Append-only log of community-credit grants. Used as a sliding-window
// rate-limit counter ("max 10 grants per user per 24h") and as an audit
// trail. Cleanup of rows older than 7d is handled by a nightly cron.
@ -121,3 +150,4 @@ export const feedbackGrantLog = feedbackSchema.table(
export type Feedback = typeof userFeedback.$inferSelect;
export type FeedbackReaction = typeof feedbackReactions.$inferSelect;
export type FeedbackNotification = typeof feedbackNotifications.$inferSelect;

View file

@ -61,6 +61,31 @@ export function createFeedbackRoutes(feedbackService: FeedbackService) {
return c.json(await feedbackService.getMyFeedback(user.userId));
});
r.get('/me/reacted', async (c) => {
const user = c.get('user');
const limit = Math.min(parseInt(c.req.query('limit') || '100', 10), 200);
return c.json({ items: await feedbackService.getMyReactedItems(user.userId, limit) });
});
r.get('/me/notifications', async (c) => {
const user = c.get('user');
const unreadOnly = c.req.query('unread_only') === 'true';
const limit = Math.min(parseInt(c.req.query('limit') || '50', 10), 200);
return c.json({
items: await feedbackService.getNotifications(user.userId, { unreadOnly, limit }),
});
});
r.post('/me/notifications/:id/read', async (c) => {
const user = c.get('user');
return c.json(await feedbackService.markNotificationRead(c.req.param('id'), user.userId));
});
r.post('/me/notifications/read-all', async (c) => {
const user = c.get('user');
return c.json(await feedbackService.markAllNotificationsRead(user.userId));
});
r.get('/:id/replies', async (c) => {
return c.json(await feedbackService.getReplies(c.req.param('id')));
});

View file

@ -13,7 +13,12 @@
*/
import { eq, and, desc, sql, isNull, gte, inArray } from 'drizzle-orm';
import { userFeedback, feedbackReactions, feedbackGrantLog } from '../db/schema/feedback';
import {
userFeedback,
feedbackReactions,
feedbackGrantLog,
feedbackNotifications,
} from '../db/schema/feedback';
import type { Database } from '../db/connection';
import { NotFoundError, BadRequestError } from '../lib/errors';
import { createDisplayHash, generateDisplayName } from '../lib/pseudonym';
@ -260,6 +265,30 @@ export class FeedbackService {
.orderBy(desc(userFeedback.createdAt));
}
/**
* Items the user has reacted to (any emoji), with their current
* status feeds the /profile/my-wishes "what you supported" tab so
* users can watch wishes they cared about move through the pipeline.
* Excludes the user's own posts (those are in getMyFeedback).
*/
async getMyReactedItems(userId: string, limit = 100): Promise<PublicFeedbackItem[]> {
const rows = await this.db
.selectDistinct({ feedback: userFeedback })
.from(feedbackReactions)
.innerJoin(userFeedback, eq(feedbackReactions.feedbackId, userFeedback.id))
.where(
and(
eq(feedbackReactions.userId, userId),
sql`${userFeedback.userId} <> ${userId}`,
eq(userFeedback.isPublic, true)
)
)
.orderBy(desc(userFeedback.updatedAt))
.limit(limit);
return rows.map((r) => redact(r.feedback));
}
/** Map of emoji → boolean for the requesting user on a feedback item. */
async getMyReactionsFor(feedbackId: string, userId: string): Promise<string[]> {
const rows = await this.db
@ -409,11 +438,23 @@ export class FeedbackService {
.where(eq(userFeedback.id, feedbackId))
.returning();
// Ship-Bonus: only on the FRESH 'completed' transition. Status-
// flapping ('completed' → 'in_progress' → 'completed') won't
// double-pay because the credit grant is keyed off
// `${id}_shipped`, but skipping the trigger entirely keeps the
// reactioner-bonus loop from spamming as well.
// Status-Transition triggert immer eine Author-Notification, plus
// Reactioner-Notifications + Ship-Bonus-Credits beim FRISCHEN
// 'completed'-Übergang. Doppel-Triggering bei Status-Flapping wird
// strukturell durch den `before.status !== row.status`-Guard
// verhindert, plus Idempotency via referenceId in den Credit-Grants.
const statusChanged = before.status !== row.status && patch.status !== undefined;
const adminResponseChanged =
patch.adminResponse !== undefined && before.adminResponse !== row.adminResponse;
if (statusChanged) {
void this.enqueueStatusNotifications(row, before.status);
}
if (adminResponseChanged && row.adminResponse) {
void this.enqueueAdminResponseNotification(row);
}
// Credit-Layer hängt nur am completed-Übergang.
if (before.status !== 'completed' && row.status === 'completed') {
void this.tryGrantShipBonus(row);
}
@ -421,6 +462,101 @@ export class FeedbackService {
return row;
}
/**
* Enqueue a per-user notification for the author when status changes.
* Reactioners-with-👍/🚀 get their own notification on 'completed'
* via tryGrantShipBonus (which also pays them +25). Statuses that
* mean "we heard you" (planned, in_progress) only notify the author.
*/
private async enqueueStatusNotifications(
feedback: typeof userFeedback.$inferSelect,
previousStatus: string
): Promise<void> {
try {
const titleByStatus: Record<string, string> = {
planned: `Geplant: ${feedback.title ?? this.shortTitle(feedback)}`,
in_progress: `Wir bauen ${feedback.title ?? this.shortTitle(feedback)} gerade`,
completed: `Dein Wunsch ist live: ${feedback.title ?? this.shortTitle(feedback)}`,
declined: `Abgelehnt: ${feedback.title ?? this.shortTitle(feedback)}`,
submitted: `Reaktiviert: ${feedback.title ?? this.shortTitle(feedback)}`,
under_review: `Wird geprüft: ${feedback.title ?? this.shortTitle(feedback)}`,
};
const title = titleByStatus[feedback.status] ?? `Status: ${feedback.status}`;
const bodyByStatus: Record<string, string | undefined> = {
planned: 'Auf der Roadmap eingetragen.',
in_progress: 'Wird aktiv umgesetzt — wir melden uns wenn live.',
completed: '+500 Mana — danke für die Idee 🎉',
declined: 'Können wir gerade nicht umsetzen. Schau in der Antwort, falls du möchtest.',
under_review: 'Schauen wir uns gleich an.',
};
await this.db.insert(feedbackNotifications).values({
userId: feedback.userId,
feedbackId: feedback.id,
kind: `status_${feedback.status}`,
title,
body: bodyByStatus[feedback.status] ?? null,
creditsAwarded: feedback.status === 'completed' ? REWARD.shipped : 0,
});
void previousStatus; // reserved for future "wurde rückgesetzt"-flavored copy
} catch (err) {
console.warn('[feedback] enqueue status notify failed:', err);
}
}
private async enqueueAdminResponseNotification(
feedback: typeof userFeedback.$inferSelect
): Promise<void> {
try {
await this.db.insert(feedbackNotifications).values({
userId: feedback.userId,
feedbackId: feedback.id,
kind: 'admin_response',
title: `Antwort vom Team: ${feedback.title ?? this.shortTitle(feedback)}`,
body: feedback.adminResponse?.slice(0, 200) ?? null,
creditsAwarded: 0,
});
} catch (err) {
console.warn('[feedback] enqueue admin-response notify failed:', err);
}
}
private shortTitle(feedback: typeof userFeedback.$inferSelect): string {
return feedback.feedbackText.slice(0, 40) + (feedback.feedbackText.length > 40 ? '…' : '');
}
// ── Notifications inbox ──────────────────────────────────────────
async getNotifications(userId: string, opts: { unreadOnly?: boolean; limit?: number } = {}) {
const { unreadOnly = false, limit = 50 } = opts;
const conds = [eq(feedbackNotifications.userId, userId)];
if (unreadOnly) conds.push(isNull(feedbackNotifications.readAt));
return this.db
.select()
.from(feedbackNotifications)
.where(and(...conds))
.orderBy(desc(feedbackNotifications.createdAt))
.limit(limit);
}
async markNotificationRead(notifId: string, userId: string): Promise<{ ok: true }> {
await this.db
.update(feedbackNotifications)
.set({ readAt: new Date() })
.where(and(eq(feedbackNotifications.id, notifId), eq(feedbackNotifications.userId, userId)));
return { ok: true };
}
async markAllNotificationsRead(userId: string): Promise<{ ok: true; count: number }> {
const result = await this.db
.update(feedbackNotifications)
.set({ readAt: new Date() })
.where(and(eq(feedbackNotifications.userId, userId), isNull(feedbackNotifications.readAt)))
.returning({ id: feedbackNotifications.id });
return { ok: true, count: result.length };
}
private async tryGrantShipBonus(feedback: typeof userFeedback.$inferSelect): Promise<void> {
try {
// Original wisher gets the +500.
@ -460,6 +596,18 @@ export class FeedbackService {
referenceId: `${feedback.id}_reaction_${supporter}`,
description: `Du hast ${feedback.title ?? '(Wunsch)'} unterstützt — danke!`,
});
// Inbox-Notify zusätzlich zur Credit-Auszahlung — sonst sieht
// der Reactioner zwar Credits in seiner History, aber weiß
// nicht warum.
await this.db.insert(feedbackNotifications).values({
userId: supporter,
feedbackId: feedback.id,
kind: 'reactioner_bonus',
title: `Dein Like ist gelandet: ${feedback.title ?? this.shortTitle(feedback)}`,
body: '+25 Mana — der Wunsch, den du unterstützt hast, ist live.',
creditsAwarded: REWARD.reactionMatch,
});
} catch (err) {
console.warn('[feedback] reactioner-bonus failed for', supporter, err);
}