diff --git a/apps/mana/apps/web/src/lib/modules/community/views/MyWishesView.svelte b/apps/mana/apps/web/src/lib/modules/community/views/MyWishesView.svelte new file mode 100644 index 000000000..cb901b8b7 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/community/views/MyWishesView.svelte @@ -0,0 +1,527 @@ + + + +
+
+

Meine Wünsche

+

+ Was du eingereicht hast, was du unterstützt hast, und was sich getan hat. Dein persönlicher + Loop in der Community. +

+
+ + + + + {#if loading} +
Lade…
+ {:else if error} +
{error}
+ {:else if active === 'mine'} + {#if myItems.length === 0} +
+ Noch keine eigenen Wünsche. Schreib einen über das Idee-Lämpchen oder die Pille unten rechts + — oder über + /community. +
+ {:else} +
+ {#each myItems as item (item.id)} + {@const cfg = statusOf(item.status)} + + + +
goToItem(item.id)}> +
+ {#if cfg} + + {cfg.label} + + {/if} + {#if item.moduleContext} + {item.moduleContext} + {/if} + {fmtDate(item.createdAt)} + {#if !item.isPublic} + privat + {/if} +
+ {#if item.title} +

{item.title}

+ {/if} +

{item.feedbackText}

+ {#if item.adminResponse} +
+
Antwort vom Team
+

{item.adminResponse}

+
+ {/if} +
+ {/each} +
+ {/if} + {:else if active === 'reacted'} + {#if reactedItems.length === 0} +
+ Du hast noch nichts unterstützt. Reagier mit 👍 / ❤️ / 🚀 auf einen Wunsch im + Community-Feed. +
+ {:else} +
+ {#each reactedItems as item (item.id)} + + {/each} +
+ {/if} + {:else if active === 'inbox'} +
+ {#if unreadCount > 0} + + {/if} +
+ {#if notifications.length === 0} +
Noch keine Benachrichtigungen.
+ {:else} +
+ {#each notifications as n (n.id)} + + + +
goToItem(n.feedbackId)} + > +
+ {n.title} + {fmtRelativeFromNow(n.createdAt)} +
+ {#if n.body} +

{n.body}

+ {/if} + {#if n.creditsAwarded > 0} + +{n.creditsAwarded} Mana + {/if} +
+ {/each} +
+ {/if} + {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/notifications/feedback-toaster.svelte.ts b/apps/mana/apps/web/src/lib/notifications/feedback-toaster.svelte.ts new file mode 100644 index 000000000..a2e2fa7a1 --- /dev/null +++ b/apps/mana/apps/web/src/lib/notifications/feedback-toaster.svelte.ts @@ -0,0 +1,62 @@ +/** + * Feedback-Notification-Toaster — boot-time + periodic puller. + * + * Fetches `/api/v1/feedback/me/notifications?unread_only=true` on app + * mount and every POLL_INTERVAL_MS thereafter. Each unread notification + * is rendered as a Toast and immediately marked read server-side, so + * we don't show it twice. + * + * The richer notification UI (with feedback-link, credit-amount, etc.) + * lives in /profile/my-wishes — toasts are just the "hey, look here" + * trigger. + */ + +import { feedbackService } from '$lib/api/feedback'; +import { authStore } from '$lib/stores/auth.svelte'; +import { toast } from '$lib/stores/toast.svelte'; + +const POLL_INTERVAL_MS = 60_000; + +let timer: ReturnType | null = null; +let inflight = false; + +async function pullOnce(): Promise { + if (inflight) return; + if (!authStore.user) return; + inflight = true; + try { + const items = await feedbackService.getNotifications({ unreadOnly: true, limit: 20 }); + for (const n of items) { + const message = n.creditsAwarded > 0 ? `${n.title} · +${n.creditsAwarded} Mana` : n.title; + // Status-completed and reactioner-bonus = success vibes + // (credits flowing). Everything else is an info. + const isReward = n.kind === 'status_completed' || n.kind === 'reactioner_bonus'; + (isReward ? toast.success : toast.info)(message); + + // Fire-and-forget mark-read; if it fails, we'll show again on + // the next poll which is fine — better duplicates than silence. + void feedbackService.markNotificationRead(n.id).catch((err) => { + console.warn('[feedback-toaster] mark-read failed:', err); + }); + } + } catch (err) { + console.warn('[feedback-toaster] pull failed:', err); + } finally { + inflight = false; + } +} + +export function startFeedbackToaster(): void { + if (timer) return; + void pullOnce(); + timer = setInterval(() => { + void pullOnce(); + }, POLL_INTERVAL_MS); +} + +export function stopFeedbackToaster(): void { + if (timer) { + clearInterval(timer); + timer = null; + } +} diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index dfa19ab5a..e1e5a80e5 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -24,6 +24,10 @@ import { startEventBridge, stopEventBridge } from '$lib/triggers/event-bridge'; import { startStreakTracker, stopStreakTracker } from '$lib/data/projections/streaks'; import { startGoalTracker, stopGoalTracker } from '$lib/companion/goals'; + import { + startFeedbackToaster, + stopFeedbackToaster, + } from '$lib/notifications/feedback-toaster.svelte'; import { initByok } from '$lib/byok'; import { bottomBarStore } from '$lib/stores/bottom-bar.svelte'; import { locale, _ } from 'svelte-i18n'; @@ -567,6 +571,7 @@ startEventBridge(); startStreakTracker(); startGoalTracker(); + startFeedbackToaster(); initByok(); startLlmQueue(); startMemoroLlmWatcher(); @@ -729,6 +734,7 @@ stopEventBridge(); stopStreakTracker(); stopGoalTracker(); + stopFeedbackToaster(); stopMissionTick(); stopServerIterationExecutor(); guestMode?.destroy(); diff --git a/apps/mana/apps/web/src/routes/(app)/profile/my-wishes/+page.svelte b/apps/mana/apps/web/src/routes/(app)/profile/my-wishes/+page.svelte new file mode 100644 index 000000000..def6474b8 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/profile/my-wishes/+page.svelte @@ -0,0 +1,29 @@ + + + + Meine Wünsche · Mana + + + +
+ +
+
+ + diff --git a/packages/feedback/src/createFeedbackService.ts b/packages/feedback/src/createFeedbackService.ts index 432464e0b..25a63bc0a 100644 --- a/packages/feedback/src/createFeedbackService.ts +++ b/packages/feedback/src/createFeedbackService.ts @@ -18,7 +18,7 @@ import type { ReactInput, } from './api'; import type { FeedbackServiceConfig } from './types'; -import type { PublicFeedbackItem, ReactionEmoji } from './feedback'; +import type { FeedbackNotification, PublicFeedbackItem, ReactionEmoji } from './feedback'; export function createFeedbackService(config: FeedbackServiceConfig) { const { @@ -95,6 +95,40 @@ export function createFeedbackService(config: FeedbackServiceConfig) { return fetchWithAuth(`${feedbackEndpoint}/me`); } + async function getMyReactedItems(): Promise { + const res = await fetchWithAuth<{ items: PublicFeedbackItem[] }>( + `${feedbackEndpoint}/me/reacted` + ); + return res.items; + } + + async function getNotifications(opts?: { + unreadOnly?: boolean; + limit?: number; + }): Promise { + const params = new URLSearchParams(); + if (opts?.unreadOnly) params.set('unread_only', 'true'); + if (opts?.limit) params.set('limit', String(opts.limit)); + const qs = params.toString(); + const res = await fetchWithAuth<{ items: FeedbackNotification[] }>( + `${feedbackEndpoint}/me/notifications${qs ? `?${qs}` : ''}` + ); + return res.items; + } + + async function markNotificationRead(id: string): Promise<{ ok: true }> { + return fetchWithAuth<{ ok: true }>(`${feedbackEndpoint}/me/notifications/${id}/read`, { + method: 'POST', + }); + } + + async function markAllNotificationsRead(): Promise<{ ok: true; count: number }> { + return fetchWithAuth<{ ok: true; count: number }>( + `${feedbackEndpoint}/me/notifications/read-all`, + { method: 'POST' } + ); + } + async function getReplies(feedbackId: string): Promise { return fetchWithAuth(`${feedbackEndpoint}/${feedbackId}/replies`); } @@ -153,6 +187,10 @@ export function createFeedbackService(config: FeedbackServiceConfig) { getPublicFeedAnonymous, getPublicItemAnonymous, getMyFeedback, + getMyReactedItems, + getNotifications, + markNotificationRead, + markAllNotificationsRead, getReplies, toggleReaction, deleteFeedback, diff --git a/packages/feedback/src/feedback.ts b/packages/feedback/src/feedback.ts index bedcda457..f891c7c98 100644 --- a/packages/feedback/src/feedback.ts +++ b/packages/feedback/src/feedback.ts @@ -64,6 +64,33 @@ export interface PublicFeedbackItem { myReactions?: string[]; } +/** + * Inbox notification — server enqueues one row per status-transition + * for the author + each reactioner-with-👍/🚀-on-completed. Web polls + * /me/notifications and renders unread ones as toasts. + */ +export type NotificationKind = + | 'status_planned' + | 'status_in_progress' + | 'status_completed' + | 'status_declined' + | 'status_under_review' + | 'status_submitted' + | 'admin_response' + | 'reactioner_bonus'; + +export interface FeedbackNotification { + id: string; + userId: string; + feedbackId: string; + kind: NotificationKind; + title: string; + body: string | null; + creditsAwarded: number; + readAt: string | null; + createdAt: string; +} + /** * Authenticated, full feedback record (own submissions / admin views). * Includes the user-private fields the public feed redacts. diff --git a/packages/feedback/src/index.ts b/packages/feedback/src/index.ts index 59520dfaa..9061861b0 100644 --- a/packages/feedback/src/index.ts +++ b/packages/feedback/src/index.ts @@ -13,6 +13,8 @@ export { type FeedbackCategory, type FeedbackStatus, type Feedback, + type FeedbackNotification, + type NotificationKind, type PublicFeedbackItem, type ReactionEmoji, REACTION_EMOJIS, diff --git a/services/mana-analytics/drizzle/0004_feedback_notifications.sql b/services/mana-analytics/drizzle/0004_feedback_notifications.sql new file mode 100644 index 000000000..a6d8ed602 --- /dev/null +++ b/services/mana-analytics/drizzle/0004_feedback_notifications.sql @@ -0,0 +1,32 @@ +-- 0004_feedback_notifications.sql +-- +-- Phase 3.B.1 von docs/plans/feedback-rewards-and-identity.md. +-- +-- Per-User-Inbox-Tabelle für Status-Change-Notifications. Server +-- enqueued bei jedem adminUpdate, Web-App pollt beim Boot und rendert +-- Unread-Items als Toast. +-- +-- ON DELETE CASCADE: wenn ein feedback-Item gelöscht wird, sind +-- baumelnde Notifications irrelevant. + +BEGIN; + +CREATE TABLE IF NOT EXISTS feedback.feedback_notifications ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id text NOT NULL, + feedback_id uuid NOT NULL REFERENCES feedback.user_feedback(id) ON DELETE CASCADE, + kind text NOT NULL, + title text NOT NULL, + body text, + credits_awarded integer NOT NULL DEFAULT 0, + read_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS feedback_notifications_unread_idx + ON feedback.feedback_notifications (user_id, created_at); + +CREATE INDEX IF NOT EXISTS feedback_notifications_feedback_idx + ON feedback.feedback_notifications (feedback_id); + +COMMIT; diff --git a/services/mana-analytics/src/db/schema/feedback.ts b/services/mana-analytics/src/db/schema/feedback.ts index 64694fca7..c24adf445 100644 --- a/services/mana-analytics/src/db/schema/feedback.ts +++ b/services/mana-analytics/src/db/schema/feedback.ts @@ -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; diff --git a/services/mana-analytics/src/routes/feedback.ts b/services/mana-analytics/src/routes/feedback.ts index 844618835..0012d4be6 100644 --- a/services/mana-analytics/src/routes/feedback.ts +++ b/services/mana-analytics/src/routes/feedback.ts @@ -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'))); }); diff --git a/services/mana-analytics/src/services/feedback.ts b/services/mana-analytics/src/services/feedback.ts index 7f3e801a0..e30f246f3 100644 --- a/services/mana-analytics/src/services/feedback.ts +++ b/services/mana-analytics/src/services/feedback.ts @@ -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 { + 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 { 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 { + try { + const titleByStatus: Record = { + 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 = { + 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 { + 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 { 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); }