mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 01:01:09 +02:00
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:
parent
c94b67395a
commit
3a18a5e50d
11 changed files with 933 additions and 7 deletions
|
|
@ -0,0 +1,527 @@
|
||||||
|
<!--
|
||||||
|
MyWishesView — Persönliche Roadmap.
|
||||||
|
Zeigt:
|
||||||
|
- eigene Posts mit Status-Badge + Live-Reactions-Count
|
||||||
|
- Items, auf die man reagiert hat (mit deren Status)
|
||||||
|
- Notification-Inbox (zuletzt empfangene Status-Änderungen)
|
||||||
|
Schließt den Loop: User sieht seinen Beitrag durchs System wandern.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
FEEDBACK_STATUS_CONFIG,
|
||||||
|
type Feedback,
|
||||||
|
type FeedbackNotification,
|
||||||
|
type FeedbackStatus,
|
||||||
|
type PublicFeedbackItem,
|
||||||
|
} from '@mana/feedback';
|
||||||
|
import { feedbackService } from '$lib/api/feedback';
|
||||||
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import ItemCard from '../components/ItemCard.svelte';
|
||||||
|
|
||||||
|
type Tab = 'mine' | 'reacted' | 'inbox';
|
||||||
|
|
||||||
|
let active = $state<Tab>('mine');
|
||||||
|
|
||||||
|
let myItems = $state<Feedback[]>([]);
|
||||||
|
let reactedItems = $state<PublicFeedbackItem[]>([]);
|
||||||
|
let notifications = $state<FeedbackNotification[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
if (!authStore.user) {
|
||||||
|
error = 'Login erforderlich.';
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
// Run the three fetches in parallel — they don't depend on each
|
||||||
|
// other and the user wants the page to feel snappy.
|
||||||
|
const [mineRes, reactedRes, notifsRes] = await Promise.all([
|
||||||
|
feedbackService.getMyFeedback(),
|
||||||
|
feedbackService.getMyReactedItems(),
|
||||||
|
feedbackService.getNotifications({ limit: 30 }),
|
||||||
|
]);
|
||||||
|
myItems = mineRes.items as unknown as Feedback[];
|
||||||
|
reactedItems = reactedRes;
|
||||||
|
notifications = notifsRes;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[my-wishes] load failed:', err);
|
||||||
|
error = err instanceof Error ? err.message : 'Laden fehlgeschlagen';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void loadAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
let unreadCount = $derived(notifications.filter((n) => !n.readAt).length);
|
||||||
|
|
||||||
|
function statusOf(s: string) {
|
||||||
|
return FEEDBACK_STATUS_CONFIG[s as FeedbackStatus] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(s: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(s).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: '2-digit',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return s.slice(0, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtRelativeFromNow(iso: string): string {
|
||||||
|
const ms = Date.now() - new Date(iso).getTime();
|
||||||
|
const min = Math.round(ms / 60000);
|
||||||
|
if (min < 1) return 'gerade eben';
|
||||||
|
if (min < 60) return `vor ${min} Min`;
|
||||||
|
const hrs = Math.round(min / 60);
|
||||||
|
if (hrs < 24) return `vor ${hrs} Std`;
|
||||||
|
const days = Math.round(hrs / 24);
|
||||||
|
if (days < 7) return `vor ${days} Tg`;
|
||||||
|
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllRead() {
|
||||||
|
try {
|
||||||
|
await feedbackService.markAllNotificationsRead();
|
||||||
|
notifications = notifications.map((n) => ({
|
||||||
|
...n,
|
||||||
|
readAt: n.readAt ?? new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[my-wishes] markAllRead failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToItem(id: string) {
|
||||||
|
void goto(`/community/${id}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="my-wishes">
|
||||||
|
<header class="hero">
|
||||||
|
<h1>Meine Wünsche</h1>
|
||||||
|
<p class="lead">
|
||||||
|
Was du eingereicht hast, was du unterstützt hast, und was sich getan hat. Dein persönlicher
|
||||||
|
Loop in der Community.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
|
||||||
|
<nav class="tabs" role="tablist">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tab"
|
||||||
|
class:active={active === 'mine'}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active === 'mine'}
|
||||||
|
onclick={() => (active = 'mine')}
|
||||||
|
>
|
||||||
|
<span>Eigene</span>
|
||||||
|
{#if myItems.length > 0}<span class="count">{myItems.length}</span>{/if}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tab"
|
||||||
|
class:active={active === 'reacted'}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active === 'reacted'}
|
||||||
|
onclick={() => (active = 'reacted')}
|
||||||
|
>
|
||||||
|
<span>Unterstützt</span>
|
||||||
|
{#if reactedItems.length > 0}<span class="count">{reactedItems.length}</span>{/if}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tab"
|
||||||
|
class:active={active === 'inbox'}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active === 'inbox'}
|
||||||
|
onclick={() => (active = 'inbox')}
|
||||||
|
>
|
||||||
|
<span>Inbox</span>
|
||||||
|
{#if unreadCount > 0}<span class="count badge-unread">{unreadCount}</span>{/if}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="state">Lade…</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="state error">{error}</div>
|
||||||
|
{:else if active === 'mine'}
|
||||||
|
{#if myItems.length === 0}
|
||||||
|
<div class="state">
|
||||||
|
Noch keine eigenen Wünsche. Schreib einen über das Idee-Lämpchen oder die Pille unten rechts
|
||||||
|
— oder über
|
||||||
|
<a class="link" href="/community">/community</a>.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid">
|
||||||
|
{#each myItems as item (item.id)}
|
||||||
|
{@const cfg = statusOf(item.status)}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
|
||||||
|
<article class="row" role="button" tabindex="0" onclick={() => goToItem(item.id)}>
|
||||||
|
<div class="row-meta">
|
||||||
|
{#if cfg}
|
||||||
|
<span class="status-pill" style:color={cfg.color} style:border-color={cfg.color}>
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if item.moduleContext}
|
||||||
|
<span class="badge module">{item.moduleContext}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="muted">{fmtDate(item.createdAt)}</span>
|
||||||
|
{#if !item.isPublic}
|
||||||
|
<span class="badge private">privat</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if item.title}
|
||||||
|
<h3 class="row-title">{item.title}</h3>
|
||||||
|
{/if}
|
||||||
|
<p class="row-text">{item.feedbackText}</p>
|
||||||
|
{#if item.adminResponse}
|
||||||
|
<div class="admin-response">
|
||||||
|
<div class="admin-label">Antwort vom Team</div>
|
||||||
|
<p>{item.adminResponse}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if active === 'reacted'}
|
||||||
|
{#if reactedItems.length === 0}
|
||||||
|
<div class="state">
|
||||||
|
Du hast noch nichts unterstützt. Reagier mit 👍 / ❤️ / 🚀 auf einen Wunsch im
|
||||||
|
<a class="link" href="/community">Community-Feed</a>.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid">
|
||||||
|
{#each reactedItems as item (item.id)}
|
||||||
|
<ItemCard {item} readOnly onClick={goToItem} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if active === 'inbox'}
|
||||||
|
<div class="inbox-controls">
|
||||||
|
{#if unreadCount > 0}
|
||||||
|
<button class="btn-ghost" onclick={markAllRead}> Alle als gelesen markieren </button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if notifications.length === 0}
|
||||||
|
<div class="state">Noch keine Benachrichtigungen.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid">
|
||||||
|
{#each notifications as n (n.id)}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
|
||||||
|
<article
|
||||||
|
class="notif"
|
||||||
|
class:unread={!n.readAt}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={() => goToItem(n.feedbackId)}
|
||||||
|
>
|
||||||
|
<header class="notif-head">
|
||||||
|
<span class="notif-title">{n.title}</span>
|
||||||
|
<span class="muted">{fmtRelativeFromNow(n.createdAt)}</span>
|
||||||
|
</header>
|
||||||
|
{#if n.body}
|
||||||
|
<p class="notif-body">{n.body}</p>
|
||||||
|
{/if}
|
||||||
|
{#if n.creditsAwarded > 0}
|
||||||
|
<span class="reward">+{n.creditsAwarded} Mana</span>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.my-wishes {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.5rem 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding: 0.5rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
margin: 0 0 0.375rem 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
line-height: 1.5;
|
||||||
|
max-width: 60ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
background: hsl(var(--color-muted) / 0.3);
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
color 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: hsl(var(--color-card));
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
box-shadow: 0 1px 3px hsl(0 0% 0% / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: hsl(var(--color-muted) / 0.5);
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active .count {
|
||||||
|
background: hsl(var(--color-muted) / 0.7);
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.count.badge-unread {
|
||||||
|
background: hsl(var(--color-primary));
|
||||||
|
color: hsl(var(--color-primary-foreground, 0 0% 100%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border: 1px solid hsl(var(--color-border));
|
||||||
|
background: hsl(var(--color-card));
|
||||||
|
border-radius: 0.875rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover {
|
||||||
|
border-color: hsl(var(--color-primary) / 0.4);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
padding: 0.125rem 0.4375rem;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 0.125rem 0.4375rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: hsl(var(--color-muted) / 0.4);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.module {
|
||||||
|
background: hsl(var(--color-primary) / 0.12);
|
||||||
|
color: hsl(var(--color-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.private {
|
||||||
|
background: hsl(var(--color-error, 0 84% 60%) / 0.12);
|
||||||
|
color: hsl(var(--color-error, 0 84% 60%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-response {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-left: 3px solid hsl(var(--color-primary));
|
||||||
|
background: hsl(var(--color-primary) / 0.06);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-response p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-label {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: hsl(var(--color-primary));
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
border: 1px solid hsl(var(--color-border));
|
||||||
|
background: hsl(var(--color-card));
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif.unread {
|
||||||
|
border-color: hsl(var(--color-primary) / 0.5);
|
||||||
|
background: hsl(var(--color-primary) / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif:hover {
|
||||||
|
border-color: hsl(var(--color-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-body {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: hsl(var(--color-primary) / 0.12);
|
||||||
|
color: hsl(var(--color-primary));
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
padding: 0.375rem 0.625rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid hsl(var(--color-border));
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
border-color: hsl(var(--color-primary) / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state.error {
|
||||||
|
color: hsl(var(--color-error, 0 84% 60%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
color: hsl(var(--color-primary));
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -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<typeof setInterval> | null = null;
|
||||||
|
let inflight = false;
|
||||||
|
|
||||||
|
async function pullOnce(): Promise<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,10 @@
|
||||||
import { startEventBridge, stopEventBridge } from '$lib/triggers/event-bridge';
|
import { startEventBridge, stopEventBridge } from '$lib/triggers/event-bridge';
|
||||||
import { startStreakTracker, stopStreakTracker } from '$lib/data/projections/streaks';
|
import { startStreakTracker, stopStreakTracker } from '$lib/data/projections/streaks';
|
||||||
import { startGoalTracker, stopGoalTracker } from '$lib/companion/goals';
|
import { startGoalTracker, stopGoalTracker } from '$lib/companion/goals';
|
||||||
|
import {
|
||||||
|
startFeedbackToaster,
|
||||||
|
stopFeedbackToaster,
|
||||||
|
} from '$lib/notifications/feedback-toaster.svelte';
|
||||||
import { initByok } from '$lib/byok';
|
import { initByok } from '$lib/byok';
|
||||||
import { bottomBarStore } from '$lib/stores/bottom-bar.svelte';
|
import { bottomBarStore } from '$lib/stores/bottom-bar.svelte';
|
||||||
import { locale, _ } from 'svelte-i18n';
|
import { locale, _ } from 'svelte-i18n';
|
||||||
|
|
@ -567,6 +571,7 @@
|
||||||
startEventBridge();
|
startEventBridge();
|
||||||
startStreakTracker();
|
startStreakTracker();
|
||||||
startGoalTracker();
|
startGoalTracker();
|
||||||
|
startFeedbackToaster();
|
||||||
initByok();
|
initByok();
|
||||||
startLlmQueue();
|
startLlmQueue();
|
||||||
startMemoroLlmWatcher();
|
startMemoroLlmWatcher();
|
||||||
|
|
@ -729,6 +734,7 @@
|
||||||
stopEventBridge();
|
stopEventBridge();
|
||||||
stopStreakTracker();
|
stopStreakTracker();
|
||||||
stopGoalTracker();
|
stopGoalTracker();
|
||||||
|
stopFeedbackToaster();
|
||||||
stopMissionTick();
|
stopMissionTick();
|
||||||
stopServerIterationExecutor();
|
stopServerIterationExecutor();
|
||||||
guestMode?.destroy();
|
guestMode?.destroy();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { ModuleShell } from '$lib/components/shell';
|
||||||
|
import MyWishesView from '$lib/modules/community/views/MyWishesView.svelte';
|
||||||
|
import { Megaphone } from '@mana/shared-icons';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Meine Wünsche · Mana</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<ModuleShell
|
||||||
|
variant="fill"
|
||||||
|
title="Meine Wünsche"
|
||||||
|
color="#F59E0B"
|
||||||
|
icon={Megaphone}
|
||||||
|
backHref="/community"
|
||||||
|
moduleId="community"
|
||||||
|
hideFeedback
|
||||||
|
>
|
||||||
|
<div class="page">
|
||||||
|
<MyWishesView />
|
||||||
|
</div>
|
||||||
|
</ModuleShell>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -18,7 +18,7 @@ import type {
|
||||||
ReactInput,
|
ReactInput,
|
||||||
} from './api';
|
} from './api';
|
||||||
import type { FeedbackServiceConfig } from './types';
|
import type { FeedbackServiceConfig } from './types';
|
||||||
import type { PublicFeedbackItem, ReactionEmoji } from './feedback';
|
import type { FeedbackNotification, PublicFeedbackItem, ReactionEmoji } from './feedback';
|
||||||
|
|
||||||
export function createFeedbackService(config: FeedbackServiceConfig) {
|
export function createFeedbackService(config: FeedbackServiceConfig) {
|
||||||
const {
|
const {
|
||||||
|
|
@ -95,6 +95,40 @@ export function createFeedbackService(config: FeedbackServiceConfig) {
|
||||||
return fetchWithAuth<FeedbackListResponse>(`${feedbackEndpoint}/me`);
|
return fetchWithAuth<FeedbackListResponse>(`${feedbackEndpoint}/me`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getMyReactedItems(): Promise<PublicFeedbackItem[]> {
|
||||||
|
const res = await fetchWithAuth<{ items: PublicFeedbackItem[] }>(
|
||||||
|
`${feedbackEndpoint}/me/reacted`
|
||||||
|
);
|
||||||
|
return res.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getNotifications(opts?: {
|
||||||
|
unreadOnly?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<FeedbackNotification[]> {
|
||||||
|
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<PublicFeedbackItem[]> {
|
async function getReplies(feedbackId: string): Promise<PublicFeedbackItem[]> {
|
||||||
return fetchWithAuth<PublicFeedbackItem[]>(`${feedbackEndpoint}/${feedbackId}/replies`);
|
return fetchWithAuth<PublicFeedbackItem[]>(`${feedbackEndpoint}/${feedbackId}/replies`);
|
||||||
}
|
}
|
||||||
|
|
@ -153,6 +187,10 @@ export function createFeedbackService(config: FeedbackServiceConfig) {
|
||||||
getPublicFeedAnonymous,
|
getPublicFeedAnonymous,
|
||||||
getPublicItemAnonymous,
|
getPublicItemAnonymous,
|
||||||
getMyFeedback,
|
getMyFeedback,
|
||||||
|
getMyReactedItems,
|
||||||
|
getNotifications,
|
||||||
|
markNotificationRead,
|
||||||
|
markAllNotificationsRead,
|
||||||
getReplies,
|
getReplies,
|
||||||
toggleReaction,
|
toggleReaction,
|
||||||
deleteFeedback,
|
deleteFeedback,
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,33 @@ export interface PublicFeedbackItem {
|
||||||
myReactions?: string[];
|
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).
|
* Authenticated, full feedback record (own submissions / admin views).
|
||||||
* Includes the user-private fields the public feed redacts.
|
* Includes the user-private fields the public feed redacts.
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ export {
|
||||||
type FeedbackCategory,
|
type FeedbackCategory,
|
||||||
type FeedbackStatus,
|
type FeedbackStatus,
|
||||||
type Feedback,
|
type Feedback,
|
||||||
|
type FeedbackNotification,
|
||||||
|
type NotificationKind,
|
||||||
type PublicFeedbackItem,
|
type PublicFeedbackItem,
|
||||||
type ReactionEmoji,
|
type ReactionEmoji,
|
||||||
REACTION_EMOJIS,
|
REACTION_EMOJIS,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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
|
// 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
|
// 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.
|
// 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 Feedback = typeof userFeedback.$inferSelect;
|
||||||
export type FeedbackReaction = typeof feedbackReactions.$inferSelect;
|
export type FeedbackReaction = typeof feedbackReactions.$inferSelect;
|
||||||
|
export type FeedbackNotification = typeof feedbackNotifications.$inferSelect;
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,31 @@ export function createFeedbackRoutes(feedbackService: FeedbackService) {
|
||||||
return c.json(await feedbackService.getMyFeedback(user.userId));
|
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) => {
|
r.get('/:id/replies', async (c) => {
|
||||||
return c.json(await feedbackService.getReplies(c.req.param('id')));
|
return c.json(await feedbackService.getReplies(c.req.param('id')));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,12 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { eq, and, desc, sql, isNull, gte, inArray } from 'drizzle-orm';
|
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 type { Database } from '../db/connection';
|
||||||
import { NotFoundError, BadRequestError } from '../lib/errors';
|
import { NotFoundError, BadRequestError } from '../lib/errors';
|
||||||
import { createDisplayHash, generateDisplayName } from '../lib/pseudonym';
|
import { createDisplayHash, generateDisplayName } from '../lib/pseudonym';
|
||||||
|
|
@ -260,6 +265,30 @@ export class FeedbackService {
|
||||||
.orderBy(desc(userFeedback.createdAt));
|
.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. */
|
/** Map of emoji → boolean for the requesting user on a feedback item. */
|
||||||
async getMyReactionsFor(feedbackId: string, userId: string): Promise<string[]> {
|
async getMyReactionsFor(feedbackId: string, userId: string): Promise<string[]> {
|
||||||
const rows = await this.db
|
const rows = await this.db
|
||||||
|
|
@ -409,11 +438,23 @@ export class FeedbackService {
|
||||||
.where(eq(userFeedback.id, feedbackId))
|
.where(eq(userFeedback.id, feedbackId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Ship-Bonus: only on the FRESH 'completed' transition. Status-
|
// Status-Transition triggert immer eine Author-Notification, plus
|
||||||
// flapping ('completed' → 'in_progress' → 'completed') won't
|
// Reactioner-Notifications + Ship-Bonus-Credits beim FRISCHEN
|
||||||
// double-pay because the credit grant is keyed off
|
// 'completed'-Übergang. Doppel-Triggering bei Status-Flapping wird
|
||||||
// `${id}_shipped`, but skipping the trigger entirely keeps the
|
// strukturell durch den `before.status !== row.status`-Guard
|
||||||
// reactioner-bonus loop from spamming as well.
|
// 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') {
|
if (before.status !== 'completed' && row.status === 'completed') {
|
||||||
void this.tryGrantShipBonus(row);
|
void this.tryGrantShipBonus(row);
|
||||||
}
|
}
|
||||||
|
|
@ -421,6 +462,101 @@ export class FeedbackService {
|
||||||
return row;
|
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> {
|
private async tryGrantShipBonus(feedback: typeof userFeedback.$inferSelect): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Original wisher gets the +500.
|
// Original wisher gets the +500.
|
||||||
|
|
@ -460,6 +596,18 @@ export class FeedbackService {
|
||||||
referenceId: `${feedback.id}_reaction_${supporter}`,
|
referenceId: `${feedback.id}_reaction_${supporter}`,
|
||||||
description: `Du hast ›${feedback.title ?? '(Wunsch)'}‹ unterstützt — danke!`,
|
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) {
|
} catch (err) {
|
||||||
console.warn('[feedback] reactioner-bonus failed for', supporter, err);
|
console.warn('[feedback] reactioner-bonus failed for', supporter, err);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue