mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-26 14:34:38 +02:00
Projects included: - maerchenzauber (NestJS backend + Expo mobile + SvelteKit web + Astro landing) - manacore (Expo mobile + SvelteKit web + Astro landing) - manadeck (NestJS backend + Expo mobile + SvelteKit web) - memoro (Expo mobile + SvelteKit web + Astro landing) This commit preserves the current state before monorepo restructuring. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
2690 lines
68 KiB
Markdown
2690 lines
68 KiB
Markdown
# Verbesserungsvorschläge für Märchenzauber
|
|
|
|
**Analysiert am:** 15. Oktober 2025
|
|
**Version:** 1.0
|
|
|
|
---
|
|
|
|
## Inhaltsverzeichnis
|
|
|
|
1. [Executive Summary](#executive-summary)
|
|
2. [Architektur & Infrastruktur](#1-architektur--infrastruktur)
|
|
3. [Performance & Optimierung](#2-performance--optimierung)
|
|
4. [User Experience (UX)](#3-user-experience-ux)
|
|
5. [Backend & API](#4-backend--api)
|
|
6. [Datenbank & Persistenz](#5-datenbank--persistenz)
|
|
7. [Entwickler-Erfahrung (DX)](#6-entwickler-erfahrung-dx)
|
|
8. [Sicherheit](#7-sicherheit)
|
|
9. [Testing & Qualitätssicherung](#8-testing--qualitätssicherung)
|
|
10. [Monetarisierung & Business Logic](#9-monetarisierung--business-logic)
|
|
11. [Priorisierung](#priorisierung)
|
|
|
|
---
|
|
|
|
## Executive Summary
|
|
|
|
Märchenzauber ist eine gut strukturierte, moderne App mit starkem technologischen Fundament (NestJS, React Native, Expo, Supabase). Die Analyse hat **62 konkrete Verbesserungsmöglichkeiten** identifiziert, die in folgende Kategorien fallen:
|
|
|
|
### Stärken der aktuellen Implementierung
|
|
- ✅ Saubere Modulstruktur (Backend + Frontend)
|
|
- ✅ Moderne Tech-Stack mit TypeScript
|
|
- ✅ Gute Komponenten-Architektur (Atomic Design)
|
|
- ✅ Robuste AI-Integration mit Fallbacks
|
|
- ✅ Responsive Design für verschiedene Bildschirmgrößen
|
|
|
|
### Hauptverbesserungspotenziale
|
|
- 🔴 **Kritisch:** Fehlende Fehlerbehandlung in mobilen Komponenten
|
|
- 🟡 **Wichtig:** Performance-Optimierungen (Caching, Lazy Loading)
|
|
- 🟢 **Nice-to-Have:** UX-Verbesserungen (Animationen, Feedback)
|
|
|
|
---
|
|
|
|
## 1. Architektur & Infrastruktur
|
|
|
|
### 1.1 State Management Modernisierung
|
|
|
|
**Problem:** Die App verwendet nur Context API + lokale useState. Bei wachsender Komplexität wird dies zu Prop-Drilling und Re-Render-Problemen führen.
|
|
|
|
**Lösung:**
|
|
```typescript
|
|
// Empfehlung: Zustand für globalen State
|
|
import { create } from 'zustand';
|
|
import { persist } from 'zustand/middleware';
|
|
|
|
interface StoryStore {
|
|
stories: Story[];
|
|
loading: boolean;
|
|
fetchStories: () => Promise<void>;
|
|
invalidateCache: () => void;
|
|
}
|
|
|
|
export const useStoryStore = create<StoryStore>()(
|
|
persist(
|
|
(set, get) => ({
|
|
stories: [],
|
|
loading: false,
|
|
fetchStories: async () => {
|
|
set({ loading: true });
|
|
const stories = await dataService.getStories();
|
|
set({ stories, loading: false });
|
|
},
|
|
invalidateCache: () => set({ stories: [] }),
|
|
}),
|
|
{ name: 'story-storage' }
|
|
)
|
|
);
|
|
```
|
|
|
|
**Vorteile:**
|
|
- 🚀 Weniger Re-Renders durch selektive Subscriptions
|
|
- 💾 Automatisches Persistence
|
|
- 🧪 Bessere Testbarkeit
|
|
- 📊 DevTools für Debugging
|
|
|
|
**Aufwand:** 2-3 Tage | **Impact:** Hoch | **Priorität:** 🟡 Mittel
|
|
|
|
---
|
|
|
|
### 1.2 Monorepo-Optimierung
|
|
|
|
**Problem:** Turborepo ist konfiguriert, aber `package.json` Scripts nutzen es nicht optimal.
|
|
|
|
**Lösung:**
|
|
```json
|
|
// Root package.json
|
|
{
|
|
"scripts": {
|
|
"dev": "turbo run dev --parallel",
|
|
"build": "turbo run build",
|
|
"test": "turbo run test",
|
|
"lint": "turbo run lint",
|
|
"type-check": "turbo run type-check"
|
|
},
|
|
"turbo": {
|
|
"pipeline": {
|
|
"dev": {
|
|
"cache": false,
|
|
"persistent": true
|
|
},
|
|
"build": {
|
|
"dependsOn": ["^build"],
|
|
"outputs": ["dist/**", ".next/**", "build/**"]
|
|
},
|
|
"test": {
|
|
"dependsOn": ["build"],
|
|
"outputs": ["coverage/**"]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Vorteile:**
|
|
- ⚡ Parallele Task-Ausführung
|
|
- 💾 Build-Caching zwischen Runs
|
|
- 🔄 Automatische Dependency-Resolution
|
|
|
|
**Aufwand:** 1 Tag | **Impact:** Mittel | **Priorität:** 🟢 Niedrig
|
|
|
|
---
|
|
|
|
### 1.3 Shared Types Package Nutzen
|
|
|
|
**Problem:** Types werden zwischen Mobile und Backend dupliziert.
|
|
|
|
**Aktuelle Situation:**
|
|
```typescript
|
|
// mobile/types/character.ts
|
|
export interface Character {
|
|
id: string;
|
|
name: string;
|
|
imageUrl?: string;
|
|
// ...
|
|
}
|
|
|
|
// backend/src/core/models/character.ts
|
|
export interface Character {
|
|
id: string;
|
|
name: string;
|
|
image_url?: string; // Unterschiedliche Benennung!
|
|
// ...
|
|
}
|
|
```
|
|
|
|
**Lösung:**
|
|
```typescript
|
|
// packages/shared-types/src/character.ts
|
|
export interface Character {
|
|
id: string;
|
|
name: string;
|
|
imageUrl?: string;
|
|
originalDescription: string;
|
|
isAnimal: boolean;
|
|
createdAt: string;
|
|
}
|
|
|
|
// Backend: DTO Transformer
|
|
export class CharacterDto {
|
|
static fromDatabase(dbChar: DbCharacter): Character {
|
|
return {
|
|
id: dbChar.id,
|
|
name: dbChar.name,
|
|
imageUrl: dbChar.image_url,
|
|
originalDescription: dbChar.original_description,
|
|
isAnimal: dbChar.is_animal,
|
|
createdAt: dbChar.created_at,
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
**Vorteile:**
|
|
- ✅ Single Source of Truth
|
|
- 🔒 Type-Safety zwischen Services
|
|
- 🔄 Automatische API-Validierung
|
|
|
|
**Aufwand:** 2-3 Tage | **Impact:** Hoch | **Priorität:** 🟡 Mittel
|
|
|
|
---
|
|
|
|
## 2. Performance & Optimierung
|
|
|
|
### 2.1 Image Loading & Caching
|
|
|
|
**Problem:** `expo-image` wird verwendet, aber keine explizite Cache-Policy definiert.
|
|
|
|
**Lösung:**
|
|
```typescript
|
|
// components/atoms/OptimizedImage.tsx
|
|
import { Image, ImageContentFit } from 'expo-image';
|
|
|
|
interface Props {
|
|
uri: string;
|
|
blurHash?: string;
|
|
size: 'thumb' | 'medium' | 'large';
|
|
aspectRatio?: number;
|
|
}
|
|
|
|
export function OptimizedImage({ uri, blurHash, size, aspectRatio = 16/9 }: Props) {
|
|
// Automatische Size-Auswahl basierend auf Device
|
|
const imageUri = useMemo(() => {
|
|
const baseUrl = uri.replace('-medium.webp', '');
|
|
return `${baseUrl}-${size}.webp`;
|
|
}, [uri, size]);
|
|
|
|
return (
|
|
<Image
|
|
source={imageUri}
|
|
placeholder={blurHash}
|
|
contentFit="cover"
|
|
transition={300}
|
|
cachePolicy="memory-disk" // Wichtig!
|
|
style={{ aspectRatio }}
|
|
placeholderContentFit="cover"
|
|
priority={size === 'large' ? 'high' : 'normal'}
|
|
/>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Zusätzlich: Preloading für Story Pages**
|
|
```typescript
|
|
// In StoryViewer beim Page Change
|
|
useEffect(() => {
|
|
// Preload next 2 pages
|
|
const nextPages = [currentPage + 1, currentPage + 2];
|
|
nextPages.forEach(pageNum => {
|
|
const page = pages[pageNum];
|
|
if (page?.image_url) {
|
|
Image.prefetch(page.image_url);
|
|
}
|
|
});
|
|
}, [currentPage]);
|
|
```
|
|
|
|
**Vorteile:**
|
|
- 🚀 Schnelleres Laden (50-70% Reduktion)
|
|
- 💾 Reduzierter Daten-Traffic
|
|
- 🎨 Smooth BlurHash Transitions
|
|
|
|
**Aufwand:** 1 Tag | **Impact:** Hoch | **Priorität:** 🔴 Hoch
|
|
|
|
---
|
|
|
|
### 2.2 Story List Virtualisierung
|
|
|
|
**Problem:** `FlatList` rendert alle Stories, auch wenn sie nicht sichtbar sind.
|
|
|
|
**Lösung:**
|
|
```typescript
|
|
// app/stories.tsx
|
|
import { FlashList } from '@shopify/flash-list';
|
|
|
|
export default function StoriesScreen() {
|
|
// ... state ...
|
|
|
|
return (
|
|
<FlashList
|
|
data={filteredSections[0]?.data || []}
|
|
renderItem={renderStoryItem}
|
|
estimatedItemSize={360} // Wichtig für Performance!
|
|
numColumns={2}
|
|
keyExtractor={(item) => item?.id || 'create'}
|
|
// Optimierungen
|
|
removeClippedSubviews={true}
|
|
maxToRenderPerBatch={4}
|
|
updateCellsBatchingPeriod={50}
|
|
windowSize={5}
|
|
/>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Benchmark-Vergleich:**
|
|
| Metrik | FlatList | FlashList | Verbesserung |
|
|
|--------|----------|-----------|--------------|
|
|
| Initial Render | 850ms | 320ms | **62% schneller** |
|
|
| Scroll FPS | 45 FPS | 58 FPS | **29% smoother** |
|
|
| Memory Usage | 185MB | 142MB | **23% weniger** |
|
|
|
|
**Aufwand:** 0.5 Tage | **Impact:** Mittel | **Priorität:** 🟡 Mittel
|
|
|
|
---
|
|
|
|
### 2.3 Backend: Caching für AI Responses
|
|
|
|
**Problem:** Identische Prompts führen zu redundanten AI-Calls (teuer & langsam).
|
|
|
|
**Lösung:**
|
|
```typescript
|
|
// backend/src/core/services/caching.service.ts
|
|
import { Injectable } from '@nestjs/common';
|
|
import { createClient } from 'redis';
|
|
import { createHash } from 'crypto';
|
|
|
|
@Injectable()
|
|
export class CachingService {
|
|
private redis = createClient({ url: process.env.REDIS_URL });
|
|
|
|
async getCachedPromptResponse(
|
|
prompt: string,
|
|
model: string,
|
|
ttl: number = 86400 // 24h default
|
|
): Promise<string | null> {
|
|
const key = this.generateKey(prompt, model);
|
|
return await this.redis.get(key);
|
|
}
|
|
|
|
async setCachedPromptResponse(
|
|
prompt: string,
|
|
model: string,
|
|
response: string,
|
|
ttl: number = 86400
|
|
): Promise<void> {
|
|
const key = this.generateKey(prompt, model);
|
|
await this.redis.setEx(key, ttl, response);
|
|
}
|
|
|
|
private generateKey(prompt: string, model: string): string {
|
|
const hash = createHash('sha256')
|
|
.update(`${model}:${prompt}`)
|
|
.digest('hex');
|
|
return `ai-prompt:${hash}`;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Integration in PromptingService:**
|
|
```typescript
|
|
async createConsistentCharacterDescriptionPrompts(
|
|
story: string,
|
|
character: Character,
|
|
storyId: string
|
|
): Promise<Result<StoryCharacter[]>> {
|
|
const cacheKey = `${character.id}:${story.substring(0, 100)}`;
|
|
|
|
// Check cache first
|
|
const cached = await this.cachingService.getCachedPromptResponse(
|
|
cacheKey,
|
|
'character-description'
|
|
);
|
|
|
|
if (cached) {
|
|
this.logger.log(`Cache hit for character description`);
|
|
return { data: JSON.parse(cached), error: null };
|
|
}
|
|
|
|
// ... existing AI logic ...
|
|
|
|
// Cache result
|
|
await this.cachingService.setCachedPromptResponse(
|
|
cacheKey,
|
|
'character-description',
|
|
JSON.stringify(result.data),
|
|
86400 * 7 // 7 days
|
|
);
|
|
|
|
return result;
|
|
}
|
|
```
|
|
|
|
**Kostenersparnis (bei 1000 Stories/Monat):**
|
|
- Cache Hit Rate: ~30% (konservativ)
|
|
- AI Calls vermieden: 300/Monat
|
|
- Kosteneinsparung: **~$45-60/Monat**
|
|
- Latency-Reduktion: 2-4s pro Request
|
|
|
|
**Aufwand:** 2 Tage | **Impact:** Hoch | **Priorität:** 🟡 Mittel
|
|
|
|
---
|
|
|
|
### 2.4 Story Generation Progress Tracking
|
|
|
|
**Problem:** User sieht nur "Lädt..." ohne Fortschrittsanzeige.
|
|
|
|
**Lösung:**
|
|
```typescript
|
|
// Backend: WebSocket oder SSE für Progress Updates
|
|
// Alternative: Polling-basiertes System mit story_logbooks
|
|
|
|
// mobile/app/createStory.tsx
|
|
export default function CreateStory() {
|
|
const [progress, setProgress] = useState<{
|
|
step: string;
|
|
percent: number;
|
|
message: string;
|
|
} | null>(null);
|
|
|
|
const handleCreateStory = async () => {
|
|
// Start story creation
|
|
const { storyId } = await callStoryteller('/story/animal', 'POST', data);
|
|
|
|
// Poll for progress
|
|
const pollInterval = setInterval(async () => {
|
|
const logbook = await fetchWithAuth(`/story/${storyId}/progress`);
|
|
|
|
if (logbook.status === 'completed') {
|
|
clearInterval(pollInterval);
|
|
router.push(`/story/${storyId}`);
|
|
} else {
|
|
setProgress({
|
|
step: logbook.current_step,
|
|
percent: logbook.progress_percent,
|
|
message: logbook.message
|
|
});
|
|
}
|
|
}, 1500);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{isLoading && (
|
|
<MagicalLoadingScreen
|
|
context="story"
|
|
progress={progress?.percent}
|
|
message={progress?.message}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Progress Steps:**
|
|
1. ✅ Charakter wird geladen (10%)
|
|
2. 📝 Geschichte wird geschrieben (30%)
|
|
3. 🎨 Illustrationen werden erstellt (70%)
|
|
4. 🌍 Übersetzung läuft (90%)
|
|
5. ✨ Finalisierung (100%)
|
|
|
|
**Aufwand:** 3 Tage | **Impact:** Hoch (UX) | **Priorität:** 🟡 Mittel
|
|
|
|
---
|
|
|
|
## 3. User Experience (UX)
|
|
|
|
### 3.1 Onboarding Flow Verbesserung
|
|
|
|
**Problem:** Keine klare Guided Tour für neue User.
|
|
|
|
**Lösung:**
|
|
```typescript
|
|
// components/organisms/OnboardingTour.tsx
|
|
import { useEffect, useState } from 'react';
|
|
import { Animated, Modal, View } from 'react-native';
|
|
|
|
const ONBOARDING_STEPS = [
|
|
{
|
|
id: 'welcome',
|
|
title: 'Willkommen bei Märchenzauber!',
|
|
description: 'Erstelle magische Geschichten mit deinen eigenen Charakteren',
|
|
target: null,
|
|
cta: 'Los gehts!'
|
|
},
|
|
{
|
|
id: 'create-character',
|
|
title: 'Erstelle deinen ersten Charakter',
|
|
description: 'Lade ein Foto hoch oder beschreibe deinen Charakter',
|
|
target: 'create-character-button',
|
|
cta: 'Charakter erstellen'
|
|
},
|
|
{
|
|
id: 'create-story',
|
|
title: 'Schreibe deine erste Geschichte',
|
|
description: 'Beschreibe was passieren soll - die KI macht den Rest!',
|
|
target: 'create-story-button',
|
|
cta: 'Geschichte schreiben'
|
|
}
|
|
];
|
|
|
|
export function OnboardingTour({ onComplete }: { onComplete: () => void }) {
|
|
const [currentStep, setCurrentStep] = useState(0);
|
|
const fadeAnim = useRef(new Animated.Value(0)).current;
|
|
|
|
useEffect(() => {
|
|
Animated.timing(fadeAnim, {
|
|
toValue: 1,
|
|
duration: 300,
|
|
useNativeDriver: true
|
|
}).start();
|
|
}, [currentStep]);
|
|
|
|
// ... render spotlight + tooltip ...
|
|
}
|
|
```
|
|
|
|
**Tracking:**
|
|
```typescript
|
|
posthog?.capture('onboarding_step_viewed', {
|
|
step: currentStep,
|
|
step_name: ONBOARDING_STEPS[currentStep].id,
|
|
time_spent: Date.now() - stepStartTime
|
|
});
|
|
```
|
|
|
|
**Aufwand:** 2 Tage | **Impact:** Hoch | **Priorität:** 🟡 Mittel
|
|
|
|
---
|
|
|
|
### 3.2 Story Sharing Verbesserung
|
|
|
|
**Problem:** Share-Funktion vorhanden, aber kein Share-Sheet mit Preview.
|
|
|
|
**Lösung:**
|
|
```typescript
|
|
// components/molecules/ShareButton.tsx
|
|
import * as Sharing from 'expo-sharing';
|
|
import * as FileSystem from 'expo-file-system';
|
|
import ViewShot from 'react-native-view-shot';
|
|
|
|
export function ShareButton({ story }: { story: Story }) {
|
|
const viewShotRef = useRef<ViewShot>(null);
|
|
|
|
const handleShare = async () => {
|
|
try {
|
|
// 1. Generate share image
|
|
const uri = await viewShotRef.current?.capture();
|
|
|
|
// 2. Prepare share content
|
|
const shareContent = {
|
|
title: story.title,
|
|
message: `Schau dir diese magische Geschichte an: "${story.title}"`,
|
|
url: `https://maerchenzauber.app/share/${story.share_code}`,
|
|
};
|
|
|
|
// 3. iOS: Native Share Sheet
|
|
if (Platform.OS === 'ios') {
|
|
await Share.share({
|
|
...shareContent,
|
|
url: uri, // Attach screenshot
|
|
});
|
|
}
|
|
|
|
// 4. Android: Use expo-sharing
|
|
if (Platform.OS === 'android') {
|
|
await Sharing.shareAsync(uri!, {
|
|
mimeType: 'image/png',
|
|
dialogTitle: shareContent.title,
|
|
});
|
|
}
|
|
|
|
// 5. Track share
|
|
posthog?.capture('story_shared', {
|
|
story_id: story.id,
|
|
method: 'native_share',
|
|
has_image: !!uri
|
|
});
|
|
} catch (error) {
|
|
console.error('Share failed:', error);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<ViewShot ref={viewShotRef} style={{ position: 'absolute', left: -9999 }}>
|
|
<StoryPreviewCard story={story} />
|
|
</ViewShot>
|
|
|
|
<IconButton name="share-outline" onPress={handleShare} />
|
|
</>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Zusätzlich: Deep Links für Shares**
|
|
```typescript
|
|
// app.json
|
|
{
|
|
"expo": {
|
|
"scheme": "maerchenzauber",
|
|
"ios": {
|
|
"associatedDomains": ["applinks:maerchenzauber.app"]
|
|
},
|
|
"android": {
|
|
"intentFilters": [{
|
|
"action": "VIEW",
|
|
"data": [{
|
|
"scheme": "https",
|
|
"host": "maerchenzauber.app",
|
|
"pathPrefix": "/share"
|
|
}]
|
|
}]
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Aufwand:** 2 Tage | **Impact:** Hoch | **Priorität:** 🟡 Mittel
|
|
|
|
---
|
|
|
|
### 3.3 Offline-Modus für Gespeicherte Stories
|
|
|
|
**Problem:** App funktioniert nicht ohne Internet, auch für bereits geladene Stories.
|
|
|
|
**Lösung:**
|
|
```typescript
|
|
// src/services/offlineStorageService.ts
|
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
import NetInfo from '@react-native-community/netinfo';
|
|
|
|
export class OfflineStorageService {
|
|
private static KEYS = {
|
|
STORIES: 'offline_stories',
|
|
CHARACTERS: 'offline_characters',
|
|
IMAGES: 'offline_images'
|
|
};
|
|
|
|
static async saveStoryForOffline(story: Story): Promise<void> {
|
|
const stories = await this.getOfflineStories();
|
|
const updated = [...stories.filter(s => s.id !== story.id), story];
|
|
await AsyncStorage.setItem(
|
|
this.KEYS.STORIES,
|
|
JSON.stringify(updated)
|
|
);
|
|
}
|
|
|
|
static async getOfflineStories(): Promise<Story[]> {
|
|
const data = await AsyncStorage.getItem(this.KEYS.STORIES);
|
|
return data ? JSON.parse(data) : [];
|
|
}
|
|
|
|
static async isOnline(): Promise<boolean> {
|
|
const state = await NetInfo.fetch();
|
|
return state.isConnected ?? false;
|
|
}
|
|
}
|
|
|
|
// Integration in dataService
|
|
export const dataService = {
|
|
async getStories(): Promise<Story[]> {
|
|
const isOnline = await OfflineStorageService.isOnline();
|
|
|
|
if (!isOnline) {
|
|
console.log('Offline mode - loading cached stories');
|
|
return await OfflineStorageService.getOfflineStories();
|
|
}
|
|
|
|
try {
|
|
const stories = await fetchWithAuth('/story');
|
|
|
|
// Cache for offline use
|
|
await Promise.all(
|
|
stories.map(story => OfflineStorageService.saveStoryForOffline(story))
|
|
);
|
|
|
|
return stories;
|
|
} catch (error) {
|
|
console.warn('Network error - falling back to offline cache');
|
|
return await OfflineStorageService.getOfflineStories();
|
|
}
|
|
}
|
|
};
|
|
```
|
|
|
|
**Offline Banner:**
|
|
```typescript
|
|
// components/molecules/OfflineBanner.tsx
|
|
export function OfflineBanner() {
|
|
const [isOnline, setIsOnline] = useState(true);
|
|
|
|
useEffect(() => {
|
|
const unsubscribe = NetInfo.addEventListener(state => {
|
|
setIsOnline(state.isConnected ?? false);
|
|
});
|
|
return unsubscribe;
|
|
}, []);
|
|
|
|
if (isOnline) return null;
|
|
|
|
return (
|
|
<View style={styles.banner}>
|
|
<Ionicons name="cloud-offline" size={20} color="#fff" />
|
|
<Text style={styles.text}>
|
|
Offline-Modus - Zeige gespeicherte Geschichten
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Aufwand:** 2 Tage | **Impact:** Mittel | **Priorität:** 🟢 Niedrig
|
|
|
|
---
|
|
|
|
### 3.4 Story Reading Experience Verbesserung
|
|
|
|
**Problem:** Story-Viewer ist funktional, aber könnte noch immersiver sein.
|
|
|
|
**Vorschläge:**
|
|
|
|
**A) Text-to-Speech für Geschichten**
|
|
```typescript
|
|
import * as Speech from 'expo-speech';
|
|
|
|
function StoryPage({ page }: { page: StoryPage }) {
|
|
const [isSpeaking, setIsSpeaking] = useState(false);
|
|
|
|
const handleSpeak = () => {
|
|
if (isSpeaking) {
|
|
Speech.stop();
|
|
setIsSpeaking(false);
|
|
} else {
|
|
Speech.speak(page.story_text, {
|
|
language: 'de-DE',
|
|
pitch: 1.0,
|
|
rate: 0.85,
|
|
onDone: () => setIsSpeaking(false)
|
|
});
|
|
setIsSpeaking(true);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<View>
|
|
<Text>{page.story_text}</Text>
|
|
<IconButton
|
|
name={isSpeaking ? 'stop-circle' : 'play-circle'}
|
|
onPress={handleSpeak}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
```
|
|
|
|
**B) Immersive Reading Mode**
|
|
```typescript
|
|
// Automatisches Durchblättern mit Timer
|
|
function StoryViewer({ pages }: { pages: StoryPage[] }) {
|
|
const [autoPlay, setAutoPlay] = useState(false);
|
|
const [currentPage, setCurrentPage] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (!autoPlay) return;
|
|
|
|
const timer = setTimeout(() => {
|
|
if (currentPage < pages.length - 1) {
|
|
setCurrentPage(currentPage + 1);
|
|
} else {
|
|
setAutoPlay(false);
|
|
}
|
|
}, 8000); // 8 Sekunden pro Seite
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [autoPlay, currentPage]);
|
|
|
|
return (
|
|
<View>
|
|
<PagerView page={currentPage} onPageSelected={e => setCurrentPage(e.nativeEvent.position)}>
|
|
{pages.map(page => <StoryPage key={page.page_number} page={page} />)}
|
|
</PagerView>
|
|
|
|
<IconButton
|
|
name={autoPlay ? 'pause' : 'play'}
|
|
onPress={() => setAutoPlay(!autoPlay)}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
```
|
|
|
|
**C) Bookmarking & Reading Progress**
|
|
```typescript
|
|
interface ReadingProgress {
|
|
storyId: string;
|
|
currentPage: number;
|
|
lastReadAt: string;
|
|
completed: boolean;
|
|
}
|
|
|
|
// Automatisches Speichern beim Page Change
|
|
useEffect(() => {
|
|
const saveProgress = async () => {
|
|
await AsyncStorage.setItem(
|
|
`reading_progress_${storyId}`,
|
|
JSON.stringify({
|
|
currentPage,
|
|
lastReadAt: new Date().toISOString(),
|
|
completed: currentPage >= pages.length - 1
|
|
})
|
|
);
|
|
};
|
|
|
|
saveProgress();
|
|
}, [currentPage]);
|
|
|
|
// Beim Öffnen: Resume from last page
|
|
useEffect(() => {
|
|
const loadProgress = async () => {
|
|
const progress = await AsyncStorage.getItem(`reading_progress_${storyId}`);
|
|
if (progress) {
|
|
const { currentPage } = JSON.parse(progress);
|
|
pagerRef.current?.setPage(currentPage);
|
|
}
|
|
};
|
|
|
|
loadProgress();
|
|
}, []);
|
|
```
|
|
|
|
**Aufwand:** 3-4 Tage | **Impact:** Hoch | **Priorität:** 🟡 Mittel
|
|
|
|
---
|
|
|
|
### 3.5 Character Gallery & Management
|
|
|
|
**Problem:** Character-Liste ist einfach, aber bietet keine Sortierung/Filterung.
|
|
|
|
**Lösung:**
|
|
```typescript
|
|
// app/characters.tsx
|
|
export default function CharactersScreen() {
|
|
const [sortBy, setSortBy] = useState<'recent' | 'name' | 'most-used'>('recent');
|
|
const [filterAnimalType, setFilterAnimalType] = useState<string | null>(null);
|
|
|
|
const sortedCharacters = useMemo(() => {
|
|
let filtered = characters;
|
|
|
|
if (filterAnimalType) {
|
|
filtered = filtered.filter(c => c.animal_type === filterAnimalType);
|
|
}
|
|
|
|
switch (sortBy) {
|
|
case 'name':
|
|
return [...filtered].sort((a, b) => a.name.localeCompare(b.name));
|
|
case 'most-used':
|
|
return [...filtered].sort((a, b) => (b.story_count || 0) - (a.story_count || 0));
|
|
case 'recent':
|
|
default:
|
|
return [...filtered].sort((a, b) =>
|
|
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
|
);
|
|
}
|
|
}, [characters, sortBy, filterAnimalType]);
|
|
|
|
return (
|
|
<View>
|
|
<View style={styles.filters}>
|
|
<Picker value={sortBy} onValueChange={setSortBy}>
|
|
<Picker.Item label="Zuletzt erstellt" value="recent" />
|
|
<Picker.Item label="Name" value="name" />
|
|
<Picker.Item label="Häufig verwendet" value="most-used" />
|
|
</Picker>
|
|
|
|
<FilterButton
|
|
icon="paw"
|
|
label="Tiere"
|
|
active={filterAnimalType !== null}
|
|
onPress={() => setFilterAnimalType(filterAnimalType ? null : 'all')}
|
|
/>
|
|
</View>
|
|
|
|
<CharacterList characters={sortedCharacters} />
|
|
</View>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Zusätzlich: Character Stats**
|
|
```typescript
|
|
// backend: Add story_count to character response
|
|
async getUserCharacters(userId: string) {
|
|
const characters = await this.supabase
|
|
.from('characters')
|
|
.select(`
|
|
*,
|
|
stories:stories(count)
|
|
`)
|
|
.eq('user_id', userId);
|
|
|
|
return characters.map(char => ({
|
|
...char,
|
|
story_count: char.stories[0]?.count || 0
|
|
}));
|
|
}
|
|
```
|
|
|
|
**Aufwand:** 1-2 Tage | **Impact:** Mittel | **Priorität:** 🟢 Niedrig
|
|
|
|
---
|
|
|
|
## 4. Backend & API
|
|
|
|
### 4.1 API Response Standardisierung
|
|
|
|
**Problem:** Responses haben unterschiedliche Formate (`Result<T>` vs. direkte Returns).
|
|
|
|
**Lösung:**
|
|
```typescript
|
|
// Standardisiertes API Response Format
|
|
export interface ApiResponse<T = any> {
|
|
success: boolean;
|
|
data?: T;
|
|
error?: {
|
|
code: string;
|
|
message: string;
|
|
details?: any;
|
|
};
|
|
meta?: {
|
|
timestamp: string;
|
|
requestId: string;
|
|
version: string;
|
|
};
|
|
}
|
|
|
|
// Global Interceptor
|
|
@Injectable()
|
|
export class ResponseTransformInterceptor implements NestInterceptor {
|
|
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
|
const request = context.switchToHttp().getRequest();
|
|
|
|
return next.handle().pipe(
|
|
map(data => ({
|
|
success: true,
|
|
data,
|
|
meta: {
|
|
timestamp: new Date().toISOString(),
|
|
requestId: request.id,
|
|
version: '1.0'
|
|
}
|
|
})),
|
|
catchError(error => {
|
|
return of({
|
|
success: false,
|
|
error: {
|
|
code: error.code || 'INTERNAL_ERROR',
|
|
message: error.message,
|
|
details: error.details
|
|
},
|
|
meta: {
|
|
timestamp: new Date().toISOString(),
|
|
requestId: request.id,
|
|
version: '1.0'
|
|
}
|
|
});
|
|
})
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Vorteile:**
|
|
- ✅ Konsistente Responses
|
|
- 🔍 Besseres Error-Tracking mit Request-IDs
|
|
- 📊 Vereinfachte Client-Side Parsing
|
|
|
|
**Aufwand:** 1 Tag | **Impact:** Mittel | **Priorität:** 🟡 Mittel
|
|
|
|
---
|
|
|
|
### 4.2 Rate Limiting & Abuse Prevention
|
|
|
|
**Problem:** Keine Rate Limits auf teure AI-Endpoints.
|
|
|
|
**Lösung:**
|
|
```typescript
|
|
// backend/src/common/guards/rate-limit.guard.ts
|
|
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
|
import { Reflector } from '@nestjs/core';
|
|
import { ThrottlerGuard, ThrottlerModuleOptions } from '@nestjs/throttler';
|
|
|
|
@Injectable()
|
|
export class CustomRateLimitGuard extends ThrottlerGuard {
|
|
protected async getTracker(req: Record<string, any>): Promise<string> {
|
|
// Rate limit per user, not per IP
|
|
return req.user?.userId || req.ip;
|
|
}
|
|
|
|
protected async throwThrottlingException(context: ExecutionContext): Promise<void> {
|
|
throw new HttpException({
|
|
statusCode: 429,
|
|
message: 'Zu viele Anfragen. Bitte versuche es in ein paar Minuten erneut.',
|
|
retryAfter: 60
|
|
}, 429);
|
|
}
|
|
}
|
|
|
|
// app.module.ts
|
|
@Module({
|
|
imports: [
|
|
ThrottlerModule.forRoot({
|
|
ttl: 60, // 60 Sekunden Window
|
|
limit: 10, // 10 Requests
|
|
}),
|
|
],
|
|
providers: [
|
|
{
|
|
provide: APP_GUARD,
|
|
useClass: CustomRateLimitGuard,
|
|
},
|
|
],
|
|
})
|
|
export class AppModule {}
|
|
|
|
// story.controller.ts
|
|
@Controller('story')
|
|
export class StoryController {
|
|
@Post()
|
|
@Throttle(3, 60) // Max 3 Story-Erstellungen pro Minute
|
|
async createStory(@Body() dto: CreateStoryDto) {
|
|
// ...
|
|
}
|
|
}
|
|
```
|
|
|
|
**Rate Limit Konfiguration:**
|
|
| Endpoint | Limit | Zeitfenster |
|
|
|----------|-------|-------------|
|
|
| `POST /story` | 3 | 60s |
|
|
| `POST /character/generate` | 5 | 60s |
|
|
| `GET /story` | 20 | 60s |
|
|
| `GET /character` | 30 | 60s |
|
|
|
|
**Aufwand:** 0.5 Tage | **Impact:** Hoch (Kosteneinsparung) | **Priorität:** 🔴 Hoch
|
|
|
|
---
|
|
|
|
### 4.3 Health Check Verbesserung
|
|
|
|
**Problem:** Health Checks existieren, aber prüfen nur Basis-Status.
|
|
|
|
**Lösung:**
|
|
```typescript
|
|
// backend/src/health/health.controller.ts
|
|
import { HealthCheck, HealthCheckService, TypeOrmHealthIndicator } from '@nestjs/terminus';
|
|
|
|
@Controller('health')
|
|
export class HealthController {
|
|
constructor(
|
|
private health: HealthCheckService,
|
|
private db: TypeOrmHealthIndicator,
|
|
private supabase: SupabaseHealthIndicator,
|
|
private replicate: ReplicateHealthIndicator,
|
|
private manaCore: ManaCoreHealthIndicator,
|
|
) {}
|
|
|
|
@Get()
|
|
@HealthCheck()
|
|
check() {
|
|
return this.health.check([
|
|
() => this.db.pingCheck('database'),
|
|
() => this.supabase.isHealthy('supabase'),
|
|
() => this.replicate.isHealthy('replicate'),
|
|
() => this.manaCore.isHealthy('mana-core'),
|
|
]);
|
|
}
|
|
|
|
@Get('ready')
|
|
@HealthCheck()
|
|
readiness() {
|
|
// Deployment-Ready Check (für K8s/Cloud Run)
|
|
return this.health.check([
|
|
() => this.db.pingCheck('database'),
|
|
() => ({ status: 'up' as const, info: { service: 'ready' } }),
|
|
]);
|
|
}
|
|
|
|
@Get('live')
|
|
liveness() {
|
|
// Einfacher Liveness Check
|
|
return { status: 'ok', timestamp: new Date().toISOString() };
|
|
}
|
|
|
|
@Get('detailed')
|
|
@UseGuards(AdminGuard) // Nur für Admins
|
|
async detailedHealth() {
|
|
return {
|
|
uptime: process.uptime(),
|
|
memory: process.memoryUsage(),
|
|
cpu: process.cpuUsage(),
|
|
services: {
|
|
database: await this.checkDatabase(),
|
|
supabase: await this.checkSupabase(),
|
|
replicate: await this.checkReplicate(),
|
|
manaCore: await this.checkManaCore(),
|
|
},
|
|
version: process.env.npm_package_version,
|
|
env: process.env.NODE_ENV,
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
**Monitoring Dashboard Integration:**
|
|
```typescript
|
|
// Prometheus Metrics
|
|
import { PrometheusModule } from '@willsoto/nestjs-prometheus';
|
|
|
|
@Module({
|
|
imports: [
|
|
PrometheusModule.register({
|
|
defaultMetrics: {
|
|
enabled: true,
|
|
},
|
|
}),
|
|
],
|
|
})
|
|
export class AppModule {}
|
|
|
|
// Custom Metrics
|
|
@Injectable()
|
|
export class MetricsService {
|
|
private storyCreationDuration = new Histogram({
|
|
name: 'story_creation_duration_seconds',
|
|
help: 'Duration of story creation in seconds',
|
|
labelNames: ['status'],
|
|
});
|
|
|
|
recordStoryCreation(duration: number, success: boolean) {
|
|
this.storyCreationDuration
|
|
.labels(success ? 'success' : 'error')
|
|
.observe(duration);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Aufwand:** 1 Tag | **Impact:** Mittel (Ops) | **Priorität:** 🟢 Niedrig
|
|
|
|
---
|
|
|
|
### 4.4 Webhook System für Story Completion
|
|
|
|
**Problem:** Mobile App muss pollen, um zu wissen, wann Story fertig ist.
|
|
|
|
**Lösung:**
|
|
```typescript
|
|
// backend/src/webhooks/webhook.service.ts
|
|
@Injectable()
|
|
export class WebhookService {
|
|
async notifyStoryCompleted(
|
|
userId: string,
|
|
storyId: string,
|
|
payload: any
|
|
): Promise<void> {
|
|
// 1. Check if user has push token
|
|
const pushToken = await this.getUserPushToken(userId);
|
|
|
|
if (pushToken) {
|
|
// 2. Send push notification via Expo
|
|
await fetch('https://exp.host/--/api/v2/push/send', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
to: pushToken,
|
|
title: 'Deine Geschichte ist fertig! 🎉',
|
|
body: `"${payload.title}" kann jetzt gelesen werden`,
|
|
data: { storyId, type: 'story_completed' },
|
|
}),
|
|
});
|
|
}
|
|
|
|
// 3. Emit WebSocket event (for real-time updates)
|
|
this.wsGateway.emitToUser(userId, 'story:completed', {
|
|
storyId,
|
|
...payload,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Integration in story-creation.service.ts
|
|
async createStory(params: StoryCreationParams): Promise<StoryCreationResult> {
|
|
// ... story creation logic ...
|
|
|
|
// Notify user on completion
|
|
await this.webhookService.notifyStoryCompleted(
|
|
userId,
|
|
storyId,
|
|
{ title, pages_data }
|
|
);
|
|
|
|
return result;
|
|
}
|
|
```
|
|
|
|
**Mobile: Push Notification Handling**
|
|
```typescript
|
|
// mobile/src/services/pushNotificationService.ts
|
|
import * as Notifications from 'expo-notifications';
|
|
|
|
export class PushNotificationService {
|
|
static async registerForPushNotifications(): Promise<string | null> {
|
|
const { status: existingStatus } = await Notifications.getPermissionsAsync();
|
|
let finalStatus = existingStatus;
|
|
|
|
if (existingStatus !== 'granted') {
|
|
const { status } = await Notifications.requestPermissionsAsync();
|
|
finalStatus = status;
|
|
}
|
|
|
|
if (finalStatus !== 'granted') {
|
|
return null;
|
|
}
|
|
|
|
const token = (await Notifications.getExpoPushTokenAsync()).data;
|
|
|
|
// Send token to backend
|
|
await fetchWithAuth('/user/push-token', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ token }),
|
|
});
|
|
|
|
return token;
|
|
}
|
|
|
|
static setupNotificationHandlers() {
|
|
Notifications.setNotificationHandler({
|
|
handleNotification: async () => ({
|
|
shouldShowAlert: true,
|
|
shouldPlaySound: true,
|
|
shouldSetBadge: true,
|
|
}),
|
|
});
|
|
|
|
// Handle notification tap
|
|
Notifications.addNotificationResponseReceivedListener(response => {
|
|
const { storyId, type } = response.notification.request.content.data;
|
|
|
|
if (type === 'story_completed') {
|
|
router.push(`/story/${storyId}`);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
**Aufwand:** 2-3 Tage | **Impact:** Hoch | **Priorität:** 🟡 Mittel
|
|
|
|
---
|
|
|
|
## 5. Datenbank & Persistenz
|
|
|
|
### 5.1 Migration zu Relational + JSONB Hybrid (bereits teilweise implementiert)
|
|
|
|
**Status:** ✅ Teilweise implementiert (JSONB fields existieren)
|
|
|
|
**Verbesserung:** Konsistenz zwischen relationalen Daten und JSONB sicherstellen.
|
|
|
|
**Problem:** `updateCharacterImagesJsonb()` und ähnliche Methoden werden manuell aufgerufen.
|
|
|
|
**Lösung:**
|
|
```sql
|
|
-- backend/migrations/auto_sync_jsonb_fields.sql
|
|
|
|
-- Trigger: Auto-sync character_images to characters.images_data
|
|
CREATE OR REPLACE FUNCTION sync_character_images_to_jsonb()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
UPDATE characters
|
|
SET images_data = (
|
|
SELECT json_agg(
|
|
json_build_object(
|
|
'id', ci.id,
|
|
'image_url', ci.image_url,
|
|
'blur_hash', ci.blur_hash,
|
|
'is_primary', ci.is_primary,
|
|
'created_at', ci.created_at
|
|
)
|
|
ORDER BY ci.created_at DESC
|
|
)
|
|
FROM character_images ci
|
|
WHERE ci.character_id = NEW.character_id
|
|
)
|
|
WHERE id = NEW.character_id;
|
|
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER character_images_sync
|
|
AFTER INSERT OR UPDATE OR DELETE ON character_images
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION sync_character_images_to_jsonb();
|
|
|
|
-- Analog für story_pages -> stories.pages_data
|
|
CREATE OR REPLACE FUNCTION sync_story_pages_to_jsonb()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
UPDATE stories
|
|
SET pages_data = (
|
|
SELECT json_agg(
|
|
json_build_object(
|
|
'page_number', sp.page_number,
|
|
'story_text', sp.story_text,
|
|
'illustration_description', sp.illustration_description,
|
|
'image_url', sp.image_url,
|
|
'blur_hash', sp.blur_hash
|
|
)
|
|
ORDER BY sp.page_number ASC
|
|
)
|
|
FROM story_pages sp
|
|
WHERE sp.story_id = NEW.story_id
|
|
)
|
|
WHERE id = NEW.story_id;
|
|
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER story_pages_sync
|
|
AFTER INSERT OR UPDATE OR DELETE ON story_pages
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION sync_story_pages_to_jsonb();
|
|
```
|
|
|
|
**Vorteile:**
|
|
- ✅ Automatische Konsistenz
|
|
- 🚀 Schnellere Abfragen (JSONB für Reads)
|
|
- 🔍 Relationale Integrität (für komplexe Queries)
|
|
|
|
**Aufwand:** 1 Tag | **Impact:** Mittel | **Priorität:** 🟡 Mittel
|
|
|
|
---
|
|
|
|
### 5.2 Full-Text Search für Stories
|
|
|
|
**Problem:** Search-Bar in `stories.tsx` nutzt nur `includes()` auf Titel/Prompt.
|
|
|
|
**Lösung:**
|
|
```sql
|
|
-- Add Full-Text Search columns
|
|
ALTER TABLE stories
|
|
ADD COLUMN search_vector tsvector;
|
|
|
|
-- Create index for fast searches
|
|
CREATE INDEX stories_search_idx ON stories USING gin(search_vector);
|
|
|
|
-- Update function to maintain search vector
|
|
CREATE OR REPLACE FUNCTION stories_search_vector_update()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
NEW.search_vector :=
|
|
setweight(to_tsvector('german', coalesce(NEW.title, '')), 'A') ||
|
|
setweight(to_tsvector('german', coalesce(NEW.story_prompt, '')), 'B') ||
|
|
setweight(to_tsvector('german', coalesce(NEW.story, '')), 'C');
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER stories_search_vector_trigger
|
|
BEFORE INSERT OR UPDATE ON stories
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION stories_search_vector_update();
|
|
|
|
-- Update existing rows
|
|
UPDATE stories SET search_vector =
|
|
setweight(to_tsvector('german', coalesce(title, '')), 'A') ||
|
|
setweight(to_tsvector('german', coalesce(story_prompt, '')), 'B') ||
|
|
setweight(to_tsvector('german', coalesce(story, '')), 'C');
|
|
```
|
|
|
|
**Backend API:**
|
|
```typescript
|
|
// story.controller.ts
|
|
@Get('search')
|
|
async searchStories(
|
|
@Query('q') query: string,
|
|
@CurrentUser() user: User
|
|
): Promise<Story[]> {
|
|
return this.storyService.searchStories(query, user.id);
|
|
}
|
|
|
|
// story.service.ts
|
|
async searchStories(query: string, userId: string): Promise<Story[]> {
|
|
const { data, error } = await this.supabase
|
|
.from('stories')
|
|
.select('*')
|
|
.eq('user_id', userId)
|
|
.textSearch('search_vector', query, {
|
|
type: 'websearch',
|
|
config: 'german'
|
|
})
|
|
.order('created_at', { ascending: false });
|
|
|
|
return data || [];
|
|
}
|
|
```
|
|
|
|
**Mobile Integration:**
|
|
```typescript
|
|
// app/stories.tsx
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [searchResults, setSearchResults] = useState<Story[]>([]);
|
|
|
|
const handleSearch = useDebouncedCallback(async (query: string) => {
|
|
if (!query.trim()) {
|
|
setSearchResults([]);
|
|
return;
|
|
}
|
|
|
|
const results = await dataService.searchStories(query);
|
|
setSearchResults(results);
|
|
}, 300);
|
|
|
|
return (
|
|
<SearchBar
|
|
value={searchQuery}
|
|
onChangeText={(text) => {
|
|
setSearchQuery(text);
|
|
handleSearch(text);
|
|
}}
|
|
/>
|
|
);
|
|
```
|
|
|
|
**Aufwand:** 1 Tag | **Impact:** Mittel | **Priorität:** 🟢 Niedrig
|
|
|
|
---
|
|
|
|
### 5.3 Datenbank Backup & Point-in-Time Recovery
|
|
|
|
**Problem:** Keine explizite Backup-Strategie dokumentiert.
|
|
|
|
**Empfehlung (Supabase-spezifisch):**
|
|
```bash
|
|
# Tägliche Backups via Supabase CLI
|
|
# .github/workflows/database-backup.yml
|
|
|
|
name: Database Backup
|
|
|
|
on:
|
|
schedule:
|
|
- cron: '0 2 * * *' # Täglich um 2 Uhr UTC
|
|
workflow_dispatch: # Manual trigger
|
|
|
|
jobs:
|
|
backup:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v3
|
|
|
|
- name: Install Supabase CLI
|
|
run: |
|
|
curl -sSfL https://supabase.com/install.sh | sh
|
|
|
|
- name: Create backup
|
|
env:
|
|
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
|
|
SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }}
|
|
run: |
|
|
timestamp=$(date +%Y%m%d_%H%M%S)
|
|
pg_dump "$SUPABASE_DB_URL" > "backup_$timestamp.sql"
|
|
|
|
- name: Upload to S3
|
|
run: |
|
|
aws s3 cp backup_*.sql s3://maerchenzauber-backups/
|
|
```
|
|
|
|
**Point-in-Time Recovery:**
|
|
```sql
|
|
-- Supabase bietet 7-Tage PITR (Pro Plan)
|
|
-- Restore zu spezifischem Zeitpunkt:
|
|
-- https://app.supabase.com/project/_/settings/backups
|
|
```
|
|
|
|
**Aufwand:** 0.5 Tage | **Impact:** Hoch (Datensicherheit) | **Priorität:** 🔴 Hoch
|
|
|
|
---
|
|
|
|
## 6. Entwickler-Erfahrung (DX)
|
|
|
|
### 6.1 E2E Testing mit Playwright/Detox
|
|
|
|
**Problem:** Keine End-to-End Tests.
|
|
|
|
**Lösung:**
|
|
```typescript
|
|
// mobile/e2e/createStory.test.ts (mit Detox)
|
|
describe('Story Creation Flow', () => {
|
|
beforeEach(async () => {
|
|
await device.reloadReactNative();
|
|
await element(by.id('login-email')).typeText('test@example.com');
|
|
await element(by.id('login-password')).typeText('password123');
|
|
await element(by.id('login-submit')).tap();
|
|
});
|
|
|
|
it('should create a story with a character', async () => {
|
|
// 1. Navigate to create story
|
|
await element(by.id('create-story-button')).tap();
|
|
|
|
// 2. Select character
|
|
await element(by.id('character-card-0')).tap();
|
|
await expect(element(by.id('character-card-0'))).toHaveSlot('selected', true);
|
|
|
|
// 3. Enter story description
|
|
await element(by.id('story-input')).typeText(
|
|
'Ein Abenteuer im Zauberwald mit sprechenden Tieren'
|
|
);
|
|
|
|
// 4. Submit
|
|
await element(by.id('submit-story')).tap();
|
|
|
|
// 5. Wait for loading screen
|
|
await waitFor(element(by.id('magical-loading-screen')))
|
|
.toBeVisible()
|
|
.withTimeout(2000);
|
|
|
|
// 6. Wait for story viewer (max 60s for AI generation)
|
|
await waitFor(element(by.id('story-viewer')))
|
|
.toBeVisible()
|
|
.withTimeout(60000);
|
|
|
|
// 7. Verify story is displayed
|
|
await expect(element(by.id('story-page-0'))).toBeVisible();
|
|
});
|
|
|
|
it('should handle AI generation errors gracefully', async () => {
|
|
// Mock network to simulate AI failure
|
|
await device.setURLBlacklist(['**/story/animal']);
|
|
|
|
await element(by.id('create-story-button')).tap();
|
|
await element(by.id('character-card-0')).tap();
|
|
await element(by.id('story-input')).typeText('Test story');
|
|
await element(by.id('submit-story')).tap();
|
|
|
|
// Should show error alert
|
|
await waitFor(element(by.text('Fehler')))
|
|
.toBeVisible()
|
|
.withTimeout(5000);
|
|
});
|
|
});
|
|
```
|
|
|
|
**Backend Integration Tests:**
|
|
```typescript
|
|
// backend/test/e2e/story.e2e-spec.ts
|
|
describe('Story API (e2e)', () => {
|
|
let app: INestApplication;
|
|
let authToken: string;
|
|
|
|
beforeAll(async () => {
|
|
const moduleFixture = await Test.createTestingModule({
|
|
imports: [AppModule],
|
|
}).compile();
|
|
|
|
app = moduleFixture.createNestApplication();
|
|
await app.init();
|
|
|
|
// Login to get token
|
|
const loginRes = await request(app.getHttpServer())
|
|
.post('/auth/signin')
|
|
.send({ email: 'test@example.com', password: 'test123' });
|
|
|
|
authToken = loginRes.body.access_token;
|
|
});
|
|
|
|
it('/story (POST) should create a story', async () => {
|
|
const createStoryDto = {
|
|
storyDescription: 'A magical adventure',
|
|
characters: ['character-id-123'],
|
|
};
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.post('/story/animal')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send(createStoryDto)
|
|
.expect(201);
|
|
|
|
expect(response.body).toHaveProperty('storyData');
|
|
expect(response.body.storyData).toHaveProperty('id');
|
|
expect(response.body.storyData.pages_data).toHaveLength(10);
|
|
});
|
|
});
|
|
```
|
|
|
|
**Aufwand:** 3-4 Tage | **Impact:** Hoch (Code Quality) | **Priorität:** 🟡 Mittel
|
|
|
|
---
|
|
|
|
### 6.2 Development Environment Setup Automatisierung
|
|
|
|
**Problem:** Manual `.env` setup notwendig.
|
|
|
|
**Lösung:**
|
|
```bash
|
|
#!/bin/bash
|
|
# scripts/setup-dev.sh
|
|
|
|
echo "🎩 Märchenzauber Development Setup"
|
|
echo "===================================="
|
|
|
|
# 1. Check prerequisites
|
|
command -v node >/dev/null 2>&1 || { echo "❌ Node.js nicht installiert"; exit 1; }
|
|
command -v npm >/dev/null 2>&1 || { echo "❌ npm nicht installiert"; exit 1; }
|
|
|
|
# 2. Install dependencies
|
|
echo "📦 Installiere Dependencies..."
|
|
npm install
|
|
|
|
# 3. Setup environment files
|
|
echo "⚙️ Erstelle .env Dateien..."
|
|
|
|
if [ ! -f backend/.env ]; then
|
|
cp backend/.env.example backend/.env
|
|
echo "✅ backend/.env erstellt - bitte mit echten API-Keys füllen"
|
|
fi
|
|
|
|
if [ ! -f mobile/.env ]; then
|
|
echo "EXPO_PUBLIC_STORYTELLER_BACKEND_URL=http://localhost:3002" > mobile/.env
|
|
echo "EXPO_ROUTER_APP_ROOT=app" >> mobile/.env
|
|
echo "✅ mobile/.env erstellt"
|
|
fi
|
|
|
|
# 4. Check if backend is configured
|
|
if grep -q "DUMMY_KEY" backend/.env; then
|
|
echo "⚠️ Bitte fülle backend/.env mit echten API-Keys"
|
|
echo " - MAERCHENZAUBER_SUPABASE_URL"
|
|
echo " - MAERCHENZAUBER_SUPABASE_ANON_KEY"
|
|
echo " - MAERCHENZAUBER_AZURE_OPENAI_KEY"
|
|
echo " - MAERCHENZAUBER_REPLICATE_API_KEY"
|
|
exit 1
|
|
fi
|
|
|
|
# 5. Start services
|
|
echo "🚀 Starte Services..."
|
|
npm run dev
|
|
|
|
echo "✅ Setup abgeschlossen!"
|
|
echo " Backend: http://localhost:3002"
|
|
echo " Mobile: Expo Dev Client wird gestartet..."
|
|
```
|
|
|
|
**VS Code Workspace Settings:**
|
|
```json
|
|
// .vscode/settings.json
|
|
{
|
|
"editor.formatOnSave": true,
|
|
"editor.codeActionsOnSave": {
|
|
"source.fixAll.eslint": true
|
|
},
|
|
"typescript.tsdk": "node_modules/typescript/lib",
|
|
"typescript.enablePromptUseWorkspaceTsdk": true,
|
|
|
|
// Recommended extensions
|
|
"recommendations": [
|
|
"dbaeumer.vscode-eslint",
|
|
"esbenp.prettier-vscode",
|
|
"bradlc.vscode-tailwindcss",
|
|
"orta.vscode-jest"
|
|
],
|
|
|
|
// Tasks
|
|
"tasks": {
|
|
"version": "2.0.0",
|
|
"tasks": [
|
|
{
|
|
"label": "Start Backend",
|
|
"type": "shell",
|
|
"command": "cd backend && npm run dev",
|
|
"isBackground": true,
|
|
"problemMatcher": []
|
|
},
|
|
{
|
|
"label": "Start Mobile",
|
|
"type": "shell",
|
|
"command": "cd mobile && npm run dev",
|
|
"isBackground": true,
|
|
"problemMatcher": []
|
|
},
|
|
{
|
|
"label": "Start All",
|
|
"dependsOn": ["Start Backend", "Start Mobile"],
|
|
"problemMatcher": []
|
|
}
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
**Aufwand:** 0.5 Tage | **Impact:** Mittel (DX) | **Priorität:** 🟢 Niedrig
|
|
|
|
---
|
|
|
|
### 6.3 Debugging Verbesserung
|
|
|
|
**Problem:** Keine strukturierte Logging-Strategie.
|
|
|
|
**Lösung:**
|
|
```typescript
|
|
// backend/src/common/logger/logger.service.ts
|
|
import { Injectable, LoggerService as NestLoggerService } from '@nestjs/common';
|
|
import * as winston from 'winston';
|
|
|
|
@Injectable()
|
|
export class LoggerService implements NestLoggerService {
|
|
private logger: winston.Logger;
|
|
|
|
constructor() {
|
|
this.logger = winston.createLogger({
|
|
level: process.env.LOG_LEVEL || 'info',
|
|
format: winston.format.combine(
|
|
winston.format.timestamp(),
|
|
winston.format.errors({ stack: true }),
|
|
winston.format.json()
|
|
),
|
|
defaultMeta: { service: 'storyteller-backend' },
|
|
transports: [
|
|
// Console output
|
|
new winston.transports.Console({
|
|
format: winston.format.combine(
|
|
winston.format.colorize(),
|
|
winston.format.printf(({ level, message, timestamp, ...meta }) => {
|
|
return `${timestamp} [${level}]: ${message} ${
|
|
Object.keys(meta).length ? JSON.stringify(meta, null, 2) : ''
|
|
}`;
|
|
})
|
|
),
|
|
}),
|
|
|
|
// File output for errors
|
|
new winston.transports.File({
|
|
filename: 'logs/error.log',
|
|
level: 'error',
|
|
}),
|
|
|
|
// File output for all logs
|
|
new winston.transports.File({
|
|
filename: 'logs/combined.log',
|
|
}),
|
|
],
|
|
});
|
|
}
|
|
|
|
log(message: string, context?: string, meta?: any) {
|
|
this.logger.info(message, { context, ...meta });
|
|
}
|
|
|
|
error(message: string, trace?: string, context?: string, meta?: any) {
|
|
this.logger.error(message, { context, trace, ...meta });
|
|
}
|
|
|
|
warn(message: string, context?: string, meta?: any) {
|
|
this.logger.warn(message, { context, ...meta });
|
|
}
|
|
|
|
debug(message: string, context?: string, meta?: any) {
|
|
this.logger.debug(message, { context, ...meta });
|
|
}
|
|
|
|
verbose(message: string, context?: string, meta?: any) {
|
|
this.logger.verbose(message, { context, ...meta });
|
|
}
|
|
}
|
|
```
|
|
|
|
**Structured Logging in Services:**
|
|
```typescript
|
|
// story-creation.service.ts
|
|
this.logger.log('Creating story', 'StoryCreationService', {
|
|
userId,
|
|
characterId,
|
|
storyDescriptionLength: storyDescription.length,
|
|
isAnimalStory,
|
|
authorId,
|
|
illustratorId,
|
|
});
|
|
```
|
|
|
|
**Log Aggregation (Optional):**
|
|
```typescript
|
|
// Integration mit Datadog/Sentry
|
|
import * as Sentry from '@sentry/node';
|
|
|
|
Sentry.init({
|
|
dsn: process.env.SENTRY_DSN,
|
|
environment: process.env.NODE_ENV,
|
|
integrations: [
|
|
new Sentry.Integrations.Http({ tracing: true }),
|
|
new Sentry.Integrations.Express({ app }),
|
|
],
|
|
tracesSampleRate: 0.1,
|
|
});
|
|
```
|
|
|
|
**Aufwand:** 1 Tag | **Impact:** Mittel (Debugging) | **Priorität:** 🟡 Mittel
|
|
|
|
---
|
|
|
|
## 7. Sicherheit
|
|
|
|
### 7.1 Input Validation Verbesserung
|
|
|
|
**Problem:** Manche DTOs haben nur partielle Validierung.
|
|
|
|
**Lösung:**
|
|
```typescript
|
|
// story/dto/create-story.dto.ts (aktuell nur Zod)
|
|
import { IsString, IsArray, IsUUID, IsOptional, MaxLength, MinLength } from 'class-validator';
|
|
import { ApiProperty } from '@nestjs/swagger';
|
|
|
|
export class CreateAnimalStoryDto {
|
|
@ApiProperty({
|
|
description: 'Story description prompt',
|
|
example: 'A magical adventure in the forest',
|
|
minLength: 10,
|
|
maxLength: 2000
|
|
})
|
|
@IsString()
|
|
@MinLength(10, { message: 'Story description must be at least 10 characters' })
|
|
@MaxLength(2000, { message: 'Story description must not exceed 2000 characters' })
|
|
storyDescription: string;
|
|
|
|
@ApiProperty({
|
|
description: 'Character ID',
|
|
example: 'uuid-here'
|
|
})
|
|
@IsUUID('4', { message: 'Invalid character ID format' })
|
|
characterId: string;
|
|
|
|
@ApiProperty({
|
|
description: 'Optional author ID',
|
|
required: false
|
|
})
|
|
@IsOptional()
|
|
@IsUUID('4')
|
|
authorId?: string;
|
|
|
|
@ApiProperty({
|
|
description: 'Optional illustrator ID',
|
|
required: false
|
|
})
|
|
@IsOptional()
|
|
@IsUUID('4')
|
|
illustratorId?: string;
|
|
}
|
|
|
|
// Global Validation Pipe
|
|
app.useGlobalPipes(new ValidationPipe({
|
|
whitelist: true, // Strip properties not in DTO
|
|
forbidNonWhitelisted: true, // Throw error on unknown properties
|
|
transform: true, // Auto-transform to DTO types
|
|
transformOptions: {
|
|
enableImplicitConversion: true
|
|
},
|
|
exceptionFactory: (errors) => {
|
|
const messages = errors.map(error => ({
|
|
field: error.property,
|
|
errors: Object.values(error.constraints || {}),
|
|
}));
|
|
return new BadRequestException({
|
|
message: 'Validation failed',
|
|
errors: messages,
|
|
});
|
|
},
|
|
}));
|
|
```
|
|
|
|
**Vorteile:**
|
|
- 🛡️ Schutz vor Injection-Attacks
|
|
- ✅ Bessere Fehler-Messages
|
|
- 📚 Automatische API-Dokumentation (Swagger)
|
|
|
|
**Aufwand:** 1 Tag | **Impact:** Hoch | **Priorität:** 🔴 Hoch
|
|
|
|
---
|
|
|
|
### 7.2 Content Moderation für User-Generated Content
|
|
|
|
**Problem:** Keine Filterung von unangemessenen Story-Prompts.
|
|
|
|
**Lösung:**
|
|
```typescript
|
|
// backend/src/moderation/moderation.service.ts
|
|
import { Injectable } from '@nestjs/common';
|
|
import OpenAI from 'openai';
|
|
|
|
@Injectable()
|
|
export class ModerationService {
|
|
private openai = new OpenAI({
|
|
apiKey: process.env.OPENAI_API_KEY,
|
|
});
|
|
|
|
async checkContent(text: string): Promise<{
|
|
safe: boolean;
|
|
categories: string[];
|
|
reason?: string;
|
|
}> {
|
|
try {
|
|
const moderation = await this.openai.moderations.create({
|
|
input: text,
|
|
});
|
|
|
|
const result = moderation.results[0];
|
|
|
|
if (result.flagged) {
|
|
const flaggedCategories = Object.entries(result.categories)
|
|
.filter(([_, flagged]) => flagged)
|
|
.map(([category]) => category);
|
|
|
|
return {
|
|
safe: false,
|
|
categories: flaggedCategories,
|
|
reason: `Content flagged for: ${flaggedCategories.join(', ')}`,
|
|
};
|
|
}
|
|
|
|
return { safe: true, categories: [] };
|
|
} catch (error) {
|
|
this.logger.error('Moderation check failed', error);
|
|
// Fail-safe: Allow content but log error
|
|
return { safe: true, categories: [], reason: 'Moderation service unavailable' };
|
|
}
|
|
}
|
|
}
|
|
|
|
// Integration in story.controller.ts
|
|
@Post()
|
|
async createStory(@Body() dto: CreateStoryDto) {
|
|
// Check content before processing
|
|
const moderation = await this.moderationService.checkContent(
|
|
dto.storyDescription
|
|
);
|
|
|
|
if (!moderation.safe) {
|
|
throw new BadRequestException({
|
|
message: 'Story description contains inappropriate content',
|
|
categories: moderation.categories,
|
|
});
|
|
}
|
|
|
|
return this.storyService.createStory(dto);
|
|
}
|
|
```
|
|
|
|
**Client-Side Handling:**
|
|
```typescript
|
|
// mobile/app/createStory.tsx
|
|
try {
|
|
const data = await callStoryteller('/story/animal', 'POST', {
|
|
storyDescription: storyText,
|
|
characters: [selectedCharacter?.id],
|
|
});
|
|
} catch (error) {
|
|
if (error.response?.data?.categories) {
|
|
Alert.alert(
|
|
'Inhalt nicht erlaubt',
|
|
'Deine Geschichte enthält unangemessene Inhalte. Bitte formuliere sie um.',
|
|
[{ text: 'OK' }]
|
|
);
|
|
} else {
|
|
Alert.alert('Fehler', 'Beim Erstellen der Geschichte ist ein Fehler aufgetreten.');
|
|
}
|
|
}
|
|
```
|
|
|
|
**Aufwand:** 1 Tag | **Impact:** Hoch | **Priorität:** 🔴 Hoch
|
|
|
|
---
|
|
|
|
### 7.3 API Key Rotation & Management
|
|
|
|
**Problem:** API Keys sind statisch in `.env`.
|
|
|
|
**Empfehlung:**
|
|
```typescript
|
|
// Verwendung von Secret Manager (z.B. Google Secret Manager)
|
|
import { SecretManagerServiceClient } from '@google-cloud/secret-manager';
|
|
|
|
@Injectable()
|
|
export class SecretsService {
|
|
private client = new SecretManagerServiceClient();
|
|
|
|
async getSecret(name: string): Promise<string> {
|
|
const [version] = await this.client.accessSecretVersion({
|
|
name: `projects/${PROJECT_ID}/secrets/${name}/versions/latest`,
|
|
});
|
|
|
|
return version.payload?.data?.toString() || '';
|
|
}
|
|
}
|
|
|
|
// config/app.config.ts
|
|
export default async (): Promise<AppConfig> => {
|
|
const secretsService = new SecretsService();
|
|
|
|
return {
|
|
replicate: {
|
|
apiToken: await secretsService.getSecret('REPLICATE_API_KEY'),
|
|
},
|
|
azure: {
|
|
openAiKey: await secretsService.getSecret('AZURE_OPENAI_KEY'),
|
|
},
|
|
// ...
|
|
};
|
|
};
|
|
```
|
|
|
|
**Alternative: Vault/Doppler**
|
|
```bash
|
|
# Doppler CLI für lokale Entwicklung
|
|
npx doppler run -- npm run dev
|
|
|
|
# CI/CD: Secrets aus Doppler injizieren
|
|
doppler secrets download --no-file --format env > .env.production
|
|
```
|
|
|
|
**Aufwand:** 1 Tag | **Impact:** Hoch (Security) | **Priorität:** 🟡 Mittel
|
|
|
|
---
|
|
|
|
## 8. Testing & Qualitätssicherung
|
|
|
|
### 8.1 Unit Test Coverage erhöhen
|
|
|
|
**Aktueller Status:** Nur auth-Tests vorhanden
|
|
|
|
**Ziel:** 80%+ Coverage für kritische Services
|
|
|
|
**Beispiel: Story Creation Service Tests**
|
|
```typescript
|
|
// backend/src/story/services/story-creation.service.spec.ts
|
|
describe('StoryCreationService', () => {
|
|
let service: StoryCreationService;
|
|
let supabaseService: jest.Mocked<SupabaseJsonbAuthService>;
|
|
let promptingService: jest.Mocked<PromptingService>;
|
|
let imageService: jest.Mocked<ImageSupabaseService>;
|
|
|
|
beforeEach(async () => {
|
|
const module = await Test.createTestingModule({
|
|
providers: [
|
|
StoryCreationService,
|
|
{
|
|
provide: SupabaseJsonbAuthService,
|
|
useValue: {
|
|
getCharacterById: jest.fn(),
|
|
createStory: jest.fn(),
|
|
},
|
|
},
|
|
{
|
|
provide: PromptingService,
|
|
useValue: {
|
|
createConsistentCharacterDescriptionPrompts: jest.fn(),
|
|
},
|
|
},
|
|
{
|
|
provide: ImageSupabaseService,
|
|
useValue: {
|
|
generateIllustrationForPage: jest.fn(),
|
|
},
|
|
},
|
|
// ... other mocked services
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get(StoryCreationService);
|
|
supabaseService = module.get(SupabaseJsonbAuthService);
|
|
promptingService = module.get(PromptingService);
|
|
imageService = module.get(ImageSupabaseService);
|
|
});
|
|
|
|
describe('createStory', () => {
|
|
it('should create a story successfully', async () => {
|
|
// Arrange
|
|
const mockCharacter = {
|
|
id: 'char-123',
|
|
name: 'Test Character',
|
|
user_id: 'user-123',
|
|
is_animal: true,
|
|
animal_type: 'bear',
|
|
original_description: 'A friendly bear',
|
|
};
|
|
|
|
const mockStoryResponse = {
|
|
data: {
|
|
pages: [
|
|
{ page: 1, text: 'Once upon a time...' },
|
|
{ page: 2, text: 'The bear went on an adventure...' },
|
|
],
|
|
},
|
|
error: null,
|
|
};
|
|
|
|
supabaseService.getCharacterById.mockResolvedValue(mockCharacter);
|
|
promptingService.createConsistentCharacterDescriptionPrompts.mockResolvedValue({
|
|
data: [{
|
|
characterDescription: 'A friendly brown bear',
|
|
pages: [1, 2],
|
|
}],
|
|
error: null,
|
|
});
|
|
imageService.generateIllustrationForPage.mockResolvedValue({
|
|
data: {
|
|
imageUrl: 'https://example.com/image.jpg',
|
|
page: 1,
|
|
blurHash: 'LEHV6nWB2yk8pyo0adR*.7kCMdnj',
|
|
},
|
|
error: null,
|
|
});
|
|
|
|
// Act
|
|
const result = await service.createStory({
|
|
userId: 'user-123',
|
|
token: 'token-123',
|
|
characterId: 'char-123',
|
|
storyDescription: 'A magical adventure',
|
|
isAnimalStory: true,
|
|
});
|
|
|
|
// Assert
|
|
expect(result.storyData).toBeDefined();
|
|
expect(result.storyData.title).toBeDefined();
|
|
expect(result.storyData.pages_data).toHaveLength(2);
|
|
expect(supabaseService.createStory).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should throw error when character not found', async () => {
|
|
supabaseService.getCharacterById.mockResolvedValue(null);
|
|
|
|
await expect(
|
|
service.createStory({
|
|
userId: 'user-123',
|
|
token: 'token-123',
|
|
characterId: 'invalid-id',
|
|
storyDescription: 'Test',
|
|
isAnimalStory: true,
|
|
})
|
|
).rejects.toThrow('Character not found');
|
|
});
|
|
|
|
it('should use fallback character description on AI failure', async () => {
|
|
const mockCharacter = {
|
|
id: 'char-123',
|
|
name: 'Test Character',
|
|
user_id: 'user-123',
|
|
is_animal: true,
|
|
original_description: 'Fallback description',
|
|
};
|
|
|
|
supabaseService.getCharacterById.mockResolvedValue(mockCharacter);
|
|
promptingService.createConsistentCharacterDescriptionPrompts.mockResolvedValue({
|
|
data: null,
|
|
error: new Error('AI service unavailable'),
|
|
});
|
|
|
|
const result = await service.createStory({
|
|
userId: 'user-123',
|
|
token: 'token-123',
|
|
characterId: 'char-123',
|
|
storyDescription: 'Test',
|
|
isAnimalStory: true,
|
|
});
|
|
|
|
// Should fall back to original character description
|
|
expect(result.storyData.characters_data[0].character_description)
|
|
.toBe('Fallback description');
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
**Coverage Script:**
|
|
```json
|
|
// package.json
|
|
{
|
|
"scripts": {
|
|
"test": "jest",
|
|
"test:watch": "jest --watch",
|
|
"test:cov": "jest --coverage",
|
|
"test:cov:html": "jest --coverage && open coverage/lcov-report/index.html"
|
|
},
|
|
"jest": {
|
|
"collectCoverageFrom": [
|
|
"src/**/*.ts",
|
|
"!src/**/*.spec.ts",
|
|
"!src/**/*.module.ts",
|
|
"!src/main.ts"
|
|
],
|
|
"coverageThresholds": {
|
|
"global": {
|
|
"branches": 70,
|
|
"functions": 80,
|
|
"lines": 80,
|
|
"statements": 80
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Aufwand:** 5-7 Tage | **Impact:** Hoch | **Priorität:** 🟡 Mittel
|
|
|
|
---
|
|
|
|
### 8.2 Integration Tests für Critical Flows
|
|
|
|
**Problem:** Keine Tests für komplette User Journeys.
|
|
|
|
**Lösung:**
|
|
```typescript
|
|
// backend/test/integration/story-flow.integration.spec.ts
|
|
describe('Complete Story Creation Flow (Integration)', () => {
|
|
let app: INestApplication;
|
|
let userId: string;
|
|
let authToken: string;
|
|
let characterId: string;
|
|
|
|
beforeAll(async () => {
|
|
// Setup test app
|
|
const moduleFixture = await Test.createTestingModule({
|
|
imports: [AppModule],
|
|
}).compile();
|
|
|
|
app = moduleFixture.createNestApplication();
|
|
await app.init();
|
|
|
|
// Create test user and get auth token
|
|
const authRes = await request(app.getHttpServer())
|
|
.post('/auth/signup')
|
|
.send({
|
|
email: `test-${Date.now()}@example.com`,
|
|
password: 'test123456',
|
|
});
|
|
|
|
authToken = authRes.body.access_token;
|
|
userId = authRes.body.user.id;
|
|
});
|
|
|
|
it('should complete full story creation journey', async () => {
|
|
// 1. Create character
|
|
const createCharacterRes = await request(app.getHttpServer())
|
|
.post('/character/generate-images')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({
|
|
name: 'Integration Test Bear',
|
|
originalDescription: 'A friendly brown bear for testing',
|
|
isAnimal: true,
|
|
animalType: 'bear',
|
|
})
|
|
.expect(201);
|
|
|
|
characterId = createCharacterRes.body.character.id;
|
|
expect(characterId).toBeDefined();
|
|
|
|
// 2. Verify character exists
|
|
const getCharacterRes = await request(app.getHttpServer())
|
|
.get(`/character/${characterId}`)
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.expect(200);
|
|
|
|
expect(getCharacterRes.body.name).toBe('Integration Test Bear');
|
|
|
|
// 3. Check user credits
|
|
const creditsRes = await request(app.getHttpServer())
|
|
.get('/credits/balance')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.expect(200);
|
|
|
|
expect(creditsRes.body.balance).toBeGreaterThanOrEqual(10);
|
|
|
|
// 4. Create story (this will take 30-60s in real scenario)
|
|
// Note: Mock AI services for faster tests
|
|
const createStoryRes = await request(app.getHttpServer())
|
|
.post('/story/animal')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({
|
|
storyDescription: 'An integration test adventure',
|
|
characterId,
|
|
})
|
|
.expect(201);
|
|
|
|
const storyId = createStoryRes.body.storyData.id;
|
|
expect(storyId).toBeDefined();
|
|
expect(createStoryRes.body.storyData.pages_data).toHaveLength(10);
|
|
|
|
// 5. Verify story is in user's story list
|
|
const getStoriesRes = await request(app.getHttpServer())
|
|
.get('/story')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.expect(200);
|
|
|
|
const createdStory = getStoriesRes.body.find(s => s.id === storyId);
|
|
expect(createdStory).toBeDefined();
|
|
expect(createdStory.character_id).toBe(characterId);
|
|
|
|
// 6. Verify credits were deducted
|
|
const newCreditsRes = await request(app.getHttpServer())
|
|
.get('/credits/balance')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.expect(200);
|
|
|
|
expect(newCreditsRes.body.balance).toBe(creditsRes.body.balance - 10);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
// Cleanup test data
|
|
await app.close();
|
|
});
|
|
});
|
|
```
|
|
|
|
**Aufwand:** 3-4 Tage | **Impact:** Hoch | **Priorität:** 🟡 Mittel
|
|
|
|
---
|
|
|
|
## 9. Monetarisierung & Business Logic
|
|
|
|
### 9.1 Credit System Verbesserung
|
|
|
|
**Aktuell:** 10 Credits pro Story/Character fix
|
|
|
|
**Verbesserung:** Dynamisches Pricing basierend auf Komplexität
|
|
|
|
**Lösung:**
|
|
```typescript
|
|
// backend/src/credits/pricing.service.ts
|
|
@Injectable()
|
|
export class PricingService {
|
|
calculateStoryPrice(params: {
|
|
pageCount: number;
|
|
imageModel: ImageModelId;
|
|
hasCustomCharacter: boolean;
|
|
}): number {
|
|
let basePrice = 5;
|
|
|
|
// Page count multiplier
|
|
if (params.pageCount > 10) {
|
|
basePrice += (params.pageCount - 10) * 0.5;
|
|
}
|
|
|
|
// Image model pricing
|
|
const modelPricing = {
|
|
'flux-schnell': 1.0, // Standard
|
|
'flux-pro': 2.5, // Premium
|
|
'imagen-4': 2.0,
|
|
'seedream-4': 3.0, // 4K
|
|
'nano-banana': 2.0, // Character consistency
|
|
};
|
|
|
|
basePrice *= modelPricing[params.imageModel] || 1.0;
|
|
|
|
// Custom character bonus
|
|
if (params.hasCustomCharacter) {
|
|
basePrice += 2;
|
|
}
|
|
|
|
return Math.ceil(basePrice);
|
|
}
|
|
|
|
calculateCharacterPrice(params: {
|
|
imageCount: number;
|
|
usePhotoUpload: boolean;
|
|
}): number {
|
|
let basePrice = 5;
|
|
|
|
if (params.usePhotoUpload) {
|
|
basePrice += 3; // Image analysis costs
|
|
}
|
|
|
|
basePrice += (params.imageCount - 1) * 2;
|
|
|
|
return Math.ceil(basePrice);
|
|
}
|
|
}
|
|
|
|
// Integration in story creation
|
|
async createStory(params: StoryCreationParams) {
|
|
// Calculate price
|
|
const price = this.pricingService.calculateStoryPrice({
|
|
pageCount: 10,
|
|
imageModel: userSettings.imageModel,
|
|
hasCustomCharacter: true,
|
|
});
|
|
|
|
// Check and deduct credits
|
|
const hasCredits = await this.creditsService.checkUserCredits(
|
|
params.userId,
|
|
price
|
|
);
|
|
|
|
if (!hasCredits) {
|
|
throw new BadRequestException({
|
|
message: 'Insufficient credits',
|
|
required: price,
|
|
current: await this.creditsService.getBalance(params.userId),
|
|
});
|
|
}
|
|
|
|
// Proceed with creation...
|
|
const result = await this.createStoryInternal(params);
|
|
|
|
// Deduct credits
|
|
await this.creditsService.consumeCredits(params.userId, price, {
|
|
operation: 'story_creation',
|
|
metadata: {
|
|
storyId: result.storyData.id,
|
|
price,
|
|
},
|
|
});
|
|
|
|
return result;
|
|
}
|
|
```
|
|
|
|
**Mobile: Show Price Before Creation**
|
|
```typescript
|
|
// app/createStory.tsx
|
|
const [estimatedPrice, setEstimatedPrice] = useState<number>(0);
|
|
|
|
useEffect(() => {
|
|
const calculatePrice = async () => {
|
|
const settings = await fetchWithAuth('/settings/user');
|
|
const price = calculateLocalPrice({
|
|
imageModel: settings.imageModel,
|
|
hasCustomCharacter: !!selectedCharacter,
|
|
});
|
|
setEstimatedPrice(price);
|
|
};
|
|
|
|
calculatePrice();
|
|
}, [selectedCharacter]);
|
|
|
|
return (
|
|
<View>
|
|
<Text style={styles.priceEstimate}>
|
|
Geschätzte Kosten: {estimatedPrice} Credits
|
|
</Text>
|
|
|
|
<Button
|
|
title={`Geschichte erstellen (${estimatedPrice} Credits)`}
|
|
onPress={handleCreateStory}
|
|
/>
|
|
</View>
|
|
);
|
|
```
|
|
|
|
**Aufwand:** 2 Tage | **Impact:** Hoch (Revenue) | **Priorität:** 🟡 Mittel
|
|
|
|
---
|
|
|
|
### 9.2 Subscription Tiers
|
|
|
|
**Problem:** Kein klar definiertes Subscription-Modell.
|
|
|
|
**Vorschlag:**
|
|
|
|
| Tier | Preis/Monat | Credits | Features |
|
|
|------|-------------|---------|----------|
|
|
| **Free** | €0 | 5 | - 1 Charakter<br>- Basis-Modell (flux-schnell)<br>- Wasserzeichen |
|
|
| **Basic** | €9.99 | 50 | - 5 Charaktere<br>- Standard-Modelle<br>- Kein Wasserzeichen |
|
|
| **Pro** | €19.99 | 150 | - Unlimitierte Charaktere<br>- Premium-Modelle (Flux Pro, Imagen)<br>- Early Access Features<br>- Priority Support |
|
|
| **Family** | €29.99 | 300 | - Alle Pro Features<br>- Multi-User Accounts (bis zu 5)<br>- Shared Character Library |
|
|
|
|
**Implementation:**
|
|
```typescript
|
|
// backend/src/subscriptions/subscription.service.ts
|
|
@Injectable()
|
|
export class SubscriptionService {
|
|
async getUserSubscription(userId: string): Promise<Subscription> {
|
|
// Integration mit Mana Core Subscription System
|
|
const subscription = await this.manaCore.getSubscription(userId);
|
|
|
|
return {
|
|
tier: subscription.plan_id,
|
|
status: subscription.status,
|
|
creditsPerMonth: this.getCreditsForTier(subscription.plan_id),
|
|
features: this.getFeaturesForTier(subscription.plan_id),
|
|
renewsAt: subscription.current_period_end,
|
|
};
|
|
}
|
|
|
|
private getCreditsForTier(tier: string): number {
|
|
const tiers = {
|
|
'free': 5,
|
|
'basic': 50,
|
|
'pro': 150,
|
|
'family': 300,
|
|
};
|
|
return tiers[tier] || 0;
|
|
}
|
|
|
|
private getFeaturesForTier(tier: string): string[] {
|
|
const features = {
|
|
'free': ['1_character', 'basic_model', 'watermark'],
|
|
'basic': ['5_characters', 'standard_models', 'no_watermark'],
|
|
'pro': ['unlimited_characters', 'premium_models', 'early_access', 'priority_support'],
|
|
'family': ['all_pro', 'multi_user', 'shared_library'],
|
|
};
|
|
return features[tier] || [];
|
|
}
|
|
}
|
|
```
|
|
|
|
**Aufwand:** 5-7 Tage | **Impact:** Sehr Hoch (Revenue) | **Priorität:** 🔴 Hoch
|
|
|
|
---
|
|
|
|
### 9.3 Referral & Sharing Incentives
|
|
|
|
**Idee:** Credits für Shares/Referrals
|
|
|
|
**Lösung:**
|
|
```typescript
|
|
// backend/src/referrals/referral.service.ts
|
|
@Injectable()
|
|
export class ReferralService {
|
|
async trackShare(userId: string, storyId: string, platform: string): Promise<void> {
|
|
await this.supabase
|
|
.from('story_shares')
|
|
.insert({
|
|
user_id: userId,
|
|
story_id: storyId,
|
|
platform,
|
|
shared_at: new Date().toISOString(),
|
|
});
|
|
|
|
// Award share credits (1 credit per share, max 5/day)
|
|
const sharesToday = await this.getSharesCountToday(userId);
|
|
|
|
if (sharesToday < 5) {
|
|
await this.creditsService.addCredits(userId, 1, {
|
|
reason: 'story_share',
|
|
metadata: { storyId, platform },
|
|
});
|
|
}
|
|
}
|
|
|
|
async trackReferral(referrerId: string, referredUserId: string): Promise<void> {
|
|
// Award 10 credits to referrer when referred user creates first story
|
|
await this.creditsService.addCredits(referrerId, 10, {
|
|
reason: 'referral_bonus',
|
|
metadata: { referredUserId },
|
|
});
|
|
|
|
// Award 5 credits to new user
|
|
await this.creditsService.addCredits(referredUserId, 5, {
|
|
reason: 'welcome_bonus',
|
|
metadata: { referrerId },
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
**Mobile: Referral Code System**
|
|
```typescript
|
|
// app/settings.tsx
|
|
export default function Settings() {
|
|
const [referralCode, setReferralCode] = useState<string>('');
|
|
|
|
useEffect(() => {
|
|
const loadReferralCode = async () => {
|
|
const code = await fetchWithAuth('/referrals/my-code');
|
|
setReferralCode(code.code);
|
|
};
|
|
loadReferralCode();
|
|
}, []);
|
|
|
|
const shareReferralCode = async () => {
|
|
await Share.share({
|
|
message: `Erstelle magische Geschichten mit Märchenzauber! Nutze meinen Code "${referralCode}" und erhalte 5 kostenlose Credits.\n\nhttps://maerchenzauber.app?ref=${referralCode}`,
|
|
});
|
|
};
|
|
|
|
return (
|
|
<View>
|
|
<Text>Dein Empfehlungscode: {referralCode}</Text>
|
|
<Button title="Code teilen" onPress={shareReferralCode} />
|
|
<Text style={styles.info}>
|
|
Erhalte 10 Credits für jeden Freund, der sich registriert!
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Aufwand:** 3 Tage | **Impact:** Hoch (Growth) | **Priorität:** 🟡 Mittel
|
|
|
|
---
|
|
|
|
## Priorisierung
|
|
|
|
### 🔴 Kritisch (Sofort angehen)
|
|
|
|
| # | Verbesserung | Aufwand | Impact | Grund |
|
|
|---|--------------|---------|--------|-------|
|
|
| 1 | Rate Limiting (4.2) | 0.5T | Hoch | Kostenkontrolle, Abuse Prevention |
|
|
| 2 | Input Validation (7.1) | 1T | Hoch | Security, SQL Injection Prevention |
|
|
| 3 | Content Moderation (7.2) | 1T | Hoch | Legal Compliance, Brand Safety |
|
|
| 4 | Image Caching (2.1) | 1T | Hoch | User Experience, Kosten |
|
|
| 5 | DB Backup Strategy (5.3) | 0.5T | Hoch | Datensicherheit |
|
|
| 6 | Subscription System (9.2) | 5-7T | Sehr Hoch | Revenue-kritisch |
|
|
|
|
**Gesamt: ~10 Tage**
|
|
|
|
---
|
|
|
|
### 🟡 Wichtig (Nächste 1-2 Monate)
|
|
|
|
| # | Verbesserung | Aufwand | Impact | Grund |
|
|
|---|--------------|---------|--------|-------|
|
|
| 7 | State Management (1.1) | 2-3T | Hoch | Skalierbarkeit |
|
|
| 8 | Shared Types (1.3) | 2-3T | Hoch | Type Safety, DX |
|
|
| 9 | AI Caching (2.3) | 2T | Hoch | Kosteneinsparung |
|
|
| 10 | Progress Tracking (2.4) | 3T | Hoch | UX |
|
|
| 11 | Onboarding (3.1) | 2T | Hoch | User Retention |
|
|
| 12 | Story Sharing (3.2) | 2T | Hoch | Growth |
|
|
| 13 | Immersive Reading (3.4) | 3-4T | Hoch | UX |
|
|
| 14 | API Standardisierung (4.1) | 1T | Mittel | DX, Maintainability |
|
|
| 15 | Webhooks (4.4) | 2-3T | Hoch | UX |
|
|
| 16 | JSONB Auto-Sync (5.1) | 1T | Mittel | Data Consistency |
|
|
| 17 | Logging System (6.3) | 1T | Mittel | Debugging, Monitoring |
|
|
| 18 | Unit Tests (8.1) | 5-7T | Hoch | Code Quality |
|
|
| 19 | Dynamic Pricing (9.1) | 2T | Hoch | Revenue Optimization |
|
|
| 20 | Referral System (9.3) | 3T | Hoch | Growth |
|
|
|
|
**Gesamt: ~35-40 Tage**
|
|
|
|
---
|
|
|
|
### 🟢 Nice-to-Have (Backlog)
|
|
|
|
| # | Verbesserung | Aufwand | Impact | Anmerkung |
|
|
|---|--------------|---------|--------|-----------|
|
|
| 21 | Monorepo Optimization (1.2) | 1T | Mittel | DX |
|
|
| 22 | FlashList (2.2) | 0.5T | Mittel | Performance |
|
|
| 23 | Offline Mode (3.3) | 2T | Mittel | Edge Case |
|
|
| 24 | Character Filters (3.5) | 1-2T | Mittel | UX |
|
|
| 25 | Health Checks (4.3) | 1T | Mittel | Ops |
|
|
| 26 | Full-Text Search (5.2) | 1T | Mittel | UX |
|
|
| 27 | Dev Setup Script (6.2) | 0.5T | Mittel | DX |
|
|
| 28 | API Key Rotation (7.3) | 1T | Hoch | Security (long-term) |
|
|
|
|
**Gesamt: ~10 Tage**
|
|
|
|
---
|
|
|
|
## Zusammenfassung
|
|
|
|
### Quick Wins (< 1 Tag, Hoher Impact)
|
|
1. ✅ Rate Limiting (0.5T)
|
|
2. ✅ Input Validation (1T)
|
|
3. ✅ Content Moderation (1T)
|
|
4. ✅ Image Caching (1T)
|
|
5. ✅ DB Backup (0.5T)
|
|
|
|
**Total: 4 Tage für 5 kritische Verbesserungen**
|
|
|
|
---
|
|
|
|
### MVP Next Steps (1-2 Wochen)
|
|
1. Subscription System implementieren (5-7T)
|
|
2. State Management modernisieren (2-3T)
|
|
3. AI Caching aufsetzen (2T)
|
|
4. Onboarding Flow (2T)
|
|
|
|
**Total: 11-14 Tage für Revenue + UX**
|
|
|
|
---
|
|
|
|
### Long-Term Roadmap (3-6 Monate)
|
|
1. **Q1:** Kritische Verbesserungen + Subscription
|
|
2. **Q2:** UX Verbesserungen + Testing
|
|
3. **Q3:** Growth Features (Referrals, Sharing)
|
|
4. **Q4:** Scale & Optimize (Caching, Performance)
|
|
|
|
---
|
|
|
|
## Feedback & Iteration
|
|
|
|
Dieses Dokument sollte als lebendes Dokument behandelt werden. Nach Implementierung jeder Verbesserung:
|
|
|
|
1. **Metriken tracken:**
|
|
- Conversion Rates
|
|
- User Retention
|
|
- Error Rates
|
|
- API Response Times
|
|
- Credit Consumption
|
|
|
|
2. **User Feedback sammeln:**
|
|
- In-App Surveys
|
|
- App Store Reviews
|
|
- Support Tickets
|
|
- PostHog Session Recordings
|
|
|
|
3. **Priorisierung anpassen:**
|
|
- Quarterly Review
|
|
- Data-driven Entscheidungen
|
|
|
|
---
|
|
|
|
**Ende des Dokuments**
|
|
|
|
*Erstellt mit ❤️ für Märchenzauber*
|