mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 02:59:40 +02:00
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>
202 lines
6.8 KiB
TypeScript
202 lines
6.8 KiB
TypeScript
/**
|
|
* Feedback Service Factory — Public-Community Hub client.
|
|
*
|
|
* One factory builds the auth-required service used by logged-in users
|
|
* (submit, react, manage own items). The companion `createPublicFeedbackService`
|
|
* (in createPublicFeedbackService.ts) is for SSR / unauthenticated reads.
|
|
*/
|
|
|
|
import type {
|
|
CreateFeedbackInput,
|
|
FeedbackQueryParams,
|
|
FeedbackResponse,
|
|
FeedbackListResponse,
|
|
PublicFeedListResponse,
|
|
PublicItemResponse,
|
|
ReactionResponse,
|
|
AdminPatchInput,
|
|
ReactInput,
|
|
} from './api';
|
|
import type { FeedbackServiceConfig } from './types';
|
|
import type { FeedbackNotification, PublicFeedbackItem, ReactionEmoji } from './feedback';
|
|
|
|
export function createFeedbackService(config: FeedbackServiceConfig) {
|
|
const {
|
|
apiUrl,
|
|
appId,
|
|
getAuthToken,
|
|
feedbackEndpoint = '/api/v1/feedback',
|
|
publicEndpoint = '/api/v1/public/feedback',
|
|
} = config;
|
|
|
|
const baseUrl = apiUrl.replace(/\/$/, '');
|
|
|
|
async function fetchWithAuth<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
|
const token = await getAuthToken();
|
|
if (!token) throw new Error('Not authenticated');
|
|
|
|
const response = await fetch(`${baseUrl}${endpoint}`, {
|
|
...options,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
'X-App-Id': appId,
|
|
...options.headers,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
async function fetchPublic<T>(endpoint: string): Promise<T> {
|
|
const response = await fetch(`${baseUrl}${endpoint}`);
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
function feedQueryString(query?: FeedbackQueryParams): string {
|
|
const params = new URLSearchParams();
|
|
params.set('appId', query?.appId ?? appId);
|
|
if (query?.moduleContext) params.set('moduleContext', query.moduleContext);
|
|
if (query?.status) params.set('status', query.status);
|
|
if (query?.category) params.set('category', query.category);
|
|
if (query?.sort) params.set('sort', query.sort);
|
|
if (query?.limit) params.set('limit', String(query.limit));
|
|
if (query?.offset) params.set('offset', String(query.offset));
|
|
return params.toString();
|
|
}
|
|
|
|
// ── Submission ──────────────────────────────────────────────────
|
|
|
|
async function createFeedback(input: CreateFeedbackInput): Promise<FeedbackResponse> {
|
|
return fetchWithAuth<FeedbackResponse>(feedbackEndpoint, {
|
|
method: 'POST',
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
// ── Reads (auth-required) ──────────────────────────────────────
|
|
|
|
/** Auth-enriched public feed: each item carries `myReactions[]`. */
|
|
async function getPublicFeed(query?: FeedbackQueryParams): Promise<PublicFeedbackItem[]> {
|
|
const qs = feedQueryString(query);
|
|
const res = await fetchWithAuth<PublicFeedListResponse>(`${feedbackEndpoint}/public?${qs}`);
|
|
return res.items;
|
|
}
|
|
|
|
async function getMyFeedback(): Promise<FeedbackListResponse> {
|
|
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[]> {
|
|
return fetchWithAuth<PublicFeedbackItem[]>(`${feedbackEndpoint}/${feedbackId}/replies`);
|
|
}
|
|
|
|
// ── Reads (anonymous, no auth) ─────────────────────────────────
|
|
|
|
async function getPublicFeedAnonymous(
|
|
query?: FeedbackQueryParams
|
|
): Promise<PublicFeedbackItem[]> {
|
|
const qs = feedQueryString(query);
|
|
const res = await fetchPublic<PublicFeedListResponse>(`${publicEndpoint}/feed?${qs}`);
|
|
return res.items;
|
|
}
|
|
|
|
async function getPublicItemAnonymous(id: string): Promise<PublicItemResponse> {
|
|
return fetchPublic<PublicItemResponse>(`${publicEndpoint}/${id}`);
|
|
}
|
|
|
|
// ── Reactions ──────────────────────────────────────────────────
|
|
|
|
async function toggleReaction(
|
|
feedbackId: string,
|
|
emoji: ReactionEmoji
|
|
): Promise<ReactionResponse> {
|
|
return fetchWithAuth<ReactionResponse>(`${feedbackEndpoint}/${feedbackId}/react`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ emoji } satisfies ReactInput),
|
|
});
|
|
}
|
|
|
|
// ── Mutations ──────────────────────────────────────────────────
|
|
|
|
async function deleteFeedback(feedbackId: string): Promise<{ success: boolean }> {
|
|
return fetchWithAuth<{ success: boolean }>(`${feedbackEndpoint}/${feedbackId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
// ── Admin ──────────────────────────────────────────────────────
|
|
|
|
async function adminListAll(query?: FeedbackQueryParams): Promise<FeedbackListResponse> {
|
|
const qs = feedQueryString(query);
|
|
return fetchWithAuth<FeedbackListResponse>(`${feedbackEndpoint}/admin?${qs}`);
|
|
}
|
|
|
|
async function adminPatch(feedbackId: string, patch: AdminPatchInput): Promise<FeedbackResponse> {
|
|
return fetchWithAuth<FeedbackResponse>(`${feedbackEndpoint}/admin/${feedbackId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(patch),
|
|
});
|
|
}
|
|
|
|
return {
|
|
createFeedback,
|
|
getPublicFeed,
|
|
getPublicFeedAnonymous,
|
|
getPublicItemAnonymous,
|
|
getMyFeedback,
|
|
getMyReactedItems,
|
|
getNotifications,
|
|
markNotificationRead,
|
|
markAllNotificationsRead,
|
|
getReplies,
|
|
toggleReaction,
|
|
deleteFeedback,
|
|
adminListAll,
|
|
adminPatch,
|
|
};
|
|
}
|
|
|
|
export type FeedbackService = ReturnType<typeof createFeedbackService>;
|