managarten/packages/feedback/src/createFeedbackService.ts
Till JS c9b122076a feat(feedback): public feed types + ReactionBar + service split
@mana/feedback wird zur Pflege-SSOT für Public-Community-Hub.

- PublicFeedbackItem-Typ: anonymisiertes Item, das nur display_name +
  reactions + status führt — kein userId, displayHash, deviceInfo.
- ReactionEmoji ('👍' '❤️' '🚀' '🤔' '🎉') + REACTION_LABELS mit DE-Labels.
- CreateFeedbackInput erweitert um moduleContext + parentId. Reactions
  + score auf Feedback-Type optional gemacht.
- Service-Split:
  createFeedbackService    — auth-required Submit/React/Manage,
                            getPublicFeed (auth-enriched mit myReactions)
  createPublicFeedbackService — anonymous, SSR-only, getFeed/getItem.
  toggleReaction(emoji) statt vote/unvote (legacy-Shims bleiben für
  back-compat zu vote → '👍'-Toggle).
- ReactionBar.svelte: Slack-Style emoji-row mit Active-Highlighting für
  myReactions, ReadOnly-Mode für Public-SSR. Auto-disabled-Tooltip.
- index.ts re-exportiert die neuen Typen + ReactionBar; FeedbackVote
  rausgeschmissen (durch FeedbackReactions im Server-Schema ersetzt).

FeedbackCard + FeedbackPage minimal angepasst, damit svelte-check
clean bleibt — die Legacy-Komponenten bleiben funktional, werden aber
in Phase 3 zu @mana/feedback's neuen Modul-Views ausgemistet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 00:01:06 +02:00

191 lines
6.6 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,
VoteResponse,
} from './api';
import type { FeedbackServiceConfig } from './types';
import type { 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 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),
});
}
// ── Legacy (back-compat shims) ─────────────────────────────────
// Older callers still use vote/unvote — translate to 👍-reaction toggles.
async function vote(feedbackId: string): Promise<VoteResponse> {
const res = await toggleReaction(feedbackId, '👍');
return {
success: true,
newVoteCount: res.reactions['👍'] ?? 0,
userHasVoted: res.userHasReacted,
};
}
const unvote = vote; // toggle, semantically idempotent for legacy callers
async function toggleVote(feedbackId: string): Promise<VoteResponse> {
return vote(feedbackId);
}
async function getPublicFeedback(query?: FeedbackQueryParams): Promise<FeedbackListResponse> {
const items = await getPublicFeed(query);
return { items: items as unknown as FeedbackListResponse['items'] };
}
return {
createFeedback,
getPublicFeed,
getPublicFeedAnonymous,
getPublicItemAnonymous,
getMyFeedback,
getReplies,
toggleReaction,
deleteFeedback,
adminListAll,
adminPatch,
// Legacy (deprecated):
getPublicFeedback,
vote,
unvote,
toggleVote,
};
}
export type FeedbackService = ReturnType<typeof createFeedbackService>;